Skip to content

Commit b13c6b9

Browse files
bhaskargurram-aithodson-usgsclaude
authored
fix(ogc): read each side of a "start/end" interval string like the list 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>
1 parent ebdc219 commit b13c6b9

11 files changed

Lines changed: 297 additions & 41 deletions

File tree

‎NEWS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
**10/12/2026:** **Behavior change:** a date range passed as one `"start/end"` string, such as `time="2024-01-01T10:00:00/.."`, is now formatted like the list form `["2024-01-01T10:00:00", None]` in the `waterdata` and `ngwmn` OGC getters and in `waterdata.get_ratings()`. **Bug fix:** the string used to be sent unchanged, so the two forms could select different data: an offset was not converted to `Z`, and a date-only collection such as `daily` received times. A string now raises `ValueError` before any request is sent if a side is not a date (`time="2024-01-01/tomorrow"`), if it contains more than one `/`, or if either side is a duration (`"2024-01-01/P7D"`), which the services rejected with HTTP 400. An empty side is an open bound, and `"../.."` means no date filter. A lone duration such as `"P7D"` is unchanged. **Bug fix:** in `ngwmn.get_water_level()` and `waterdata.get_ratings()`, a range with an open start, such as `[None, "2016-01-01"]` or `"../2016-01-01"`, failed with HTTP 403 because those services' firewalls reject `../`. Both getters now send an empty start (`"/2016-01-01"`), which both services treat as open. The other Water Data getters still send `..`, because that API rejects an empty start. Fractional seconds, such as `2024-01-01T10:00:00.999Z`, are kept instead of truncated, and a minute-precision time (`2024-01-01T10:00Z`) and spaces around the `/` are accepted. **Behavior change:** a naive date or time is now UTC, as in pandas (`pd.to_datetime(..., utc=True)`) and `get_nearest_continuous()`, instead of the machine's local time, so `time=["2024-03-01", "2024-03-02"]` sends `2024-03-01T00:00:00Z/2024-03-02T00:00:00Z` on every machine. An aware value is converted to UTC, including in a date-only collection such as `daily`. For a local time, include the offset: `"2024-03-01T00:00:00-06:00"`.
2+
13
**10/12/2026:** Date arguments of the `waterdata` and `ngwmn` OGC getters (`time`, `begin`, `end`, `datetime`, `last_modified`) and of `waterdata.get_ratings()` accept `datetime.date`, `datetime.datetime`, and `pandas.Timestamp` values, alone or as either end of a range, such as `time=[df.index.min(), None]`. Each is read like the equivalent string: a `date` is midnight, and `NaT` is an open bound. **Bug fix:** these values used to raise `AttributeError` or `TypeError` without naming the argument. Any other type, such as a number, now raises `ValueError` naming the argument and the accepted types.
24

35
**10/11/2026:** **Bug fix:** a list passed to most `waterdata` OGC filters, such as `get_daily(approval_status=["Approved", "Provisional"])`, returned no rows: the Water Data API reads a comma-joined value as one value for all but a few identifier and code filters, and rejects a numeric list with HTTP 400. Lists are now sent as a CQL2 `in` filter. A list combined with `filter=` in `get_monitoring_locations()` and `get_combined_metadata()` is no longer ignored. **Bug fix:** `filter_lang="cql-json"` failed with HTTP 400 in every `waterdata` OGC getter. The filter is now sent as the request body; one that is not a JSON object raises `ValueError`. A blank `filter=""` is now ignored rather than failing with HTTP 400. `ngwmn` is unchanged.

‎dataretrieval/ngwmn.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,13 @@
8080
# (there is no service-specific id name as there is for the main collections).
8181
_NGWMN_OUTPUT_ID = "id"
8282

