Skip to content

Commit 91cf1dd

Browse files
committed
prevent additional properties
1 parent b9c377e commit 91cf1dd

7 files changed

Lines changed: 214 additions & 3 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
@@ -89,14 +89,22 @@ class ExampleDataclass:
8989
"""
9090

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

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

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

101109
# Wrap actual decorator if called with parentheses
102110
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
@@ -11,10 +11,33 @@
1111
from .base_exceptions import ValidationError
1212

1313
__all__ = [
14+
'AdditionalPropertiesError',
1415
'DataclassPostValidationError',
1516
]
1617

1718

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

src/validataclass/validators/dataclass_validator.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from validataclass.dataclasses import BaseDefault, NoDefault
1515
from validataclass.exceptions import (
16+
AdditionalPropertiesError,
1617
DataclassInvalidPreValidateSignatureException,
1718
DataclassPostValidationError,
1819
DataclassValidatorFieldException,
@@ -114,6 +115,9 @@ def __post_validate__(self, *, require_optional_field: bool = False):
114115
# Dataclass type that the validated dictionary will be converted to
115116
dataclass_cls: type[T_Dataclass]
116117

118+
# Whether to prevent additional properties in the input dictionary
119+
prevent_additional_properties: bool
120+
117121
# Field default values
118122
field_defaults: dict[str, BaseDefault[Any]]
119123

@@ -134,6 +138,7 @@ def __init__(self, dataclass_cls: type[T_Dataclass] | None = None) -> None:
134138
raise InvalidValidatorOptionException('Parameter "dataclass_cls" must be a dataclass type.')
135139

136140
self.dataclass_cls = dataclass_cls
141+
self.prevent_additional_properties = getattr(dataclass_cls, '__prevent_additional_properties__', False)
137142
self.field_defaults = {}
138143

139144
# Collect field validators and required fields for the DictValidator by examining the dataclass fields
@@ -228,7 +233,14 @@ def _pre_validate(self, input_data: Any, **kwargs: Any) -> dict[str, Any]:
228233
# Filter input dictionary through __pre_validate__()
229234
input_data = pre_validate_func(input_data, **context_kwargs)
230235

231-
# Validate raw dictionary using DictValidator
236+
# Check for additional properties if not allowed
237+
if self.prevent_additional_properties:
238+
self._ensure_type(input_data, dict)
239+
additional = sorted(set(input_data.keys()) - set(self.field_validators.keys()))
240+
if additional:
241+
raise AdditionalPropertiesError(additional_properties=additional)
242+
243+
# Validate raw dictionary using underlying DictValidator
232244
validated_dict = self.dict_validator.validate(input_data, **kwargs)
233245

234246
# Fill optional fields with default values

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: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from tests.test_utils import UnitTestContextValidator
1515
from validataclass.dataclasses import Default, DefaultFactory, DefaultUnset, validataclass, validataclass_field
1616
from validataclass.exceptions import (
17+
AdditionalPropertiesError,
1718
DataclassInvalidPreValidateSignatureException,
1819
DataclassPostValidationError,
1920
DataclassValidatorFieldException,
@@ -59,6 +60,17 @@ class UnitTestNestedDataclass:
5960
validataclass_field(DataclassValidator(UnitTestDataclass), default=Default(None))
6061

6162

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

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

0 commit comments

Comments
 (0)