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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
**10/09/2026:** **Behavior change:** a date argument given as one `"start/end"` string -- `time="2024-01-01T10:00:00/.."`, the half-bounded form shown in the getter docstrings -- is now read side by side like the two-value list form `["2024-01-01T10:00:00", None]`, for the OGC getters (`get_daily()`, `get_continuous()`, `ngwmn.get_water_level()`, and every `time`, `begin`, `end`, `datetime`, and `last_modified` argument) and `waterdata.get_ratings()`. **Bug fix:** the string used to be sent unchanged, so the same range spelled two documented ways could select different data: a naive time was not converted from local time to UTC (off by 7 hours in Denver in January), a time with an offset was not converted to `Z`, and a date-only collection such as `daily` received times. A side that cannot be read as a date -- `time="not-a-date/also-bad"` or `"2024-01-01/tomorrow"` -- now raises `ValueError` naming the argument before any request is sent, as a bad list element already does, and so does a string that is not two sides separated by one `/`, or that pairs a duration with an open end or a second duration. An empty side is an open bound like `..` (the service rejects `"2024-01-01T00:00:00Z/"`), and `"../.."` means no date filter, as `[None, None]` does. A lone duration such as `"P7D"` is unchanged, and one side may still be a duration paired with a date (`"2024-01-01/P7D"`), which is sent as before.

**10/07/2026:** **Behavior change:** a date argument the package cannot read as a date -- a typo such as `time="2024-13-45"`, an unsupported format such as `"Jan 1 2024"`, or one bad end of a range such as `time=["2024-01-01", "tomorrow"]` -- now raises `ValueError` naming the argument and the value, before any request is sent. It used to drop the date filter: `waterdata.get_ratings()` searched with no `datetime` and silently returned every rating, and the OGC getters (`get_daily()`, `get_continuous()`, `ngwmn.get_water_level()`, and every `time`, `begin`, `end`, `datetime`, and `last_modified` argument, plus the deprecated `begin_utc` and `end_utc`) sent an empty `time=` that the service rejected with HTTP 400 "Invalid datetime format", which named no argument. To leave one end of a range open, pass `None` (`["2024-01-01", None]`) or `".."`; the getter docstrings now show this list form. A range whose ends are all open still means no date filter.

