Repository: encode/starlette

Issue:
Support for Multiple Files for One Form Field
I am trying to submit a list of data files under the same parameter name but only the first file is parsed from `request.form()`.

```bash
curl -X POST "http://0.0.0.0:3031/myapi" -H "accept: multipart/form-data" -H "Authorization: Bearer atoken"  -F "data=@/path/tp/favicon-16x16.png" -F"data=@/path/to/file.txt"
```
According to IETF RFC 7578 this should be supported.  Is this something you would consider supporting?
https://tools.ietf.org/html/rfc7578#section-4.3
```
4.3.  Multiple Files for One Form Field

   The form data for a form field might include multiple files.

   [RFC2388] suggested that multiple files for a single form field be
   transmitted using a nested "multipart/mixed" part.  This usage is
   deprecated.

   To match widely deployed implementations, multiple files MUST be sent
   by supplying each file in a separate part but all with the same
   "name" parameter.

   Receiving applications intended for wide applicability (e.g.,
   multipart/form-data parsing libraries) SHOULD also support the older
   method of supplying multiple files.
```

Comments:

Patch:
diff --git a/starlette/datastructures.py b/starlette/datastructures.py
index 1ce3aab..67e3d05 100644
--- a/starlette/datastructures.py
+++ b/starlette/datastructures.py
@@ -1,9 +1,11 @@
+import tempfile
 import typing
 from collections import namedtuple
 from collections.abc import Sequence
 from shlex import shlex
 from urllib.parse import ParseResult, parse_qsl, urlencode, urlparse
 
+from starlette.concurrency import run_in_threadpool
 from starlette.types import Scope
 
 Address = namedtuple("Address", ["host", "port"])
@@ -302,6 +304,94 @@ class QueryParams(typing.Mapping[str, str]):
         return f"{self.__class__.__name__}(query_string={repr(str(self))})"
 
 
