Skip to content

Commit b47e5cc

Browse files
thodson-usgsclaude
andcommitted
feat(waterdata): add chunk_granularity to control OGC chunk fan-out
The OGC getters chunk a multi-value request only as far as the server's ~8 KB URL limit forces — the fewest sub-requests. But because every sub-request paginates, splitting a large result further is usually quota-neutral, so that conservative default can be needlessly coarse: ten states pulled as one under-limit request page just as many times as ten per-state requests would. Add `waterdata.chunk_granularity(level)`, a context manager that lets a caller who knows their pull is large opt into a finer split — trading the same pages for more, smaller sub-requests (smoother progress, more even concurrency, a smaller unit of retry/resume). The level is "low", "medium", or "high" (typed as `GranularityLevel`, a Literal, so a type checker rejects anything else; an invalid string raises ValueError at the `with`). Each level caps how many sub-chunks a multi-value argument is split into, derived from the default fan-out concurrency (`API_USGS_CONCURRENT`): high = the full width, medium a quarter, low a sixteenth (32 / 8 / 2 by default). Capping the aggressive end at the concurrency width bounds the blast radius so an accidental "high" on a huge list can't explode into thousands of sub-requests. There is no "off" level — not entering the block is off. It is a scoped `with` block, not an env var, because the library can't tell in advance whether a query is large (a short-window query might fit one page, where extra chunks only burn quota). Implementation: a soft `ChunkPlan._refine` pass runs after the hard byte pass; it only ever splits further, so the url_limit invariant holds and it never raises. The resolved per-axis cap is read from a contextvar (Ambient) set by the context manager at plan-construction time. Exported (with the `GranularityLevel` type) from `dataretrieval.waterdata` and the top-level `dataretrieval` package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 74c4856 commit b47e5cc

8 files changed

Lines changed: 572 additions & 16 deletions

File tree

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
**07/01/2026:** Added `waterdata.chunk_granularity(...)` — 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 is usually quota-neutral, 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.chunk_granularity("high"): df, md = waterdata.get_daily(monitoring_location_id=many_sites)`. The level is one of `"low"`, `"medium"`, or `"high"` — typed as `waterdata.GranularityLevel` (a `typing.Literal`), so a type checker rejects any other value and an invalid string raises `ValueError` at the `with`. Each level caps how many sub-chunks a multi-value argument is split into, *derived* from the default fan-out concurrency (`API_USGS_CONCURRENT`) rather than hardcoded: `"high"` = the full width, `"medium"` a quarter, `"low"` a sixteenth (32 / 8 / 2 by default). Capping the aggressive end at the concurrency width bounds the blast radius — an accidental `"high"` on a huge list 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.chunk_granularity`.
2+
13
**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.
24

35
**06/23/2026:** **Breaking change (1.2.0):** removed the `nadp` module and the deprecated `samples` module ahead of the 1.2.0 release. `nadp` was deprecated on 05/01/2026 — NADP is not a USGS data source, so retrieve NADP data directly from https://nadp.slh.wisc.edu/. The `samples.get_usgs_samples` shim (a deprecated forward to the modern getter) is gone; use `waterdata.get_samples()` instead. `import dataretrieval.nadp` / `import dataretrieval.samples` now raise `ModuleNotFoundError`.

dataretrieval/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@
4444
URLTooLong,
4545
)
4646

47+
# Chunk-granularity 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 chunk_granularity``.
50+
from dataretrieval.ogc.chunking import GranularityLevel, chunk_granularity
51+
4752
# Resumable chunk-interruption exceptions. They are defined in
4853
# ``dataretrieval.ogc.interruptions`` rather than ``dataretrieval.exceptions``
4954
# because they carry pandas/httpx state and a resumable ``ChunkedCall`` handle,
@@ -93,5 +98,8 @@
9398
"ChunkInterrupted",
9499
"QuotaExhausted",
95100
"ServiceInterrupted",
101+
# chunk-granularity control (defined in ogc.chunking)
102+
"chunk_granularity",
103+
"GranularityLevel",
96104
"__version__",
97105
]

dataretrieval/ogc/chunking.py

