Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions dataretrieval/_querying.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,22 +171,37 @@ def attempt() -> httpx.Response:
raise _url_too_long_error(f"httpx rejected the URL client-side: {exc}") from exc


def _materialize_query_iterables(payload: dict[str, Any]) -> dict[str, Any]:
"""Copy a query payload, materializing non-string iterables as lists."""
return {
key: list(value)
if isinstance(value, Iterable) and not isinstance(value, (str, list))
else value
for key, value in payload.items()
}


def _query_with_retry(
url: str,
payload: dict[str, Any],
delimiter: str = ",",
delimiter: str | None = ",",
ssl_check: bool = True,
*,
retry_policy: RetryPolicy | None = None,
adapter: str | None = None,
) -> httpx.Response:
"""Send an active-service query with bounded transient retry by default."""
"""Send an active-service query with bounded transient retry by default.

When ``delimiter`` is ``None``, iterable values are materialized for
``httpx`` to encode as repeated query parameters. Strings remain scalar.
"""

for key, value in payload.items():
payload[key] = to_str(value, delimiter)
# httpx serializes None params as ``foo=``; USGS rejects with 400.
# Drop them. (``to_str`` returns None for non-iterable scalars like bools.)
payload = {k: v for k, v in payload.items() if v is not None}
if delimiter is None:
payload = _materialize_query_iterables(payload)
else:
payload = {key: to_str(value, delimiter) for key, value in payload.items()}
# httpx serializes None params as ``foo=``; USGS rejects with 400. Drop them.
payload = {key: value for key, value in payload.items() if value is not None}

user_agent = {"user-agent": USER_AGENT}

Expand Down
40 changes: 24 additions & 16 deletions dataretrieval/wqp.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from dataretrieval.credentials import refuse_credential_keywords
from dataretrieval.exceptions import DataCurrencyWarning

from ._querying import _query_with_retry
from ._querying import _materialize_query_iterables, _query_with_retry
from ._wqx import _attach_datetime_columns

