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 @@
**05/06/2026:** Each remaining active function in `dataretrieval.nwis` now emits a per-function `DeprecationWarning` naming the `waterdata` replacement to migrate to (visible the first time users call each getter). The `nwis` module is scheduled for removal on or after **2027-05-06**.

**05/06/2026:** Added `waterdata.get_ratings(...)` — wraps the new Water Data STAC catalog (`api.waterdata.usgs.gov/stac/v0/search`) for USGS stage-discharge rating curves. Returns parsed `exsa` / `base` / `corr` rating tables as a dict of DataFrames keyed by feature ID, or just the list of available STAC features when `download_and_parse=False`. Mirrors R's `read_waterdata_ratings`.

**05/06/2026:** Added `waterdata.get_field_measurements_metadata(...)` — wraps the OGC `field-measurements-metadata` collection. Returns one row per (location, parameter) field-measurement series describing its period of record, units, etc., without the underlying observations. Discrete-measurement analogue to `get_time_series_metadata`. Mirrors R's `read_waterdata_field_meta`.
Expand Down
68 changes: 67 additions & 1 deletion dataretrieval/nwis.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from __future__ import annotations

import functools
import threading
import warnings
from json import JSONDecodeError

Expand Down Expand Up @@ -53,6 +55,61 @@
}


_NWIS_REMOVAL_DATE = "2027-05-06"
_REPLACEMENTS = {
"get_dv": "`waterdata.get_daily()`",
"get_iv": "`waterdata.get_continuous()`",
"get_info": "`waterdata.get_monitoring_locations()`",
Comment thread
thodson-usgs marked this conversation as resolved.
"what_sites": "`waterdata.get_monitoring_locations()`",
"get_stats": "`waterdata.get_stats_por()` or `waterdata.get_stats_date_range()`",
"get_discharge_peaks": "`waterdata.get_peaks()`",
"get_ratings": "`waterdata.get_ratings()`",
Comment thread
thodson-usgs marked this conversation as resolved.
"get_record": "the appropriate `waterdata.get_*()` for the service you need",
"query_waterdata": "a high-level `waterdata.get_*()` helper",
"query_waterservices": "a high-level `waterdata.get_*()` helper",
}

_deprecation_state = threading.local()


def _warn_deprecated(func_name: str) -> None:
"""Emit a per-function DeprecationWarning pointing at the waterdata replacement."""
warnings.warn(
f"`nwis.{func_name}` is deprecated and will be removed from "
f"`dataretrieval` on or after {_NWIS_REMOVAL_DATE}; "
f"use {_REPLACEMENTS[func_name]} instead.",
DeprecationWarning,
stacklevel=3,
)


def _deprecated(func):
"""Mark an nwis function as deprecated.

Wrappers like ``get_record`` -> ``get_iv`` -> ``query_waterservices`` would
otherwise emit one warning per layer; the thread-local sentinel ensures the
user sees only the outermost call's warning.
"""
if func.__name__ not in _REPLACEMENTS:
raise RuntimeError(
f"_REPLACEMENTS missing entry for {func.__name__!r}; "
"add a `waterdata` replacement before applying @_deprecated."
)

@functools.wraps(func)
def wrapper(*args, **kwargs):
if getattr(_deprecation_state, "active", False):
return func(*args, **kwargs)
_deprecation_state.active = True
try:
_warn_deprecated(func.__name__)
return func(*args, **kwargs)
finally:
_deprecation_state.active = False

return wrapper


def _parse_json_or_raise(response: requests.Response) -> pd.DataFrame:
"""Parse a JSON NWIS response, raising a helpful error on HTML responses."""
try:
Expand Down Expand Up @@ -163,6 +220,7 @@ def get_discharge_measurements(**kwargs):
)


@_deprecated
def get_discharge_peaks(
sites: list[str] | str | None = None,
start: str | None = None,
Expand Down Expand Up @@ -240,6 +298,7 @@ def get_gwlevels(**kwargs):
)


