Skip to content
Draft
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
4 changes: 4 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
**09/22/2026:** `waterdata.get_continuous()` accepts `method_category`, a column the `continuous` collection added in September 2026: the RLMS method category code (`STNRD`, `LMTUS`, `EXPER` or `UNKWN`) for the method in effect over an observation's interval. It is returned on every record and is null for time series that have not been categorized. It could already be passed through `**queryables`; it is now a documented parameter. `get_latest_continuous()` is unchanged, because `latest-continuous` does not have the field.

**09/22/2026:** The Water Data OGC getters now request **v1** of the Water Data APIs (`api.waterdata.usgs.gov/ogcapi/v1`), [released September 2026](https://waterdata.usgs.gov/blog/api-v1-release/); v0 stays online until June 2027. **New:** `WaterdataConfiguration(api_version="v0")`, or `api_version = "v0"` in the `[waterdata]` table of the configuration file, pins v0 during the transition. It changes only the version segment of the OGC path; Samples, Statistics and STAC are versioned separately and have no v1. **Behavior change:** `waterdata.get_time_series_metadata()` returns `begin` and `end` in UTC with a time zone, and no longer returns `begin_utc`, `end_utc`, `state_name` or `hydrologic_unit_code`. **Deprecation:** passing `begin_utc`, `end_utc`, `state`, `state_name` or `hydrologic_unit_code` to that getter, as a filter or in `properties`, emits a `DeprecationWarning` and sends the call to v0; this may be removed on or after 2027-06-01. Use `begin`, `end`, and `get_combined_metadata()` instead. **Behavior change:** `waterdata.get_field_measurements()` returns `time` as a date rather than a datetime, parsed to a tz-naive midnight timestamp as `get_daily()` already does; the time of day is in `time_of_day`. The `field-measurements-metadata` collection has no `time` field and is unaffected.

**09/09/2026:** **Bug fix:** code and identifier columns keep their leading zeros. A bare `pandas.read_csv` infers a zero-padded code as a number, so `waterdata.get_samples()` returned parameter code `00060` as `60` and HUC12 `070700050502` as `70700050502`, and `nwis.get_info()` returned `huc_cd` `02060005` as `2060005`. One rule now decides what a code column is — a name ending in `code`, the RDB abbreviation `_cd`, or a name containing `identifier`, `huc`, or `fips` — and every delimited response is parsed through it: the Samples and WQP CSV readers, `rdb.read_rdb` (which reads the names from the RDB header rather than the caller listing them), and the Water Use CSV pages. **Behavior change:** these columns now hold strings. `waterdata.get_samples()`: `USGSpcode`, `Location_HUCEightDigitCode`, `Location_HUCTwelveDigitCode`, `SampleCollectionMethod_Identifier` (`get_samples_summary()` shares the parse; no column in its current profile was affected). `nwis.get_info()`, `nwis.what_sites()`, and `nwis.get_record(service="site")`: `huc_cd`, `state_cd`, `county_cd`, `district_cd`. A comparison against a number — `df["USGSpcode"] == 60` — or a merge onto a numeric key now matches nothing instead of raising, so compare against the padded string (`== "00060"`) or call `.astype(int)` where the number is what you want. **Behavior change:** a count whose name reads as an identifier is numeric again. WQP's `AlternateLocation_IdentifierCount` has been read as text since 05/31/2026 because "Identifier" appears in its name; a name ending in `count` is now excluded from the rule, so the same column has one dtype in every service that reports it. Measurement columns are unchanged, and the `waterdata` OGC getters and `ngwmn` were never affected: their JSON responses deliver codes as strings and numeric coercion there is limited to a fixed list of measurement columns. **Correction to the 1.2.0 notes:** the same fix was applied to the nine `wqp` getters on 05/31/2026 and never recorded here — `wqp.get_results()` and the `what_*` getters have returned HUCs, parameter codes, and FIPS codes as strings since that release.

**09/01/2026:** **Announcement:** We at USGS Water Data for the Nation want your feedback! Tell us how we're doing by taking our quick [survey](https://usgswaterresources.gov1.qualtrics.com/jfe/form/SV_07gX8G1DeOtVrH8), available through September 2026.
Expand Down
100 changes: 74 additions & 26 deletions dataretrieval/_configuration_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import math
import os
import re
import stat
import sys
import warnings
Expand All @@ -26,11 +27,16 @@
from dataretrieval.exceptions import ConfigurationError

#: Settings only an adapter can hold, because they name one service. No
#: package-wide value could mean anything for them: there is no one base URL.
#: package-wide value could mean anything for them: there is no one base URL,
#: and a version is a segment of one service's paths.
#:
#: The package-wide roster is :data:`SETTINGS`, declared below the class it is
#: derived from.
ADAPTER_ONLY_SETTINGS: tuple[str, ...] = ("base_url",)
ADAPTER_ONLY_SETTINGS: tuple[str, ...] = ("base_url", "api_version")

#: The adapter-only settings the file also refuses, so only a ``configure()``
#: block can supply them (ADR 0011).
BLOCK_ONLY_SETTINGS: tuple[str, ...] = ("base_url",)

#: Environment variable backing a setting (precedence step 2).
#:
Expand All @@ -52,12 +58,9 @@

#: Variables the environment is *refused* for, by setting. Named rather than left out of
#: :data:`ENV_VARS`, so a caller who exports ``API_USGS_BASE_URL`` gets an error instead
#: of an ignored variable. The file refuses the same key in the same words
#: (:func:`_accepted_keys`): a base URL set outside the code could redirect the library
#: to another host without a reader of the script seeing it (ADR 0011).
#:
#: Derived from :data:`ADAPTER_ONLY_SETTINGS` so the file and the environment
#: cannot drift apart on which settings are code-only.
#: of an ignored variable. Derived from :data:`ADAPTER_ONLY_SETTINGS`, because a
#: variable applies to every adapter and those settings name one. The file
#: refuses only :data:`BLOCK_ONLY_SETTINGS` (ADR 0011).
_REFUSED_ENV_VARS: dict[str, str] = {
name: f"API_USGS_{name.upper()}" for name in ADAPTER_ONLY_SETTINGS
}
Expand Down Expand Up @@ -393,9 +396,7 @@ def _provenance(self) -> str:
# Plain mixins rather than ``BaseConfiguration`` subclasses: a group has no
# adapter and cannot be passed to :func:`configure`, so keeping it off that
# branch leaves one linear base for the behavior. Frozen because a dataclass
# may not mix frozen and non-frozen bases; fields collect in reverse MRO order,
# so an adapter composing all four reads ``retries, stall_timeout, base_url,
# concurrency, parallel_chunks``.
# may not mix frozen and non-frozen bases. Fields collect in reverse MRO order.


@dataclass(frozen=True)
Expand All @@ -413,6 +414,13 @@ class _Redirectable:
base_url: str | None = _UNSET


@dataclass(frozen=True)
class _Versioned:
"""An adapter whose service publishes its API under a version path segment."""

api_version: str | None = _UNSET


@dataclass(frozen=True)
class _Concurrent:
"""An adapter that issues more than one request per call."""
Expand Down Expand Up @@ -771,6 +779,7 @@ def _coerce_count(value: object, label: str, optional: str) -> str:
#: so the wider check cannot change a TOML outcome.)
_TYPES: dict[str, Callable[[object, str, str], str]] = {
"api_key": _coerce_string,
"api_version": _coerce_string,
"base_url": _coerce_string,
"progress": _coerce_progress,
"concurrency": _coerce_concurrency,
Expand Down Expand Up @@ -899,6 +908,26 @@ def _parse_base_url(raw: str, label: str) -> str:
return value


#: The one shape a version takes in every Water Data path: ``v`` and digits.
_API_VERSION_RE = re.compile(r"^v\d+$")


def _parse_api_version(raw: str, label: str) -> str:
"""Parse an API version: the segment the service publishes it under, ``v1``.

Checked as a shape, not against a list. This module cannot know which
versions a service has published, and a closed list here would refuse a
version the service already serves until a release of this package named it.
"""
value = raw.strip()
if not _API_VERSION_RE.match(value):
raise ConfigurationError(
f"{label} must be the version segment of the service's path, "
f"such as 'v1' (got {raw!r})."
)
return value


def _parse_progress(raw: str, label: str, *, strict: bool) -> bool:
"""Parse a progress toggle, optionally preserving legacy env truthiness."""
value = raw.strip().lower()
Expand Down Expand Up @@ -931,6 +960,7 @@ def _parse_progress(raw: str, label: str, *, strict: bool) -> bool:
"parallel_chunks": _parse_parallel_chunks,
"stall_timeout": _parse_seconds,
"base_url": _parse_base_url,
"api_version": _parse_api_version,
}


Expand Down Expand Up @@ -1048,11 +1078,13 @@ def _adapter_file_settings(

where = f"[{adapter}]"
# An adapter this process has not imported declares no vocabulary, so its
# table is checked against the package-wide settings alone: refusing a key
# for want of a schema would make the file's validity depend on which
# optional extras happened to be installed.
# table is checked against every setting this release has a grammar for:
# refusing a key for want of a schema would make the file's validity depend
# on which optional extras happened to be installed.
accepted = settings_for(adapter)
validated = _scalars(table, path, where, SETTINGS if accepted is None else accepted)
validated = _scalars(
table, path, where, _ALL_SETTINGS if accepted is None else accepted
)
label = f"{path} {where}"
result: Mapping[str, tuple[str, str]] = MappingProxyType(
{name: (value, label) for name, value in validated.items()}
Expand Down Expand Up @@ -1230,7 +1262,7 @@ def _accepted_keys(
# and :func:`_named_profile` refuses a table inside a profile, so a
# sub-table here is always a profile rather than deeper nesting.
continue
if key in ADAPTER_ONLY_SETTINGS:
if key in BLOCK_ONLY_SETTINGS:
# Rejected from the file wherever it appears. A file that
# redirects a data-retrieval library to another host is a
# supply-chain hazard; an in-code block keeps the redirect
Expand All @@ -1240,16 +1272,7 @@ def _accepted_keys(
"configure() block, never from a file."
)
if key not in allowed:
if key in SETTINGS:
# A real setting, in a table that does not read it. Unlike an
# unrecognized name -- which may belong to a newer release --
# this cannot become meaningful later, and ignoring it without an error
# would leave a caller believing they had tuned something. See
# ADR 0010.
raise ConfigurationError(
f"{path}: {key!r} at {where} is not a setting that table "
f"accepts. It accepts: {', '.join(sorted(allowed))}."
)
_reject_known_setting(key, path, where, allowed)
warnings.warn(
f"{path}: unknown setting {key!r} at {where} (ignored). "
f"Known settings: {', '.join(SETTINGS)}.",
Expand All @@ -1261,6 +1284,31 @@ def _accepted_keys(
return out


def _reject_known_setting(
key: str, path: Path, where: str, allowed: frozenset[str] | tuple[str, ...]
) -> None:
"""Raise if *key* is a setting this release knows but that table cannot use.

Returns for an unknown name, which the caller warns about instead: an
unknown name may belong to a newer release, but a known setting in the
wrong table will never take effect, and ignoring it would leave the caller
believing it had (ADR 0010).
"""
if where == _TOP_LEVEL and key in ADAPTER_ONLY_SETTINGS:
# The generic message below would list only top-level settings, none of
# which is the one to write.
raise ConfigurationError(
f"{path}: {key!r} at {where} names one service and has no "
"package-wide value; set it in the table of the adapter it belongs "
"to, such as [waterdata]."
)
if key in _ALL_SETTINGS:
raise ConfigurationError(
f"{path}: {key!r} at {where} is not a setting that table "
f"accepts. It accepts: {', '.join(sorted(allowed))}."
)


def _checked_table(
table: dict[str, Any],
path: Path,
Expand Down
4 changes: 4 additions & 0 deletions dataretrieval/_deprecation.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"waterdata.get_cql(service=)": "2027-08-09",
"wateruse": "2027-08-11",
"ogc.interruptions": "2027-08-25",
# Set by the service, not by this package: v0 of the collection serves these
# filters until June 2027, and the shim cannot outlive the endpoint it sends
# to (https://waterdata.usgs.gov/blog/api-v1-release/).
"waterdata.get_time_series_metadata(v0 filters)": "2027-06-01",
}


Expand Down
66 changes: 58 additions & 8 deletions dataretrieval/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
# isort: off
from dataretrieval._configuration_core import (
ADAPTERS as ADAPTERS,
BLOCK_ONLY_SETTINGS as BLOCK_ONLY_SETTINGS,
CONFIG_PATH_ENV as CONFIG_PATH_ENV,
CONCURRENCY_UNBOUNDED as CONCURRENCY_UNBOUNDED,
DEFAULT_CONCURRENCY as DEFAULT_CONCURRENCY,
Expand All @@ -94,6 +95,7 @@
_Frame as _Frame,
_named_profiles as _named_profiles,
_NO_FILE as _NO_FILE,
_parse_api_version as _parse_api_version,
_parse_base_url as _parse_base_url,
_parse_concurrency as _parse_concurrency,
_parse_parallel_chunks as _parse_parallel_chunks,
Expand All @@ -112,6 +114,7 @@
_SettingValue as _SettingValue,
_UNSET as _UNSET,
_validated_raw as _validated_raw,
_Versioned as _Versioned,
config_path as config_path,
settings_for as settings_for,
)
Expand Down Expand Up @@ -655,6 +658,42 @@ def base_url(*, adapter: str | None = None, default: str | None = None) -> str |
return _parse_base_url(raw, label)


@overload
def api_version(*, adapter: str | None = ...) -> str | None: ...


@overload
def api_version(*, adapter: str | None = ..., default: str) -> str: ...


def api_version(
*, adapter: str | None = None, default: str | None = None
) -> str | None:
"""An adapter's configured API version, falling back to *default*.

Settable from code or from the adapter's table in the file. The
environment refuses it (:data:`_REFUSED_ENV_VARS`), as it refuses every
adapter-only setting: a variable is package-wide, and a version belongs to
one service.

Like :func:`base_url`, it has no package-wide default. The adapter passes
its own, as in ``api_version(adapter="waterdata", default=OGC_API_VERSION)``,
so the version is declared in the module that builds the path.

Parameters
----------
adapter : str, optional
Whose version to resolve.
default : str, optional
Returned when nothing configured a version. If omitted, ``None`` is
returned; :func:`show_configuration` relies on this.
"""
raw, label, _source = _resolve("api_version", adapter)
if raw is None:
return default
return _parse_api_version(raw, label)


# --- resolution ----------------------------------------------------------

#: Which source of the chain supplied a resolution. Machine-readable so a
Expand Down Expand Up @@ -729,16 +768,26 @@ def _check_env_not_refused(name: str) -> None:
Refused before anything is consulted, not when the chain reaches the environment
source. The file and the environment refuse ``base_url`` as one rule (ADR 0011), so
a variable that cannot work is not outranked, with no error, by a block that happens
to work.
to work. ``api_version`` is refused from the environment only; the message
points to the file's adapter table as well as the block.
"""
refused = _REFUSED_ENV_VARS.get(name)
if refused is not None and refused in os.environ:
raise ConfigurationError(
f"{_env_label(refused)} is set, but {name!r} may only be set "
"in code, in a configure() block, never from the environment. Unset "
f"it and pass the value on the adapter's configuration, e.g. "
f"WaterdataConfiguration({name}=...)."
)
if refused is None or refused not in os.environ:
return
block_only = name in BLOCK_ONLY_SETTINGS
fault = (
"may only be set in code, in a configure() block, never from the environment"
if block_only
else "names one service and has no package-wide value, so the environment "
"cannot set it"
)
# Suggest the file only for the settings it accepts.
or_the_file = "" if block_only else ", or in that adapter's table of the file"
raise ConfigurationError(
f"{_env_label(refused)} is set, but {name!r} {fault}. Unset it and pass "
f"the value on the adapter's configuration, e.g. "
f"WaterdataConfiguration({name}=...){or_the_file}."
)


def _resolve_from_block(
Expand Down Expand Up @@ -837,6 +886,7 @@ def _display_progress(_adapter: str | None = None) -> str:
"parallel_chunks": lambda adapter: str(parallel_chunks(adapter=adapter)),
"stall_timeout": lambda adapter: f"{stall_timeout(adapter=adapter):g}s",
"base_url": lambda adapter: base_url(adapter=adapter) or "<service default>",
"api_version": lambda adapter: api_version(adapter=adapter) or "<service default>",
}

if set(_DISPLAYS) != set(_ALL_SETTINGS): # pragma: no cover - guards a coding error
Expand Down
28 changes: 19 additions & 9 deletions dataretrieval/waterdata/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@
_Redirectable,
_register,
_Retrying,
_Versioned,
)

__all__ = ["WaterdataConfiguration"]


@dataclass(frozen=True)
class WaterdataConfiguration(
_Chunked, _Concurrent, _Redirectable, _Retrying, BaseConfiguration
_Chunked, _Concurrent, _Redirectable, _Retrying, _Versioned, BaseConfiguration
):
"""Settings for Water Data calls alone.

Expand All @@ -45,9 +46,18 @@ class WaterdataConfiguration(
base_url : str, optional
Root to send Water Data requests to, instead of the service's own. The package
appends its own paths, so one value redirects all four families together --
``/ogcapi/v0``, ``/samples-data``, ``/statistics/v0`` and ``/stac/v0``. Code
only: the file and the environment refuse it. The API key is scoped to the host
that accepts it, so a redirected call sends no key.
``/ogcapi/<api_version>``, ``/samples-data``, ``/statistics/v0`` and
``/stac/v0``. Code only: the file and the environment refuse it. The API key is
scoped to the host that accepts it, so a redirected call sends no key.
api_version : str, optional
Version of the Water Data API to request, as the segment of its path:
``"v1"``, which this release is written against, or ``"v0"`` while the
service keeps it online (until June 2027). It replaces that one segment,
so the Samples, Statistics and STAC families -- versioned separately, with
no v1 -- are unaffected. Settable in code or in the ``[waterdata]`` table
of the file, never from the environment. A version the response shaping
was not written for returns that version's columns as the service sends
them.
concurrency : int or str, optional
Cap on simultaneous sub-requests, or ``"unbounded"``.
parallel_chunks : int, optional
Expand All @@ -56,11 +66,11 @@ class WaterdataConfiguration(
"""

# The settings this service reads, named by the groups they come from:
# every adapter's retry settings, a redirectable base, and -- because Water
# Data queries divide along a URL byte budget and are executed concurrently
# -- both fan-out settings. Each group declares the setting itself once, in
# :mod:`dataretrieval.configuration`, which also defines its grammar and
# its coercion.
# every adapter's retry settings, a redirectable base, a versioned API, and
# -- because Water Data queries divide along a URL byte budget and are
# executed concurrently -- both fan-out settings. Each group declares the
# setting itself once, in :mod:`dataretrieval.configuration`, which also
# defines its grammar and its coercion.
adapter: ClassVar[str] = "waterdata"


Expand Down
Loading
Loading