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.

Comments:

Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 703bfe6..138d5f1 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,20 +1,23 @@
 Changelog
 ---------
 
-7.1.0 (Unreleased)
+8.0.0 (Unreleased)
 ******************
 
 Features:
 
+* ``unknown`` defaults to `None` for body locations (`json`, `form` and
+  `json_or_form`) (:issue:`580`).
+
 * Detection of fields as "multi-value" for unpacking lists from multi-dict
-  types is now extensible with the `is_multiple` attribute. If a field sets
-  `is_multiple = True` it will be detected as a multi-value field.
+  types is now extensible with the ``is_multiple`` attribute. If a field sets
+  ``is_multiple = True`` it will be detected as a multi-value field.
   (:issue:`563`)
 
-* If `is_multiple` is not set or is set to `None`, webargs will check if the
-  field is an instance of `List`.
+* If ``is_multiple`` is not set or is set to ``None``, webargs will check if the
+  field is an instance of ``List`` or ``Tuple``.
 
-* A new attribute on `Parser` objects, ``Parser.KNOWN_MULTI_FIELDS`` can be
+* A new attribute on ``Parser`` objects, ``Parser.KNOWN_MULTI_FIELDS`` can be
   used to set fields which should be detected as ``is_multiple=True`` even when
   the attribute is not set.
 
diff --git a/setup.py b/setup.py
index 15c033b..b201de5 100644
--- a/setup.py
+++ b/setup.py
@@ -21,7 +21,7 @@ EXTRAS_REQUIRE = {
     + FRAMEWORKS,
     "lint": [
         "mypy==0.812",
-        "flake8==3.8.4",
+        "flake8==3.9.0",
         "flake8-bugbear==21.3.2",
         "pre-commit~=2.4",
     ],
diff --git a/src/webargs/aiohttpparser.py b/src/webargs/aiohttpparser.py
index 334e601..b85ff51 100644
--- a/src/webargs/aiohttpparser.py
+++ b/src/webargs/aiohttpparser.py
@@ -71,7 +71,7 @@ del _find_exceptions
 class AIOHTTPParser(AsyncParser):
     """aiohttp request argument parser."""
 
-    DEFAULT_UNKNOWN_BY_LOCATION = {
+    DEFAULT_UNKNOWN_BY_LOCATION: typing.Dict[str, typing.Optional[str]] = {
         "match_info": RAISE,
         "path": RAISE,
         **core.Parser.DEFAULT_UNKNOWN_BY_LOCATION,
diff --git a/src/webargs/core.py b/src/webargs/core.py
index de8953d..3752479 100644
--- a/src/webargs/core.py
+++ b/src/webargs/core.py
@@ -126,10 +126,10 @@ class Parser:
     DEFAULT_LOCATION: str = "json"
     #: Default value to use for 'unknown' on schema load
     #  on a per-location basis
-    DEFAULT_UNKNOWN_BY_LOCATION: typing.Dict[str, str] = {
-        "json": ma.RAISE,
-        "form": ma.RAISE,
-        "json_or_form": ma.RAISE,
+    DEFAULT_UNKNOWN_BY_LOCATION: typing.Dict[str, typing.Optional[str]] = {
+        "json": None,
+        "form": None,
+        "json_or_form": None,
         "querystring": ma.EXCLUDE,
         "query": ma.EXCLUDE,
         "headers": ma.EXCLUDE,
@@ -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/flaskparser.py b/src/webargs/flaskparser.py
index 29bc93d..053988d 100644
--- a/src/webargs/flaskparser.py
+++ b/src/webargs/flaskparser.py
@@ -20,6 +20,8 @@ Example: ::
             uid=uid, per_page=args["per_page"]
         )
 """
+import typing
+
 import flask
 from werkzeug.exceptions import HTTPException
 
@@ -49,7 +51,7 @@ def is_json_request(req):
 class FlaskParser(core.Parser):
     """Flask request argument parser."""
 
-    DEFAULT_UNKNOWN_BY_LOCATION = {
+    DEFAULT_UNKNOWN_BY_LOCATION: typing.Dict[str, typing.Optional[str]] = {
         "view_args": ma.RAISE,
         "path": ma.RAISE,
         **core.Parser.DEFAULT_UNKNOWN_BY_LOCATION,
diff --git a/src/webargs/multidictproxy.py b/src/webargs/multidictproxy.py
index 7017d96..a277178 100644
--- a/src/webargs/multidictproxy.py
+++ b/src/webargs/multidictproxy.py
@@ -18,7 +18,10 @@ class MultiDictProxy(Mapping):
         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
diff --git a/src/webargs/pyramidparser.py b/src/webargs/pyramidparser.py
index 46bb377..4be2884 100644
--- a/src/webargs/pyramidparser.py
+++ b/src/webargs/pyramidparser.py
@@ -25,6 +25,7 @@ Example usage: ::
         server.serve_forever()
 """
 import functools
+import typing
 from collections.abc import Mapping
 
 from webob.multidict import MultiDict
@@ -43,7 +44,7 @@ def is_json_request(req):
 class PyramidParser(core.Parser):
     """Pyramid request argument parser."""
 
-    DEFAULT_UNKNOWN_BY_LOCATION = {
+    DEFAULT_UNKNOWN_BY_LOCATION: typing.Dict[str, typing.Optional[str]] = {
         "matchdict": ma.RAISE,
         "path": ma.RAISE,
         **core.Parser.DEFAULT_UNKNOWN_BY_LOCATION,


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index d7b35fb..1219659 100644
--- 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 @@ def test_is_multiple_detection(web_request, parser, input_dict, setting):
         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