Repository: marshmallow-code/webargs

Issue:
Add marshmallow.fields.Tuple to detected `is_multiple` fields
I noticed this while working on #584 . Currently, `is_multiple(ma.fields.List(...)) == True`, but `is_multiple(ma.fields.Tuple(...)) == False`.
We should add `Tuple` so that `is_multiple` returns true.

It is possible that users have subclassed `fields.Tuple` or done something else which could make that a breaking change. I don't think that's very likely, but we are already queuing things up for 8.0 . I'm going to put this in the 8.0 milestone. We could probably do it sooner if there's a reason to do so.


I don't think using `Tuple` for a query param is a good idea. Supporting `...?foo=1&foo=2` but *not* `...?foo=1` or `...?foo=1&foo=2&foo=3` is unusual and therefore likely to be confusing. I'd advise webargs users not to do that. However! It's surprising that `Tuple` is not treated as `is_multiple=True`, and I'm just aiming for the least surprising behavior for webargs here.


Discussion:

Resolving patch:
diff --git a/src/webargs/core.py b/src/webargs/core.py
--- a/src/webargs/core.py
+++ b/src/webargs/core.py
@@ -142,9 +142,8 @@ class Parser:
     DEFAULT_VALIDATION_STATUS: int = DEFAULT_VALIDATION_STATUS
     #: Default error message for validation errors
     DEFAULT_VALIDATION_MESSAGE: str = "Invalid value."
-    # TODO: add ma.fields.Tuple in v8.0
     #: field types which should always be treated as if they set `is_multiple=True`
-    KNOWN_MULTI_FIELDS: typing.List[typing.Type] = [ma.fields.List]
+    KNOWN_MULTI_FIELDS: typing.List[typing.Type] = [ma.fields.List, ma.fields.Tuple]
 
     #: Maps location => method name
     __location_map__: typing.Dict[str, typing.Union[str, typing.Callable]] = {
diff --git a/src/webargs/multidictproxy.py b/src/webargs/multidictproxy.py
--- a/src/webargs/multidictproxy.py
+++ b/src/webargs/multidictproxy.py
@@ -18,7 +18,10 @@ def __init__(
         self,
         multidict,
         schema: ma.Schema,
-        known_multi_fields: typing.Tuple[typing.Type, ...] = (ma.fields.List,),
+        known_multi_fields: typing.Tuple[typing.Type, ...] = (
+            ma.fields.List,
+            ma.fields.Tuple,
+        ),
     ):
         self.data = multidict
         self.known_multi_fields = known_multi_fields


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -35,7 +35,7 @@ class MockRequestParser(Parser):
     """A minimal parser implementation that parses mock requests."""
 
     def load_querystring(self, req, schema):
-        return MultiDictProxy(req.query, schema)
+        return self._makeproxy(req.query, schema)
 
     def load_json(self, req, schema):
         return req.json
@@ -1040,6 +1040,7 @@ def test_type_conversion_with_multiple_required(web_request, parser):
         "is_multiple_false",
         "is_multiple_notset",
         "list_field",
+        "tuple_field",
         "added_to_known",
     ],
 )
@@ -1103,13 +1104,20 @@ class CustomNonMultipleField(CustomMultiplexingField):
         args = {"foos": fields.List(fields.Str())}
         result = parser.parse(args, web_request, location="query")
         assert result["foos"] in (["a", "b"], ["b", "a"])
+    # case 5: the field is a Tuple (special case)
+    elif setting == "tuple_field":
+        # this should behave like the is_multiple=True case and produce a tuple
+        args = {"foos": fields.Tuple((fields.Str, fields.Str))}
+        result = parser.parse(args, web_request, location="query")
+        assert result["foos"] in (("a", "b"), ("b", "a"))
+    # case 6: the field is custom, but added to the known fields of the parser
     elif setting == "added_to_known":
         # if it's included in the known multifields and is_multiple is not set, behave
         # like is_multiple=True
         parser.KNOWN_MULTI_FIELDS.append(CustomMultiplexingField)
         args = {"foos": CustomMultiplexingField()}
         result = parser.parse(args, web_request, location="query")
-        assert result["foos"] in ("a", "b")
+        assert result["foos"] in (["a", "b"], ["b", "a"])
     else:
         raise NotImplementedError