**10/07/2026:** **Bug fix:** a `".."` endpoint in a two-value date range -- `time=["2024-01-01", ".."]`, the open-ended form shown in the `waterdata.get_ratings()` docstring -- discarded the whole range, because `..` was not recognized as an open bound and failed to parse as a date. `waterdata.get_ratings()` then searched with no `datetime` at all and returned every rating regardless of date (59 instead of 44 for the docstring's bounding box from 2026-09-20 on), and the OGC getters (`get_daily()`, `get_continuous()`, `get_field_measurements()`, and the other `time`, `begin`, `end`, and `last_modified` arguments) sent an empty `time=` that the service rejected with HTTP 400 "Invalid datetime format". `".."` is now an open bound like `None`, so `["2024-01-01", ".."]` sends the same range as `"2024-01-01/.."` and `["2024-01-01", None]`.
Expand Down
45 changes: 41 additions & 4 deletions dataretrieval/ogc/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,38 @@ def _coerce_to_list(

def _is_passthrough(single: str) -> bool:
"""True when a single-element input should be returned as-is."""
return bool(_DURATION_RE.match(single) or "/" in single)
return bool(_DURATION_RE.match(single))


def _format_interval(interval: str, *, date: bool, name: str) -> str | None:
"""Format each side of a pre-formatted ``"start/end"`` interval string.

Each side is formatted like an element of the two-value list form, so
``"2024-01-01T10:00:00/.."`` sends the same range as
``["2024-01-01T10:00:00", None]``. One side may instead be an ISO 8601
duration paired with an instant (``"2024-01-01/P7D"``), which is kept
unchanged. Returns None when both sides are open.

Raises ``ValueError`` naming *name* when the string is not two sides
separated by one ``"/"``, or a side cannot be read.
"""
sides = interval.split("/")
durations = [bool(_DURATION_RE.match(side)) for side in sides]
if len(sides) != 2 or (
any(durations) and (all(durations) or any(_is_blank(s) for s in sides))
):
raise ValueError(
f"{name} is not a valid interval: {interval!r}. Pass a start and "
"an end separated by '/', such as '2024-01-01/2024-12-31', with "
"'..' for an open end ('2024-01-01/..')."
)
formatted = [
side if is_duration else _format_one(side, date=date, name=name)
for side, is_duration in zip(sides, durations, strict=True)
]
if formatted == [_OPEN_BOUND, _OPEN_BOUND]:
return None
return "/".join(formatted)


def _all_blank(items: list[str | None]) -> bool:
Expand Down Expand Up @@ -182,8 +213,11 @@ def _format_api_dates(
or ``".."`` endpoint is rendered as ``".."`` to denote an open bound
(e.g. ``"2024-01-01/.."``); the range is only None when *every* element
is blank/NA/``".."``.
- Supports ISO 8601 durations such as "P7D" and "PT36H" and pre-formatted
intervals containing ``"/"``; both are passed through unchanged.
- Supports ISO 8601 durations such as "P7D" and "PT36H", which are passed
through unchanged.
- A single string containing ``"/"`` is an interval: each side is formatted
like an element of the two-value form, and may also be an ISO 8601
duration paired with an instant (``"2024-01-01/P7D"``).
- Converts datetimes to UTC and formats as ISO 8601 with 'Z' suffix when
`date` is False. Inputs with an explicit offset (``Z`` or ``+HH:MM``) are
converted from that offset to UTC; naive inputs are interpreted in the
Expand All @@ -204,7 +238,10 @@ def _format_api_dates(
"or two for a closed interval ('2020-01-01', '2020-12-31')."
)

# Pass through duration ("P7D", "PT36H") and pre-formatted interval ("a/b")
if len(items) == 1 and isinstance(items[0], str) and "/" in items[0]:
return _format_interval(items[0], date=date, name=name)

# Pass through duration ("P7D", "PT36H")
if len(items) == 1 and isinstance(items[0], str) and _is_passthrough(items[0]):
return items[0]

Expand Down
4 changes: 3 additions & 1 deletion tests/waterdata_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,9 @@ def test_get_daily_keeps_a_dotdot_open_bound_in_time(httpx_mock):


@pytest.mark.parametrize(
"time", ["2025-13-45", ["2025-01-01", "yesterday"]], ids=["single", "range"]
"time",
["2025-13-45", ["2025-01-01", "yesterday"], "not-a-date/also-bad"],
ids=["single", "range", "interval_string"],
)
def test_get_daily_rejects_an_unreadable_time_before_any_request(httpx_mock, time):
"""A bound that matches no date format used to drop the whole filter, so
Expand Down
97 changes: 94 additions & 3 deletions tests/waterdata_utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,7 @@ def test_type_cols_warning_is_singular_for_one_value():
"fractional_seconds",
"offset_to_utc",
"iso8601_pair_to_interval",
"passthrough_interval",
"utc_interval_string",
"passthrough_duration",
"time_only_duration",
"date_only",
Expand All @@ -964,8 +964,8 @@ def test_type_cols_warning_is_singular_for_one_value():
def test_format_api_dates(value, date, expected):
"""``_format_api_dates`` normalizes ISO 8601 datetimes to UTC (dropping
fractional seconds, converting offsets), joins a pair into an interval,
passes durations / intervals through unchanged, and renders a None
endpoint as ``..``."""
passes durations through unchanged, and renders a None endpoint as
``..``."""
assert _format_api_dates(value, date=date) == expected


Expand All @@ -982,6 +982,97 @@ def test_format_api_dates_treats_an_all_blank_sequence_as_no_filter():
assert _format_api_dates(["..", ".."]) is None


@pytest.mark.parametrize(
"interval, pair, date",
[
("2024-01-01T10:00:00/..", ["2024-01-01T10:00:00", None], False),
("../2024-01-01T10:00:00", [None, "2024-01-01T10:00:00"], False),
("2018-02-12T19:20:50-04:00/..", ["2018-02-12T19:20:50-04:00", ".."], False),
(
"2024-01-01 10:00:00/2024-01-02",
["2024-01-01 10:00:00", "2024-01-02"],
False,
),
(
"2024-01-01T10:00:00Z/2024-02-01T00:00:00Z",
["2024-01-01T10:00:00Z", "2024-02-01T00:00:00Z"],
True,
),
("2024-01-01T10:00:00Z/", ["2024-01-01T10:00:00Z", ""], False),
],
ids=[
"naive_start",
"naive_end",
"offset",
"space_separated",
"date_only_truncates",
"empty_end",
],
)
def test_format_api_dates_formats_an_interval_string_like_the_pair(
interval, pair, date
):
"""A ``"start/end"`` string and the two-value list are two spellings of the
same range, so they send the same value. The string used to be sent
unchanged: a naive time was not converted from local time to UTC, and a
date-only collection received times."""
assert _format_api_dates(interval, date=date) == _format_api_dates(pair, date=date)


@pytest.mark.parametrize(
"interval, date, expected",
[
("2018-02-12T19:20:50-04:00/..", False, "2018-02-12T23:20:50Z/.."),
(
"2024-01-01T10:00:00Z/2024-02-01T00:00:00Z",
True,
"2024-01-01/2024-02-01",
),
("2024-01-01T10:00:00+02:00/PT36H", False, "2024-01-01T08:00:00Z/PT36H"),
("P7D/2024-01-08T00:00:00Z", True, "P7D/2024-01-08"),
],
ids=["offset_to_utc", "date_only", "start_duration", "duration_end"],
)
def test_format_api_dates_formats_each_side_of_an_interval_string(
interval, date, expected
):
"""Each side is formatted on its own; an ISO 8601 duration paired with an
instant is kept unchanged."""
assert _format_api_dates(interval, date=date) == expected


def test_format_api_dates_treats_an_all_open_interval_string_as_no_filter():
"""``"../.."`` is the string spelling of ``[None, None]``."""
assert _format_api_dates("../..") is None


@pytest.mark.parametrize(
"interval, message",
[
("not-a-date/also-bad", "time could not be read as a date or datetime"),
("2024-01-01/tomorrow", "time could not be read as a date or datetime"),
("2024-01-01/2024-02-01/2024-03-01", "time is not a valid interval"),
("P7D/P1D", "time is not a valid interval"),
("P7D/..", "time is not a valid interval"),
("../P7D", "time is not a valid interval"),
],
ids=[
"both_sides",
"one_side",
"three_sides",
"two_durations",
"duration_open_end",
"open_start_duration",
],
)
def test_format_api_dates_rejects_an_unreadable_interval_string(interval, message):
"""An interval string used to be sent unchanged, so ``time="garbage/.."``
reached the service. It is now rejected like an unreadable list element,
naming the caller's argument."""
with pytest.raises(ValueError, match=f"^{message}"):
_format_api_dates(interval, name="time")


@pytest.mark.parametrize(
"value",
["2024-13-45", "Jan 1 2024", "Apr", ["2024-01-01", "garbage"], ["garbage", None]],
Expand Down