Skip to content

Commit ec3b215

Browse files
thodson-usgsclaude
andcommitted
Extract RDB parsing into a shared dataretrieval.rdb module
The USGS RDB tab-separated format is used both by NWIS web services and by the new Water Data STAC catalog's rating-curve assets. Until now, only nwis.py had a parser, and waterdata/ratings.py reached across to import the private nwis._read_rdb. That smell is gone: - New top-level module dataretrieval.rdb with two public helpers: - read_rdb(text, dtypes=None): pure parser. Strips the comment block, tab-parses the header row, skips the format-spec row, and returns a DataFrame. Forwards optional caller-supplied dtype hints to pandas.read_csv (unknown column names are silently ignored, so dicts are safe to pass on any RDB). Raises ValueError on HTML responses. - extract_rdb_comment(text): returns the #-prefixed header block as a list of lines (the equivalent of R's comment(df)). - nwis._read_rdb is now a thin wrapper around rdb.read_rdb that applies the NWIS-specific dtype hints (site_no, dec_long_va, ...) and runs format_response() for datetime indexing. The private symbol's contract is unchanged; existing 30 nwis tests pass. Dropped the now-unused StringIO import. - waterdata.ratings switches its import from nwis._read_rdb to rdb.read_rdb / extract_rdb_comment, dropping the local _extract_rdb_comment helper. The cross-module-private-import comment block goes away — the dependency is now a clean same-package import. 7 new unit tests pin the shared parser's behavior: basic shape parsing, format-spec-row skipping, dtype passthrough, all-comments empty-result, HTML rejection, and the comment extractor. Net stat: +177 / -72. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6dc6b64 commit ec3b215

4 files changed

Lines changed: 177 additions & 72 deletions

File tree

dataretrieval/nwis.py

Lines changed: 20 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
from __future__ import annotations
88

99
import warnings
10-
from io import StringIO
1110
from json import JSONDecodeError
1211

1312
import pandas as pd
1413
import requests
1514

15+
from dataretrieval.rdb import read_rdb as _read_rdb_text
1616
from dataretrieval.utils import BaseMetadata
1717

1818
from .utils import query
@@ -1017,65 +1017,29 @@ def _read_json(json):
10171017
return merged_df
10181018

10191019

