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
4 changes: 2 additions & 2 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ layers =
progress
; Response-format conventions sit above the pure leaves because they read the
; code tables, and below every adapter that shapes a response with them.
_wqx | _csv
_wqx
_response_metadata | codes | combining | interruptions | rdb
credentials
configuration
Expand All @@ -42,7 +42,7 @@ layers =
; Pure dependency-free mechanisms sit together at the floor: none imports
; first-party code, so every layer can use them without reaching sideways or
; creating artificial dependencies among them.
_ambient | _deprecation | _validation
_ambient | _csv | _deprecation | _validation
; Every top-level module must be placed in the stack deliberately. A new
; top-level module fails this contract until someone decides where it sits.
exhaustive = True
Expand Down
2 changes: 1 addition & 1 deletion NEWS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
**09/10/2026:** **Bug fix:** `waterdata.get_samples()` and `waterdata.get_samples_summary()` now preserve leading zeros in code and identifier columns, including parameter codes, HUCs, FIPS codes, and monitoring-location and organization identifiers. These columns now contain strings instead of inferred numbers; measurement and count columns retain numeric inference. Samples and WQP share the existing two-pass CSV parsing convention. WQP behavior, request parameters, metadata, and datetime shaping are unchanged.
**09/09/2026:** **Bug fix:** code and identifier columns keep their leading zeros. A bare `pandas.read_csv` infers a zero-padded code as a number, so `waterdata.get_samples()` returned parameter code `00060` as `60` and HUC12 `070700050502` as `70700050502`, and `nwis.get_info()` returned `huc_cd` `02060005` as `2060005`. One rule now decides what a code column is — a name ending in `code`, the RDB abbreviation `_cd`, or a name containing `identifier`, `huc`, or `fips` — and every delimited response is parsed through it: the Samples and WQP CSV readers, `rdb.read_rdb` (which reads the names from the RDB header rather than the caller listing them), and the Water Use CSV pages. **Behavior change:** these columns now hold strings. `waterdata.get_samples()`: `USGSpcode`, `Location_HUCEightDigitCode`, `Location_HUCTwelveDigitCode`, `SampleCollectionMethod_Identifier` (`get_samples_summary()` shares the parse; no column in its current profile was affected). `nwis.get_info()`, `nwis.what_sites()`, and `nwis.get_record(service="site")`: `huc_cd`, `state_cd`, `county_cd`, `district_cd`. A comparison against a number — `df["USGSpcode"] == 60` — or a merge onto a numeric key now matches nothing instead of raising, so compare against the padded string (`== "00060"`) or call `.astype(int)` where the number is what you want. **Behavior change:** a count whose name reads as an identifier is numeric again. WQP's `AlternateLocation_IdentifierCount` has been read as text since 05/31/2026 because "Identifier" appears in its name; a name ending in `count` is now excluded from the rule, so the same column has one dtype in every service that reports it. Measurement columns are unchanged, and the `waterdata` OGC getters and `ngwmn` were never affected: their JSON responses deliver codes as strings and numeric coercion there is limited to a fixed list of measurement columns. **Correction to the 1.2.0 notes:** the same fix was applied to the nine `wqp` getters on 05/31/2026 and never recorded here — `wqp.get_results()` and the `what_*` getters have returned HUCs, parameter codes, and FIPS codes as strings since that release.

**09/01/2026:** **Announcement:** We at USGS Water Data for the Nation want your feedback! Tell us how we're doing by taking our quick [survey](https://usgswaterresources.gov1.qualtrics.com/jfe/form/SV_07gX8G1DeOtVrH8), available through September 2026.

Expand Down
59 changes: 41 additions & 18 deletions dataretrieval/_csv.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Shared CSV parsing that preserves significant zeros in codes and identifiers."""
"""Identify the code columns in a response, and read CSV with them as strings."""

from collections.abc import Collection
from collections.abc import Iterable
from io import StringIO

import pandas as pd
Expand All @@ -10,27 +10,50 @@
def _is_code_column(name: str) -> bool:
"""Report whether a column name denotes a code or identifier.

