Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/05-dataclasses.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,50 @@ It is also worth noting that you can use the `@validataclass` decorator with opt
being applied to the class.


## Reject unknown fields

By default, validataclass just ignores any unknown fields in the input dictionary when validating an object.
This makes sense for normal APIs, as additional fields are just filtered out, and it makes validataclass more robust to
changes in the API. There might be situations where one needs to have a strict validation of additional fields,
for example, to match an OpenAPI validation.

You can reject unknown fields by setting `reject_unknown_fields` either on the `@validataclass` decorator or on the
`DataclassValidator` itself. When set on the `DataclassValidator`, it overrides the setting from the decorator.

Setting it on the decorator:

```python
from validataclass.dataclasses import validataclass
from validataclass.validators import DataclassValidator, StringValidator

@validataclass(reject_unknown_fields=True)
class MyModel:
name: str = StringValidator()

my_validator = DataclassValidator(MyModel)

my_validator.validate({'name': 'test'}) # would work fine
my_validator.validate({'name': 'test', 'more': 'stuff'}) # would throw a DictFieldsValidationError with FieldNotAllowedError
```

Setting it on the `DataclassValidator` (this also works with dataclasses that don't have `reject_unknown_fields` set
on the decorator, and can override the decorator setting):

```python
from validataclass.dataclasses import validataclass
from validataclass.validators import DataclassValidator, StringValidator

@validataclass
class MyModel:
name: str = StringValidator()

my_validator = DataclassValidator(MyModel, reject_unknown_fields=True)

my_validator.validate({'name': 'test'}) # would work fine
my_validator.validate({'name': 'test', 'more': 'stuff'}) # would throw a DictFieldsValidationError with FieldNotAllowedError
```


## Defining field defaults

While dataclasses have built-in support for field default values, they unfortunately have a rather impractical
Expand Down
25 changes: 24 additions & 1 deletion src/validataclass/dataclasses/validataclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ def validataclass(cls: None = None, /, **kwargs: Any) -> Callable[[type[_T]], ty
def validataclass(
cls: type[_T] | None = None,
/,
*,
reject_unknown_fields: bool | None = None,
Comment thread
the-infinity marked this conversation as resolved.
**kwargs: Any,
) -> type[_T] | Callable[[type[_T]], type[_T]]:
"""
Expand Down Expand Up @@ -86,6 +88,22 @@ class ExampleDataclass:

Optional parameters to the decorator will be passed directly to the `@dataclass` decorator. In most cases no
parameters are necessary. By default, the argument `kw_only=True` will be used for validataclasses.

Additionally, the following validataclass-specific parameter is supported:

`reject_unknown_fields`: If set to `True`, the `DataclassValidator` will reject any input fields that are not
defined in the validataclass, raising a validation error for each unknown field.

Example with `reject_unknown_fields`:

```
@validataclass(reject_unknown_fields=True)
class StrictDataclass:
example_field1: str = StringValidator()
```

In this example, validating `{'example_field1': 'cookie', 'unknown': 'value'}` would raise a
`DictFieldsValidationError` with an error for the `unknown` field.
"""

def decorator(_cls: type[_T]) -> type[_T]:
Expand All @@ -96,7 +114,12 @@ def decorator(_cls: type[_T]) -> type[_T]:
_prepare_dataclass_metadata(_cls)

# Use @dataclass decorator to turn class into a dataclass
return dataclasses.dataclass(**kwargs)(_cls)
_cls = dataclasses.dataclass(**kwargs)(_cls)

# Store validataclass-specific settings on the class
_cls.__reject_unknown_fields__ = reject_unknown_fields # type: ignore[attr-defined]

return _cls

# Wrap actual decorator if called with parentheses
return decorator if cls is None else decorator(cls)
Expand Down
21 changes: 18 additions & 3 deletions src/validataclass/validators/dataclass_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ValidationError,
)
from .dict_validator import DictValidator
from .reject_validator import RejectValidator
from .validator import Validator

