Make failures actionable and harden validation, APIs, and CI - #387
Make failures actionable and harden validation, APIs, and CI#387thodson-usgs wants to merge 11 commits into
Conversation
595a8ef to
b0fd59d
Compare
Four message defects found reviewing DOI-USGS#387, all of the shape the new AGENTS.md rule names: following the remedy literally must produce a working call. nldi._validate_feature_source_comid checked the feature pair before the origin conflict, so `get_features(comid=13294314, feature_id='X')` was told "Pass both, e.g. feature_source='WQP'" -- and the corrected call then raised "Provide exactly one of comid or feature_source". Two round trips for one mistake, in both directions of the pair. A comid now rejects either half up front, and both checks offer the same two ways forward from one shared hint. nwdc._resolve_locations closed its empty-value complaint with "(exactly one of state, county, or huc must be given)" -- but `state=[]` did give exactly one, so the parenthetical sent the caller to change a different selector than the one at fault. _nwdc_error_detail is annotated `str | None` and returned `body["detail"]` unguarded; a validation envelope spells that as a list of error objects, which would have been interpolated verbatim into the message. It also appended a period to a detail that already ended in one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Point agents at the shared vocabulary and authoritative dependency map, replace stale file inventories with placement rules, document the current quality gates, and correct the definition of a configuration setting.
Centralize the recurring missing, grouped, alternative, and conflicting argument checks. Adopt them across adapters, make every rejection name an executable remedy, prevent NLDI and nearest-observation corrections from producing silent wrong answers, and keep the associated NLDI tests offline.
Expose the OGC engine's existing max_rows limit through get_cql so callers can cap total results rather than accidentally turning a small page size into hundreds of requests.
Enable branch coverage, close behaviorally meaningful gaps, exclude only generated or environment-unreachable paths, and set the threshold at the measured value so future regressions fail without encouraging hollow tests.
Correct stale service and collection documentation, make the date-formatting hint parameter describe rather than imply enforcement, and document how to interpret the coverage threshold when local platform skips differ from CI.
Move the coverage gate into the existing test matrix, keep Windows informational where POSIX-only tests skip, and exclude version-conditional branches so every supported Linux interpreter measures the same ratchet.
Add the permissive any-of shape and the closed-vocabulary remedy extension needed by legacy NWIS and CQL callers, then replace the remaining hand-written recurring checks without changing their caller-facing intent.
Record the public behavior changes in NEWS and remove comments that merely restated measured values or nearby code while retaining rationale and constraints.
Add the five inhabited US territories to state normalization, allow Water Data and NGWMN queries the upstream services already support, and make endpoint-specific remedies use arguments their public getter accepts.
Consolidate shared message rendering and context handling, finish adoption in NLDI, NWIS, states, and ratings, return validated selections instead of re-deriving them, and standardize malformed argument values on ValueError while improving the NWIS ratings replacement guidance.
a939ca4 to
5d7ff50
Compare
| - name: Coverage ratchet | ||
| # Windows skips POSIX-only tests, so its number measures a smaller | ||
| # suite. Negative condition: a matrix edit cannot silently unenforce it. | ||
| if: runner.os != 'Windows' |
There was a problem hiding this comment.
Aren't these tests run with Linux on GitHub?
There was a problem hiding this comment.
The test matrix is defined a few lines earlier https://github.com/thodson-usgs/dataretrieval-python/blob/5d7ff503220332438cb27b698725926f8c794c3e/.github/workflows/python-package.yml#L128-L135 Which the diff won't show.
ehinman
left a comment
There was a problem hiding this comment.
Interesting work with the error message building. Happy to see additional states and territories added.
| and only those. They are the endpoint's own state | ||
| parameters *as the caller spells them*: the mutual-exclusion guard below is | ||
| proof the getter accepts them as keyword arguments. ``into`` is deliberately | ||
| not offered, because it is a wire queryable that need not exist on the |
| offered = dict.fromkeys(n for n in (into, *reject) if n in reject) | ||
| raise ValueError( | ||
| f"{err} Pass {' or '.join(offered)} instead -- they take the " | ||
| f"values the API itself uses, unnormalized." |
There was a problem hiding this comment.
I don't think this error would make sense to a user, since they wouldn't know what is normalized vs not normalized.
| filter matching more rows than ``limit`` still returns every matching | ||
| row across multiple pages, so a small ``limit`` makes *more* requests, | ||
| not fewer. Use ``max_rows`` to cap the total instead. | ||
| max_rows : int, optional |
There was a problem hiding this comment.
Why is this an input? Trying to think of when someone would want to use this...
| ``monitoring_location_id`` is what it groups by, so a ``properties`` list | ||
| omitting either silently collapses every site into one row per target | ||
| rather than failing. Added rather than rejected: the caller asked for | ||
| columns, not for a lecture about which ones this getter needs. |
| # ever returns one rather than leaking a bare pandas exception. | ||
| raise DataRetrievalError( | ||
| f"NWDC returned an empty response body (URL: {response.url})." | ||
| f"NWDC returned an empty response body (URL: {response.url}). " |
There was a problem hiding this comment.
Did this happen and that's why there's a guard against it?
| ] | ||
| # The major filters each query function accepts, hoisted beside the service | ||
| # lists so the checks and their remedies read from one roster. | ||
| _WATERDATA_MAJOR_FILTERS = ("site_no", "stateCd") |
There was a problem hiding this comment.
Why is this named WATERDATA? It's confusing since there's a whole module for water data.
| require_together( | ||
| {name: kwargs.get(name) for name in _WATERDATA_BBOX_CORNERS}, | ||
| context="to describe a bounding box", | ||
| remedy=( |
There was a problem hiding this comment.
Seems like all these remedies could be in a table somewhere rather than hidden in the functions?
| - name: Coverage ratchet | ||
| # Windows skips POSIX-only tests, so its number measures a smaller | ||
| # suite. Negative condition: a matrix edit cannot silently unenforce it. | ||
| if: runner.os != 'Windows' |
| @@ -1,3 +1,7 @@ | |||
| **08/20/2026:** The `state` filter now accepts the five US territories. `dataretrieval.codes.states` held the 50 states and DC, so `ngwmn.get_sites(state='Puerto Rico')`, `waterdata.get_monitoring_locations(state_name='Puerto Rico')` via the unified `state`, and `nwdc.get_wateruse(state='PR')` were refused locally -- while all three services carry the data (NGWMN answers with 36 Puerto Rico monitoring locations, the Water Data monitoring-locations collection returns Puerto Rico sites, and legacy NWIS lists 1,148 stream sites for `stateCd=PR`). American Samoa, Guam, the Northern Mariana Islands, Puerto Rico and the US Virgin Islands are now in both code tables under their real ANSI/FIPS codes, so every encoding resolves: `'Puerto Rico'`, `'PR'`, `'72'` and `'US:72'` all normalize alike. **Behavior change:** a territory that used to raise `ValueError` now produces a request. A value the table genuinely does not hold still fails fast. | |||
|
|
|||
| **08/20/2026:** Argument checks now share one vocabulary, and every rejection names a move the caller can execute. `dataretrieval._validation` owns the message shape for each check that recurs across the adapters -- a value outside a closed vocabulary (`require_one_of`), a missing argument (`require_argument`, `require_together`), a query with no filter at all (`require_any_of`), and arguments that conflict (`require_exactly_one`, `reject_together`) -- so a new check cannot invent its own phrasing, which is how `get_reference_table` came to tell callers who passed a bad `collection` that their *code service* was invalid. Each check takes the caller's own spelling of the parameter and a remedy for the move it cannot derive, and every check raises `ValueError` -- one class for a bad argument value. **Behavior change:** the text of those rejections moves with them -- `"Unrecognized service: 'x'. get_record serves …"` is now `"Invalid service: 'x'. Valid options are: …"`, and the major-filter and bounding-box complaints in `query_waterdata` / `query_waterservices` are rendered in the shared form. **Behavior change:** the deprecated `nwis` query entry points (`query_waterdata`, `query_waterservices`, `get_record`) now answer a missing major filter, an incomplete bounding box, or an unknown service with `ValueError` rather than their historic `TypeError` -- `TypeError` remains for a genuinely mistyped argument, such as a non-string `sites`. Code catching `TypeError` there, or matching on the old strings, must update. **Bug fix:** a `None` passed as a major filter (`query_waterservices(service='dv', sites=None)`) counted as a filter and reached the service as an empty `sites=`; `None` now means not supplied, and the call is refused with the filters that would have served. **Bug fix:** four messages told callers to do something that raised again or named a parameter their getter does not accept -- `nldi.get_features(comid=…, feature_id=…)` said to supply the missing half of the feature pair, and the corrected call then failed on the conflict with `comid`; `nwdc` answered `state=[]` by saying exactly one of `state`, `county` or `huc` must be given, when exactly one was; `codes.states.apply_state` offered NGWMN callers a `state_name` / `state_code` parameter no NGWMN getter accepts; and `BaseMetadata` pointed NGWMN and NWDC callers at a Water Data getter. **Bug fix:** `nldi.get_features(navigation_mode=…)` without a `data_source` spelled `None` into the URL path and returned an empty frame from a 200; it now raises and names the source to pass. **Behavior change:** `waterdata.get_nearest_continuous` appends `time` and `monitoring_location_id` to an explicit `properties` list rather than honoring it verbatim -- omitting either silently collapsed every monitoring location into one row per target, a wrong answer rather than an error -- so a caller who passed `properties=['time', 'value']` now gets a third column. **Behavior change:** `nwis.query_waterdata` serves `'peaks'` only; the `'ratings'` URL it used to build was never an NwisWeb program and answered with an HTML error page. `nwis.get_record(service='ratings')` is unaffected -- it routes to `get_ratings`, which is served from a different endpoint. New: `waterdata.get_cql(..., max_rows=N)` caps the total rows a CQL query returns. | |||
There was a problem hiding this comment.
Suggest using term "user" rather than "caller". This is more relevant to people reading the news.
|
Also, it looks like |
Summary
This PR started as an agent-documentation refresh and an error-message cleanup. Exercising each proposed remedy exposed several cases where following the message caused a second failure or, worse, returned a plausible but wrong result. The scope now covers the resulting validation, correctness, API, geographic-code, test, and CI changes as one caller-correctness pass.
Actionable argument validation
dataretrieval._validationnow owns the recurring argument-check shapes and their message vocabulary:require_one_ofrequire_argumentrequire_togetherrequire_any_ofrequire_exactly_one(name, value)reject_togetherThe helpers are adopted across NLDI, NWDC, NWIS, WQP, OGC dates, Water Data CQL/reference/nearest, and state normalization. Messages identify the caller's parameter spelling, state the problem, and name a correction the caller can execute.
The ratings
file_typevalidator remains local deliberately: it validates a collection and reports every invalid member, whilerequire_one_ofvalidates one scalar. Their shared formatting is centralized throughrender_options; adding a second collection-helper interface for one caller would be speculative.Compatibility note
Malformed argument values now consistently raise
ValueError. The deprecatednwis.query_waterdata,query_waterservices, andget_recordpaths therefore no longer use their historicTypeErrorfor missing filters, incomplete bounding boxes, or unknown services.TypeErrorremains for genuinely mistyped arguments such as a non-stringsitesvalue.Correctness fixes found by executing the remedies
data_sourcebefore constructing a navigation URL. A missing source can no longer become a literal/Nonepath whose 200 response looks like a genuine empty result.waterdata.get_nearest_continuousaddstimeandmonitoring_location_idto an explicitpropertieslist because both are required to select one row per target and monitoring location. This is a documented returned-column-shape change.nwdcempty selectors and error envelopes now produce actionable text rather than advice that repeats the same failure or prints a raw validation-object list.query_waterdata("ratings", ...)route now points towaterdata.get_ratings, including the dict return shape and feature-ID keys callers need to use it.nldi._query_nldistill turns a 200 non-JSON body into an empty GeoDataFrame. That is an existing documented exception, not changed here, and is called out inAGENTS.mdso it is not mistaken for the general error policy.Additional API and data behavior
waterdata.get_cql(..., max_rows=N)exposes the OGC engine's existing total-row cap.limitremains page size; a smalllimitalone can increase request count rather than cap results.US:<FIPS>forms normalize alike for adapters whose upstream service supports them.countries, and its hand-maintained reference docstring is contract-tested against that vocabulary.NEWS.mdrecords the public behavior and exception changes.Coverage and CI
Coverage is now a branch-coverage ratchet rather than an informational number. The branch adds behavior-focused tests for dispatch, malformed response envelopes, NLDI navigation, nearest-row grouping, configuration fallbacks, date handling, pagination, and previously uncovered public getters.
The ratchet runs in the existing OS/Python test matrix, blocks on every non-Windows leg, and reports informationally on Windows where POSIX-only skips measure a smaller suite. Version-conditional and environment-unreachable import arms are excluded explicitly so Python 3.10, 3.13, and 3.14 enforce the same measurement. Complexity and dependency-direction jobs remain fast and independent.
Agent and contributor documentation
AGENTS.mdnow starts fromCONTEXT.md, explains placement rules rather than carrying a stale file inventory, names.importlinteras the authoritative dependency map, documents the current test/tooling gates, and records the executable-remedy policy.CONTEXT.mdcorrects the definition of a setting: a public keyword such asssl_checkis not automatically part of configuration resolution.CONTRIBUTING.mddocuments branch coverage, platform-dependent local measurements, complexity ratchets, import-contract ownership, and the periodic structural-health sweep.Validation
ruff check .andruff format --check .mypyin strict modelint-imports: 8 contracts keptxenonandcomplexipy