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.


Discussion:
I admit that I might not be well enough educated about marshmallow-sqlalchemy (I've never used it) to be able to make a good judgement about that part of this issue. But clearly, `is_multiple` isn't very extensible right now, and that's a problem. (Also, I think it doesn't handle `Tuple`? So that's a bug.)

If we put `MultiValueField` into `marshmallow`, that might get confusing with `Nested(..., many=True)` as a possibility. I think `is_multiple` makes more sense as a characteristic of a field instance than a field class -- that makes it possible (at least in theory) to have a field whose instances vary with respect to this.

As a result, I think the `getattr` solution is the right one. `is_multiple` seems like a reasonable name for the attribute to me. @lafrech, do you maybe have a different opinion about this, or any thoughts about naming?

Resolving patch:
diff --git a/src/webargs/core.py b/src/webargs/core.py
--- a/src/webargs/core.py
+++ b/src/webargs/core.py
@@ -8,8 +8,6 @@
 from marshmallow import ValidationError
 from marshmallow.utils import missing
 
-from webargs.fields import DelimitedList
-
 logger = logging.getLogger(__name__)
 
 
@@ -34,6 +32,12 @@
 # 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
--- 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


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- 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.")