|
| 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