Such columns (HUCs, parameter codes, FIPS codes) have leading zeros that
are significant and must be preserved as ``str``. A name qualifies if it
ends with "code" or contains "identifier", "huc", or "fips".
USGS services spell a code column three ways -- a ``code`` suffix
(``Location_HUCEightDigitCode``), the RDB abbreviation ``_cd``
(``huc_cd``), or a code vocabulary in the name (``identifier``, ``huc``,
``fips``). All three qualify. Their leading zeros carry meaning, so the
column must be read as ``str``.

A name ending in ``count`` is a tally rather than a code, even when it
reads like one: ``AlternateLocation_IdentifierCount`` counts a location's
alternate identifiers.
"""
lname = name.lower()
return lname.endswith("code") or any(
token in lname for token in ("identifier", "huc", "fips")
if lname.endswith("count"):
return False
return (
lname.endswith("code")
or lname.endswith("_cd")
or any(token in lname for token in ("identifier", "huc", "fips"))
)


def read_code_csv(text: str, *, infer_columns: Collection[str] = ()) -> DataFrame:
"""Read CSV text with code/identifier columns as strings.
def code_columns(names: Iterable[object]) -> dict[str, type]:
"""Map each code or identifier name in ``names`` to ``str``.

Read the header first to select string columns before numeric inference can
discard leading zeros (``"00060"`` -> ``60``). Other columns retain pandas'
inferred types, and the default missing-value handling is unchanged.
``infer_columns`` also retain inference when their names match a code or
identifier, allowing a caller to distinguish counts from identifiers.
Returns the ``dtype`` map for a caller that already has the column names
and parses the body itself, such as :func:`dataretrieval.rdb.read_rdb`.
"""
return {str(name): str for name in names if _is_code_column(str(name))}


def read_code_csv(text: str) -> DataFrame:
"""Read CSV text with code and identifier columns as strings.

Read the header first to select those columns before numeric inference
discards their leading zeros (``"00060"`` -> ``60``). Other columns keep
pandas' inferred types, and missing-value handling is unchanged.

``low_memory=False`` types each column from the whole body rather than per
chunk. It adds parse time on a large response and keeps a column's dtype
independent of where the chunk boundaries fall.
"""
columns = pd.read_csv(StringIO(text), delimiter=",", nrows=0).columns
str_cols = {
col: str for col in columns if col not in infer_columns and _is_code_column(col)
}
return pd.read_csv(StringIO(text), delimiter=",", low_memory=False, dtype=str_cols)
return pd.read_csv(
StringIO(text),
delimiter=",",
low_memory=False,
dtype=code_columns(columns),
)
14 changes: 7 additions & 7 deletions dataretrieval/nwdc.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@

from __future__ import annotations

import io
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Any, ClassVar
Expand All @@ -46,6 +45,7 @@
import pandas as pd

from dataretrieval import configuration as _configuration
from dataretrieval._csv import read_code_csv
from dataretrieval._querying import _raise_for_status, to_str
from dataretrieval._response_metadata import BaseMetadata
from dataretrieval._validation import render_options, require_exactly_one
Expand Down Expand Up @@ -99,10 +99,6 @@
#: general setting outranks a module's default rather than the reverse.
DEFAULT_CONCURRENT_REQUESTS = 4

# Page responses hold the HUC12 identifier in this column; it must stay a
# string so leading zeros (e.g. "010900020502") are preserved through parsing.
_HUC12_COLUMN = "huc12_id"


