diff --git a/httpx/_urls.py b/httpx/_urls.py index 301d0874d5..1539cbff47 100644 --- a/httpx/_urls.py +++ b/httpx/_urls.py @@ -105,13 +105,43 @@ def __init__(self, url: URL | str = "", **kwargs: typing.Any) -> None: kwargs[key] = value.decode("ascii") if "params" in kwargs: - # Replace any "params" keyword with the raw "query" instead. + # Merge any "params" keyword with existing query params. + # + # When a URL already has query parameters and additional params + # are provided, we merge them together rather than replacing. + # This matches the behavior of the requests library. # # Ensure that empty params use `kwargs["query"] = None` rather # than `kwargs["query"] = ""`, so that generated URLs do not # include an empty trailing "?". params = kwargs.pop("params") - kwargs["query"] = None if not params else str(QueryParams(params)) + + # If params is a QueryParams object, use it directly + # (for copy_* methods like copy_remove_param) + if isinstance(params, QueryParams): + kwargs["query"] = str(params) if params else None + else: + # Get existing query params from the URL + if isinstance(url, str): + parsed_url = urlparse(url) + existing_params = ( + QueryParams(parsed_url.query) + if parsed_url.query + else QueryParams() + ) + else: # isinstance(url, URL) + existing_params = url.params + + # Merge existing and new params + if params: + merged_params = existing_params.merge(params) + kwargs["query"] = str(merged_params) if merged_params else None + elif existing_params: + # params is empty but URL has existing params - keep them + kwargs["query"] = str(existing_params) + else: + # Both empty - no query string + kwargs["query"] = None 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..3899187399 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 @@ -861,3 +861,65 @@ def test_ipv6_url_copy_with_host(url_str, new_host): assert url.host == "::ffff:192.168.0.1" assert url.netloc == b"[::ffff:192.168.0.1]:1234" assert str(url) == "http://[::ffff:192.168.0.1]:1234" + + +def test_url_params_merge_with_existing_query(): + """ + Test that when constructing a URL with both existing query params + and a params argument, they are merged together. + + Regression test for issue #3621. + """ + # URL with existing query params + additional params argument + url = httpx.URL( + "https://example.com/get?page=post&s=list", params={"pid": 0, "tags": "test"} + ) + + assert url.path == "/get" + assert "page=post" in str(url) + assert "s=list" in str(url) + assert "pid=0" in str(url) + assert "tags=test" in str(url) + + # Verify all params are present + params = dict(url.params) + assert params == {"page": "post", "s": "list", "pid": "0", "tags": "test"} + + +def test_url_params_override_with_same_key(): + """ + Test that when a URL has existing query params and new params with + the same key are provided, the new params override the old ones. + """ + url = httpx.URL( + "https://example.com/get?a=old&b=keep", params={"a": "new", "c": "add"} + ) + + params = dict(url.params) + assert params["a"] == "new" # Overridden + assert params["b"] == "keep" # Preserved + assert params["c"] == "add" # Added + + +def test_url_params_empty_dict_preserves_existing(): + """ + Test that passing an empty params dict doesn't remove existing URL params. + """ + url = httpx.URL("https://example.com/get?x=5&y=6", params={}) + + assert "x=5" in str(url) + assert "y=6" in str(url) + params = dict(url.params) + assert params == {"x": "5", "y": "6"} + + +def test_url_no_existing_params_with_params_arg(): + """ + Test that a URL without existing query params works normally with params argument. + """ + url = httpx.URL("https://example.com/get", params={"a": "1", "b": "2"}) + + assert "a=1" in str(url) + assert "b=2" in str(url) + params = dict(url.params) + assert params == {"a": "1", "b": "2"}