Repository: pallets/werkzeug (python)

Issue:
MultiDict converts byte values to int list since version 3.1.0
Since version 3.1.0, when I create a MultiDict with a bytes value as input it stores the bytes value in the MultiDict as a list of integer values. Prior to 3.1.0, this would preserve the bytes value in the MultiDict.

To reproduce, run this snippet with werkzeug installed:
```python
from werkzeug.datastructures import MultiDict

multi_dict = MultiDict({
    'bytes_value': b'abc',
    'str_value': 'abc'
})

print(multi_dict)
```

Running the above on version 3.1.0 prints:
`MultiDict([('bytes_value', 97), ('bytes_value', 98), ('bytes_value', 99), ('str_value', 'abc')])`

Running the snippet with version 3.0.6 prints:
`MultiDict([('bytes_value', b'abc'), ('str_value', 'abc')])`

I expected the behaviour from 3.0.6 to be preserved in this case

Environment:
- Python version: 3.11
- Werkzeug version: >= 3.1.0


Pull request description:
restrict containers accepted by multi
When inlining the type annotations, I noticed that `iter_multi_items` and related methods were annotated to accept `collections.abc.Iterable`, but actually called `isinstance(value, (list, tuple)`. I expanded this to `isinstance(value, Container) and not isinstance(value, str)`. However, this incorrectly matched and iterated over `bytes`, `bytearray`, `memoryview`, `array`, etc, all of which should be treated as single values. Rather than trying to build up an allow list, I've gone back to restricting to `(list, tuple, set)`, adding `set` since it was the one built-in collection type missing that would make sense to use.

fixes #2994 

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 75f01962..6a2fd2ed 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -5,6 +5,11 @@ Version 3.1.3
 
 Unreleased
 
+-   Initial data passed to ``MultiDict`` and similar interfaces only accepts
+    ``list``, ``tuple``, or ``set`` when passing multiple values. It had been
+    changed to accept any ``Collection``, but this matched types that should be
+    treated as single values, such as ``bytes``. :issue:`2994`
+
 
 Version 3.1.2
 -------------
diff --git a/src/werkzeug/datastructures/headers.py b/src/werkzeug/datastructures/headers.py
index e8cbdd8e..1088e3bc 100644
--- a/src/werkzeug/datastructures/headers.py
+++ b/src/werkzeug/datastructures/headers.py
@@ -62,7 +62,7 @@ class Headers:
         defaults: (
             Headers
             | MultiDict[str, t.Any]
-            | cabc.Mapping[str, t.Any | cabc.Collection[t.Any]]
+            | cabc.Mapping[str, t.Any | list[t.Any] | tuple[t.Any, ...] | set[t.Any]]
             | cabc.Iterable[tuple[str, t.Any]]
             | None
         ) = None,
@@ -227,7 +227,7 @@ class Headers:
         arg: (
             Headers
             | MultiDict[str, t.Any]
-            | cabc.Mapping[str, t.Any | cabc.Collection[t.Any]]
+            | cabc.Mapping[str, t.Any | list[t.Any] | tuple[t.Any, ...] | set[t.Any]]
             | cabc.Iterable[tuple[str, t.Any]]
             | None
         ) = None,
@@ -491,12 +491,14 @@ class Headers:
         arg: (
             Headers
             | MultiDict[str, t.Any]
-            | cabc.Mapping[str, t.Any | cabc.Collection[t.Any]]
+            | cabc.Mapping[
+                str, t.Any | list[t.Any] | tuple[t.Any, ...] | cabc.Set[t.Any]
+            ]
             | cabc.Iterable[tuple[str, t.Any]]
             | None
         ) = None,
         /,
