Skip to content

Commit 96dc1a6

Browse files
authored
Merge pull request #509 from sirosen/add-delimited-tuple
Add DelimitedTuple field (ma3 only)
2 parents 2c85a33 + 2a63e91 commit 96dc1a6

File tree

3 files changed

+161
-14
lines changed

3 files changed

+161
-14
lines changed

CHANGELOG.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
Changelog
22
---------
33

4+
6.1.0 (Unreleased)
5+
******************
6+
7+
Features:
8+
9+
* Add ``fields.DelimitedTuple`` when using marshmallow 3. This behaves as a
10+
combination of ``fields.DelimitedList`` and ``marshmallow.fields.Tuple``. It
11+
takes an iterable of fields, plus a delimiter (defaults to ``,``), and parses
12+
delimiter-separated strings into tuples.
13+
14+
415
6.0.0 (2020-02-27)
516
******************
617

src/webargs/fields.py

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,26 +43,24 @@ def __init__(self, nested, *args, **kwargs):
4343
super().__init__(nested, *args, **kwargs)
4444

4545

46-
class DelimitedList(ma.fields.List):
47-
"""A field which is similar to a List, but takes its input as a delimited
48-
string (e.g. "foo,bar,baz").
46+
class DelimitedFieldMixin:
47+
"""
48+
This is a mixin class for subclasses of ma.fields.List and ma.fields.Tuple
49+
which split on a pre-specified delimiter. By default, the delimiter will be ","
4950
50-
Like List, it can be given a nested field type which it will use to
51-
de/serialize each element of the list.
51+
Because we want the MRO to reach this class before the List or Tuple class,
52+
it must be listed first in the superclasses
5253
53-
:param Field cls_or_instance: A field class or instance.
54-
:param str delimiter: Delimiter between values.
54+
For example, a DelimitedList-like type can be defined like so:
55+
56+
>>> class MyDelimitedList(DelimitedFieldMixin, ma.fields.List):
57+
>>> pass
5558
"""
5659

57-
default_error_messages = {"invalid": "Not a valid delimited list."}
5860
delimiter = ","
5961

60-
def __init__(self, cls_or_instance, *, delimiter=None, **kwargs):
61-
self.delimiter = delimiter or self.delimiter
62-
super().__init__(cls_or_instance, **kwargs)
63-
6462
def _serialize(self, value, attr, obj):
65-
# serializing will start with List serialization, so that we correctly
63+
# serializing will start with parent-class serialization, so that we correctly
6664
# output lists of non-primitive types, e.g. DelimitedList(DateTime)
6765
return self.delimiter.join(
6866
format(each) for each in super()._serialize(value, attr, obj)
@@ -76,3 +74,45 @@ def _deserialize(self, value, attr, data, **kwargs):
7674
else:
7775
raise self.make_error("invalid")
7876
return super()._deserialize(value.split(self.delimiter), attr, data, **kwargs)
77+
78+
79+
class DelimitedList(DelimitedFieldMixin, ma.fields.List):
80+
"""A field which is similar to a List, but takes its input as a delimited
81+
string (e.g. "foo,bar,baz").
82+
83+
Like List, it can be given a nested field type which it will use to
84+
de/serialize each element of the list.
85+
86+
:param Field cls_or_instance: A field class or instance.
87+
:param str delimiter: Delimiter between values.
88+
"""
89+
90+
default_error_messages = {"invalid": "Not a valid delimited list."}
91+
delimiter = ","
92+
93+
def __init__(self, cls_or_instance, *, delimiter=None, **kwargs):
94+
self.delimiter = delimiter or self.delimiter
95+
super().__init__(cls_or_instance, **kwargs)
96+
97+
98+
# DelimitedTuple can only be defined when using marshmallow3, when Tuple was
99+
# added
100+
if MARSHMALLOW_VERSION_INFO[0] >= 3:
101+
102+
class DelimitedTuple(DelimitedFieldMixin, ma.fields.Tuple):
103+
"""A field which is similar to a Tuple, but takes its input as a delimited
104+
string (e.g. "foo,bar,baz").
105+
106+
Like Tuple, it can be given a tuple of nested field types which it will use to
107+
de/serialize each element of the tuple.
108+
109+
:param Iterable[Field] tuple_fields: An iterable of field classes or instances.
110+
:param str delimiter: Delimiter between values.
111+
"""
112+
113+
default_error_messages = {"invalid": "Not a valid delimited tuple."}
114+
delimiter = ","
115+
116+
def __init__(self, tuple_fields, *, delimiter=None, **kwargs):
117+
self.delimiter = delimiter or self.delimiter
118+
super().__init__(tuple_fields, **kwargs)

tests/test_core.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -851,7 +851,53 @@ def test_delimited_list_default_delimiter(web_request, parser):
851851
assert data["ids"] == "1,2,3"
852852

853853

854-
def test_delimited_list_as_string_v2(web_request, parser):
854+
@pytest.mark.skipif(
855+
MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3"
856+
)
857+
def test_delimited_tuple_default_delimiter(web_request, parser):
858+
"""
859+
Test load and dump from DelimitedTuple, including the use of a datetime
860+
type (similar to a DelimitedList test below) which confirms that we aren't
861+
relying on __str__, but are properly de/serializing the included fields
862+
"""
863+
web_request.json = {"ids": "1,2,2020-05-04"}
864+
schema_cls = dict2schema(
865+
{
866+
"ids": fields.DelimitedTuple(
867+
(fields.Int, fields.Int, fields.DateTime(format="%Y-%m-%d"))
868+
)
869+
}
870+
)
871+
schema = schema_cls()
872+
873+
parsed = parser.parse(schema, web_request)
874+
assert parsed["ids"] == (1, 2, datetime.datetime(2020, 5, 4))
875+
876+
data = schema.dump(parsed)
877+
assert data["ids"] == "1,2,2020-05-04"
878+
879+
880+
@pytest.mark.skipif(
881+
MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3"
882+
)
883+
def test_delimited_tuple_incorrect_arity(web_request, parser):
884+
web_request.json = {"ids": "1,2"}
885+
schema_cls = dict2schema(
886+
{"ids": fields.DelimitedTuple((fields.Int, fields.Int, fields.Int))}
887+
)
888+
schema = schema_cls()
889+
890+
with pytest.raises(ValidationError):
891+
parser.parse(schema, web_request)
892+
893+
894+
def test_delimited_list_with_datetime(web_request, parser):
895+
"""
896+
Test that DelimitedList(DateTime(format=...)) correctly parses and dumps
897+
dates to and from strings -- indicates that we're doing proper
898+
serialization of values in dump() and not just relying on __str__ producing
899+
correct results
900+
"""
855901
web_request.json = {"dates": "2018-11-01,2018-11-02"}
856902
schema_cls = dict2schema(
857903
{"dates": fields.DelimitedList(fields.DateTime(format="%Y-%m-%d"))}
@@ -877,6 +923,27 @@ def test_delimited_list_custom_delimiter(web_request, parser):
877923
parsed = parser.parse(schema, web_request)
878924
assert parsed["ids"] == [1, 2, 3]
879925

926+
dumped = schema.dump(parsed)
927+
data = dumped.data if MARSHMALLOW_VERSION_INFO[0] < 3 else dumped
928+
assert data["ids"] == "1|2|3"
929+
930+
931+
@pytest.mark.skipif(
932+
MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3"
933+
)
934+
def test_delimited_tuple_custom_delimiter(web_request, parser):
935+
web_request.json = {"ids": "1|2"}
936+
schema_cls = dict2schema(
937+
{"ids": fields.DelimitedTuple((fields.Int, fields.Int), delimiter="|")}
938+
)
939+
schema = schema_cls()
940+
941+
parsed = parser.parse(schema, web_request)
942+
assert parsed["ids"] == (1, 2)
943+
944+
data = schema.dump(parsed)
945+
assert data["ids"] == "1|2"
946+
880947

881948
def test_delimited_list_load_list_errors(web_request, parser):
882949
web_request.json = {"ids": [1, 2, 3]}
@@ -891,6 +958,22 @@ def test_delimited_list_load_list_errors(web_request, parser):
891958
assert errors["ids"] == ["Not a valid delimited list."]
892959

893960

961+
@pytest.mark.skipif(
962+
MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3"
963+
)
964+
def test_delimited_tuple_load_list_errors(web_request, parser):
965+
web_request.json = {"ids": [1, 2]}
966+
schema_cls = dict2schema({"ids": fields.DelimitedTuple((fields.Int, fields.Int))})
967+
schema = schema_cls()
968+
969+
with pytest.raises(ValidationError) as excinfo:
970+
parser.parse(schema, web_request)
971+
exc = excinfo.value
972+
assert isinstance(exc, ValidationError)
973+
errors = exc.args[0]
974+
assert errors["ids"] == ["Not a valid delimited tuple."]
975+
976+
894977
# Regresion test for https://github.com/marshmallow-code/webargs/issues/149
895978
def test_delimited_list_passed_invalid_type(web_request, parser):
896979
web_request.json = {"ids": 1}
@@ -902,6 +985,19 @@ def test_delimited_list_passed_invalid_type(web_request, parser):
902985
assert excinfo.value.messages == {"json": {"ids": ["Not a valid delimited list."]}}
903986

904987

988+
@pytest.mark.skipif(
989+
MARSHMALLOW_VERSION_INFO[0] < 3, reason="fields.Tuple added in marshmallow3"
990+
)
991+
def test_delimited_tuple_passed_invalid_type(web_request, parser):
992+
web_request.json = {"ids": 1}
993+
schema_cls = dict2schema({"ids": fields.DelimitedTuple((fields.Int,))})
994+
schema = schema_cls()
995+
996+
with pytest.raises(ValidationError) as excinfo:
997+
parser.parse(schema, web_request)
998+
assert excinfo.value.messages == {"json": {"ids": ["Not a valid delimited tuple."]}}
999+
1000+
9051001
def test_missing_list_argument_not_in_parsed_result(web_request, parser):
9061002
# arg missing in request
9071003
web_request.json = {}

0 commit comments

Comments
 (0)