CONTEXT.mdis the shared vocabulary — getter, query, chunk, plan, fan-out, page, adapter, facade, leaf, transport, collection, profile, effective configuration, and the legacy names that are deliberately not renamed. Read it before writing code, docstrings, or commit messages. Two kinds of term are defined there and they bind differently (ADR 0013). A core term is ours and takes one spelling everywhere including identifiers, so a name in the code that conflicts with it is a defect. A domain term -- monitoring location, collection -- is fixed for prose only: each adapter keeps its own service's spelling in its parameters, sonwis.get_record(service="dv")and WQP'sStationare that service's vocabulary rather than drift. Each entry states which kind it is.- Architectural decisions and their rationale:
docs/source/architecture/decisions/(ADRs, referenced by number throughout the code and by.importlinter). - Contributor workflow, style, and the quality gates in detail:
CONTRIBUTING.md.
Use ls/grep for the file list; what follows is the placement logic, so you
can predict where a thing is defined.
dataretrieval/— the public surface is one adapter module per service, named for the service (nldi,nwdc,ngwmn,streamstats,wqp, and legacynwis); each owns that service's URLs, parameters, and response quirks.waterdata/is the one adapter large enough to be a package, split by collection family; itsapi.pyis a compatibility facade holding no logic. Everything else in the package is shared code the adapters depend on — configuration, credentials, progress, exceptions, code tables, response formats. Shared code below the adapter layer must not reference any particular service.dataretrieval/ogc/— the OGC API protocol code (chunk planning, filters, request building, response shaping). Shared by the two OGC services only;.importlinterrefuses any other importer.dataretrieval/transport/— service-neutral request code (HTTP, retry, pagination, fan-out). It names no service and no protocol, and is not public API.- Leading-underscore top-level modules are private; the dependency-free leaves are at the bottom of the stack so anything may use them without importing the rest of the package. Check for an existing leaf before writing a small helper.
.importlinterrecords where every module belongs. Itslayerscontract lists every top-level module in dependency order and isexhaustive = True, so it is both the authoritative statement of where a module belongs and the thing that fails when a new module has not been placed. Read it before adding a module or an import.tests/— flat, one*_test.pyper module or concern, organized into four dependency-oriented layers (public contract, adapter contract, component, cross-component) thattests/contracts/README.mddefines and assigns files to.architecture_test.pyholds the fitness functions a boundary checker cannot express (symbols,__all__, AST shape, cycles); pure module-to-module direction belongs in.importlinterinstead.docs/source/—reference/(one page per public module),userguide/(prose topics),architecture/(overview + ADRs),meta/(project docs),examples/(.nblinkfiles pointing atdemos/; the docs build executes them).demos/— one notebook per Water Data collection or topic, plushydroshare/mirroring them per service for HydroShare, andnwqn_data_pull/as a non-notebook batch-pipeline example. When adding a collection, add a demo alongside the existing ones and an.nblinkindocs/source/examples/.
R/,experiments/,build/,dist/,.kiro/, and anyUntitled*.ipynbor*_test.ipynbat the top level are untracked local scratch — don't edit, commit, or cite them.- Exclude
.claude/worktrees/from searches and edits; stale worktrees there add spurious results.
pip install .[test,nldi](CI uses pip, not uv, despiteuv.lock). Docs:pip install .[doc,nldi]. Gates:pip install -e .[metrics].- Python >= 3.10; the CI test matrix is 3.10, 3.13, 3.14.
- Lint:
ruff check .andruff format --check .(pinned to the version in.pre-commit-config.yamland the CI lint job — keep them aligned). - Tests:
coverage run -m pytest tests/ && coverage report, or focused likepytest tests/waterdata_test.py::test_mock_get_samples.coverage reportis a merge gate: branch coverage with afail_underratchet in[tool.coverage.report]. Cover the uncovered branch, not the number -- a test written only to mark a line as covered catches nothing and adds maintenance. If a path is unreachable, add it toexclude_alsowith a reason, or leave the ratchet unchanged. - Types:
mypy(strict = trueinpyproject.toml; CI runs it over the PR-merged-into-main, so baredict/listannotations fail there even if they pass on your branch). - Structure:
lint-imports,xenon --max-absolute C --max-modules B --max-average A dataretrieval, andcomplexipy dataretrieval(max complexity 10). All three gate merges. - Docs: install docs deps,
ipython kernel install --name "python3" --user, thenmake htmlfromdocs/.make docsadds doctest+linkcheck (network-dependent).
- The suite is offline by default:
addopts = "-m 'not live'". Tests marked@pytest.mark.livecall real USGS services and run on a schedule (.github/workflows/live-api.yml); run them locally withpytest tests/ -m live. - HTTP is mocked with
pytest-httpx'shttpx_mockfixture plus fixtures undertests/data/; keep new API tests offline. tests/conftest.pyrelaxes the fixture's strict-mode defaults and pins the fan-out env (API_USGS_CONCURRENT=1,API_USGS_RETRIES=0,API_USGS_STALL_TIMEOUT=0) plus a nonexistentDATARETRIEVAL_CONFIG, so tests are deterministic and never read a developer's real config. Concurrency and retry tests opt back in viamonkeypatch.setenvinside the test body.
Most callers here are programs — a script, a pipeline stage, an agent — so a message is the only channel through which a caller can correct itself. Every raise states the problem and then the action that fixes it, in that order.
- Name the remedy, not just the fault.
"Service not recognized"gives a caller nothing to try next; listing the services it does accept does. For a transport failure the remedy is whether to retry, andtransport.pagination. paginated_failure_message()is the model: cause, thenTo recover: …. - Don't invent a phrasing for a check that recurs.
dataretrieval/_validation.pyowns the wording for the shared cases — bad value in a closed vocabulary (require_one_of), missing argument (require_argument), incomplete group (require_together), no filter at all (require_any_of), and conflicting arguments (require_exactly_one,reject_together). Use one before hand-writing a message. A service-specific pointer is not a reason to hand-write: every check takes aremedy=for the action it cannot derive. Every check raisesValueError-- one class for a bad argument value, so a caller catches by error class rather than by which module rejected it. require_argumentreturns the narrowed value andrequire_exactly_onethe selected(name, value)pair, so use their results rather than re-testing forNoneto satisfy mypy — a second, unreachable message beside the first is how the two drift apart.- Paste the remedy back before trusting it. Whatever a message names must be
a real parameter of the function the caller called — not a private helper's
local, not a prose label — and following it literally must produce a working
call. Messages that read well have failed all three:
datetime_inputwas a private local no getter accepts,configure(Configuration(...))was a no-op that raised nothing becauseconfigureis a context manager,pip install dataretrieval[nldi]globs in zsh, and a navigation missing itsdata_sourcewroteNoneinto the URL and returned an empty frame. Run the corrected call against the real service; wording review does not catch these. - Shared checks take the caller's argument name.
_validate_data_source,_format_api_dates, andrequire_one_ofall accept aname=so the subject of the message is the argument that was passed. A helper that hard-codes one noun reports the wrong parameter the moment a second call site reuses it. - Prefer raising over returning something empty when the library cannot distinguish
an empty result from a service failure: a caller that gets an empty frame has
no signal to act on.
nldi._query_nldiis the deliberate exception — a 200 with a non-JSON body becomes an empty GeoDataFrame by design.
- HTTP client is
httpx(migrated fromrequestsin #289); new code useshttpxand tests mock withhttpx_mock. - Public getters return
(DataFrame, metadata). dataretrieval/__init__.pyimports the service modules by name and lists them in__all__; it does not star-import them, so a getter is accessed through its module (dataretrieval.nwis.get_record), never from the top level.nldiis deliberately absent — it needsgeopandasat import time, so it is imported on demand.dataretrieval/waterdata/__init__.pycontrols Water Data exports via__all__.- The
API_USGS_PATcredential is owned by thecredentialsleaf and applied as theX-Api-Keyheader bytransport.http.default_headers(), which sends it only to the host it belongs to. Never hard-code tokens in examples or tests. - Water Data request builders translate Python kwargs to API names
(
skip_geometry->skipGeometry,filter_lang->filter-lang); tests assert exact URLs and query params. - Multi-value OGC params are comma-joined GETs, except
monitoring-locationsandcombined-metadata, which POST CQL2 JSON because comma-joined values return no rows. The OGC edge WAF caps total request bytes (URL + body) at ~8200, sodataretrieval/ogc/chunking.pyauto-splits oversized queries across chunks (both GET and POST paths); preserve this when adding new list-shaped kwargs. - NLDI requires
geopandasat import time (pip install .[nldi]); other modules fall back to pandas when geopandas is absent.