Make errors actionable for programmatic callers, and refresh AGENTS.md - #387
Draft
thodson-usgs wants to merge 18 commits into
Draft
Make errors actionable for programmatic callers, and refresh AGENTS.md#387thodson-usgs wants to merge 18 commits into
thodson-usgs wants to merge 18 commits into
Conversation
AGENTS.md was 37 commits stale and never mentioned CONTEXT.md, so an agent reading it missed the shared vocabulary entirely. It also described a file layout that has since moved, which is worse than saying nothing: a wrong map is followed confidently. Replace the layout listing with the placement *logic* -- adapters are named for their service, shared machinery below them names none, `.importlinter`'s exhaustive `layers` contract is the authoritative map -- so an agent can predict where a thing lives and use `ls`/`grep` for the rest rather than carrying a file list in context. Corrections to claims that had drifted: the CI matrix is 3.10/3.13/3.14, and `__init__.py` imports the service modules by name rather than star-importing them, so a getter is reached through its module and `dataretrieval.get_record` is an AttributeError. In CONTEXT.md, the Setting entry implied every setting applies everywhere. It does not -- `concurrency` is meaningless to an adapter that issues one request at a time -- and `ssl_check` is a getter argument on four adapters rather than a setting at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_validation.py` exists because a check copied between adapters brings its
message along: `get_reference_table` told callers who passed a bad
`collection` that their *code service* was invalid, because the check came
from `samples.py` with the noun unchanged. It owned the wording for one
shape -- a value outside a closed vocabulary -- while two others recurred at
some twenty sites in as many phrasings, most naming no remedy at all.
Add the missing shapes: a missing argument (`require_argument`), an
all-or-none group (`require_together`), a choice between sufficient
alternatives (`require_exactly_one`), and arguments that conflict but are
jointly optional (`reject_together`).
`require_argument` returns the narrowed value rather than None. A void check
leaves every call site that feeds an optional into a required parameter
re-testing for None to satisfy mypy, which puts a second, unreachable message
beside the first -- and the two drift. Returning the value makes
`x = require_argument("x", x)` both the check and the narrowing, which is
also what keeps this change free of `type: ignore`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Most callers of this package are programs -- a script, a pipeline stage, an agent -- so the message is the only channel through which a caller can correct itself. `"Service not recognized"` gives one nothing to try next. Adopt the shared checks across the adapters and give the remaining hand-written raises an action. Then, because a remedy that reads well can still be unusable, every message was triggered for real and its remedy pasted back and run against the live service. That found three ways a well-written remedy still fails: It can name something the caller cannot use. `datetime_input` was a private helper's local, so correcting the argument the message named sent an unrecognized parameter; `_format_api_dates` and `_validate_data_source` now take the caller's spelling, so the subject is the argument actually passed. `configure(Configuration(api_key=...))` was unrunnable twice over -- the name is not bound unqualified, and `configure` is a context manager, so the bare call the message printed applied nothing and the retry went out unauthenticated, causing the very failure the message exists to prevent. `pip install dataretrieval[nldi]` globs in zsh and installs nothing. It can name values the service rejects. `query_waterdata` advertised 'ratings', which is not an NwisWeb program: the URL it builds answers 200 with an HTML error page. The bounding-box remedies omitted `coordinate_format='decimal_degrees'`, without which the completed four-corner box is refused the same way. `get_reference_table` rejected 'countries', a collection served beside 'counties'. It can point somewhere useless. Defunct getters said "no replacement available" where one exists, page-walk failures advised obtaining an API token on hosts that honour none, and rdb's HTML fallback linked a docs landing page with no status information instead of the decommission notice. Tests assert the remedy rather than the sentence: pinning exact phrasing freezes the wording and verifies nothing that matters to a caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both of these produced no error at all, which for an autonomous caller is worse than a raise: an empty or truncated frame reads as a finding. `get_features` told a caller supplying only `comid` that `navigation_mode` was the missing argument. Supplying it built `/comid/13294314/navigation/UM/None` -- the absent `data_source` interpolated into the path as the literal string "None" -- and the service answered 200 with an empty FeatureCollection, which `_features_to_gdf` deliberately turns into an empty GeoDataFrame. Indistinguishable from a navigation that genuinely has nothing on it. `data_source` is now required wherever a navigation is built, which covers the `comid` and `feature_source` origins alike, and the tail can no longer be None. `get_nearest_continuous` told callers to include 'time' in `properties`. A list that did -- `['time', 'value']` -- came back without `monitoring_location_id`, so `_select_nearest_rows` fell back to a single ungrouped batch and returned one row per target across all sites instead of one per (target, site). Two sites became one row, silently. The getter now injects both columns into a caller's `properties` rather than rejecting the list: the caller asked for columns, not for a lecture about which ones this getter needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_cql was the only Water Data getter without `max_rows`, and its `limit` is the page size. So the obvious way to ask for a few rows -- `limit=5` -- instead paged the entire match five rows at a time. An agent probing the getter that way spent ~400 requests of an hourly quota of 1000 in a single call before the service refused it, and nothing warned it: `limit` was documented only as "Page size, clamped server-side to 50,000", while the sibling getters spell the trap out and point at `max_rows`. The cap needed no new machinery. `get_ogc_data` already takes `max_rows` and the cql_body branch already forwards it as `row_cap`; get_cql simply never exposed the parameter. Measured live against a 10-row match: `limit=3` fetches 4 pages, `limit=3, max_rows=3` fetches 1. `limit`'s docstring now says what it does -- including that a small value makes more requests, not fewer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
thodson-usgs
force-pushed
the
docs/refresh-agents-context
branch
from
August 20, 2026 18:36
595a8ef to
b0fd59d
Compare
The suite is offline by default, and this test was the one exception: triggering the missing-``data_source`` check through a ``feature_source`` origin validates that source first, which fetches the NLDI catalog. The autouse fixture clears the catalog cache before every test, so the fetch was a live request -- 0.29s, one real socket, and a CI failure whenever NLDI is down, for an error that is entirely local. Found by running the suite under a socket spy; it and the deliberate localhost server in waterdata_chunking_test were the only two tests opening connections. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
``get_reference_table`` singularized the collection name three ways: the counties/countries special case, a strip-trailing-s arm, and a fallback for a collection that is not plural. All nineteen collections in ``METADATA_COLLECTIONS`` end in "s", and ``require_one_of`` rejects anything outside that closed vocabulary before this runs, so the fallback could not execute -- coverage found it as a permanently uncovered line. ``removesuffix`` collapses the two general arms into one that behaves identically for all nineteen (verified) and stays correct if a singular collection is ever added, which the deleted branch was there to guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage already ran in CI and reported a number nobody was accountable to. Make it a ratchet alongside xenon, complexipy, and lint-imports: branch coverage, `fail_under` set at the measured value in `[tool.coverage.report]`, so it fails on regression rather than demanding tests of a change that added none. Branches rather than lines, because that is where this package's bugs live: a dispatch arm routing to the wrong getter, an error path that never fires, a fallback that quietly becomes the norm. 96% -> 98.97%, from 67 tests that pin behaviour the suite was not asserting: - `get_record`'s service dispatch. Six near-identical arms, and every one forwards `sites` except `ratings`, which takes a scalar `site`. Nothing covered any of them. - The 200-with-an-HTML-error-page path, whose message this PR rewrote and which no test reached. - `get_features_by_data_source` -- an entire public getter with no test. - The deliberate empty-frame-on-non-JSON contract in `_query_nldi`, pinned because it reads like an oversight and is easy to "fix" into a raise that would crash a legitimately empty navigation. - `format_datetime`'s zone handling and its incomplete-date warning. - Config resolution when the filesystem will not answer: no home directory, a deleted working directory, an unreadable file. - The Windows home-variable precedence, which the memo has to agree with. Excluded rather than faked: `_version.py` (generated at build time) and the `ImportError`/`PackageNotFoundError` environment fallbacks, which the test extras make unreachable by construction -- reaching them means unimporting a module mid-run, which tests the import system rather than this package. The threshold carries two decimals on purpose: 98.97% renders as "99%" and would otherwise pass `fail_under = 99` on rounding alone. The remaining gap is concentrated in async fan-out internals, the shaping dialect defaults, and a few partial branches -- reachable, but each needs real scaffolding. Those are the next rungs, not exclusions. The blocking run is one Linux job; the OS matrix reports with `--fail-under=0`, since its POSIX-only skips measure a smaller suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings on this branch, all introduced by it.
`query_waterdata`'s docstring still advertised `'peaks' or 'ratings'` after
the service check narrowed to peaks-only, so the documented call
`query_waterdata("ratings", ...)` now raises. It points at `get_ratings`
instead. (`WATERDATA_SERVICES` keeps `'ratings'` on purpose -- `get_record`
still dispatches on it.)
`get_reference_table`'s docstring enumerates the vocabulary by hand and did
not gain `"countries"` when the collection did, so the prose denied a
collection the function serves. A test now pins that list against
`METADATA_COLLECTIONS`, since hand-maintained enumerations are exactly the
thing that drifts.
`allow_duration` read as a rejection switch but only chose wording inside
one message; `get_ratings` was safe solely because its own duration guard
runs first. The next caller passing `allow_duration=False` would have
accepted `time="P7D"` silently. Renamed to `advertise_duration`, and the
docstring now says in as many words that it does not enforce. Also documents
`name`, which was undocumented too.
On the coverage ratchet's margin: branch arcs can differ between
interpreters, and the threshold was measured on 3.12 while the blocking job
runs 3.14. Measured on both -- 98.97%, with identical statement and branch
counts -- so the threshold is verified against the interpreter that enforces
it. Recorded in the config. CONTRIBUTING now also says to use
`--fail-under=0` locally on Windows or without the nldi extra, where tests
skip and the local number falls under the gate through no fault of the
change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three of the new messages named a move that was wrong for some of the callers that can reach them. `codes.states` is a shared leaf: `to_state` is a public conversion with no `state` argument at all, and `apply_state` serves Water Data (`state_name`/`state_code`) and NGWMN (`state_name` for sites, `state` for providers). Hard-coding `state_name=`/`state_code=` in the table's rejection told a `ngwmn.get_providers(state="Puerto Rico")` caller to send a parameter that service does not have. The table now reports only what it knows -- no row for a territory -- and `apply_state`, which does know the endpoint's queryables, appends them. `BaseMetadata.site_info` is the metadata class for NGWMN and NWDC as well as Water Data, so pointing every caller at `waterdata.get_monitoring_locations` misdirected two of the three. `_get_features_request` required `data_source` before validating `navigation_mode`, so a mistyped mode was answered with a message about a different argument; the caller fixed that, re-ran, and only then heard about the typo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`apply_state` built its territory remedy from `(into, *reject)`. `into` is
the wire queryable the endpoint filters on, not necessarily a parameter the
getter accepts, so on NGWMN the message named an argument that does not
exist:
ngwmn.get_sites(state="Puerto Rico")
-> "... Pass state_name instead ..."
ngwmn.get_sites(state_name="Puerto Rico")
-> TypeError: unexpected keyword argument 'state_name'
and on `providers`, where the queryable *is* `state`, following the remedy
re-raised the identical error. Only `reject` proves a spelling reaches the
getter -- the mutual-exclusion guard is that proof -- so the remedy is now
drawn from `reject` alone, with `into` leading when it appears there too
(the Water Data message is unchanged). An endpoint with an empty `reject`
has no alternative to offer and appends nothing.
The NGWMN `state` docstrings said "State/territory filter"; territories have
never been accepted there, so they now say so.
Also documents that `get_nearest_continuous` adds `time` and
`monitoring_location_id` to a caller's `properties` list, since the returned
frame carries columns the caller did not request.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
…gain The ratchet was bolted onto the `complexity` job, which has no `needs:` and previously ran only fast structural checks. It installed the full test stack and ran the whole suite there, so the suite executed 7x per PR and xenon, complexipy and import-linter no longer failed fast. It moves to the test matrix, which already runs that suite, and blocks on every leg but Windows -- where POSIX-only tests skip and the number measures a genuinely smaller suite. Phrased as "not Windows" rather than by naming one leg, so editing the matrix cannot leave the gate silently unenforced. That only works if every enforcing interpreter measures the same number, and they did not: 3.10 read 98.95% against 3.14's 98.97%, because the `tomllib` arm of the version-conditional import in `_toml_parser` is dead there while the `tomli` arm it does take is already excluded. Version-conditional code is now excluded outright -- one arm of it is dead on every interpreter by construction. Measured 3693/20/970/28 = 98.97% identically on 3.10, 3.13 and 3.14. The exclusion patterns are also anchored to the start of a statement. They were matched anywhere on a line, so `except ImportError` named in a comment or a string would have silently excluded whatever block followed it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_validation` landed with the claim that it owns the wording for the checks that recur, but only `nldi` adopted it. Every other site that should have had a reason the module could not serve it, so each stayed hand-written -- which is the drift the module exists to end, reintroduced in the commit that created it. The module now serves them. `error=`: a check raises the caller's exception class. `nwis` has answered a malformed query with `TypeError` since long before this module existed, and a deprecated module cannot start raising `ValueError` without breaking the handlers written against it. The exception type is the caller's spelling in the same sense the parameter name already was. `remedy=` on `require_one_of`: a vocabulary narrower than the service's needs both halves -- the options this function takes, and how to reach the rest. There is no derived remedy to override here, so it is added rather than substituted; that gap is why `get_cql` hand-wrote its collection check in the same commit that added the rule against hand-writing. `require_any_of`: "at least one filter" was written twice in `nwis` and had no shape here. Six messages unify as a result, and `sites=None` no longer counts as a major filter -- it satisfied `key in kwargs` and reached the service as an empty `sites=`, which is the wrong-answer shape the AGENTS.md rule names. The `WATERDATA_SERVICES` constant now says why it is wider than what `query_waterdata` reaches, rather than reading as a stale routing table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch changes rejection wording across nwis, adds get_cql(max_rows=), and makes get_nearest_continuous append two columns to an explicit properties list -- a returned-column-shape change that until now lived only in a private helper's docstring. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeps the constraint, drops the narration: measured coverage figures and the interpreter that produced them, the paragraph naming which modules the remaining gap sits in, and a comment describing what require_together does next to the call. Where a comment survives, it says why the code is shaped that way, in a line or two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 20-agent A/B run over this branch measured how many tokens an agent needs to fix a bad call. Three results came out of the traces. The most expensive task in the experiment -- 6.8k output tokens, 816k total, and the one case the branch barely improved -- was a caller asking NGWMN for Puerto Rico. Agents spent four to six tool calls spelunking through ``ogc/engine`` to hand-build a raw request, and succeeded: NGWMN answers ``state_name='Puerto Rico'`` with 36 monitoring locations. Water Data returns Puerto Rico sites too, and legacy NWIS lists 1,148 for ``stateCd=PR``. The table was blocking data all three services carry, so the fix is the table, not the message. ``get_sites(state='Puerto Rico')`` now returns those 36 rows. The ``query_waterdata`` service remedy pointed at ``nwis.get_ratings(site=...)``, which is ``@_deprecated`` and keyed by a bare site number. An agent following it paid for a DeprecationWarning and then a second discovery of the AGENCY-ID form; one trial took five attempts. It now names ``waterdata.get_ratings(monitoring_location_id='USGS-01646500')``. The two tasks the branch won most (-28%, -44%) had remedies carrying a concrete value; the one it lost (+10%) named parameters only. The major-filter remedies now carry example values. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round-2 traces: with the remedy naming waterdata.get_ratings, source reading fell from 9/10 to 2/10 -- and both survivors opened waterdata/ratings.py for the same reason, to learn what the call returns. Every trial in both arms ended at ratings["USGS-01646500.exsa.rdb"], a key no caller can guess from a function name. A remedy the caller has to follow with a source read has not finished naming the move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related changes: the agent-facing docs were stale, and the error messages those docs point at often told a caller what went wrong without telling it what to do.
1. Errors say what to do, not just what went wrong
Most callers of this package are programs — a script, a pipeline stage, an agent. A message is the only channel through which such a caller can correct itself, and
"Service not recognized"gives it nothing: it cannot enumerate the services, cannot tell whether a retry is worth attempting, and cannot find the argument it left out.The taxonomy already knew this in places —
transport.pagination.paginated_failure_message()spells outTo recover: …, and the configuration errors name the table to write and the keys it accepts. The adapters mostly did not. This applies the same standard outward from the transport layer.Shared checks.
_validation.pyowned one of the three checks that recur across adapters — a value outside a closed vocabulary. The other two, a missing argument and arguments that conflict, were hand-written at ~20 sites in as many phrasings, almost none naming a fix. They now joinrequire_one_of:require_argumentrequire_togetherlat/long)require_exactly_onereject_togetherrequire_argumentreturns the value it validated. Validating in one place and then re-testing forNoneto satisfy mypy leaves a second, unreachable message beside the first, and two messages for one condition drift apart.Before / after:
Also covered: NLDI's fourteen origin checks, the NWIS service rejections, and single messages in
nwdc,ogc/dates,rdb,wqp,codes/states,waterdata/nearestandwaterdata/ratings.nldi._query_nlditurns a 200 with a non-JSON body into an emptyGeoDataFrame. A caller cannot distinguish that from a genuine empty result, which is the worst case for an autonomous one — but it is a documented contract (_features_to_gdf: "return an empty GeoDataFrame … instead of crashing"), not an oversight, so changing it is your call rather than a side effect of this PR. It is recorded in AGENTS.md as the exception to the rule it breaks.Tests assert the remedy, not the sentence: what must hold is that the message names a move the caller can make. Pinning exact phrasing would freeze the wording and test nothing that matters.
2. AGENTS.md refresh
Last touched 37 commits ago, and five statements were no longer true:
dataretrieval/waterdata/chunking.pydataretrieval/ogc/chunking.pywaterdata.utils._default_headers()transport.http.default_headers(); the credential is owned by thecredentialsleaf and scoped to its hostrequires-python = ">=3.10"; no version skips remaintests/nwis_test.py::test_nwis_service_live@pytest.mark.live, deselected byaddoptsand run bylive-api.ymlUSGS_NGWMN_Examples.ipynbwhile asserting anything unlisted was scratchIt also never pointed at
CONTEXT.md, and predated the structural gates (lint-imports,xenon,complexipy,mypy --strict) — so a change could pass the tests and still fail the merge. Rewritten around where a thing lives and by what rule, with.importlinternamed as the authoritative map since itslayerscontract isexhaustive = True. Adds an Error messages section recording the convention above.3. CONTEXT.md
Verified accurate throughout — every glossary claim still matches the code. One exception: the Setting entry cited
ssl_check, which is a plain getter kwarg on four adapters and resolves through no chain. Replaced with settings that are in the roster, plus the distinction it was blurring.Testing
1015 passed. All merge gates clean locally:ruff check/format,mypy(strict),lint-imports(8 contracts),xenon,complexipy.🤖 Generated with Claude Code