Skip to content

Commit cc0bc0e

Browse files
committed
fix!: append params after the URL's own query and keep its bytes
1 parent b1fe7e5 commit cc0bc0e

2 files changed

Lines changed: 50 additions & 28 deletions

File tree

‎src/httpware/client.py‎

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,18 @@ async def _send_capped_async(client: httpx2.AsyncClient, request: httpx2.Request
202202
await flow.aclose()
203203

204204

205+
@contextlib.asynccontextmanager
206+
async def _stream_async(
207+
client: httpx2.AsyncClient, method: str, url: httpx2.URL | str, kwargs: dict[str, typing.Any]
208+
) -> AsyncIterator[httpx2.Response]:
209+
"""Mirror of `httpx2.AsyncClient.stream` that builds via `_build_request`."""
210+
response = await client.send(_build_request(client, method, url, kwargs), stream=True)
211+
try:
212+
yield response
213+
finally:
214+
await response.aclose()
215+
216+
205217
@contextlib.asynccontextmanager
206218
async def _stream_capped_async(
207219
client: httpx2.AsyncClient,
@@ -211,7 +223,7 @@ async def _stream_capped_async(
211223
cap: int,
212224
) -> AsyncIterator[httpx2.Response]:
213225
"""Async mirror of `httpx2.AsyncClient.stream` that sends via `_send_capped_async`."""
214-
response = await _send_capped_async(client, client.build_request(method, url, **kwargs), cap)
226+
response = await _send_capped_async(client, _build_request(client, method, url, kwargs), cap)
215227
try:
216228
yield response
217229
finally:
@@ -271,6 +283,18 @@ def _send_capped(client: httpx2.Client, request: httpx2.Request, cap: int) -> ht
271283
flow.close()
272284

273285

286+
@contextlib.contextmanager
287+
def _stream(
288+
client: httpx2.Client, method: str, url: httpx2.URL | str, kwargs: dict[str, typing.Any]
289+
) -> Iterator[httpx2.Response]:
290+
"""Sync mirror of `_stream_async`."""
291+
response = client.send(_build_request(client, method, url, kwargs), stream=True)
292+
try:
293+
yield response
294+
finally:
295+
response.close()
296+
297+
274298
@contextlib.contextmanager
275299
def _stream_capped(
276300
client: httpx2.Client,
@@ -280,7 +304,7 @@ def _stream_capped(
280304
cap: int,
281305
) -> Iterator[httpx2.Response]:
282306
"""Sync mirror of `_stream_capped_async`."""
283-
response = _send_capped(client, client.build_request(method, url, **kwargs), cap)
307+
response = _send_capped(client, _build_request(client, method, url, kwargs), cap)
284308
try:
285309
yield response
286310
finally:
@@ -322,14 +346,17 @@ def _assemble_request_kwargs( # noqa: PLR0913 — 9 per-request kwargs from htt
322346
return kwargs
323347

324348

325-
def _merge_url_query(
326-
url: httpx2.URL | str, params: typing.Any | None, client_params: httpx2.QueryParams
327-
) -> tuple[httpx2.URL | str, typing.Any | None]:
328-
"""Fold the URL's own query into `params`; httpx2 would otherwise replace it."""
349+
def _build_request(
350+
client: httpx2.Client | httpx2.AsyncClient, method: str, url: httpx2.URL | str, kwargs: dict[str, typing.Any]
351+
) -> httpx2.Request:
352+
"""Build via `client`, keeping the URL's own query bytes ahead of any params, which httpx2 would replace."""
329353
parsed = httpx2.URL(url)
330-
if not parsed.query or (params is None and not client_params):
331-
return url, params
332-
return parsed.copy_with(query=None), parsed.params.merge(params)
354+
if not parsed.query:
355+
return client.build_request(method, url, **kwargs)
356+
request = client.build_request(method, parsed.copy_with(query=None), **kwargs)
357+
built_query = request.url.query
358+
request.url = request.url.copy_with(query=parsed.query + b"&" + built_query if built_query else parsed.query)
359+
return request
333360

334361

335362
class AsyncClient:
@@ -430,8 +457,7 @@ async def send_with_response(
430457

431458
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
432459
"""Delegate request construction to the wrapped httpx2.AsyncClient, keeping the URL's own query."""
433-
merged_url, params = _merge_url_query(url, kwargs.pop("params", None), self._httpx2_client.params)
434-
return self._httpx2_client.build_request(method, merged_url, params=params, **kwargs)
460+
return _build_request(self._httpx2_client, method, url, kwargs)
435461

436462
def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
437463
self,
@@ -448,7 +474,6 @@ def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures;
448474
data: typing.Any | None = None,
449475
files: typing.Any | None = None,
450476
) -> httpx2.Request:
451-
merged_url, params = _merge_url_query(url, params, self._httpx2_client.params)
452477
kwargs = _assemble_request_kwargs(
453478
params=params,
454479
headers=headers,
@@ -460,7 +485,7 @@ def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures;
460485
data=data,
461486
files=files,
462487
)
463-
request = self._httpx2_client.build_request(method, merged_url, **kwargs)
488+
request = _build_request(self._httpx2_client, method, url, kwargs)
464489
if _is_streaming_body_async(content) or _is_streaming_body_async(data) or _is_streaming_body_async(files):
465490
request.extensions[STREAMING_BODY_MARKER] = True
466491
return request
@@ -1216,7 +1241,6 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa
12161241
Maps httpx2 exceptions raised during the request OR body consumption to
12171242
httpware exceptions via _httpx2_exception_mapper.
12181243
"""
1219-
merged_url, params = _merge_url_query(url, params, self._httpx2_client.params)
12201244
kwargs = _assemble_request_kwargs(
12211245
params=params,
12221246
headers=headers,
@@ -1231,9 +1255,9 @@ async def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwa
12311255

12321256
cap = self._max_response_body_bytes
12331257
opened = (
1234-
self._httpx2_client.stream(method, merged_url, **kwargs)
1258+
_stream_async(self._httpx2_client, method, url, kwargs)
12351259
if cap is None
1236-
else _stream_capped_async(self._httpx2_client, method, merged_url, kwargs, cap)
1260+
else _stream_capped_async(self._httpx2_client, method, url, kwargs, cap)
12371261
)
12381262
async with _httpx2_exception_mapper(), opened as response:
12391263
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx
@@ -1392,8 +1416,7 @@ def send_with_response(
13921416

13931417
def build_request(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Request:
13941418
"""Delegate request construction to the wrapped httpx2.Client, keeping the URL's own query."""
1395-
merged_url, params = _merge_url_query(url, kwargs.pop("params", None), self._httpx2_client.params)
1396-
return self._httpx2_client.build_request(method, merged_url, params=params, **kwargs)
1419+
return _build_request(self._httpx2_client, method, url, kwargs)
13971420

13981421
def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-forwarding complexity is structural
13991422
self,
@@ -1410,7 +1433,6 @@ def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures;
14101433
data: typing.Any | None = None,
14111434
files: typing.Any | None = None,
14121435
) -> httpx2.Request:
1413-
merged_url, params = _merge_url_query(url, params, self._httpx2_client.params)
14141436
kwargs = _assemble_request_kwargs(
14151437
params=params,
14161438
headers=headers,
@@ -1422,7 +1444,7 @@ def _prepare_request( # noqa: PLR0913 — mirrors httpx2 per-method signatures;
14221444
data=data,
14231445
files=files,
14241446
)
1425-
request = self._httpx2_client.build_request(method, merged_url, **kwargs)
1447+
request = _build_request(self._httpx2_client, method, url, kwargs)
14261448
if _is_streaming_body_sync(content) or _is_streaming_body_sync(data) or _is_streaming_body_sync(files):
14271449
request.extensions[STREAMING_BODY_MARKER] = True
14281450
return request
@@ -2175,7 +2197,6 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo
21752197
Maps httpx2 exceptions raised during the request OR body consumption to
21762198
httpware exceptions via _httpx2_exception_mapper_sync.
21772199
"""
2178-
merged_url, params = _merge_url_query(url, params, self._httpx2_client.params)
21792200
kwargs = _assemble_request_kwargs(
21802201
params=params,
21812202
headers=headers,
@@ -2190,9 +2211,9 @@ def stream( # noqa: PLR0913 — mirrors httpx2 per-method signatures; kwargs-fo
21902211

21912212
cap = self._max_response_body_bytes
21922213
opened = (
2193-
self._httpx2_client.stream(method, merged_url, **kwargs)
2214+
_stream(self._httpx2_client, method, url, kwargs)
21942215
if cap is None
2195-
else _stream_capped(self._httpx2_client, method, merged_url, kwargs, cap)
2216+
else _stream_capped(self._httpx2_client, method, url, kwargs, cap)
21962217
)
21972218
with _httpx2_exception_mapper_sync(), opened as response:
21982219
if HTTPStatus.BAD_REQUEST <= response.status_code < 600: # noqa: PLR2004 — 600 is the synthetic upper bound for 5xx

‎tests/test_url_query_merge.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""The URL's own query string survives per-request and client-level `params`."""
1+
"""The URL's own query string survives per-request and client-level `params`, which are appended after it."""
22

33
from http import HTTPStatus
44

@@ -30,14 +30,15 @@ def handler(request: httpx2.Request) -> httpx2.Response:
3030

3131
_CASES = [
3232
pytest.param("https://example.test/x?a=1", {"b": "2"}, {}, "a=1&b=2", id="url-query-plus-params"),
33-
pytest.param("https://example.test/x?a=1&b=1", {"b": "2"}, {}, "a=1&b=2", id="params-override-same-key"),
33+
pytest.param("https://example.test/x?a=1&b=1", {"b": "2"}, {}, "a=1&b=1&b=2", id="same-key-appended"),
3434
pytest.param("https://example.test/x?a=1&a=2", {"b": "3"}, {}, "a=1&a=2&b=3", id="repeated-url-keys-kept"),
3535
pytest.param("https://example.test/x?a=1", {}, {}, "a=1", id="empty-params-keeps-url-query"),
36-
pytest.param("https://example.test/x?a=1", None, {"c": "3"}, "c=3&a=1", id="client-params-keep-url-query"),
36+
pytest.param("https://example.test/x?a=1", None, {"c": "3"}, "a=1&c=3", id="client-params-after-url-query"),
3737
pytest.param(
38-
"https://example.test/x?a=1&c=1", {"b": "2"}, {"c": "3"}, "c=1&a=1&b=2", id="url-query-overrides-client"
38+
"https://example.test/x?a=1&c=1", {"b": "2"}, {"c": "3"}, "a=1&c=1&c=3&b=2", id="url-and-client-same-key-kept"
3939
),
4040
pytest.param("https://example.test/x", {"b": "2"}, {"c": "3"}, "c=3&b=2", id="no-url-query-unchanged"),
41+
pytest.param("https://example.test/x?q=a%20b&flag", {"p": "1"}, {}, "q=a%20b&flag&p=1", id="url-query-bytes-kept"),
4142
]
4243

4344

@@ -98,7 +99,7 @@ def test_sync_stream_merges_url_query(
9899
def test_owned_client_relative_url_merges_with_base_url() -> None:
99100
client = Client(base_url="https://example.test/api", params={"c": "3"})
100101
request = client.build_request("GET", "items?a=1", params={"b": "2"})
101-
assert str(request.url) == "https://example.test/api/items?c=3&a=1&b=2"
102+
assert str(request.url) == "https://example.test/api/items?a=1&c=3&b=2"
102103

103104

104105
def test_url_query_without_params_is_left_verbatim() -> None:

0 commit comments

Comments
 (0)