diff --git a/NEWS.md b/NEWS.md index 2b988d4b..2faaeb42 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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`. diff --git a/dataretrieval/nwis.py b/dataretrieval/nwis.py index ec8d2537..3b13d1b1 100644 --- a/dataretrieval/nwis.py +++ b/dataretrieval/nwis.py @@ -6,6 +6,8 @@ from __future__ import annotations +import functools +import threading import warnings from json import JSONDecodeError @@ -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()`", + "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()`", + "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: @@ -163,6 +220,7 @@ def get_discharge_measurements(**kwargs): ) +@_deprecated def get_discharge_peaks( sites: list[str] | str | None = None, start: str | None = None, @@ -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]: @@ -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: @@ -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: @@ -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, @@ -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. @@ -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, @@ -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", @@ -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. @@ -767,7 +833,6 @@ 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) @@ -775,6 +840,7 @@ def what_sites(ssl_check: bool = True, **kwargs) -> tuple[pd.DataFrame, BaseMeta return df, NWIS_Metadata(response, **kwargs) +@_deprecated def get_record( sites: list[str] | str | None = None, start: str | None = None, diff --git a/tests/nwis_test.py b/tests/nwis_test.py index a42ba509..a3b23da6 100644 --- a/tests/nwis_test.py +++ b/tests/nwis_test.py @@ -1,5 +1,6 @@ import datetime import json +import warnings from pathlib import Path from unittest import mock @@ -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."""