From 5f8e67591398a3df2251ec65846c7fa205a6ad45 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 13 Aug 2025 00:31:35 +0330 Subject: [PATCH 1/4] Fix: Raise helpful TypeError for invalid list 'data' --- httpx/_client.py | 10 ++++++++ tests/client/test_async_client.py | 40 +++++++++++++++++++++++++++++++ tests/client/test_client.py | 39 ++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+) diff --git a/httpx/_client.py b/httpx/_client.py index 13cd933673..2de7984506 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -363,6 +363,16 @@ def build_request( [0]: /advanced/clients/#request-instances """ + # Validate data parameter for better error messages + if data is not None and isinstance(data, list): + # Check if this looks like invalid JSON array (list of dicts/strings) + # but allow valid multipart form data (list of 2-item tuples) + if data and all(isinstance(item, (dict, str, int, float, bool)) for item in data): + raise TypeError( + "Invalid value for 'data'. To send a JSON array, use the 'json' parameter. " + "For form data, use a dictionary or a list of 2-item tuples." + ) + url = self._merge_url(url) headers = self._merge_headers(headers) cookies = self._merge_cookies(cookies) diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index 8d7eaa3c58..91b7065c15 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -373,3 +373,43 @@ async def test_server_extensions(server): response = await client.get(url) assert response.status_code == 200 assert response.extensions["http_version"] == b"HTTP/1.1" + + +INVALID_DATA_FORMATS_ASYNC = [ + pytest.param([{"a": "b"}], id="list-of-dicts"), + pytest.param(["a", "b", "c"], id="list-of-strings"), + pytest.param([1, 2, 3], id="list-of-integers"), +] + + +@pytest.mark.anyio +@pytest.mark.parametrize("invalid_data", INVALID_DATA_FORMATS_ASYNC) +async def test_async_build_request_with_invalid_data_list(invalid_data): + """ + Verify that AsyncClient.build_request raises a helpful TypeError for invalid list formats. + """ + async with httpx.AsyncClient() as client: + expected_message = ( + "Invalid value for 'data'. To send a JSON array, use the 'json' parameter. " + "For form data, use a dictionary or a list of 2-item tuples." + ) + with pytest.raises(TypeError, match=expected_message): + client.build_request("POST", "https://example.com", data=invalid_data) + + +@pytest.mark.anyio +async def test_async_build_request_with_valid_data_formats(): + """ + Verify that AsyncClient.build_request accepts valid data formats without raising our custom TypeError. + """ + async with httpx.AsyncClient() as client: + # Test with a dictionary + request = client.build_request("POST", "https://example.com", data={"a": "b"}) + assert isinstance(request, httpx.Request) + + # Test with a list of 2-item tuples (for multipart) + # This is a valid use case and should not raise our TypeError. + # We explicitly catch and ignore the DeprecationWarning that httpx raises in this specific case. + with pytest.warns(DeprecationWarning): + request = client.build_request("POST", "https://example.com", data=[("a", "b")]) + assert isinstance(request, httpx.Request) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 657839018a..fa96f7cb41 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -460,3 +460,42 @@ def cp1252_but_no_content_type(request): assert response.reason_phrase == "OK" assert response.encoding == "ISO-8859-1" assert response.text == text + + +INVALID_DATA_FORMATS_SYNC = [ + pytest.param([{"a": "b"}], id="list-of-dicts"), + pytest.param(["a", "b", "c"], id="list-of-strings"), + pytest.param([1, 2, 3], id="list-of-integers"), +] + + +@pytest.mark.parametrize("invalid_data", INVALID_DATA_FORMATS_SYNC) +def test_sync_build_request_with_invalid_data_list(invalid_data): + """ + Verify that Client.build_request raises a helpful TypeError for invalid list formats. + """ + client = httpx.Client() + expected_message = ( + "Invalid value for 'data'. To send a JSON array, use the 'json' parameter. " + "For form data, use a dictionary or a list of 2-item tuples." + ) + with pytest.raises(TypeError, match=expected_message): + client.build_request("POST", "https://example.com", data=invalid_data) + + +def test_sync_build_request_with_valid_data_formats(): + """ + Verify that Client.build_request accepts valid data formats without raising our custom TypeError. + """ + client = httpx.Client() + + # Test with a dictionary + request = client.build_request("POST", "https://example.com", data={"a": "b"}) + assert isinstance(request, httpx.Request) + + # Test with a list of 2-item tuples (for multipart) + # This is a valid use case and should not raise our TypeError. + # We explicitly catch and ignore the DeprecationWarning that httpx raises in this specific case. + with pytest.warns(DeprecationWarning): + request = client.build_request("POST", "https://example.com", data=[("a", "b")]) + assert isinstance(request, httpx.Request) From da3da4b7095f2d52f5588606ae4aa6466fe4a9b8 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 13 Aug 2025 00:45:57 +0330 Subject: [PATCH 2/4] fix: resolve ruff formatting issues --- httpx/_client.py | 4 +++- tests/client/test_async_client.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/httpx/_client.py b/httpx/_client.py index 2de7984506..04efd250d5 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -367,7 +367,9 @@ def build_request( if data is not None and isinstance(data, list): # Check if this looks like invalid JSON array (list of dicts/strings) # but allow valid multipart form data (list of 2-item tuples) - if data and all(isinstance(item, (dict, str, int, float, bool)) for item in data): + if data and all( + isinstance(item, (dict, str, int, float, bool)) for item in data + ): raise TypeError( "Invalid value for 'data'. To send a JSON array, use the 'json' parameter. " "For form data, use a dictionary or a list of 2-item tuples." diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index 91b7065c15..d693a83a8e 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -411,5 +411,7 @@ async def test_async_build_request_with_valid_data_formats(): # This is a valid use case and should not raise our TypeError. # We explicitly catch and ignore the DeprecationWarning that httpx raises in this specific case. with pytest.warns(DeprecationWarning): - request = client.build_request("POST", "https://example.com", data=[("a", "b")]) + request = client.build_request( + "POST", "https://example.com", data=[("a", "b")] + ) assert isinstance(request, httpx.Request) From 2eef94deeb2a576ec4c85973840516b6b45720aa Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 13 Aug 2025 01:19:52 +0330 Subject: [PATCH 3/4] fix: resolve ruff formatting issues --- httpx/_client.py | 8 +++-- httpx/_content.py | 52 ++++++++++++++++++++++--------- httpx/_multipart.py | 14 ++++++--- httpx/_types.py | 2 +- tests/client/test_async_client.py | 12 +++---- tests/client/test_client.py | 10 +++--- 6 files changed, 63 insertions(+), 35 deletions(-) diff --git a/httpx/_client.py b/httpx/_client.py index 04efd250d5..614e2afe95 100644 --- a/httpx/_client.py +++ b/httpx/_client.py @@ -370,10 +370,12 @@ def build_request( if data and all( isinstance(item, (dict, str, int, float, bool)) for item in data ): - raise TypeError( - "Invalid value for 'data'. To send a JSON array, use the 'json' parameter. " - "For form data, use a dictionary or a list of 2-item tuples." + message = ( + "Invalid value for 'data'. To send a JSON array, use the 'json' " + "parameter. For form data, use a dictionary or a list of 2-item " + "tuples." ) + raise TypeError(message) url = self._merge_url(url) headers = self._merge_headers(headers) diff --git a/httpx/_content.py b/httpx/_content.py index 6f479a0885..b4625445e9 100644 --- a/httpx/_content.py +++ b/httpx/_content.py @@ -137,11 +137,21 @@ def encode_urlencoded_data( data: RequestData, ) -> tuple[dict[str, str], ByteStream]: plain_data = [] - for key, value in data.items(): - if isinstance(value, (list, tuple)): - plain_data.extend([(key, primitive_value_to_str(item)) for item in value]) - else: + + if isinstance(data, list): + # Handle list of tuples case + for key, value in data: plain_data.append((key, primitive_value_to_str(value))) + else: + # Handle dictionary case + for key, value in data.items(): + if isinstance(value, (list, tuple)): + plain_data.extend( + [(key, primitive_value_to_str(item)) for item in value] + ) + else: + plain_data.append((key, primitive_value_to_str(value))) + body = urlencode(plain_data, doseq=True).encode("utf-8") content_length = str(len(body)) content_type = "application/x-www-form-urlencoded" @@ -195,16 +205,30 @@ def encode_request( returning a two-tuple of (, ). """ if data is not None and not isinstance(data, Mapping): - # We prefer to separate `content=` - # for raw request content, and `data=
` for url encoded or - # multipart form content. - # - # However for compat with requests, we *do* still support - # `data=` usages. We deal with that case here, treating it - # as if `content=<...>` had been supplied instead. - message = "Use 'content=<...>' to upload raw bytes/text content." - warnings.warn(message, DeprecationWarning, stacklevel=2) - return encode_content(data) + # Check if this is a list of tuples (valid form data) + if ( + isinstance(data, list) + and data + and all(isinstance(item, tuple) and len(item) == 2 for item in data) + ): + # This is valid form data as a list of tuples + pass + else: + # We prefer to separate `content=` + # for raw request content, and `data=` for url encoded or + # multipart form content. + # + # However for compat with requests, we *do* still support + # `data=` usages. We deal with that case here, treating it + # as if `content=<...>` had been supplied instead. + message = "Use 'content=<...>' to upload raw bytes/text content." + warnings.warn(message, DeprecationWarning, stacklevel=2) + # At this point, data is not a list of tuples, so it's safe to pass to + # encode_content + from typing import cast + + content_data = cast("RequestContent", data) + return encode_content(content_data) if content is not None: return encode_content(content) diff --git a/httpx/_multipart.py b/httpx/_multipart.py index b4761af9b2..1192d65f84 100644 --- a/httpx/_multipart.py +++ b/httpx/_multipart.py @@ -244,12 +244,16 @@ def __init__( def _iter_fields( self, data: RequestData, files: RequestFiles ) -> typing.Iterator[FileField | DataField]: - for name, value in data.items(): - if isinstance(value, (tuple, list)): - for item in value: - yield DataField(name=name, value=item) - else: + if isinstance(data, list): + for name, value in data: yield DataField(name=name, value=value) + else: + for name, value in data.items(): + if isinstance(value, (tuple, list)): + for item in value: + yield DataField(name=name, value=item) + else: + yield DataField(name=name, value=value) file_items = files.items() if isinstance(files, typing.Mapping) else files for name, value in file_items: diff --git a/httpx/_types.py b/httpx/_types.py index 704dfdffc8..32854bf040 100644 --- a/httpx/_types.py +++ b/httpx/_types.py @@ -69,7 +69,7 @@ ResponseContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]] ResponseExtensions = Mapping[str, Any] -RequestData = Mapping[str, Any] +RequestData = Union[Mapping[str, Any], List[Tuple[str, Any]]] FileContent = Union[IO[bytes], bytes, str] FileTypes = Union[ diff --git a/tests/client/test_async_client.py b/tests/client/test_async_client.py index d693a83a8e..fb8eeb92b8 100644 --- a/tests/client/test_async_client.py +++ b/tests/client/test_async_client.py @@ -386,7 +386,8 @@ async def test_server_extensions(server): @pytest.mark.parametrize("invalid_data", INVALID_DATA_FORMATS_ASYNC) async def test_async_build_request_with_invalid_data_list(invalid_data): """ - Verify that AsyncClient.build_request raises a helpful TypeError for invalid list formats. + Verify that AsyncClient.build_request raises a helpful TypeError for invalid list + formats. """ async with httpx.AsyncClient() as client: expected_message = ( @@ -400,7 +401,8 @@ async def test_async_build_request_with_invalid_data_list(invalid_data): @pytest.mark.anyio async def test_async_build_request_with_valid_data_formats(): """ - Verify that AsyncClient.build_request accepts valid data formats without raising our custom TypeError. + Verify that AsyncClient.build_request accepts valid data formats without raising + our custom TypeError. """ async with httpx.AsyncClient() as client: # Test with a dictionary @@ -409,9 +411,5 @@ async def test_async_build_request_with_valid_data_formats(): # Test with a list of 2-item tuples (for multipart) # This is a valid use case and should not raise our TypeError. - # We explicitly catch and ignore the DeprecationWarning that httpx raises in this specific case. - with pytest.warns(DeprecationWarning): - request = client.build_request( - "POST", "https://example.com", data=[("a", "b")] - ) + request = client.build_request("POST", "https://example.com", data=[("a", "b")]) assert isinstance(request, httpx.Request) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index fa96f7cb41..f11d61a7d9 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -472,7 +472,8 @@ def cp1252_but_no_content_type(request): @pytest.mark.parametrize("invalid_data", INVALID_DATA_FORMATS_SYNC) def test_sync_build_request_with_invalid_data_list(invalid_data): """ - Verify that Client.build_request raises a helpful TypeError for invalid list formats. + Verify that Client.build_request raises a helpful TypeError for invalid list + formats. """ client = httpx.Client() expected_message = ( @@ -485,7 +486,8 @@ def test_sync_build_request_with_invalid_data_list(invalid_data): def test_sync_build_request_with_valid_data_formats(): """ - Verify that Client.build_request accepts valid data formats without raising our custom TypeError. + Verify that Client.build_request accepts valid data formats without raising our + custom TypeError. """ client = httpx.Client() @@ -495,7 +497,5 @@ def test_sync_build_request_with_valid_data_formats(): # Test with a list of 2-item tuples (for multipart) # This is a valid use case and should not raise our TypeError. - # We explicitly catch and ignore the DeprecationWarning that httpx raises in this specific case. - with pytest.warns(DeprecationWarning): - request = client.build_request("POST", "https://example.com", data=[("a", "b")]) + request = client.build_request("POST", "https://example.com", data=[("a", "b")]) assert isinstance(request, httpx.Request) From 4a7f04f7961fd7a32411e359f0f02915fb329f1f Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 13 Aug 2025 01:42:04 +0330 Subject: [PATCH 4/4] fix: resolve ruff formatting issues --- httpx/_content.py | 8 +++---- tests/test_multipart.py | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/httpx/_content.py b/httpx/_content.py index b4625445e9..a7f3595484 100644 --- a/httpx/_content.py +++ b/httpx/_content.py @@ -206,12 +206,10 @@ def encode_request( """ if data is not None and not isinstance(data, Mapping): # Check if this is a list of tuples (valid form data) - if ( - isinstance(data, list) - and data - and all(isinstance(item, tuple) and len(item) == 2 for item in data) + if isinstance(data, list) and ( + not data or all(isinstance(item, tuple) and len(item) == 2 for item in data) ): - # This is valid form data as a list of tuples + # This is valid form data as a list of tuples (including empty list) pass else: # We prefer to separate `content=` diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 764f85a253..6ffe48e0ee 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -467,3 +467,52 @@ def test_unicode_with_control_character(self): files = {"upload": (filename, b"")} request = httpx.Request("GET", "https://www.example.com", files=files) assert expected in request.read() + + +def test_multipart_with_empty_list_data(): + client = httpx.Client(transport=httpx.MockTransport(echo_request_content)) + data: list[tuple[str, str]] = [] + files = {"file": io.BytesIO(b"")} + response = client.post("http://127.0.0.1:8000/", data=data, files=files) + boundary = response.request.headers["Content-Type"].split("boundary=")[-1] + boundary_bytes = boundary.encode("ascii") + + assert response.status_code == 200 + assert response.content == b"".join( + [ + b"--" + boundary_bytes + b"\r\n", + b'Content-Disposition: form-data; name="file"; filename="upload"\r\n', + b"Content-Type: application/octet-stream\r\n", + b"\r\n", + b"\r\n", + b"--" + boundary_bytes + b"--\r\n", + ] + ) + + +def test_multipart_with_invalid_list_data(): + client = httpx.Client(transport=httpx.MockTransport(echo_request_content)) + data: list[str] = ["not-a-tuple"] # Invalid: not a 2-tuple + files = {"file": io.BytesIO(b"")} + with pytest.raises(TypeError): + client.post("http://127.0.0.1:8000/", data=data, files=files) # type: ignore[arg-type] + + +def test_multipart_with_tuple_list_data(): + client = httpx.Client(transport=httpx.MockTransport(echo_request_content)) + data: list[tuple[str, str]] = [("foo", "bar")] + files: dict[str, io.BytesIO] = {} + response = client.post("http://127.0.0.1:8000/", data=data, files=files) + assert response.status_code == 200 + assert b"foo" in response.content + assert b"bar" in response.content + + +def test_multipart_iter_fields_with_tuple_list(): + client = httpx.Client(transport=httpx.MockTransport(echo_request_content)) + data: list[tuple[str, str]] = [("key", "value")] + files: dict[str, io.BytesIO] = {"dummy": io.BytesIO(b"")} + response = client.post("http://127.0.0.1:8000/", data=data, files=files) + assert response.status_code == 200 + assert b"key" in response.content + assert b"value" in response.content