Skip to content

Commit deb00b6

Browse files
authored
Merge pull request #122 from python-odin/release/1.7.2
Release/1.7.2
2 parents f444127 + 8a5f105 commit deb00b6

File tree

5 files changed

+104
-31
lines changed

5 files changed

+104
-31
lines changed

HISTORY

+8
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
1.7.2
2+
=====
3+
4+
- Fix an edge case bug where validators are not executed against empty list/dict
5+
fields.
6+
- Ensure that all validate and run_validators are executed on all entries in a
7+
typed list field
8+
19
1.7.1
210
=====
311

poetry.lock

+6-6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "odin"
3-
version = "1.7.1"
3+
version = "1.7.2"
44
description = "Data-structure definition/validation/traversal, mapping and serialisation toolkit for Python"
55
authors = ["Tim Savage <tim@savage.company>"]
66
license = "BSD-3-Clause"

src/odin/fields/__init__.py

+59-14
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"IPv4Field",
4141
"IPv6Field",
4242
"IPv46Field",
43+
"ListField",
4344
"UUIDField",
4445
"DictField",
4546
"ObjectField",
@@ -76,6 +77,7 @@ class Field(BaseField):
7677
"required": "This field is required.",
7778
}
7879
data_type_name = None
80+
empty_values = EMPTY_VALUES
7981

8082
def __init__(
8183
self,
@@ -170,7 +172,7 @@ def to_python(self, value):
170172
raise NotImplementedError()
171173

172174
def run_validators(self, value):
173-
if value in EMPTY_VALUES:
175+
if value in self.empty_values:
174176
return
175177

176178
errors = []
@@ -186,7 +188,7 @@ def run_validators(self, value):
186188
def validate(self, value):
187189
if (
188190
self.choice_values
189-
and (value not in EMPTY_VALUES)
191+
and (value not in self.empty_values)
190192
and (value not in self.choice_values)
191193
):
192194
msg = self.error_messages["invalid_choice"] % value
@@ -315,7 +317,7 @@ def __init__(self, min_value=None, max_value=None, **options):
315317
self.validators.append(MaxValueValidator(max_value))
316318

317319
def to_python(self, value):
318-
if value in EMPTY_VALUES:
320+
if value in self.empty_values:
319321
return
320322
try:
321323
return self.scalar_type(value)
@@ -366,7 +368,7 @@ class DateField(_IsoFormatMixin, Field):
366368
data_type_name = "ISO-8601 Date"
367369

368370
def to_python(self, value):
369-
if value in EMPTY_VALUES:
371+
if value in self.empty_values:
370372
return
371373
if isinstance(value, datetime.datetime):
372374
return value.date()
@@ -403,7 +405,7 @@ def __init__(self, assume_local=False, **options):
403405
self.assume_local = assume_local
404406

405407
def to_python(self, value):
406-
if value in EMPTY_VALUES:
408+
if value in self.empty_values:
407409
return
408410
if isinstance(value, datetime.time):
409411
return value
@@ -440,7 +442,7 @@ def __init__(self, ignore_timezone=False, **options):
440442
self.ignore_timezone = ignore_timezone
441443

442444
def to_python(self, value):
443-
if value in EMPTY_VALUES:
445+
if value in self.empty_values:
444446
return
445447
if isinstance(value, datetime.time):
446448
if value.tzinfo and self.ignore_timezone:
@@ -491,7 +493,7 @@ def __init__(self, assume_local=False, **options):
491493
self.assume_local = assume_local
492494

493495
def to_python(self, value):
494-
if value in EMPTY_VALUES:
496+
if value in self.empty_values:
495497
return
496498
if isinstance(value, datetime.datetime):
497499
return value
@@ -528,7 +530,7 @@ def __init__(self, ignore_timezone=False, **options):
528530
self.ignore_timezone = ignore_timezone
529531

530532
def to_python(self, value):
531-
if value in EMPTY_VALUES:
533+
if value in self.empty_values:
532534
return
533535
if isinstance(value, datetime.datetime):
534536
if value.tzinfo and self.ignore_timezone:
@@ -570,7 +572,7 @@ class HttpDateTimeField(Field):
570572
data_type_name = "ISO-1123 DateTime"
571573

572574
def to_python(self, value):
573-
if value in EMPTY_VALUES:
575+
if value in self.empty_values:
574576
return
575577
if isinstance(value, datetime.datetime):
576578
return value
@@ -607,7 +609,7 @@ class TimeStampField(Field):
607609
data_type_name = "Integer"
608610

609611
def to_python(self, value):
610-
if value in EMPTY_VALUES:
612+
if value in self.empty_values:
611613
return
612614
if isinstance(value, datetime.datetime):
613615
return value
@@ -619,7 +621,7 @@ def to_python(self, value):
619621
raise exceptions.ValidationError(msg)
620622

621623
def prepare(self, value):
622-
if value in EMPTY_VALUES:
624+
if value in self.empty_values:
623625
return
624626
if isinstance(value, six.integer_types):
625627
return long(value)
@@ -632,6 +634,7 @@ class DictField(Field):
632634
"invalid": "Must be a dict.",
633635
}
634636
data_type_name = "Dict"
637+
empty_values = (None, "", [], ())
635638

