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
74 changes: 55 additions & 19 deletions dataretrieval/streamstats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"]
30 changes: 22 additions & 8 deletions dataretrieval/wqp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
62 changes: 62 additions & 0 deletions tests/streamstats_test.py
Original file line number Diff line number Diff line change
@@ -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")
39 changes: 39 additions & 0 deletions tests/wqp_test.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"}
Loading