Add max_depth constraint to ListField and DictField

This commit is contained in:
Mahdi 2025-12-30 17:39:21 +03:30 committed by Mahdi Rahimi
parent 48fe0750b5
commit acc3fd726a
5 changed files with 386 additions and 5 deletions

View File

@ -471,12 +471,13 @@ Requires either the `Pillow` package or `PIL` package. The `Pillow` package is
A field class that validates a list of objects.
**Signature**: `ListField(child=<A_FIELD_INSTANCE>, allow_empty=True, min_length=None, max_length=None)`
**Signature**: `ListField(child=<A_FIELD_INSTANCE>, allow_empty=True, min_length=None, max_length=None, max_depth=None)`
* `child` - A field instance that should be used for validating the objects in the list. If this argument is not provided then objects in the list will not be validated.
* `allow_empty` - Designates if empty lists are allowed.
* `min_length` - Validates that the list contains no fewer than this number of elements.
* `max_length` - Validates that the list contains no more than this number of elements.
* `max_depth` - Validates that nesting depth does not exceed this value. This applies to both the field schema depth and the raw input data depth. Depth of 0 permits the field itself but no nesting. Defaults to `None` (no limit).
For example, to validate a list of integers you might use something like the following:
@ -495,10 +496,11 @@ We can now reuse our custom `StringListField` class throughout our application,
A field class that validates a dictionary of objects. The keys in `DictField` are always assumed to be string values.
**Signature**: `DictField(child=<A_FIELD_INSTANCE>, allow_empty=True)`
**Signature**: `DictField(child=<A_FIELD_INSTANCE>, allow_empty=True, max_depth=None)`
* `child` - A field instance that should be used for validating the values in the dictionary. If this argument is not provided then values in the mapping will not be validated.
* `allow_empty` - Designates if empty dictionaries are allowed.
* `max_depth` - Validates that nesting depth does not exceed this value. This applies to both the field schema depth and the raw input data depth. Depth of 0 permits the field itself but no nesting. Defaults to `None` (no limit).
For example, to create a field that validates a mapping of strings to strings, you would write something like this:

View File