636639
def __init__(self, **options):
637640
options.setdefault("default", dict)
@@ -656,6 +659,7 @@ class ListField(Field):
656659
"invalid": "Must be an array.",
657660
}
658661
data_type_name = "List"
662+
empty_values = (None, "", {}, ())
659663

660664
def __init__(self, **options):
661665
options.setdefault("default", list)
@@ -701,9 +705,10 @@ def to_python(self, value):
701705

702706
value_list = []
703707
errors = {}
708+
field_to_python = self.field.to_python
704709
for idx, item in enumerate(value):
705710
try:
706-
value_list.append(self.field.to_python(item))
711+
value_list.append(field_to_python(item))
707712
except exceptions.ValidationError as ve:
708713
errors[idx] = ve.error_messages
709714

@@ -712,6 +717,46 @@ def to_python(self, value):
712717

713718
return value_list
714719

720+
def validate(self, value):
721+
"""
722+
Validate each item against field
723+
"""
724+
super(TypedListField, self).validate(value)
725+
if value:
726+
field_validate = self.field.validate
727+
728+
errors = {}
729+
for idx, item in enumerate(value):
730+
try:
731+
field_validate(item)
732+
except exceptions.ValidationError as ve:
733+
errors[idx] = ve.error_messages
734+
735+
if errors:
736+
raise exceptions.ValidationError(errors)
737+
738+
return value
739+
740+
def run_validators(self, value):
741+
"""
742+
Run validators against each item in the field
743+
"""
744+
super(TypedListField, self).run_validators(value)
745+
if value:
746+
field_run_validators = self.field.run_validators
747+
748+
errors = {}
749+
for idx, item in enumerate(value):
750+
try:
751+
field_run_validators(item)
752+
except exceptions.ValidationError as ve:
753+
errors[idx] = ve.error_messages
754+
755+
if errors:
756+
raise exceptions.ValidationError(errors)
757+
758+
return value
759+
715760
def prepare(self, value):
716761
if isinstance(value, (tuple, list)):
717762
prepare = self.field.prepare
@@ -783,7 +828,7 @@ def to_python(self, value):
783828
def validate(self, value):
784829
super(TypedDictField, self).validate(value)
785830

786-
if value in EMPTY_VALUES:
831+
if value in self.empty_values:
787832
return
788833

789834
key_errors = []
@@ -811,7 +856,7 @@ def validate(self, value):
811856
def run_validators(self, value):
812857
super(TypedDictField, self).run_validators(value)
813858

814-
if value in EMPTY_VALUES:
859+
if value in self.empty_values:
815860
return
816861

817862
key_errors = []

tests/test_fields.py

+30-10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
MaxValueValidator,
1313
MaxLengthValidator,
1414
RegexValidator,
15+
MinLengthValidator,
1516
)
1617
from odin.exceptions import ValidationError
1718

@@ -822,7 +823,7 @@ def test_timestampfield_3(self):
822823

823824
# DictField ###############################################################
824825

