Skip to content

Commit 115a230

Browse files
thodson-usgsclaude
andcommitted
refactor(waterdata): make parallel_chunks take an integer, not low/medium/high
Replace the three-tier "low"/"medium"/"high" enum with a plain positive integer: parallel_chunks(n) fans a call out into n sub-requests. More expressive (any n, not just 2/8/32), precise, and mirrors the int-valued API_USGS_CONCURRENT. Removes ParallelChunksLevel, the _LEVEL_CAPS/_MAX_PARALLEL_CHUNKS constants, and _resolve_level; validation is now an inline positive-int check (rejects 0, negatives, floats, bool, str). n is bounded below by the byte-limit minimum and above by the number of values to split; n=1 is an explicit no-op. Docstrings, NEWS, user guide, README, exports, and tests updated; 2/8/32 remain as documented examples. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2b2f5f0 commit 115a230

9 files changed

Lines changed: 166 additions & 236 deletions

File tree

NEWS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
**07/01/2026:** Added `waterdata.parallel_chunks(...)` — a context manager to control how finely the OGC `waterdata` getters split multi-value requests. By default the getters chunk only as much as the server's ~8 KB URL limit forces (the fewest sub-requests). But because every sub-request paginates, splitting a large result further costs little or no extra quota when each sub-request still spans many pages, so a caller who *knows* their pull is large can opt into a finer split for smoother progress, more even concurrency, and a smaller unit of retry/resume: `with waterdata.parallel_chunks("high"): df, md = waterdata.get_daily(monitoring_location_id=many_sites)`. The level is one of `"low"`, `"medium"`, or `"high"` — typed as `waterdata.ParallelChunksLevel` (a `typing.Literal`), so a type checker rejects any other value and an invalid string raises `ValueError` at the `with`. Each level caps the *total* number of sub-requests the call is split into — `"low"` / `"medium"` / `"high"` cap at 2 / 8 / 32 overall, across every multi-value argument combined, not per argument. That ceiling is a parallel_chunks constant, deliberately independent of the fan-out concurrency (`API_USGS_CONCURRENT`): how finely a query splits is orthogonal to how many sub-requests run at once. Capping the aggressive end at 32 bounds the blast radius — an accidental `"high"` on a huge list, or a call with several multi-value arguments, can't explode into thousands of sub-requests. There is no "off" level: not entering the block *is* off. It is deliberately a scoped `with` block rather than an automatic behavior or an environment variable, since the library can't tell in advance whether a query is large — a short-window query might fit in a single page, where extra chunks would only burn quota. Also exported at the top level as `dataretrieval.parallel_chunks`.
1+
**07/01/2026:** Added `waterdata.parallel_chunks(n)` — a context manager to control how finely the OGC `waterdata` getters split multi-value requests. By default the getters chunk only as much as the server's ~8 KB URL limit forces (the fewest sub-requests). But because every sub-request paginates, splitting a large result further costs little or no extra quota when each sub-request still spans many pages, so a caller who *knows* their pull is large can opt into a finer split for smoother progress, more even concurrency, and a smaller unit of retry/resume: `with waterdata.parallel_chunks(32): df, md = waterdata.get_daily(monitoring_location_id=many_sites)`. `n` is a positive integer (e.g. `2`, `8`, `32`) — the number of sub-requests to fan the call out into; a non-integer or non-positive value raises `ValueError` at the `with`. It caps the *total* sub-request count across every multi-value argument combined (not per argument), bounded below by what the byte limit already forces and above by how many values there are to split, so several multi-value arguments can't multiply past it and `n=1` asks for no extra fan-out. Each sub-request costs a request against your hourly rate limit, and because how many run *at once* is capped separately by `API_USGS_CONCURRENT` (default 32) an `n` beyond that adds quota without adding parallelism, so the useful range is roughly `2` up to `API_USGS_CONCURRENT`. It is deliberately a scoped `with` block rather than an automatic behavior or an environment variable, since the library can't tell in advance whether a query is large — a short-window query might fit in a single page, where extra chunks would only burn quota. Also exported at the top level as `dataretrieval.parallel_chunks`.
22

