Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2de9155
feat(ogc): accept date, datetime and Timestamp values in date arguments
bhaskargurram-ai Oct 9, 2026
3bb0074
fix(ogc): read each side of a "start/end" interval string like the li…
bhaskargurram-ai Oct 9, 2026
0783657
fix(ratings): accept a lone date or Timestamp in get_ratings(time=...)
thodson-usgs Oct 11, 2026
6647ff5
fix(ogc): reject a duration side in an interval string
thodson-usgs Oct 11, 2026
638374d
refactor(ogc): return the interval sides as a tuple
thodson-usgs Oct 11, 2026
5125381
fix(ogc): say in the interval error that a side cannot be a duration
thodson-usgs Oct 11, 2026
f6cbbbb
fix(ogc): spell an open range start the way each API accepts
thodson-usgs Oct 11, 2026
cc0cead
docs(adr): propose 0014, declare services, endpoints, and dialects as…
thodson-usgs Oct 11, 2026
1a59336
refactor: declare one Service per adapter (ADR 0014)
thodson-usgs Oct 11, 2026
dd67d6d
fix: review findings on the service declarations
thodson-usgs Oct 11, 2026
8a09052
refactor(ogc): reach the OGC getters' API through one Endpoint (ADR 0…
thodson-usgs Oct 11, 2026
0860fd0
docs(ogc): policy no longer says calls are targeted by base_url
thodson-usgs Oct 11, 2026
d9d658c
docs(adr): 0014, an Endpoint value exists only where protocol code re…
thodson-usgs Oct 11, 2026
d69bddc
Merge branch 'pr-442' into refactor/service-endpoint
thodson-usgs Oct 11, 2026
45ee723
Merge branch 'pr-443' into refactor/service-endpoint
thodson-usgs Oct 11, 2026
997d91f
refactor: read a date range once, at the public boundary (ADR 0014)
thodson-usgs Oct 11, 2026
c3a6f63
refactor: one interval grammar for OGC and STAC, adjusted by DateDial…
thodson-usgs Oct 11, 2026
aef4fce
refactor(ogc): the OGC code names nothing a dialect declares (ADR 0014)
thodson-usgs Oct 11, 2026
f993563
refactor: simplify the date reader and interval writer (ADR 0014)
thodson-usgs Oct 11, 2026
83af0ed
fix: review findings on the date-range and dialect steps (ADR 0014)
thodson-usgs Oct 11, 2026
630a9f0
docs(adr): 0014, cql2_collections records the wrong behavior
thodson-usgs Oct 12, 2026
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
8 changes: 7 additions & 1 deletion .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ layers =
wateruse
ngwmn | nldi | nwdc | nwis | streamstats | waterdata | wqp
ogc
; The ISO 8601 interval grammar is a protocol OGC shares with STAC, which is
; not OGC, so it sits below ``ogc`` rather than inside it (ADR 0014).
_intervals
utils
_querying
transport
Expand All @@ -38,11 +41,14 @@ layers =
credentials
configuration
_configuration_core
; The service declaration type is a dependency-free leaf, so every layer
; above may accept one (ADR 0014).
_service
exceptions
; Pure dependency-free mechanisms sit together at the floor: none imports
; first-party code, so every layer can use them without reaching sideways or
; creating artificial dependencies among them.
_ambient | _csv | _deprecation | _validation
_ambient | _csv | _dates | _deprecation | _validation
; Every top-level module must be placed in the stack deliberately. A new
; top-level module fails this contract until someone decides where it sits.
exhaustive = True
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ raise states the problem and then the action that fixes it, in that order.
wrote `None` into the URL and returned an empty frame. Run the corrected
call against the real service; wording review does not catch these.
- Shared checks take the caller's argument name. `_validate_data_source`,
`_format_api_dates`, and `require_one_of` all accept a `name=` so the subject
`read_date_range`, and `require_one_of` all accept a `name=` so the subject
of the message is the argument that was passed. A helper that hard-codes
one noun reports the wrong parameter the moment a second call site reuses it.
- Prefer raising over returning something empty when the library cannot distinguish
Expand Down
43 changes: 31 additions & 12 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,15 +253,34 @@ value. Where the distinction matters — reporting what a call will use

