Repository: encode/httpx

Issue:
Support for multiple files per POST field
```
async def http_upload():
    async with httpx.AsyncClient(verify=False) as client:
        data_path = os.path.join(PARENT_PATH, 'data_file/blob/data')
        index_path = os.path.join(PARENT_PATH, 'data_file/blob/index')
        files = {
            "upload_file": [
                open(data_path, 'rb'),
                open(index_path, 'rb'),
            ]
        }
        # files = [
        #     ("upload_file", open(data_path, 'rb')),
        #     ("upload_file", open(index_path, 'rb'))
        # ]
        params = {
            "requestId": "10001",
            "sdkVersion": "V1.2.3",
            "area": "CH"
        }

        response = await session.post(url, data=params, files=files)
```

```console
self = <httpx.content_streams.MultipartStream.FileField object at 0x7f503c22ea60>

    def render_data(self) -> bytes:
        if isinstance(self.file, str):
            content = self.file
        else:
>           content = self.file.read()
E           AttributeError: 'list' object has no attribute 'read'

or
    def iter_fields(
        self, data: dict, files: dict
    ) -> typing.Iterator[typing.Union["FileField", "DataField"]]:
        for name, value in data.items():
            if isinstance(value, (list, dict)):
                for item in value:
                    yield self.DataField(name=name, value=item)
            else:
                yield self.DataField(name=name, value=value)

>       for name, value in files.items():
E       AttributeError: 'list' object has no attribute 'items'
```

Excuse me, how should I use。


Discussion:
You should read the manual https://www.python-httpx.org/advanced/#multipart-file-encoding
Hi, not sure was the problem was exactly, but going to close this as resolved for now. If there's anything unclear/missing in the docs relative to your problem, feel free to ping back here! Thanks.
It seems to me the OP's problem was the same I'm facing right now in that I'm trying to replicate what is described in the `requests` docs here: https://2.python-requests.org/en/master/user/advanced/#post-multiple-multipart-encoded-files
The `httpx` docs @j178 pointed to only seem to describe posting a single file (which works exactly as for `requests`). However, once multiple files need to be uploaded, `requests` stops taking a `dict` and uses a `list` instead, as a `dict` can't use the same key multiple times.
The question now would be how, if at all, this behavior of the `requests` library could be replicated in `httpx`.
> You should read the manual https://www.python-httpx.org/advanced/#multipart-file-encoding

Upload multiple files at once, not one.Can't HTTPX solve it?
> Hi, not sure was the problem was exactly, but going to close this as resolved for now. If there's anything unclear/missing in the docs relative to your problem, feel free to ping back here! Thanks.

Upload multiple files at once, not one.Can't HTTPX solve it?
The problem is not solved, why is it closed?
Just like requests: https://2.python-requests.org/en/master/user/advanced/#post-multiple-multipart-encoded-files
@ysq1222 Reopened, I think it’s clearer now what this is about: uploading multiple files for a given POST field using HTTPX (we only support 1 file per file right now).

That last link is particularly helpful, especially because it shows what the API should look like for such a feature, i.e. a list of file dicts (which is a reasonable API I guess, even though the typing for `files` is already quite involved).

Since it’s supported by Requests I don’t see why we shouldn’t support it. :)

At this point, I imagine any PRs towards resolving this are much welcome? 😄
Edited the issue description to better reflect the need here. 👍
So, I think we also want to support the "either a dict, or a two-tuple of items" for the `data=...` case too.

Ie, this works in requests, but not in httpx...

```python
import requests

url = 'https://httpbin.org/post'
r = requests.post(url, data=[("a", "1"), ("a", "2")])
print(r.text)
```

In both cases we want to change how we iterate over the items in `data`/`files`...

https://github.com/encode/httpx/blob/b3db9ff0b6554ed3a48ff912682f4a7d10c3f3f8/httpx/_content_streams.py#L293

Eg... 

```python
data_items = data.items() if isinstance(data, dict) else data
files_items = files.items() if isinstance(files, dict) else files
for name, value in data_items:
    ...
for name, value in files_items:
    ...
```

> Reopened

Thanks! Great to see quick progress here now :D

> That last link is particularly helpful

You're welcome! Oh. Wait. 😉
> @ysq1222 Reopened, I think it’s clearer now what this is about: uploading multiple files for a given POST field using HTTPX (we only support 1 file per file right now).
> 
> That last link is particularly helpful, especially because it shows what the API should look like for such a feature, i.e. a list of file dicts (which is a reasonable API I guess, even though the typing for `files` is already quite involved).
> 
> Since it’s supported by Requests I don’t see why we shouldn’t support it. :)
> 
> At this point, I imagine any PRs towards resolving this are much welcome?

Will it be supported in the next version?
Can someone point me where to look in the codebase if tackling this issue?
Hi @tegkhanna 👋 you can look at `MultipartStream` in `._content_streams.py`. There should be various hints upper in this thread too.
Thanks @florimondmanca! I will get to it straight away! 
Is there any other site similar to httpbin.org?
I made some changes and they didn't appear to be working. Tested everything and then realized that httpbin.org is not sending the right response. When adding multiple files to the same input, it just sends back the first filename. Checked the same with ```requests``` as given in the last link by @ysq1222 and got the same result. Just the name of the first file.
Nevermind. Checked using https://ptsv2.com/ and it worked like a charm!

Should I add support for this as well as mentioned by @tomchristie? 
> So, I think we also want to support the "either a dict, or a two-tuple of items" for the `data=...` case too.
> 
> Ie, this works in requests, but not in httpx...
> 
> ```python
> import requests
> 
> url = 'https://httpbin.org/post'
> r = requests.post(url, data=[("a", "1"), ("a", "2")])
> print(r.text)
> ```

Added a pull request:
https://github.com/encode/httpx/pull/891

Resolving patch:
diff --git a/httpx/_content_streams.py b/httpx/_content_streams.py
--- 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
--- 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]]]


Test patch:
diff --git a/tests/test_content_streams.py b/tests/test_content_streams.py
--- 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 content 1>")),
+        ("file", io.BytesIO(b"<file content 2>")),
+    ]
+    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"<file content 1>\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"<file content 2>\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"<file content 1>\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"<file content 2>\r\n",
+            b"--+++--\r\n",
+        ]
+    )