83-
# NGWMN's request shape matches the generic OGC default: it reads comma-joined
84-
# GET values as lists, and its firewall refuses a text filter naming a site, so
85-
# a list could not be folded into one. It has no date-only collections, but its
86-
# result columns need their own coercion and sort vocabulary:
87-
# water-level observations are timestamped by ``sample_time`` (not the Water
88-
# Data ``time``) and report depths/levels in feet.
83+
# NGWMN reads comma-joined GET values as lists, and its firewall refuses a text
84+
# filter naming a site, so a list could not be folded into one. It has no
85+
# date-only collections, but its result columns need their own coercion and
86+
# sort vocabulary: water-level observations are timestamped by ``sample_time``
87+
# (not the Water Data ``time``) and report depths/levels in feet. Its firewall
88+
# rejects ``datetime=../2016-01-01`` with HTTP 403, so an open start is sent
89+
# empty (``/2016-01-01``), which NGWMN treats as open.
8990
NGWMN_DIALECT = OgcDialect(
9091
time_cols=frozenset({"sample_time"}),
9192
numerical_cols=frozenset(
@@ -96,6 +97,7 @@
9697
}
9798
),
9899
sort_cols=("sample_time", "monitoring_location_id"),
100+
open_start="",
99101
)
100102

101103

‎dataretrieval/ogc/dates.py‎

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
"%Y-%m-%dT%H:%M:%S%z",
2121
"%Y-%m-%dT%H:%M:%S.%f",
2222
"%Y-%m-%dT%H:%M:%S",
23+
"%Y-%m-%dT%H:%M%z",
24+
"%Y-%m-%dT%H:%M",
2325
"%Y-%m-%d %H:%M:%S.%f",
2426
"%Y-%m-%d %H:%M:%S",
2527
"%Y-%m-%d",
@@ -118,14 +120,14 @@ def _format_one(dt: _DateLike, *, date: bool, name: str) -> str:
118120
if dt is None or _is_blank(dt):
119121
return _OPEN_BOUND
120122
parsed = _to_datetime(dt, name=name)
123+
# As in pandas, a naive value is UTC and an aware one is converted to UTC,
124+
# so the same call sends the same range on every machine.
125+
utc = parsed if parsed.tzinfo is None else parsed.astimezone(ZoneInfo("UTC"))
121126
if date:
122-
return parsed.strftime("%Y-%m-%d")
123-
# Naive inputs are interpreted in the system local zone (for backwards
124-
# compatibility). Use ``.astimezone()`` rather than a fixed offset so each
125-
# value is resolved against the DST rules for its own date — a fixed
126-
# ``datetime.now()`` offset shifted off-season inputs by an hour.
127-
aware = parsed if parsed.tzinfo is not None else parsed.astimezone()
128-
return aware.astimezone(ZoneInfo("UTC")).strftime("%Y-%m-%dT%H:%M:%SZ")
127+
return utc.strftime("%Y-%m-%d")
128+
# Keep fractional seconds: truncating them changes a closed bound.
129+
fraction = f".{utc.microsecond:06d}".rstrip("0") if utc.microsecond else ""
130+
return f"{utc.strftime('%Y-%m-%dT%H:%M:%S')}{fraction}Z"
129131

130132