33
**06/23/2026:** **Breaking change (1.2.0):** the minimum supported Python is now **3.10** (`requires-python = ">=3.10"`). 3.9 support was already effectively broken — the `waterdata` module's dependencies (`anyio`, the test stack) require 3.10+, and the `waterdata` test modules already skipped on <3.10. `anyio` is now declared as a direct dependency (it is imported directly by `waterdata`), and the CI/ruff/mypy targets move to 3.10. Also fully removed the deprecated `variable_info` metadata property: the `NWIS_Metadata` override only warned and returned `None` (it relied on the defunct `get_pmcodes`), and the `BaseMetadata` abstract is gone too since nothing implemented it — accessing `.variable_info` now raises `AttributeError`. `site_info` is unaffected.
44

README.md

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,12 @@ By default the getters split a multi-value request only as far as the server's
111111
~8 KB URL limit forces — the fewest sub-requests. For a **large, paginated**
112112
pull that is needlessly conservative: every sub-request pages through its own
113113
results, so dividing the query into more, smaller sub-requests lets those pages
114-
be fetched **in parallel**. `parallel_chunks` opts a single call into that
115-
finer split. It pays off only when the result is large enough to span many
116-
pages *and* the query has a multi-value argument to divide (such as a list of
117-
monitoring locations); on a small query — or one with nothing to split — it just
118-
adds requests, so it is a deliberate, scoped `with` block, never the default.
114+
be fetched **in parallel**. `parallel_chunks(n)` opts a single call into that
115+
finer split, fanning it out into `n` sub-requests. It pays off only when the
116+
result is large enough to span many pages *and* the query has a multi-value
117+
argument to divide (such as a list of monitoring locations); on a small query —
118+
or one with nothing to split — it just adds requests, so it is a deliberate,
119+
scoped `with` block, never the default.
119120

120121
```python
121122
from dataretrieval import waterdata
@@ -124,34 +125,35 @@ from dataretrieval import waterdata
124125
# enough to span many pages, so it profits from a finer split.
125126
sites, _ = waterdata.get_monitoring_locations(state="Ohio", site_type_code="ST")
126127

127-
with waterdata.parallel_chunks("high"): # "low" | "medium" | "high"
128+
with waterdata.parallel_chunks(32): # fan out into 32 sub-requests
128129
df, md = waterdata.get_daily(
129130
monitoring_location_id=sites["monitoring_location_id"].tolist(),
130131
parameter_code="00060", # discharge
131132
time="2004-01-01/2023-12-31",
132133
)
133134
```
134135

