diff --git a/dataretrieval/streamstats.py b/dataretrieval/streamstats.py index 9a27f936..6737d54c 100644 --- a/dataretrieval/streamstats.py +++ b/dataretrieval/streamstats.py @@ -60,7 +60,7 @@ def get_sample_watershed(): from the streamstats JSON object. """ - return get_watershed("NY", -74.524, 43.939) + return get_watershed("NY", -74.524, 43.939, format="object") def get_watershed( @@ -135,29 +135,65 @@ def get_watershed( return r if format == "shape": - # use Fiona to return a shape object - pass - - if format == "object": - # return a python object - pass - + # Returning a shapefile/Fiona object isn't implemented; fail + # loudly instead of silently falling through to a Watershed. + raise NotImplementedError( + "format='shape' is not implemented. Use format='geojson' " + "(default) for the raw response, or format='object' for a " + "parsed Watershed." + ) + + # format == "object" (and any other value): parse into a Watershed. data = json.loads(r.text) return Watershed.from_streamstats_json(data) class Watershed: - """Class to extract information from the streamstats JSON object.""" + """Parsed StreamStats watershed result. - @classmethod - def from_streamstats_json(cls, streamstats_json): - """Method that creates a Watershed object from a streamstats JSON.""" - cls.watershed_point = streamstats_json["featurecollection"][0]["feature"] - cls.watershed_polygon = streamstats_json["featurecollection"][1]["feature"] - cls.parameters = streamstats_json["parameters"] - cls._workspaceID = streamstats_json["workspaceID"] - return cls + Holds the delineated watershed features, the computed basin + parameters, and the service ``workspaceID`` extracted from a + StreamStats watershed response. Build one from an already-fetched + payload with :meth:`from_streamstats_json`, or construct directly + from a location to fetch and parse in a single step. + + Attributes + ---------- + watershed_point : dict + GeoJSON feature for the delineation (pour) point. + watershed_polygon : dict + GeoJSON feature for the delineated basin polygon. + parameters : list + Basin characteristics returned by the service. + _workspaceID : str + Service workspace id, usable with + :obj:`dataretrieval.streamstats.download_workspace`. + """ def __init__(self, rcode, xlocation, ylocation): - """Init method that calls the :obj:`from_streamstats_json` method.""" - get_watershed(rcode, xlocation, ylocation) + """Delineate the watershed at ``(xlocation, ylocation)`` and + parse the response onto this instance.""" + response = get_watershed(rcode, xlocation, ylocation, format="geojson") + self._populate(json.loads(response.text)) + + @classmethod + def from_streamstats_json(cls, streamstats_json) -> "Watershed": + """Create a :class:`Watershed` from an already-parsed StreamStats + JSON payload, without issuing a new request. + + Builds a fresh instance (via ``__new__``, so the + network-fetching ``__init__`` is bypassed) and populates it; each + call returns an independent object rather than mutating shared + class state. + """ + self = cls.__new__(cls) + self._populate(streamstats_json) + return self + + def _populate(self, streamstats_json) -> None: + """Extract watershed fields from a StreamStats JSON payload onto + this instance.""" + self.watershed_point = streamstats_json["featurecollection"][0]["feature"] + self.watershed_polygon = streamstats_json["featurecollection"][1]["feature"] + self.parameters = streamstats_json["parameters"] + self._workspaceID = streamstats_json["workspaceID"] diff --git a/dataretrieval/wqp.py b/dataretrieval/wqp.py index e874f0be..e41235a9 100644 --- a/dataretrieval/wqp.py +++ b/dataretrieval/wqp.py @@ -656,14 +656,28 @@ def __init__(self, response, **parameters) -> None: self._parameters = parameters - @property - def site_info(self): - if "sites" in self._parameters: - return what_sites(sites=parameters["sites"]) - elif "site" in self._parameters: - return what_sites(sites=parameters["site"]) - elif "site_no" in self._parameters: - return what_sites(sites=parameters["site_no"]) + @property + def site_info(self) -> tuple[DataFrame, WQP_Metadata] | None: + """Site information for the query. + + Populated when the query included ``sites``, ``site`` or + ``site_no`` (in that order of preference); ``None`` otherwise. + + Returns + ------- + df : ``pandas.DataFrame`` + Formatted requested data from calling ``wqp.what_sites``. + md : :obj:`dataretrieval.wqp.WQP_Metadata` + A WQP_Metadata object. + """ + if "sites" in self._parameters: + return what_sites(sites=self._parameters["sites"]) + elif "site" in self._parameters: + return what_sites(sites=self._parameters["site"]) + elif "site_no" in self._parameters: + return what_sites(sites=self._parameters["site_no"]) + else: + return None def _check_kwargs(kwargs): diff --git a/tests/streamstats_test.py b/tests/streamstats_test.py new file mode 100644 index 00000000..ee528693 --- /dev/null +++ b/tests/streamstats_test.py @@ -0,0 +1,62 @@ +"""Tests for ``dataretrieval.streamstats``.""" + +import json + +import pytest + +from dataretrieval.streamstats import Watershed, get_watershed + +# Minimal StreamStats watershed payload shaped like the service response +# (two-element featurecollection: point + delineated basin polygon). +_SAMPLE = { + "featurecollection": [ + {"name": "globalwatershedpoint", "feature": {"type": "Feature", "id": "pt"}}, + {"name": "globalwatershed", "feature": {"type": "Feature", "id": "poly"}}, + ], + "parameters": [{"code": "DRNAREA", "value": 12.3}], + "workspaceID": "WS-ABC", +} + + +def test_watershed_from_streamstats_json_builds_independent_instances(): + """B3 regression: ``from_streamstats_json`` previously wrote *class* + attributes and returned the class object, so it produced no real + instance and a second parse clobbered the first. It must now return + an independent, populated ``Watershed`` instance.""" + w1 = Watershed.from_streamstats_json(_SAMPLE) + assert isinstance(w1, Watershed) # was the class object pre-fix + assert w1.watershed_point == {"type": "Feature", "id": "pt"} + assert w1.watershed_polygon == {"type": "Feature", "id": "poly"} + assert w1.parameters == [{"code": "DRNAREA", "value": 12.3}] + assert w1._workspaceID == "WS-ABC" + + w2 = Watershed.from_streamstats_json(dict(_SAMPLE, workspaceID="WS-XYZ")) + assert w1 is not w2 + assert w1._workspaceID == "WS-ABC" # not clobbered by w2 (was shared class state) + assert w2._workspaceID == "WS-XYZ" + + +def test_get_watershed_object_returns_instance(httpx_mock): + """``get_watershed(format='object')`` parses the response into a + populated ``Watershed`` instance.""" + httpx_mock.add_response(text=json.dumps(_SAMPLE)) + w = get_watershed("NY", -74.524, 43.939, format="object") + assert isinstance(w, Watershed) + assert w._workspaceID == "WS-ABC" + assert w.parameters == [{"code": "DRNAREA", "value": 12.3}] + + +def test_get_watershed_geojson_returns_raw_response(httpx_mock): + """The default ``format='geojson'`` returns the raw httpx response.""" + httpx_mock.add_response(text=json.dumps(_SAMPLE)) + r = get_watershed("NY", -74.524, 43.939) + assert r.status_code == 200 + assert json.loads(r.text)["workspaceID"] == "WS-ABC" + + +def test_get_watershed_shape_raises_not_implemented(httpx_mock): + """B3: the unimplemented ``format='shape'`` must fail loudly rather + than silently falling through to a (previously broken) ``Watershed``.""" + httpx_mock.add_response(text=json.dumps(_SAMPLE)) + with pytest.raises(NotImplementedError): + get_watershed("NY", -74.524, 43.939, format="shape") diff --git a/tests/wqp_test.py b/tests/wqp_test.py index 356f7ac8..5ea4edea 100644 --- a/tests/wqp_test.py +++ b/tests/wqp_test.py @@ -1,9 +1,11 @@ import datetime +from unittest import mock import pytest from pandas import DataFrame from dataretrieval.wqp import ( + WQP_Metadata, _check_kwargs, get_results, what_activities, @@ -243,3 +245,40 @@ def test_get_results_wqx3_preserves_user_dataProfile(httpx_mock): assert isinstance(df, DataFrame) sent = httpx_mock.get_requests()[-1] assert sent.url.params.get("dataProfile") == "narrow" + + +def _wqp_metadata(**parameters): + """Build a ``WQP_Metadata`` from a lightweight mock response.""" + resp = mock.Mock( + url="https://www.waterqualitydata.us/", + elapsed=datetime.timedelta(seconds=0.01), + headers={}, + ) + return WQP_Metadata(resp, **parameters) + + +def test_wqp_metadata_site_info_is_accessible_property(): + """B2 regression: ``WQP_Metadata.site_info`` was accidentally defined + *inside* ``__init__`` (a discarded local function), so the attribute + did not exist and accessing it fell through to + ``BaseMetadata.site_info``, which raises ``NotImplementedError``. It + must now be a real property that returns ``None`` (no site param) + without raising.""" + assert isinstance(type(_wqp_metadata()).site_info, property) + assert _wqp_metadata().site_info is None # must NOT raise + + +def test_wqp_metadata_site_info_routes_to_what_sites(monkeypatch): + """When the query carried ``sites`` (or ``site``/``site_no``), + ``site_info`` delegates to ``wqp.what_sites`` with that identifier.""" + import dataretrieval.wqp as wqp_mod + + captured = {} + + def fake_what_sites(**kwargs): + captured.update(kwargs) + return "SENTINEL" + + monkeypatch.setattr(wqp_mod, "what_sites", fake_what_sites) + assert _wqp_metadata(sites="USGS-05427718").site_info == "SENTINEL" + assert captured == {"sites": "USGS-05427718"}