Repository: lundberg/respx

Issue:
Data pattern matching for lists broken between 0.20.2 and 0.21.1
Using httpx==0.25.1

Test case:

```py
def test_respx_data_list():
    with respx.mock:
        respx.post("http://example.com", data={"foo": ["bar"]})
        httpx.post("http://example.com", data={"foo": ["bar"]})
```

In 0.20.2 this test passes as expected. In 0.21.1 it fails with a `AllMockedAssertionError`

Discussion:

Patch:
diff --git a/respx/patterns.py b/respx/patterns.py
index da2022a..c5b351d 100644
--- a/respx/patterns.py
+++ b/respx/patterns.py
@@ -548,9 +548,17 @@ class Data(MultiItemsMixin, Pattern):
     key = "data"
     value: MultiItems
 
-    def clean(self, value: Dict) -> MultiItems:
+    def _normalize_value(self, value: Any) -> Union[str, List[str]]:
+        if value is None:
+            return ""
+        elif isinstance(value, (tuple, list)):
+            return [str(v) for v in value]
+        else:
+            return str(value)
+
+    def clean(self, value: Dict[str, Any]) -> MultiItems:
         return MultiItems(
-            (key, "" if value is None else str(value)) for key, value in value.items()
+            (key, self._normalize_value(value)) for key, value in value.items()
         )
 
     def parse(self, request: httpx.Request) -> Any:
@@ -563,7 +571,7 @@ class Files(MultiItemsMixin, Pattern):
     key = "files"
     value: MultiItems
 
-    def _normalize_file_value(self, value: FileTypes) -> Tuple[Any, Any]:
+    def _normalize_file_value(self, value: FileTypes) -> Tuple[Tuple[Any, Any]]:
         # Mimic httpx `FileField` to normalize `files` kwarg to shortest tuple style
         if isinstance(value, tuple):
             filename, fileobj = value[:2]
@@ -580,7 +588,7 @@ class Files(MultiItemsMixin, Pattern):
         elif isinstance(fileobj, str):
             fileobj = fileobj.encode()
 
-        return filename, fileobj
+        return ((filename, fileobj),)
 
     def clean(self, value: RequestFiles) -> MultiItems:
         if isinstance(value, Mapping):
diff --git a/respx/utils.py b/respx/utils.py
index de15ea3..aa59488 100644
--- a/respx/utils.py
+++ b/respx/utils.py
@@ -1,9 +1,11 @@
 import email
+from collections import defaultdict
 from datetime import datetime
 from email.message import Message
 from typing import (
     Any,
     Dict,
+    Iterable,
     List,
     Literal,
     NamedTuple,
@@ -19,15 +21,24 @@ from urllib.parse import parse_qsl
 import httpx
 
 
-class MultiItems(dict):
+class MultiItems(defaultdict):
+    def __init__(self, values: Optional[Iterable[Tuple[str, Any]]] = None) -> None:
+        super().__init__(tuple)
+        if values is not None:
+            for key, value in values:
+                if isinstance(value, (tuple, list)):
+                    self[key] += tuple(value)  # Convert list to tuple and extend
+                else:
+                    self[key] += (value,)  # Extend with value
+
     def get_list(self, key: str) -> List[Any]:
-        try:
-            return [self[key]]
-        except KeyError:  # pragma: no cover
-            return []
+        return list(self[key])
+
+    def multi_items(self) -> List[Tuple[str, str]]:
+        return [(key, value) for key, values in self.items() for value in values]
 
-    def multi_items(self) -> List[Tuple[str, Any]]:
-        return list(self.items())
+    def append(self, key: str, value: Any) -> None:
+        self[key] += (value,)
 
 
 def _parse_multipart_form_data(
@@ -45,16 +56,17 @@ def _parse_multipart_form_data(
     for payload in email.message_from_bytes(form_data).get_payload():
         payload = cast(Message, payload)
         name = payload.get_param("name", header="Content-Disposition")
+        assert isinstance(name, str)
         filename = payload.get_filename()
         content_type = payload.get_content_type()
         value = payload.get_payload(decode=True)
         assert isinstance(value, bytes)
         if content_type.startswith("text/") and filename is None:
             # Text field
-            data[name] = value.decode(payload.get_content_charset() or "utf-8")
+            data.append(name, value.decode(payload.get_content_charset() or "utf-8"))
         else:
             # File field
-            files[name] = filename, value
+            files.append(name, (filename, value))
 
     return data, files
 


Test patch:
diff --git a/tests/test_patterns.py b/tests/test_patterns.py
index 451b0dd..3d51d5e 100644
--- a/tests/test_patterns.py
+++ b/tests/test_patterns.py
@@ -333,6 +333,12 @@ def test_content_pattern(lookup, content, expected):
             None,
             True,
         ),
+        (
+            Lookup.EQUAL,
+            {"foo": "bar", "ham": ["spam", "egg"]},
+            None,
+            True,
+        ),
         (
             Lookup.EQUAL,
             {"foo": "bar", "ham": "spam"},