Skip to content

Commit 0cb5bfa

Browse files
thodson-usgsclaude
andcommitted
docs+refactor(ogc): address code-review findings 1/3/5/6 on parallel_chunks
From the xhigh workflow review of the parallel_chunks branch: - [5] Extract the duplicated positive-integer validation (numbers.Integral + bool-exclusion + < 1) into utils._require_positive_int; call it from both max_rows (engine) and parallel_chunks (chunking) so the two count knobs can't drift. Drops the now-unused `import numbers` from both. ChunkPlan's own `< 1` guard keeps its domain-specific "1 disables fan-out" message. - [1,3] Document the consequences of fanning out a fitting request in the parallel_chunks docstring (new Notes section): with max_rows the result is drawn from the union of the sub-requests (a different, still-valid, sorted row set than the un-fanned call); a fanned-out call can fail partway and become resumable; cross-chunk dedup keys on id. These are the same caveats byte-forced chunking already carries — the block just extends their reach. - [6] Collapse the triplicated cap-contract prose in planning.py: the ChunkPlan.max_chunks param docstring is the single authority; _refine now documents only its algorithm and cross-references the contract. No behavior change. Messages preserve the substrings the tests match ("positive integer"); numpy-int caps still accepted, bool/float/str/< 1 still rejected. Signed-off-by: thodson-usgs <thodson@usgs.gov> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b611e9f commit 0cb5bfa

4 files changed

Lines changed: 80 additions & 46 deletions

File tree

dataretrieval/ogc/chunking.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@
7373

7474
import asyncio
7575
import functools
76-
import numbers
7776
import os
7877
from collections.abc import Callable, Iterator
7978
from contextlib import contextmanager
@@ -84,7 +83,7 @@
8483
import pandas as pd
8584
from anyio.from_thread import start_blocking_portal
8685

87-
from dataretrieval.utils import HTTPX_DEFAULTS, Ambient
86+
from dataretrieval.utils import HTTPX_DEFAULTS, Ambient, _require_positive_int
8887

