Skip to content

Commit 1dffcc0

Browse files
thodson-usgsclaude
andcommitted
nldi: fix several user-visible bugs in get_features, get_flowlines, search
Bundles six discrete fixes to the NLDI module: * `search(find='flowlines')` without `navigation_mode` crashed with `AttributeError: 'NoneType' object has no attribute 'upper'` from inside `_validate_navigation_mode`. `_validate_navigation_mode` now treats `None` as a clean `ValueError`, returns the upper-cased value (callers use the normalized form), and raises `ValueError` rather than `TypeError` for invalid modes — these are bad values, not bad types. `search` also surfaces a clear error before delegating to `get_flowlines`. * `get_flowlines(comid=...)` silently dropped the `trim_start` argument: the `comid` branch built `query_params` without `trimStart`. Move the shared params out of the branch. * `get_features(lat=0.0, ...)` and `search(lat=0.0, ...)` treated the equator/prime-meridian as missing because the code used `if lat:` / `if comid:` truthiness checks. Switch to `is None` checks throughout so coordinates of exactly zero are accepted and `comid=0` is no longer conflated with "no comid". * The error message produced by `get_features` for feature-source + data-source paths had an unbalanced quote (`"feature_id 'USGS-X, and data source ..."`). Closing quote restored via a small `_features_err_msg` helper that the two branches share. * The unconditional `_validate_data_source(feature_source)` in `get_features` is now guarded by `if feature_source:` to avoid validating `None` once the cache-bug fix (#246) lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 51ac674 commit 1dffcc0

2 files changed

Lines changed: 135 additions & 56 deletions

File tree

dataretrieval/nldi.py

Lines changed: 59 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,14 @@ def get_flowlines(
7979
... comid=13294314, navigation_mode="UM"
8080
... )
8181
"""
82-
# validate the navigation mode
83-
_validate_navigation_mode(navigation_mode)
84-
# validate the feature source and comid
82+
navigation_mode = _validate_navigation_mode(navigation_mode)
8583
_validate_feature_source_comid(feature_source, feature_id, comid)
8684
if feature_source:
87-
# validate the feature source
8885
_validate_data_source(feature_source)
89-
9086
url = f"{NLDI_API_BASE_URL}/{feature_source}/{feature_id}/navigation"
91-
query_params = {"distance": str(distance), "trimStart": str(trim_start).lower()}
9287
else:
9388
url = f"{NLDI_API_BASE_URL}/comid/{comid}/navigation"
94-
query_params = {"distance": str(distance)}
89+
query_params = {"distance": str(distance), "trimStart": str(trim_start).lower()}
9590

9691
url += f"/{navigation_mode}/flowlines"
9792
if stop_comid is not None:
@@ -232,67 +227,52 @@ def get_features(
232227
>>> gdf = dataretrieval.nldi.get_features(lat=43.073051, long=-89.401230)
233228
"""
234229

235-
# check only one origin is provided
236-
if (lat and long is None) or (long and lat is None):
230+
if (lat is None) != (long is None):
237231
raise ValueError("Both lat and long are required")
238232

239-
if lat:
240-
if comid:
233+
have_latlong = lat is not None
234+
if have_latlong:
235+
if comid is not None:
241236
raise ValueError(
242237
"Provide only one origin type - comid cannot be provided"
243238
" with lat or long"
244239
)
245-
if feature_source or feature_id:
240+
if feature_source is not None or feature_id is not None:
246241
raise ValueError(
247242
"Provide only one origin type - feature_source and feature_id cannot"
248243
" be provided with lat or long"
249244
)
250-
251-
if not lat:
252-
if (comid or data_source) and navigation_mode is None:
245+
else:
246+
if (comid is not None or data_source is not None) and navigation_mode is None:
253247
raise ValueError(
254248
"navigation_mode is required if comid or data_source is provided"
255249
)
256-
# validate the feature source and comid
257250
_validate_feature_source_comid(feature_source, feature_id, comid)
258-
# validate the data source
259251
if data_source:
260252
_validate_data_source(data_source)
261-
# validate feature source
262-
_validate_data_source(feature_source)
263-
# validate the navigation mode
253+
if feature_source:
254+
_validate_data_source(feature_source)
264255
if navigation_mode:
265-
_validate_navigation_mode(navigation_mode)
256+
navigation_mode = _validate_navigation_mode(navigation_mode)
266257

267-
if lat:
258+
if have_latlong:
268259
url = f"{NLDI_API_BASE_URL}/comid/position"
269260
query_params = {"coords": f"POINT({long} {lat})"}
270-
else:
271-
if navigation_mode:
272-
if feature_source:
273-
url = f"{NLDI_API_BASE_URL}/{feature_source}/{feature_id}/navigation"
274-
else:
275-
url = f"{NLDI_API_BASE_URL}/comid/{comid}/navigation"
276-
url += f"/{navigation_mode}/{data_source}"
277-
query_params = {"distance": str(distance)}
278-
if stop_comid is not None:
279-
query_params["stopComid"] = str(stop_comid)
280-
else:
281-
url = f"{NLDI_API_BASE_URL}/{feature_source}/{feature_id}"
282-
query_params = {}
283-
284-
if lat:
285261
err_msg = f"Error getting features for lat '{lat}' and long '{long}'"
286-
elif feature_source:
287-
err_msg = (
288-
f"Error getting features for feature source '{feature_source}'"
289-
f" and feature_id '{feature_id}, and data source '{data_source}'"
290-
)
262+
elif navigation_mode:
263+
if feature_source:
264+
url = f"{NLDI_API_BASE_URL}/{feature_source}/{feature_id}/navigation"
265+
else:
266+
url = f"{NLDI_API_BASE_URL}/comid/{comid}/navigation"
267+
url += f"/{navigation_mode}/{data_source}"
268+
query_params = {"distance": str(distance)}
269+
if stop_comid is not None:
270+
query_params["stopComid"] = str(stop_comid)
271+
err_msg = _features_err_msg(feature_source, feature_id, comid, data_source)
291272
else:
292-
err_msg = (
293-
f"Error getting features for comid '{comid}'"
294-
f" and data source '{data_source}'"
295-
)
273+
url = f"{NLDI_API_BASE_URL}/{feature_source}/{feature_id}"
274+
query_params = {}
275+
err_msg = _features_err_msg(feature_source, feature_id, comid, data_source)
296276

297277
feature_collection = _query_nldi(url, query_params, err_msg)
298278
if as_json:
@@ -413,28 +393,26 @@ def search(
413393
... )
414394
415395
"""
416-
if (lat and long is None) or (long and lat is None):
396+
if (lat is None) != (long is None):
417397
raise ValueError("Both lat and long are required")
418398

419-
# validate find
420399
find = find.lower()
421400
if find not in ("basin", "flowlines", "features"):
422401
raise ValueError(
423402
f"Invalid value for find: {find} - allowed values are:"
424403
f" 'basin', 'flowlines', or 'features'"
425404
)
426-
if lat and find != "features":
405+
if lat is not None and find != "features":
427406
raise ValueError(
428407
f"Invalid value for find: {find} - lat/long is to get features not {find}"
429408
)
430-
if comid and find == "basin":
409+
if comid is not None and find == "basin":
431410
raise ValueError(
432411
"Invalid value for find: basin - comid is to get features"
433412
" or flowlines not basin"
434413
)
435414

436-
if lat:
437-
# get features by hydrologic location
415+
if lat is not None:
438416
return get_features(lat=lat, long=long, as_json=True)
439417

440418
if find == "basin":
@@ -443,6 +421,11 @@ def search(
443421
)
444422

445423
if find == "flowlines":
424+
if navigation_mode is None:
425+
raise ValueError(
426+
"navigation_mode is required for find='flowlines';"
427+
f" allowed values are {_VALID_NAVIGATION_MODES}"
428+
)
446429
return get_flowlines(
447430
navigation_mode=navigation_mode,
448431
distance=distance,
@@ -483,10 +466,30 @@ def _validate_data_source(data_source: str):
483466
raise ValueError(err_msg)
484467

485468

486-
def _validate_navigation_mode(navigation_mode: str):
487-
navigation_mode = navigation_mode.upper()
488-
if navigation_mode not in ("UM", "DM", "UT", "DD"):
489-
raise TypeError(f"Invalid navigation mode '{navigation_mode}'")
469+
_VALID_NAVIGATION_MODES = ("UM", "DM", "UT", "DD")
470+
471+
472+
def _features_err_msg(feature_source, feature_id, comid, data_source) -> str:
473+
if feature_source is not None:
474+
return (
475+
f"Error getting features for feature source '{feature_source}'"
476+
f" and feature_id '{feature_id}', and data source '{data_source}'"
477+
)
478+
return f"Error getting features for comid '{comid}' and data source '{data_source}'"
479+
480+
481+
def _validate_navigation_mode(navigation_mode: str | None) -> str:
482+
if navigation_mode is None:
483+
raise ValueError(
484+
f"navigation_mode is required; allowed values are {_VALID_NAVIGATION_MODES}"
485+
)
486+
normalized = navigation_mode.upper()
487+
if normalized not in _VALID_NAVIGATION_MODES:
488+
raise ValueError(
489+
f"Invalid navigation mode '{navigation_mode}';"
490+
f" allowed values are {_VALID_NAVIGATION_MODES}"
491+
)
492+
return normalized
490493

491494

492495
def _validate_feature_source_comid(

tests/nldi_test.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import pytest
12
from geopandas import GeoDataFrame
23

34
from dataretrieval.nldi import (
45
NLDI_API_BASE_URL,
6+
_validate_navigation_mode,
57
get_basin,
68
get_features,
79
get_flowlines,
@@ -280,3 +282,77 @@ def test_search_for_features_by_lat_long(requests_mock):
280282
assert search_results["features"][0]["type"] == "Feature"
281283
assert search_results["features"][0]["geometry"]["type"] == "LineString"
282284
assert len(search_results["features"][0]["geometry"]["coordinates"]) == 27
285+
286+
287+
# --- regression tests for nldi cleanup batch ---
288+
289+
290+
def test_search_flowlines_without_navigation_mode_raises_value_error():
291+
"""Regression: previously crashed with AttributeError on None.upper()."""
292+
with pytest.raises(ValueError, match="navigation_mode is required"):
293+
search(comid=13294314, find="flowlines")
294+
295+
296+
def test_validate_navigation_mode_raises_value_error_for_invalid():
297+
"""Regression: previously raised TypeError; should be ValueError."""
298+
with pytest.raises(ValueError, match="Invalid navigation mode"):
299+
_validate_navigation_mode("XX")
300+
301+
302+
def test_validate_navigation_mode_normalizes_lowercase():
303+
"""Regression: lowercase values used to validate but be sent unchanged."""
304+
assert _validate_navigation_mode("um") == "UM"
305+
306+
307+
def test_get_flowlines_by_comid_includes_trim_start(requests_mock):
308+
"""Regression: trim_start was previously dropped on the comid code path."""
309+
request_url = f"{NLDI_API_BASE_URL}/comid/13294314/navigation/UM/flowlines"
310+
response_file_path = "tests/data/nldi_get_flowlines_by_comid.json"
311+
mock_request_data_sources(requests_mock)
312+
mock_request(requests_mock, request_url, response_file_path)
313+
314+
get_flowlines(navigation_mode="UM", comid=13294314, trim_start=True)
315+
316+
sent = requests_mock.request_history[-1]
317+
assert sent.qs.get("trimstart") == ["true"]
318+
319+
320+
def test_get_features_with_zero_coordinates(requests_mock):
321+
"""Regression: lat=0.0 / long=0.0 were treated as missing (falsy bug)."""
322+
request_url = f"{NLDI_API_BASE_URL}/comid/position"
323+
requests_mock.get(
324+
request_url,
325+
json={"type": "FeatureCollection", "features": []},
326+
headers={"mock_header": "value"},
327+
)
328+
329+
gdf = get_features(lat=0.0, long=0.0, as_json=True)
330+
assert isinstance(gdf, dict)
331+
sent = requests_mock.request_history[-1]
332+
assert "POINT(0.0 0.0)" in sent.qs.get("coords", [""])[0].upper()
333+
334+
335+
def test_get_features_lat_zero_long_missing_raises():
336+
"""Regression: lat=0.0 with missing long was silently accepted (falsy bug)."""
337+
with pytest.raises(ValueError, match="Both lat and long are required"):
338+
get_features(lat=0.0)
339+
340+
341+
def test_get_features_error_message_has_balanced_quotes(requests_mock):
342+
"""Regression: error message had a missing closing quote after feature_id."""
343+
request_url = f"{NLDI_API_BASE_URL}/WQP/USGS-bad/navigation/UM/nwissite"
344+
# Use a status code utils.query() doesn't intercept so the _query_nldi
345+
# error path runs and we see its formatted message.
346+
requests_mock.get(request_url, status_code=403, reason="Forbidden", json={})
347+
mock_request_data_sources(requests_mock)
348+
349+
with pytest.raises(ValueError) as exc:
350+
get_features(
351+
feature_source="WQP",
352+
feature_id="USGS-bad",
353+
data_source="nwissite",
354+
navigation_mode="UM",
355+
)
356+
msg = str(exc.value)
357+
# Closing quote after feature_id must now be present.
358+
assert "feature_id 'USGS-bad'," in msg

0 commit comments

Comments
 (0)