+class UploadFile:
+    def __init__(self, filename: str, file: typing.IO = None) -> None:
+        self.filename = filename
+        if file is None:
+            file = tempfile.SpooledTemporaryFile()
+        self.file = file
+
+    async def write(self, data: typing.Union[bytes, str]) -> None:
+        await run_in_threadpool(self.file.write, data)
+
+    async def read(self, size: int = None) -> typing.Union[bytes, str]:
+        return await run_in_threadpool(self.file.read, size)
+
+    async def seek(self, offset: int) -> None:
+        await run_in_threadpool(self.file.seek, offset)
+
+    async def close(self) -> None:
+        await run_in_threadpool(self.file.close)
+
+
+FormValue = typing.Union[str, UploadFile]
+
+
+class FormData(typing.Mapping[str, FormValue]):
+    """
+    An immutable multidict, containing both file uploads and text input.
+    """
+
+    def __init__(
+        self,
+        form: typing.Union["FormData", typing.Mapping[str, FormValue]] = None,
+        items: typing.List[typing.Tuple[str, FormValue]] = None,
+    ) -> None:
+        _items = []  # type: typing.List[typing.Tuple[str, FormValue]]
+        if form is not None:
+            assert items is None, "Cannot set both 'form' and 'items'"
+            if isinstance(form, FormData):
+                _items = list(form.multi_items())
+            else:
+                _items = list(form.items())
+        elif items is not None:
+            _items = list(items)
+
+        self._dict = {k: v for k, v in _items}
+        self._list = _items
+
+    def getlist(self, key: typing.Any) -> typing.List[FormValue]:
+        return [item_value for item_key, item_value in self._list if item_key == key]
+
+    def keys(self) -> typing.List[str]:  # type: ignore
+        return list(self._dict.keys())
+
+    def values(self) -> typing.List[FormValue]:  # type: ignore
+        return list(self._dict.values())
+
+    def items(self) -> typing.List[typing.Tuple[str, FormValue]]:  # type: ignore
+        return list(self._dict.items())
+
+    def multi_items(self) -> typing.List[typing.Tuple[str, FormValue]]:
+        return list(self._list)
+
+    def get(self, key: typing.Any, default: typing.Any = None) -> typing.Any:
+        if key in self._dict:
+            return self._dict[key]
+        return default
+
+    def __getitem__(self, key: typing.Any) -> FormValue:
+        return self._dict[key]
+
+    def __contains__(self, key: typing.Any) -> bool:
+        return key in self._dict
+
+    def __iter__(self) -> typing.Iterator[typing.Any]:
+        return iter(self.keys())
+
+    def __len__(self) -> int:
+        return len(self._dict)
+
+    def __eq__(self, other: typing.Any) -> bool:
+        if not isinstance(other, FormData):
+            return False
+        return sorted(self._list) == sorted(other._list)
+
+    def __repr__(self) -> str:
+        items = self.multi_items()
+        return f"{self.__class__.__name__}(items={repr(items)})"
+
+
 class Headers(typing.Mapping[str, str]):
     """
     An immutable, case-insensitive multidict.
diff --git a/starlette/formparsers.py b/starlette/formparsers.py
index d148df8..223119f 100644
--- a/starlette/formparsers.py
+++ b/starlette/formparsers.py
@@ -1,12 +1,8 @@
-import asyncio
-import io
-import tempfile
 import typing
 from enum import Enum
 from urllib.parse import unquote_plus
 
-from starlette.concurrency import run_in_threadpool
-from starlette.datastructures import Headers
+from starlette.datastructures import FormData, FormValue, Headers, UploadFile
 
 try:
     from multipart.multipart import parse_options_header
@@ -35,31 +31,6 @@ class MultiPartMessage(Enum):
     END = 8
 
 
-class UploadFile:
-    def __init__(self, filename: str) -> None:
-        self.filename = filename
-        self._file = io.BytesIO()  # type: typing.IO[typing.Any]
-        self._loop = asyncio.get_event_loop()
-
-    def create_tempfile(self) -> None:
-        self._file = tempfile.SpooledTemporaryFile()
-
-    async def setup(self) -> None:
-        await run_in_threadpool(self.create_tempfile)
-
-    async def write(self, data: bytes) -> None:
-        await run_in_threadpool(self._file.write, data)
-
-    async def read(self, size: int = None) -> bytes:
-        return await run_in_threadpool(self._file.read, size)
-
-    async def seek(self, offset: int) -> None:
-        await run_in_threadpool(self._file.seek, offset)
-
-    async def close(self) -> None:
-        await run_in_threadpool(self._file.close)
-
-
 class FormParser:
     def __init__(
         self, headers: Headers, stream: typing.AsyncGenerator[bytes, None]
@@ -91,7 +62,7 @@ class FormParser:
         message = (FormMessage.END, b"")
         self.messages.append(message)
 
-    async def parse(self) -> typing.Dict[str, typing.Union[str, UploadFile]]:
+    async def parse(self) -> FormData:
         # Callbacks dictionary.
         callbacks = {
             "on_field_start": self.on_field_start,
@@ -106,7 +77,7 @@ class FormParser:
         field_name = b""
         field_value = b""
 
-        result = {}  # type: typing.Dict[str, typing.Union[str, UploadFile]]
+        items = []  # type: typing.List[typing.Tuple[str, FormValue]]
 
         # Feed the parser with data from the request.
         async for chunk in self.stream:
@@ -127,11 +98,11 @@ class FormParser:
                 elif message_type == FormMessage.FIELD_END:
                     name = unquote_plus(field_name.decode("latin-1"))
                     value = unquote_plus(field_value.decode("latin-1"))
-                    result[name] = value
+                    items.append((name, value))
                 elif message_type == FormMessage.END:
                     pass
 
-        return result
+        return FormData(items=items)
 
 
 class MultiPartParser:
@@ -177,7 +148,7 @@ class MultiPartParser:
         message = (MultiPartMessage.END, b"")
         self.messages.append(message)
 
-    async def parse(self) -> typing.Dict[str, typing.Union[str, UploadFile]]:
+    async def parse(self) -> FormData:
         # Parse the Content-Type header to get the multipart boundary.
         content_type, params = parse_options_header(self.headers["Content-Type"])
         boundary = params.get(b"boundary")
@@ -203,7 +174,7 @@ class MultiPartParser:
         data = b""
         file = None  # type: typing.Optional[UploadFile]
 
-        result = {}  # type: typing.Dict[str, typing.Union[str, UploadFile]]
+        items = []  # type: typing.List[typing.Tuple[str, FormValue]]
 
         # Feed the parser with data from the request.
         async for chunk in self.stream:
@@ -230,7 +201,6 @@ class MultiPartParser:
                     if b"filename" in options:
                         filename = options[b"filename"].decode("latin-1")
                         file = UploadFile(filename=filename)
-                        await file.setup()
                     else:
                         file = None
                 elif message_type == MultiPartMessage.PART_DATA:
@@ -240,12 +210,12 @@ class MultiPartParser:
                         await file.write(message_bytes)
                 elif message_type == MultiPartMessage.PART_END:
                     if file is None:
-                        result[field_name] = data.decode("latin-1")
+                        items.append((field_name, data.decode("latin-1")))
                     else:
                         await file.seek(0)
-                        result[field_name] = file
+                        items.append((field_name, file))
                 elif message_type == MultiPartMessage.END:
                     pass
 
         parser.finalize()
-        return result
+        return FormData(items=items)
diff --git a/starlette/requests.py b/starlette/requests.py
index 5b703ce..600e6fb 100644
--- a/starlette/requests.py
+++ b/starlette/requests.py
@@ -4,7 +4,7 @@ import json
 import typing
 from collections.abc import Mapping
 
-from starlette.datastructures import URL, Address, Headers, QueryParams
+from starlette.datastructures import URL, Address, FormData, Headers, QueryParams
 from starlette.formparsers import FormParser, MultiPartParser
 from starlette.types import Message, Receive, Scope
 
@@ -168,7 +168,7 @@ class Request(HTTPConnection):
             self._json = json.loads(body)
         return self._json
 
-    async def form(self) -> dict:
+    async def form(self) -> FormData:
         if not hasattr(self, "_form"):
             assert (
                 parse_options_header is not None
@@ -182,12 +182,12 @@ class Request(HTTPConnection):
                 form_parser = FormParser(self.headers, self.stream())
                 self._form = await form_parser.parse()
             else:
-                self._form = {}
+                self._form = FormData()
         return self._form
 
     async def close(self) -> None:
         if hasattr(self, "_form"):
-            for item in self._form.values():
+            for key, item in self._form.multi_items():
                 if hasattr(item, "close"):
                     await item.close()  # type: ignore
 


Test patch:
diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py
index 51c9ba8..bcf1535 100644
--- a/tests/test_datastructures.py
+++ b/tests/test_datastructures.py
@@ -1,7 +1,10 @@
+import io
+
 from starlette.datastructures import (
     URL,
     CommaSeparatedStrings,
     DatabaseURL,
+    FormData,
     Headers,
     MutableHeaders,
     QueryParams,
@@ -201,3 +204,30 @@ def test_queryparams():
 
     q = QueryParams(items=[("a", "123"), ("a", "456")])
     assert QueryParams(q) == q
+
+
+def test_formdata():
+    upload = io.BytesIO(b"test")
+    form = FormData(items=[("a", "123"), ("a", "456"), ("b", upload)])
+    assert "a" in form
+    assert "A" not in form
+    assert "c" not in form
+    assert form["a"] == "456"
+    assert form.get("a") == "456"
+    assert form.get("nope", default=None) is None
+    assert form.getlist("a") == ["123", "456"]
+    assert form.keys() == ["a", "b"]
+    assert form.values() == ["456", upload]
+    assert form.items() == [("a", "456"), ("b", upload)]
+    assert len(form) == 2
+    assert list(form) == ["a", "b"]
+    assert dict(form) == {"a": "456", "b": upload}
+    assert (
+        repr(form)
+        == "FormData(items=[('a', '123'), ('a', '456'), ('b', " + repr(upload) + ")])"
+    )
+    assert FormData(form) == form
+    assert FormData({"a": "123", "b": "789"}) == FormData(
+        items=[("a", "123"), ("b", "789")]
+    )
+    assert FormData({"a": "123", "b": "789"}) != {"a": "123", "b": "789"}
diff --git a/tests/test_formparsers.py b/tests/test_formparsers.py
index 1d533ce..2f263fd 100644
--- a/tests/test_formparsers.py
+++ b/tests/test_formparsers.py
@@ -33,6 +33,28 @@ def app(scope):
     return asgi
 
 
+def multi_items_app(scope):
+    async def asgi(receive, send):
+        request = Request(scope, receive)
+        data = await request.form()
+        output = {}
+        for key, value in data.multi_items():
+            if key not in output:
+                output[key] = []
+            if isinstance(value, UploadFile):
+                content = await value.read()
+                output[key].append(
+                    {"filename": value.filename, "content": content.decode()}
+                )
+            else:
+                output[key].append(value)
+        await request.close()
+        response = JSONResponse(output)
+        await response(receive, send)
+
+    return asgi
+
+
 def app_read_body(scope):
     async def asgi(receive, send):
         request = Request(scope, receive)
@@ -86,6 +108,29 @@ def test_multipart_request_multiple_files(tmpdir):
         }
 
 
+def test_multi_items(tmpdir):
+    path1 = os.path.join(tmpdir, "test1.txt")
+    with open(path1, "wb") as file:
+        file.write(b"<file1 content>")
+
+    path2 = os.path.join(tmpdir, "test2.txt")
+    with open(path2, "wb") as file:
+        file.write(b"<file2 content>")
+
+    client = TestClient(multi_items_app)
+    with open(path1, "rb") as f1, open(path2, "rb") as f2:
+        response = client.post(
+            "/", data=[("test1", "abc")], files=[("test1", f1), ("test1", f2)]
+        )
+        assert response.json() == {
+            "test1": [
+                "abc",
+                {"filename": "test1.txt", "content": "<file1 content>"},
+                {"filename": "test2.txt", "content": "<file2 content>"},
+            ]
+        }
+
+
 def test_multipart_request_mixed_files_and_data(tmpdir):
     client = TestClient(app)
     response = client.post(
diff --git a/tests/test_requests.py b/tests/test_requests.py
index 32d0ab6..8e3f368 100644
--- a/tests/test_requests.py
+++ b/tests/test_requests.py
@@ -130,7 +130,7 @@ def test_request_form_urlencoded():
         async def asgi(receive, send):
             request = Request(scope, receive)
             form = await request.form()
-            response = JSONResponse({"form": form})
+            response = JSONResponse({"form": dict(form)})
             await response(receive, send)
 
         return asgi