8988
from . import progress as _progress
9089
from .interruptions import (
@@ -250,6 +249,29 @@ def parallel_chunks(n: int) -> Iterator[None]:
250249
any request is issued, so a bad value fails loudly rather than silently
251250
doing nothing.
252251
252+
Notes
253+
-----
254+
Fanning out carries the same consequences as the byte-limit chunking the
255+
getters already do for oversized requests; opting in just brings them to a
256+
request that would otherwise be a single call:
257+
258+
- ``max_rows``: each sub-request paginates up to ``max_rows`` rows
259+
independently, then the combined result is sorted and truncated to
260+
``max_rows``. So a call with ``max_rows`` set returns a *different*
261+
(though still valid and deterministically sorted) row set inside a
262+
``parallel_chunks`` block than without one — the cap is drawn from the
263+
union of the sub-requests, not a single stream. Don't pair a tight
264+
``max_rows`` preview with ``parallel_chunks`` if you need exactly the
265+
rows the un-fanned call would return.
266+
- Resumability: a single request either fully succeeds or fully fails,
267+
but a fanned-out call can fail partway (e.g. a mid-call rate-limit) and
268+
raise a resumable :class:`~dataretrieval.exceptions.ChunkInterrupted`
269+
(or ``QuotaExhausted``) carrying the completed sub-requests, which you
270+
finish with ``exc.call.resume()``.
271+
- Cross-sub-request de-duplication keys on the feature ``id``; features
272+
with no ``id`` can't be deduped, so overlapping filter clauses split
273+
across chunks may yield duplicate rows.
274+
253275
Examples
254276
--------
255277
>>> from dataretrieval import waterdata
@@ -262,13 +284,9 @@ def parallel_chunks(n: int) -> Iterator[None]:
262284
--------
263285
ChunkPlan._refine : the planning-side effect of ``n``.
264286
"""
265-
# Accept any integer type (incl. numpy ints, mirroring ``max_rows``); ``bool``
266-
# is an ``Integral`` subclass but nonsensical here, so reject it explicitly.
267-
if not isinstance(n, numbers.Integral) or isinstance(n, bool) or n < 1:
268-
raise ValueError(
269-
f"parallel_chunks(n): n must be a positive integer (e.g. 2, 8, 32); "
270-
f"got {n!r}."
271-
)
287+
# Fail loudly on a bad ``n`` at ``with`` entry, before any request. Shared
288+
# rules with ``max_rows`` via the helper (accepts numpy ints, rejects bool).
289+
_require_positive_int(n, "parallel_chunks(n)", examples="2, 8, 32")
272290
with _parallel_chunks(n):
273291
yield
274292

dataretrieval/ogc/engine.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
import functools
2828
import json
2929
import logging
30-
import numbers
3130
import re
3231
from collections.abc import (
3332
AsyncIterator,
@@ -59,6 +58,7 @@
5958
_default_headers,
6059
_get,
6160
_network_error,
61+
_require_positive_int,
6262
)
6363

6464
# Set up logger for this module
@@ -876,18 +876,12 @@ def get_ogc_data(
876876
- Handles optional arguments such as `convert_type`.
877877
- Applies column cleanup and reordering based on service and properties.
878878
"""
879-
# Enforce a genuine positive integer: a float (even ``10.0``) or ``bool``
880-
# would pass a bare ``< 1`` check and then crash deep in
879+
# Enforce a genuine positive integer up front: a float (even ``10.0``) or
880+
# ``bool`` would pass a bare ``< 1`` check and then crash deep in
881881
# ``pd.DataFrame.head`` with an opaque ``TypeError`` after HTTP I/O has
882-
# already fired. ``numbers.Integral`` (not ``int``) so numpy integers —
883-
# e.g. ``max_rows`` derived from a numpy/pandas computation — are accepted;
884-
# ``bool`` is an ``Integral`` subtype, so exclude it explicitly.
885-
if max_rows is not None and (
886-
not isinstance(max_rows, numbers.Integral)
887-
or isinstance(max_rows, bool)
888-
or max_rows < 1
889-
):
890-
raise ValueError(f"max_rows must be a positive integer (got {max_rows!r}).")
882+
# already fired. Shared with ``parallel_chunks(n)`` via the helper.
883+
if max_rows is not None:
884+
_require_positive_int(max_rows, "max_rows")
891885

892886
if dialect is None:
893887
dialect = _DEFAULT_DIALECT

dataretrieval/ogc/planning.py

Lines changed: 19 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -512,36 +512,30 @@ def _plan(
512512
def _refine(self, max_chunks: int) -> None:
513513
"""
514514
Fan the plan out more finely than the byte budget alone requires —
515-
the parallel_chunks dial (see
515+
the ``parallel_chunks`` dial (see
516516
:func:`~dataretrieval.ogc.chunking.parallel_chunks` for why a caller
517-
would want this).
518-
519-
Caps the plan's *total* sub-request count (:attr:`total`, the
520-
cartesian product across all axes) at ``max_chunks``, not
521-
each axis independently — with several multi-value axes, a cap of 32
522-
still means at most 32 sub-requests overall, not ``32 ** n_axes``.
523-
The cap is a hard ceiling: every split multiplies the plan by
524-
``(k+1)/k`` for the chosen axis (adding ``total // k`` sub-requests,
525-
not one), so a split is taken only when it keeps ``total`` within the
526-
cap. When no in-budget split remains the plan lands *below* the cap
527-
rather than overshooting it — a multi-axis plan may not divide evenly
528-
onto ``max_chunks`` (e.g. two even axes can reach 4 but not 5, so a
529-
cap of 5 yields 4). Each split picks the single largest splittable
530-
chunk among the in-budget axes (ties broken by axis-extraction order,
531-
then lowest index), so growth is distributed round-robin rather than
532-
one axis saturating before another is touched. Purely additive — only
533-
ever *splits* existing chunks, so the byte pass's work and the
534-
``url_limit`` invariant are both preserved, and it never raises. A
535-
no-op at ``max_chunks == 1``.
517+
would want this, and :class:`ChunkPlan`'s ``max_chunks`` parameter for
518+
the cap's contract: total-not-per-axis, a hard ceiling that may land
519+
below the cap).
520+
521+
Implementation. Each split multiplies the plan by ``(k+1)/k`` for the
522+
chosen axis (adding ``total // k`` sub-requests, not one), so a split
523+
is taken only when it keeps :attr:`total` within the cap; when no
524+
in-budget split remains the plan stops *below* the cap rather than
525+
overshooting (two even axes can reach 4 but not 5, so a cap of 5 yields
526+
4). Each split picks the single largest splittable chunk among the
527+
in-budget axes (ties broken by axis-extraction order, then lowest
528+
index), so growth is distributed round-robin rather than one axis
529+
saturating before another is touched. Purely additive — only ever
530+
*splits* existing chunks, so the byte pass's work and the ``url_limit``
531+
invariant are both preserved, and it never raises. A no-op at
532+
``max_chunks == 1``.
536533
537534
Parameters
538535
----------
539536
max_chunks : int
540-
Hard cap on the plan's total sub-request count (``1`` = off) — the
541-
``parallel_chunks(n)`` value. The cap is on the whole plan (the
542-
cartesian product across axes), not per axis, so several multi-value
543-
axes can't multiply past it; the plan never exceeds it and may fall
544-
short when no whole split lands on it exactly.
537+
The ``parallel_chunks(n)`` value; see :class:`ChunkPlan`'s
538+
``max_chunks`` parameter for the full contract.
545539
"""
546540
if max_chunks <= 1:
547541
return

dataretrieval/utils.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from __future__ import annotations
66

7+
import numbers
78
import os
89
import warnings
910
from collections.abc import Callable, Iterable, Iterator
@@ -66,6 +67,33 @@ def __call__(self, value: _T) -> Iterator[None]:
6667
self._var.reset(token)
6768

6869

70+
def _require_positive_int(
71+
value: int, name: str, *, examples: str | None = None
72+
) -> None:
73+
"""Validate that ``value`` is a positive integer, else raise ``ValueError``.
74+
75+
Accepts any :class:`numbers.Integral` (so a numpy/pandas integer passes,
76+
not only ``int``) but rejects ``bool`` — an ``Integral`` subtype that is
77+
nonsensical as a count. A non-integer (float, str, ``None``) or a value
78+
``< 1`` raises before any I/O, rather than crashing later (e.g. deep in
79+
``pd.DataFrame.head``). Shared by the count-like knobs (``max_rows`` and
80+
``parallel_chunks(n)``) so they validate by identical rules and can't drift.
81+
82+
Parameters
83+
----------
84+
value : int
85+
The value to check. Typed ``int`` for callers, but validated at
86+
runtime because the real value may be anything the user passed.
87+
name : str
88+
Parameter name, used as the subject of the error message.
89+
examples : str, optional
90+
Illustrative values appended to the message (e.g. ``"2, 8, 32"``).
91+
"""
92+
if not isinstance(value, numbers.Integral) or isinstance(value, bool) or value < 1:
93+
eg = f", e.g. {examples}" if examples else ""
94+
raise ValueError(f"{name} must be a positive integer{eg} (got {value!r}).")
95+
96+
6997
def _default_headers() -> dict[str, str]:
7098
"""Build the default HTTP headers for a USGS web-API request.
7199

0 commit comments

Comments
 (0)