-        **kwargs: t.Any | cabc.Collection[t.Any],
+        **kwargs: t.Any | list[t.Any] | tuple[t.Any, ...] | cabc.Set[t.Any],
     ) -> None:
         """Replace headers in this object with items from another
         headers object and keyword arguments.
@@ -516,9 +518,7 @@ class Headers:
                     self.setlist(key, arg.getlist(key))
             elif isinstance(arg, cabc.Mapping):
                 for key, value in arg.items():
-                    if isinstance(value, cabc.Collection) and not isinstance(
-                        value, str
-                    ):
+                    if isinstance(value, (list, tuple, set)):
                         self.setlist(key, value)
                     else:
                         self.set(key, value)
@@ -527,13 +527,16 @@ class Headers:
                     self.set(key, value)
 
         for key, value in kwargs.items():
-            if isinstance(value, cabc.Collection) and not isinstance(value, str):
+            if isinstance(value, (list, tuple, set)):
                 self.setlist(key, value)
             else:
                 self.set(key, value)
 
     def __or__(
-        self, other: cabc.Mapping[str, t.Any | cabc.Collection[t.Any]]
+        self,
+        other: cabc.Mapping[
+            str, t.Any | list[t.Any] | tuple[t.Any, ...] | cabc.Set[t.Any]
+        ],
     ) -> te.Self:
         if not isinstance(other, cabc.Mapping):
             return NotImplemented
@@ -545,13 +548,11 @@ class Headers:
     def __ior__(
         self,
         other: (
-            cabc.Mapping[str, t.Any | cabc.Collection[t.Any]]
+            cabc.Mapping[str, t.Any | list[t.Any] | tuple[t.Any, ...] | cabc.Set[t.Any]]
             | cabc.Iterable[tuple[str, t.Any]]
         ),
     ) -> te.Self:
-        if not isinstance(other, (cabc.Mapping, cabc.Iterable)) or isinstance(
-            other, str
-        ):
+        if not isinstance(other, (cabc.Mapping, cabc.Iterable)):
             return NotImplemented
 
         self.update(other)
diff --git a/src/werkzeug/datastructures/structures.py b/src/werkzeug/datastructures/structures.py
index fcfa160d..dbb7e804 100644
--- a/src/werkzeug/datastructures/structures.py
+++ b/src/werkzeug/datastructures/structures.py
@@ -22,7 +22,7 @@ T = t.TypeVar("T")
 def iter_multi_items(
     mapping: (
         MultiDict[K, V]
-        | cabc.Mapping[K, V | cabc.Collection[V]]
+        | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]]
         | cabc.Iterable[tuple[K, V]]
     ),
 ) -> cabc.Iterator[tuple[K, V]]:
@@ -33,11 +33,11 @@ def iter_multi_items(
         yield from mapping.items(multi=True)
     elif isinstance(mapping, cabc.Mapping):
         for key, value in mapping.items():
-            if isinstance(value, cabc.Collection) and not isinstance(value, str):
+            if isinstance(value, (list, tuple, set)):
                 for v in value:
                     yield key, v
             else:
-                yield key, value  # type: ignore[misc]
+                yield key, value
     else:
         yield from mapping
 
@@ -182,7 +182,7 @@ class MultiDict(TypeConversionDict[K, V]):
         self,
         mapping: (
             MultiDict[K, V]
-            | cabc.Mapping[K, V | cabc.Collection[V]]
+            | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]]
             | cabc.Iterable[tuple[K, V]]
             | None
         ) = None,
@@ -194,7 +194,7 @@ class MultiDict(TypeConversionDict[K, V]):
         elif isinstance(mapping, cabc.Mapping):
             tmp = {}
             for key, value in mapping.items():
-                if isinstance(value, cabc.Collection) and not isinstance(value, str):
+                if isinstance(value, (list, tuple, set)):
                     value = list(value)
 
                     if not value:
@@ -419,7 +419,7 @@ class MultiDict(TypeConversionDict[K, V]):
         self,
         mapping: (
             MultiDict[K, V]
-            | cabc.Mapping[K, V | cabc.Collection[V]]
+            | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]]
             | cabc.Iterable[tuple[K, V]]
         ),
     ) -> None:
@@ -444,7 +444,7 @@ class MultiDict(TypeConversionDict[K, V]):
             self.add(key, value)
 
     def __or__(  # type: ignore[override]
-        self, other: cabc.Mapping[K, V | cabc.Collection[V]]
+        self, other: cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]]
     ) -> MultiDict[K, V]:
         if not isinstance(other, cabc.Mapping):
             return NotImplemented
@@ -455,11 +455,12 @@ class MultiDict(TypeConversionDict[K, V]):
 
     def __ior__(  # type: ignore[override]
         self,
-        other: cabc.Mapping[K, V | cabc.Collection[V]] | cabc.Iterable[tuple[K, V]],
+        other: (
+            cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]]
+            | cabc.Iterable[tuple[K, V]]
+        ),
     ) -> te.Self:
-        if not isinstance(other, (cabc.Mapping, cabc.Iterable)) or isinstance(
-            other, str
-        ):
+        if not isinstance(other, (cabc.Mapping, cabc.Iterable)):
             return NotImplemented
 
         self.update(other)
@@ -600,7 +601,7 @@ class _OrderedMultiDict(MultiDict[K, V]):
         self,
         mapping: (
             MultiDict[K, V]
-            | cabc.Mapping[K, V | cabc.Collection[V]]
+            | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]]
             | cabc.Iterable[tuple[K, V]]
             | None
         ) = None,
@@ -744,7 +745,7 @@ class _OrderedMultiDict(MultiDict[K, V]):
         self,
         mapping: (
             MultiDict[K, V]
-            | cabc.Mapping[K, V | cabc.Collection[V]]
+            | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]]
             | cabc.Iterable[tuple[K, V]]
         ),
     ) -> None:
@@ -1009,7 +1010,7 @@ class _ImmutableOrderedMultiDict(  # type: ignore[misc]
         self,
         mapping: (
             MultiDict[K, V]
-            | cabc.Mapping[K, V | cabc.Collection[V]]
+            | cabc.Mapping[K, V | list[V] | tuple[V, ...] | set[V]]
             | cabc.Iterable[tuple[K, V]]
             | None
         ) = None,


Test patch:
diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py
index dcbc7969..0cd49743 100644
--- a/tests/test_datastructures.py
+++ b/tests/test_datastructures.py
@@ -1,6 +1,9 @@
+from __future__ import annotations
+
 import io
 import pickle
 import tempfile
+import typing as t
 from contextlib import contextmanager
 from copy import copy
 from copy import deepcopy
@@ -43,7 +46,7 @@ class TestNativeItermethods:
 
 
 class _MutableMultiDictTests:
-    storage_class: type["ds.MultiDict"]
+    storage_class: type[ds.MultiDict]
 
     def test_pickle(self):
         cls = self.storage_class
@@ -1280,3 +1283,15 @@ def test_range_to_header(ranges):
 def test_range_validates_ranges(ranges):
     with pytest.raises(ValueError):
         ds.Range("bytes", ranges)
+
+
+@pytest.mark.parametrize(
+    ("value", "expect"),
+    [
+        ({"a": "ab"}, [("a", "ab")]),
+        ({"a": ["a", "b"]}, [("a", "a"), ("a", "b")]),
+        ({"a": b"ab"}, [("a", b"ab")]),
+    ],
+)
+def test_iter_multi_data(value: t.Any, expect: list[tuple[t.Any, t.Any]]) -> None:
+    assert list(ds.iter_multi_items(value)) == expect