def get_wateruse(
model: str,
Expand Down Expand Up @@ -389,9 +385,13 @@ def finalize(


def _read_csv_page(response: httpx.Response) -> pd.DataFrame:
"""Parse one CSV page; ``huc12_id`` stays a string to keep leading zeros."""
"""Parse one CSV page through the shared code-preserving reader.

``huc12_id`` is a code column by name, so its leading zeros survive
(``"010900020502"``).
"""
try:
return pd.read_csv(io.BytesIO(response.content), dtype={_HUC12_COLUMN: str})
return read_code_csv(response.text)
except pd.errors.EmptyDataError as exc:
# NWDC normally signals "no data" with a 400 (handled above) or rows of
# zeros, never an empty body — but keep the typed-error contract if it
Expand Down
6 changes: 4 additions & 2 deletions dataretrieval/nwis.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,14 @@
# NAD83
_CRS = "EPSG:4269"

# Hints for columns whose names do not mark them as codes: ``site_no`` is a
# zero-padded identifier, and the decimal coordinates are floats. Code columns
# (``parm_cd``, ``huc_cd``, ``state_cd``, ...) are detected from the RDB header
# by ``rdb.read_rdb``.
_NWIS_RDB_DTYPES = {
"site_no": str,
"dec_long_va": float,
"dec_lat_va": float,
"parm_cd": str,
"parameter_cd": str,
}


Expand Down
11 changes: 9 additions & 2 deletions dataretrieval/rdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,23 @@

import pandas as pd

from dataretrieval._csv import code_columns


def read_rdb(text: str, dtypes: dict[str, type] | None = None) -> pd.DataFrame:
"""Parse an RDB text response into a ``pandas.DataFrame``.

Every column whose name marks it as a code is read as ``str`` so its
leading zeros survive -- ``huc_cd`` ``02060005`` rather than ``2060005``.
See :func:`dataretrieval._csv.code_columns` for which names those are.

Parameters
----------
text : str
The RDB text response from a USGS web service.
dtypes : dict[str, type] or None, optional
Column-name to dtype hints, forwarded to ``pandas.read_csv``. Unknown
Column-name to dtype hints, forwarded to ``pandas.read_csv`` and
applied over the code columns detected from the header. Unknown
column names are ignored, so callers can pass a dict of every
column they might be interested in.

Expand Down Expand Up @@ -78,7 +85,7 @@ def read_rdb(text: str, dtypes: dict[str, type] | None = None) -> pd.DataFrame:
skiprows=header_idx + 2, # +1 for header, +1 for the format-spec row
names=fields,
na_values="NaN",
dtype=dtypes,
dtype=code_columns(fields) | (dtypes or {}),
)


Expand Down
5 changes: 1 addition & 4 deletions dataretrieval/waterdata/samples.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,7 @@ def _get_samples_csv(
**HTTPX_DEFAULTS,
)
_raise_for_non_200(response)
# This field counts alternate identifiers; it is not an identifier itself.
df = read_code_csv(
response.text, infer_columns=("AlternateLocation_IdentifierCount",)
)
df = read_code_csv(response.text)
return df, response


Expand Down
6 changes: 3 additions & 3 deletions dataretrieval/wqp.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple

from dataretrieval import configuration as _configuration
from dataretrieval._csv import read_code_csv as _read_wqp_csv
from dataretrieval._csv import read_code_csv
from dataretrieval._response_metadata import BaseMetadata
from dataretrieval._validation import require_one_of
from dataretrieval.configuration import (
Expand Down Expand Up @@ -132,7 +132,7 @@ def _query_wqp(
and :func:`wqp_url` otherwise. Legacy-only collections route through
:func:`_legacy_only_url`, which warns and falls back to the legacy
profile. ``dataProfile`` is validated against :data:`_PROFILE_RULES`, and
the CSV response is parsed via :func:`_read_wqp_csv`.
the CSV response is parsed via :func:`dataretrieval._csv.read_code_csv`.
"""
kwargs = _check_kwargs(kwargs)
kwargs = _resolve_profile(service, legacy, kwargs)
Expand All @@ -152,7 +152,7 @@ def _query_wqp(
response = _query_with_retry(
url, payload=kwargs, delimiter=delimiter, ssl_check=ssl_check, adapter="wqp"
)
df = _read_wqp_csv(response.text)
df = read_code_csv(response.text)
# Only get_results documents the appended DateTime columns and the
# activity-start sort, so the other collections keep their parsed shape.
if service == "Result":
Expand Down
83 changes: 83 additions & 0 deletions tests/_csv_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Contract of the shared code-preserving CSV reader.

Component layer: exercises ``dataretrieval._csv`` directly, with no service and no
HTTP. The adapters' own tests (``wqp_test.py``, ``waterdata_test.py``,
``nwdc_test.py``, ``rdb_test.py``) cover how each getter uses it.
"""

import pandas as pd
import pytest

from dataretrieval._csv import _is_code_column, code_columns, read_code_csv


@pytest.mark.parametrize(
"name",
[
"USGSpcode",
"Location_HUCEightDigitCode",
"Activity_TypeCode",
"stateFips",
"countyFips",
"MonitoringLocationIdentifier",
"huc12_id",
"huc_cd",
"parameter_cd",
],
)
def test_is_code_column_flags_codes_and_identifiers(name):
"""A code suffix, the RDB ``_cd`` abbreviation, or a code vocabulary qualifies."""
assert _is_code_column(name)


@pytest.mark.parametrize(
"name",
[
"ResultMeasureValue",
"resultCount",
"Activity_StartDate",
"value",
"year_month",
"dec_lat_va",
# A tally, though its name reads as an identifier.
"AlternateLocation_IdentifierCount",
],
)
def test_is_code_column_passes_over_values(name):
"""Measurement, count, and date names are left to pandas' inference."""
assert not _is_code_column(name)


def test_read_code_csv_preserves_leading_zeros():
"""Codes keep their significant zeros; value columns stay numeric.

A bare ``read_csv`` infers code columns as int/float and drops the zeros
without warning (``"00060"`` -> ``60``, HUC8 ``"07090002"`` -> ``7090002``).
"""
csv = (
"Location_HUCEightDigitCode,USGSpcode,ResultMeasureValue\n07090002,00060,1.5\n"
)

df = read_code_csv(csv)

assert df["Location_HUCEightDigitCode"].iloc[0] == "07090002"
assert df["USGSpcode"].iloc[0] == "00060"
assert df["ResultMeasureValue"].iloc[0] == 1.5


def test_read_code_csv_infers_counts():
"""A count stays numeric even though its name ends in ``Identifier`` + ``Count``."""
csv = "AlternateLocation_IdentifierCount,USGSpcode\n0,00060\n"

df = read_code_csv(csv)

assert pd.api.types.is_numeric_dtype(df["AlternateLocation_IdentifierCount"])
assert df["AlternateLocation_IdentifierCount"].iloc[0] == 0
assert df["USGSpcode"].iloc[0] == "00060"


def test_code_columns_selects_by_name():
"""The dtype map holds the code columns only, for a caller that parses itself."""
names = ["huc_cd", "site_no", "resultCount", "USGSpcode"]

assert code_columns(names) == {"huc_cd": str, "USGSpcode": str}
4 changes: 2 additions & 2 deletions tests/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ The suite uses four dependency-oriented layers without moving established tests:
`wqp_test.py`, `nldi_test.py`, `streamstats_test.py`): service request construction,
response parsing, and documented protocol behavior.
- **Component** (`transport_test.py`, `waterdata_chunking_test.py`,
`waterdata_queryables_test.py`, `rdb_test.py`): one internal responsibility in
isolation.
`waterdata_queryables_test.py`, `rdb_test.py`, `_csv_test.py`): one internal
responsibility in isolation.
- **Cross-component** (`architecture_test.py`, `headers_host_scoping_test.py`,
`waterdata_progress_test.py`): dependency fitness functions and behavior that
spans adapters, OGC, transport, or security boundaries.
Expand Down
Loading