Skip to content

Commit d6fa476

Browse files
committed
refactor(ogc): extract combining.py from planning.py
Move response/frame recombination helpers (_combine_chunk_frames, _combine_chunk_responses, _merge_response, _lowest_remaining, _safe_elapsed, _set_response_url, _QUOTA_HEADER) into a dedicated combining.py. planning.py re-exports them for backwards compat. Separates 'what to split' (planning) from 'how to reassemble' (combining), each with its own reason to change.
1 parent f978a72 commit d6fa476

2 files changed

Lines changed: 283 additions & 239 deletions

File tree

dataretrieval/ogc/combining.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""Result recombination: merge per-chunk frames and responses (no I/O).
2+
3+
These utilities assemble the output of a chunked/fan-out call from its
4+
individual per-sub-request results. They have no event-loop, retry, or
5+
network state — they're pure data transforms imported by both the
6+
chunked-call execution (:mod:`dataretrieval.ogc.chunking`) and the
7+
per-page pagination (:mod:`dataretrieval.ogc.engine`).
8+
9+
Separated from :mod:`dataretrieval.ogc.planning` so that module stays
10+
focused on *what* to split, while this module owns *how* to reassemble.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import copy
16+
from datetime import timedelta
17+
18+
import httpx
19+
import pandas as pd
20+
21+
# Response header USGS uses to advertise remaining hourly quota. Lives in this
22+
# module so every layer (the combine helpers below, the engine's per-page
23+
# progress reporter) reads it from one place rather than hard-coding the string.
24+
_QUOTA_HEADER = "x-ratelimit-remaining"
25+
26+
27+
def _safe_elapsed(response: httpx.Response) -> timedelta:
28+
"""
29+
Read ``response.elapsed``, falling back to ``timedelta(0)`` when
30+
the attribute hasn't been populated.
31+
32+
httpx only writes ``.elapsed`` when a response is closed through
33+
its normal transport path. ``MockTransport`` (used by
34+
``pytest-httpx``) and hand-constructed ``httpx.Response`` objects
35+
leave the attribute unset, so accessing it raises ``RuntimeError``.
36+
Combining responses across chunks needs a defined duration, so we
37+
treat the missing attribute as zero elapsed.
38+
"""
39+
try:
40+
return response.elapsed
41+
except RuntimeError:
42+
return timedelta(0)
43+
44+
45+
def _set_response_url(response: httpx.Response, url: str | httpx.URL) -> None:
46+
"""
47+
Overwrite the URL surfaced by a response without back-propagating
48+
the change into any aliased original.
49+
50+
Try the direct assignment first: on lightweight test mocks ``.url``
51+
is a plain writable attribute. On real ``httpx.Response`` it's
52+
read-only (it resolves through the bound request), so swap in a
53+
fresh :class:`httpx.Request` carrying the new URL — mutating the
54+
existing one would leak through any shallow copy that shares the
55+
same ``.request``.
56+
"""
57+
try:
58+
response.url = url # type: ignore[misc, assignment]
59+
except AttributeError:
60+
target = httpx.URL(str(url))
61+
try:
62+
old = response.request
63+
except RuntimeError:
64+
# No request bound (some hand-built httpx.Response fixtures);
65+
# synthesize a minimal one to hold the URL.
66+
response.request = httpx.Request("GET", target)
67+
return
68+
response.request = httpx.Request(
69+
method=old.method, url=target, headers=old.headers
70+
)
71+
72+
73+
def _lowest_remaining(responses: list[httpx.Response]) -> httpx.Response:
74+
"""The response reporting the lowest ``x-ratelimit-remaining``.
75+
76+
The rate-limit counter decreases monotonically within a window, so the
77+
smallest value any sub-request saw is the most-current "quota left after
78+
this call" — the right thing to surface. Under concurrent fan-out the
79+
last response *by index* need not be the one the server processed last, so
80+
pick the minimum (falling back to the last response if none report it).
81+
"""
82+
best: httpx.Response | None = None
83+
best_remaining: int | None = None
84+
for response in responses:
85+
try:
86+
remaining = int(response.headers[_QUOTA_HEADER])
87+
except (KeyError, ValueError):
88+
continue
89+
if best_remaining is None or remaining < best_remaining:
90+
best, best_remaining = response, remaining
91+
return best if best is not None else responses[-1]
92+
93+
94+
def _merge_response(
95+
base: httpx.Response,
96+
*,
97+
headers_from: httpx.Response,
98+
elapsed: timedelta,
99+
url: str | httpx.URL | None = None,
100+
) -> httpx.Response:
101+
"""Fold several responses into one: a shallow copy of ``base`` whose
102+
``.headers`` are rebuilt as a fresh ``httpx.Headers`` from ``headers_from``,
103+
``.elapsed`` set to ``elapsed``, and ``.url`` overridden when ``url`` is
104+
given. ``base`` and ``headers_from`` are never mutated, and the fresh
105+
``httpx.Headers`` means downstream mutations don't back-propagate into any
106+
underlying response — so callers may re-fold idempotently. This is the one
107+
low-level merge behind both pagination (:func:`_paginate`) and the chunked /
108+
fan-out aggregation (:func:`_combine_chunk_responses`)."""
109+
merged = copy.copy(base)
110+
merged.headers = httpx.Headers(headers_from.headers)
111+
merged.elapsed = elapsed
112+
if url is not None:
113+
_set_response_url(merged, url)
114+
return merged
115+
116+
117+
def _combine_chunk_frames(
118+
frames: list[pd.DataFrame], *, dedup: bool = True
119+
) -> pd.DataFrame:
120+
"""
121+
Concatenate per-chunk frames, dropping empties and optionally deduping
122+
by ``id``.
123+
124+
Parameters
125+
----------
126+
frames : list[pandas.DataFrame]
127+
One frame per completed sub-request.
128+
dedup : bool, default True
129+
Whether to drop duplicate rows keyed on the ``id`` column.
130+
List-axis chunks (multi-value list parameters like
131+
``monitoring_location_id``) produce non-overlapping partitions,
132+
so dedup is unnecessary and skipping it saves ~2 ms on a 50 k-row
133+
result. Filter-axis chunks *can* overlap (a feature matching
134+
multiple OR-clauses appears in each clause's chunk), so dedup is
135+
required there. Callers that know their chunks don't overlap
136+
(e.g. :meth:`ChunkedCall._combine_raw` when the plan has no
137+
filter axis) pass ``dedup=False``.
138+
139+
Returns
140+
-------
141+
pandas.DataFrame
142+
The concatenated (and optionally deduplicated) result. Empty when
143+
every input frame is empty.
144+
145+
Notes
146+
-----
147+
An empty chunk can be a plain ``pd.DataFrame()`` (no geopandas);
148+
concatenating it with real ``GeoDataFrame``s downgrades the result
149+
to plain ``DataFrame`` and strips geometry/CRS, so empties are
150+
dropped first. Dedup on the pre-rename feature ``id`` keeps
151+
overlapping user OR-clauses from producing duplicate rows across
152+
chunks.
153+
154+
Dedup is restricted to rows whose ``id`` is non-null. ``pandas``
155+
treats NaN==NaN as a duplicate for ``drop_duplicates``, so a
156+
blanket call would collapse every id-less row into a single one —
157+
silent data loss if any chunk emits features without an
158+
``id`` field.
159+
"""
160+
non_empty = [f for f in frames if not f.empty]
161+
if not non_empty:
162+
# Preserve the frame type (GeoDataFrame vs DataFrame) of the
163+
# input even when every chunk is empty — ``_get_resp_data``
164+
# returns ``gpd.GeoDataFrame()`` on empty geopd responses, and
165+
# returning a plain ``pd.DataFrame()`` here would downgrade
166+
# the type in a downstream ``pd.concat([result, geo_page])`` to
167+
# a plain DataFrame and strip geometry/CRS.
168+
return frames[0] if frames else pd.DataFrame()
169+
if len(non_empty) == 1:
170+
# Single-completed-chunk fast path. Return a copy so callers
171+
# who treat ``ChunkedCall.partial_frame`` as a fresh result
172+
# (the property docstring says "live; recomputed per access")
173+
# don't accidentally mutate ``_chunks[0][0]`` in place.
174+
return non_empty[0].copy()
175+
combined = pd.concat(non_empty, ignore_index=True)
176+
if dedup and "id" in combined.columns:
177+
has_id = combined["id"].notna()
178+
if has_id.all():
179+
combined = combined.drop_duplicates(subset="id", ignore_index=True)
180+
elif has_id.any():
181+
# Mixed: dedupe only the id-bearing rows; preserve id-less
182+
# rows verbatim (their order relative to id-bearing rows
183+
# may shift, which is acceptable — dedup can't be id-keyed
184+
# for rows without an id).
185+
id_rows = combined[has_id].drop_duplicates(subset="id")
186+
no_id_rows = combined[~has_id]
187+
combined = pd.concat([id_rows, no_id_rows], ignore_index=True)
188+
return combined
189+
190+
191+
def _combine_chunk_responses(
192+
responses: list[httpx.Response], canonical_url: str | None
193+
) -> httpx.Response:
194+
"""
195+
Fold per-sub-request responses into a single aggregated response.
196+
197+
For a multi-response input, returns a shallow copy of
198+
``responses[0]`` with ``.headers`` set to those of the most-depleted
199+
response (lowest ``x-ratelimit-remaining`` — the quota actually left
200+
after the fan-out; see :func:`_lowest_remaining`), ``.elapsed`` set
201+
to total wall-clock across every response, and ``.url`` set to the
202+
canonical original-query URL (when supplied) so ``BaseMetadata``
203+
reflects the user's full request rather than the first chunk.
204+
205+
For a single-response input with no canonical-URL override,
206+
``responses[0]`` is returned unchanged to skip the copy on the
207+
passthrough hot path.
208+
209+
Parameters
210+
----------
211+
responses : list[httpx.Response]
212+
One response per completed sub-request, in execution order.
213+
canonical_url : str or None
214+
URL of the unchunked original request. ``None`` skips the URL
215+
override — used by the passthrough path (the fetcher's
216+
response already carries the original-query URL) and by the
217+
worst-case overflow path (no buildable canonical URL exists).
218+
219+
Returns
220+
-------
221+
httpx.Response
222+
A shallow copy of the first response with aggregated
223+
``headers``, ``elapsed``, and ``url``. The function is
224+
idempotent (the input responses' ``headers`` / ``elapsed`` /
225+
``url`` are never mutated), so it's safe to call repeatedly
226+
via :attr:`ChunkedCall.partial_response` during error
227+
inspection or resume retries. ``headers`` on the returned
228+
object is a fresh ``httpx.Headers``, so mutations there don't
229+
back-propagate into any chunk's underlying response.
230+
"""
231+
if len(responses) == 1 and canonical_url is None:
232+
return responses[0]
233+
234+
# Headers come from the most-depleted response (lowest quota left after a
235+
# concurrent fan-out; ``_lowest_remaining`` returns the lone response as-is
236+
# for a single-element list). ``_merge_response`` re-sums elapsed onto a
237+
# fresh copy, so repeated calls (e.g. via ``ChunkedCall.partial_response``
238+
# during resume) stay idempotent.
239+
elapsed = sum((_safe_elapsed(r) for r in responses), start=timedelta())
240+
return _merge_response(
241+
responses[0],
242+
headers_from=_lowest_remaining(responses),
243+
elapsed=elapsed,
244+
url=canonical_url,
245+
)

0 commit comments

Comments
 (0)