Skip to content

Commit d8f630e

Browse files
thodson-usgsclaude
andauthored
feat(waterdata): get_queryables + queryables monitor + passthrough enablement (#333)
* feat(waterdata): add get_queryables + a live queryables monitor Add `waterdata.get_queryables(collection)`, returning the OGC queryable properties of a Water Data collection (`daily`, `continuous`, `monitoring-locations`, ...) as a tidy `(DataFrame, BaseMetadata)` — one row per filterable property with its type, title, and description. Add `tests/waterdata_queryables_test.py`: offline parsing / error tests plus a live monitor that compares each collection's advertised queryables against a committed snapshot (`tests/data/waterdata_queryables.json`). The monitor fails when the upstream API adds / removes / renames a queryable — the signal to regenerate the snapshot and enable any new queryables on the matching getter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sjb14HkwuCydKSKMsaXsgd * feat(waterdata): enable arbitrary queryables as passthrough filters The OGC data getters (`get_daily`, `get_continuous`, `get_peaks`, ...) exposed ~11 of each collection's ~50 queryables as named params; the rest — mostly the shared monitoring-location attributes (`state_name`, `county_code`, `site_type`, `altitude`, ...) now filterable on the data endpoints — were reachable only via the raw `filter` CQL. Accept any queryable as a passthrough kwarg: each OGC getter gains `**queryables`, and the shared `_get_args` flattens it so an extra filter such as `state_name="Wisconsin"` is normalized and sent exactly like a named param. The service itself validates names (an unknown one returns HTTP 400 → typed error), so no client-side queryable list is bundled. The passthrough is provisional (see the PR description for the trade-off vs. explicit per-property keyword arguments). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sjb14HkwuCydKSKMsaXsgd * refactor(waterdata): have get_queryables reuse _check_ogc_requests get_queryables duplicated the GET + _raise_for_non_200 sequence that _check_ogc_requests already does for the identical "queryables" request shape (it's req_type's own default), and hardcoded OGC_API_URL instead of going through _ogc_base_url_var like every other URL-building call site in engine.py. _check_ogc_requests now also returns the raw httpx.Response (needed for BaseMetadata), so get_queryables can call it directly; the one existing caller (shaping._deal_with_empty's schema lookup) is updated to unpack the new tuple. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(waterdata): simplify the queryables flatten in _get_args A ``**queryables`` parameter is always a dict (empty when the caller passes no passthrough kwargs), never None — so the None sentinel and the ``if queryables:`` truthiness guard were unnecessary scaffolding. Collapse to a single ``local_vars.update(local_vars.pop("queryables", {}))``; updating with an empty dict is a harmless no-op, so the common (no-passthrough) path behaves identically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(waterdata): flatten queryables before the state mutual-exclusion check; correct the passthrough docstring Two issues surfaced by an xhigh code review of the **queryables passthrough: 1. State mutual-exclusion guard bypass. `_with_state` runs apply_state's `reject=("state_code", "state_name")` check on `locals()` before the `**queryables` flatten happened (that lived in `_get_args`, which runs after). So on get_time_series_metadata — whose `state_code` is NOT an explicit parameter — `get_time_series_metadata(state="Wisconsin", state_code="55")` let `state_code` land in `**queryables`, slipped past the guard, then flattened back in, silently sending BOTH state_name and state_code (server 400) instead of raising the documented ValueError. Fix: extract `_flatten_queryables` and call it at the top of `_with_state` (before apply_state) as well as in `_get_args`. The flatten pops its key so it's idempotent; the second call is a no-op. Now robust for every _with_state getter regardless of which reject target is an explicit param. 2. Misleading docstring example. The shared `**queryables` docstring asserted `state_name`/`site_type_code` as the example queryables, but channel-measurements and field-measurements-metadata expose neither, so the copied example would 400 for those two getters. Reword (uniformly across all 11) to present those as common-but-not-universal attributes and point to get_queryables for the collection's actual queryables. Adds a unit test pinning the passthrough-via-queryables conflict case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(waterdata): trim the **queryables docstring to point at get_queryables Per review feedback: drop the examples and per-collection caveats; just tell users to call get_queryables to see a collection's queryables. Applied uniformly across all 11 getters. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c5c0f9f commit d8f630e

8 files changed

Lines changed: 863 additions & 5 deletions

File tree

dataretrieval/ogc/engine.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,10 +241,14 @@ def _cql2_param(args: dict[str, Any]) -> str:
241241
return json.dumps(query, separators=(",", ":"))
242242

243243

244-
def _check_ogc_requests(endpoint: str, req_type: str = "queryables") -> dict[str, Any]:
244+
def _check_ogc_requests(
245+
endpoint: str, req_type: str = "queryables"
246+
) -> tuple[dict[str, Any], httpx.Response]:
245247
"""
246248
Sends an HTTP GET request to the specified OGC endpoint and request type,
247-
returning the JSON response.
249+
returning the parsed JSON body alongside the raw response (so a caller
250+
that needs response-derived metadata, e.g. :class:`BaseMetadata`, doesn't
251+
have to re-issue the request).
248252
249253
Parameters
250254
----------
@@ -258,6 +262,9 @@ def _check_ogc_requests(endpoint: str, req_type: str = "queryables") -> dict[str
258262
-------
259263
dict
260264
The JSON response from the OGC endpoint.
265+
httpx.Response
266+
The raw response, for callers that need it (URL, elapsed time,
267+
headers).
261268
262269
Raises
263270
------
@@ -275,7 +282,7 @@ def _check_ogc_requests(endpoint: str, req_type: str = "queryables") -> dict[str
275282
_raise_for_non_200(resp)
276283
# ``Response.json`` is typed ``Any``; the OGC queryables/schema endpoints
277284
# return a JSON object, and callers index it as a dict.
278-
return cast("dict[str, Any]", resp.json())
285+
return cast("dict[str, Any]", resp.json()), resp
279286

280287

281288
def _ogc_query_params(

dataretrieval/ogc/shaping.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def _deal_with_empty(
190190
# call (it goes away once requests move to their own module).
191191
from dataretrieval.ogc.engine import _check_ogc_requests
192192

193-
schema = _check_ogc_requests(endpoint=service, req_type="schema")
193+
schema, _ = _check_ogc_requests(endpoint=service, req_type="schema")
194194
properties = list(schema.get("properties", {}).keys())
195195
return pd.DataFrame(columns=properties)
196196
return return_list

dataretrieval/waterdata/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
get_latest_daily,
2626
get_monitoring_locations,
2727
get_peaks,
28+
get_queryables,
2829
get_reference_table,
2930
get_samples,
3031
get_samples_summary,
@@ -62,6 +63,7 @@
6263
"get_monitoring_locations",
6364
"get_nearest_continuous",
6465
"get_peaks",
66+
"get_queryables",
6567
"get_ratings",
6668
"get_reference_table",
6769
"get_samples",

dataretrieval/waterdata/api.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
SAMPLES_URL,
3939
_accept_legacy_kwargs,
4040
_as_str_list,
41+
_check_ogc_requests,
4142
_check_profiles,
4243
_construct_cql_request,
4344
_default_headers,
@@ -74,6 +75,7 @@ def get_daily(
7475
filter: str | None = None,
7576
filter_lang: FILTER_LANG | None = None,
7677
convert_type: bool = True,
78+
**queryables: Any,
7779
) -> tuple[pd.DataFrame, BaseMetadata]:
7880
"""Daily data provide one data value to represent water conditions for the
7981
day.
@@ -206,6 +208,10 @@ def get_daily(
206208
and the lexicographic-comparison pitfall.
207209
convert_type : boolean, optional
208210
If True, converts columns to appropriate types.
211+
**queryables : string or iterable of strings, optional
212+
Any other queryable property of this collection, passed through as a
213+
server-side filter. Call :func:`get_queryables` to see the queryables a
214+
collection supports.
209215
210216
Returns
211217
-------
@@ -295,6 +301,7 @@ def get_continuous(
295301
filter: str | None = None,
296302
filter_lang: FILTER_LANG | None = None,
297303
convert_type: bool = True,
304+
**queryables: Any,
298305
) -> tuple[pd.DataFrame, BaseMetadata]:
299306
"""
300307
Continuous data provide instantaneous water conditions.
@@ -421,6 +428,10 @@ def get_continuous(
421428
and the lexicographic-comparison pitfall.
422429
convert_type : boolean, optional
423430
If True, converts columns to appropriate types.
431+
**queryables : string or iterable of strings, optional
432+
Any other queryable property of this collection, passed through as a
433+
server-side filter. Call :func:`get_queryables` to see the queryables a
434+
collection supports.
424435
425436
Returns
426437
-------
@@ -520,6 +531,7 @@ def get_monitoring_locations(
520531
filter: str | None = None,
521532
filter_lang: FILTER_LANG | None = None,
522533
convert_type: bool = True,
534+
**queryables: Any,
523535
) -> tuple[pd.DataFrame, BaseMetadata]:
524536
"""Location information is basic information about the monitoring location
525537
including the name, identifier, agency responsible for data collection, and
@@ -738,6 +750,10 @@ def get_monitoring_locations(
738750
and the lexicographic-comparison pitfall.
739751
convert_type : boolean, optional
740752
If True, converts columns to appropriate types.
753+
**queryables : string or iterable of strings, optional
754+
Any other queryable property of this collection, passed through as a
755+
server-side filter. Call :func:`get_queryables` to see the queryables a
756+
collection supports.
741757
742758
Returns
743759
-------
@@ -808,6 +824,7 @@ def get_time_series_metadata(
808824
filter: str | None = None,
809825
filter_lang: FILTER_LANG | None = None,
810826
convert_type: bool = True,
827+
**queryables: Any,
811828
) -> tuple[pd.DataFrame, BaseMetadata]:
812829
"""Daily data and continuous measurements are grouped into time series,
813830
which represent a collection of observations of a single parameter,
@@ -975,6 +992,10 @@ def get_time_series_metadata(
975992
and the lexicographic-comparison pitfall.
976993
convert_type : boolean, optional
977994
If True, converts columns to appropriate types.
995+
**queryables : string or iterable of strings, optional
996+
Any other queryable property of this collection, passed through as a
997+
server-side filter. Call :func:`get_queryables` to see the queryables a
998+
collection supports.
978999
9791000
Returns
9801001
-------
@@ -1080,6 +1101,7 @@ def get_combined_metadata(
10801101
filter: str | None = None,
10811102
filter_lang: FILTER_LANG | None = None,
10821103
convert_type: bool = True,
1104+
**queryables: Any,
10831105
) -> tuple[pd.DataFrame, BaseMetadata]:
10841106
"""Get combined monitoring-location and time-series metadata.
10851107
@@ -1182,6 +1204,10 @@ def get_combined_metadata(
11821204
and the lexicographic-comparison pitfall.
11831205
convert_type : boolean, optional
11841206
If True, converts columns to appropriate types.
1207+
**queryables : string or iterable of strings, optional
1208+
Any other queryable property of this collection, passed through as a
1209+
server-side filter. Call :func:`get_queryables` to see the queryables a
1210+
collection supports.
11851211
11861212
Returns
11871213
-------
@@ -1277,6 +1303,7 @@ def get_latest_continuous(
12771303
filter: str | None = None,
12781304
filter_lang: FILTER_LANG | None = None,
12791305
convert_type: bool = True,
1306+
**queryables: Any,
12801307
) -> tuple[pd.DataFrame, BaseMetadata]:
12811308
"""This endpoint provides the most recent observation for each time series
12821309
of continuous data. Continuous data are collected via automated sensors
@@ -1406,6 +1433,10 @@ def get_latest_continuous(
14061433
and the lexicographic-comparison pitfall.
14071434
convert_type : boolean, optional
14081435
If True, converts columns to appropriate types.
1436+
**queryables : string or iterable of strings, optional
1437+
Any other queryable property of this collection, passed through as a
1438+
server-side filter. Call :func:`get_queryables` to see the queryables a
1439+
collection supports.
14091440
14101441
Returns
14111442
-------
@@ -1478,6 +1509,7 @@ def get_latest_daily(
14781509
filter: str | None = None,
14791510
filter_lang: FILTER_LANG | None = None,
14801511
convert_type: bool = True,
1512+
**queryables: Any,
14811513
) -> tuple[pd.DataFrame, BaseMetadata]:
14821514
"""Daily data provide one data value to represent water conditions for the
14831515
day.
@@ -1609,6 +1641,10 @@ def get_latest_daily(
16091641
and the lexicographic-comparison pitfall.
16101642
convert_type : boolean, optional
16111643
If True, converts columns to appropriate types.
1644+
**queryables : string or iterable of strings, optional
1645+
Any other queryable property of this collection, passed through as a
1646+
server-side filter. Call :func:`get_queryables` to see the queryables a
1647+
collection supports.
16121648
16131649
Returns
16141650
-------
@@ -1682,6 +1718,7 @@ def get_field_measurements(
16821718
filter: str | None = None,
16831719
filter_lang: FILTER_LANG | None = None,
16841720
convert_type: bool = True,
1721+
**queryables: Any,
16851722
) -> tuple[pd.DataFrame, BaseMetadata]:
16861723
"""Field measurements are physically measured values collected during a
16871724
visit to the monitoring location. Field measurements consist of measurements
@@ -1804,6 +1841,10 @@ def get_field_measurements(
18041841
and the lexicographic-comparison pitfall.
18051842
convert_type : boolean, optional
18061843
If True, converts columns to appropriate types.
1844+
**queryables : string or iterable of strings, optional
1845+
Any other queryable property of this collection, passed through as a
1846+
server-side filter. Call :func:`get_queryables` to see the queryables a
1847+
collection supports.
18071848
18081849
Returns
18091850
-------
@@ -1873,6 +1914,7 @@ def get_field_measurements_metadata(
18731914
filter: str | None = None,
18741915
filter_lang: FILTER_LANG | None = None,
18751916
convert_type: bool = True,
1917+
**queryables: Any,
18761918
) -> tuple[pd.DataFrame, BaseMetadata]:
18771919
"""Get field-measurement metadata: one row per (location, parameter) series.
18781920
@@ -1926,6 +1968,10 @@ def get_field_measurements_metadata(
19261968
and the lexicographic-comparison pitfall.
19271969
convert_type : boolean, optional
19281970
If True, converts columns to appropriate types.
1971+
**queryables : string or iterable of strings, optional
1972+
Any other queryable property of this collection, passed through as a
1973+
server-side filter. Call :func:`get_queryables` to see the queryables a
1974+
collection supports.
19291975
19301976
Returns
19311977
-------
@@ -1998,6 +2044,7 @@ def get_peaks(
19982044
filter: str | None = None,
19992045
filter_lang: FILTER_LANG | None = None,
20002046
convert_type: bool = True,
2047+
**queryables: Any,
20012048
) -> tuple[pd.DataFrame, BaseMetadata]:
20022049
"""Get the annual peak streamflow / stage record for a monitoring location.
20032050
@@ -2056,6 +2103,10 @@ def get_peaks(
20562103
and the lexicographic-comparison pitfall.
20572104
convert_type : boolean, optional
20582105
If True, converts columns to appropriate types.
2106+
**queryables : string or iterable of strings, optional
2107+
Any other queryable property of this collection, passed through as a
2108+
server-side filter. Call :func:`get_queryables` to see the queryables a
2109+
collection supports.
20592110
20602111
Returns
20612112
-------
@@ -2198,6 +2249,68 @@ def get_reference_table(
21982249
)
21992250

22002251

2252+
def get_queryables(collection: str) -> tuple[pd.DataFrame, BaseMetadata]:
2253+
"""List the queryable properties of a Water Data API collection.
2254+
2255+
Every OGC collection (``daily``, ``continuous``, ``monitoring-locations``,
2256+
...) advertises the set of properties that can be filtered on -- exposed as
2257+
the typed keyword arguments of the matching ``get_*`` function, and usable
2258+
directly in a CQL2 ``filter``. This returns that set, so the available
2259+
filters can be discovered programmatically and monitored for upstream
2260+
additions.
2261+
2262+
Parameters
2263+
----------
2264+
collection : string
2265+
The collection id, e.g. ``"daily"``, ``"continuous"``,
2266+
``"monitoring-locations"``, or ``"time-series-metadata"``. See
2267+
:data:`dataretrieval.waterdata.types.WATERDATA_SERVICES` for the data
2268+
collections; reference collections (e.g. ``"parameter-codes"``) work
2269+
too.
2270+
2271+
Returns
2272+
-------
2273+
df : ``pandas.DataFrame``
2274+
One row per queryable, sorted by name, with columns ``queryable`` (the
2275+
property name), ``type``, ``title``, and ``description``.
2276+
md : :class:`dataretrieval.utils.BaseMetadata`
2277+
Metadata describing the request (URL, query time, response headers).
2278+
2279+
Raises
2280+
------
2281+
DataRetrievalError
2282+
On an HTTP error response (e.g. an unknown ``collection`` yields a 404),
2283+
the typed subclass for the status.
2284+
2285+
Examples
2286+
--------
2287+
.. doctest::
2288+
:skipif: True # network
2289+
2290+
>>> from dataretrieval import waterdata
2291+
>>> df, md = waterdata.get_queryables("daily")
2292+
>>> df.set_index("queryable").loc["state_name", "type"]
2293+
'string'
2294+
"""
2295+
# The OGC queryables document is a JSON Schema whose ``properties`` map each
2296+
# filterable property name to a ``{title, type, description}`` definition.
2297+
body, response = _check_ogc_requests(endpoint=collection, req_type="queryables")
2298+
properties: dict[str, Any] = body.get("properties", {})
2299+
df = pd.DataFrame(
2300+
[
2301+
{
2302+
"queryable": name,
2303+
"type": prop.get("type"),
2304+
"title": prop.get("title"),
2305+
"description": (prop.get("description") or "").strip(),
2306+
}
2307+
for name, prop in sorted(properties.items())
2308+
],
2309+
columns=["queryable", "type", "title", "description"],
2310+
)
2311+
return df, BaseMetadata(response)
2312+
2313+
22012314
def get_codes(code_service: CODE_SERVICES) -> tuple[pd.DataFrame, BaseMetadata]:
22022315
"""Return codes from a Samples code service.
22032316
@@ -2916,6 +3029,7 @@ def get_channel(
29163029
filter: str | None = None,
29173030
filter_lang: FILTER_LANG | None = None,
29183031
convert_type: bool = True,
3032+
**queryables: Any,
29193033
) -> tuple[pd.DataFrame, BaseMetadata]:
29203034
"""
29213035
Channel measurements taken as part of streamflow field measurements.
@@ -3045,6 +3159,10 @@ def get_channel(
30453159
and the lexicographic-comparison pitfall.
30463160
convert_type : boolean, optional
30473161
If True, converts columns to appropriate types.
3162+
**queryables : string or iterable of strings, optional
3163+
Any other queryable property of this collection, passed through as a
3164+
server-side filter. Call :func:`get_queryables` to see the queryables a
3165+
collection supports.
30483166
30493167
Returns
30503168
-------

dataretrieval/waterdata/utils.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,23 @@
155155
}
156156

157157

158+
def _flatten_queryables(local_vars: dict[str, Any]) -> dict[str, Any]:
159+
"""Merge a getter's ``**queryables`` passthrough kwargs -- collected by
160+
``locals()`` under the ``queryables`` key -- up into ``local_vars`` as
161+
top-level entries, so an extra server-side filter such as
162+
``state_name="Wisconsin"`` is normalized, mutual-exclusion-checked, and sent
163+
exactly like a named param. See
164+
:func:`dataretrieval.waterdata.get_queryables` for each collection's
165+
filterable properties (the service rejects an unknown one with a 400).
166+
167+
``**queryables`` always arrives as a dict (empty when unused) and the key is
168+
popped, so this is a no-op on getters without the passthrough and idempotent
169+
if called twice.
170+
"""
171+
local_vars.update(local_vars.pop("queryables", {}))
172+
return local_vars
173+
174+
158175
def _get_args(
159176
local_vars: dict[str, Any], exclude: set[str] | None = None
160177
) -> dict[str, Any]:
@@ -163,8 +180,10 @@ def _get_args(
163180
Supplies the Water Data API's extended ``no_normalize`` set (numeric
164181
params such as ``water_year``, ``thresholds``, ``boundingBox``) so they
165182
keep their element types. See :func:`engine._get_args` for the full
166-
normalization contract.
183+
normalization contract. Also flattens any ``**queryables`` passthrough
184+
(see :func:`_flatten_queryables`).
167185
"""
186+
_flatten_queryables(local_vars)
168187
return _engine_get_args(local_vars, exclude, no_normalize=_NO_NORMALIZE_PARAMS)
169188

170189

@@ -180,6 +199,12 @@ def _with_state(local_vars: dict[str, Any], *, to: str, into: str) -> dict[str,
180199
raw values (e.g. non-US FIPS); passing ``state`` together with either
181200
raises ``ValueError``.
182201
"""
202+
# Flatten ``**queryables`` first so a native state param arriving that way
203+
# (e.g. ``get_time_series_metadata``'s ``state_code``, which isn't an
204+
# explicit parameter) is visible to apply_state's mutual-exclusion guard.
205+
# Otherwise ``state`` plus a passthrough ``state_code`` would slip past the
206+
# check and silently send both.
207+
_flatten_queryables(local_vars)
183208
return apply_state(
184209
local_vars, to=to, into=into, reject=("state_code", "state_name")
185210
)

0 commit comments

Comments
 (0)