131133
def _coerce_to_list(
@@ -147,9 +149,27 @@ def _coerce_to_list(
147149
return list(datetime_input)
148150

149151

150-
def _is_passthrough(single: str) -> bool:
151-
"""True when a single-element input should be returned as-is."""
152-
return bool(_DURATION_RE.match(single) or "/" in single)
152+
def _split_interval(interval: str, *, name: str) -> tuple[str, str]:
153+
"""Split a ``"start/end"`` string into its two sides.
154+
155+
Each side is then formatted like a list element, so
156+
``"2024-01-01T10:00:00/.."`` sends the same range as
157+
``["2024-01-01T10:00:00", None]``.
158+
159+
Raises ``ValueError`` naming *name* unless the string is two sides
160+
separated by one ``"/"``, neither of them a duration. The Water Data and
161+
NGWMN services reject ``"2024-01-01/P7D"`` and ``"P7D/2024-01-08"`` with
162+
HTTP 400.
163+
"""
164+
sides = [side.strip() for side in interval.split("/")]
165+
if len(sides) != 2 or any(_DURATION_RE.match(side) for side in sides):
166+
raise ValueError(
167+
f"{name} is not a valid interval: {interval!r}. Pass a start and "
168+
"an end separated by '/', such as '2024-01-01/2024-12-31', with "
169+
"'..' for an open end ('2024-01-01/..'). Neither side can be a "
170+
"duration."
171+
)
172+
return sides[0], sides[1]
153173

154174

155175
def _all_blank(items: list[_DateLike]) -> bool:
@@ -163,6 +183,7 @@ def _format_api_dates(
163183
*,
164184
name: str = "date input",
165185
single_value_hint: str = "an instant or a duration ('2020-01-01', 'P7D')",
186+
open_start: str = _OPEN_BOUND,
166187
) -> str | None:
167188
"""
168189
Formats date or datetime input(s) for use with an API.
@@ -194,6 +215,12 @@ def _format_api_dates(
194215
forms (``get_ratings`` rejects durations) enforces that itself and
195216
passes a hint naming only what it accepts, so the remedy does not
196217
direct a caller to a value that is rejected.
218+
open_start : str, optional
219+
The string written for an open range start (``".."`` by default).
220+
OGC API Features and STAC also allow an empty string
221+
(``"/2024-01-01"``), which an API whose firewall rejects ``"../"``
222+
requires; see :attr:`OgcDialect.open_start`. An open end is always
223+
``".."``.
197224
198225
Returns
199226
-------
@@ -213,20 +240,25 @@ def _format_api_dates(
213240
Notes
214241
-----
215242
- A single blank/NA value returns None. In a two-value range, a blank/NA
216-
or ``".."`` endpoint is rendered as ``".."`` to denote an open bound
217-
(e.g. ``"2024-01-01/.."``); the range is only None when *every* element
218-
is blank/NA/``".."``.
219-
- Supports ISO 8601 durations such as "P7D" and "PT36H" and pre-formatted
220-
intervals containing ``"/"``; both are passed through unchanged.
243+
or ``".."`` endpoint is an open bound. An open end is written ``".."``
244+
(``"2024-01-01/.."``) and an open start as ``open_start``. The range is
245+
None only when *every* element is blank/NA/``".."``.
246+
- Supports ISO 8601 durations such as "P7D" and "PT36H", which are passed
247+
through unchanged.
248+
- A single string containing ``"/"`` is an interval: it is split into the
249+
two-value form, so ``"2024-01-01/.."`` and ``["2024-01-01", None]`` are
250+
formatted alike. A duration is not accepted as either side.
221251
- Converts datetimes to UTC and formats as ISO 8601 with 'Z' suffix when
222-
`date` is False. Inputs with an explicit offset (``Z`` or ``+HH:MM``) are
223-
converted from that offset to UTC; naive inputs are interpreted in the
224-
local time zone for backwards compatibility.
252+
`date` is False. As in pandas, an input with an offset (``Z`` or
253+
``+HH:MM``) is converted to UTC and a naive one is UTC; a date-only
254+
collection gets the UTC date.
225255
"""
226256
if datetime_input is None:
227257
return None
228258

229259
items = _coerce_to_list(datetime_input, name)
260+
if len(items) == 1 and isinstance(items[0], str) and "/" in items[0]:
261+
items = list(_split_interval(items[0], name=name))
230262

231263
if _all_blank(items):
232264
return None
@@ -238,8 +270,11 @@ def _format_api_dates(
238270
"or two for a closed interval ('2020-01-01', '2020-12-31')."
239271
)
240272

241-
# Pass through duration ("P7D", "PT36H") and pre-formatted interval ("a/b")
242-
if len(items) == 1 and isinstance(items[0], str) and _is_passthrough(items[0]):
273+
# Pass through a duration ("P7D", "PT36H")
274+
if len(items) == 1 and isinstance(items[0], str) and _DURATION_RE.match(items[0]):
243275
return items[0]
244276

245-
return "/".join(_format_one(dt, date=date, name=name) for dt in items)
277+
formatted = [_format_one(dt, date=date, name=name) for dt in items]
278+
if len(formatted) == 2 and formatted[0] == _OPEN_BOUND:
279+
formatted[0] = open_start
280+
return "/".join(formatted)

‎dataretrieval/ogc/policy.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,20 @@ class OgcDialect:
6161
Columns to sort the combined result by, in priority order. Sorting
6262
is applied only when the first (primary) column is present; any
6363
later columns also present are added as secondary keys.
64+
open_start : str
65+
The string written for the open start of a date range: ``".."``
66+
(``"../2024-01-01"``, the default) or ``""`` (``"/2024-01-01"``).
67+
OGC API Features allows both, but each service accepts only one: the
68+
Water Data API rejects an empty start with HTTP 400, and the NGWMN
69+
firewall rejects ``"../"`` with HTTP 403.
6470
"""
6571

6672
lists_as_cql2: bool = False
6773
date_only_services: frozenset[str] = field(default_factory=frozenset)
6874
time_cols: frozenset[str] = field(default_factory=frozenset)
6975
numerical_cols: frozenset[str] = field(default_factory=frozenset)
7076
sort_cols: tuple[str, ...] = field(default_factory=tuple)
77+
open_start: str = ".."
7178

7279

7380
# Default dialect: a plain OGC API, with lists comma-joined in the URL and every

‎dataretrieval/ogc/requests.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ def _construct_api_requests(
211211
date=(
212212
collection in dialect.date_only_services and key != "last_modified"
213213
),
214+
open_start=dialect.open_start,
214215
)
215216
json_filter = _pop_json_filter(kwargs)
216217
params, post_params = _partition_request_params(

‎dataretrieval/waterdata/ratings.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,11 @@ def get_ratings(
8282
persisting the bytes to disk; ``df.attrs["url"]`` still records
8383
where each rating came from.
8484
time : string, date, datetime, or pandas.Timestamp, or a list of them, optional
85-
STAC ``datetime`` filter (passed through verbatim under that name)
86-
— a single date / datetime, an interval (``"start/end"``,
87-
optionally half-bounded with ``..``), or a list of two values
88-
with ``None`` for an open end (``["2026-04-29", None]``). A value
85+
STAC ``datetime`` filter — a single date / datetime, an interval
86+
(``"start/end"``, optionally half-bounded with ``..``), or a list of
87+
two values with ``None`` for an open end (``["2026-04-29", None]``).
88+
An interval string is read like the list form: each side is
89+
converted to UTC, and a naive one is UTC, as in pandas. A value
8990
that is not a date raises ``ValueError``. ISO 8601 *durations*
9091
(``"P1M"``, ``"PT36H"``, …) are **not** supported by the
9192
rating-curve service; passing one raises ``ValueError``.
@@ -179,7 +180,12 @@ def get_ratings(
179180
_validate_time_no_duration(time)
180181
time_str = (
181182
_format_api_dates(
182-
time, name="time", single_value_hint="an instant ('2020-01-01')"
183+
time,
184+
name="time",
185+
single_value_hint="an instant ('2020-01-01')",
186+
# The STAC firewall rejects ``datetime=../2026-01-01`` with HTTP
187+
# 403; STAC treats an empty start (``/2026-01-01``) as open.
188+
open_start="",
183189
)
184190
if time is not None
185191
else None

‎docs/source/userguide/timeconventions.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ continuous (instantaneous) values — ``time`` is a timezone-aware
1010
``datetime64[us, UTC]`` column. Daily values represent a whole calendar day,
1111
so their ``time`` column is timezone-naive (dates only).
1212

13+
The same rule applies to a getter's date arguments (``time``, ``begin``,
14+
``end``, ``datetime``, ``last_modified``). As in pandas, a date or time without
15+
an offset is UTC, and one with an offset is converted to UTC, so
16+
``time="2024-03-01/2024-03-02"`` selects the same UTC days on every machine.
17+
For a local time, include the offset: ``"2024-03-01T00:00:00-06:00"``.
18+
1319

1420
Inspecting Timestamps
1521
*********************

‎tests/ngwmn_test.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -447,8 +447,6 @@ def test_get_water_level_datetime_subsets(httpx_mock):
447447
windowed_body = _collection(_WATER_LEVELS["features"][1:])
448448
_mock(httpx_mock, "waterLevelObs", windowed_body)
449449

450-
# Offsets given explicitly: a naive input would be resolved against the
451-
# machine's local zone, making the expected wire value machine-dependent.
452450
windowed, _ = ngwmn.get_water_level(
453451
monitoring_location_id=_SITE,
454452
datetime=["2022-01-01T00:00:00Z", "2024-01-01T00:00:00Z"],
@@ -460,6 +458,20 @@ def test_get_water_level_datetime_subsets(httpx_mock):
460458
assert qs["datetime"] == ["2022-01-01T00:00:00Z/2024-01-01T00:00:00Z"]
461459

462460

461+
@pytest.mark.parametrize(
462+
"datetime", [[None, "2016-01-01T00:00:00Z"], "../2016-01-01T00:00:00Z"]
463+
)
464+
def test_get_water_level_sends_an_open_start_empty(httpx_mock, datetime):
465+
"""The NGWMN firewall rejects ``datetime=../2016-01-01`` with HTTP 403, so
466+
an open start is sent as an empty string, which NGWMN treats as open."""
467+
_mock(httpx_mock, "waterLevelObs", _WATER_LEVELS)
468+
469+
ngwmn.get_water_level(monitoring_location_id=_SITE, datetime=datetime)
470+
471+
qs = _queries(httpx_mock, "waterLevelObs")[0]
472+
assert qs["datetime"] == ["/2016-01-01T00:00:00Z"]
473+
474+
463475
def test_get_lithology(httpx_mock):
464476
_mock(httpx_mock, "lithologyObs", _LITHOLOGY)
465477

‎tests/waterdata_ratings_test.py‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,23 @@ def test_get_ratings_download_and_parse_false_returns_features(httpx_mock):
171171
assert features[0]["id"] == "USGS-01104475.exsa.rdb"
172172

173173

174+
@pytest.mark.parametrize(
175+
"time", [[None, "2026-04-29T00:00:00Z"], "../2026-04-29T00:00:00Z"]
176+
)
177+
def test_get_ratings_sends_an_open_start_empty(httpx_mock, time):
178+
"""The STAC firewall rejects ``datetime=../2026-04-29`` with HTTP 403, so an
179+
open start is sent as an empty string, which STAC treats as open."""
180+
httpx_mock.add_response(
181+
method="GET", url=STAC_SEARCH_RE, json=_stub_search_response()
182+
)
183+
get_ratings(
184+
monitoring_location_id="USGS-01104475", time=time, download_and_parse=False
185+
)
186+
(request,) = httpx_mock.get_requests()
187+
params = parse_qs(urlsplit(str(request.url)).query)
188+
assert params["datetime"] == ["/2026-04-29T00:00:00Z"]
189+
190+
174191
def test_get_ratings_keeps_a_dotdot_open_bound_in_time(httpx_mock):
175192
"""``time=[start, ".."]`` is the documented open-ended range. The ``..``
176193
endpoint used to fail to parse, which discarded the whole range, so the
@@ -182,8 +199,6 @@ def test_get_ratings_keeps_a_dotdot_open_bound_in_time(httpx_mock):
182199
)
183200
get_ratings(
184201
monitoring_location_id="USGS-01104475",
185-
# A UTC instant, so the expected bound does not depend on the local zone
186-
# a naive date is read in.
187202
time=["2026-04-29T00:00:00Z", ".."],
188203
download_and_parse=False,
189204
)

‎tests/waterdata_test.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,15 @@ def test_get_args_materializes_numpy_and_series_numeric_params():
721721
assert "bbox=-92.8%2C44.2%2C-88.9%2C46.0" in str(req.url)
722722

723723

724+
def test_construct_api_requests_keeps_dotdot_for_an_open_start():
725+
"""The Water Data API rejects an empty start (``time=/2024-01-31``) with
726+
HTTP 400, so its dialect keeps the default ``..``."""
727+
req = _construct_api_requests(
728+
"daily", monitoring_location_id="USGS-05427718", time=[None, "2024-01-31"]
729+
)
730+
assert "time=..%2F2024-01-31" in str(req.url)
731+
732+
724733
def test_construct_api_requests_two_element_date_list_becomes_interval():
725734
"""A two-element date list is interpreted as start/end of an OGC datetime
726735
interval (joined with '/'), NOT as two discrete dates. The OGC `datetime`
@@ -948,7 +957,9 @@ def test_get_daily_keeps_a_dotdot_open_bound_in_time(httpx_mock):
948957

949958

950959
@pytest.mark.parametrize(
951-
"time", ["2025-13-45", ["2025-01-01", "yesterday"]], ids=["single", "range"]
960+
"time",
961+
["2025-13-45", ["2025-01-01", "yesterday"], "not-a-date/also-bad"],
962+
ids=["single", "range", "interval_string"],
952963
)
953964
def test_get_daily_rejects_an_unreadable_time_before_any_request(httpx_mock, time):
954965
"""A bound that matches no date format used to drop the whole filter, so

0 commit comments

Comments
 (0)