## Boundaries

**Adapter** — A module owning one service's conventions: its URLs, parameters,
error shapes, and response quirks. Adapters may use shared code; shared code
may not reference adapters.

**Dialect** — The per-API quirks the shared OGC code needs in order to
serve two services from one code path: which collections must be POSTed as
CQL2, which render dates date-only, which columns to coerce and sort by. An
adapter supplies one and the shared code reads it, which is how protocol code
stays free of service names.
**Adapter** — A module owning one service's conventions: its getters, public
parameters, and their translation, error shapes, and response quirks. Adapters
may use shared code; shared code may not reference adapters. An adapter
declares its service, the endpoints it calls, and their dialects as values,
and hands those to shared code (ADR 0014).

**Service declaration** — The value an adapter declares for its service: the
name configuration resolves its settings by, and the URL a `base_url` setting
replaces. The one place that name is written outside the configuration
roster. In code, `Service`.

**Endpoint** — One API of a service, at one URL, speaking one protocol. Water
Data is one service with four endpoints: OGC API Features, the ratings STAC
catalog, Samples, and Statistics. The adapter builds an endpoint when a call
starts, so the URL it holds is the one in effect then; it also carries the
dialect. In code, `Endpoint`.

**Protocol** — The rules for talking to one kind of standard API, implemented
once as functions that receive an endpoint: OGC API Features, or the ISO 8601
interval grammar OGC and STAC share. A protocol gets its own module only when
two endpoints speak it.

**Dialect** — How one endpoint departs from its protocol's standard, as data
the protocol code reads. Each field is one extension, restriction, or renaming,
and its default is the standard, so a default dialect describes an endpoint
that follows the standard. Fields are grouped by the code that reads them; the
date group, `DateDialect`, is meant to be shared by the protocols that write
intervals (ADR 0014, proposed).

**Single-shot adapter** — An adapter whose query is always exactly one request:
WQP, NLDI, StreamStats, and deprecated NWIS. Nothing divides and nothing
Expand Down Expand Up @@ -312,9 +331,9 @@ entry (ADR 0013).
OGC internals, the Water Data wrappers, and all eleven typed getters now say
`collection`; `waterdata.get_cql` takes `collection`; and the type alias is
`WATERDATA_COLLECTIONS`. `service=` on `get_cql` and the `WATERDATA_SERVICES`
alias remain — a deprecated keyword and a permanent alias respectively. Two
`OgcDialect` fields also still say it: `cql2_services` and
`date_only_services` are keyed by collection.
alias remain — a deprecated keyword and a permanent alias respectively. The
dialect fields keyed by collection now say so too: `cql2_collections` and
`DateDialect.date_only_collections`.

`service` still means the external system in `transport` and `progress`,
where it labels a progress line. That usage is correct.
Expand Down
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
**10/11/2026:** **Behavior change:** `ngwmn.get_water_level()` raises `ValueError` for a lone ISO 8601 duration such as `datetime="P7D"` before sending, instead of failing with the service's HTTP 400. Pass a start and an end, such as `datetime=["2024-01-01", "2024-01-08"]`. `waterdata.get_ratings()` already refused a duration; its message now names `time` and suggests that same form. The Water Data OGC getters still accept durations.

**10/09/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: an aware value is converted to UTC, a naive one is read in local time, 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.

**10/09/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 `waterdata.get_ratings()`. **Bug fix:** the string used to be sent unchanged, so the two spellings could select different data: a naive time was not converted from local time to UTC, an offset was not converted to `Z`, and a date-only collection such as `daily` received times. A side that is not a date (`time="2024-01-01/tomorrow"`) now raises `ValueError` before any request is sent, as does a string with more than one `/` or with a duration as either side (`"2024-01-01/P7D"`), which the services answered 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:** a range with an open start, such as `[None, "2016-01-01"]` or `"../2016-01-01"`, failed with HTTP 403 in `ngwmn.get_water_level()` and `waterdata.get_ratings()`, because those services' firewalls reject `../`. Both now send an empty start (`"/2016-01-01"`), which both services read as open. The other Water Data getters still send `..`, because that service rejects an empty start.

