Repository navigation
fix(ogc): send Water Data list filters and CQL2 JSON filters correctly - #446
Conversation
The earlier note said the behavior behind cql2_collections did not reproduce. That was from two identifier lists, the one kind of parameter the API reads correctly as a comma-joined list. Probing every list-valued parameter showed the behavior varies by parameter, not collection; DOI-USGS#446 fixes it with a per-API switch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK
thodson-usgs
left a comment
There was a problem hiding this comment.
claude, see my suggestions
| cql2_services : frozenset[str] | ||
| Collections that don't accept comma-separated multi-value GET | ||
| parameters and so must be queried via POST with a CQL2 JSON body. | ||
| multi_value_cql2 : bool |
There was a problem hiding this comment.
is there a more intuitive name for this function?
There was a problem hiding this comment.
Renamed to lists_as_cql2 ("send lists as CQL2"), in b98b45f. It names what the request builder does with a list, which multi_value_cql2 left implicit.
| Server-side CQL filter, sent as the OGC ``filter`` / ``filter-lang`` | ||
| query parameters; with ``filter_lang="cql-json"`` the filter is a CQL2 | ||
| JSON object, sent as the request body. See | ||
| :mod:`dataretrieval.ogc.filters` for syntax, auto-chunking, | ||
| and the lexicographic-comparison pitfall. |
There was a problem hiding this comment.
revert this change, similarly for other occurrences of filter, filter_lang. Should filter and filter_lang be given the same name? Should we omit the comment on lexicographic-comparison pitfalls.
There was a problem hiding this comment.
Reverted the docstring change in all 11 getters (b98b45f). The fix makes cql-json work as users already expect, so the docstrings do not need to describe the transport.
On your two questions:
- One name for
filterandfilter_lang: I would keep them separate. They mirror the OGCfilter/filter-langquery parameters, and R dataRetrieval uses the same pair. One possible follow-up is to accept adictforfilterand infercql-jsonfrom that, but that is a new feature, not part of this fix. - The lexicographic-pitfall pointer: I agree it should go.
:mod:dataretrieval.ogc.filtersis not in the reference docs, so the link goes nowhere, and the module docstring does not explain the pitfall to users anyway._check_numeric_filter_pitfallalready raises aValueErrorthat explains it when a filter triggers it. The pointer is onmainin all 11 getters, so I would remove it in a small separate PR rather than add docstring changes back to this one. Want me to open that?
| **10/11/2026:** **Bug fix:** a list passed to most filters of the `waterdata` OGC getters returned an empty frame. `get_daily(approval_status=["Approved", "Provisional"])` returned no rows, because the Water Data API reads a comma-joined value as one value for every filter except a few identifiers and codes (`monitoring_location_id`, `parameter_code`, `statistic_id`, `time_series_id`, and some others). A numeric list, such as `altitude` or `drainage_area` in `get_monitoring_locations()`, failed with HTTP 400. Every list is now sent as a CQL2 `in` filter, so it returns the rows matching any of its values. **Bug fix:** in `get_monitoring_locations()` and `get_combined_metadata()`, a list combined with `filter=` ignored the list and returned rows for every value. A list is now added to the text filter as `IN (...)`. **Bug fix:** `filter_lang="cql-json"` failed with HTTP 400 "Bad CQL text" in every `waterdata` OGC getter, because the API does not read a CQL2 JSON filter from the URL. It is now sent as the request body, combined with any list filters. A `filter` that is not a JSON object raises `ValueError` before sending. `ngwmn` is unchanged; its service reads comma-joined values as lists. | ||
|
|
There was a problem hiding this comment.
Too verbose, can you explain the fixes in roughly half the length.
There was a problem hiding this comment.
Cut from 180 words to 101 (b98b45f). It keeps the approval_status example, the cause, and the three changes in behavior, and drops the list of parameters that did work and the explanation of why the text filter dropped the list.
b98b45f to
c7b453a
Compare
The Water Data API reads a comma-joined GET value as one value for every filter except a few identifiers and codes, so a list returned no rows: get_daily(approval_status=["Approved", "Provisional"]) was empty, and a numeric list (altitude, drainage_area) failed with HTTP 400. A live probe of every list-valued getter parameter on 2026-10-11 found the comma form correct for 36 parameter/collection pairs and wrong for 105, on all 11 collections and in v0 and v1; a CQL2 POST was right for all of them. The per-collection cql2_services is replaced by a per-API switch, OgcDialect.lists_as_cql2, which Water Data sets: every multi-value filter goes in a CQL2 POST body. The server ignores that body when the URL carries a text filter -- monitoring-locations returned every row for a site list plus filter= -- so with a CQL-text filter the lists are added to it as IN clauses and sent by GET. _cql_in_clause in ogc.filters builds that clause for both the request builder and ratings' STAC filter. The API's CQL-text parser rejects the standard '' escape for an apostrophe with HTTP 400 (v0 and v1, measured 2026-10-12), so a folded value containing ' is double-quoted instead. A double-quoted token naming a property is read as that property, which no value containing ' can be; a value with both quote characters raises ValueError. This replaces _quote_cql_str. A blank filter= is dropped instead of being sent: the API answers it with HTTP 400 although it constrains nothing. filter_lang="cql-json" failed with HTTP 400 "Bad CQL text" in every Water Data OGC getter: OGC API Features allows a CQL2 JSON filter in the URL, but neither the Water Data nor the NGWMN API reads one there. Both accept it as a POST body, so it is sent there, ANDed with any list predicates. A filter that is not a JSON object raises ValueError before sending. NGWMN keeps comma-joined GETs: it reads them as lists, and its firewall answers a text filter naming a site with HTTP 403, so a list could not be folded into a filter there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK
c7b453a to
d6cc6d8
Compare
DOI-USGS#446 replaced the per-collection cql2_services with the per-API switch OgcDialect.lists_as_cql2; this branch had renamed the field cql2_collections and grouped the date and shaping fields. The switch takes the field's place in the regrouped OgcDialect, so the request builder reads endpoint.dialect.lists_as_cql2. ADR 0014 and CONTEXT.md no longer name cql2_collections, and the ADR records the switch's live test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK
- The NGWMN and Water Data dialect comments state only the fields the code cannot show (lists, open start, durations), one line each. - ngwmn's module docstring names the endpoint, not the removed base_url. - ADR 0014 and CONTEXT.md wording left over from the merge. - The API-key host test reads literals through _code_string_literals, so one rule excludes docstrings. - A live_status fixture replaces three copies of the dialect probe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK
_validate_time_no_duration iterated a lone Timestamp/date and raised TypeError before the formatter could read it; it now normalizes with _coerce_to_list and checks only string elements. Also: - drop the redundant date check in _coerce_to_list (a date is not iterable), and update the Mapping TypeError to name the accepted types; - read a bytes value as one element, so it gets the message naming the accepted types rather than "takes at most 2 values" over its byte codes; - get_ratings' time docstring names the accepted date types; - tighten the NEWS entry and date it by its land, above DOI-USGS#446's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK
- Reject a duration as either side of an interval string: the Water Data and NGWMN services answer '2024-01-01/P7D' and 'P7D/2024-01-08' with HTTP 400, so passing them through only deferred the failure to an error that names no argument. _split_interval rejects them and hands the two sides to the list path as a tuple, which removes _format_interval's duration bookkeeping and the _is_passthrough wrapper. - Spell an open range start the way each API accepts. The NGWMN and STAC firewalls answer any datetime containing '../' with HTTP 403, so get_water_level(datetime=[None, end]) and get_ratings(time=[None, end]) never worked. The Water Data API is the reverse: it accepts '../end' and answers '/end' with HTTP 400. OgcDialect.open_start records the spelling (default '..'); NGWMN sets '', and get_ratings passes '' to _format_api_dates since STAC has no dialect. - Keep a fractional second instead of cutting it, which moved a closed bound; the Water Data, NGWMN, and STAC services all accept one (checked 2026-10-12). Accept a minute-precision time and space around the '/', both of which used to be sent verbatim and worked. - Read a naive date or time as UTC, as pandas does (and as get_nearest_continuous already did), rather than the machine's local time; an aware value is converted to UTC, also in a date-only collection. With the string form read like the list form, local time would have made time="2024-03-01/2024-03-02" -- the call the docs show -- start at 06:00Z in Chicago and the day before in Sydney. - Docstrings: get_ratings' time is no longer passed through verbatim, and _format_api_dates' notes give the open-start spelling. The duration test ids name the side that is a duration. - Date the NEWS entry by its land, above DOI-USGS#446's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK
- Reject a duration as either side of an interval string: the Water Data and NGWMN services answer '2024-01-01/P7D' and 'P7D/2024-01-08' with HTTP 400, so passing them through only deferred the failure to an error that names no argument. _split_interval rejects them and hands the two sides to the list path as a tuple, which removes _format_interval's duration bookkeeping and the _is_passthrough wrapper. - Spell an open range start the way each API accepts. The NGWMN and STAC firewalls answer any datetime containing '../' with HTTP 403, so get_water_level(datetime=[None, end]) and get_ratings(time=[None, end]) never worked. The Water Data API is the reverse: it accepts '../end' and answers '/end' with HTTP 400. OgcDialect.open_start records the spelling (default '..'); NGWMN sets '', and get_ratings passes '' to _format_api_dates since STAC has no dialect. - Keep a fractional second instead of cutting it, which moved a closed bound; the Water Data, NGWMN, and STAC services all accept one (checked 2026-10-12). Accept a minute-precision time and space around the '/', both of which used to be sent verbatim and worked. - Read a naive date or time as UTC, as pandas does (and as get_nearest_continuous already did), rather than the machine's local time; an aware value is converted to UTC, also in a date-only collection. With the string form read like the list form, local time would have made time="2024-03-01/2024-03-02" -- the call the docs show -- start at 06:00Z in Chicago and the day before in Sydney. - Docstrings: get_ratings' time is no longer passed through verbatim, and _format_api_dates' notes give the open-start spelling. The duration test ids name the side that is a duration. - Date the NEWS entry by its land, above DOI-USGS#446's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK
…ts (#442) * feat(ogc): accept date, datetime and Timestamp values in date arguments A datetime.date, datetime.datetime or pandas.Timestamp passed to a date argument (time=[df.index.min(), None]) failed inside the formatter with AttributeError ('endswith') or, for a lone datetime, TypeError (not iterable), naming no argument. Read them like the equivalent string: aware values convert to UTC, naive ones use the local zone as naive strings do, a date is midnight of that day, and NaT is an open bound. Any other type raises ValueError naming the argument. Follow-up named in #441. * fix(ratings): accept a lone date or Timestamp in get_ratings(time=...) _validate_time_no_duration iterated a lone Timestamp/date and raised TypeError before the formatter could read it; it now normalizes with _coerce_to_list and checks only string elements. Also: - drop the redundant date check in _coerce_to_list (a date is not iterable), and update the Mapping TypeError to name the accepted types; - read a bytes value as one element, so it gets the message naming the accepted types rather than "takes at most 2 values" over its byte codes; - get_ratings' time docstring names the accepted date types; - tighten the NEWS entry and date it by its land, above #446's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK --------- Co-authored-by: thodson-usgs <thodson@usgs.gov> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Reject a duration as either side of an interval string: the Water Data and NGWMN services answer '2024-01-01/P7D' and 'P7D/2024-01-08' with HTTP 400, so passing them through only deferred the failure to an error that names no argument. _split_interval rejects them and hands the two sides to the list path as a tuple, which removes _format_interval's duration bookkeeping and the _is_passthrough wrapper. - Spell an open range start the way each API accepts. The NGWMN and STAC firewalls answer any datetime containing '../' with HTTP 403, so get_water_level(datetime=[None, end]) and get_ratings(time=[None, end]) never worked. The Water Data API is the reverse: it accepts '../end' and answers '/end' with HTTP 400. OgcDialect.open_start records the spelling (default '..'); NGWMN sets '', and get_ratings passes '' to _format_api_dates since STAC has no dialect. - Keep a fractional second instead of cutting it, which moved a closed bound; the Water Data, NGWMN, and STAC services all accept one (checked 2026-10-12). Accept a minute-precision time and space around the '/', both of which used to be sent verbatim and worked. - Read a naive date or time as UTC, as pandas does (and as get_nearest_continuous already did), rather than the machine's local time; an aware value is converted to UTC, also in a date-only collection. With the string form read like the list form, local time would have made time="2024-03-01/2024-03-02" -- the call the docs show -- start at 06:00Z in Chicago and the day before in Sydney. - Docstrings: get_ratings' time is no longer passed through verbatim, and _format_api_dates' notes give the open-start spelling. The duration test ids name the side that is a duration. - Date the NEWS entry by its land, above DOI-USGS#446's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK
- Reject a duration as either side of an interval string. The Water Data and NGWMN services reject '2024-01-01/P7D' and 'P7D/2024-01-08' with HTTP 400, and that error does not name the argument. _split_interval raises instead and passes the two sides to the list path as a tuple, which removes the duration handling in _format_interval and the _is_passthrough helper. - Write an open range start in the form each API accepts. The NGWMN and STAC firewalls reject any datetime containing '../' with HTTP 403, so get_water_level(datetime=[None, end]) and get_ratings(time=[None, end]) always failed. The Water Data API accepts '../end' and rejects '/end' with HTTP 400. OgcDialect.open_start holds the form (default '..'); NGWMN sets '', and get_ratings passes '' to _format_api_dates because STAC has no dialect. - Keep fractional seconds instead of truncating them, which changed a closed bound. The Water Data, NGWMN, and STAC services all accept them (checked 2026-10-12). Accept a minute-precision time and spaces around the '/'; both were previously sent unchanged and worked. - Read a naive date or time as UTC, as pandas does and as get_nearest_continuous already did, instead of as the machine's local time. Convert an aware value to UTC, including in a date-only collection. Now that the string form is parsed like the list form, local time would have made time="2024-03-01/2024-03-02", the example in the docs, start at 06:00Z in Chicago and on the previous day in Sydney. - Docstrings: get_ratings no longer says time is passed through unchanged, and the _format_api_dates notes describe open_start. The duration test ids name the side that is a duration. - Date the NEWS entry by the day it lands and place it above DOI-USGS#446's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK
…st form (#443) * fix(ogc): read each side of a "start/end" interval string like the list form _format_api_dates sent any single string containing "/" unchanged. The same range spelled as the documented string ("2024-01-01T10:00:00/..") and as the list (["2024-01-01T10:00:00", None]) could select different data: the string skipped the local-to-UTC conversion of naive times, the offset-to-Z conversion, and the date truncation for date-only collections, and "not-a-date/also-bad" reached the service unvalidated. Split the string on its "/" and format each side as a list element is formatted, so an unreadable side raises ValueError naming the caller's argument before any request. An empty side becomes "..", and "../.." means no filter, as [None, None] does. One side may still be an ISO 8601 duration paired with an instant ("2024-01-01/P7D"), kept unchanged; any other shape (three sides, two durations, a duration with an open end) raises ValueError naming the argument. A lone duration ("P7D") is unchanged. Follow-up named in #441. * fix(ogc): reject duration sides, open starts per API, naive dates as UTC - Reject a duration as either side of an interval string. The Water Data and NGWMN services reject '2024-01-01/P7D' and 'P7D/2024-01-08' with HTTP 400, and that error does not name the argument. _split_interval raises instead and passes the two sides to the list path as a tuple, which removes the duration handling in _format_interval and the _is_passthrough helper. - Write an open range start in the form each API accepts. The NGWMN and STAC firewalls reject any datetime containing '../' with HTTP 403, so get_water_level(datetime=[None, end]) and get_ratings(time=[None, end]) always failed. The Water Data API accepts '../end' and rejects '/end' with HTTP 400. OgcDialect.open_start holds the form (default '..'); NGWMN sets '', and get_ratings passes '' to _format_api_dates because STAC has no dialect. - Keep fractional seconds instead of truncating them, which changed a closed bound. The Water Data, NGWMN, and STAC services all accept them (checked 2026-10-12). Accept a minute-precision time and spaces around the '/'; both were previously sent unchanged and worked. - Read a naive date or time as UTC, as pandas does and as get_nearest_continuous already did, instead of as the machine's local time. Convert an aware value to UTC, including in a date-only collection. Now that the string form is parsed like the list form, local time would have made time="2024-03-01/2024-03-02", the example in the docs, start at 06:00Z in Chicago and on the previous day in Sydney. - Docstrings: get_ratings no longer says time is passed through unchanged, and the _format_api_dates notes describe open_start. The duration test ids name the side that is a duration. - Date the NEWS entry by the day it lands and place it above #446's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK --------- Co-authored-by: thodson-usgs <thodson@usgs.gov> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Summary
A list passed to most filters of the
waterdataOGC getters returned an empty frame.get_daily(bbox=..., time=..., approval_status="Approved")returns 759 rows;approval_status=["Approved", "Provisional"]returned 0, without an error. The library sent lists as comma-joined GET values, and the Water Data API reads that as one value for most parameters.How I checked. For every list-valued parameter of the 11 Water Data OGC getters, I sampled two real values (bbox around Madison, WI) and compared three things:
inPOST;That covered all collections on v1 and three on v0; v0 behaved the same way.
monitoring_location_id,monitoring_location_number,parameter_code,statistic_id,time_series_id,computation_identifier,computation_period_identifier,data_type, andfield_visit_idonchannel-measurements(but not onfield-measurements)approval_status,unit_of_measure,site_type_code,hydrologic_unit_code,sublocation_identifieraltitude,drainage_area, the well depthsAnother 40 had too few distinct values in the sample to test.
The behavior varies by parameter, not by collection, so the per-collection
cql2_servicesfield had it backwards. It also had a second bug: with a CQL-textfilter=, the server ignores the POST body. Onmonitoring-locations, a two-site list plus a filter returned 10,000 rows instead of 2. That already affectsget_monitoring_locations()andget_combined_metadata()onmain.Change
OgcDialect.cql2_services(per collection) is replaced byOgcDialect.lists_as_cql2(per API), whichWATERDATA_DIALECTsets.inpredicate in a POST body.filter, the lists are added to the filter asprop IN ('a', 'b') AND (filter)and sent by GET.ogc.filters._cql_in_clausebuilds that clause, andwaterdata.ratingsnow uses it for its STAC filter too. The values are quoted, because every Water Data queryable is typed as a string; numbers work quoted or not.state_name);=orIN, with HTTP 403, so a list can't be folded into a filter there;A CQL2 JSON filter goes in the request body.
filter_lang="cql-json"failed with HTTP 400 "Bad CQL text" in every Water Data OGC getter. OGC API Features allows a CQL2 JSON filter in the URL, but neither the Water Data nor the NGWMN API reads one there; both accept it as a POST body (checked 2026-10-11). The body is ANDed with any list predicates. On NGWMN, comma-joined list values stay in the URL beside it. Afilterthat isn't a JSON object raisesValueErrorbefore sending.Apostrophes in a folded value. The API's CQL-text parser rejects the standard
''escape for an apostrophe with HTTP 400 (v0 and v1, checked 2026-10-12). A folded value containing'is therefore double-quoted. A double-quoted token that names a property is read as that property, which a value containing'can't be. A value with both quote characters raisesValueError._cql_text_literalreplaces_quote_cql_str.Blank filter.
filter=""(or whitespace) is no longer sent, and neither is itsfilter_lang; the API answered it with HTTP 400.Tests
@pytest.mark.livetest checks thatget_daily(approval_status=[a, b])returns the rows ofaplus the rows ofb, with and without a text filter. Both pass on this branch; the version without a filter returns 0 rows onmain.main:get_monitoring_locationswith a list plus a filter: 2 rows, expected 2;get_latest_daily: 93 rows, the same asmain;get_nearest_continuouswith two sites: 3 rows, the same asmain;ngwmn.get_water_levelwith two sites: unchanged.Notes
Getter docstrings are unchanged. The fix makes
filter_lang="cql-json"work as documented, so the docstrings don't describe how it is sent.Cost of the text-filter fold. An
INclause costs about 10 bytes per value, where a comma-joined value costs 3. A large list combined withfilter=therefore splits into roughly 1.45–2× as many chunks, which means more requests against the hourly quota. A simplify-pass measurement on 3,000 site ids with a filter gave 16 chunks instead of 8. Without a filter, the POST body is no larger than the comma form (8 chunks either way). Keeping comma-joined GETs for the identifier parameters would avoid this, but it needs a list of the parameters the API reads correctly, which is the option this PR deliberately doesn't take.Caching: list queries on every collection are now POSTs. I haven't checked whether the edge caches GET responses; if it does, those queries no longer get cache hits.
Follow-up:
get_cqlstill sends its verbatim body through a separate builder (cql_body). It could use thefilter_lang="cql-json"path instead, but that changes behavior (the body is parsed and re-serialized), so it isn't part of this PR.Conflict with ADR 0014 (proposed): Service declarations and OGC endpoints #445: ADR 0014 (proposed): Service declarations and OGC endpoints #445 renames
cql2_servicestocql2_collectionsand groups the dialect fields. If this merges first, ADR 0014 (proposed): Service declarations and OGC endpoints #445's rebase replaces that rename with this switch.🤖 Generated with Claude Code
https://claude.ai/code/session_01HBt3Ee7eneKtdKQWq2T3YK