Skip to content
Merged
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/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]`.

**10/03/2026:** `waterdata.get_field_measurements()` and
`waterdata.get_field_measurements_metadata()` accept `sublocation_identifier`, a
field USGS added to the `field-measurements` and `field-measurements-metadata`
Expand Down
26 changes: 17 additions & 9 deletions dataretrieval/ogc/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,23 @@ def _parse_datetime(value: str) -> datetime | None:
return None


# The OGC API spelling of an open interval bound, as in ``"2024-01-01/.."``.
_OPEN_BOUND = ".."


def _is_blank(dt: str | None) -> bool:
"""True for a None, NaN, or empty-string element."""
return dt is None or bool(pd.isna(dt)) or dt == ""
"""True for a None, NaN, empty-string, or ``..`` element.

Each is a spelling of an open bound, so ``["2024-01-01", ".."]`` means the
same range as ``["2024-01-01", None]`` and the string ``"2024-01-01/.."``.
"""
return dt is None or bool(pd.isna(dt)) or dt in ("", _OPEN_BOUND)


def _format_one(dt: str | None, *, date: bool) -> str | None:
"""Format a single datetime element for inclusion in the API time arg."""
if dt is None or _is_blank(dt):
return ".."
return _OPEN_BOUND
parsed = _parse_datetime(dt)
if parsed is None:
return None
Expand Down Expand Up @@ -104,7 +112,7 @@ def _is_passthrough(single: str) -> bool:


def _all_blank(items: list[str | None]) -> bool:
"""True when every element is None, NaN, or the empty string."""
"""True when every element is None, NaN, the empty string, or ``..``."""
return all(_is_blank(dt) for dt in items)


Expand All @@ -127,8 +135,8 @@ def _format_api_dates(
A single date/datetime string or a list of one or two date/datetime
strings. Accepts formats like "%Y-%m-%d %H:%M:%S", ISO 8601 (with or
without ``Z``/numeric offset), or relative periods (e.g., "P7D" /
"PT36H"). Range endpoints may be ``None``/``NaN``/empty to denote a
half-bounded range.
"PT36H"). Range endpoints may be ``None``/``NaN``/empty or ``".."``
to denote a half-bounded range.
date : bool, optional
If True, uses only the date portion ("YYYY-MM-DD"). If False (default),
returns full datetime in UTC ISO 8601 format ("YYYY-MM-DDTHH:MM:SSZ").
Expand Down Expand Up @@ -162,9 +170,9 @@ def _format_api_dates(
Notes
-----
- A single blank/NA value returns None. In a two-value range, a blank/NA
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 or any non-NA element fails to parse.
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/``".."`` or any other element fails to parse.
- Supports ISO 8601 durations such as "P7D" and "PT36H" and pre-formatted
intervals containing ``"/"``; both are passed through unchanged.
- Converts datetimes to UTC and formats as ISO 8601 with 'Z' suffix when
Expand Down
21 changes: 21 additions & 0 deletions tests/waterdata_ratings_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,27 @@ def test_get_ratings_download_and_parse_false_returns_features(httpx_mock):
assert features[0]["id"] == "USGS-01104475.exsa.rdb"


def test_get_ratings_keeps_a_dotdot_open_bound_in_time(httpx_mock):
"""``time=[start, ".."]`` is the documented open-ended range. The ``..``
endpoint used to fail to parse, which discarded the whole range, so the
search went out with no ``datetime`` and returned every rating."""
httpx_mock.add_response(
method="GET",
url=STAC_SEARCH_RE,
json=_stub_search_response(),
)
get_ratings(
monitoring_location_id="USGS-01104475",
# A UTC instant, so the expected bound does not depend on the local zone
# a naive date is read in.
time=["2026-04-29T00:00:00Z", ".."],
download_and_parse=False,
)
(request,) = httpx_mock.get_requests()
params = parse_qs(urlsplit(str(request.url)).query)
assert params["datetime"] == ["2026-04-29T00:00:00Z/.."]


def test_get_ratings_multi_type_filters_via_property(httpx_mock, tmp_path):
"""File_type list: server filter omits it; local filter reads the property."""
httpx_mock.add_response(
Expand Down
16 changes: 16 additions & 0 deletions tests/waterdata_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,22 @@ def test_get_daily(httpx_mock):
assert hasattr(md, "url") and hasattr(md, "query_time")


def test_get_daily_keeps_a_dotdot_open_bound_in_time(httpx_mock):
"""A ``..`` endpoint in a ``time`` list is an open bound, as in the string
form ``"2025-01-01/.."``. It used to discard the whole range and send an
empty ``time=``, which the service rejects as an invalid datetime."""
_mock_items(httpx_mock, "daily")

get_daily(
monitoring_location_id="USGS-05427718",
parameter_code="00060",
time=["2025-01-01", ".."],
)

(sent,) = _sent(httpx_mock, "daily")
assert sent["time"] == ["2025-01-01/.."]


def test_get_daily_value_is_float_when_every_value_is_whole(httpx_mock):
"""Issue #428: whole-number values used to infer ``int64``, so ``value``
changed dtype between calls. It is ``float64`` regardless of the data."""
Expand Down
11 changes: 11 additions & 0 deletions tests/waterdata_utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,13 @@ def test_type_cols_warning_is_singular_for_one_value():
("2024-01-01 00:00:00", True, "2024-01-01"),
(["2024-01-01", None], True, "2024-01-01/.."),
([None, "2024-01-01"], True, "../2024-01-01"),
(["2024-01-01", ".."], True, "2024-01-01/.."),
(["..", "2024-01-01"], True, "../2024-01-01"),
(
["2018-02-12T23:20:50Z", ".."],
False,
"2018-02-12T23:20:50Z/..",
),
],
ids=[
"iso8601_z",
Expand All @@ -951,6 +958,9 @@ def test_type_cols_warning_is_singular_for_one_value():
"space_separated",
"open_ended_none_end",
"open_ended_none_start",
"open_ended_dotdot_end",
"open_ended_dotdot_start",
"open_ended_dotdot_datetime",
],
)
def test_format_api_dates(value, date, expected):
Expand All @@ -971,6 +981,7 @@ def test_format_api_dates_treats_an_all_blank_sequence_as_no_filter():
all -- sending ``../..`` would be a query the service has to reject."""
assert _format_api_dates([None, None]) is None
assert _format_api_dates(["", ""]) is None
assert _format_api_dates(["..", ".."]) is None


def test_format_api_dates_rejects_more_than_two_values():
Expand Down