@_deprecated
def get_stats(
sites: list[str] | str | None = None, ssl_check: bool = True, **kwargs
) -> tuple[pd.DataFrame, BaseMetadata]:
Expand Down Expand Up @@ -301,6 +360,7 @@ def get_stats(
return _read_rdb(response.text), NWIS_Metadata(response, **kwargs)


@_deprecated
def query_waterdata(
service: str, ssl_check: bool = True, **kwargs
) -> requests.models.Response:
Expand Down Expand Up @@ -346,6 +406,7 @@ def query_waterdata(
return query(url, payload=kwargs, ssl_check=ssl_check)


@_deprecated
def query_waterservices(
service: str, ssl_check: bool = True, **kwargs
) -> requests.models.Response:
Expand Down Expand Up @@ -409,6 +470,7 @@ def query_waterservices(
return query(url, payload=kwargs, ssl_check=ssl_check)


@_deprecated
def get_dv(
sites: list[str] | str | None = None,
start: str | None = None,
Expand Down Expand Up @@ -480,6 +542,7 @@ def get_dv(
return format_response(df, **kwargs), NWIS_Metadata(response, **kwargs)


@_deprecated
def get_info(ssl_check: bool = True, **kwargs) -> tuple[pd.DataFrame, BaseMetadata]:
"""
Get site description information from NWIS.
Expand Down Expand Up @@ -595,6 +658,7 @@ def get_info(ssl_check: bool = True, **kwargs) -> tuple[pd.DataFrame, BaseMetada
return _read_rdb(response.text), NWIS_Metadata(response, **kwargs)


@_deprecated
def get_iv(
sites: list[str] | str | None = None,
start: str | None = None,
Expand Down Expand Up @@ -678,6 +742,7 @@ def get_water_use(**kwargs):
raise NameError("`nwis.get_water_use` is defunct.")


@_deprecated
def get_ratings(
site: str | None = None,
file_type: str = "base",
Expand Down Expand Up @@ -735,6 +800,7 @@ def get_ratings(
return _read_rdb(response.text), NWIS_Metadata(response, site_no=site)


@_deprecated
def what_sites(ssl_check: bool = True, **kwargs) -> tuple[pd.DataFrame, BaseMetadata]:
"""
Search NWIS for sites within a region with specific data.
Expand Down Expand Up @@ -767,14 +833,14 @@ def what_sites(ssl_check: bool = True, **kwargs) -> tuple[pd.DataFrame, BaseMeta
... )

"""

response = query_waterservices(service="site", ssl_check=ssl_check, **kwargs)

df = _read_rdb(response.text)

return df, NWIS_Metadata(response, **kwargs)


@_deprecated
def get_record(
sites: list[str] | str | None = None,
start: str | None = None,
Expand Down
88 changes: 88 additions & 0 deletions tests/nwis_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import json
import warnings
from pathlib import Path
from unittest import mock

Expand Down Expand Up @@ -118,6 +119,93 @@ def test_preformat_peaks_response():
# Removed defunct gwlevels tests.


class TestDeprecationWarnings:
"""Verify per-function DeprecationWarning fires with the right replacement.

The module-level "use waterdata instead" warning fires on import; these
tests pin the function-specific replacements so users see actionable
migration guidance the first time they call each NWIS getter.
"""

@pytest.mark.parametrize(
"func_name, replacement_substring",
[
("get_dv", "waterdata.get_daily"),
("get_iv", "waterdata.get_continuous"),
("get_info", "waterdata.get_monitoring_locations"),
("what_sites", "waterdata.get_monitoring_locations"),
("get_stats", "waterdata.get_stats_por"),
("get_discharge_peaks", "waterdata.get_peaks"),
("get_ratings", "waterdata.get_ratings"),
("get_record", "waterdata.get_*"),
("query_waterdata", "waterdata.get_*"),
("query_waterservices", "waterdata.get_*"),
],
)
def test_warn_message_includes_replacement(self, func_name, replacement_substring):
"""Each deprecated function emits a warning naming the right replacement."""
from dataretrieval.nwis import _NWIS_REMOVAL_DATE, _warn_deprecated

with pytest.warns(DeprecationWarning, match=func_name) as record:
_warn_deprecated(func_name)
message = str(record[0].message)
assert replacement_substring in message
assert _NWIS_REMOVAL_DATE in message

def test_get_iv_fires_deprecation_on_call(self, requests_mock):
"""End-to-end: a real call routes through _warn_deprecated."""
requests_mock.get(
"https://waterservices.usgs.gov/nwis/iv",
json={"value": {"timeSeries": []}},
)
with pytest.warns(DeprecationWarning, match="get_iv.*waterdata.get_continuous"):
get_iv(sites="01491000")

def test_nested_calls_emit_one_warning(self, requests_mock):
"""get_record(service='iv') wraps get_iv -> query_waterservices.

Without re-entrancy suppression the user would see 3 near-identical
deprecation warnings for one call; pin the outermost-only contract.
"""
requests_mock.get(
"https://waterservices.usgs.gov/nwis/iv",
json={"value": {"timeSeries": []}},
)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always", DeprecationWarning)
get_record(sites="01491000", service="iv")
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
assert len(deprecations) == 1
assert "get_record" in str(deprecations[0].message)

@pytest.mark.parametrize(
"name",
[
"get_daily",
"get_continuous",
"get_monitoring_locations",
"get_stats_por",
"get_stats_date_range",
"get_peaks",
"get_ratings",
],
)
def test_named_replacement_exists_in_waterdata(self, name):
"""Tripwire: every concrete `waterdata.*` named in a deprecation message
must actually exist, so a user following the migration guidance doesn't
hit AttributeError.

Fails loudly if this PR ever lands before its referenced replacement
does (e.g. before `get_peaks` from #267).
"""
import dataretrieval.waterdata as wd

assert callable(getattr(wd, name, None)), (
f"`waterdata.{name}` is missing — fix `_REPLACEMENTS` in nwis.py "
"or add the replacement before merging."
)


class TestDefunct:
"""Verify that defunct functions raise NameError."""

Expand Down
Loading