Skip to content

Commit 4cf3eb0

Browse files
committed
prevent additional properties
1 parent 98eba12 commit 4cf3eb0

7 files changed

Lines changed: 214 additions & 2 deletions

File tree

docs/05-dataclasses.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,31 @@ It is also worth noting that you can use the `@validataclass` decorator with opt
297297
being applied to the class.
298298

299299

300+
## Prevent additional properties
301+
302+
Per default, validataclass just ignores any additional properties in the input dictionary when validating an object.
303+
This makes sense for normal APIs, as additional fields are just filtered out, and it makes validataclass more robust to
304+
changes in the API. There might be situations where one needs to have a strict validation of additional parameters,
305+
for example, to match an OpenAPI validation.
306+
307+
You can prevent additional properties by setting `prevent_additional_properties` at the `@validataclass` to `True`,
308+
like this:
309+
310+
```python
311+
from validataclass.dataclasses import validataclass
312+
from validataclass.validators import DataclassValidator, StringValidator
313+
314+
@validataclass(prevent_additional_properties=True)
315+
class MyModel:
316+
name: str = StringValidator()
317+
318+
my_validator = DataclassValidator(MyModel)
319+
320+
my_validator.validate({'name': 'test'}) # would work fine
321+
my_validator.validate({'name': 'test', 'more': 'stuff'}) # would throw an AdditionalPropertiesError
322+
```
323+
324+
300325
## Defining field defaults
301326

302327
While dataclasses have built-in support for field default values, they unfortunately have a rather impractical

src/validataclass/dataclasses/validataclass.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,22 @@ class ExampleDataclass:
8888
"""
8989

9090
def decorator(_cls: type[_T]) -> type[_T]:
91+
# Pop validataclass-specific options before passing kwargs to @dataclass
92+
prevent_additional_properties = kwargs.pop('prevent_additional_properties', False)
93+
9194
# Set kw_only=True as the default to allow required and optional fields in any order
9295
kwargs.setdefault('kw_only', True)
9396

9497
# Prepare class to become a validataclass
9598
_prepare_dataclass_metadata(_cls)
9699

97100
# Use @dataclass decorator to turn class into a dataclass
98-
return dataclasses.dataclass(**kwargs)(_cls)
101+
_cls = dataclasses.dataclass(**kwargs)(_cls)
102+
103+
# Store validataclass-specific settings on the class
104+
_cls.__prevent_additional_properties__ = prevent_additional_properties # type: ignore[attr-defined]
105+
106+
return _cls
99107

100108
# Wrap actual decorator if called with parentheses
101109
return decorator if cls is None else decorator(cls)

src/validataclass/exceptions/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
InvalidTypeError,
1111
RequiredValueError,
1212
)
13-
from .dataclass_exceptions import DataclassPostValidationError
13+
from .dataclass_exceptions import AdditionalPropertiesError, DataclassPostValidationError
1414
from .datetime_exceptions import (
1515
DateTimeRangeError,
1616
InvalidDateError,
@@ -50,6 +50,7 @@
5050
from .url_exceptions import InvalidUrlError
5151

5252
__all__ = [
53+
'AdditionalPropertiesError',
5354
'DataclassInvalidPreValidateSignatureException',
5455
'DataclassPostValidationError',
5556
'DataclassValidatorFieldException',

src/validataclass/exceptions/dataclass_exceptions.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,33 @@
99
from .base_exceptions import ValidationError
1010

1111
__all__ = [
12+
'AdditionalPropertiesError',
1213
'DataclassPostValidationError',
1314
]
1415

1516

17+
class AdditionalPropertiesError(ValidationError):
18+
"""
19+
Validation error raised by `DataclassValidator` when the input dictionary contains keys that do not correspond to
20+
any field in the dataclass, and `prevent_additional_properties` is set to `True`.
21+
22+
The `additional_properties` attribute contains a sorted list of the extra field names.
23+
24+
Example as dictionary:
25+
26+
```
27+
{
28+
'code': 'additional_properties',
29+
'additional_properties': ['unknown1', 'unknown2'],
30+
}
31+
```
32+
"""
33+
code = 'additional_properties'
34+
35+
def __init__(self, *, additional_properties: list[str], **kwargs: Any):
36+
super().__init__(additional_properties=sorted(additional_properties), **kwargs)
37+
38+
1639
class DataclassPostValidationError(ValidationError):
1740
"""
1841
Validation error raised by `DataclassValidator` (or by a dataclass itself) when a user-defined post-validation