@ -1650,7 +1650,8 @@ class ListField(Field):
'not_a_list': _('Expected a list of items but got type "{input_type}".'),
'empty': _('This list may not be empty.'),
'min_length': _('Ensure this field has at least {min_length} elements.'),
'max_length': _('Ensure this field has no more than {max_length} elements.')
'max_length': _('Ensure this field has no more than {max_length} elements.'),
'max_depth': _('List nesting depth exceeds maximum allowed depth of {max_depth}.')
}
def __init__(self, **kwargs):
@ -1658,6 +1659,7 @@ class ListField(Field):
self.allow_empty = kwargs.pop('allow_empty', True)
self.max_length = kwargs.pop('max_length', None)
self.min_length = kwargs.pop('min_length', None)
self.max_depth = kwargs.pop('max_depth', None)
assert not inspect.isclass(self.child), '`child` has not been instantiated.'
assert self.child.source is None, (
@ -1666,6 +1668,8 @@ class ListField(Field):
)
super().__init__(**kwargs)
self._current_depth = 0
self._root_max_depth = self.max_depth
self.child.bind(field_name='', parent=self)
if self.max_length is not None:
message = lazy_format(self.error_messages['max_length'], max_length=self.max_length)
@ -1674,6 +1678,37 @@ class ListField(Field):
message = lazy_format(self.error_messages['min_length'], min_length=self.min_length)
self.validators.append(MinLengthValidator(self.min_length, message=message))
def bind(self, field_name, parent):
super().bind(field_name, parent)
if hasattr(parent, '_root_max_depth') and parent._root_max_depth is not None:
self._root_max_depth = parent._root_max_depth
self._current_depth = parent._current_depth + 1
self._propagate_depth_to_child()
def _propagate_depth_to_child(self):
if self._root_max_depth is not None and hasattr(self.child, '_root_max_depth'):
self.child._root_max_depth = self._root_max_depth
self.child._current_depth = self._current_depth + 1
if hasattr(self.child, '_propagate_depth_to_child'):
self.child._propagate_depth_to_child()
def _check_data_depth(self, data, current=0):
if self._root_max_depth is not None:
if isinstance(data, (list, tuple)):
for item in data:
if isinstance(item, (list, tuple, dict)):
next_depth = current + 1
if next_depth > self._root_max_depth:
self.fail('max_depth', max_depth=self._root_max_depth)
self._check_data_depth(item, next_depth)
elif isinstance(data, dict):
for value in data.values():
if isinstance(value, (list, tuple, dict)):
next_depth = current + 1
if next_depth > self._root_max_depth:
self.fail('max_depth', max_depth=self._root_max_depth)
self._check_data_depth(value, next_depth)
def get_value(self, dictionary):
if self.field_name not in dictionary:
if getattr(self.root, 'partial', False):
@ -1699,6 +1734,9 @@ class ListField(Field):
self.fail('not_a_list', input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
self.fail('empty')
if self._root_max_depth is not None and self._current_depth > self._root_max_depth:
self.fail('max_depth', max_depth=self._root_max_depth)
self._check_data_depth(data, self._current_depth)
return self.run_child_validation(data)
def to_representation(self, data):
@ -1730,11 +1768,13 @@ class DictField(Field):
default_error_messages = {
'not_a_dict': _('Expected a dictionary of items but got type "{input_type}".'),
'empty': _('This dictionary may not be empty.'),
'max_depth': _('Dictionary nesting depth exceeds maximum allowed depth of {max_depth}.')
}
def __init__(self, **kwargs):
self.child = kwargs.pop('child', copy.deepcopy(self.child))
self.allow_empty = kwargs.pop('allow_empty', True)
self.max_depth = kwargs.pop('max_depth', None)
assert not inspect.isclass(self.child), '`child` has not been instantiated.'
assert self.child.source is None, (
@ -1743,8 +1783,41 @@ class DictField(Field):
)
super().__init__(**kwargs)
self._current_depth = 0
self._root_max_depth = self.max_depth
self.child.bind(field_name='', parent=self)
def bind(self, field_name, parent):
super().bind(field_name, parent)
if hasattr(parent, '_root_max_depth') and parent._root_max_depth is not None:
self._root_max_depth = parent._root_max_depth
self._current_depth = parent._current_depth + 1
self._propagate_depth_to_child()
def _propagate_depth_to_child(self):
if self._root_max_depth is not None and hasattr(self.child, '_root_max_depth'):
self.child._root_max_depth = self._root_max_depth
self.child._current_depth = self._current_depth + 1
if hasattr(self.child, '_propagate_depth_to_child'):
self.child._propagate_depth_to_child()
def _check_data_depth(self, data, current=0):
if self._root_max_depth is not None:
if isinstance(data, dict):
for value in data.values():
if isinstance(value, (list, tuple, dict)):
next_depth = current + 1
if next_depth > self._root_max_depth:
self.fail('max_depth', max_depth=self._root_max_depth)
self._check_data_depth(value, next_depth)
elif isinstance(data, (list, tuple)):
for item in data:
if isinstance(item, (list, tuple, dict)):
next_depth = current + 1
if next_depth > self._root_max_depth:
self.fail('max_depth', max_depth=self._root_max_depth)
self._check_data_depth(item, next_depth)
def get_value(self, dictionary):
# We override the default field access in order to support
# dictionaries in HTML forms.
@ -1762,7 +1835,9 @@ class DictField(Field):
self.fail('not_a_dict', input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
self.fail('empty')
if self._root_max_depth is not None and self._current_depth > self._root_max_depth:
self.fail('max_depth', max_depth=self._root_max_depth)
self._check_data_depth(data, self._current_depth)
return self.run_child_validation(data)
def to_representation(self, value):

View File

@ -1,7 +1,7 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
#
# Translators:
msgid ""
msgstr ""
@ -357,6 +357,11 @@ msgstr "Ensure this field has at least {min_length} elements."
msgid "Ensure this field has no more than {max_length} elements."
msgstr "Ensure this field has no more than {max_length} elements."
#: fields.py:1654
#, python-brace-format
msgid "List nesting depth exceeds maximum allowed depth of {max_depth}."
msgstr "List nesting depth exceeds maximum allowed depth of {max_depth}."
#: fields.py:1682
#, python-brace-format
msgid "Expected a dictionary of items but got type \"{input_type}\"."
@ -366,6 +371,11 @@ msgstr "Expected a dictionary of items but got type \"{input_type}\"."
msgid "This dictionary may not be empty."
msgstr "This dictionary may not be empty."
#: fields.py:1733
#, python-brace-format
msgid "Dictionary nesting depth exceeds maximum allowed depth of {max_depth}."
msgstr "Dictionary nesting depth exceeds maximum allowed depth of {max_depth}."
#: fields.py:1755
msgid "Value must be valid JSON."
msgstr "Value must be valid JSON."

View File

@ -119,6 +119,23 @@ class BaseSerializer(Field):
self._context = kwargs.pop('context', {})
kwargs.pop('many', None)
super().__init__(**kwargs)
self._current_depth = 0
self._root_max_depth = None
def bind(self, field_name, parent):
super().bind(field_name, parent)
if hasattr(parent, '_root_max_depth') and parent._root_max_depth is not None:
self._root_max_depth = parent._root_max_depth
self._current_depth = parent._current_depth + 1
def _propagate_depth_to_child(self):
if self._root_max_depth is not None and 'fields' in self.__dict__:
for field in self.__dict__['fields'].values():
if hasattr(field, '_root_max_depth'):
field._root_max_depth = self._root_max_depth
field._current_depth = self._current_depth + 1
if hasattr(field, '_propagate_depth_to_child'):
field._propagate_depth_to_child()
def __new__(cls, *args, **kwargs):
# We override this method in order to automatically create
@ -373,6 +390,7 @@ class Serializer(BaseSerializer, metaclass=SerializerMetaclass):
fields = BindingDict(self)
for key, value in self.get_fields().items():
fields[key] = value
self._propagate_depth_to_child()
return fields
@property

View File

@ -2545,6 +2545,282 @@ class TestUnvalidatedDictField(FieldValues):
field = serializers.DictField()
class TestListFieldMaxDepth:
def test_flat_list_with_max_depth_none(self):
field = serializers.ListField(child=serializers.IntegerField(), max_depth=None)
output = field.run_validation([1, 2, 3])
assert output == [1, 2, 3]
def test_nested_list_with_max_depth_none(self):
field = serializers.ListField(
child=serializers.ListField(child=serializers.ListField(child=serializers.IntegerField())),
max_depth=None
)
output = field.run_validation([[[1, 2]], [[3]]])
assert output == [[[1, 2]], [[3]]]
def test_max_depth_zero_allows_field_itself(self):
field = serializers.ListField(child=serializers.IntegerField(), max_depth=0)
output = field.run_validation([1, 2, 3])
assert output == [1, 2, 3]
def test_max_depth_zero_rejects_nested_list(self):
field = serializers.ListField(
child=serializers.ListField(child=serializers.IntegerField()),
max_depth=0
)
with pytest.raises(serializers.ValidationError) as exc_info:
field.run_validation([[1, 2], [3]])
assert 'max_depth' in str(exc_info.value.detail)
def test_max_depth_one_allows_one_level_nesting(self):
field = serializers.ListField(
child=serializers.ListField(child=serializers.IntegerField()),
max_depth=1
)
output = field.run_validation([[1, 2], [3, 4]])
assert output == [[1, 2], [3, 4]]
def test_max_depth_one_rejects_two_levels_nesting(self):
field = serializers.ListField(
child=serializers.ListField(child=serializers.ListField(child=serializers.IntegerField())),
max_depth=1
)
with pytest.raises(serializers.ValidationError) as exc_info:
field.run_validation([[[1, 2]], [[3]]])
assert 'max_depth' in str(exc_info.value.detail)
def test_deeply_nested_exceeds_max_depth(self):
field = serializers.ListField(
child=serializers.ListField(
child=serializers.ListField(
child=serializers.ListField(
child=serializers.ListField(child=serializers.IntegerField())
)
)
),
max_depth=3
)
with pytest.raises(serializers.ValidationError):
field.run_validation([[[[[1]]]]])
def test_max_depth_with_mixed_nesting_list_and_dict(self):
field = serializers.ListField(
child=serializers.DictField(child=serializers.ListField(child=serializers.IntegerField())),
max_depth=2
)
output = field.run_validation([{'a': [1, 2], 'b': [3]}])
assert output == [{'a': [1, 2], 'b': [3]}]
def test_max_depth_with_mixed_nesting_exceeds_limit(self):
field = serializers.ListField(
child=serializers.DictField(
child=serializers.ListField(child=serializers.ListField(child=serializers.IntegerField()))
),
max_depth=2
)
with pytest.raises(serializers.ValidationError):
field.run_validation([{'a': [[1, 2]]}])
def test_error_message_contains_max_depth_value(self):
field = serializers.ListField(
child=serializers.ListField(child=serializers.IntegerField()),
max_depth=0
)
with pytest.raises(serializers.ValidationError) as exc_info:
field.run_validation([[1, 2]])
error_msg = str(exc_info.value.detail[0])
assert '0' in error_msg
class TestDictFieldMaxDepth:
def test_flat_dict_with_max_depth_none(self):
field = serializers.DictField(child=serializers.IntegerField(), max_depth=None)
output = field.run_validation({'a': 1, 'b': 2})
assert output == {'a': 1, 'b': 2}
def test_nested_dict_with_max_depth_none(self):
field = serializers.DictField(
child=serializers.DictField(child=serializers.DictField(child=serializers.IntegerField())),
max_depth=None
)
output = field.run_validation({'a': {'b': {'c': 1}}})
assert output == {'a': {'b': {'c': 1}}}
def test_max_depth_zero_allows_field_itself(self):
field = serializers.DictField(child=serializers.IntegerField(), max_depth=0)
output = field.run_validation({'a': 1, 'b': 2})
assert output == {'a': 1, 'b': 2}
def test_max_depth_zero_rejects_nested_dict(self):
field = serializers.DictField(
child=serializers.DictField(child=serializers.IntegerField()),
max_depth=0
)
with pytest.raises(serializers.ValidationError) as exc_info:
field.run_validation({'a': {'b': 1}})
assert 'max_depth' in str(exc_info.value.detail)
def test_max_depth_one_allows_one_level_nesting(self):
field = serializers.DictField(
child=serializers.DictField(child=serializers.IntegerField()),
max_depth=1
)
output = field.run_validation({'a': {'b': 1}, 'c': {'d': 2}})
assert output == {'a': {'b': 1}, 'c': {'d': 2}}
def test_max_depth_one_rejects_two_levels_nesting(self):
field = serializers.DictField(
child=serializers.DictField(child=serializers.DictField(child=serializers.IntegerField())),
max_depth=1
)
with pytest.raises(serializers.ValidationError) as exc_info:
field.run_validation({'a': {'b': {'c': 1}}})
assert 'max_depth' in str(exc_info.value.detail)
def test_max_depth_with_mixed_nesting_dict_and_list(self):
field = serializers.DictField(
child=serializers.ListField(child=serializers.DictField(child=serializers.IntegerField())),
max_depth=2
)
output = field.run_validation({'a': [{'b': 1, 'c': 2}]})
assert output == {'a': [{'b': 1, 'c': 2}]}
def test_max_depth_with_mixed_nesting_exceeds_limit(self):
field = serializers.DictField(
child=serializers.ListField(
child=serializers.DictField(child=serializers.DictField(child=serializers.IntegerField()))
),
max_depth=2
)
with pytest.raises(serializers.ValidationError):
field.run_validation({'a': [{'b': {'c': 1}}]})
def test_error_message_contains_max_depth_value(self):
field = serializers.DictField(
child=serializers.DictField(child=serializers.IntegerField()),
max_depth=0
)
with pytest.raises(serializers.ValidationError) as exc_info:
field.run_validation({'a': {'b': 1}})
error_msg = str(exc_info.value.detail)
assert '0' in error_msg
class TestMaxDepthEdgeCases:
def test_field_reuse_does_not_leak_depth_state(self):
child_field = serializers.ListField(child=serializers.IntegerField())
field = serializers.ListField(child=child_field, max_depth=1)
output1 = field.run_validation([[1, 2], [3, 4]])
assert output1 == [[1, 2], [3, 4]]
output2 = field.run_validation([[5, 6], [7, 8]])
assert output2 == [[5, 6], [7, 8]]
def test_max_depth_with_empty_nested_structures(self):
field = serializers.ListField(
child=serializers.ListField(child=serializers.IntegerField()),
max_depth=1
)
output = field.run_validation([[], []])
assert output == [[], []]
def test_very_deep_nesting_rejected_immediately(self):
child = serializers.IntegerField()
for _ in range(10):
child = serializers.ListField(child=child)
field = serializers.ListField(child=child, max_depth=5)
data = [1]
for _ in range(10):
data = [data]
with pytest.raises(serializers.ValidationError):
field.run_validation(data)
class TestMaxDepthDataInspection:
def test_flat_field_rejects_deeply_nested_data(self):
field = serializers.ListField(max_depth=1)
field.run_validation([[1, 2]])
with pytest.raises(serializers.ValidationError) as exc_info:
field.run_validation([[[1]]])
assert 'max_depth' in str(exc_info.value.detail)
def test_flat_dict_field_rejects_deeply_nested_data(self):
field = serializers.DictField(max_depth=1)
field.run_validation({'a': {'b': 1}})
with pytest.raises(serializers.ValidationError) as exc_info:
field.run_validation({'a': {'b': {'c': 1}}})
assert 'max_depth' in str(exc_info.value.detail)
def test_max_depth_zero_rejects_any_nesting_in_data(self):
field = serializers.ListField(max_depth=0)
field.run_validation([1, 2, 3])
with pytest.raises(serializers.ValidationError):
field.run_validation([[1]])
def test_data_depth_check_with_mixed_structures(self):
field = serializers.ListField(max_depth=1)
field.run_validation([{'a': 1}, [2], 3])
with pytest.raises(serializers.ValidationError):
field.run_validation([{'a': {'b': 1}}])
def test_dict_field_data_depth_with_nested_lists(self):
field = serializers.DictField(max_depth=1)
field.run_validation({'a': [1, 2], 'b': 'text'})
with pytest.raises(serializers.ValidationError):
field.run_validation({'a': [[1, 2]]})
def test_data_depth_respects_current_depth(self):
inner = serializers.ListField(child=serializers.IntegerField())
outer = serializers.ListField(child=inner, max_depth=2)
outer.run_validation([[1, 2], [3, 4]])
with pytest.raises(serializers.ValidationError):
outer.run_validation([[[1]]])
class TestMaxDepthWithSerializers:
def test_list_field_containing_serializer_with_nested_list(self):
class InnerSerializer(serializers.Serializer):
numbers = serializers.ListField(child=serializers.IntegerField())
field = serializers.ListField(child=InnerSerializer(), max_depth=2)
valid_data = [{'numbers': [1, 2]}, {'numbers': [3, 4]}]
output = field.run_validation(valid_data)
assert output == [{'numbers': [1, 2]}, {'numbers': [3, 4]}]
def test_list_field_containing_serializer_exceeds_max_depth(self):
class InnerSerializer(serializers.Serializer):
nested_list = serializers.ListField(
child=serializers.ListField(child=serializers.IntegerField())
)
field = serializers.ListField(child=InnerSerializer(), max_depth=2)
with pytest.raises(serializers.ValidationError):
field.run_validation([{'nested_list': [[1, 2]]}])
def test_serializer_within_dict_field_respects_depth(self):
class ValueSerializer(serializers.Serializer):
data = serializers.ListField(child=serializers.IntegerField())
field = serializers.DictField(child=ValueSerializer(), max_depth=2)
valid_data = {'key1': {'data': [1, 2]}, 'key2': {'data': [3, 4]}}
output = field.run_validation(valid_data)
assert output == {'key1': {'data': [1, 2]}, 'key2': {'data': [3, 4]}}
def test_deeply_nested_serializer_structure_rejected(self):
class Level3Serializer(serializers.Serializer):
values = serializers.ListField(child=serializers.IntegerField())
class Level2Serializer(serializers.Serializer):
level3 = Level3Serializer()
class Level1Serializer(serializers.Serializer):
level2 = Level2Serializer()
field = serializers.ListField(child=Level1Serializer(), max_depth=3)
with pytest.raises(serializers.ValidationError):
field.run_validation([{'level2': {'level3': {'values': [1, 2]}}}])
class TestHStoreField(FieldValues):
"""
Values for `ListField` with CharField as child.