Skip to content

Commit 0195113

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 0195113

8 files changed

Lines changed: 542 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 — `"low"` / `"medium"` / `"high"` cap at 2 / 8 / 32. That ceiling is a granularity 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 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: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
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 as
13+
the byte limit forces. A caller who knows their result is large can opt into a
14+
finer split via the ``chunk_granularity`` context manager
15+
(``"low"`` / ``"medium"`` / ``"high"``); the resolved cap drives
16+
:meth:`ChunkPlan._refine`. See ``chunk_granularity`` for the why and the when.
17+
1218
This module owns the *execution* half — the event loop and bounded
1319
concurrency that drive a plan to completion (``ChunkedCall``) plus the
1420
public ``multi_value_chunked`` decorator. The neighboring concerns live in
@@ -69,8 +75,9 @@
6975
import functools
7076
import os
7177
from collections.abc import Callable, Iterator
78+
from contextlib import contextmanager
7279
from contextvars import copy_context
73-
from typing import Any, cast
80+
from typing import Any, Literal, cast, get_args
7481

7582
import httpx
7683
import pandas as pd
@@ -172,6 +179,130 @@ def get_active_client() -> httpx.AsyncClient | None:
172179
return _chunked_client.get()
173180

174181

182+
# Chunk-granularity dial: opt-in to fan a query out *more finely* than the byte
183+
# limit alone requires. Scoped to a ``with chunk_granularity(...):`` block (a
184+
# ContextVar), deliberately NOT an env var (see :func:`chunk_granularity` for
185+
# why). The ambient holds the resolved cap on sub-chunks per axis; ``0`` (the
186+
# default, outside any block) means "chunk only as much as the byte limit needs".
187+
_granularity: Ambient[int] = Ambient("ogc_chunk_granularity", 0)
188+
189+
#: The three accepted granularity levels, as a typing ``Literal`` so a type
190+
#: checker rejects any other value at the call site.
191+
GranularityLevel = Literal["low", "medium", "high"]
192+
193+
#: Valid levels derived from the type, so ``GranularityLevel`` stays the single
194+
#: source of truth for what ``_resolve_granularity`` accepts (mirrors the
195+
#: ``get_args``-based ``_VALID_ON_TIE`` / ``_VALID_FILE_TYPES`` in sibling
196+
#: modules).
197+
_VALID_LEVELS: tuple[GranularityLevel, ...] = get_args(GranularityLevel)
198+
199+
# Granularity's own ceiling on sub-chunks per axis — deliberately NOT tied to the
200+
# concurrency default: fan-out *volume* (how many sub-requests a query becomes)
201+
# is orthogonal to how many run at once (``API_USGS_CONCURRENT``). 32 is a sane
202+
# single-axis ceiling that also bounds the blast radius of an accidental
203+
# ``"high"`` on a very long list; the milder levels are a quarter and a
204+
# sixteenth of it (the three are spaced 4x apart).
205+
_GRANULARITY_MAX_CHUNKS = 32
206+
_GRANULARITY_LEVELS: dict[str, int] = {
207+
"low": _GRANULARITY_MAX_CHUNKS // 16, # 2
208+
"medium": _GRANULARITY_MAX_CHUNKS // 4, # 8
209+
"high": _GRANULARITY_MAX_CHUNKS, # 32
210+
}
211+
212+
213+
def _resolve_granularity(level: GranularityLevel) -> int:
214+
"""
215+
Map a granularity level name to its per-axis sub-chunk cap.
216+
217+
Parameters
218+
----------
219+
level : {"low", "medium", "high"}
220+
The user-supplied level.
221+
222+
Returns
223+
-------
224+
int
225+
The maximum sub-chunks per multi-value axis for that level
226+
(``2`` / ``8`` / ``32``).
227+
228+
Raises
229+
------
230+
ValueError
231+
If ``level`` is anything other than ``"low"``, ``"medium"``, or
232+
``"high"`` — raised eagerly (at the ``with`` statement) so a typo fails
233+
loudly rather than silently doing nothing.
234+
"""
235+
if level not in _VALID_LEVELS:
236+
raise ValueError(
237+
f"chunk_granularity level must be one of {_VALID_LEVELS}; got {level!r}."
238+
)
239+
return _GRANULARITY_LEVELS[level]
240+
241+
242+
@contextmanager
243+
def chunk_granularity(level: GranularityLevel) -> Iterator[None]:
244+
"""
245+
Scope how finely the OGC getters chunk multi-value requests.
246+
247+
By default the Water Data / NGWMN getters chunk a request only as much as
248+
the server's ~8 KB URL-byte limit forces — the fewest sub-requests that
249+
fit. That is the safe default, but it can be *needlessly* conservative:
250+
because every sub-request paginates, splitting a large result further is
251+
usually quota-neutral (ten states pulled as one under-limit request page
252+
just as many times as ten per-state requests would). This context manager
253+
lets a caller who *knows* their pull is large ask for that finer split —
254+
trading the same pages for more, smaller sub-requests, which gives smoother
255+
progress, more even concurrency, and a smaller unit of retry/resume.
256+
257+
Because the library can't tell in advance whether a query is large (ten
258+
states over a short window might fit in a single page, where extra chunks
259+
would only burn quota), this is a *deliberate* per-call knob rather than an
260+
automatic behavior or a process-wide environment variable — scoping it to a
261+
``with`` block keeps an aggressive setting from leaking into unrelated calls
262+
and accidentally spending quota. Outside any block the getters use the
263+
conservative default; there is no "off" level because *not* entering the
264+
block is off. Only the OGC getters (Water Data, NGWMN) read this; wrapping a
265+
legacy NWIS call in the block is a harmless no-op.
266+
267+
Parameters
268+
----------
269+
level : {"low", "medium", "high"}
270+
How aggressively to chunk within the block. Each level caps how many
271+
sub-chunks a single multi-value argument is split into — ``2`` / ``8`` /
272+
``32`` for ``"low"`` / ``"medium"`` / ``"high"`` — or one value per
273+
sub-request once the argument has fewer values than the cap. The ceiling
274+
is fixed (it is *not* tied to ``API_USGS_CONCURRENT``: how finely a query
275+
splits is orthogonal to how many sub-requests run at once); capping
276+
``"high"`` at ``32`` keeps an accidental aggressive level on a very long
277+
list from exploding into thousands of sub-requests. (With several
278+
multi-value arguments the per-argument counts still multiply.)
279+
280+
Yields
281+
------
282+
None
283+
284+
Raises
285+
------
286+
ValueError
287+
If ``level`` isn't ``"low"``, ``"medium"``, or ``"high"`` — raised on
288+
``with`` entry, before any request is issued.
289+
290+
Examples
291+
--------
292+
>>> from dataretrieval import waterdata
293+
>>> with waterdata.chunk_granularity("high"):
294+
... df, md = waterdata.get_daily(
295+
... monitoring_location_id=many_sites, parameter_code="00060"
296+
... ) # doctest: +SKIP
297+
298+
See Also
299+
--------
300+
ChunkPlan._refine : the planning-side effect of the level.
301+
"""
302+
with _granularity(_resolve_granularity(level)):
303+
yield
304+
305+
175306
class ChunkedCall:
176307
"""
177308
Stateful handle for a chunked call.
@@ -591,8 +722,9 @@ def multi_value_chunked(
591722
``async def fetch(args) -> (df, response)``, and drives it to
592723
completion via :meth:`ChunkedCall.resume`. The plan splits multi-value
593724
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.
725+
byte limit; an already-fitting request is a one-step plan, unless an
726+
active :func:`chunk_granularity` block asks the plan to fan out more
727+
finely. See the module docstring for the concurrency model.
596728
597729
Parameters
598730
----------
@@ -636,7 +768,14 @@ def wrapper(
636768
finalize: _Finalize = _passthrough_result,
637769
) -> tuple[pd.DataFrame, Any]:
638770
limit = _OGC_URL_BYTE_LIMIT if url_limit is None else url_limit
639-
plan = ChunkPlan(args, build_request, limit)
771+
# Read the granularity dial from the ambient set by
772+
# ``chunk_granularity`` (0 = off outside any such block; otherwise the
773+
# per-axis sub-chunk cap). It only affects *planning*, done here up
774+
# front, so a later resume — which re-issues the already-planned
775+
# sub-requests — needs no snapshot.
776+
plan = ChunkPlan(
777+
args, build_request, limit, max_chunks_per_axis=_granularity.get()
778+
)
640779
retry_policy = RetryPolicy.from_env()
641780
# The concurrency cap is resolved inside ``resume()`` from
642781
# ``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 a splittable chunk each pass, so
520+
# this reaches exactly ``target`` and terminates. Split the chunk with
521+
# the most *atoms* (``_plan`` splits by *bytes*): here we even out
522+
# cardinality for smooth fan-out, not URL size. Ties take the lowest
523+
# index, keeping the split 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)