Repository: lundberg/respx

Issue:
Add a files pattern?
As mentioned in #87, it might be of interest to allow matching a request on files being sent.

- What do we want to match on?
- Is it even possible once internal `HTTPX` request is built.

```py
route = respx.post("https://example.org/", files=?)
```

Since uploading files with `HTTPX` supports multiple arg types, it might *only* be possible to match on file names, if given. Reading a file object's content could affect `HTTPX` internals, if not handled properly, like reseting seek point etc.

Needs further investigation, [multiple files](https://github.com/encode/httpx/pull/1032/files).

Comments:

Patch:
diff --git a/docs/api.md b/docs/api.md
index ae54c7c..f43c7a8 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -316,6 +316,17 @@ Matches request *form data*, excluding files, using [eq](#eq) as default lookup.
 respx.post("https://example.org/", data={"foo": "bar"})
 ```
 
+### Files
+Matches files within request *form data*, using [contains](#contains) as default lookup.
+> Key: `files`  
+> Lookups: [contains](#contains), [eq](#eq)
+``` python
+respx.post("https://example.org/", files={"some_file": b"..."})
+respx.post("https://example.org/", files={"some_file": ANY})
+respx.post("https://example.org/", files={"some_file": ("filename.txt", b"...")})
+respx.post("https://example.org/", files={"some_file": ("filename.txt", ANY)})
+```
+
 ### JSON
 Matches request *json* content, using [eq](#eq) as default lookup.
 > Key: `json`  
diff --git a/respx/patterns.py b/respx/patterns.py
index 75cb815..d8b00b5 100644
--- a/respx/patterns.py
+++ b/respx/patterns.py
@@ -1,5 +1,6 @@
 import json as jsonlib
 import operator
+import pathlib
 import re
 from abc import ABC
 from enum import Enum
@@ -12,6 +13,7 @@ from typing import (
     ClassVar,
     Dict,
     List,
+    Mapping,
     Optional,
     Pattern as RegexPattern,
     Sequence,
@@ -30,8 +32,10 @@ from respx.utils import MultiItems, decode_data
 from .types import (
     URL as RawURL,
     CookieTypes,
+    FileTypes,
     HeaderTypes,
     QueryParamTypes,
+    RequestFiles,
     URLPatternTypes,
 )
 
@@ -551,6 +555,38 @@ class Data(MultiItemsMixin, Pattern):
         return data
 
 
+class Files(MultiItemsMixin, Pattern):
+    lookups = (Lookup.CONTAINS, Lookup.EQUAL)
+    key = "files"
+    value: MultiItems
+
+    def _normalize_file_value(self, value: FileTypes) -> Tuple[Any, ...]:
+        # Mimic httpx `FileField` to normalize `files` kwarg to shortest tuple style
+        if isinstance(value, tuple):
+            filename, fileobj = value[:2]
+        else:
+            try:
+                filename = pathlib.Path(str(getattr(value, "name"))).name  # noqa: B009
+            except AttributeError:
+                filename = ANY
+            fileobj = value
+
+        return filename, fileobj
+
+    def clean(self, value: RequestFiles) -> MultiItems:
+        if isinstance(value, Mapping):
+            value = list(value.items())
+
+        files = MultiItems(
+            (name, self._normalize_file_value(file_value)) for name, file_value in value
+        )
+        return files
+
+    def parse(self, request: httpx.Request) -> Any:
+        _, files = decode_data(request)
+        return files
+
+
 def M(*patterns: Pattern, **lookups: Any) -> Pattern:
     extras = None
 
diff --git a/respx/types.py b/respx/types.py
index 0ce2101..1cf3e0c 100644
--- a/respx/types.py
+++ b/respx/types.py
@@ -1,4 +1,5 @@
 from typing import (
+    IO,
     Any,
     AsyncIterable,
     Awaitable,
@@ -7,6 +8,7 @@ from typing import (
     Iterable,
     Iterator,
     List,
+    Mapping,
     Optional,
     Pattern,
     Sequence,
@@ -53,3 +55,17 @@ SideEffectTypes = Union[
     Type[Exception],
     Iterator[SideEffectListTypes],
 ]
+
+# Borrowed from HTTPX's "private" types.
+FileContent = Union[IO[bytes], bytes, str]
+FileTypes = Union[
+    # file (or bytes)
+    FileContent,
+    # (filename, file (or bytes))
+    Tuple[Optional[str], FileContent],
+    # (filename, file (or bytes), content_type)
+    Tuple[Optional[str], FileContent, Optional[str]],
+    # (filename, file (or bytes), content_type, headers)
+    Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]],
+]
+RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]


Test patch:
diff --git a/tests/test_api.py b/tests/test_api.py
index 597c589..ef1dddd 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -272,6 +272,16 @@ def test_data_post_body():
         assert route.called
 
 
+def test_files_post_body():
+    with respx.mock:
+        url = "https://foo.bar/"
+        file = ("file", ("filename.txt", b"...", "text/plain", {"X-Foo": "bar"}))
+        route = respx.post(url, files={"file": mock.ANY}) % 201
+        response = httpx.post(url, files=[file])
+        assert response.status_code == 201
+        assert route.called
+
+
 async def test_raising_content(client):
     async with MockRouter() as respx_mock:
         url = "https://foo.bar/"
diff --git a/tests/test_patterns.py b/tests/test_patterns.py
index f492307..dda7914 100644
--- a/tests/test_patterns.py
+++ b/tests/test_patterns.py
@@ -10,6 +10,7 @@ from respx.patterns import (
     Content,
     Cookies,
     Data,
+    Files,
     Headers,
     Host,
     Lookup,
@@ -389,6 +390,112 @@ def test_data_pattern(lookup, data, request_data, expected):
     assert bool(match) is expected
 
 
+@pytest.mark.parametrize(
+    ("lookup", "files", "request_files", "expected"),
+    [
+        (
+            Lookup.EQUAL,
+            [("file_1", b"foo..."), ("file_2", b"bar...")],
+            None,
+            True,
+        ),
+        (
+            Lookup.EQUAL,
+            {"file_1": b"foo...", "file_2": b"bar..."},
+            None,
+            True,
+        ),
+        (
+            Lookup.EQUAL,
+            {"file_1": ANY},
+            {"file_1": b"foobar..."},
+            True,
+        ),
+        (
+            Lookup.EQUAL,
+            {
+                "file_1": ("filename_1.txt", b"foo..."),
+                "file_2": ("filename_2.txt", b"bar..."),
+            },
+            None,
+            True,
+        ),
+        (
+            Lookup.EQUAL,
+            {"file_1": ("filename_1.txt", ANY)},
+            {"file_1": ("filename_1.txt", b"...")},
+            True,
+        ),
+        (
+            Lookup.EQUAL,
+            {"upload": b"foo..."},
+            {"upload": b"bar..."},  # Wrong file data
+            False,
+        ),
+        (
+            Lookup.EQUAL,
+            {
+                "file_1": ("filename_1.txt", b"foo..."),
+                "file_2": ("filename_2.txt", b"bar..."),
+            },
+            {
+                "file_1": ("filename_1.txt", b"foo..."),
+                "file_2": ("filename_2.txt", b"ham..."),  # Wrong file data
+            },
+            False,
+        ),
+        (
+            Lookup.CONTAINS,
+            {
+                "file_1": ("filename_1.txt", b"foo..."),
+            },
+            {
+                "file_1": ("filename_1.txt", b"foo..."),
+                "file_2": ("filename_2.txt", b"bar..."),
+            },
+            True,
+        ),
+        (
+            Lookup.CONTAINS,
+            {
+                "file_1": ("filename_1.txt", ANY),
+            },
+            {
+                "file_1": ("filename_1.txt", b"foo..."),
+                "file_2": ("filename_2.txt", b"bar..."),
+            },
+            True,
+        ),
+        (
+            Lookup.CONTAINS,
+            [("file_1", ANY)],
+            {
+                "file_1": ("filename_1.txt", b"foo..."),
+                "file_2": ("filename_2.txt", b"bar..."),
+            },
+            True,
+        ),
+        (
+            Lookup.CONTAINS,
+            [("file_1", b"ham...")],
+            {
+                "file_1": ("filename_1.txt", b"foo..."),
+                "file_2": ("filename_2.txt", b"bar..."),
+            },
+            False,
+        ),
+    ],
+)
+def test_files_pattern(lookup, files, request_files, expected):
+    request = httpx.Request(
+        "POST",
+        "https://foo.bar/",
+        files=request_files or files,
+    )
+    match = Files(files, lookup=lookup).match(request)
+    assert bool(match) is expected
+
+
 @pytest.mark.parametrize(
     ("lookup", "value", "json", "expected"),
     [