Skip to content

Commit 2b2f5f0

Browse files
thodson-usgsclaude
andcommitted
refactor(waterdata): rename chunk_granularity -> parallel_chunks
Rename the public context manager chunk_granularity to parallel_chunks and the GranularityLevel type to ParallelChunksLevel (both exported from dataretrieval and dataretrieval.waterdata), plus internals (_resolve_level, _MAX_PARALLEL_CHUNKS, _LEVEL_CAPS, the _parallel_chunks ambient). 'parallel_chunks' names what the knob does — fan a query into more, parallel sub-requests — without colliding with API_USGS_CONCURRENT (the separate in-flight cap). Docstrings, NEWS, user guide, README, and tests updated to match. Pure rename; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e87feb6 commit 2b2f5f0

9 files changed

Lines changed: 103 additions & 102 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.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 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.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 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 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, 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.chunk_granularity`.
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`.
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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,13 @@ df, metadata = waterdata.get_continuous(
105105
print(f"Retrieved {len(df)} continuous gage height measurements")
106106
```
107107

108-
#### Speeding up large downloads with `chunk_granularity`
108+
#### Speeding up large downloads with `parallel_chunks`
109109

110110
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**. `chunk_granularity` opts a single call into that
114+
be fetched **in parallel**. `parallel_chunks` opts a single call into that
115115
finer split. It pays off only when the result is large enough to span many
116116
pages *and* the query has a multi-value argument to divide (such as a list of
117117
monitoring locations); on a small query — or one with nothing to split — it just
@@ -124,7 +124,7 @@ from dataretrieval import waterdata
124124
# enough to span many pages, so it profits from a finer split.
125125
sites, _ = waterdata.get_monitoring_locations(state="Ohio", site_type_code="ST")
126126

127-
with waterdata.chunk_granularity("high"): # "low" | "medium" | "high"
127+
with waterdata.parallel_chunks("high"): # "low" | "medium" | "high"
128128
df, md = waterdata.get_daily(
129129
monitoring_location_id=sites["monitoring_location_id"].tolist(),
130130
parameter_code="00060", # discharge

dataretrieval/__init__.py

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

47-
# Chunk-granularity control (a context manager) and its level type. Defined with
47+
# Parallel-chunks control (a context manager) and its level type. Defined with
4848
# 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
49+
# public path ``from dataretrieval import parallel_chunks``.
50+
from dataretrieval.ogc.chunking import ParallelChunksLevel, parallel_chunks
5151

5252
# Resumable chunk-interruption exceptions. They are defined in
5353
# ``dataretrieval.ogc.interruptions`` rather than ``dataretrieval.exceptions``
@@ -98,8 +98,8 @@
9898
"ChunkInterrupted",
9999
"QuotaExhausted",
100100
"ServiceInterrupted",
101-
# chunk-granularity control (defined in ogc.chunking)
102-
"chunk_granularity",
103-
"GranularityLevel",
101+
# parallel-chunks control (defined in ogc.chunking)
102+
"parallel_chunks",
103+
"ParallelChunksLevel",
104104
"__version__",
105105
]

dataretrieval/ogc/chunking.py

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
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
12+
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 ``chunk_granularity`` context manager
14+
finer split via the ``parallel_chunks`` context manager
1515
(``"low"`` / ``"medium"`` / ``"high"``); the resolved cap drives
16-
:meth:`ChunkPlan._refine`. See ``chunk_granularity`` for the why and the when.
16+
:meth:`ChunkPlan._refine`. See ``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
@@ -179,43 +179,43 @@ def get_active_client() -> httpx.AsyncClient | None:
179179
return _chunked_client.get()
180180

181181

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
182+
# Parallel-chunks dial: opt-in to fan a query out *more finely* than the byte
183+
# limit alone requires. Scoped to a ``with parallel_chunks(...):`` block (a
184+
# ContextVar), deliberately NOT an env var (see :func:`parallel_chunks` for
185185
# why). The ambient holds the resolved cap on the plan's total sub-request
186186
# count; ``0`` (the default, outside any block) means "chunk only as much as
187187
# the byte limit needs".
188-
_granularity: Ambient[int] = Ambient("ogc_chunk_granularity", 0)
188+
_parallel_chunks: Ambient[int] = Ambient("ogc_parallel_chunks", 0)
189189

190-
#: The three accepted granularity levels, as a typing ``Literal`` so a type
190+
#: The three accepted parallel_chunks levels, as a typing ``Literal`` so a type
191191
#: checker rejects any other value at the call site.
192-
GranularityLevel = Literal["low", "medium", "high"]
192+
ParallelChunksLevel = Literal["low", "medium", "high"]
193193

194-
#: Valid levels derived from the type, so ``GranularityLevel`` stays the single
195-
#: source of truth for what ``_resolve_granularity`` accepts (mirrors the
194+
#: Valid levels derived from the type, so ``ParallelChunksLevel`` stays the single
195+
#: source of truth for what ``_resolve_level`` accepts (mirrors the
196196
#: ``get_args``-based ``_VALID_ON_TIE`` / ``_VALID_FILE_TYPES`` in sibling
197197
#: modules).
198-
_VALID_LEVELS: tuple[GranularityLevel, ...] = get_args(GranularityLevel)
198+
_VALID_LEVELS: tuple[ParallelChunksLevel, ...] = get_args(ParallelChunksLevel)
199199

200-
# Granularity's own ceiling on the plan's total sub-request count —
200+
# The parallel_chunks ceiling on the plan's total sub-request count —
201201
# deliberately NOT tied to the concurrency default: fan-out *volume* (how many
202202
# sub-requests a query becomes) is orthogonal to how many run at once
203203
# (``API_USGS_CONCURRENT``). 32 is a sane ceiling on the whole call (across
204204
# every axis, not per axis — see ``ChunkPlan._refine``) that bounds the blast
205205
# radius of an accidental ``"high"`` on a very long list or several multi-value
206206
# arguments at once; the milder levels are a quarter and a sixteenth of it (the
207207
# three are spaced 4x apart).
208-
_GRANULARITY_MAX_CHUNKS = 32
209-
_GRANULARITY_LEVELS: dict[str, int] = {
210-
"low": _GRANULARITY_MAX_CHUNKS // 16, # 2
211-
"medium": _GRANULARITY_MAX_CHUNKS // 4, # 8
212-
"high": _GRANULARITY_MAX_CHUNKS, # 32
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
213213
}
214214

215215

216-
def _resolve_granularity(level: GranularityLevel) -> int:
216+
def _resolve_level(level: ParallelChunksLevel) -> int:
217217
"""
218-
Map a granularity level name to its total sub-request cap.
218+
Map a parallel_chunks level name to its total sub-request cap.
219219
220220
Parameters
221221
----------
@@ -237,13 +237,13 @@ def _resolve_granularity(level: GranularityLevel) -> int:
237237
"""
238238
if level not in _VALID_LEVELS:
239239
raise ValueError(
240-
f"chunk_granularity level must be one of {_VALID_LEVELS}; got {level!r}."
240+
f"parallel_chunks level must be one of {_VALID_LEVELS}; got {level!r}."
241241
)
242-
return _GRANULARITY_LEVELS[level]
242+
return _LEVEL_CAPS[level]
243243

244244

245245
@contextmanager
246-
def chunk_granularity(level: GranularityLevel) -> Iterator[None]:
246+
def parallel_chunks(level: ParallelChunksLevel) -> Iterator[None]:
247247
"""
248248
Scope how finely the OGC getters chunk multi-value requests.
249249
@@ -301,7 +301,7 @@ def chunk_granularity(level: GranularityLevel) -> Iterator[None]:
301301
Examples
302302
--------
303303
>>> from dataretrieval import waterdata
304-
>>> with waterdata.chunk_granularity("high"):
304+
>>> with waterdata.parallel_chunks("high"):
305305
... df, md = waterdata.get_daily(
306306
... monitoring_location_id=many_sites, parameter_code="00060"
307307
... ) # doctest: +SKIP
@@ -310,7 +310,7 @@ def chunk_granularity(level: GranularityLevel) -> Iterator[None]:
310310
--------
311311
ChunkPlan._refine : the planning-side effect of the level.
312312
"""
313-
with _granularity(_resolve_granularity(level)):
313+
with _parallel_chunks(_resolve_level(level)):
314314
yield
315315

316316

@@ -734,7 +734,7 @@ def multi_value_chunked(
734734
completion via :meth:`ChunkedCall.resume`. The plan splits multi-value
735735
list params and the cql-text filter so each sub-request URL fits the
736736
byte limit; an already-fitting request is a one-step plan, unless an
737-
active :func:`chunk_granularity` block asks the plan to fan out more
737+
active :func:`parallel_chunks` block asks the plan to fan out more
738738
finely. See the module docstring for the concurrency model.
739739
740740
Parameters
@@ -779,13 +779,13 @@ def wrapper(
779779
finalize: _Finalize = _passthrough_result,
780780
) -> tuple[pd.DataFrame, Any]:
781781
limit = _OGC_URL_BYTE_LIMIT if url_limit is None else url_limit
782-
# Read the granularity dial from the ambient set by
783-
# ``chunk_granularity`` (0 = off outside any such block; otherwise the
782+
# Read the parallel_chunks dial from the ambient set by
783+
# ``parallel_chunks`` (0 = off outside any such block; otherwise the
784784
# per-axis sub-chunk cap). It only affects *planning*, done here up
785785
# front, so a later resume — which re-issues the already-planned
786786
# sub-requests — needs no snapshot.
787787
plan = ChunkPlan(
788-
args, build_request, limit, max_chunks_per_axis=_granularity.get()
788+
args, build_request, limit, max_chunks_per_axis=_parallel_chunks.get()
789789
)
790790
retry_policy = RetryPolicy.from_env()
791791
# The concurrency cap is resolved inside ``resume()`` from

dataretrieval/ogc/planning.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ def _split_at(chunks: list[list[str]], idx: int) -> None:
296296
(each atom survives, exactly once) and *contiguous, deterministic order*
297297
(resume and :meth:`ChunkPlan.iter_sub_args` depend on it). Kept in one
298298
place so those invariants can't drift between :meth:`ChunkPlan._plan`
299-
(byte-driven) and :meth:`ChunkPlan._refine` (granularity-driven).
299+
(byte-driven) and :meth:`ChunkPlan._refine` (fan-out-driven).
300300
"""
301301
chunk = chunks[idx]
302302
mid = len(chunk) // 2
@@ -337,7 +337,7 @@ class ChunkPlan:
337337
cartesian product across axes, never fewer than the byte budget
338338
already forces) — capped as a whole, not per axis, so several
339339
multi-value axes can't multiply past the cap. Set from the resolved
340-
:func:`~dataretrieval.ogc.chunking.chunk_granularity` level; see
340+
:func:`~dataretrieval.ogc.chunking.parallel_chunks` level; see
341341
:meth:`_refine`.
342342
343343
Attributes
@@ -379,7 +379,7 @@ def __init__(
379379

380380
axes = _extract_axes(args)
381381
if not axes:
382-
# No chunkable axis: nothing to split, and ``granularity`` has
382+
# No chunkable axis: nothing to split, and ``parallel_chunks`` has
383383
# nothing to act on either. If the single request fits, run it
384384
# verbatim (the common passthrough). ``_safe_request_bytes`` treats
385385
# an un-constructable URL (httpx.InvalidURL, > 64 KB) as over budget.
@@ -494,8 +494,8 @@ def _plan(
494494
def _refine(self, max_chunks_per_axis: int) -> None:
495495
"""
496496
Fan the plan out more finely than the byte budget alone requires —
497-
the granularity dial (see
498-
:func:`~dataretrieval.ogc.chunking.chunk_granularity` for why a caller
497+
the parallel_chunks dial (see
498+
:func:`~dataretrieval.ogc.chunking.parallel_chunks` for why a caller
499499
would want this).
500500
501501
Caps the plan's *total* sub-request count (:attr:`total`, the
@@ -514,7 +514,7 @@ def _refine(self, max_chunks_per_axis: int) -> None:
514514
----------
515515
max_chunks_per_axis : int
516516
Soft cap on the plan's total sub-request count (``0`` = off),
517-
the resolved granularity level. Despite the name — kept for the
517+
the resolved parallel_chunks level. Despite the name — kept for the
518518
public dial's "sub-chunks per multi-value argument" framing,
519519
which matches this cap exactly in the common single-axis case —
520520
multi-axis plans are capped on the product, not per axis.

dataretrieval/waterdata/__init__.py

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

1010
from __future__ import annotations
1111

12-
from dataretrieval.ogc.chunking import GranularityLevel, chunk_granularity
12+
from dataretrieval.ogc.chunking import ParallelChunksLevel, parallel_chunks
1313
from dataretrieval.ogc.filters import FILTER_LANG
1414

1515
# Public API exports
@@ -51,8 +51,8 @@
5151
"PROFILE_LOOKUP",
5252
"SERVICES",
5353
"WATERDATA_SERVICES",
54-
"GranularityLevel",
55-
"chunk_granularity",
54+
"ParallelChunksLevel",
55+
"parallel_chunks",
5656
"get_channel",
5757
"get_codes",
5858
"get_combined_metadata",

docs/source/userguide/errors.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ extra quota *as long as each sub-request still spans many pages* (ten states
106106
pulled as one request then page nearly as many times as ten per-state requests
107107
would; a split that leaves each sub-request only a page or two adds its partial
108108
final page). So if you *know* your pull is large you can ask for a finer split
109-
with ``chunk_granularity`` -- trading roughly the same pages for more, smaller
109+
with ``parallel_chunks`` -- trading roughly the same pages for more, smaller
110110
sub-requests, which gives smoother progress, more even concurrency, and a
111111
smaller unit of retry/resume. It is a scoped ``with``
112112
block, so an aggressive setting can't leak into unrelated calls and
@@ -116,7 +116,7 @@ accidentally spend quota:
116116
117117
from dataretrieval import waterdata
118118
119-
with waterdata.chunk_granularity("high"):
119+
with waterdata.parallel_chunks("high"):
120120
df, md = waterdata.get_daily(
121121
monitoring_location_id=many_sites, parameter_code="00060"
122122
)

tests/utils_test.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -208,21 +208,21 @@ def test_chunk_interruptions_exported_at_top_level(self):
208208
dataretrieval.ChunkInterrupted, dataretrieval.DataRetrievalError
209209
)
210210

211-
def test_chunk_granularity_exported_at_top_level_and_waterdata(self):
212-
"""The ``chunk_granularity`` context manager and its ``GranularityLevel``
211+
def test_parallel_chunks_exported_at_top_level_and_waterdata(self):
212+
"""The ``parallel_chunks`` context manager and its ``ParallelChunksLevel``
213213
type are reachable both from the top level (``from dataretrieval import
214-
chunk_granularity``) and from the user-facing ``dataretrieval.waterdata``
214+
parallel_chunks``) and from the user-facing ``dataretrieval.waterdata``
215215
namespace, and both resolve to the single objects defined in
216216
``dataretrieval.ogc.chunking``."""
217217
import dataretrieval
218218
from dataretrieval import waterdata
219219
from dataretrieval.ogc import chunking
220220

221-
assert dataretrieval.chunk_granularity is chunking.chunk_granularity
222-
assert waterdata.chunk_granularity is chunking.chunk_granularity
223-
assert dataretrieval.GranularityLevel is chunking.GranularityLevel
224-
assert waterdata.GranularityLevel is chunking.GranularityLevel
225-
for name in ("chunk_granularity", "GranularityLevel"):
221+
assert dataretrieval.parallel_chunks is chunking.parallel_chunks
222+
assert waterdata.parallel_chunks is chunking.parallel_chunks
223+
assert dataretrieval.ParallelChunksLevel is chunking.ParallelChunksLevel
224+
assert waterdata.ParallelChunksLevel is chunking.ParallelChunksLevel
225+
for name in ("parallel_chunks", "ParallelChunksLevel"):
226226
assert name in dataretrieval.__all__
227227
assert name in waterdata.__all__
228228

0 commit comments

Comments
 (0)