135-
`"high"` fans a call out into up to 32 sub-requests, `"medium"` up to 8, and
136-
`"low"` up to 2 — a fixed ceiling, so an accidental setting on a huge list can't
137-
explode into thousands of requests.
136+
`n` is the number of sub-requests to fan the call out into. It is capped by how
137+
many values there are to split, and each sub-request costs a request against
138+
your hourly [rate limit](https://api.waterdata.usgs.gov/signup/); since how many
139+
run *at once* is capped separately by `API_USGS_CONCURRENT` (default 32), the
140+
useful range is roughly `2` up to that value.
138141

139142
Benchmark — 271 Ohio discharge sites (`get_daily`, `parameter_code="00060"`),
140-
cold cache, each level run against its own time window so results are not
141-
cache-served, with the page size fixed so every level fetches roughly the same
143+
cold cache, each `n` run against its own time window so results are not
144+
cache-served, with the page size fixed so every run fetches roughly the same
142145
number of pages (isolating the effect of parallelism):
143146

144-
| level | parallelism | pages | wall-clock | speedup |
145-
| -------------- | ----------- | ----- | ------------------------- | ------- |
146-
| `default` | 1 | 32 | 23.9 s / 27.4 s (2 runs) ||
147-
| `"medium"` (8) | 8 | 37 | 4.1 s / 4.3 s | ~|
148-
| `"high"` (32) | 32 | 44 | 2.0 s | ~12× |
147+
| `n` | parallelism | pages | wall-clock | speedup |
148+
| ---- | ----------- | ----- | ------------------------- | ------- |
149+
| off | 1 | 32 | 23.9 s / 27.4 s (2 runs) ||
150+
| `8` | 8 | 37 | 4.1 s / 4.3 s | ~|
151+
| `32` | 32 | 44 | 2.0 s | ~12× |
149152

150153
The gain comes from overlapping each sub-request's per-page latency and
151154
server-side work — a genuinely large pull at the default page size shows a
152-
similar multi-fold speedup. The extra sub-requests each cost one request against
153-
your hourly [rate limit](https://api.waterdata.usgs.gov/signup/), so reserve the
154-
aggressive levels for pulls you know are large.
155+
similar multi-fold speedup. The extra sub-requests each cost quota, so reserve a
156+
large `n` for pulls you know are large.
155157

156158
Visit the
157159
[API Reference](https://doi-usgs.github.io/dataretrieval-python/reference/waterdata.html)

dataretrieval/__init__.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@
4444
URLTooLong,
4545
)
4646

47-
# Parallel-chunks control (a context manager) and its level type. Defined with
48-
# the chunker in ``dataretrieval.ogc.chunking``; surfaced here for a stable
49-
# public path ``from dataretrieval import parallel_chunks``.
50-
from dataretrieval.ogc.chunking import ParallelChunksLevel, parallel_chunks
47+
# Parallel-chunks control (a context manager). Defined with the chunker in
48+
# ``dataretrieval.ogc.chunking``; surfaced here for a stable public path
49+
# ``from dataretrieval import parallel_chunks``.
50+
from dataretrieval.ogc.chunking import parallel_chunks
5151

5252
# Resumable chunk-interruption exceptions. They are defined in
5353
# ``dataretrieval.ogc.interruptions`` rather than ``dataretrieval.exceptions``
@@ -100,6 +100,5 @@
100100
"ServiceInterrupted",
101101
# parallel-chunks control (defined in ogc.chunking)
102102
"parallel_chunks",
103-
"ParallelChunksLevel",
104103
"__version__",
105104
]

dataretrieval/ogc/chunking.py

Lines changed: 43 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
1212
Parallel chunks: the planner is conservative by default — it splits only as far as
1313
the byte limit forces. A caller who knows their result is large can opt into a
14-
finer split via the ``parallel_chunks`` context manager
15-
(``"low"`` / ``"medium"`` / ``"high"``); the resolved cap drives
16-
:meth:`ChunkPlan._refine`. See ``parallel_chunks`` for the why and the when.
14+
finer split via the ``parallel_chunks(n)`` context manager, which fans the query
15+
out into ``n`` parallel sub-requests; ``n`` drives :meth:`ChunkPlan._refine`. See
16+
``parallel_chunks`` for the why and the when.
1717
1818
This module owns the *execution* half — the event loop and bounded
1919
concurrency that drive a plan to completion (``ChunkedCall``) plus the
@@ -77,7 +77,7 @@
7777
from collections.abc import Callable, Iterator
7878
from contextlib import contextmanager
7979
from contextvars import copy_context
80-
from typing import Any, Literal, cast, get_args
80+
from typing import Any, cast
8181

8282
import httpx
8383
import pandas as pd
@@ -182,70 +182,16 @@ def get_active_client() -> httpx.AsyncClient | None:
182182
# Parallel-chunks dial: opt-in to fan a query out *more finely* than the byte
183183
# limit alone requires. Scoped to a ``with parallel_chunks(...):`` block (a
184184
# ContextVar), deliberately NOT an env var (see :func:`parallel_chunks` for
185-
# why). The ambient holds the resolved cap on the plan's total sub-request
186-
# count; ``0`` (the default, outside any block) means "chunk only as much as
187-
# the byte limit needs".
185+
# why). The ambient holds ``n`` — the requested cap on the plan's total
186+
# sub-request count; ``0`` (the default, outside any block) means "chunk only as
187+
# much as the byte limit needs".
188188
_parallel_chunks: Ambient[int] = Ambient("ogc_parallel_chunks", 0)
189189

190-
#: The three accepted parallel_chunks levels, as a typing ``Literal`` so a type
191-
#: checker rejects any other value at the call site.
192-
ParallelChunksLevel = Literal["low", "medium", "high"]
193-
194-
#: Valid levels derived from the type, so ``ParallelChunksLevel`` stays the single
195-
#: source of truth for what ``_resolve_level`` accepts (mirrors the
196-
#: ``get_args``-based ``_VALID_ON_TIE`` / ``_VALID_FILE_TYPES`` in sibling
197-
#: modules).
198-
_VALID_LEVELS: tuple[ParallelChunksLevel, ...] = get_args(ParallelChunksLevel)
199-
200-
# The parallel_chunks ceiling on the plan's total sub-request count —
201-
# deliberately NOT tied to the concurrency default: fan-out *volume* (how many
202-
# sub-requests a query becomes) is orthogonal to how many run at once
203-
# (``API_USGS_CONCURRENT``). 32 is a sane ceiling on the whole call (across
204-
# every axis, not per axis — see ``ChunkPlan._refine``) that bounds the blast
205-
# radius of an accidental ``"high"`` on a very long list or several multi-value
206-
# arguments at once; the milder levels are a quarter and a sixteenth of it (the
207-
# three are spaced 4x apart).
208-
_MAX_PARALLEL_CHUNKS = 32
209-
_LEVEL_CAPS: dict[str, int] = {
210-
"low": _MAX_PARALLEL_CHUNKS // 16, # 2
211-
"medium": _MAX_PARALLEL_CHUNKS // 4, # 8
212-
"high": _MAX_PARALLEL_CHUNKS, # 32
213-
}
214-
215-
216-
def _resolve_level(level: ParallelChunksLevel) -> int:
217-
"""
218-
Map a parallel_chunks level name to its total sub-request cap.
219-
220-
Parameters
221-
----------
222-
level : {"low", "medium", "high"}
223-
The user-supplied level.
224-
225-
Returns
226-
-------
227-
int
228-
The maximum total sub-requests for the whole call at that level
229-
(``2`` / ``8`` / ``32``).
230-
231-
Raises
232-
------
233-
ValueError
234-
If ``level`` is anything other than ``"low"``, ``"medium"``, or
235-
``"high"`` — raised eagerly (at the ``with`` statement) so a typo fails
236-
loudly rather than silently doing nothing.
237-
"""
238-
if level not in _VALID_LEVELS:
239-
raise ValueError(
240-
f"parallel_chunks level must be one of {_VALID_LEVELS}; got {level!r}."
241-
)
242-
return _LEVEL_CAPS[level]
243-
244190

245191
@contextmanager
246-
def parallel_chunks(level: ParallelChunksLevel) -> Iterator[None]:
192+
def parallel_chunks(n: int) -> Iterator[None]:
247193
"""
248-
Scope how finely the OGC getters chunk multi-value requests.
194+
Fan the OGC getters' multi-value requests out into ``n`` parallel sub-requests.
249195
250196
By default the Water Data / NGWMN getters chunk a request only as much as
251197
the server's ~8 KB URL-byte limit forces — the fewest sub-requests that
@@ -267,26 +213,27 @@ def parallel_chunks(level: ParallelChunksLevel) -> Iterator[None]:
267213
automatic behavior or a process-wide environment variable — scoping it to a
268214
``with`` block keeps an aggressive setting from leaking into unrelated calls
269215
and accidentally spending quota. Outside any block the getters use the
270-
conservative default; there is no "off" level because *not* entering the
271-
block is off. Only the OGC getters (Water Data, NGWMN) read this; wrapping a
272-
legacy NWIS call in the block is a harmless no-op.
216+
conservative default. Only the OGC getters (Water Data, NGWMN) read this;
217+
wrapping a legacy NWIS call in the block is a harmless no-op.
273218
274219
Parameters
275220
----------
276-
level : {"low", "medium", "high"}
277-
How aggressively to chunk within the block. Each level caps the
278-
*total* number of sub-requests the call is split into — ``2`` / ``8``
279-
/ ``32`` for ``"low"`` / ``"medium"`` / ``"high"`` — or one
280-
sub-request per remaining atom once that's fewer than the cap. The
281-
cap applies to the whole call, not per multi-value argument: with
282-
several multi-value arguments the axes are refined together against
283-
the same shared ceiling (their cartesian product does not multiply
284-
past it). The ceiling is fixed (it is *not* tied to
285-
``API_USGS_CONCURRENT``: how finely a query splits is orthogonal to
286-
how many sub-requests run at once); capping ``"high"`` at ``32`` keeps
287-
an accidental aggressive level on a very long list — or several
288-
multi-value arguments at once — from exploding into thousands of
289-
sub-requests.
221+
n : int
222+
The number of sub-requests to fan the whole call out into — a positive
223+
integer such as ``2``, ``8``, or ``32``. It caps the plan's *total*
224+
sub-request count (the cartesian product across every multi-value
225+
argument combined, not per argument), so several multi-value arguments
226+
cannot multiply past it. The actual count is bounded below by what the
227+
~8 KB URL limit already forces and above by the number of values there
228+
are to split, so an ``n`` larger than the input allows simply yields one
229+
sub-request per value; ``n=1`` asks for no extra fan-out.
230+
231+
Each sub-request fetches at least one page, so it costs at least one
232+
request against your hourly rate limit — a larger ``n`` spends more
233+
quota. And because how many sub-requests run *at once* is capped
234+
separately by ``API_USGS_CONCURRENT`` (default 32), an ``n`` beyond that
235+
adds quota without adding parallelism; the useful range is roughly ``2``
236+
up to ``API_USGS_CONCURRENT``.
290237
291238
Yields
292239
------
@@ -295,22 +242,29 @@ def parallel_chunks(level: ParallelChunksLevel) -> Iterator[None]:
295242
Raises
296243
------
297244
ValueError
298-
If ``level`` isn't ``"low"``, ``"medium"``, or ``"high"`` — raised on
299-
``with`` entry, before any request is issued.
245+
If ``n`` is not a positive integer — raised on ``with`` entry, before
246+
any request is issued, so a bad value fails loudly rather than silently
247+
doing nothing.
300248
301249
Examples
302250
--------
303251
>>> from dataretrieval import waterdata
304-
>>> with waterdata.parallel_chunks("high"):
252+
>>> with waterdata.parallel_chunks(32):
305253
... df, md = waterdata.get_daily(
306254
... monitoring_location_id=many_sites, parameter_code="00060"
307255
... ) # doctest: +SKIP
308256
309257
See Also
310258
--------
311-
ChunkPlan._refine : the planning-side effect of the level.
259+
ChunkPlan._refine : the planning-side effect of ``n``.
312260
"""
313-
with _parallel_chunks(_resolve_level(level)):
261+
# ``bool`` is an ``int`` subclass but nonsensical here; reject it explicitly.
262+
if not isinstance(n, int) or isinstance(n, bool) or n < 1:
263+
raise ValueError(
264+
f"parallel_chunks(n): n must be a positive integer (e.g. 2, 8, 32); "
265+
f"got {n!r}."
266+
)
267+
with _parallel_chunks(n):
314268
yield
315269

316270

@@ -779,11 +733,11 @@ def wrapper(
779733
finalize: _Finalize = _passthrough_result,
780734
) -> tuple[pd.DataFrame, Any]:
781735
limit = _OGC_URL_BYTE_LIMIT if url_limit is None else url_limit
782-
# Read the parallel_chunks dial from the ambient set by
736+
# Read the parallel_chunks dial ``n`` from the ambient set by
783737
# ``parallel_chunks`` (0 = off outside any such block; otherwise the
784-
# per-axis sub-chunk cap). It only affects *planning*, done here up
785-
# front, so a later resume — which re-issues the already-planned
786-
# sub-requests — needs no snapshot.
738+
# requested total sub-request cap). It only affects *planning*, done
739+
# here up front, so a later resume — which re-issues the
740+
# already-planned sub-requests — needs no snapshot.
787741
plan = ChunkPlan(
788742
args, build_request, limit, max_chunks_per_axis=_parallel_chunks.get()
789743
)

0 commit comments

Comments
 (0)