Skip to content

Commit 09b5665

Browse files
authored
refactor(ogc): clarify chunking module boundaries (DOI-USGS#346)
Extract response and frame recombination into combining.py and colocate the private fetch/finalize contracts with ChunkedCall. Centralize transient classification while preserving retry, resumability, exception snapshot, deduplication, nested GeoJSON, and response metadata contracts. Add regression coverage for those behaviors. Align Ruff 0.16.1 across CI, pre-commit, and test metadata, and apply its Markdown code-fence formatting.
1 parent d292021 commit 09b5665

16 files changed

Lines changed: 445 additions & 385 deletions

.github/workflows/python-package.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ jobs:
2020
python-version: "3.14"
2121
cache: "pip"
2222
- name: Install ruff
23-
run: pip install ruff
23+
# Keep this version aligned with the ruff-pre-commit revision.
24+
run: pip install ruff==0.16.1
2425
- name: Lint with ruff
2526
run: |
2627
ruff check . --output-format=github

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ repos:
2121
- id: debug-statements
2222

2323
- repo: https://github.com/astral-sh/ruff-pre-commit
24-
rev: v0.15.15
24+
rev: v0.16.1
2525
hooks:
2626
- id: ruff-check
2727
args: [--fix]

CONTRIBUTING.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,20 @@ via any automated processes or pipelines.
149149
* Example:
150150

151151
``` python
152+
LIGHT_MESSAGES = {
153+
"English": "There are %(number_of_lights)s lights.",
154+
"Pirate": "Arr! Thar be %(number_of_lights)s lights.",
155+
}
152156

153-
LIGHT_MESSAGES = {
154-
'English': "There are %(number_of_lights)s lights.",
155-
'Pirate': "Arr! Thar be %(number_of_lights)s lights."
156-
}
157157

158-
def lights_message(language, number_of_lights):
159-
"""Return a language-appropriate string reporting the light count."""
160-
return LIGHT_MESSAGES[language] % locals()
158+
def lights_message(language, number_of_lights):
159+
"""Return a language-appropriate string reporting the light count."""
160+
return LIGHT_MESSAGES[language] % locals()
161161

162-
def is_pirate(message):
163-
"""Return True if the given message sounds piratical."""
164-
return re.search(r"(?i)(arr|avast|yohoho)!", message) is not None
162+
163+
def is_pirate(message):
164+
"""Return True if the given message sounds piratical."""
165+
return re.search(r"(?i)(arr|avast|yohoho)!", message) is not None
165166
```
166167

167168
---

README.md

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ and set it as an environment variable:
4747

4848
```python
4949
import os
50+
5051
os.environ["API_USGS_PAT"] = "your_api_key_here"
5152
```
5253

@@ -59,9 +60,9 @@ from dataretrieval import waterdata
5960

6061
# Get daily streamflow data (returns DataFrame and metadata)
6162
df, metadata = waterdata.get_daily(
62-
monitoring_location_id='USGS-01646500',
63-
parameter_code='00060', # Discharge
64-
time='2024-10-01/2025-09-30'
63+
monitoring_location_id="USGS-01646500",
64+
parameter_code="00060", # Discharge
65+
time="2024-10-01/2025-09-30",
6566
)
6667

6768
print(f"Retrieved {len(df)} records")
@@ -72,9 +73,9 @@ Retrieve streamflow at multiple locations from October 1, 2024 to the present:
7273

7374
```python
7475
df, metadata = waterdata.get_daily(
75-
monitoring_location_id=["USGS-13018750","USGS-13013650"],
76-
parameter_code='00060',
77-
time='2024-10-01/..'
76+
monitoring_location_id=["USGS-13018750", "USGS-13013650"],
77+
parameter_code="00060",
78+
time="2024-10-01/..",
7879
)
7980

8081
print(f"Retrieved {len(df)} records")
@@ -85,8 +86,8 @@ stream sites in Maryland:
8586
```python
8687
# Get monitoring location information
8788
df, metadata = waterdata.get_monitoring_locations(
88-
state='Maryland', # full name, postal code ('MD'), or FIPS ('24')
89-
site_type_code='ST' # Stream sites
89+
state="Maryland", # full name, postal code ('MD'), or FIPS ('24')
90+
site_type_code="ST", # Stream sites
9091
)
9192

9293
print(f"Found {len(df)} stream monitoring locations in Maryland")
@@ -98,9 +99,9 @@ windows to avoid timeouts and other issues:
9899
```python
99100
# Get continuous data for a single monitoring location and water year
100101
df, metadata = waterdata.get_continuous(
101-
monitoring_location_id='USGS-01646500',
102-
parameter_code='00065', # Gage height
103-
time='2024-10-01/2025-09-30'
102+
monitoring_location_id="USGS-01646500",
103+
parameter_code="00065", # Gage height
104+
time="2024-10-01/2025-09-30",
104105
)
105106
print(f"Retrieved {len(df)} continuous gage height measurements")
106107
```
@@ -125,10 +126,10 @@ from dataretrieval import waterdata
125126
# enough to span many pages, so it profits from a finer split.
126127
sites, _ = waterdata.get_monitoring_locations(state="Ohio", site_type_code="ST")
127128

128-
with waterdata.parallel_chunks(32): # fan out into 32 sub-requests
129+
with waterdata.parallel_chunks(32): # fan out into 32 sub-requests
129130
df, md = waterdata.get_daily(
130131
monitoring_location_id=sites["monitoring_location_id"],
131-
parameter_code="00060", # discharge
132+
parameter_code="00060", # discharge
132133
time="2004-01-01/2023-12-31",
133134
)
134135
```
@@ -167,6 +168,7 @@ API — enable debug-level
167168

168169
```python
169170
import logging
171+
170172
logging.basicConfig(level=logging.DEBUG)
171173
```
172174

@@ -181,14 +183,14 @@ from dataretrieval import ngwmn
181183

182184
# Find the groundwater monitoring sites in a state
183185
# (state accepts a full name, a postal code like 'WI', or a FIPS code like '55')
184-
sites, metadata = ngwmn.get_sites(state='Wisconsin')
186+
sites, metadata = ngwmn.get_sites(state="Wisconsin")
185187

186188
print(f"Found {len(sites)} NGWMN sites in Wisconsin")
187189

188190
# Pull water levels from the first twenty sites over a time window.
189191
water_levels, metadata = ngwmn.get_water_level(
190-
monitoring_location_id=sites['monitoring_location_id'][:20],
191-
datetime=['2022-01-01', '2024-01-01']
192+
monitoring_location_id=sites["monitoring_location_id"][:20],
193+
datetime=["2022-01-01", "2024-01-01"],
192194
)
193195

194196
print(f"Retrieved {len(water_levels)} water-level observations")
@@ -203,16 +205,15 @@ from dataretrieval import wqp
203205

204206
# Find water quality monitoring sites (returns a DataFrame and metadata)
205207
sites, metadata = wqp.what_sites(
206-
statecode='US:55', # Wisconsin
207-
siteType='Stream'
208+
statecode="US:55", # Wisconsin
209+
siteType="Stream",
208210
)
209211

210212
print(f"Found {len(sites)} stream monitoring sites in Wisconsin")
211213

212214
# Get water quality results
213215
results, metadata = wqp.get_results(
214-
siteid='USGS-05427718',
215-
characteristicName='Temperature, water'
216+
siteid="USGS-05427718", characteristicName="Temperature, water"
216217
)
217218

218219
print(f"Retrieved {len(results)} temperature measurements")
@@ -227,18 +228,18 @@ from dataretrieval import nldi
227228

228229
# Get watershed basin for a stream reach
229230
basin = nldi.get_basin(
230-
feature_source='comid',
231-
feature_id='13293474' # NHD reach identifier
231+
feature_source="comid",
232+
feature_id="13293474", # NHD reach identifier
232233
)
233234

234235
print(f"Basin contains {len(basin)} feature(s)")
235236

236237
# Find upstream flowlines
237238
flowlines = nldi.get_flowlines(
238-
feature_source='comid',
239-
feature_id='13293474',
240-
navigation_mode='UT', # Upstream tributaries
241-
distance=50 # km
239+
feature_source="comid",
240+
feature_id="13293474",
241+
navigation_mode="UT", # Upstream tributaries
242+
distance=50, # km
242243
)
243244

244245
print(f"Found {len(flowlines)} upstream tributaries within 50km")
@@ -255,17 +256,17 @@ from dataretrieval import wateruse
255256
# Monthly public-supply withdrawals for Rhode Island, split into
256257
# groundwater and surface-water sources (returns a DataFrame and metadata).
257258
df, metadata = wateruse.get_wateruse(
258-
model='wu-public-supply-wd',
259-
variable=['pswdtot', 'pswdgw', 'pswdsw'],
260-
state='RI', # name/postal/FIPS; pass a list to fan out over several areas
261-
start_date='2020-01',
262-
time_resolution='monthly',
259+
model="wu-public-supply-wd",
260+
variable=["pswdtot", "pswdgw", "pswdsw"],
261+
state="RI", # name/postal/FIPS; pass a list to fan out over several areas
262+
start_date="2020-01",
263+
time_resolution="monthly",
263264
)
264265

265266
print(f"Retrieved {len(df)} records across {df['huc12_id'].nunique()} watersheds")
266267

267268
# Aggregate the HUC12 grid to a statewide monthly total (million gallons/day)
268-
statewide = df.groupby('year_month')['pswdtot_mgd'].sum()
269+
statewide = df.groupby("year_month")["pswdtot_mgd"].sum()
269270
print(statewide.head())
270271
```
271272

dataretrieval/ogc/chunking.py

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
import asyncio
7575
import functools
7676
import os
77-
from collections.abc import Callable, Iterator
77+
from collections.abc import Awaitable, Callable, Iterator
7878
from contextlib import contextmanager
7979
from contextvars import copy_context
8080
from typing import Any, cast
@@ -86,17 +86,14 @@
8686
from dataretrieval.utils import HTTPX_DEFAULTS, Ambient, _require_positive_int
8787

8888
from . import progress as _progress
89-
from .interruptions import (
90-
ChunkInterrupted,
91-
_Fetch,
92-
_Finalize,
93-
_passthrough_result,
94-
)
95-
from .planning import (
96-
ChunkPlan,
89+
from .combining import (
9790
_combine_chunk_frames,
9891
_combine_chunk_responses,
9992
)
93+
from .interruptions import (
94+
ChunkInterrupted,
95+
)
96+
from .planning import ChunkPlan
10097
from .retry import (
10198
_NO_RETRY,
10299
RetryPolicy,
@@ -291,6 +288,30 @@ def parallel_chunks(n: int) -> Iterator[None]:
291288
yield
292289

293290

291+
# ---------------------------------------------------------------------------
292+
# Type aliases for the ChunkedCall contract.
293+
# ---------------------------------------------------------------------------
294+
295+
# The per-sub-request fetcher the decorator wraps and ``ChunkedCall`` drives:
296+
# an ``async def fetch(args) -> (df, response)``.
297+
_Fetch = Callable[[dict[str, Any]], Awaitable[tuple[pd.DataFrame, httpx.Response]]]
298+
299+
# Caller-supplied transform applied to the combined chunk result, so a
300+
# resumed call returns the same shape as an un-interrupted one rather than
301+
# the chunker's raw ``(frame, httpx.Response)``. This keeps the chunker
302+
# generic: the OGC getters inject their post-processing (type coercion,
303+
# column arrangement, ``BaseMetadata``) through ``_finalize_ogc``.
304+
# The default is identity, so direct ``ChunkedCall`` use is unaffected.
305+
_Finalize = Callable[[pd.DataFrame, httpx.Response], tuple[pd.DataFrame, Any]]
306+
307+
308+
def _passthrough_result(
309+
frame: pd.DataFrame, response: httpx.Response
310+
) -> tuple[pd.DataFrame, Any]:
311+
"""Default :data:`_Finalize`: return the raw combined pair unchanged."""
312+
return frame, response
313+
314+
294315
class ChunkedCall:
295316
"""
296317
Stateful handle for a chunked call.
@@ -417,12 +438,11 @@ def _combine_raw(self) -> tuple[pd.DataFrame, httpx.Response]:
417438
418439
Frames concatenate in sub-args *index* order (``sorted`` keys —
419440
deterministic, independent of parallel completion order). The
420-
aggregated response takes its headers from the most-recently-
421-
*completed* sub-request: the ``track`` closure in :meth:`_run`
422-
is the only writer of ``self._chunks`` and ``dict`` preserves
423-
insertion order, so the chunks' natural order is completion
424-
order and the last one carries the freshest
425-
``x-ratelimit-remaining``.
441+
aggregated response takes its headers from the response with the
442+
lowest reported ``x-ratelimit-remaining`` value. If no response
443+
reports that header, it falls back to the last completed response;
444+
``self._chunks`` preserves completion order because the ``track``
445+
closure in :meth:`_run` is its only writer.
426446
427447
Returns
428448
-------
@@ -521,8 +541,9 @@ def resume(self) -> tuple[pd.DataFrame, Any]:
521541
Combined data from every successful sub-request.
522542
response
523543
The finalized aggregate — a raw :class:`httpx.Response`
524-
(canonical URL, most-recently-completed sub-request's headers,
525-
cumulative elapsed time) by default, or whatever
544+
(canonical URL, headers from the response with the lowest reported
545+
remaining quota, and summed response elapsed durations) by default,
546+
or whatever
526547
:attr:`finalize` produces (e.g. ``BaseMetadata`` for the OGC
527548
getters).
528549
@@ -603,8 +624,9 @@ async def _run(self, max_concurrent: int | None) -> tuple[pd.DataFrame, Any]:
603624
Combined data from every sub-request.
604625
response
605626
The finalized aggregate — a raw :class:`httpx.Response`
606-
(canonical URL, most-recently-completed sub-request's headers,
607-
cumulative elapsed time) by default, or whatever
627+
(canonical URL, headers from the response with the lowest reported
628+
remaining quota, and summed response elapsed durations) by default,
629+
or whatever
608630
:attr:`finalize` produces (e.g. ``BaseMetadata`` for OGC getters).
609631
610632
Raises

0 commit comments

Comments
 (0)