**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
251 changes: 251 additions & 0 deletions dataretrieval/_dates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
"""What a caller meant by a date argument, read once at the public boundary.

A dependency-free leaf (ADR 0014): a getter reads its date arguments into a
:class:`DateRange` when it is called, and the protocol code that sends the
request writes that value in its own wire format. Reading -- which types and
string formats are accepted, what spells an open bound, and that a naive value
is local time -- is package policy, so it lives here and names no service.
"""

from __future__ import annotations

import re
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import date as _date
from datetime import datetime

import pandas as pd

_DATETIME_FORMATS = (
"%Y-%m-%dT%H:%M:%S.%f%z",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%d",
)


# Anchored to ``[Pp]\d`` so a normal word containing ``p`` (e.g. ``"Apr"``)
# doesn't get mis-classified as an ISO 8601 duration; the optional ``T``
# admits time-only forms like ``PT36H``.
_DURATION_RE = re.compile(r"^[Pp]T?\d")


# The ISO 8601 spelling of an open interval bound, as in ``"2024-01-01/.."``.
OPEN_BOUND = ".."


# One element of a date argument: an ISO 8601 string, or a ``date``,
# ``datetime`` or ``pandas.Timestamp`` (both subclasses of ``datetime.date``),
# with ``None`` (or ``NaN`` / ``NaT``) for an open bound.
DateLike = str | _date | None


@dataclass(frozen=True)
class DateRange:
"""One date argument as the caller meant it.

Exactly one shape is set: an instant (``instant=True``, ``start`` only), a
range of two bounds where ``None`` is an open bound (``start``, ``end``),
or an ISO 8601 duration (``duration``, such as ``"P7D"``). A bound is
naive exactly when the caller gave no zone, and a naive bound is local
time.
"""

start: datetime | None = None
end: datetime | None = None
instant: bool = False
duration: str | None = None


def _parse_datetime(value: str) -> datetime | None:
"""Parse a single datetime string against the supported formats.

Returns a ``datetime`` (tz-aware iff the input included a UTC offset),
or ``None`` if no format matched.
"""
# ``datetime.strptime`` accepts a numeric offset like ``+00:00`` but not
# the ``Z`` shorthand, so normalize trailing ``Z`` first.
candidate = value[:-1] + "+00:00" if value.endswith("Z") else value
for fmt in _DATETIME_FORMATS:
try:
# DTZ007: naive is the documented outcome for a naive input --
# ``_DATETIME_FORMATS`` carries both the ``%z`` and the bare forms.
return datetime.strptime(candidate, fmt) # noqa: DTZ007
except ValueError:
continue
return None


def _is_blank(dt: DateLike) -> bool:
"""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 _to_datetime(dt: str | _date, *, name: str) -> datetime:
"""Read one non-blank element as a ``datetime`` (naive iff it has no zone).

A ``date`` is midnight of that day, like the string ``"2024-01-01"``.
Raises ``ValueError`` naming *name* for an unreadable string or a value of
another type.
"""
if isinstance(dt, pd.Timestamp):
# A writer converts with ``astimezone``, which on a ``Timestamp`` is
# ``tz_convert`` and requires a zone, so keep a plain ``datetime``.
converted: datetime = dt.to_pydatetime(warn=False)
return converted
if isinstance(dt, datetime):
return dt
if isinstance(dt, _date):
return datetime(dt.year, dt.month, dt.day) # noqa: DTZ001
parsed = _parse_datetime(dt) if isinstance(dt, str) else None
if parsed is None:
raise ValueError(
f"{name} could not be read as a date or datetime: {dt!r}. "
"Pass an ISO 8601 date or datetime such as '2024-01-01' or "
"'2024-01-01T12:00:00Z' (a datetime.date, datetime.datetime or "
"pandas.Timestamp also works), and None for an open end of a range "
"(['2024-01-01', None])."
)
return parsed


def _read_bound(dt: DateLike, *, name: str) -> datetime | None:
"""One element as a bound: ``None`` when it is blank (an open bound)."""
return None if dt is None or _is_blank(dt) else _to_datetime(dt, name=name)


def _coerce_to_list(
value: str | _date | Sequence[DateLike],
name: str,
) -> list[DateLike]:
"""Normalize a date argument to a list of elements, raising on a mapping."""
if isinstance(value, str) or not isinstance(value, Iterable):
# A lone value (a ``date`` is not iterable); a non-date is rejected
# per element.
return [value]
if isinstance(value, Mapping):
raise TypeError(
f"{name} must be a date, a string, or a sequence of them, "
f"not {type(value).__name__}."
)
return list(value)


def _split_interval(interval: str, *, name: str) -> tuple[str, str]:
"""Split a ``"start/end"`` string into its two sides.

