|
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 | | - elapsed: object = response.elapsed |
41 | | - except RuntimeError: |
42 | | - return timedelta(0) |
43 | | - return elapsed if isinstance(elapsed, timedelta) else timedelta(0) |
44 | | - |
45 | | - |
46 | | -def _set_response_url(response: httpx.Response, url: str | httpx.URL) -> None: |
47 | | - """ |
48 | | - Overwrite the URL surfaced by a response without back-propagating |
49 | | - the change into any aliased original. |
50 | | -
|
51 | | - Lightweight test doubles expose ``.url`` as a writable attribute. Real |
52 | | - :class:`httpx.Response` objects resolve it through a bound request, so swap |
53 | | - in a fresh request carrying the new URL; mutating the existing request would |
54 | | - leak through any shallow copy that shares it. |
55 | | - """ |
56 | | - if not isinstance(response, httpx.Response): |
57 | | - # Lightweight test doubles expose ``url`` as a writable attribute. |
58 | | - response.url = url |
59 | | - return |
60 | | - |
61 | | - target = httpx.URL(str(url)) |
62 | | - try: |
63 | | - old = response.request |
64 | | - except RuntimeError: |
65 | | - # No request bound (some hand-built httpx.Response fixtures); |
66 | | - # synthesize a minimal one to hold the URL. |
67 | | - response.request = httpx.Request("GET", target) |
68 | | - return |
69 | | - response.request = httpx.Request(method=old.method, url=target, headers=old.headers) |
70 | | - |
71 | | - |
72 | | -def _lowest_remaining(responses: list[httpx.Response]) -> httpx.Response: |
73 | | - """The response reporting the lowest ``x-ratelimit-remaining``. |
74 | | -
|
75 | | - Within a rate-limit window, the counter decreases monotonically, so the |
76 | | - smallest value observed is the most conservative value to surface. Under |
77 | | - concurrent fan-out, the last response *by index* need not be the one the |
78 | | - server processed last. Fall back to the last response when none reports |
79 | | - the header. |
80 | | - """ |
81 | | - best: httpx.Response | None = None |
82 | | - best_remaining: int | None = None |
83 | | - for response in responses: |
84 | | - try: |
85 | | - remaining = int(response.headers[_QUOTA_HEADER]) |
86 | | - except (KeyError, ValueError): |
87 | | - continue |
88 | | - if best_remaining is None or remaining < best_remaining: |
89 | | - best, best_remaining = response, remaining |
90 | | - return best if best is not None else responses[-1] |
91 | | - |
92 | | - |
93 | | -def _merge_response( |
94 | | - base: httpx.Response, |
95 | | - *, |
96 | | - headers_from: httpx.Response, |
97 | | - elapsed: timedelta, |
98 | | - url: str | httpx.URL | None = None, |
99 | | -) -> httpx.Response: |
100 | | - """Fold several responses into one: a shallow copy of ``base`` whose |
101 | | - ``.headers`` are rebuilt as a fresh ``httpx.Headers`` from ``headers_from``, |
102 | | - ``.elapsed`` set to ``elapsed``, and ``.url`` overridden when ``url`` is |
103 | | - given. ``base`` and ``headers_from`` are never mutated, and the fresh |
104 | | - ``httpx.Headers`` means downstream mutations don't back-propagate into any |
105 | | - underlying response — so callers may re-fold idempotently. This is the one |
106 | | - low-level merge behind both pagination |
107 | | - (:func:`~dataretrieval.ogc.engine._paginate`) and the chunked / fan-out |
108 | | - 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(frames: list[pd.DataFrame]) -> pd.DataFrame: |
118 | | - """Concatenate per-chunk frames and deduplicate IDs across chunks. |
119 | | -
|
120 | | - Empty frames are ignored before concatenation so an empty plain |
121 | | - :class:`pandas.DataFrame` cannot downgrade a real ``GeoDataFrame`` and |
122 | | - strip its geometry or CRS. When every frame is empty, the first frame is |
123 | | - returned to preserve its concrete type. |
124 | | -
|
125 | | - When multiple non-empty frames are combined, non-null feature IDs are |
126 | | - deduplicated regardless of the plan axis. Filter clauses can match the same |
127 | | - feature, and list inputs can contain repeated values or otherwise select |
128 | | - overlapping records. Rows without an ``id`` are preserved verbatim: pandas |
129 | | - treats null values as duplicates, so deduplicating them would silently lose |
130 | | - data. |
131 | | - """ |
132 | | - non_empty = [frame for frame in frames if not frame.empty] |
133 | | - if not non_empty: |
134 | | - return frames[0] if frames else pd.DataFrame() |
135 | | - if len(non_empty) == 1: |
136 | | - return non_empty[0].copy() |
137 | | - |
138 | | - combined = pd.concat(non_empty, ignore_index=True) |
139 | | - if "id" not in combined.columns: |
140 | | - return combined |
141 | | - |
142 | | - has_id = combined["id"].notna() |
143 | | - if has_id.all(): |
144 | | - return combined.drop_duplicates(subset="id", ignore_index=True) |
145 | | - if has_id.any(): |
146 | | - id_rows = combined[has_id].drop_duplicates(subset="id") |
147 | | - no_id_rows = combined[~has_id] |
148 | | - return pd.concat([id_rows, no_id_rows], ignore_index=True) |
149 | | - return combined |
150 | | - |
151 | | - |
152 | | -def _combine_chunk_responses( |
153 | | - responses: list[httpx.Response], canonical_url: str | None |
154 | | -) -> httpx.Response: |
155 | | - """ |
156 | | - Fold per-sub-request responses into a single aggregated response. |
157 | | -
|
158 | | - For a multi-response input, returns a shallow copy of |
159 | | - ``responses[0]`` with ``.headers`` set to those of the response reporting |
160 | | - the lowest ``x-ratelimit-remaining`` value (the most conservative quota |
161 | | - observation; see :func:`_lowest_remaining`), ``.elapsed`` set to the sum of |
162 | | - the per-response elapsed durations, and ``.url`` set to the |
163 | | - canonical original-query URL (when supplied) so ``BaseMetadata`` |
164 | | - reflects the user's full request rather than the first chunk. |
165 | | -
|
166 | | - For a single-response input with no canonical-URL override, |
167 | | - ``responses[0]`` is returned unchanged to skip the copy on the |
168 | | - passthrough hot path. |
169 | | -
|
170 | | - Parameters |
171 | | - ---------- |
172 | | - responses : list[httpx.Response] |
173 | | - One response per completed sub-request, in caller-provided order. |
174 | | - canonical_url : str or None |
175 | | - URL of the unchunked original request. ``None`` skips the URL |
176 | | - override — used by the passthrough path (the fetcher's |
177 | | - response already carries the original-query URL) and by the |
178 | | - worst-case overflow path (no buildable canonical URL exists). |
179 | | -
|
180 | | - Returns |
181 | | - ------- |
182 | | - httpx.Response |
183 | | - A shallow copy of the first response with aggregated |
184 | | - ``headers``, ``elapsed``, and ``url``. The function is |
185 | | - idempotent (the input responses' ``headers`` / ``elapsed`` / |
186 | | - ``url`` are never mutated), so it's safe to call repeatedly |
187 | | - via :attr:`ChunkedCall.partial_response` during error |
188 | | - inspection or resume retries. ``headers`` on the returned |
189 | | - object is a fresh ``httpx.Headers``, so mutations there don't |
190 | | - back-propagate into any chunk's underlying response. |
191 | | - """ |
192 | | - if len(responses) == 1 and canonical_url is None: |
193 | | - return responses[0] |
194 | | - |
195 | | - # Headers come from the response with the lowest reported remaining quota; |
196 | | - # ``_lowest_remaining`` returns the lone response as-is |
197 | | - # for a single-element list). ``_merge_response`` re-sums elapsed onto a |
198 | | - # fresh copy, so repeated calls (e.g. via ``ChunkedCall.partial_response`` |
199 | | - # during resume) stay idempotent. |
200 | | - elapsed = sum((_safe_elapsed(r) for r in responses), start=timedelta()) |
201 | | - return _merge_response( |
202 | | - responses[0], |
203 | | - headers_from=_lowest_remaining(responses), |
204 | | - elapsed=elapsed, |
205 | | - url=canonical_url, |
206 | | - ) |
| 1 | +"""Compatibility imports for response aggregation now owned by transport.""" |
| 2 | + |
| 3 | +from dataretrieval.transport.combining import ( |
| 4 | + _QUOTA_HEADER, |
| 5 | + _combine_chunk_frames, |
| 6 | + _combine_chunk_responses, |
| 7 | + _lowest_remaining, |
| 8 | + _merge_response, |
| 9 | + _safe_elapsed, |
| 10 | + _set_response_url, |
| 11 | +) |
| 12 | + |
| 13 | +__all__ = [ |
| 14 | + "_QUOTA_HEADER", |
| 15 | + "_combine_chunk_frames", |
| 16 | + "_combine_chunk_responses", |
| 17 | + "_lowest_remaining", |
| 18 | + "_merge_response", |
| 19 | + "_safe_elapsed", |
| 20 | + "_set_response_url", |
| 21 | +] |
0 commit comments