From 5ba77da4d34e0af09e0d4f508aa5ab66ef083abd Mon Sep 17 00:00:00 2001 From: zhangjianfei Date: Fri, 1 Aug 2025 12:07:51 +0800 Subject: [PATCH 1/4] Fix URL params to merge with existing query parameters instead of replacing them When creating a URL with `URL(url, params=params)` or `Request(method, url, params=params)`, the params now merge with existing query parameters in the URL instead of completely replacing them. This makes the behavior consistent with the Python requests library. Before: URL("https://example.com?a=1", params={"b": "2"}) Result: "https://example.com?b=2" # 'a=1' was lost After: URL("https://example.com?a=1", params={"b": "2"}) Result: "https://example.com?a=1&b=2" # parameters merged Special cases handled: - Empty dict params={} preserves existing query parameters - None params preserves existing query parameters - QueryParams objects are used directly (for copy_* methods) - Overlapping parameter names are overridden by new values Fixes #652 --- httpx/_urls.py | 28 +++++++++++++++++++++++++++- tests/models/test_requests.py | 4 ++-- tests/models/test_url.py | 4 ++-- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/httpx/_urls.py b/httpx/_urls.py index 147a8fa333..192f853c02 100644 --- a/httpx/_urls.py +++ b/httpx/_urls.py @@ -110,8 +110,34 @@ def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None: # Ensure that empty params use `kwargs["query"] = None` rather # than `kwargs["query"] = ""`, so that generated URLs do not # include an empty trailing "?". + # + # Merge new params with existing query parameters instead of replacing them. params = kwargs.pop("params") - kwargs["query"] = None if not params else str(QueryParams(params)) + # Get existing query parameters from the URL + if isinstance(url, str): + parsed_url = urlparse(url) + existing_params = QueryParams(parsed_url.query) + elif isinstance(url, URL): + existing_params = url.params + else: + existing_params = QueryParams() + + if isinstance(params, QueryParams): + # If params is a QueryParams object, use it directly (for copy_* methods) + kwargs["query"] = None if not params else str(params) + elif params: + # Merge existing parameters with new params (dict, list, etc.) + merged_params = existing_params.merge(params) + kwargs["query"] = None if not merged_params else str(merged_params) + elif isinstance(params, dict) and not params: + # If params is an empty dict, keep existing query parameters + kwargs["query"] = None if not existing_params else str(existing_params) + elif params is None: + # If params is None, keep existing query parameters + kwargs["query"] = None if not existing_params else str(existing_params) + else: + # Fallback case + kwargs["query"] = None if not params else str(params) if isinstance(url, str): self._uri_reference = urlparse(url, **kwargs) diff --git a/tests/models/test_requests.py b/tests/models/test_requests.py index b31fe007be..936ec5137d 100644 --- a/tests/models/test_requests.py +++ b/tests/models/test_requests.py @@ -235,7 +235,7 @@ def test_request_params(): request = httpx.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 = httpx.Request("GET", "http://example.com?a=1", params={}) - assert str(request.url) == "http://example.com" + assert str(request.url) == "http://example.com?a=1" diff --git a/tests/models/test_url.py b/tests/models/test_url.py index 03072e8f5c..f5580285fd 100644 --- a/tests/models/test_url.py +++ b/tests/models/test_url.py @@ -159,8 +159,8 @@ def test_url_params(): url = httpx.URL( "https://example.org:123/path/to/somewhere?b=456", params={"a": "123"} ) - assert str(url) == "https://example.org:123/path/to/somewhere?a=123" - assert url.params == httpx.QueryParams({"a": "123"}) + assert str(url) == "https://example.org:123/path/to/somewhere?b=456&a=123" + assert url.params == httpx.QueryParams({"b": "456", "a": "123"}) # Tests for username and password From 36e1bd9c81d9819a0a50830181605d4aeeff1e17 Mon Sep 17 00:00:00 2001 From: zhangjianfei Date: Fri, 1 Aug 2025 12:29:08 +0800 Subject: [PATCH 2/4] Fix code formatting in _urls.py --- httpx/_urls.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/httpx/_urls.py b/httpx/_urls.py index 192f853c02..8f823a3617 100644 --- a/httpx/_urls.py +++ b/httpx/_urls.py @@ -131,10 +131,14 @@ def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None: kwargs["query"] = None if not merged_params else str(merged_params) elif isinstance(params, dict) and not params: # If params is an empty dict, keep existing query parameters - kwargs["query"] = None if not existing_params else str(existing_params) + kwargs["query"] = ( + None if not existing_params else str(existing_params) + ) elif params is None: # If params is None, keep existing query parameters - kwargs["query"] = None if not existing_params else str(existing_params) + kwargs["query"] = ( + None if not existing_params else str(existing_params) + ) else: # Fallback case kwargs["query"] = None if not params else str(params) @@ -405,7 +409,7 @@ def __repr__(self) -> str: if ":" in userinfo: # Mask any password component. - userinfo = f'{userinfo.split(":")[0]}:[secure]' + userinfo = f"{userinfo.split(':')[0]}:[secure]" authority = "".join( [ From 2d141a84d10327e99fe42a8f1fb472b167193f5d Mon Sep 17 00:00:00 2001 From: zhangjianfei Date: Fri, 1 Aug 2025 12:35:49 +0800 Subject: [PATCH 3/4] Fix line length in URL parameter comments Split long comments to comply with ruff E501 line length checks. --- httpx/_urls.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/httpx/_urls.py b/httpx/_urls.py index 8f823a3617..9e088af450 100644 --- a/httpx/_urls.py +++ b/httpx/_urls.py @@ -111,7 +111,8 @@ def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None: # than `kwargs["query"] = ""`, so that generated URLs do not # include an empty trailing "?". # - # Merge new params with existing query parameters instead of replacing them. + # Merge new params with existing query parameters instead of + # replacing them. params = kwargs.pop("params") # Get existing query parameters from the URL if isinstance(url, str): @@ -123,7 +124,8 @@ def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None: existing_params = QueryParams() if isinstance(params, QueryParams): - # If params is a QueryParams object, use it directly (for copy_* methods) + # If params is a QueryParams object, use it directly + # (for copy_* methods) kwargs["query"] = None if not params else str(params) elif params: # Merge existing parameters with new params (dict, list, etc.) From 5ae6ee05c244d9b7ccf0e3288fa678a6a0ec6a95 Mon Sep 17 00:00:00 2001 From: zhangjianfei Date: Fri, 1 Aug 2025 12:45:07 +0800 Subject: [PATCH 4/4] Add missing test cases for URL constructor edge cases - Add test for invalid URL type with params parameter (covers line 124) - Add test for explicit params=None handling (covers line 141) - Achieves 100% test coverage for httpx/_urls.py --- tests/models/test_url.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/models/test_url.py b/tests/models/test_url.py index f5580285fd..4ee2e833fa 100644 --- a/tests/models/test_url.py +++ b/tests/models/test_url.py @@ -467,6 +467,21 @@ class ExternalURLClass: # representing external URL class httpx.URL(ExternalURLClass()) # type: ignore +def test_url_invalid_type_with_params(): + with pytest.raises(TypeError): + httpx.URL(123, params={"a": "b"}) # type: ignore + + +def test_url_with_params_none(): + # Test with existing query parameters + url = httpx.URL("https://example.com?existing=param", params=None) + assert "existing=param" in str(url) + + # Test without existing query parameters + url = httpx.URL("https://example.com", params=None) + assert str(url) == "https://example.com" + + def test_url_with_invalid_component(): with pytest.raises(TypeError) as exc: httpx.URL(scheme="https", host="www.example.com", incorrect="/")