Repository: lundberg/respx

Issue:
`files` pattern not handling `str` and `io.BytesIO`
When using the `files` parameter in a helper (http) function, both `str` and `io.BytesIO` payloads are not deemed equal and raise an error.

# Input

## `str` payload
```py
@respx.mock
def test_file_str_payload():
    FILE_DATA = {"upload": ("image/png", "str", "image/png")}

    respx.patch("https://api.example.com/endpoint/123", files=FILE_DATA)

    httpx.patch("https://api.example.com/endpoint/123", files=FILE_DATA)

```

## `io.BytesIO` payload
```py
@respx.mock
def test_file_bytesio_payload():
    FILE_DATA = {
        "upload": ("image/png", io.BytesIO(b"some image content"), "image/png")
    }

    respx.patch("https://api.example.com/endpoint/123", files=FILE_DATA)

    httpx.patch("https://api.example.com/endpoint/123", files=FILE_DATA)
```

# Output

## Error raised
```bash
raise AllMockedAssertionError(f"RESPX: {request!r} not mocked!")
respx.models.AllMockedAssertionError: RESPX: <Request('PATCH', 'https://api.example.com/endpoint/123')> not mocked!
```

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/lundberg/respx/pull/260?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`15522db`)](https://app.codecov.io/gh/lundberg/respx/commit/15522db36d6a08f2c062831ec664df2b9d2e1f69?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg) to head [(`26bc846`)](https://app.codecov.io/gh/lundberg/respx/pull/260?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg).


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##            master      #260   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           22        22           
  Lines         2910      2916    +6     
  Branches       443       445    +2     
=========================================
+ Hits          2910      2916    +6     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/lundberg/respx/pull/260?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Jonas+Lundberg).


Patch:
diff --git a/respx/patterns.py b/respx/patterns.py
index 8d80148..da2022a 100644
--- a/respx/patterns.py
+++ b/respx/patterns.py
@@ -1,3 +1,4 @@
+import io
 import json as jsonlib
 import operator
 import pathlib
@@ -562,7 +563,7 @@ class Files(MultiItemsMixin, Pattern):
     key = "files"
     value: MultiItems
 
-    def _normalize_file_value(self, value: FileTypes) -> Tuple[Any, ...]:
+    def _normalize_file_value(self, value: FileTypes) -> Tuple[Any, Any]:
         # Mimic httpx `FileField` to normalize `files` kwarg to shortest tuple style
         if isinstance(value, tuple):
             filename, fileobj = value[:2]
@@ -573,6 +574,12 @@ class Files(MultiItemsMixin, Pattern):
                 filename = ANY
             fileobj = value
 
+        # Normalize file-like objects and strings to bytes to allow equality check
+        if isinstance(fileobj, io.BytesIO):
+            fileobj = fileobj.read()
+        elif isinstance(fileobj, str):
+            fileobj = fileobj.encode()
+
         return filename, fileobj
 
     def clean(self, value: RequestFiles) -> MultiItems:


Test patch:
diff --git a/tests/test_patterns.py b/tests/test_patterns.py
index 4b119aa..451b0dd 100644
--- a/tests/test_patterns.py
+++ b/tests/test_patterns.py
@@ -1,3 +1,4 @@
+import io
 import re
 from unittest.mock import ANY
 
@@ -456,6 +457,18 @@ def test_data_pattern(lookup, data, request_data, expected):
             },
             False,
         ),
+        (
+            Lookup.EQUAL,
+            {"file_1": ("filename.png", io.BytesIO(b"some..image..data"), "image/png")},
+            None,
+            True,
+        ),
+        (
+            Lookup.EQUAL,
+            {"file_1": ("filename.png", "some..image..data", "image/png")},  # str data
+            {"file_1": ("filename.png", io.BytesIO(b"some..image..data"), "image/png")},
+            True,
+        ),
         (
             Lookup.CONTAINS,
             {
@@ -487,6 +500,15 @@ def test_data_pattern(lookup, data, request_data, expected):
             },
             True,
         ),
+        (
+            Lookup.CONTAINS,
+            {"file_1": "foo..."},  # str data
+            {
+                "file_1": ("filename_1.txt", io.BytesIO(b"foo...")),
+                "file_2": ("filename_2.txt", io.BytesIO(b"bar...")),
+            },
+            True,
+        ),
         (
             Lookup.CONTAINS,
             [("file_1", b"ham...")],