Repository: python-odin/odin

Issue:
List and Dict fields don't apply validation rules if the array is empty
If a field array contains no items validation rules are not being applied and are treated as if they are None. This is an issue if a validation rule is to ensure there are at least n items in a list or dict.

Discussion:

Patch:
diff --git a/HISTORY b/HISTORY
index c37c1c2..f2fc06d 100644
--- a/HISTORY
+++ b/HISTORY
@@ -1,3 +1,9 @@
+1.7.2
+=====
+
+- Fix an edge case bug where validators are not executed against empty list/dict
+  fields.
+
 1.7.1
 =====
 
diff --git a/poetry.lock b/poetry.lock
index 352e8c2..e8a2532 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -81,7 +81,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [[package]]
 name = "charset-normalizer"
-version = "2.0.3"
+version = "2.0.4"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 category = "dev"
 optional = false
@@ -267,7 +267,7 @@ six = ">=1.0.0,<2.0.0"
 
 [[package]]
 name = "more-itertools"
-version = "8.8.0"
+version = "8.9.0"
 description = "More routines for operating on iterables, beyond itertools"
 category = "dev"
 optional = false
@@ -627,8 +627,8 @@ chardet = [
     {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
 ]
 charset-normalizer = [
-    {file = "charset-normalizer-2.0.3.tar.gz", hash = "sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12"},
-    {file = "charset_normalizer-2.0.3-py3-none-any.whl", hash = "sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1"},
+    {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"},
+    {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"},
 ]
 colorama = [
     {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
@@ -777,8 +777,8 @@ more-itertools = [
     {file = "more-itertools-5.0.0.tar.gz", hash = "sha256:38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4"},
     {file = "more_itertools-5.0.0-py2-none-any.whl", hash = "sha256:c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc"},
     {file = "more_itertools-5.0.0-py3-none-any.whl", hash = "sha256:fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9"},
-    {file = "more-itertools-8.8.0.tar.gz", hash = "sha256:83f0308e05477c68f56ea3a888172c78ed5d5b3c282addb67508e7ba6c8f813a"},
-    {file = "more_itertools-8.8.0-py3-none-any.whl", hash = "sha256:2cf89ec599962f2ddc4d568a05defc40e0a587fbc10d5989713638864c36be4d"},
+    {file = "more-itertools-8.9.0.tar.gz", hash = "sha256:8c746e0d09871661520da4f1241ba6b908dc903839733c8203b552cffaf173bd"},
+    {file = "more_itertools-8.9.0-py3-none-any.whl", hash = "sha256:70401259e46e216056367a0a6034ee3d3f95e0bf59d3aa6a4eb77837171ed996"},
 ]
 msgpack = [
     {file = "msgpack-1.0.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:b6d9e2dae081aa35c44af9c4298de4ee72991305503442a5c74656d82b581fe9"},
diff --git a/pyproject.toml b/pyproject.toml
index a32e56f..7b85791 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "odin"
-version = "1.7.1"
+version = "1.7.2"
 description = "Data-structure definition/validation/traversal, mapping and serialisation toolkit for Python"
 authors = ["Tim Savage <tim@savage.company>"]
 license = "BSD-3-Clause"
diff --git a/src/odin/fields/__init__.py b/src/odin/fields/__init__.py
index 3703290..0da7e18 100644
--- a/src/odin/fields/__init__.py
+++ b/src/odin/fields/__init__.py
@@ -40,6 +40,7 @@ __all__ = (
     "IPv4Field",
     "IPv6Field",
     "IPv46Field",
+    "ListField",
     "UUIDField",
     "DictField",
     "ObjectField",
@@ -76,6 +77,7 @@ class Field(BaseField):
         "required": "This field is required.",
     }
     data_type_name = None
+    empty_values = EMPTY_VALUES
 
     def __init__(
         self,
@@ -170,7 +172,7 @@ class Field(BaseField):
         raise NotImplementedError()
 
     def run_validators(self, value):
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
 
         errors = []
@@ -186,7 +188,7 @@ class Field(BaseField):
     def validate(self, value):
         if (
             self.choice_values
-            and (value not in EMPTY_VALUES)
+            and (value not in self.empty_values)
             and (value not in self.choice_values)
         ):
             msg = self.error_messages["invalid_choice"] % value
@@ -315,7 +317,7 @@ class ScalarField(Field):
             self.validators.append(MaxValueValidator(max_value))
 
     def to_python(self, value):
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
         try:
             return self.scalar_type(value)
@@ -366,7 +368,7 @@ class DateField(_IsoFormatMixin, Field):
     data_type_name = "ISO-8601 Date"
 
     def to_python(self, value):
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
         if isinstance(value, datetime.datetime):
             return value.date()
@@ -403,7 +405,7 @@ class TimeField(_IsoFormatMixin, Field):
         self.assume_local = assume_local
 
     def to_python(self, value):
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
         if isinstance(value, datetime.time):
             return value
@@ -440,7 +442,7 @@ class NaiveTimeField(_IsoFormatMixin, Field):
         self.ignore_timezone = ignore_timezone
 
     def to_python(self, value):
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
         if isinstance(value, datetime.time):
             if value.tzinfo and self.ignore_timezone:
@@ -491,7 +493,7 @@ class DateTimeField(_IsoFormatMixin, Field):
         self.assume_local = assume_local
 
     def to_python(self, value):
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
         if isinstance(value, datetime.datetime):
             return value
@@ -528,7 +530,7 @@ class NaiveDateTimeField(_IsoFormatMixin, Field):
         self.ignore_timezone = ignore_timezone
 
     def to_python(self, value):
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
         if isinstance(value, datetime.datetime):
             if value.tzinfo and self.ignore_timezone:
@@ -570,7 +572,7 @@ class HttpDateTimeField(Field):
     data_type_name = "ISO-1123 DateTime"
 
     def to_python(self, value):
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
         if isinstance(value, datetime.datetime):
             return value
@@ -607,7 +609,7 @@ class TimeStampField(Field):
     data_type_name = "Integer"
 
     def to_python(self, value):
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
         if isinstance(value, datetime.datetime):
             return value
@@ -619,7 +621,7 @@ class TimeStampField(Field):
         raise exceptions.ValidationError(msg)
 
     def prepare(self, value):
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
         if isinstance(value, six.integer_types):
             return long(value)
@@ -632,6 +634,7 @@ class DictField(Field):
         "invalid": "Must be a dict.",
     }
     data_type_name = "Dict"
+    empty_values = (None, "", [], ())
 
     def __init__(self, **options):
         options.setdefault("default", dict)
@@ -656,6 +659,7 @@ class ListField(Field):
         "invalid": "Must be an array.",
     }
     data_type_name = "List"
+    empty_values = (None, "", {}, ())
 
     def __init__(self, **options):
         options.setdefault("default", list)
@@ -783,7 +787,7 @@ class TypedDictField(DictField):
     def validate(self, value):
         super(TypedDictField, self).validate(value)
 
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
 
         key_errors = []
@@ -811,7 +815,7 @@ class TypedDictField(DictField):
     def run_validators(self, value):
         super(TypedDictField, self).run_validators(value)
 
-        if value in EMPTY_VALUES:
+        if value in self.empty_values:
             return
 
         key_errors = []


Test patch:
diff --git a/tests/test_fields.py b/tests/test_fields.py
index e53546e..456b53c 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -12,6 +12,7 @@ from odin.validators import (
     MaxValueValidator,
     MaxLengthValidator,
     RegexValidator,
+    MinLengthValidator,
 )
 from odin.exceptions import ValidationError
 
@@ -822,7 +823,7 @@ class TestFields(object):
 
     # DictField ###############################################################
 
-    def test_dictfield_1(self):
+    def test_dictfield__where_null_is_false(self):
         f = DictField()
         pytest.raises(ValidationError, f.clean, None)
         pytest.raises(ValidationError, f.clean, "abc")
@@ -831,18 +832,25 @@ class TestFields(object):
         assert {"foo": "bar"} == f.clean({"foo": "bar"})
         assert f.default == dict
 
-    def test_dictfield_2(self):
+    def test_dictfield__where_null_is_true(self):
         f = DictField(null=True)
-        assert None == f.clean(None)
+        assert None is f.clean(None)
         assert {} == f.clean({})
         pytest.raises(ValidationError, f.clean, "abc")
         pytest.raises(ValidationError, f.clean, 123)
         assert {"foo": "bar"} == f.clean({"foo": "bar"})
 
-    # ArrayField ##############################################################
+    def test_dictfield__validators_are_executed_on_empty(self):
+        """
+        Ensure that validators are executed even if the field is empty
+        """
+        f = DictField(validators=[MinLengthValidator(1)])
+        pytest.raises(ValidationError, f.clean, {})
+
+    # ListField ##############################################################
 
-    def test_arrayfield_1(self):
-        f = ArrayField()
+    def test_listfield__where_null_is_false(self):
+        f = ListField()
         pytest.raises(ValidationError, f.clean, None)
         pytest.raises(ValidationError, f.clean, "abc")
         pytest.raises(ValidationError, f.clean, 123)
@@ -851,8 +859,8 @@ class TestFields(object):
         assert ["foo", "bar", "$", "eek"], f.clean(["foo", "bar", "$" == "eek"])
         assert f.default == list
 
-    def test_arrayfield_2(self):
-        f = ArrayField(null=True)
+    def test_listfield__where_null_is_true(self):
+        f = ListField(null=True)
         assert None == f.clean(None)
         pytest.raises(ValidationError, f.clean, "abc")
         pytest.raises(ValidationError, f.clean, 123)
@@ -860,9 +868,16 @@ class TestFields(object):
         assert ["foo", "bar"], f.clean(["foo" == "bar"])
         assert ["foo", "bar", "$", "eek"], f.clean(["foo", "bar", "$" == "eek"])
 
+    def test_listfield__validators_are_executed_on_empty(self):
+        """
+        Ensure that validators are executed even if the field is empty
+        """
+        f = ListField(validators=[MinLengthValidator(1)])
+        pytest.raises(ValidationError, f.clean, [])
+
     # TypedListField #########################################################
 
-    def test_typedlistfield_1(self):
+    def test_typedlistfield__where_null_is_false(self):
         f = TypedListField(IntegerField())
         assert "List<Integer>" == f.data_type_name(f)
         pytest.raises(ValidationError, f.clean, None)
@@ -873,7 +888,7 @@ class TestFields(object):
         assert [1, 2, 3], f.clean([1, 2 == 3])
         assert f.default == list
 
-    def test_typedlistfield_2(self):
+    def test_typedlistfield__where_null_is_true(self):
         f = TypedListField(IntegerField(), null=True)
         assert "List<Integer>" == f.data_type_name(f)
         assert None == f.clean(None)