1020-
def _read_rdb(rdb):
1021-
"""
1022-
Convert NWIS rdb table into a ``pandas.dataframe``.
1020+
# NWIS-specific column dtype hints; pandas silently ignores unknown
1021+
# names, so passing the dict to read_rdb is safe even on responses
1022+
# whose columns don't include any of these.
1023+
_NWIS_RDB_DTYPES = {
1024+
"site_no": str,
1025+
"dec_long_va": float,
1026+
"dec_lat_va": float,
1027+
"parm_cd": str,
1028+
"parameter_cd": str,
1029+
}
10231030

1024-
Parameters
1025-
----------
1026-
rdb: string
1027-
A string representation of an rdb table
10281031

1029-
Returns
1030-
-------
1031-
df: ``pandas.dataframe``
1032-
A formatted pandas data frame
1032+
def _read_rdb(rdb):
1033+
"""Parse an NWIS RDB response and apply NWIS-specific post-processing.
10331034
1035+
Thin wrapper around :func:`dataretrieval.rdb.read_rdb` that adds the
1036+
NWIS column-dtype hints and runs :func:`format_response` (datetime
1037+
index, multi-site MultiIndex, optional GeoDataFrame).
10341038
"""
1035-
if "<html>" in rdb.lower() or "<!doctype html>" in rdb.lower():
1036-
raise ValueError(
1037-
"Received HTML response instead of RDB. This often indicates "
1038-
"that the service has been moved or is currently unavailable."
1039-
)
1040-
1041-
count = 0
1042-
lines = rdb.splitlines()
1043-
1044-
for line in lines:
1045-
# ignore comment lines
1046-
if line.startswith("#"):
1047-
count = count + 1
1048-
1049-
else:
1050-
break
1051-
1052-
if count >= len(lines):
1053-
# All lines are comments — the service returned no data rows (e.g.
1054-
# "No sites found matching all criteria"). This is a legitimate empty
1055-
# result, so return an empty DataFrame rather than raising.
1056-
return pd.DataFrame()
1057-
1058-
fields = lines[count].split("\t")
1059-
fields = [field.replace(",", "").strip() for field in fields if field.strip()]
1060-
dtypes = {
1061-
"site_no": str,
1062-
"dec_long_va": float,
1063-
"dec_lat_va": float,
1064-
"parm_cd": str,
1065-
"parameter_cd": str,
1066-
}
1067-
1068-
df = pd.read_csv(
1069-
StringIO(rdb),
1070-
delimiter="\t",
1071-
skiprows=count + 2,
1072-
names=fields,
1073-
na_values="NaN",
1074-
dtype=dtypes,
1075-
)
1076-
1077-
df = format_response(df)
1078-
return df
1039+
df = _read_rdb_text(rdb, dtypes=_NWIS_RDB_DTYPES)
1040+
if df.empty:
1041+
return df
1042+
return format_response(df)
10791043

10801044

10811045
def _check_sites_value_types(sites):

dataretrieval/rdb.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Format-agnostic parser for the USGS RDB tab-separated text format.
2+
3+
RDB (Relational DataBase) is a USGS-internal text format used by NWIS web
4+
services and by the Water Data STAC catalog's rating-curve assets. Every
5+
RDB file has the same shape:
6+
7+
- One or more ``#``-prefixed comment lines carrying provenance metadata
8+
(data source, retrieval timestamp, station name, parameter codes, etc.).
9+
- A tab-separated header row naming each column.
10+
- A second tab-separated row giving column format specs (e.g. ``5s 15s``);
11+
it is informational only and skipped during parsing.
12+
- Tab-separated data rows.
13+
14+
This module exposes the parsing primitives that both ``dataretrieval.nwis``
15+
and ``dataretrieval.waterdata.ratings`` use. Callers layer their own
16+
post-processing (NWIS-specific datetime indexing, ratings-specific
17+
``df.attrs`` provenance, etc.) on top of the raw frame.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from io import StringIO
23+
24+
import pandas as pd
25+
26+
27+
def read_rdb(rdb: str, dtypes: dict[str, type] | None = None) -> pd.DataFrame:
28+
"""Parse an RDB text response into a ``pandas.DataFrame``.
29+
30+
Parameters
31+
----------
32+
rdb : str
33+
The RDB text response from a USGS web service.
34+
dtypes : dict[str, type] or None, optional
35+
Optional column-name to dtype hints, forwarded to
36+
``pandas.read_csv``. Unknown column names are silently ignored, so
37+
callers may safely pass a dict of all columns they might be
38+
interested in.
39+
40+
Returns
41+
-------
42+
pandas.DataFrame
43+
The parsed data. An RDB consisting only of comment lines (e.g. a
44+
"no sites found" response) returns an empty DataFrame rather than
45+
raising.
46+
47+
Raises
48+
------
49+
ValueError
50+
If the response body looks like HTML, which usually means the
51+
service has been moved, is degraded, or returned an error page.
52+
"""
53+
if "<html>" in rdb.lower() or "<!doctype html>" in rdb.lower():
54+
raise ValueError(
55+
"Received HTML response instead of RDB. This often indicates "
56+
"that the service has been moved or is currently unavailable."
57+
)
58+
59+
lines = rdb.splitlines()
60+
header_idx = next(
61+
(i for i, line in enumerate(lines) if not line.startswith("#")),
62+
len(lines),
63+
)
64+
if header_idx >= len(lines):
65+
# All lines are comments — a legitimate empty result.
66+
return pd.DataFrame()
67+
68+
fields = lines[header_idx].split("\t")
69+
fields = [f.replace(",", "").strip() for f in fields if f.strip()]
70+
71+
return pd.read_csv(
72+
StringIO(rdb),
73+
delimiter="\t",
74+
skiprows=header_idx + 2, # +1 for header, +1 for the format-spec row
75+
names=fields,
76+
na_values="NaN",
77+
dtype=dtypes,
78+
)
79+
80+
81+
def extract_rdb_comment(rdb: str) -> list[str]:
82+
"""Return the RDB ``#``-prefixed comment block as a list of header lines.
83+
84+
The comment block carries provenance metadata that is otherwise lost
85+
during parsing — data source, retrieval timestamp, parameter codes,
86+
rating id and last-shifted timestamp for ratings, etc. R's
87+
``dataRetrieval`` exposes the equivalent via ``comment(df)``.
88+
"""
89+
return [line for line in rdb.splitlines() if line.startswith("#")]

dataretrieval/waterdata/ratings.py

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,7 @@
1919
import pandas as pd
2020
import requests
2121

22-
# Rating files use the same USGS RDB shape as NWIS responses, so we reuse
23-
# the parser already in ``nwis``. ``_read_rdb`` is private; if it ever
24-
# moves we want a loud failure here, hence the explicit import.
25-
from dataretrieval.nwis import _read_rdb
22+
from dataretrieval.rdb import extract_rdb_comment, read_rdb
2623

2724
from .utils import _DURATION_RE, BASE_URL, _default_headers, _format_api_dates
2825

@@ -245,16 +242,6 @@ def _search(
245242
return response.json().get("features", [])
246243

247244

248-
def _extract_rdb_comment(rdb: str) -> list[str]:
249-
"""Return the RDB ``#``-prefixed comment block as a list of header lines.
250-
251-
Carries useful per-rating provenance — rating id, parameter description,
252-
expansion type, last-shifted timestamp, etc. R's ``read_waterdata_ratings``
253-
exposes the equivalent via ``comment(df)``.
254-
"""
255-
return [line for line in rdb.splitlines() if line.startswith("#")]
256-
257-
258245
def _download_and_parse(
259246
feature: dict[str, Any],
260247
file_path: str | None,
@@ -269,7 +256,7 @@ def _download_and_parse(
269256
with open(os.path.join(file_path, feature["id"]), "w") as f:
270257
f.write(response.text)
271258

272-
df = _read_rdb(response.text)
273-
df.attrs["comment"] = _extract_rdb_comment(response.text)
259+
df = read_rdb(response.text)
260+
df.attrs["comment"] = extract_rdb_comment(response.text)
274261
df.attrs["url"] = url
275262
return df

tests/rdb_test.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import pandas as pd
2+
import pytest
3+
4+
from dataretrieval.rdb import extract_rdb_comment, read_rdb
5+
6+
# A minimally complete RDB: comment block, header row, format-spec row,
7+
# data rows. Both NWIS responses and ratings RDBs share this shape.
8+
_BASIC_RDB = """\
9+
# header line one
10+
# header line two
11+
agency_cd\tsite_no\tINDEP\tDEP
12+
5s\t15s\t10n\t10n
13+
USGS\t01104475\t0.10\t0.0
14+
USGS\t01104475\t0.20\t0.5
15+
USGS\t01104475\t0.30\t1.2
16+
"""
17+
18+
19+
def test_read_rdb_parses_basic_shape():
20+
df = read_rdb(_BASIC_RDB)
21+
assert list(df.columns) == ["agency_cd", "site_no", "INDEP", "DEP"]
22+
assert len(df) == 3
23+
assert df["INDEP"].tolist() == [0.10, 0.20, 0.30]
24+
25+
26+
def test_read_rdb_skips_format_spec_row():
27+
"""The "5s 15s 10n 10n" row is metadata, not data."""
28+
df = read_rdb(_BASIC_RDB)
29+
# If the format-spec row had been treated as data, df would have 4 rows
30+
# and "5s" / "15s" would appear in the parsed values.
31+
assert "5s" not in df["agency_cd"].tolist()
32+
33+
34+
def test_read_rdb_dtype_hints_applied():
35+
"""Caller-supplied dtype hints are forwarded to pandas; unknown names ignored."""
36+
df = read_rdb(_BASIC_RDB, dtypes={"site_no": str, "DEP": float, "unknown": int})
37+
# Without the str hint, pandas would parse "01104475" as int and drop the
38+
# leading zero. Check the values, not the dtype name (which varies across
39+
# pandas versions: object, StringDtype, etc.).
40+
assert df["site_no"].iloc[0] == "01104475"
41+
assert df["DEP"].dtype == float
42+
43+
44+
def test_read_rdb_empty_when_only_comments():
45+
"""All-comments input is a legitimate "no data" response, not an error."""
46+
df = read_rdb("# only a comment\n# and another\n")
47+
assert isinstance(df, pd.DataFrame)
48+
assert df.empty
49+
50+
51+
def test_read_rdb_raises_on_html_response():
52+
"""If the service returns an HTML error page, surface it loudly."""
53+
with pytest.raises(ValueError, match="HTML"):
54+
read_rdb("<html><body>Service Unavailable</body></html>")
55+
with pytest.raises(ValueError, match="HTML"):
56+
read_rdb("<!DOCTYPE html>\n<html>...")
57+
58+
59+
def test_extract_rdb_comment_returns_only_hash_lines():
60+
comments = extract_rdb_comment(_BASIC_RDB)
61+
assert comments == ["# header line one", "# header line two"]
62+
63+
64+
def test_extract_rdb_comment_empty_when_no_comments():
65+
assert extract_rdb_comment("a\tb\n1\t2\n") == []

0 commit comments

Comments
 (0)