Lines changed: 146 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@
99
cartesian product of chunks. Requests that already fit get a trivial
1010
single-step plan — ``ChunkedCall`` has one code path either way.
1111
12+
Granularity: the planner is conservative by default — it splits only as far
13+
as the byte limit forces, minimizing sub-requests. A caller who knows their
14+
result is large can widen that split via the ``chunk_granularity`` context
15+
manager (``"low"`` / ``"medium"`` / ``"high"``): because every sub-request
16+
paginates, a finer split of a large result is usually quota-neutral, and buys
17+
smoother progress, more even concurrency, and a smaller unit of retry/resume.
18+
It is a scoped ``with`` block — not an env var like ``API_USGS_CONCURRENT`` —
19+
precisely so an aggressive setting can't leak past the call it was meant for
20+
and burn quota on a query that would have fit in one page. The level drives
21+
:meth:`ChunkPlan._refine`; see ``chunk_granularity`` for the full rationale.
22+
1223
This module owns the *execution* half — the event loop and bounded
1324
concurrency that drive a plan to completion (``ChunkedCall``) plus the
1425
public ``multi_value_chunked`` decorator. The neighboring concerns live in
@@ -69,8 +80,9 @@
6980
import functools
7081
import os
7182
from collections.abc import Callable, Iterator
83+
from contextlib import contextmanager
7284
from contextvars import copy_context
73-
from typing import Any, cast
85+
from typing import Any, Literal, cast
7486

7587
import httpx
7688
import pandas as pd
@@ -172,6 +184,128 @@ def get_active_client() -> httpx.AsyncClient | None:
172184
return _chunked_client.get()
173185

174186

