Repository: marshmallow-code/webargs

Issue:
'Not a valid tuple.'  when trying to use marshmallow fields.Tuple for argument validation
I'm trying to use the marshmallow fields.Tuple for querystring argument validation on a GET request using Flask.  The issue I'm running into is that no matter what type of object I declare and no matter what I use in the request, I always get the default 'Not a valid tuple.' response.  I have tried using a tuple of size 1 and 2; using fields.String and/or fields.Integer, etc with the same result.

- I'm using Python 3.6.9 with these dependencies:
anyjson==0.3.3
apipkg==1.5
arrow==0.15.5
attrs==19.3.0
backports.functools-lru-cache==1.6.1
cassandra-driver==3.22.0
Cerberus==1.3.2
certifi==2019.11.28
cffi==1.13.2
chardet==3.0.4
click==7.1.1
execnet==1.7.1
Flask==1.1.1
Flask-Cors==3.0.8
funcsigs==1.0.2
futures==3.1.1
geomet==0.1.2
gevent==1.4.0
greenlet==0.4.13
gunicorn==20.0.4
idna==2.9
importlib-metadata==1.6.0
itsdangerous==1.1.0
Jinja2==2.11.1
jsonklog==0.15.0
MarkupSafe==1.1.1
marshmallow==3.5.1
neurolab==0.3.5
numpy==1.18.1
pluggy==0.13.1
py==1.8.1
pyaml==20.3.1
pymongo==3.10.1
pytest==3.3.0
pytest-forked==0.2
pytest-xdist==1.20.1
python-dateutil==2.8.1
PyYAML==5.3.1
readline==6.2.4.1
requests==2.23.0
six==1.14.0
urllib3==1.25.8
webargs==6.0.0
Werkzeug==1.0.0
zipp==3.1.0

- Here is an example of what I'm trying to do:
```
from flask import Flask
from webargs.flaskparser import parser, use_kwargs
from marshmallow import EXCLUDE, fields, Schema


app = Flask(__name__)


@app.errorhandler(422)
def custom_handler(error):
    errors = []
    if 'query' in error.data['messages']:
        for arg in error.data['messages']['query']:
            for item in error.data['messages']['query'][arg]:
                errors.append(item)
    return str(errors), 400


class test_schema(Schema):
    class Meta:
        unknown = EXCLUDE
        strict = True
    
    test_tup = fields.Tuple((fields.Integer(required=True), fields.Integer(required=True)), required=True)


@app.route('/test/', strict_slashes=False)
@parser.use_kwargs(test_schema, location='query')
def test_the_mallow(**kwargs):
    return "True"
```

- Finally, here are a couple example url's I've tried:
  localhost:2300/test/?test_tup=[0,0]
  localhost:2300/test/?test_tup=(0,0)
  localhost:2300/test/?test_tup=0,0



Discussion:
Looks like we need something like `DelimitedList`, but `DelimitedTuple` instead.
@Donovant would you like to investigate how to achieve this?

Basically taking inspiration from `DelimitedList`, perhaps factorizing what can be in a common class.
I'll note that if all elements are of the same type (as in this case), you could use `DelimitedList` and validate that it has a fixed length. The distinction between a list and tuple may or may not matter, depending on what you're doing with the results of loading.

I wouldn't mind trying to do this, but it will probably be a while before I could work on it. So if someone wants it, please feel free to open a PR.
My apologies for not responding faster! I would be interested in working on a fix for this as well but I'm not sure when I'll have time to do so.  
As for sirosen's comment, this may be possible but my actual use case for this was quite a bit more complex.  I just dumbed down the example.  That said, we ended up just using a String and a custom validator for the time being but it would be really nice to get this working as intended.

Resolving patch:
diff --git a/src/webargs/fields.py b/src/webargs/fields.py
--- a/src/webargs/fields.py
+++ b/src/webargs/fields.py
@@ -43,26 +43,24 @@ def __init__(self, nested, *args, **kwargs):
         super().__init__(nested, *args, **kwargs)
 
 