__all__ = [
Expand Down Expand Up @@ -138,8 +138,8 @@ def get_results(
US state FIPS code (Example: Illinois is "US:17").
countycode : string
US county FIPS code.
huc : string
Eight-digit hydrologic unit (HUC), delimited by semicolons.
huc : string or iterable of strings
Comment thread
thodson-usgs marked this conversation as resolved.
Eight-digit hydrologic unit (HUC).
bBox : string
Search bounding box (Example: bBox=-92.8,44.2,-88.9,46.0).
lat : string
Expand All @@ -148,18 +148,17 @@ def get_results(
Radial-search central longitude in WGS84 decimal degrees.
within : string
Radial-search distance in decimal miles.
pCode : string
Five-digit USGS parameter code, delimited by semicolons.
NWIS only.
pCode : string or iterable of strings

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pCode : string or iterable of strings
pCode : string or list of strings

Five-digit USGS parameter code. NWIS only.
startDateLo : string
Date of the earliest desired data-collection activity,
expressed as 'MM-DD-YYYY'.
startDateHi : string
Date of the last desired data-collection activity,
expressed as 'MM-DD-YYYY'.
characteristicName : string
One or more case-sensitive characteristic names, separated by
semicolons (https://www.waterqualitydata.us/public_srsnames/).
characteristicName : string or iterable of strings

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
characteristicName : string or iterable of strings
characteristicName : string or list of strings

One or more case-sensitive characteristic names
(https://www.waterqualitydata.us/public_srsnames/).
mimeType : string
Output format. Only 'csv' is supported at this time.

Expand Down Expand Up @@ -202,10 +201,12 @@ def get_results(
valid_profiles = result_profiles_legacy
kind = "legacy"
url = wqp_url("Result")
delimiter = ";"
else:
valid_profiles = result_profiles_wqx3
kind = "WQX3.0"
url = wqx3_url("Result")
delimiter = None

profile = kwargs.get("dataProfile")
if profile is not None:
Expand All @@ -216,7 +217,7 @@ def get_results(
kwargs["dataProfile"] = "fullPhysChem"

response = _query_with_retry(
url, kwargs, delimiter=";", ssl_check=ssl_check, adapter="wqp"
url, kwargs, delimiter=delimiter, ssl_check=ssl_check, adapter="wqp"
)

df = _read_wqp_csv(response.text)
Expand All @@ -241,13 +242,18 @@ def _what(
"""
kwargs = _check_kwargs(kwargs)

if service in services_wqx3:
url = wqp_url(service) if legacy else wqx3_url(service)
else:
if service not in services_wqx3:
url = _legacy_only_url(service, legacy=legacy)
delimiter = ";"
elif legacy:
url = wqp_url(service)
delimiter = ";"
else:
url = wqx3_url(service)
delimiter = None

response = _query_with_retry(
url, payload=kwargs, delimiter=";", ssl_check=ssl_check, adapter="wqp"
url, payload=kwargs, delimiter=delimiter, ssl_check=ssl_check, adapter="wqp"
)
df = _read_wqp_csv(response.text)
return df, WQP_Metadata(response, **kwargs)
Expand Down Expand Up @@ -738,7 +744,9 @@ def _check_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
``**queryables`` passthrough: ``api_key=`` is a plausible guess on any
getter now that ``configure(Configuration(api_key=...))`` is the spelling,
and this is the adapter with the widest passthrough -- ten getters, whose
filter names the portal rather than this package defines.
filter names the portal rather than this package defines. The returned
payload materializes non-string iterables as lists so one-shot iterators
remain reusable by both request serialization and response metadata.
"""
refuse_credential_keywords(kwargs)

Expand All @@ -750,7 +758,7 @@ def _check_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
else:
kwargs["mimeType"] = "csv"

return kwargs
return _materialize_query_iterables(kwargs)


def _warn_wqx3_use() -> None:
Expand Down
74 changes: 74 additions & 0 deletions tests/wqp_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,80 @@ def test_get_results_WQX3(httpx_mock):
assert df["Activity_StartDateTime"].notna().all()


def test_wqx3_get_results_repeats_list_query_parameters(httpx_mock):
"""WQX3 array filters use repeated keys rather than semicolons."""
with open("tests/data/wqp3_results.txt") as text:
httpx_mock.add_response(method="GET", text=text.read())

get_results(
legacy=False,
characteristicName=["Carbon", "Total carbon"],
siteType=["Stream", "Well"],
dataProfile="basicPhysChem",
)

params = httpx_mock.get_requests()[-1].url.params
assert params.get_list("characteristicName") == ["Carbon", "Total carbon"]
assert params.get_list("siteType") == ["Stream", "Well"]


@pytest.mark.parametrize(
"values_factory",
[
pytest.param(lambda: ("Carbon", "Total carbon"), id="tuple"),
pytest.param(
lambda: (value for value in ("Carbon", "Total carbon")), id="generator"
),
pytest.param(
lambda: DataFrame({"value": ["Carbon", "Total carbon"]})["value"],
id="series",
),
],
)
def test_wqx3_get_results_repeats_iterable_query_parameters(httpx_mock, values_factory):
"""WQX3 materializes non-list iterables before httpx serialization."""
with open("tests/data/wqp3_results.txt") as text:
httpx_mock.add_response(method="GET", text=text.read())

_df, md = get_results(
legacy=False,
characteristicName=values_factory(),
dataProfile="basicPhysChem",
)

params = httpx_mock.get_requests()[-1].url.params
assert params.get_list("characteristicName") == ["Carbon", "Total carbon"]
assert params.get_list("dataProfile") == ["basicPhysChem"]
assert list(md._parameters["characteristicName"]) == ["Carbon", "Total carbon"]


def test_legacy_get_results_preserves_generator_values_in_metadata(httpx_mock):
"""Legacy serialization must not leave an exhausted metadata iterator."""
with open("tests/data/wqp_results.txt") as text:
httpx_mock.add_response(method="GET", text=text.read())

_df, md = get_results(
legacy=True,
pCode=(value for value in ("00010", "00300")),
dataProfile="narrowResult",
)

params = httpx_mock.get_requests()[-1].url.params
assert params.get_list("pCode") == ["00010;00300"]
assert md._parameters["pCode"] == ["00010", "00300"]


def test_wqx3_what_sites_repeats_list_query_parameters(httpx_mock):
"""The WQX3 serializer also applies to metadata search endpoints."""
with open("tests/data/wqp_sites.txt") as text:
httpx_mock.add_response(method="GET", text=text.read())

what_sites(legacy=False, siteType=["Stream", "Well"])

params = httpx_mock.get_requests()[-1].url.params
assert params.get_list("siteType") == ["Stream", "Well"]


@pytest.mark.parametrize(
("builder", "service", "expected", "warning"),
[
Expand Down