187+
# Chunk-granularity dial: opt-in to fan a query out *more finely* than the byte
188+
# limit alone requires. Scoped to a ``with chunk_granularity(...):`` block (a
189+
# ContextVar), deliberately NOT an env var — unlike ``API_USGS_CONCURRENT`` /
190+
# ``API_USGS_RETRIES``, a persistent process-wide setting here would be a quota
191+
# footgun (see :func:`chunk_granularity`). The ambient holds the resolved cap on
192+
# sub-chunks per axis; ``0`` (the default, outside any block) means "chunk only
193+
# as much as the byte limit needs".
194+
_granularity: Ambient[int] = Ambient("ogc_chunk_granularity", 0)
195+
196+
#: The three accepted granularity levels, as a typing ``Literal`` so a type
197+
#: checker rejects any other value at the call site.
198+
GranularityLevel = Literal["low", "medium", "high"]
199+
200+
# Each level caps the sub-chunks a multi-value axis is split into, derived from
201+
# the default fan-out width ``_CONCURRENCY_DEFAULT`` (``API_USGS_CONCURRENT``)
202+
# rather than hardcoded — past that many in-flight sub-requests the extra chunks
203+
# mostly just queue, so it's a natural ceiling for the most aggressive level,
204+
# and deriving from the same constant keeps the two in step if it moves. Levels
205+
# are spaced 4x apart: ``"high"`` = the full width, ``"medium"`` = a quarter,
206+
# ``"low"`` = a sixteenth (floored at 1 so a tiny width can't yield a 0 cap).
207+
_GRANULARITY_LEVELS: dict[str, int] = {
208+
"low": max(1, _CONCURRENCY_DEFAULT // 16),
209+
"medium": max(1, _CONCURRENCY_DEFAULT // 4),
210+
"high": _CONCURRENCY_DEFAULT,
211+
}
212+
213+
214+
def _resolve_granularity(level: GranularityLevel) -> int:
215+
"""
216+
Map a granularity level name to its per-axis sub-chunk cap.
217+
218+
Parameters
219+
----------
220+
level : {"low", "medium", "high"}
221+
The user-supplied level.
222+
223+
Returns
224+
-------
225+
int
226+
The maximum sub-chunks per multi-value axis for that level
227+
(``4`` / ``16`` / ``32``).
228+
229+
Raises
230+
------
231+
ValueError
232+
If ``level`` is anything other than ``"low"``, ``"medium"``, or
233+
``"high"`` — raised eagerly (at the ``with`` statement) so a typo fails
234+
loudly rather than silently doing nothing.
235+
"""
236+
try:
237+
return _GRANULARITY_LEVELS[level]
238+
except (KeyError, TypeError):
239+
valid = ", ".join(map(repr, _GRANULARITY_LEVELS))
240+
raise ValueError(
241+
f"chunk_granularity level must be one of {valid}; got {level!r}."
242+
) from None
243+
244+
245+
@contextmanager
246+
def chunk_granularity(level: GranularityLevel) -> Iterator[None]:
247+
"""
248+
Scope how finely the OGC getters chunk multi-value requests.
249+
250+
By default the Water Data / NGWMN getters chunk a request only as much as
251+
the server's ~8 KB URL-byte limit forces — the fewest sub-requests that
252+
fit. That is the safe default, but it can be *needlessly* conservative:
253+
because every sub-request paginates, splitting a large result further is
254+
usually quota-neutral (ten states pulled as one under-limit request page
255+
just as many times as ten per-state requests would). This context manager
256+
lets a caller who *knows* their pull is large ask for that finer split —
257+
trading the same pages for more, smaller sub-requests, which gives smoother
258+
progress, more even concurrency, and a smaller unit of retry/resume.
259+
260+
Because the library can't tell in advance whether a query is large (ten
261+
states over a short window might fit in a single page, where extra chunks
262+
would only burn quota), this is a *deliberate* per-call knob rather than an
263+
automatic behavior or a process-wide environment variable — scoping it to a
264+
``with`` block keeps an aggressive setting from leaking into unrelated calls
265+
and accidentally spending quota. Outside any block the getters use the
266+
conservative default; there is no "off" level because *not* entering the
267+
block is off.
268+
269+
Parameters
270+
----------
271+
level : {"low", "medium", "high"}
272+
How aggressively to chunk within the block. Each level caps how many
273+
sub-chunks a single multi-value argument is split into, as a fraction
274+
of the default fan-out width (``API_USGS_CONCURRENT``, ``32``):
275+
``"high"`` up to the full width, ``"medium"`` a quarter, and ``"low"`` a
276+
sixteenth of it — ``32`` / ``8`` / ``2`` by default — or one value per
277+
sub-request once the argument has fewer values than the cap. Tying the
278+
ceiling to the concurrency width is a guardrail: extra chunks beyond it
279+
mostly just queue, and it keeps an accidental ``"high"`` on a very long
280+
list from exploding into thousands of sub-requests. (With several
281+
multi-value arguments the per-argument counts still multiply.)
282+
283+
Yields
284+
------
285+
None
286+
287+
Raises
288+
------
289+
ValueError
290+
If ``level`` isn't ``"low"``, ``"medium"``, or ``"high"`` — raised on
291+
``with`` entry, before any request is issued.
292+
293+
Examples
294+
--------
295+
>>> from dataretrieval import waterdata
296+
>>> with waterdata.chunk_granularity("high"):
297+
... df, md = waterdata.get_daily(
298+
... monitoring_location_id=many_sites, parameter_code="00060"
299+
... ) # doctest: +SKIP
300+
301+
See Also
302+
--------
303+
ChunkPlan._refine : the planning-side effect of the level.
304+
"""
305+
with _granularity(_resolve_granularity(level)):
306+
yield
307+
308+
175309
class ChunkedCall:
176310
"""
177311
Stateful handle for a chunked call.
@@ -591,8 +725,9 @@ def multi_value_chunked(
591725
``async def fetch(args) -> (df, response)``, and drives it to
592726
completion via :meth:`ChunkedCall.resume`. The plan splits multi-value
593727
list params and the cql-text filter so each sub-request URL fits the
594-
byte limit; an already-fitting request is a one-step plan. See the
595-
module docstring for the concurrency model.
728+
byte limit; an already-fitting request is a one-step plan, unless an
729+
active :func:`chunk_granularity` block asks the plan to fan out more
730+
finely. See the module docstring for the concurrency model.
596731
597732
Parameters
598733
----------
@@ -636,7 +771,14 @@ def wrapper(
636771
finalize: _Finalize = _passthrough_result,
637772
) -> tuple[pd.DataFrame, Any]:
638773
limit = _OGC_URL_BYTE_LIMIT if url_limit is None else url_limit
639-
plan = ChunkPlan(args, build_request, limit)
774+
# Read the granularity dial from the ambient set by
775+
# ``chunk_granularity`` (0 = off outside any such block; otherwise the
776+
# per-axis sub-chunk cap). It only affects *planning*, done here up
777+
# front, so a later resume — which re-issues the already-planned
778+
# sub-requests — needs no snapshot.
779+
plan = ChunkPlan(
780+
args, build_request, limit, max_chunks_per_axis=_granularity.get()
781+
)
640782
retry_policy = RetryPolicy.from_env()
641783
# The concurrency cap is resolved inside ``resume()`` from
642784
# ``API_USGS_CONCURRENT``; ``1`` is a sequential gather,

dataretrieval/ogc/planning.py

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,21 @@ def _extract_axes(args: dict[str, Any]) -> list[_Axis]:
288288
return axes
289289

290290

291+
def _split_at(chunks: list[list[str]], idx: int) -> None:
292+
"""Replace ``chunks[idx]`` in place with its two contiguous halves.
293+
294+
The single primitive both planning passes use to fan an axis out. It
295+
preserves the partition invariants every consumer relies on: *coverage*
296+
(each atom survives, exactly once) and *contiguous, deterministic order*
297+
(resume and :meth:`ChunkPlan.iter_sub_args` depend on it). Kept in one
298+
place so those invariants can't drift between :meth:`ChunkPlan._plan`
299+
(byte-driven) and :meth:`ChunkPlan._refine` (granularity-driven).
300+
"""
301+
chunk = chunks[idx]
302+
mid = len(chunk) // 2
303+
chunks[idx : idx + 1] = [chunk[:mid], chunk[mid:]]
304+
305+
291306
class ChunkPlan:
292307
"""
293308
Strategy for issuing one user-level request as a sequence of
@@ -312,7 +327,17 @@ class ChunkPlan:
312327
Factory that turns a kwargs dict into a sized httpx request,
313328
e.g. ``_construct_api_requests``.
314329
url_limit : int
315-
Byte budget for the request (URL + body).
330+
Byte budget for the request (URL + body) — a hard ceiling every
331+
sub-request must fit.
332+
max_chunks_per_axis : int, optional
333+
Soft cap on sub-chunks per multi-value axis (default ``0`` = off).
334+
``0`` chunks only as much as ``url_limit`` requires — the most
335+
conservative plan, fewest sub-requests. A positive cap fans each axis
336+
out into up to ``min(len(atoms), max_chunks_per_axis)`` pieces (never
337+
fewer than the byte budget already forces), so a large multi-page pull
338+
is issued as more, smaller sub-requests. Set from the resolved
339+
:func:`~dataretrieval.ogc.chunking.chunk_granularity` level; see
340+
:meth:`_refine`.
316341
317342
Attributes
318343
----------
@@ -344,6 +369,7 @@ def __init__(
344369
args: dict[str, Any],
345370
build_request: Callable[..., httpx.Request],
346371
url_limit: int,
372+
max_chunks_per_axis: int = 0,
347373
) -> None:
348374
self.args = args
349375
self.axes: list[_Axis] = []
@@ -352,10 +378,10 @@ def __init__(
352378

353379
axes = _extract_axes(args)
354380
if not axes:
355-
# No chunkable axis: nothing to split. If the single request fits,
356-
# run it verbatim (the common passthrough). ``_safe_request_bytes``
357-
# treats an un-constructable URL (httpx.InvalidURL, > 64 KB) as over
358-
# budget.
381+
# No chunkable axis: nothing to split, and ``granularity`` has
382+
# nothing to act on either. If the single request fits, run it
383+
# verbatim (the common passthrough). ``_safe_request_bytes`` treats
384+
# an un-constructable URL (httpx.InvalidURL, > 64 KB) as over budget.
359385
if _safe_request_bytes(build_request, args, url_limit) <= url_limit:
360386
return
361387
# Over budget. A filter the chunker doesn't manage — cql-json — is
@@ -388,14 +414,29 @@ def __init__(
388414
except httpx.InvalidURL:
389415
initial_request = None
390416

417+
fits = False
391418
if initial_request is not None:
392419
self.canonical_url = str(initial_request.url)
393-
if _request_bytes(initial_request) <= url_limit:
394-
return
420+
fits = _request_bytes(initial_request) <= url_limit
421+
422+
# A request that already fits and hasn't opted into finer chunking is
423+
# the common passthrough: leave ``axes``/``chunks`` empty so
424+
# ``total == 1`` and ``iter_sub_args`` yields the original args
425+
# verbatim. Only when ``max_chunks_per_axis`` asks for extra fan-out do
426+
# we set the axes up to be refined below.
427+
if fits and max_chunks_per_axis <= 0:
428+
return
395429

396430
self.axes = axes
397431
self.chunks = {axis.arg_key: [list(axis.atoms)] for axis in axes}
398-
self._plan(build_request, url_limit)
432+
if not fits:
433+
# Hard pass: greedy-halve until every worst-case sub-request fits
434+
# the byte budget (may raise ``Unchunkable``).
435+
self._plan(build_request, url_limit)
436+
# Soft pass: optionally split further than the byte budget requires.
437+
# Purely additive — never re-raises, and the byte budget stays
438+
# satisfied; a no-op at ``max_chunks_per_axis <= 0``.
439+
self._refine(max_chunks_per_axis)
399440

400441
if self.canonical_url is None:
401442
# Original URL was un-constructable (httpx.InvalidURL); fall
@@ -447,10 +488,42 @@ def _plan(
447488
f"sub-request). Reduce input sizes, shorten or simplify "
448489
f"the filter, or split the call manually."
449490
)
450-
axis_chunks = self.chunks[biggest_axis.arg_key]
451-
chunk = axis_chunks[biggest_idx]
452-
mid = len(chunk) // 2
453-
axis_chunks[biggest_idx : biggest_idx + 1] = [chunk[:mid], chunk[mid:]]
491+
_split_at(self.chunks[biggest_axis.arg_key], biggest_idx)
492+
493+
def _refine(self, max_chunks_per_axis: int) -> None:
494+
"""
495+
Fan each axis out more finely than the byte budget alone requires —
496+
the granularity dial (see
497+
:func:`~dataretrieval.ogc.chunking.chunk_granularity` for why a caller
498+
would want this).
499+
500+
Each axis is split until it holds at least
501+
``min(len(atoms), max_chunks_per_axis)`` chunks — up to the cap, or one
502+
atom per chunk for a shorter axis. Purely additive — only ever *splits*
503+
existing chunks, so the byte pass's work and the ``url_limit`` invariant
504+
are both preserved (an axis the byte pass already split past the cap is
505+
left alone), and it never raises. A no-op at ``max_chunks_per_axis <= 0``.
506+
507+
Parameters
508+
----------
509+
max_chunks_per_axis : int
510+
Soft cap on sub-chunks per axis (``0`` = off), the resolved
511+
granularity level. A shorter axis simply saturates at one atom per
512+
chunk.
513+
"""
514+
if max_chunks_per_axis <= 0:
515+
return
516+
for axis in self.axes:
517+
chunks = self.chunks[axis.arg_key]
518+
target = min(len(axis.atoms), max_chunks_per_axis)
519+
# ``target <= len(atoms)`` guarantees some chunk still holds >1 atom
520+
# each iteration, so the loop reaches exactly ``target`` pieces and
521+
# terminates. ``enumerate`` keeps the key closed over its own
522+
# argument (not the loop-mutated ``chunks``); ties take the lowest
523+
# index, so the split stays deterministic.
524+
while len(chunks) < target:
525+
idx, _ = max(enumerate(chunks), key=lambda kv: len(kv[1]))
526+
_split_at(chunks, idx)
454527

455528
def _worst_case_args(self) -> dict[str, Any]:
456529
"""

dataretrieval/waterdata/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from __future__ import annotations
1111

12+
from dataretrieval.ogc.chunking import GranularityLevel, chunk_granularity
1213
from dataretrieval.ogc.filters import FILTER_LANG
1314

1415
# Public API exports
@@ -50,6 +51,8 @@
5051
"PROFILE_LOOKUP",
5152
"SERVICES",
5253
"WATERDATA_SERVICES",
54+
"GranularityLevel",
55+
"chunk_granularity",
5356
"get_channel",
5457
"get_codes",
5558
"get_combined_metadata",

0 commit comments

Comments
 (0)