__all__ = [
Expand Down Expand Up @@ -117,7 +118,12 @@ def __post_validate__(self, *, require_optional_field: bool = False):
# Field default values
field_defaults: dict[str, BaseDefault[Any]]

def __init__(self, dataclass_cls: type[T_Dataclass] | None = None) -> None:
def __init__(
self,
dataclass_cls: type[T_Dataclass] | None = None,
*,
reject_unknown_fields: bool | None = None,
) -> None:
# For easier subclassing: If 'self.dataclass_cls' is already set (e.g. as class member in a subclass), use that
# class as the default.
if dataclass_cls is None:
Expand All @@ -134,6 +140,10 @@ def __init__(self, dataclass_cls: type[T_Dataclass] | None = None) -> None:
raise InvalidValidatorOptionException('Parameter "dataclass_cls" must be a dataclass type.')

self.dataclass_cls = dataclass_cls

# Use the explicit parameter if given, otherwise fall back to the dataclass setting
if reject_unknown_fields is None:
reject_unknown_fields = getattr(dataclass_cls, '__reject_unknown_fields__', False)
self.field_defaults = {}

# Collect field validators and required fields for the DictValidator by examining the dataclass fields
Expand All @@ -158,7 +168,12 @@ def __init__(self, dataclass_cls: type[T_Dataclass] | None = None) -> None:
required_fields.append(field.name)

# Initialize the DictValidator
self.dict_validator = DictValidator(field_validators=field_validators, required_fields=required_fields)
default_validator = RejectValidator(error_reason='Unknown field') if reject_unknown_fields else None
self.dict_validator = DictValidator(
field_validators=field_validators,
required_fields=required_fields,
default_validator=default_validator,
)

@staticmethod
def _get_field_validator(field: dataclasses.Field[Any]) -> Validator[Any]:
Expand Down Expand Up @@ -228,7 +243,7 @@ def _pre_validate(self, input_data: Any, **kwargs: Any) -> dict[str, Any]:
# Filter input dictionary through __pre_validate__()
input_data = pre_validate_func(input_data, **context_kwargs)

# Validate raw dictionary using DictValidator
# Validate raw dictionary using underlying DictValidator
validated_dict = self.dict_validator.validate(input_data, **kwargs)

# Fill optional fields with default values
Expand Down
184 changes: 184 additions & 0 deletions tests/unit/validators/dataclass_validator_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ class UnitTestNestedDataclass:
validataclass_field(DataclassValidator(UnitTestDataclass), default=Default(None))


# Dataclass with reject_unknown_fields=True

@validataclass(reject_unknown_fields=True)
class UnitTestStrictDataclass:
"""
Dataclass that does not allow additional properties in the input dictionary.
"""
name: str = StringValidator()
color: str = StringValidator(), Default('unknown color')


# Dataclass with non-init field and __post_init__() method

@validataclass
Expand Down Expand Up @@ -1139,3 +1150,176 @@ class IncompatibleDataclass:
str(exception_info.value)
== 'Default specified for dataclass field "foo" is not an instance of "BaseDefault".'
)

# Tests for reject_unknown_fields option
@staticmethod
def test_strict_dataclass_valid():
""" Validate a strict dataclass with no extra keys. """
validator = DataclassValidator(UnitTestStrictDataclass)
validated_data = validator.validate({
'name': 'banana',
'color': 'yellow',
})

assert type(validated_data) is UnitTestStrictDataclass
assert validated_data.name == 'banana'
assert validated_data.color == 'yellow'

@staticmethod
def test_strict_dataclass_valid_with_optional_field_omitted():
""" Validate a strict dataclass with optional field omitted. """
validator = DataclassValidator(UnitTestStrictDataclass)
validated_data = validator.validate({
'name': 'apple',
})

assert type(validated_data) is UnitTestStrictDataclass
assert validated_data.name == 'apple'
assert validated_data.color == 'unknown color'

@staticmethod
def test_strict_dataclass_with_additional_properties():
""" Test that a strict dataclass raises DictFieldsValidationError for unknown keys. """
validator = DataclassValidator(UnitTestStrictDataclass)

with pytest.raises(DictFieldsValidationError) as exception_info:
validator.validate({
'name': 'banana',
'unknown_field': 'unknown_value',
})