Each side is then read like a list element, so ``"2024-01-01T10:00:00/.."``
means the same range as ``["2024-01-01T10:00:00", None]``.

Raises ``ValueError`` naming *name* unless the string is two sides
separated by one ``"/"``, neither of them a duration, which the services
this package reaches answer with HTTP 400.
"""
sides = interval.split("/")
if len(sides) != 2 or any(_DURATION_RE.match(side) for side 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/..'). Neither side can be a "
"duration."
)
return sides[0], sides[1]


def read_date_range(
value: str | _date | Sequence[DateLike] | None,
*,
name: str,
durations: bool,
) -> DateRange | None:
"""Read a date argument as the caller meant it.

Parameters
----------
value : str, date, sequence of them, or None
A single date/datetime, a ``"start/end"`` interval string, an ISO 8601
duration (``"P7D"``, ``"PT36H"``), or a list of one or two elements.
Each element may be a ``datetime.date``, ``datetime.datetime`` or
``pandas.Timestamp``, or a string in ISO 8601 (with or without ``Z`` or
a numeric offset) or ``"%Y-%m-%d %H:%M:%S"`` form. ``None``, ``NaN``,
``NaT``, ``""`` and ``".."`` are open bounds.
name : str, optional
The caller's own spelling of this argument, used as the subject of
every message raised here, so a caller correcting the error edits an
argument their getter accepts.
durations : bool
Whether the endpoint accepts a lone ISO 8601 duration; see
``DateDialect.durations``. When it does not, a duration raises, and no
message offers one.

Returns
-------
DateRange or None
``None`` when *value* is ``None`` or every element is an open bound,
meaning no filter.

Raises
------
ValueError
For more than two elements, an unreadable interval string, a duration
where *durations* is false, or an element that is not blank and matches
no supported format or type.
TypeError
For a mapping.
"""
if value is None:
return None

items = _coerce_to_list(value, name)
if len(items) == 1 and isinstance(items[0], str) and "/" in items[0]:
items = list(_split_interval(items[0], name=name))

if all(_is_blank(dt) for dt in items):
return None

if len(items) > 2:
raise _too_many_values(items, name=name, durations=durations)
if len(items) == 1:
return _read_single(items[0], name=name, durations=durations)
return DateRange(
start=_read_bound(items[0], name=name), end=_read_bound(items[1], name=name)
)


def _too_many_values(
items: list[DateLike], *, name: str, durations: bool
) -> ValueError:
"""The error for more than two elements; it offers a duration only where
one is accepted."""
single = (
"an instant or a duration ('2020-01-01', 'P7D')"
if durations
else "an instant ('2020-01-01')"
)
return ValueError(
f"{name} takes at most 2 values, got {len(items)}: {items!r}. "
f"Pass one value for {single}, "
"or two for a closed interval ('2020-01-01', '2020-12-31')."
)


def _read_single(item: DateLike, *, name: str, durations: bool) -> DateRange:
"""One non-blank element: a duration, or an instant."""
if isinstance(item, str) and _DURATION_RE.match(item):
if not durations:
raise ValueError(
f"{name} cannot be an ISO 8601 duration for this API: "
f"{item!r}. Pass a start and an end instead, such as "
"['2024-01-01', '2024-01-08'], with None for an open end "
"(['2024-01-01', None])."
)
return DateRange(duration=item)
return DateRange(start=_read_bound(item, name=name), instant=True)
Loading
Loading