diff --git a/docs/advanced.md b/docs/advanced.md index 76321dd1b2..9d416e6f01 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -464,6 +464,16 @@ MIME header field. It is safe to upload large files this way. File uploads are streaming by default, meaning that only one chunk will be loaded into memory at a time. Non-file data fields can be included in the multipart form using by passing them to `data=...`. + +You can also send multiple files in one go with a multiple file field form. +To do that, pass a list of `(field, )` items instead of a dictionary, allowing you to pass multiple items with the same `field`. +For instance this request sends 2 files, `foo.png` and `bar.png` in one request on the `images` form field: + +```python +>>> files = [('images', ('foo.png', open('foo.png', 'rb'), 'image/png')), + ('images', ('bar.png', open('bar.png', 'rb'), 'image/png'))] +>>> r = httpx.post("https://httpbin.org/post", files=files) +``` ## Customizing authentication diff --git a/httpx/_content_streams.py b/httpx/_content_streams.py index 6436be563a..402fa959c8 100644 --- a/httpx/_content_streams.py +++ b/httpx/_content_streams.py @@ -328,7 +328,8 @@ def _iter_fields( else: yield self.DataField(name=name, value=value) - for name, value in files.items(): + file_items = files.items() if isinstance(files, typing.Mapping) else files + for name, value in file_items: yield self.FileField(name=name, value=value) def iter_chunks(self) -> typing.Iterator[bytes]: diff --git a/httpx/_types.py b/httpx/_types.py index a8925bbe76..a74020a4ae 100644 --- a/httpx/_types.py +++ b/httpx/_types.py @@ -72,4 +72,4 @@ # (filename, file (or text), content_type) Tuple[Optional[str], FileContent, Optional[str]], ] -RequestFiles = Mapping[str, FileTypes] +RequestFiles = Union[Mapping[str, FileTypes], List[Tuple[str, FileTypes]]] diff --git a/tests/test_content_streams.py b/tests/test_content_streams.py index c5eb961ddc..2b2adc92ae 100644 --- a/tests/test_content_streams.py +++ b/tests/test_content_streams.py @@ -204,3 +204,50 @@ async def test_empty_request(): def test_invalid_argument(): with pytest.raises(TypeError): encode(123) + + +@pytest.mark.asyncio +async def test_multipart_multiple_files_single_input_content(): + files = [ + ("file", io.BytesIO(b"")), + ("file", io.BytesIO(b"")), + ] + stream = encode(files=files, boundary=b"+++") + sync_content = b"".join([part for part in stream]) + async_content = b"".join([part async for part in stream]) + + assert stream.can_replay() + assert stream.get_headers() == { + "Content-Length": "271", + "Content-Type": "multipart/form-data; boundary=+++", + } + assert sync_content == b"".join( + [ + 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"--+++\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"--+++--\r\n", + ] + ) + assert async_content == b"".join( + [ + 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"--+++\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"--+++--\r\n", + ] + )