Skip to content
Closed
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
5 changes: 4 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 shared by OGC and STAC, which is not OGC,
; so it sits below ``ogc`` rather than inside it (ADR 0014).
_intervals
utils
_querying
transport
Expand All @@ -42,7 +45,7 @@ layers =
; 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
6 changes: 3 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,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. One
`OgcDialect` field also still says it: `date_only_services` is keyed by
collection.
alias remain — a deprecated keyword and a permanent alias respectively. The
dialect field keyed by collection says so too:
`OgcDialect.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
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
**10/12/2026:** **Behavior change:** a lone ISO 8601 duration, such as `datetime="P7D"`, passed to `ngwmn.get_water_level()` or another `ngwmn` getter now raises `ValueError` before any request is sent; the NGWMN service rejects one with HTTP 400. `waterdata.get_ratings()` already rejected a duration, and its message now matches the other getters'. The `waterdata` OGC getters still accept durations. Date arguments are now read in `dataretrieval._dates` and written in `dataretrieval._intervals` (ADR 0014, proposed); the values sent are otherwise unchanged.

**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"`.

**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.
Expand Down
263 changes: 263 additions & 0 deletions dataretrieval/_dates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
"""What a caller meant by a date argument (ADR 0014).

A dependency-free leaf. A date argument is read here into a :class:`DateRange`,
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 is an open
bound, and that a naive value is UTC -- 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, timezone

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-%dT%H:%M%z",
"%Y-%m-%dT%H:%M",
"%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 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"``). Every bound is
in UTC: a naive value is read as UTC, as in pandas, and an aware one is
converted.
"""

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 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):
# ``Timestamp.astimezone`` is ``tz_convert`` and requires a zone, so
# return 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 UTC ``datetime``, or None for an open bound."""
if dt is None or _is_blank(dt):
return None
parsed = _to_datetime(dt, name=name)
# As in pandas, a naive value is UTC and an aware one is converted to UTC,
# so the same call sends the same range on every machine.
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)


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


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. The Water Data and
NGWMN services reject ``"2024-01-01/P7D"`` and ``"P7D/2024-01-08"`` with
HTTP 400.
"""
sides = [side.strip() for side in 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
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 API 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)
76 changes: 76 additions & 0 deletions dataretrieval/_intervals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""The ISO 8601 interval grammar OGC API Features and STAC share (ADR 0014).

A date argument is read into a :class:`~dataretrieval._dates.DateRange`; this
module writes that value as the ``datetime`` parameter of both standards -- an
instant, a ``start/end`` interval with ``..`` open bounds, or a duration --
adjusted by the API's :class:`DateDialect`. It names no service.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime

from dataretrieval._dates import OPEN_BOUND, DateRange


@dataclass(frozen=True)
class DateDialect:
"""How one API writes dates differently from the interval standard.

Each default is the standard, so ``DateDialect()`` describes an API that
follows it. Where a field is set, the declaration records the observed
server behavior that requires it.

Attributes
----------
open_start : str
The string written for the open start of a range: ``".."``
(``"../2024-01-01"``, the default) or ``""`` (``"/2024-01-01"``). Both
standards allow either, but each service accepts only one.
durations : bool
Whether a lone ISO 8601 duration (``"P7D"``, the last seven days) is
accepted. Neither standard defines one, so the default is ``False``
and a duration raises before any request is sent.
"""

open_start: str = OPEN_BOUND
durations: bool = False


def _write_bound(bound: datetime | None, *, date_only: bool) -> str:
"""One UTC bound: ``YYYY-MM-DD``, ``YYYY-MM-DDTHH:MM:SS[.ffffff]Z``, or ``..``."""
if bound is None:
return OPEN_BOUND
if date_only:
return bound.strftime("%Y-%m-%d")
# Keep fractional seconds: truncating them changes a closed bound.
fraction = f".{bound.microsecond:06d}".rstrip("0") if bound.microsecond else ""
return f"{bound.strftime('%Y-%m-%dT%H:%M:%S')}{fraction}Z"


def write_interval(value: DateRange, dialect: DateDialect, *, date_only: bool) -> str:
"""Write a date range as the API's date parameter expects it.

Parameters
----------
value : DateRange
What the caller meant, as read by
:func:`~dataretrieval._dates.read_date_range`, which refuses a duration
the dialect does not accept.
dialect : DateDialect
The API's date dialect; supplies ``open_start``.
date_only : bool
Write each bound as its UTC date, ``YYYY-MM-DD``, rather than as a UTC
datetime; the caller's protocol decides which values are calendar days.
"""
if value.duration is not None:
return value.duration
if value.instant:
return _write_bound(value.start, date_only=date_only)
start = (
dialect.open_start
if value.start is None
else _write_bound(value.start, date_only=date_only)
)
return f"{start}/{_write_bound(value.end, date_only=date_only)}"
Loading
Loading