src/validataclass/validators/dataclass_validator.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from validataclass.dataclasses import Default, NoDefault
1313
from validataclass.exceptions import (
14+
AdditionalPropertiesError,
1415
DataclassInvalidPreValidateSignatureException,
1516
DataclassPostValidationError,
1617
DataclassValidatorFieldException,
@@ -109,6 +110,9 @@ def __post_validate__(self, *, require_optional_field: bool = False):
109110
# Dataclass type that the validated dictionary will be converted to
110111
dataclass_cls: type[T_Dataclass]
111112

113+
# Whether to prevent additional properties in the input dictionary
114+
prevent_additional_properties: bool
115+
112116
# Field default values
113117
field_defaults: dict[str, Default]
114118

@@ -129,6 +133,7 @@ def __init__(self, dataclass_cls: type[T_Dataclass] | None = None) -> None:
129133
raise InvalidValidatorOptionException('Parameter "dataclass_cls" must be a dataclass type.')
130134

131135
self.dataclass_cls = dataclass_cls
136+
self.prevent_additional_properties = getattr(dataclass_cls, '__prevent_additional_properties__', False)
132137
self.field_defaults = {}
133138

134139
# Collect field validators and required fields for the DictValidator by examining the dataclass fields
@@ -223,6 +228,13 @@ def _pre_validate(self, input_data: Any, **kwargs: Any) -> dict[str, Any]:
223228
# Filter input dictionary through __pre_validate__()
224229
input_data = pre_validate_func(input_data, **context_kwargs)
225230

231+
# Check for additional properties if not allowed
232+
if self.prevent_additional_properties:
233+
self._ensure_type(input_data, dict)
234+
additional = sorted(set(input_data.keys()) - set(self.field_validators.keys()))
235+
if additional:
236+
raise AdditionalPropertiesError(additional_properties=additional)
237+
226238
# Validate raw dictionary using underlying DictValidator
227239
validated_dict = super().validate(input_data, **kwargs)
228240

tests/unit/exceptions/dataclass_exceptions_test.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,52 @@
55
"""
66

77
from validataclass.exceptions import (
8+
AdditionalPropertiesError,
89
DataclassPostValidationError,
910
DictRequiredFieldError,
1011
InvalidTypeError,
1112
ValidationError,
1213
)
1314

1415

16+
class AdditionalPropertiesErrorTest:
17+
"""
18+
Tests for the AdditionalPropertiesError exception class.
19+
"""
20+
21+
@staticmethod
22+
def test_additional_properties_error_single_property():
23+
""" Tests AdditionalPropertiesError with a single additional property. """
24+
error = AdditionalPropertiesError(additional_properties=['unknown_field'])
25+
26+
assert error.to_dict() == {
27+
'code': 'additional_properties',
28+
'additional_properties': ['unknown_field'],
29+
}
30+
31+
@staticmethod
32+
def test_additional_properties_error_multiple_properties():
33+
""" Tests AdditionalPropertiesError with multiple additional properties (sorted). """
34+
error = AdditionalPropertiesError(additional_properties=['watermelon', 'apple', 'mango'])
35+
36+
assert error.to_dict() == {
37+
'code': 'additional_properties',
38+
'additional_properties': ['apple', 'mango', 'watermelon'],
39+
}
40+
41+
@staticmethod
42+
def test_additional_properties_error_repr():
43+
""" Tests repr of AdditionalPropertiesError. """
44+
error = AdditionalPropertiesError(additional_properties=['unknown1', 'unknown2'])
45+
46+
assert (
47+
repr(error)
48+
== "AdditionalPropertiesError(code='additional_properties', "
49+
"additional_properties=['unknown1', 'unknown2'])"
50+
)
51+
assert str(error) == repr(error)
52+
53+
1554
class DataclassPostValidationErrorTest:
1655
"""
1756
Tests for the DataclassPostValidationError exception class.

tests/unit/validators/dataclass_validator_test.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from tests.test_utils import UnitTestContextValidator
1414
from validataclass.dataclasses import Default, DefaultFactory, DefaultUnset, validataclass, validataclass_field
1515
from validataclass.exceptions import (
16+
AdditionalPropertiesError,
1617
DataclassInvalidPreValidateSignatureException,
1718
DataclassPostValidationError,
1819
DataclassValidatorFieldException,
@@ -58,6 +59,17 @@ class UnitTestNestedDataclass:
5859
validataclass_field(DataclassValidator(UnitTestDataclass), default=None)
5960

6061

62+
# Dataclass with prevent_additional_properties=True
63+
64+
@validataclass(prevent_additional_properties=True)
65+
class UnitTestStrictDataclass:
66+
"""
67+
Dataclass that does not allow additional properties in the input dictionary.
68+
"""
69+
name: str = StringValidator()
70+
color: str = StringValidator(), Default('unknown color')
71+
72+
6173
# Dataclass with non-init field and __post_init__() method
6274

6375
@validataclass
@@ -1131,3 +1143,95 @@ class IncompatibleDataclass:
11311143
DataclassValidator(IncompatibleDataclass)
11321144

11331145
assert str(exception_info.value) == 'Default specified for dataclass field "foo" is not of type "Default".'
1146+
1147+
# Tests for prevent_additional_properties option
1148+
1149+
@staticmethod
1150+
def test_strict_dataclass_valid():
1151+
""" Validate a strict dataclass with no extra keys. """
1152+
validator = DataclassValidator(UnitTestStrictDataclass)
1153+
validated_data = validator.validate({
1154+
'name': 'banana',
1155+
'color': 'yellow',
1156+
})
1157+
1158+
assert type(validated_data) is UnitTestStrictDataclass
1159+
assert validated_data.name == 'banana'
1160+
assert validated_data.color == 'yellow'
1161+
1162+
@staticmethod
1163+
def test_strict_dataclass_valid_with_optional_field_omitted():
1164+
""" Validate a strict dataclass with optional field omitted. """
1165+
validator = DataclassValidator(UnitTestStrictDataclass)
1166+
validated_data = validator.validate({
1167+
'name': 'apple',
1168+
})
1169+
1170+
assert type(validated_data) is UnitTestStrictDataclass
1171+
assert validated_data.name == 'apple'
1172+
assert validated_data.color == 'unknown color'
1173+
1174+
@staticmethod
1175+
def test_strict_dataclass_with_additional_properties():
1176+
""" Test that a strict dataclass raises AdditionalPropertiesError for unknown keys. """
1177+
validator = DataclassValidator(UnitTestStrictDataclass)
1178+
1179+
with pytest.raises(AdditionalPropertiesError) as exception_info:
1180+
validator.validate({
1181+
'name': 'banana',
1182+
'unknown_field': 'unknown_value',
1183+
})
1184+
1185+
assert exception_info.value.to_dict() == {
1186+
'code': 'additional_properties',
1187+
'additional_properties': ['unknown_field'],
1188+
}
1189+
1190+
@staticmethod
1191+
def test_strict_dataclass_with_multiple_additional_properties():
1192+
""" Test that additional properties are sorted in the error. """
1193+
validator = DataclassValidator(UnitTestStrictDataclass)
1194+
1195+
with pytest.raises(AdditionalPropertiesError) as exception_info:
1196+
validator.validate({
1197+
'name': 'banana',
1198+
'zebra': 1,
1199+
'alpha': 2,
1200+
'mango': 3,
1201+
})
1202+
1203+
assert exception_info.value.to_dict() == {
1204+
'code': 'additional_properties',
1205+
'additional_properties': ['alpha', 'mango', 'zebra'],
1206+
}
1207+
1208+
@staticmethod
1209+
def test_default_allows_additional_properties():
1210+
""" Test that by default (prevent_additional_properties=False), unknown keys are silently ignored. """
1211+
validator = DataclassValidator(UnitTestDataclass)
1212+
validated_data = validator.validate({
1213+
'name': 'banana',
1214+
'color': 'yellow',
1215+
'amount': 10,
1216+
'weight': '1.234',
1217+
'unknown_field': 'should be ignored',
1218+
})
1219+
1220+
assert type(validated_data) is UnitTestDataclass
1221+
assert validated_data.name == 'banana'
1222+
1223+
@staticmethod
1224+
def test_explicit_prevent_additional_properties_false():
1225+
""" Test that prevent_additional_properties=False explicitly allows unknown keys. """
1226+
1227+
@validataclass(prevent_additional_properties=False)
1228+
class ExplicitAllowDataclass:
1229+
name: str = StringValidator()
1230+
1231+
validator = DataclassValidator(ExplicitAllowDataclass)
1232+
validated_data = validator.validate({
1233+
'name': 'banana',
1234+
'extra': 'ignored',
1235+
})
1236+
1237+
assert validated_data.name == 'banana'

0 commit comments

Comments
 (0)