-class DelimitedList(ma.fields.List):
-    """A field which is similar to a List, but takes its input as a delimited
-    string (e.g. "foo,bar,baz").
+class DelimitedFieldMixin:
+    """
+    This is a mixin class for subclasses of ma.fields.List and ma.fields.Tuple
+    which split on a pre-specified delimiter. By default, the delimiter will be ","
 
-    Like List, it can be given a nested field type which it will use to
-    de/serialize each element of the list.
+    Because we want the MRO to reach this class before the List or Tuple class,
+    it must be listed first in the superclasses
 
-    :param Field cls_or_instance: A field class or instance.
-    :param str delimiter: Delimiter between values.
+    For example, a DelimitedList-like type can be defined like so:
+
+    >>> class MyDelimitedList(DelimitedFieldMixin, ma.fields.List):
+    >>>     pass
     """
 
-    default_error_messages = {"invalid": "Not a valid delimited list."}
     delimiter = ","
 
-    def __init__(self, cls_or_instance, *, delimiter=None, **kwargs):
-        self.delimiter = delimiter or self.delimiter
-        super().__init__(cls_or_instance, **kwargs)
-
     def _serialize(self, value, attr, obj):
-        # serializing will start with List serialization, so that we correctly
+        # serializing will start with parent-class serialization, so that we correctly
         # output lists of non-primitive types, e.g. DelimitedList(DateTime)
         return self.delimiter.join(
             format(each) for each in super()._serialize(value, attr, obj)
@@ -76,3 +74,45 @@ def _deserialize(self, value, attr, data, **kwargs):
             else:
                 raise self.make_error("invalid")
         return super()._deserialize(value.split(self.delimiter), attr, data, **kwargs)
+
+
+class DelimitedList(DelimitedFieldMixin, ma.fields.List):
+    """A field which is similar to a List, but takes its input as a delimited
+    string (e.g. "foo,bar,baz").
+
+    Like List, it can be given a nested field type which it will use to
+    de/serialize each element of the list.
+
+    :param Field cls_or_instance: A field class or instance.
+    :param str delimiter: Delimiter between values.
+    """
+
+    default_error_messages = {"invalid": "Not a valid delimited list."}
+    delimiter = ","
+
+    def __init__(self, cls_or_instance, *, delimiter=None, **kwargs):
+        self.delimiter = delimiter or self.delimiter
+        super().__init__(cls_or_instance, **kwargs)
+
+
+# DelimitedTuple can only be defined when using marshmallow3, when Tuple was
+# added
+if MARSHMALLOW_VERSION_INFO[0] >= 3:
+
+    class DelimitedTuple(DelimitedFieldMixin, ma.fields.Tuple):
+        """A field which is similar to a Tuple, but takes its input as a delimited
+        string (e.g. "foo,bar,baz").
+
+        Like Tuple, it can be given a tuple of nested field types which it will use to
+        de/serialize each element of the tuple.
+
+        :param Iterable[Field] tuple_fields: An iterable of field classes or instances.
+        :param str delimiter: Delimiter between values.
+        """
+
+        default_error_messages = {"invalid": "Not a valid delimited tuple."}
+        delimiter = ","
+
+        def __init__(self, tuple_fields, *, delimiter=None, **kwargs):
+            self.delimiter = delimiter or self.delimiter
+            super().__init__(tuple_fields, **kwargs)


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -851,7 +851,53 @@ def test_delimited_list_default_delimiter(web_request, parser):
     assert data["ids"] == "1,2,3"
 
 
-def test_delimited_list_as_string_v2(web_request, parser):
+@pytest.mark.skipif(
+    MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3"
+)
+def test_delimited_tuple_default_delimiter(web_request, parser):
+    """
+    Test load and dump from DelimitedTuple, including the use of a datetime
+    type (similar to a DelimitedList test below) which confirms that we aren't
+    relying on __str__, but are properly de/serializing the included fields
+    """
+    web_request.json = {"ids": "1,2,2020-05-04"}
+    schema_cls = dict2schema(
+        {
+            "ids": fields.DelimitedTuple(
+                (fields.Int, fields.Int, fields.DateTime(format="%Y-%m-%d"))
+            )
+        }
+    )
+    schema = schema_cls()
+
+    parsed = parser.parse(schema, web_request)
+    assert parsed["ids"] == (1, 2, datetime.datetime(2020, 5, 4))
+
+    data = schema.dump(parsed)
+    assert data["ids"] == "1,2,2020-05-04"
+
+
+@pytest.mark.skipif(
+    MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3"
+)
+def test_delimited_tuple_incorrect_arity(web_request, parser):
+    web_request.json = {"ids": "1,2"}
+    schema_cls = dict2schema(
+        {"ids": fields.DelimitedTuple((fields.Int, fields.Int, fields.Int))}
+    )
+    schema = schema_cls()
+
+    with pytest.raises(ValidationError):
+        parser.parse(schema, web_request)
+
+
+def test_delimited_list_with_datetime(web_request, parser):
+    """
+    Test that DelimitedList(DateTime(format=...)) correctly parses and dumps
+    dates to and from strings -- indicates that we're doing proper
+    serialization of values in dump() and not just relying on __str__ producing
+    correct results
+    """
     web_request.json = {"dates": "2018-11-01,2018-11-02"}
     schema_cls = dict2schema(
         {"dates": fields.DelimitedList(fields.DateTime(format="%Y-%m-%d"))}
@@ -877,6 +923,27 @@ def test_delimited_list_custom_delimiter(web_request, parser):
     parsed = parser.parse(schema, web_request)
     assert parsed["ids"] == [1, 2, 3]
 
+    dumped = schema.dump(parsed)
+    data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped
+    assert data["ids"] == "1|2|3"
+
+
+@pytest.mark.skipif(
+    MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3"
+)
+def test_delimited_tuple_custom_delimiter(web_request, parser):
+    web_request.json = {"ids": "1|2"}
+    schema_cls = dict2schema(
+        {"ids": fields.DelimitedTuple((fields.Int, fields.Int), delimiter="|")}
+    )
+    schema = schema_cls()
+
+    parsed = parser.parse(schema, web_request)
+    assert parsed["ids"] == (1, 2)
+
+    data = schema.dump(parsed)
+    assert data["ids"] == "1|2"
+
 
 def test_delimited_list_load_list_errors(web_request, parser):
     web_request.json = {"ids": [1, 2, 3]}
@@ -891,6 +958,22 @@ def test_delimited_list_load_list_errors(web_request, parser):
     assert errors["ids"] == ["Not a valid delimited list."]
 
 
+@pytest.mark.skipif(
+    MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3"
+)
+def test_delimited_tuple_load_list_errors(web_request, parser):
+    web_request.json = {"ids": [1, 2]}
+    schema_cls = dict2schema({"ids": fields.DelimitedTuple((fields.Int, fields.Int))})
+    schema = schema_cls()
+
+    with pytest.raises(ValidationError) as excinfo:
+        parser.parse(schema, web_request)
+    exc = excinfo.value
+    assert isinstance(exc, ValidationError)
+    errors = exc.args[0]
+    assert errors["ids"] == ["Not a valid delimited tuple."]
+
+
 # Regresion test for https://github.com/marshmallow-code/webargs/issues/149
 def test_delimited_list_passed_invalid_type(web_request, parser):
     web_request.json = {"ids": 1}
@@ -902,6 +985,19 @@ def test_delimited_list_passed_invalid_type(web_request, parser):
     assert excinfo.value.messages == {"json": {"ids": ["Not a valid delimited list."]}}
 
 
+@pytest.mark.skipif(
+    MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3"
+)
+def test_delimited_tuple_passed_invalid_type(web_request, parser):
+    web_request.json = {"ids": 1}
+    schema_cls = dict2schema({"ids": fields.DelimitedTuple((fields.Int,))})
+    schema = schema_cls()
+
+    with pytest.raises(ValidationError) as excinfo:
+        parser.parse(schema, web_request)
+    assert excinfo.value.messages == {"json": {"ids": ["Not a valid delimited tuple."]}}
+
+
 def test_missing_list_argument_not_in_parsed_result(web_request, parser):
     # arg missing in request
     web_request.json = {}