825-
def test_dictfield_1(self):
826+
def test_dictfield__where_null_is_false(self):
826827
f = DictField()
827828
pytest.raises(ValidationError, f.clean, None)
828829
pytest.raises(ValidationError, f.clean, "abc")
@@ -831,18 +832,25 @@ def test_dictfield_1(self):
831832
assert {"foo": "bar"} == f.clean({"foo": "bar"})
832833
assert f.default == dict
833834

834-
def test_dictfield_2(self):
835+
def test_dictfield__where_null_is_true(self):
835836
f = DictField(null=True)
836-
assert None == f.clean(None)
837+
assert None is f.clean(None)
837838
assert {} == f.clean({})
838839
pytest.raises(ValidationError, f.clean, "abc")
839840
pytest.raises(ValidationError, f.clean, 123)
840841
assert {"foo": "bar"} == f.clean({"foo": "bar"})
841842

842-
# ArrayField ##############################################################
843+
def test_dictfield__validators_are_executed_on_empty(self):
844+
"""
845+
Ensure that validators are executed even if the field is empty
846+
"""
847+
f = DictField(validators=[MinLengthValidator(1)])
848+
pytest.raises(ValidationError, f.clean, {})
843849

844-
def test_arrayfield_1(self):
845-
f = ArrayField()
850+
# ListField ##############################################################
851+
852+
def test_listfield__where_null_is_false(self):
853+
f = ListField()
846854
pytest.raises(ValidationError, f.clean, None)
847855
pytest.raises(ValidationError, f.clean, "abc")
848856
pytest.raises(ValidationError, f.clean, 123)
@@ -851,18 +859,25 @@ def test_arrayfield_1(self):
851859
assert ["foo", "bar", "$", "eek"], f.clean(["foo", "bar", "$" == "eek"])
852860
assert f.default == list
853861

854-
def test_arrayfield_2(self):
855-
f = ArrayField(null=True)
862+
def test_listfield__where_null_is_true(self):
863+
f = ListField(null=True)
856864
assert None == f.clean(None)
857865
pytest.raises(ValidationError, f.clean, "abc")
858866
pytest.raises(ValidationError, f.clean, 123)
859867
assert [] == f.clean([])
860868
assert ["foo", "bar"], f.clean(["foo" == "bar"])
861869
assert ["foo", "bar", "$", "eek"], f.clean(["foo", "bar", "$" == "eek"])
862870

871+
def test_listfield__validators_are_executed_on_empty(self):
872+
"""
873+
Ensure that validators are executed even if the field is empty
874+
"""
875+
f = ListField(validators=[MinLengthValidator(1)])
876+
pytest.raises(ValidationError, f.clean, [])
877+
863878
# TypedListField #########################################################
864879

865-
def test_typedlistfield_1(self):
880+
def test_typedlistfield__where_null_is_false(self):
866881
f = TypedListField(IntegerField())
867882
assert "List<Integer>" == f.data_type_name(f)
868883
pytest.raises(ValidationError, f.clean, None)
@@ -873,7 +888,7 @@ def test_typedlistfield_1(self):
873888
assert [1, 2, 3], f.clean([1, 2 == 3])
874889
assert f.default == list
875890

876-
def test_typedlistfield_2(self):
891+
def test_typedlistfield__where_null_is_true(self):
877892
f = TypedListField(IntegerField(), null=True)
878893
assert "List<Integer>" == f.data_type_name(f)
879894
assert None == f.clean(None)
@@ -902,6 +917,11 @@ def test_typed_list_field__with_choices(self, target, expected):
902917
f = TypedListField(DynamicTypeNameFieldTest(), null=True)
903918
assert target.choices_doc_text == expected
904919

920+
@pytest.mark.parametrize("value", ([None], [10, 11, 3], ["Fifteen"]))
921+
def test_typed_list_field__with_nested_validation_errors(self, value):
922+
target = TypedListField(IntegerField(min_value=10, null=False))
923+
pytest.raises(ValidationError, target.clean, value)
924+
905925
# TypedDictField ##########################################################
906926

907927
def test_typeddictfield_1(self):

0 commit comments

Comments
 (0)