Skip to content

fix(ogc): send Water Data list filters and CQL2 JSON filters correctly - #446

Merged
thodson-usgs merged 1 commit into
DOI-USGS:mainfrom
thodson-usgs:fix/multi-value-filters-post
Oct 12, 2026
Merged

thodson-usgs merged 1 commit into
DOI-USGS:mainfrom
thodson-usgs:fix/multi-value-filters-post

Conversation

@thodson-usgs

@thodson-usgs thodson-usgs commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A list passed to most filters of the waterdata OGC 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:

  • a comma-joined GET;
  • a CQL2 in POST;
  • the union of the two single-value requests.

That covered all collections on v1 and three on v0; v0 behaved the same way.

Result Count
Comma GET correct 36 parameter/collection pairs, all identifiers or codes: monitoring_location_id, monitoring_location_number, parameter_code, statistic_id, time_series_id, computation_identifier, computation_period_identifier, data_type, and field_visit_id on channel-measurements (but not on field-measurements)
Comma GET returns 0 rows (HTTP 200) ~90 text parameters, e.g. approval_status, unit_of_measure, site_type_code, hydrologic_unit_code, sublocation_identifier
Comma GET returns HTTP 400 the numeric parameters: altitude, drainage_area, the well depths
CQL2 POST correct for every parameter tested

Another 40 had too few distinct values in the sample to test.

The behavior varies by parameter, not by collection, so the per-collection cql2_services field had it backwards. It also had a second bug: with a CQL-text filter=, the server ignores the POST body. On monitoring-locations, a two-site list plus a filter returned 10,000 rows instead of 2. That already affects get_monitoring_locations() and get_combined_metadata() on main.

Change

  • OgcDialect.cql2_services (per collection) is replaced by OgcDialect.lists_as_cql2 (per API), which WATERDATA_DIALECT sets.
  • With it set, every multi-value filter is a CQL2 in predicate in a POST body.
  • With a CQL-text filter, the lists are added to the filter as prop IN ('a', 'b') AND (filter) and sent by GET. ogc.filters._cql_in_clause builds that clause, and waterdata.ratings now 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.
  • NGWMN is unchanged and keeps comma-joined GETs:
    • it reads them as lists (checked for site ids and state_name);
    • its firewall answers any text filter naming a site, with = or IN, with HTTP 403, so a list can't be folded into a filter there;
    • it answers a POST body plus a text filter with HTTP 400.

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. A filter that isn't a JSON object raises ValueError before 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 raises ValueError. _cql_text_literal replaces _quote_cql_str.

Blank filter. filter="" (or whitespace) is no longer sent, and neither is its filter_lang; the API answered it with HTTP 400.

Tests

  • Offline: 10 request-shape tests now assert the CQL2 body instead of the comma-joined URL. New tests cover the text-filter fold (with apostrophe quoting and the both-quotes error), the blank filter, and the comma-join default for a dialect without the switch. All 1,263 pass, and coverage is 99.03%. ruff, mypy, import-linter, xenon and complexipy are clean.
  • CQL2 JSON filter: offline tests cover the filter alone, ANDed with lists, beside NGWMN-style comma values, and rejected when it isn't a JSON object. A live test checks that a JSON filter returns the same rows as its text form, using the filter the error message suggests.
  • Live: a new live test checks that a list value containing an apostrophe, folded into a text filter, returns its rows. Another @pytest.mark.live test checks that get_daily(approval_status=[a, b]) returns the rows of a plus the rows of b, with and without a text filter. Both pass on this branch; the version without a filter returns 0 rows on main.
  • Live checks, compared with main:
    • a site list plus a text filter: 62 rows, expected 62;
    • get_monitoring_locations with a list plus a filter: 2 rows, expected 2;
    • a 600-site chunked get_latest_daily: 93 rows, the same as main;
    • get_nearest_continuous with two sites: 3 rows, the same as main;
    • ngwmn.get_water_level with 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 IN clause costs about 10 bytes per value, where a comma-joined value costs 3. A large list combined with filter= 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_cql still sends its verbatim body through a separate builder (cql_body). It could use the filter_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_services to cql2_collections and 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

thodson-usgs added a commit to thodson-usgs/dataretrieval-python that referenced this pull request Oct 12, 2026
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 thodson-usgs changed the title fix(ogc): send a Water Data list filter as CQL2, not a comma-joined GET fix(ogc): send Water Data list filters and CQL2 JSON filters correctly Oct 12, 2026

@thodson-usgs thodson-usgs left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

claude, see my suggestions

Comment thread dataretrieval/ogc/policy.py Outdated
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

is there a more intuitive name for this function?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Comment thread dataretrieval/waterdata/measurements.py Outdated
Comment on lines 318 to 322
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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 filter and filter_lang: I would keep them separate. They mirror the OGC filter / filter-lang query parameters, and R dataRetrieval uses the same pair. One possible follow-up is to accept a dict for filter and infer cql-json from that, but that is a new feature, not part of this fix.
  • The lexicographic-pitfall pointer: I agree it should go. :mod:dataretrieval.ogc.filters is 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_pitfall already raises a ValueError that explains it when a filter triggers it. The pointer is on main in 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?

Comment thread NEWS.md Outdated
Comment on lines +1 to +2
**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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Too verbose, can you explain the fixes in roughly half the length.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

@thodson-usgs
thodson-usgs force-pushed the fix/multi-value-filters-post branch from b98b45f to c7b453a Compare October 12, 2026 13:01
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
@thodson-usgs
thodson-usgs force-pushed the fix/multi-value-filters-post branch from c7b453a to d6cc6d8 Compare October 12, 2026 14:00
@thodson-usgs
thodson-usgs marked this pull request as ready for review October 12, 2026 14:29
@thodson-usgs
thodson-usgs merged commit 2180985 into DOI-USGS:main Oct 12, 2026
11 checks passed
@thodson-usgs
thodson-usgs deleted the fix/multi-value-filters-post branch October 12, 2026 14:29
thodson-usgs added a commit to thodson-usgs/dataretrieval-python that referenced this pull request Oct 12, 2026
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
thodson-usgs added a commit to thodson-usgs/dataretrieval-python that referenced this pull request Oct 12, 2026
- 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
thodson-usgs added a commit to bhaskargurram-ai/dataretrieval-python that referenced this pull request Oct 12, 2026
_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
thodson-usgs added a commit to bhaskargurram-ai/dataretrieval-python that referenced this pull request Oct 12, 2026
- 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
thodson-usgs added a commit to bhaskargurram-ai/dataretrieval-python that referenced this pull request Oct 12, 2026
- 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
thodson-usgs added a commit that referenced this pull request Oct 12, 2026
…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>
thodson-usgs added a commit to bhaskargurram-ai/dataretrieval-python that referenced this pull request Oct 12, 2026
- 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
thodson-usgs added a commit to bhaskargurram-ai/dataretrieval-python that referenced this pull request Oct 12, 2026
- 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
thodson-usgs added a commit that referenced this pull request Oct 12, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant