From 6510ff02df5da88ee92d9dfb1b9be7f99f4de794 Mon Sep 17 00:00:00 2001 From: thodson-usgs Date: Wed, 6 May 2026 18:48:56 -0500 Subject: [PATCH 1/4] Deprecate the remaining active nwis functions ahead of 2027-05-06 removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module-level "use waterdata instead" warning has been firing on import for a while; this PR makes the migration guidance actionable by emitting a per-function DeprecationWarning that names the specific waterdata replacement the user should switch to. Once peaks (#267) and ratings (#269) land, every active nwis function has a waterdata replacement, so all nine of them are deprecated here: nwis.get_dv -> waterdata.get_daily() nwis.get_iv -> waterdata.get_continuous() nwis.get_info -> waterdata.get_monitoring_locations() nwis.what_sites -> waterdata.get_monitoring_locations() nwis.get_stats -> waterdata.get_stats_por() / waterdata.get_stats_date_range() nwis.get_discharge_peaks -> waterdata.get_peaks() nwis.get_ratings -> waterdata.get_ratings() nwis.get_record -> the appropriate waterdata.get_*() nwis.query_waterdata -> a high-level waterdata.get_*() helper nwis.query_waterservices -> a high-level waterdata.get_*() helper (get_qwdata, get_discharge_measurements, get_gwlevels, get_pmcodes, and get_water_use are already defunct and raise NameError.) Implementation follows the nadp deprecation template (#243): a small _REPLACEMENTS dict + a _warn_deprecated(func_name) helper called as the first line of each public function. stacklevel=3 makes the warning point at the caller's code, not the helper's frame. 11 new parametrized tests pin the warning text — that the function name appears, the replacement helper appears, and the removal date appears — plus one end-to-end test that get_iv() actually fires its warning when called. Removal date is set to 2027-05-06, one full year out (vs. the six months used for nadp), since nwis is much more widely used and most users will need migration time. Maintainer can adjust if desired. This depends on #267 (waterdata.get_peaks) and #269 (waterdata.get_ratings) being merged: until then the deprecation messages for get_discharge_peaks and get_ratings point at functions users can't yet call. Hold this PR draft until those land. Co-Authored-By: Claude Opus 4.7 (1M context) --- NEWS.md | 2 ++ dataretrieval/nwis.py | 39 +++++++++++++++++++++++++++++++++++ tests/nwis_test.py | 47 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/NEWS.md b/NEWS.md index 2b988d4b..69f9dc6b 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` that names its `waterdata` replacement (`get_dv` → `get_daily`, `get_iv` → `get_continuous`, `get_info` / `what_sites` → `get_monitoring_locations`, `get_stats` → `get_stats_por` / `get_stats_date_range`, `get_discharge_peaks` → `get_peaks`, `get_ratings` → `get_ratings`, `get_record` / `query_waterdata` / `query_waterservices` → the appropriate `waterdata.get_*()` helper). 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..78c4212c 100644 --- a/dataretrieval/nwis.py +++ b/dataretrieval/nwis.py @@ -53,6 +53,35 @@ } +# Per-function deprecation. The module-level warning above tells users that +# `nwis` overall is being phased out; these replacements tell them which +# `waterdata` function to migrate each call to. Scheduled removal: 2027-05-06. +_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", +} + + +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 _parse_json_or_raise(response: requests.Response) -> pd.DataFrame: """Parse a JSON NWIS response, raising a helpful error on HTML responses.""" try: @@ -216,6 +245,7 @@ def get_discharge_peaks( ... ) """ + _warn_deprecated("get_discharge_peaks") _check_sites_value_types(sites) kwargs["site_no"] = kwargs.pop("site_no", sites) @@ -292,6 +322,7 @@ def get_stats( ... ) """ + _warn_deprecated("get_stats") _check_sites_value_types(sites) response = query_waterservices( @@ -322,6 +353,7 @@ def query_waterdata( request: ``requests.models.Response`` The response object from the API request to the web service """ + _warn_deprecated("query_waterdata") major_params = ["site_no", "state_cd"] bbox_params = [ "nw_longitude_va", @@ -391,6 +423,7 @@ def query_waterservices( The response object from the API request to the web service """ + _warn_deprecated("query_waterservices") if not any( key in kwargs for key in ["sites", "stateCd", "bBox", "huc", "countyCd"] ): @@ -467,6 +500,7 @@ def get_dv( >>> df, md = dataretrieval.nwis.get_dv(sites="01646500") """ + _warn_deprecated("get_dv") _check_sites_value_types(sites) kwargs["startDT"] = kwargs.pop("startDT", start) @@ -572,6 +606,7 @@ def get_info(ssl_check: bool = True, **kwargs) -> tuple[pd.DataFrame, BaseMetada >>> df, md = dataretrieval.nwis.get_info(sites=["05114000", "09423350"]) """ + _warn_deprecated("get_info") seriesCatalogOutput = kwargs.pop("seriesCatalogOutput", None) if seriesCatalogOutput in ["True", "TRUE", "true", True]: warnings.warn( @@ -650,6 +685,7 @@ def get_iv( ... ) """ + _warn_deprecated("get_iv") _check_sites_value_types(sites) kwargs["startDT"] = kwargs.pop("startDT", start) @@ -719,6 +755,7 @@ def get_ratings( >>> df, md = dataretrieval.nwis.get_ratings(site="01594440") """ + _warn_deprecated("get_ratings") site = kwargs.pop("site_no", site) payload = {} @@ -767,6 +804,7 @@ def what_sites(ssl_check: bool = True, **kwargs) -> tuple[pd.DataFrame, BaseMeta ... ) """ + _warn_deprecated("what_sites") response = query_waterservices(service="site", ssl_check=ssl_check, **kwargs) @@ -863,6 +901,7 @@ def get_record( ... ) """ + _warn_deprecated("get_record") _check_sites_value_types(sites) defunct_replacements = { diff --git a/tests/nwis_test.py b/tests/nwis_test.py index a42ba509..ef38b756 100644 --- a/tests/nwis_test.py +++ b/tests/nwis_test.py @@ -118,6 +118,53 @@ 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, requests_mock + ): + """Each deprecated function emits a warning naming the right replacement.""" + from dataretrieval.nwis import _warn_deprecated + + # Test the helper directly so we don't need to spin up a fake response + # for every function. The integration is checked once below. + with pytest.warns(DeprecationWarning, match=func_name) as record: + _warn_deprecated(func_name) + message = str(record[0].message) + assert replacement_substring in message + assert "2027-05-06" 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") + + class TestDefunct: """Verify that defunct functions raise NameError.""" From c00ab778a67b9dcb02a5ed11c3d21a7debb1677f Mon Sep 17 00:00:00 2001 From: thodson-usgs Date: Wed, 6 May 2026 18:57:04 -0500 Subject: [PATCH 2/4] Suppress nested deprecation warnings; pin removal date via constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_record(service='iv')` calls `get_iv`, which calls `query_waterservices` — each of which now emits a DeprecationWarning. Without suppression the user sees three near-identical messages for one call. Walk the call stack inside `_warn_deprecated`: if any ancestor frame is also a deprecated nwis function, skip. Only the outermost call surfaces a warning, regardless of how the wrappers are layered. Add a regression test that pins this contract. Loosen `test_warn_message_includes_replacement` to assert against the `_NWIS_REMOVAL_DATE` constant rather than the literal "2027-05-06" so a future date change updates one place, not two. Co-Authored-By: Claude Opus 4.7 (1M context) --- dataretrieval/nwis.py | 15 ++++++++++++++- tests/nwis_test.py | 22 ++++++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/dataretrieval/nwis.py b/dataretrieval/nwis.py index 78c4212c..9d8af9f3 100644 --- a/dataretrieval/nwis.py +++ b/dataretrieval/nwis.py @@ -6,6 +6,7 @@ from __future__ import annotations +import sys import warnings from json import JSONDecodeError @@ -72,7 +73,19 @@ def _warn_deprecated(func_name: str) -> None: - """Emit a per-function DeprecationWarning pointing at the waterdata replacement.""" + """Emit a per-function DeprecationWarning pointing at the waterdata replacement. + + Suppresses the warning when invoked from another deprecated nwis function so + that wrappers like ``get_record`` -> ``get_iv`` -> ``query_waterservices`` + surface only the outermost call (otherwise one user call produces three + near-identical messages). + """ + module_globals = globals() + frame = sys._getframe(2) if hasattr(sys, "_getframe") else None + while frame is not None: + if frame.f_globals is module_globals and frame.f_code.co_name in _REPLACEMENTS: + return + frame = frame.f_back warnings.warn( f"`nwis.{func_name}` is deprecated and will be removed from " f"`dataretrieval` on or after {_NWIS_REMOVAL_DATE}; " diff --git a/tests/nwis_test.py b/tests/nwis_test.py index ef38b756..b103ac0c 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 @@ -145,7 +146,7 @@ def test_warn_message_includes_replacement( self, func_name, replacement_substring, requests_mock ): """Each deprecated function emits a warning naming the right replacement.""" - from dataretrieval.nwis import _warn_deprecated + from dataretrieval.nwis import _NWIS_REMOVAL_DATE, _warn_deprecated # Test the helper directly so we don't need to spin up a fake response # for every function. The integration is checked once below. @@ -153,7 +154,7 @@ def test_warn_message_includes_replacement( _warn_deprecated(func_name) message = str(record[0].message) assert replacement_substring in message - assert "2027-05-06" 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.""" @@ -164,6 +165,23 @@ def test_get_iv_fires_deprecation_on_call(self, requests_mock): 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) + class TestDefunct: """Verify that defunct functions raise NameError.""" From 5ca35c670bc0e6102158f58e23dc225ff14c619b Mon Sep 17 00:00:00 2001 From: thodson-usgs Date: Wed, 6 May 2026 19:05:22 -0500 Subject: [PATCH 3/4] Switch to @_deprecated decorator; drop sys._getframe walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces nine `_warn_deprecated("name")` first-line calls with `@_deprecated` above the function definition. The decorator does three things: 1. Validates `func.__name__ in _REPLACEMENTS` at import time, so a missing mapping fails loudly instead of producing a KeyError on the first user call. 2. Drops the stringly-typed `_warn_deprecated("get_iv")` call sites — the name now flows from `func.__name__`, so a typo can't drift from the real symbol. 3. Replaces the `sys._getframe` stack walk with a thread-local re-entrancy flag. Because the decorator wraps the whole function call (not just the warn line), the flag's lifetime spans nested wrapper invocations correctly. No CPython implementation detail required. Tighten the NEWS.md entry and drop the unused `requests_mock` fixture from `test_warn_message_includes_replacement` (it never made an HTTP request). Co-Authored-By: Claude Opus 4.7 (1M context) --- NEWS.md | 2 +- dataretrieval/nwis.py | 70 ++++++++++++++++++++++++++----------------- tests/nwis_test.py | 6 +--- 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/NEWS.md b/NEWS.md index 69f9dc6b..2faaeb42 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,4 +1,4 @@ -**05/06/2026:** Each remaining active function in `dataretrieval.nwis` now emits a per-function `DeprecationWarning` that names its `waterdata` replacement (`get_dv` → `get_daily`, `get_iv` → `get_continuous`, `get_info` / `what_sites` → `get_monitoring_locations`, `get_stats` → `get_stats_por` / `get_stats_date_range`, `get_discharge_peaks` → `get_peaks`, `get_ratings` → `get_ratings`, `get_record` / `query_waterdata` / `query_waterservices` → the appropriate `waterdata.get_*()` helper). The `nwis` module is scheduled for removal on or after **2027-05-06**. +**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`. diff --git a/dataretrieval/nwis.py b/dataretrieval/nwis.py index 9d8af9f3..3b13d1b1 100644 --- a/dataretrieval/nwis.py +++ b/dataretrieval/nwis.py @@ -6,7 +6,8 @@ from __future__ import annotations -import sys +import functools +import threading import warnings from json import JSONDecodeError @@ -54,9 +55,6 @@ } -# Per-function deprecation. The module-level warning above tells users that -# `nwis` overall is being phased out; these replacements tell them which -# `waterdata` function to migrate each call to. Scheduled removal: 2027-05-06. _NWIS_REMOVAL_DATE = "2027-05-06" _REPLACEMENTS = { "get_dv": "`waterdata.get_daily()`", @@ -71,21 +69,11 @@ "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. - Suppresses the warning when invoked from another deprecated nwis function so - that wrappers like ``get_record`` -> ``get_iv`` -> ``query_waterservices`` - surface only the outermost call (otherwise one user call produces three - near-identical messages). - """ - module_globals = globals() - frame = sys._getframe(2) if hasattr(sys, "_getframe") else None - while frame is not None: - if frame.f_globals is module_globals and frame.f_code.co_name in _REPLACEMENTS: - return - frame = frame.f_back +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}; " @@ -95,6 +83,33 @@ def _warn_deprecated(func_name: str) -> None: ) +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: @@ -205,6 +220,7 @@ def get_discharge_measurements(**kwargs): ) +@_deprecated def get_discharge_peaks( sites: list[str] | str | None = None, start: str | None = None, @@ -258,7 +274,6 @@ def get_discharge_peaks( ... ) """ - _warn_deprecated("get_discharge_peaks") _check_sites_value_types(sites) kwargs["site_no"] = kwargs.pop("site_no", sites) @@ -283,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]: @@ -335,7 +351,6 @@ def get_stats( ... ) """ - _warn_deprecated("get_stats") _check_sites_value_types(sites) response = query_waterservices( @@ -345,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: @@ -366,7 +382,6 @@ def query_waterdata( request: ``requests.models.Response`` The response object from the API request to the web service """ - _warn_deprecated("query_waterdata") major_params = ["site_no", "state_cd"] bbox_params = [ "nw_longitude_va", @@ -391,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: @@ -436,7 +452,6 @@ def query_waterservices( The response object from the API request to the web service """ - _warn_deprecated("query_waterservices") if not any( key in kwargs for key in ["sites", "stateCd", "bBox", "huc", "countyCd"] ): @@ -455,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, @@ -513,7 +529,6 @@ def get_dv( >>> df, md = dataretrieval.nwis.get_dv(sites="01646500") """ - _warn_deprecated("get_dv") _check_sites_value_types(sites) kwargs["startDT"] = kwargs.pop("startDT", start) @@ -527,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. @@ -619,7 +635,6 @@ def get_info(ssl_check: bool = True, **kwargs) -> tuple[pd.DataFrame, BaseMetada >>> df, md = dataretrieval.nwis.get_info(sites=["05114000", "09423350"]) """ - _warn_deprecated("get_info") seriesCatalogOutput = kwargs.pop("seriesCatalogOutput", None) if seriesCatalogOutput in ["True", "TRUE", "true", True]: warnings.warn( @@ -643,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, @@ -698,7 +714,6 @@ def get_iv( ... ) """ - _warn_deprecated("get_iv") _check_sites_value_types(sites) kwargs["startDT"] = kwargs.pop("startDT", start) @@ -727,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", @@ -768,7 +784,6 @@ def get_ratings( >>> df, md = dataretrieval.nwis.get_ratings(site="01594440") """ - _warn_deprecated("get_ratings") site = kwargs.pop("site_no", site) payload = {} @@ -785,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. @@ -817,8 +833,6 @@ def what_sites(ssl_check: bool = True, **kwargs) -> tuple[pd.DataFrame, BaseMeta ... ) """ - _warn_deprecated("what_sites") - response = query_waterservices(service="site", ssl_check=ssl_check, **kwargs) df = _read_rdb(response.text) @@ -826,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, @@ -914,7 +929,6 @@ def get_record( ... ) """ - _warn_deprecated("get_record") _check_sites_value_types(sites) defunct_replacements = { diff --git a/tests/nwis_test.py b/tests/nwis_test.py index b103ac0c..0e276186 100644 --- a/tests/nwis_test.py +++ b/tests/nwis_test.py @@ -142,14 +142,10 @@ class TestDeprecationWarnings: ("query_waterservices", "waterdata.get_*"), ], ) - def test_warn_message_includes_replacement( - self, func_name, replacement_substring, requests_mock - ): + 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 - # Test the helper directly so we don't need to spin up a fake response - # for every function. The integration is checked once below. with pytest.warns(DeprecationWarning, match=func_name) as record: _warn_deprecated(func_name) message = str(record[0].message) From 78374f5291e9191b612504f852d1321fa6d9c198 Mon Sep 17 00:00:00 2001 From: thodson-usgs Date: Wed, 6 May 2026 19:22:44 -0500 Subject: [PATCH 4/4] Add tripwire: every named waterdata replacement must exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on #271: `_REPLACEMENTS` points `get_discharge_peaks` -> `waterdata.get_peaks()` and `get_ratings` -> `waterdata.get_ratings()`. If this PR merged before its dependencies landed, users following the migration guidance would hit AttributeError. Adds a parametrized test that imports `dataretrieval.waterdata` and asserts every concrete callable named in the deprecation messages exists. The test currently fails on `get_peaks` (still on PR #267, not yet on main) — exactly the desired behavior: this PR cannot merge before #267. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/nwis_test.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/nwis_test.py b/tests/nwis_test.py index 0e276186..a3b23da6 100644 --- a/tests/nwis_test.py +++ b/tests/nwis_test.py @@ -178,6 +178,33 @@ def test_nested_calls_emit_one_warning(self, requests_mock): 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."""