@@ -37,37 +37,36 @@ def _safe_elapsed(response: httpx.Response) -> timedelta:
3737 treat the missing attribute as zero elapsed.
3838 """
3939 try :
40- return response .elapsed
40+ elapsed : object = response .elapsed
4141 except RuntimeError :
4242 return timedelta (0 )
43+ return elapsed if isinstance (elapsed , timedelta ) else timedelta (0 )
4344
4445
4546def _set_response_url (response : httpx .Response , url : str | httpx .URL ) -> None :
4647 """
4748 Overwrite the URL surfaced by a response without back-propagating
4849 the change into any aliased original.
4950
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``.
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.
5655 """
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 ))
5762 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- )
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 )
7170
7271
7372def _lowest_remaining (responses : list [httpx .Response ]) -> httpx .Response :
@@ -104,8 +103,9 @@ def _merge_response(
104103 given. ``base`` and ``headers_from`` are never mutated, and the fresh
105104 ``httpx.Headers`` means downstream mutations don't back-propagate into any
106105 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`)."""
106+ low-level merge behind both pagination
107+ (:func:`~dataretrieval.ogc.engine._paginate`) and the chunked / fan-out
108+ aggregation (:func:`_combine_chunk_responses`)."""
109109 merged = copy .copy (base )
110110 merged .headers = httpx .Headers (headers_from .headers )
111111 merged .elapsed = elapsed
@@ -114,77 +114,37 @@ def _merge_response(
114114 return merged
115115
116116
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``.
117+ def _combine_chunk_frames (frames : list [pd .DataFrame ]) -> pd .DataFrame :
118+ """Concatenate per-chunk frames and deduplicate non-null feature IDs.
123119
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``.
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.
138124
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.
125+ Deduplication is unconditional because overlap is possible on every plan
126+ axis. Filter clauses can match the same feature, and list inputs can contain
127+ repeated values or otherwise select overlapping records. Rows without an
128+ ``id`` are preserved verbatim: pandas treats null values as duplicates, so
129+ applying ``drop_duplicates`` to those rows would silently lose data.
159130 """
160- non_empty = [f for f in frames if not f .empty ]
131+ non_empty = [frame for frame in frames if not frame .empty ]
161132 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.
168133 return frames [0 ] if frames else pd .DataFrame ()
169134 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.
174135 return non_empty [0 ].copy ()
136+
175137 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 )
138+ if "id" not in combined .columns :
139+ return combined
140+
141+ has_id = combined ["id" ].notna ()
142+ if has_id .all ():
143+ return combined .drop_duplicates (subset = "id" , ignore_index = True )
144+ if has_id .any ():
145+ id_rows = combined [has_id ].drop_duplicates (subset = "id" )
146+ no_id_rows = combined [~ has_id ]
147+ return pd .concat ([id_rows , no_id_rows ], ignore_index = True )
188148 return combined
189149
190150
0 commit comments