assert exception_info.value.to_dict() == {
'code': 'field_errors',
'field_errors': {
'unknown_field': {'code': 'field_not_allowed', 'reason': 'Unknown field'},
},
}

@staticmethod
def test_strict_dataclass_with_multiple_additional_fields():
""" Test that each additional field gets its own error. """
validator = DataclassValidator(UnitTestStrictDataclass)

with pytest.raises(DictFieldsValidationError) as exception_info:
validator.validate({
'name': 'banana',
'zebra': 1,
'alpha': 2,
'mango': 3,
})

assert exception_info.value.to_dict() == {
'code': 'field_errors',
'field_errors': {
'alpha': {'code': 'field_not_allowed', 'reason': 'Unknown field'},
'mango': {'code': 'field_not_allowed', 'reason': 'Unknown field'},
'zebra': {'code': 'field_not_allowed', 'reason': 'Unknown field'},
},
}

@staticmethod
def test_default_allows_additional_fields():
""" Test that by default (reject_unknown_fields=False), unknown keys are silently ignored. """
validator = DataclassValidator(UnitTestDataclass)
validated_data = validator.validate({
'name': 'banana',
'color': 'yellow',
'amount': 10,
'weight': '1.234',
'unknown_field': 'should be ignored',
})

assert type(validated_data) is UnitTestDataclass
assert validated_data.name == 'banana'

@staticmethod
def test_explicit_reject_unknown_fields_false():
""" Test that reject_unknown_fields=False explicitly allows unknown keys. """

@validataclass(reject_unknown_fields=False)
class ExplicitAllowDataclass:
name: str = StringValidator()

validator = DataclassValidator(ExplicitAllowDataclass)
validated_data = validator.validate({
'name': 'banana',
'extra': 'ignored',
})

assert validated_data.name == 'banana'

# Tests for reject_unknown_fields as DataclassValidator init parameter

@staticmethod
def test_reject_unknown_fields_validator_param_enables_rejection():
""" Test that reject_unknown_fields=True on DataclassValidator rejects unknown fields. """
validator = DataclassValidator(UnitTestDataclass, reject_unknown_fields=True)

with pytest.raises(DictFieldsValidationError) as exception_info:
validator.validate({
'name': 'banana',
'color': 'yellow',
'amount': 10,
'weight': '1.234',
'unknown_field': 'unknown_value',
})

assert exception_info.value.to_dict() == {
'code': 'field_errors',
'field_errors': {
'unknown_field': {'code': 'field_not_allowed', 'reason': 'Unknown field'},
},
}

@staticmethod
def test_reject_unknown_fields_validator_param_allows_valid_input():
""" Test that reject_unknown_fields=True on DataclassValidator allows valid input without unknown fields. """
validator = DataclassValidator(UnitTestDataclass, reject_unknown_fields=True)
validated_data = validator.validate({
'name': 'banana',
'color': 'yellow',
'amount': 10,
'weight': '1.234',
})

assert type(validated_data) is UnitTestDataclass
assert validated_data.name == 'banana'

@staticmethod
def test_reject_unknown_fields_validator_param_overrides_dataclass_true():
"""
Test that reject_unknown_fields=False on DataclassValidator overrides reject_unknown_fields=True on the
dataclass.
"""
validator = DataclassValidator(UnitTestStrictDataclass, reject_unknown_fields=False)
validated_data = validator.validate({
'name': 'banana',
'extra': 'ignored',
})

assert type(validated_data) is UnitTestStrictDataclass
assert validated_data.name == 'banana'

@staticmethod
def test_reject_unknown_fields_validator_param_overrides_dataclass_false():
"""
Test that reject_unknown_fields=True on DataclassValidator overrides reject_unknown_fields=False on the
dataclass.
"""
validator = DataclassValidator(UnitTestDataclass, reject_unknown_fields=True)

with pytest.raises(DictFieldsValidationError) as exception_info:
validator.validate({
'name': 'banana',
'color': 'yellow',
'amount': 10,
'weight': '1.234',
'extra': 'not allowed',
})

assert exception_info.value.to_dict() == {
'code': 'field_errors',
'field_errors': {
'extra': {'code': 'field_not_allowed', 'reason': 'Unknown field'},
},
}