Repository: marshmallow-code/webargs

Issue:
`is_multiple` vs custom fields
Right now I don't get multiple values in a custom field that does not inherit from the `List` field because `is_multiple` only checks for this.

And for some cases rewriting the field to be nested in an actual List field is not feasible, for example a `SQLAlchemyModelList`-like field where sending individual queries would be inefficient compared to one query with an SQL `IN` filter.

I propose adding a `MultiValueField` which would simply inherit from Field and do nothing itself, and instead of checking for isinstance List check for that one in `is_multiple`. `List` would then of course inherit from that instead of `Field`.

Alternatively, check for something like `isinstance(field, List) or getattr(field, 'is_multiple', False)`.

I can send a PR if there's interest in this.

Comments:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 310c851..392e538 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -25,3 +25,4 @@ repos:
   - id: mypy
     language_version: python3
     files: ^src/webargs/
+    additional_dependencies: ["marshmallow>=3,<4"]
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 4549beb..732f7c0 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,19 @@
 Changelog
 ---------
 
+7.1.0 (Unreleased)
+******************
+
+Features:
+
+* 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.
+  (:issue:`563`)
+
+* If `is_multiple` is not set or is set to `None`, webargs will check if the
+  field is an instance of `List`.
+
 7.0.1 (2020-12-14)
 ******************
 
diff --git a/docs/advanced.rst b/docs/advanced.rst
index 53cbde8..edac845 100644
--- a/docs/advanced.rst
+++ b/docs/advanced.rst
@@ -110,7 +110,7 @@ When you need more flexibility in defining input schemas, you can pass a marshma
 
     @use_args(UserSchema())
     def profile_view(args):
-        username = args["userame"]
+        username = args["username"]
         # ...
 
 
diff --git a/setup.py b/setup.py
index f68b745..91c44ff 100644
--- a/setup.py
+++ b/setup.py
@@ -20,12 +20,12 @@ EXTRAS_REQUIRE = {
     ]
     + FRAMEWORKS,
     "lint": [
-        "mypy==0.800",
+        "mypy==0.812",
         "flake8==3.8.4",
-        "flake8-bugbear==20.11.1",
+        "flake8-bugbear==21.3.1",
         "pre-commit~=2.4",
     ],
-    "docs": ["Sphinx==3.4.3", "sphinx-issues==1.2.0", "sphinx-typlog-theme==0.8.0"]
+    "docs": ["Sphinx==3.5.2", "sphinx-issues==1.2.0", "sphinx-typlog-theme==0.8.0"]
     + FRAMEWORKS,
 }
 EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["lint"] + ["tox"]
diff --git a/src/webargs/core.py b/src/webargs/core.py
index 25080ee..d161030 100644
--- a/src/webargs/core.py
+++ b/src/webargs/core.py
@@ -8,8 +8,6 @@ import marshmallow as ma
 from marshmallow import ValidationError
 from marshmallow.utils import missing
 
-from webargs.fields import DelimitedList
-
 logger = logging.getLogger(__name__)
 
 
@@ -34,6 +32,12 @@ ErrorHandler = typing.Callable[..., typing.NoReturn]
 # generic type var with no particular meaning
 T = typing.TypeVar("T")
 
+# a set of fields which are known to satisfy the `is_multiple` criteria, but
+# which come from marshmallow and therefore don't know about webargs (and
+# do not set `is_multiple=True`)
+# TODO: `ma.fields.Tuple` should be added here in v8.0
+KNOWN_MULTI_FIELDS: typing.List[typing.Type] = [ma.fields.List]
+
 
 # a value used as the default for arguments, so that when `None` is passed, it
 # can be distinguished from the default value
@@ -59,7 +63,12 @@ def _callable_or_raise(obj: typing.Optional[T]) -> typing.Optional[T]:
 
 def is_multiple(field: ma.fields.Field) -> bool:
     """Return whether or not `field` handles repeated/multi-value arguments."""
-    return isinstance(field, ma.fields.List) and not isinstance(field, DelimitedList)
+    # fields which set `is_multiple = True/False` will have the value selected,
+    # otherwise, we check for explicit criteria
+    is_multiple_attr = getattr(field, "is_multiple", None)
+    if is_multiple_attr is not None:
+        return is_multiple_attr
+    return isinstance(field, tuple(KNOWN_MULTI_FIELDS))
 
 
 def get_mimetype(content_type: str) -> str:
