From 3d36a56cdc42b711b50fbbe681e167071f7ad11a Mon Sep 17 00:00:00 2001 From: Keegan Cowle Date: Mon, 18 May 2026 17:50:06 +0200 Subject: [PATCH 1/3] Fix params replacing query set in URL --- src/httpx2/httpx2/_models.py | 15 +++++++++++-- tests/httpx2/client/test_queryparams.py | 28 +++++++++++++++++++++++++ tests/httpx2/models/test_requests.py | 12 +++++++++-- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/httpx2/httpx2/_models.py b/src/httpx2/httpx2/_models.py index e6aeabd6..b50adbd9 100644 --- a/src/httpx2/httpx2/_models.py +++ b/src/httpx2/httpx2/_models.py @@ -45,7 +45,7 @@ ResponseExtensions, SyncByteStream, ) -from ._urls import URL +from ._urls import URL, QueryParams from ._utils import to_bytes_or_str, to_str __all__ = ["Cookies", "Headers", "Request", "Response"] @@ -385,7 +385,18 @@ def __init__( extensions: RequestExtensions | None = None, ) -> None: self.method = method.upper() - self.url = URL(url) if params is None else URL(url, params=params) + if params is None: + self.url = URL(url) + else: + base_url = URL(url) + params_obj = QueryParams(params) + if params_obj: + new_params_bytes = str(params_obj).encode("ascii") + existing_query = base_url.query # b"" if no query + merged_query = (existing_query + b"&" + new_params_bytes) if existing_query else new_params_bytes + self.url = base_url.copy_with(query=merged_query) + else: + self.url = base_url self.headers = Headers(headers) self.extensions = {} if extensions is None else dict(extensions) diff --git a/tests/httpx2/client/test_queryparams.py b/tests/httpx2/client/test_queryparams.py index 7fb2c145..cde4d9e1 100644 --- a/tests/httpx2/client/test_queryparams.py +++ b/tests/httpx2/client/test_queryparams.py @@ -31,3 +31,31 @@ def test_client_queryparams_echo() -> None: assert response.status_code == 200 assert response.url == "http://example.org/echo_queryparams?first=str&second=dict" + + +def test_base_url_with_request_params(): + # Query params in the request URL must not be dropped when request-level + # params are also passed. + client = httpx2.Client(base_url="https://api.example.com/v1/") + request = client.build_request("GET", "users?active=true", params={"page": "2"}) + + assert str(request.url) == "https://api.example.com/v1/users?active=true&page=2" + + +def test_base_url_with_client_params_and_url_query(): + # Client-level params must be appended to query params already present in + # the request URL, not replace them. + client = httpx2.Client(base_url="https://api.example.com/v1/", params={"api_key": "abc"}) + request = client.build_request("GET", "users?active=true") + + assert str(request.url) == "https://api.example.com/v1/users?active=true&api_key=abc" + + +def test_base_url_with_client_params_request_params_and_url_query(): + # All three sources of query params (URL, client-level, request-level) + # must be combined without any being dropped. + client = httpx2.Client(base_url="https://api.example.com/v1/", params={"api_key": "abc"}) + request = client.build_request("GET", "users?active=true", params={"page": "2"}) + + assert str(request.url) == "https://api.example.com/v1/users?active=true&api_key=abc&page=2" + diff --git a/tests/httpx2/models/test_requests.py b/tests/httpx2/models/test_requests.py index 9428f0d7..f6d9e734 100644 --- a/tests/httpx2/models/test_requests.py +++ b/tests/httpx2/models/test_requests.py @@ -232,8 +232,16 @@ def test_request_params() -> None: request = httpx2.Request("GET", "http://example.com", params={}) assert str(request.url) == "http://example.com" + # params are appended to, not replacing, any existing query in the URL request = httpx2.Request("GET", "http://example.com?c=3", params={"a": "1", "b": "2"}) - assert str(request.url) == "http://example.com?a=1&b=2" + assert str(request.url) == "http://example.com?c=3&a=1&b=2" request = httpx2.Request("GET", "http://example.com?a=1", params={}) - assert str(request.url) == "http://example.com" + assert str(request.url) == "http://example.com?a=1" + + +def test_request_params_no_double_encoding(): + # The existing query string must not be reparsed through QueryParams; + # doing so risks double-encoding or reordering of existing parameters. + request = httpx2.Request("GET", "http://example.com?q=hello%20world", params={"page": "2"}) + assert str(request.url) == "http://example.com?q=hello%20world&page=2" From 17dcbf5597f0a61e583b4bd0dda05a25d67f0ac9 Mon Sep 17 00:00:00 2001 From: Keegan Cowle Date: Mon, 18 May 2026 17:58:06 +0200 Subject: [PATCH 2/3] chore: linting --- tests/httpx2/client/test_queryparams.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/httpx2/client/test_queryparams.py b/tests/httpx2/client/test_queryparams.py index cde4d9e1..30c1be8f 100644 --- a/tests/httpx2/client/test_queryparams.py +++ b/tests/httpx2/client/test_queryparams.py @@ -58,4 +58,3 @@ def test_base_url_with_client_params_request_params_and_url_query(): request = client.build_request("GET", "users?active=true", params={"page": "2"}) assert str(request.url) == "https://api.example.com/v1/users?active=true&api_key=abc&page=2" - From 2631c38b2ce322e8bfbf3b07e3fcd0ead789281a Mon Sep 17 00:00:00 2001 From: Keegan Cowle Date: Sun, 31 May 2026 14:16:49 +0200 Subject: [PATCH 3/3] chore: fix mypy errors --- tests/httpx2/client/test_queryparams.py | 6 +++--- tests/httpx2/models/test_requests.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/httpx2/client/test_queryparams.py b/tests/httpx2/client/test_queryparams.py index 30c1be8f..58d627ba 100644 --- a/tests/httpx2/client/test_queryparams.py +++ b/tests/httpx2/client/test_queryparams.py @@ -33,7 +33,7 @@ def test_client_queryparams_echo() -> None: assert response.url == "http://example.org/echo_queryparams?first=str&second=dict" -def test_base_url_with_request_params(): +def test_base_url_with_request_params() -> None: # Query params in the request URL must not be dropped when request-level # params are also passed. client = httpx2.Client(base_url="https://api.example.com/v1/") @@ -42,7 +42,7 @@ def test_base_url_with_request_params(): assert str(request.url) == "https://api.example.com/v1/users?active=true&page=2" -def test_base_url_with_client_params_and_url_query(): +def test_base_url_with_client_params_and_url_query() -> None: # Client-level params must be appended to query params already present in # the request URL, not replace them. client = httpx2.Client(base_url="https://api.example.com/v1/", params={"api_key": "abc"}) @@ -51,7 +51,7 @@ def test_base_url_with_client_params_and_url_query(): assert str(request.url) == "https://api.example.com/v1/users?active=true&api_key=abc" -def test_base_url_with_client_params_request_params_and_url_query(): +def test_base_url_with_client_params_request_params_and_url_query() -> None: # All three sources of query params (URL, client-level, request-level) # must be combined without any being dropped. client = httpx2.Client(base_url="https://api.example.com/v1/", params={"api_key": "abc"}) diff --git a/tests/httpx2/models/test_requests.py b/tests/httpx2/models/test_requests.py index f6d9e734..c3e5d4a7 100644 --- a/tests/httpx2/models/test_requests.py +++ b/tests/httpx2/models/test_requests.py @@ -240,7 +240,7 @@ def test_request_params() -> None: assert str(request.url) == "http://example.com?a=1" -def test_request_params_no_double_encoding(): +def test_request_params_no_double_encoding() -> None: # The existing query string must not be reparsed through QueryParams; # doing so risks double-encoding or reordering of existing parameters. request = httpx2.Request("GET", "http://example.com?q=hello%20world", params={"page": "2"})