diff --git a/src/webargs/fields.py b/src/webargs/fields.py
index 806cc5d..f8991d1 100644
--- a/src/webargs/fields.py
+++ b/src/webargs/fields.py
@@ -55,6 +55,8 @@ class DelimitedFieldMixin:
     """
 
     delimiter: str = ","
+    # delimited fields set is_multiple=False for webargs.core.is_multiple
+    is_multiple: bool = False
 
     def _serialize(self, value, attr, obj, **kwargs):
         # serializing will start with parent-class serialization, so that we correctly
diff --git a/tox.ini b/tox.ini
index f3915a2..f18520b 100644
--- a/tox.ini
+++ b/tox.ini
@@ -30,6 +30,7 @@ commands = pre-commit run --all-files
 # issues in which `mypy` running on every file standalone won't catch things
 [testenv:mypy]
 deps = mypy
+extras = frameworks
 commands = mypy src/
 
 [testenv:docs]


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index 829a3fe..b78f9ef 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1032,6 +1032,72 @@ def test_type_conversion_with_multiple_required(web_request, parser):
         parser.parse(args, web_request)
 
 
+@pytest.mark.parametrize("input_dict", multidicts)
+@pytest.mark.parametrize(
+    "setting",
+    ["is_multiple_true", "is_multiple_false", "is_multiple_notset", "list_field"],
+)
+def test_is_multiple_detection(web_request, parser, input_dict, setting):
+    # define a custom List-like type which deserializes string lists
+    str_instance = fields.String()
+
+    # this custom class "multiplexes" in that it can be given a single value or
+    # list of values -- a single value is treated as a string, and a list of
+    # values is treated as a list of strings
+    class CustomMultiplexingField(fields.Field):
+        def _deserialize(self, value, attr, data, **kwargs):
+            if isinstance(value, str):
+                return str_instance.deserialize(value, **kwargs)
+            return [str_instance.deserialize(v, **kwargs) for v in value]
+
+        def _serialize(self, value, attr, data, **kwargs):
+            if isinstance(value, str):
+                return str_instance._serialize(value, **kwargs)
+            return [str_instance._serialize(v, **kwargs) for v in value]
+
+    class CustomMultipleField(CustomMultiplexingField):
+        is_multiple = True
+
+    class CustomNonMultipleField(CustomMultiplexingField):
+        is_multiple = False
+
+    # the request's query params are the input multidict
+    web_request.query = input_dict
+
+    # case 1: is_multiple=True
+    if setting == "is_multiple_true":
+        # the multidict should unpack to a list of strings
+        #
+        # order is not necessarily guaranteed by the multidict implementations, but
+        # both values must be present
+        args = {"foos": CustomMultipleField()}
+        result = parser.parse(args, web_request, location="query")
+        assert result["foos"] in (["a", "b"], ["b", "a"])
+    # case 2: is_multiple=False
+    elif setting == "is_multiple_false":
+        # the multidict should unpack to a string
+        #
+        # either value may be returned, depending on the multidict implementation,
+        # but not both
+        args = {"foos": CustomNonMultipleField()}
+        result = parser.parse(args, web_request, location="query")
+        assert result["foos"] in ("a", "b")
+    # case 3: is_multiple is not set
+    elif setting == "is_multiple_notset":
+        # this should be the same as is_multiple=False
+        args = {"foos": CustomMultiplexingField()}
+        result = parser.parse(args, web_request, location="query")
+        assert result["foos"] in ("a", "b")
+    # case 4: the field is a List (special case)
+    elif setting == "list_field":
+        # this should behave like the is_multiple=True case
+        args = {"foos": fields.List(fields.Str())}
+        result = parser.parse(args, web_request, location="query")
+        assert result["foos"] in (["a", "b"], ["b", "a"])
+    else:
+        raise NotImplementedError
+
+
 def test_validation_errors_in_validator_are_passed_to_handle_error(parser, web_request):
     def validate(value):
         raise ValidationError("Something went wrong.")