From 731e01d2eccd90e5cc2e8d864da009ec38faf296 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Tue, 20 Sep 2022 14:15:27 +0200 Subject: [PATCH 1/6] Add validate_with_context() method to Validator base class for context-sensitive validation --- .../validators/dataclass_validator.py | 4 ++-- .../validators/dict_validator.py | 4 ++-- .../validators/list_validator.py | 8 ++++--- src/validataclass/validators/noneable.py | 4 ++-- src/validataclass/validators/validator.py | 22 +++++++++++++++++ tests/test_utils.py | 22 ++++++++++++++++- tests/test_utils_test.py | 17 ++++++++++++- tests/validators/dataclass_validator_test.py | 21 ++++++++++++++++ tests/validators/dict_validator_test.py | 24 +++++++++++++++++++ tests/validators/list_validator_test.py | 16 +++++++++++++ tests/validators/noneable_test.py | 9 +++++++ 11 files changed, 140 insertions(+), 11 deletions(-) diff --git a/src/validataclass/validators/dataclass_validator.py b/src/validataclass/validators/dataclass_validator.py index 6ace66b..219f82b 100644 --- a/src/validataclass/validators/dataclass_validator.py +++ b/src/validataclass/validators/dataclass_validator.py @@ -144,12 +144,12 @@ def _get_field_default(field: dataclasses.Field) -> Default: raise DataclassValidatorFieldException(f'Default specified for dataclass field "{field.name}" is not of type "Default".') return default - def validate(self, input_data: Any) -> T_Dataclass: + def validate(self, input_data: Any, **kwargs) -> T_Dataclass: """ Validate an input dictionary according to the specified dataclass. Returns an instance of the dataclass. """ # Validate raw dictionary using underlying DictValidator - validated_dict = super().validate(input_data) + validated_dict = super().validate(input_data, **kwargs) # Fill optional fields with default values for field_name, field_default in self.field_defaults.items(): diff --git a/src/validataclass/validators/dict_validator.py b/src/validataclass/validators/dict_validator.py index 43afb6a..2bbd894 100644 --- a/src/validataclass/validators/dict_validator.py +++ b/src/validataclass/validators/dict_validator.py @@ -122,7 +122,7 @@ def __init__( if optional_fields is not None: self.required_fields = self.required_fields - set(optional_fields) - def validate(self, input_data: Any) -> dict: + def validate(self, input_data: Any, **kwargs) -> dict: """ Validate input data. Returns a validated dict. """ @@ -154,7 +154,7 @@ def validate(self, input_data: Any) -> dict: # Run field validator and catch validation errors try: - validated_dict[key] = field_validator.validate(value) + validated_dict[key] = field_validator.validate_with_context(value, **kwargs) except ValidationError as error: field_errors[key] = error diff --git a/src/validataclass/validators/list_validator.py b/src/validataclass/validators/list_validator.py index 5e31b40..0b16281 100644 --- a/src/validataclass/validators/list_validator.py +++ b/src/validataclass/validators/list_validator.py @@ -56,7 +56,9 @@ class ListValidator(Validator): discard_invalid: bool = False def __init__( - self, item_validator: Validator, *, + self, + item_validator: Validator, + *, min_length: Optional[int] = None, max_length: Optional[int] = None, discard_invalid: bool = False @@ -85,7 +87,7 @@ def __init__( self.max_length = max_length self.discard_invalid = discard_invalid - def validate(self, input_data: Any) -> list: + def validate(self, input_data: Any, **kwargs) -> list: """ Validate input data. Returns a validated list. """ @@ -103,7 +105,7 @@ def validate(self, input_data: Any) -> list: # Apply item_validator to all list items and collect validation errors for index, item in enumerate(input_data): try: - validated_list.append(self.item_validator.validate(item)) + validated_list.append(self.item_validator.validate_with_context(item, **kwargs)) except ValidationError as error: validation_errors[index] = error diff --git a/src/validataclass/validators/noneable.py b/src/validataclass/validators/noneable.py index 6c1010d..2768309 100644 --- a/src/validataclass/validators/noneable.py +++ b/src/validataclass/validators/noneable.py @@ -62,7 +62,7 @@ def __init__(self, validator: Validator, *, default: Any = None): self.wrapped_validator = validator self.default_value = default - def validate(self, input_data: Any) -> Optional[Any]: + def validate(self, input_data: Any, **kwargs) -> Optional[Any]: """ Validate input data. @@ -74,7 +74,7 @@ def validate(self, input_data: Any) -> Optional[Any]: try: # Call wrapped validator for all values other than None - return self.wrapped_validator.validate(input_data) + return self.wrapped_validator.validate_with_context(input_data, **kwargs) except InvalidTypeError as error: # If wrapped validator raises an InvalidTypeError, add 'none' to its 'expected_types' list and reraise it error.add_expected_type(type(None)) diff --git a/src/validataclass/validators/validator.py b/src/validataclass/validators/validator.py index a305e07..f3cc4d9 100644 --- a/src/validataclass/validators/validator.py +++ b/src/validataclass/validators/validator.py @@ -4,6 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ +import inspect from abc import ABC, abstractmethod from typing import Any, Union, List @@ -27,6 +28,27 @@ def validate(self, input_data: Any): """ raise NotImplementedError() + def validate_with_context(self, input_data: Any, **kwargs): + """ + This method is a wrapper for `validate()` that always accepts arbitrary keyword arguments (which can be used + for context-sensitive validation). + + If the `validate()` method of this class supports arbitrary keyword arguments, the keyword arguments are passed + to the `validate()` method. Otherwise, the `validate()` method is called only with the input value and no other + arguments. + + NOTE: This method is only needed for compatibility reasons and will become obsolete in the future, when every + Validator class will be required to accept arbitrary keyword arguments (probably in version 1.0). + + Use this method only if you want/need to pass context arguments to a validator and don't know for sure that the + validator accepts keyword arguments (e.g. because you don't know the class of the validator). + """ + validate_spec = inspect.getfullargspec(self.validate) + if validate_spec.varkw is not None: + return self.validate(input_data, **kwargs) # noqa (unexpected argument) + else: + return self.validate(input_data) + @staticmethod def _ensure_not_none(input_data: Any) -> None: """ diff --git a/tests/test_utils.py b/tests/test_utils.py index 0dbd408..f12d661 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,8 +4,9 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from typing import List +from typing import Any, List +from validataclass.validators import Validator def unpack_params(*args) -> List[tuple]: """ @@ -69,3 +70,22 @@ def unpack_params(*args) -> List[tuple]: # This is a sentinel object used in parametrized tests to represent "this parameter should not be set" UNSET_PARAMETER = object() + + +# Test validator that parses context arguments +class UnitTestContextValidator(Validator): + """ + Context-sensitive string validator, only for unit testing. + + Returns the input string followed by a dump of the context arguments, optionally with a prefix string. + """ + + # Prefix that is prepended to the output to distinguish multiple validators in tests + prefix: str + + def __init__(self, *, prefix: str = ''): + self.prefix = f'[{prefix}] ' if prefix else '' + + def validate(self, input_data: Any, **kwargs) -> str: + self._ensure_type(input_data, str) + return f'{self.prefix}{input_data} / {kwargs}' diff --git a/tests/test_utils_test.py b/tests/test_utils_test.py index 8dd3392..b50f47c 100644 --- a/tests/test_utils_test.py +++ b/tests/test_utils_test.py @@ -4,7 +4,7 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ -from tests.test_utils import unpack_params +from tests.test_utils import unpack_params, UnitTestContextValidator class UnpackParamsTest: @@ -54,3 +54,18 @@ def test_unpack_multiple_lists_of_tuples(): ('baz', 'BAZ', 2, 'b'), ('baz', 'BAZ', 3, 'c'), ] + + +class UnitTestContextValidatorTest: + """ Tests for the UnitTestContextValidator helper validator. """ + + @staticmethod + def test_validator(): + validator = UnitTestContextValidator() + assert validator.validate('banana') == 'banana / {}' + assert validator.validate('banana', foo=42, bar=[]) == "banana / {'foo': 42, 'bar': []}" + + @staticmethod + def test_validator_with_prefix(): + validator = UnitTestContextValidator(prefix='UNITTEST') + assert validator.validate('banana', foo=42, bar=[]) == "[UNITTEST] banana / {'foo': 42, 'bar': []}" diff --git a/tests/validators/dataclass_validator_test.py b/tests/validators/dataclass_validator_test.py index fee8ec4..cd49f01 100644 --- a/tests/validators/dataclass_validator_test.py +++ b/tests/validators/dataclass_validator_test.py @@ -10,6 +10,7 @@ import pytest +from tests.test_utils import UnitTestContextValidator from validataclass.dataclasses import validataclass, validataclass_field, Default, DefaultFactory, DefaultUnset from validataclass.exceptions import ValidationError, RequiredValueError, DictFieldsValidationError, DataclassPostValidationError, \ InvalidValidatorOptionException, DataclassValidatorFieldException @@ -177,6 +178,26 @@ class DataclassWithDefaults: # Verify that the default list was deepcopied assert validated_objects[1].default_list is not validated_objects[2].default_list is not validated_objects[3].default_list + # Tests for DataclassValidator with context arguments + + @staticmethod + def test_validation_with_context_arguments(): + """ Test that DataclassValidator passes context arguments down to the field validators. """ + + @validataclass + class DataclassWithContextSensitiveValidators: + field1: str = UnitTestContextValidator(prefix='1') + field2: str = UnitTestContextValidator(prefix='2') + + validator = DataclassValidator(DataclassWithContextSensitiveValidators) + validated_data = validator.validate({ + 'field1': 'apple', + 'field2': 'banana', + }, foo=42) + + assert validated_data.field1 == "[1] apple / {'foo': 42}" + assert validated_data.field2 == "[2] banana / {'foo': 42}" + # Tests for more complex and nested validators using dataclasses @staticmethod diff --git a/tests/validators/dict_validator_test.py b/tests/validators/dict_validator_test.py index 3b1bb8b..397c2c1 100644 --- a/tests/validators/dict_validator_test.py +++ b/tests/validators/dict_validator_test.py @@ -7,6 +7,7 @@ from decimal import Decimal import pytest +from tests.test_utils import UnitTestContextValidator from validataclass.exceptions import DictFieldsValidationError, DictInvalidKeyTypeError, InvalidTypeError, RequiredValueError, \ InvalidValidatorOptionException, ListItemsValidationError from validataclass.validators import DictValidator, DecimalValidator, IntegerValidator, StringValidator, Noneable, ListValidator @@ -308,6 +309,29 @@ def test_dict_with_noneable_fields(): 'test_b': None, } + # Tests for DictValidator with context arguments + + @staticmethod + def test_with_context_arguments(): + """ Test that DictValidator passes context arguments down to the default and field validators. """ + validator = DictValidator( + field_validators={'unittest': UnitTestContextValidator(prefix='FIELD')}, + default_validator=UnitTestContextValidator(prefix='DEFAULT'), + ) + input_dict = { + 'unittest': 'foo', + 'foobar': 'bar', + } + + assert validator.validate(input_dict) == { + 'unittest': "[FIELD] foo / {}", + 'foobar': "[DEFAULT] bar / {}", + } + assert validator.validate(input_dict, foo=42) == { + 'unittest': "[FIELD] foo / {'foo': 42}", + 'foobar': "[DEFAULT] bar / {'foo': 42}", + } + # Test invalid validator options @staticmethod diff --git a/tests/validators/list_validator_test.py b/tests/validators/list_validator_test.py index 5fd8727..acf265e 100644 --- a/tests/validators/list_validator_test.py +++ b/tests/validators/list_validator_test.py @@ -7,6 +7,7 @@ from decimal import Decimal import pytest +from tests.test_utils import UnitTestContextValidator from validataclass.exceptions import RequiredValueError, InvalidTypeError, ListItemsValidationError, ListLengthError, \ InvalidValidatorOptionException from validataclass.validators import ListValidator, IntegerValidator, StringValidator, DecimalValidator @@ -94,6 +95,21 @@ def test_invalid_decimal_list_items(): } } + # Test ListValidator with a context-sensitive item validator + + @staticmethod + def test_with_context_arguments(): + """ Test that ListValidator passes context arguments down to the item validator. """ + validator = ListValidator(item_validator=UnitTestContextValidator()) + assert validator.validate(['unit', 'test']) == [ + "unit / {}", + "test / {}", + ] + assert validator.validate(['unit', 'test'], foo=42) == [ + "unit / {'foo': 42}", + "test / {'foo': 42}", + ] + # Check that ListValidator actually discards invalid items @staticmethod diff --git a/tests/validators/noneable_test.py b/tests/validators/noneable_test.py index 87b8577..804e3a7 100644 --- a/tests/validators/noneable_test.py +++ b/tests/validators/noneable_test.py @@ -8,6 +8,7 @@ import pytest +from tests.test_utils import UnitTestContextValidator from validataclass.exceptions import ValidationError from validataclass.validators import Noneable, DecimalValidator, IntegerValidator @@ -49,6 +50,14 @@ def test_noneable_with_default_valid(input_data, expected_result): assert type(result) == type(expected_result) assert result == expected_result + @staticmethod + def test_noneable_with_context_arguments(): + """ Test that Noneable passes context arguments down to the wrapped validator. """ + validator = Noneable(UnitTestContextValidator()) + assert validator.validate(None) is None + assert validator.validate('unittest') == "unittest / {}" + assert validator.validate('unittest', foo=42) == "unittest / {'foo': 42}" + @staticmethod def test_invalid_not_none_value(): """ Test that Noneable correctly wraps a specified validator and leaves (most) exceptions unmodified. """ From 96d242eca7ce560ca456111310d76ca2f5159cf7 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Tue, 20 Sep 2022 17:38:02 +0200 Subject: [PATCH 2/6] Implement context-sensitive post-validation in dataclasses using __post_validate__() --- .../validators/dataclass_validator.py | 10 ++ src/validataclass/validators/validator.py | 3 +- tests/validators/dataclass_validator_test.py | 113 ++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/validataclass/validators/dataclass_validator.py b/src/validataclass/validators/dataclass_validator.py index 219f82b..4b0690b 100644 --- a/src/validataclass/validators/dataclass_validator.py +++ b/src/validataclass/validators/dataclass_validator.py @@ -5,6 +5,7 @@ """ import dataclasses +import inspect from typing import Any, Optional, TypeVar, Generic, Dict from validataclass.dataclasses import Default, NoDefault @@ -159,6 +160,15 @@ def validate(self, input_data: Any, **kwargs) -> T_Dataclass: # Try to create dataclass object from validated dictionary and catch exceptions that may be raised by a __post_init__() method try: validated_object = self.dataclass_cls(**validated_dict) + + # Post validation using the custom __post_validate__() method in the dataclass (if defined) + if hasattr(validated_object, '__post_validate__'): + # Only pass context arguments if __post_validate__() accepts them + if inspect.getfullargspec(validated_object.__post_validate__).varkw is not None: + validated_object.__post_validate__(**kwargs) + else: + validated_object.__post_validate__() + return self.post_validate(validated_object) except DataclassPostValidationError as error: # Error already has correct exception type, just reraise diff --git a/src/validataclass/validators/validator.py b/src/validataclass/validators/validator.py index f3cc4d9..9a641ba 100644 --- a/src/validataclass/validators/validator.py +++ b/src/validataclass/validators/validator.py @@ -43,8 +43,7 @@ def validate_with_context(self, input_data: Any, **kwargs): Use this method only if you want/need to pass context arguments to a validator and don't know for sure that the validator accepts keyword arguments (e.g. because you don't know the class of the validator). """ - validate_spec = inspect.getfullargspec(self.validate) - if validate_spec.varkw is not None: + if inspect.getfullargspec(self.validate).varkw is not None: return self.validate(input_data, **kwargs) # noqa (unexpected argument) else: return self.validate(input_data) diff --git a/tests/validators/dataclass_validator_test.py b/tests/validators/dataclass_validator_test.py index cd49f01..d5a05c6 100644 --- a/tests/validators/dataclass_validator_test.py +++ b/tests/validators/dataclass_validator_test.py @@ -47,6 +47,9 @@ class UnitTestNestedDataclass: @validataclass class UnitTestPostInitDataclass: + """ + Dataclass with a non-init field that is set in a `__post_init__()` method (which also does some post-validation). + """ # Normal validated fields base: str = StringValidator() count: int = IntegerValidator() @@ -65,6 +68,39 @@ def __post_init__(self): self.result = self.count * self.base +# Dataclasses with __post_validate__() method (with and without context-sensitive validation) + +@validataclass +class UnitTestPostValidationDataclass: + """ + Dataclass to test post-validation using the `__post_validate__()` method. + """ + start: int = IntegerValidator() + end: int = IntegerValidator() + + def __post_validate__(self): + if self.start > self.end: + raise ValidationError(code='invalid_range', reason='"start" must be smaller than or equal to "end".') + + +@validataclass +class UnitTestContextSensitiveDataclass: + """ + Dataclass to test context-sensitive post-validation. + + The class has a field "name" that is always required, and a field "value" which usually is optional, but required + when the context argument "value_required" is set. + """ + name: str = UnitTestContextValidator() + value: Optional[int] = IntegerValidator(), Default(None) + + def __post_validate__(self, *, value_required: bool = False, **kwargs): + if value_required and self.value is None: + raise DataclassPostValidationError(field_errors={ + 'value': RequiredValueError(reason='Value is required in this context.'), + }) + + # Subclassed DataclassValidator with post_validate() method class SubclassedDataclassValidator(DataclassValidator[UnitTestDataclass]): @@ -350,6 +386,83 @@ def __post_init__(self): 'b': 0, }) + # Test dataclasses with __post_validate__() method (with and without context-sensitive validation) + + @staticmethod + def test_dataclass_with_post_validate(): + """ Validate dataclass with __post_validate__() method. """ + validator: DataclassValidator[UnitTestPostValidationDataclass] = DataclassValidator(UnitTestPostValidationDataclass) + validated_data = validator.validate({ + 'start': 3, + 'end': 4, + }) + + assert validated_data.start == 3 + assert validated_data.end == 4 + + @staticmethod + def test_dataclass_with_post_validate_invalid(): + """ Validate dataclass with __post_validate__() method, with invalid input. """ + validator: DataclassValidator[UnitTestPostValidationDataclass] = DataclassValidator(UnitTestPostValidationDataclass) + + with pytest.raises(DataclassPostValidationError) as exception_info: + validator.validate({ + 'start': 3, + 'end': 2, + }) + + assert exception_info.value.to_dict() == { + 'code': 'post_validation_errors', + 'error': { + 'code': 'invalid_range', + 'reason': '"start" must be smaller than or equal to "end".', + }, + } + + @staticmethod + @pytest.mark.parametrize( + 'validate_kwargs, input_data, expected_value', + [ + # No context arguments + ({}, {'name': 'banana'}, None), + ({}, {'name': 'banana', 'value': 13}, 13), + + # Context argument "value_required" + ({'value_required': False}, {'name': 'banana'}, None), + ({'value_required': False}, {'name': 'banana', 'value': 13}, 13), + ({'value_required': True}, {'name': 'banana', 'value': 13}, 13), + + # Test that all context arguments are passed to other validators as well + ({'value_required': False, 'foo': 42}, {'name': 'banana'}, None), + ] + ) + def test_dataclass_with_context_sensitive_post_validate(validate_kwargs, input_data, expected_value): + """ Validate dataclass with a context-sensitive __post_validate__() method. """ + validator: DataclassValidator[UnitTestContextSensitiveDataclass] = DataclassValidator(UnitTestContextSensitiveDataclass) + validated_data = validator.validate(input_data, **validate_kwargs) + + assert validated_data.name == f"banana / {validate_kwargs}" + assert validated_data.value == expected_value + + @staticmethod + def test_dataclass_with_context_sensitive_post_validate_invalid(): + """ Validate dataclass with a context-sensitive __post_validate__() method, with invalid input. """ + validator: DataclassValidator[UnitTestContextSensitiveDataclass] = DataclassValidator(UnitTestContextSensitiveDataclass) + + # Without context arguments + with pytest.raises(DataclassPostValidationError) as exception_info: + validator.validate({'name': 'banana'}, value_required=True) + + assert exception_info.value.to_dict() == { + 'code': 'post_validation_errors', + 'field_errors': { + 'value': { + 'code': 'required_value', + 'reason': 'Value is required in this context.', + }, + }, + } + # Tests for subclassed DataclassValidators (with post_validate() method) @staticmethod From 2ffca84339fdc2b9ee71aa2c9c2557a8ecc1dbd1 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Tue, 20 Sep 2022 19:20:47 +0200 Subject: [PATCH 3/6] DataclassValidator: Restructure validate() method; remove post_validate() method --- .../validators/dataclass_validator.py | 43 ++++++++------- tests/validators/dataclass_validator_test.py | 55 +------------------ 2 files changed, 25 insertions(+), 73 deletions(-) diff --git a/src/validataclass/validators/dataclass_validator.py b/src/validataclass/validators/dataclass_validator.py index 4b0690b..eb6a7ae 100644 --- a/src/validataclass/validators/dataclass_validator.py +++ b/src/validataclass/validators/dataclass_validator.py @@ -145,9 +145,9 @@ def _get_field_default(field: dataclasses.Field) -> Default: raise DataclassValidatorFieldException(f'Default specified for dataclass field "{field.name}" is not of type "Default".') return default - def validate(self, input_data: Any, **kwargs) -> T_Dataclass: + def _pre_validate(self, input_data: Any, **kwargs) -> dict: """ - Validate an input dictionary according to the specified dataclass. Returns an instance of the dataclass. + Pre-validation steps: Validates the input as a dictionary and fills in the default values. """ # Validate raw dictionary using underlying DictValidator validated_dict = super().validate(input_data, **kwargs) @@ -157,19 +157,19 @@ def validate(self, input_data: Any, **kwargs) -> T_Dataclass: if field_name not in validated_dict: validated_dict[field_name] = field_default.get_value() - # Try to create dataclass object from validated dictionary and catch exceptions that may be raised by a __post_init__() method - try: - validated_object = self.dataclass_cls(**validated_dict) + return validated_dict - # Post validation using the custom __post_validate__() method in the dataclass (if defined) - if hasattr(validated_object, '__post_validate__'): - # Only pass context arguments if __post_validate__() accepts them - if inspect.getfullargspec(validated_object.__post_validate__).varkw is not None: - validated_object.__post_validate__(**kwargs) - else: - validated_object.__post_validate__() + def validate(self, input_data: Any, **kwargs) -> T_Dataclass: + """ + Validate an input dictionary according to the specified dataclass. Returns an instance of the dataclass. + """ + # Pre-validate the raw dictionary and fill in default values + validated_dict = self._pre_validate(input_data, **kwargs) - return self.post_validate(validated_object) + # Try to create dataclass object from validated dictionary and catch exceptions that may be raised in post-validation + try: + validated_object = self.dataclass_cls(**validated_dict) + return self._post_validate(validated_object, **kwargs) except DataclassPostValidationError as error: # Error already has correct exception type, just reraise raise error @@ -178,12 +178,17 @@ def validate(self, input_data: Any, **kwargs) -> T_Dataclass: raise DataclassPostValidationError(error=error) # Ignore all non-ValidationError exceptions (these are either errors in the code or should be handled properly by the user) - # noinspection PyMethodMayBeStatic - def post_validate(self, validated_object: T_Dataclass) -> T_Dataclass: + @staticmethod + def _post_validate(validated_object: T_Dataclass, **kwargs) -> T_Dataclass: """ - Run post-validation checks on the validated dataclass instance. Returns the dataclass instance. - - This method does nothing, but can be overridden by subclasses to implement user-defined checks (and optionally modify the - instance). Exceptions raised in this method will be caught in `validate()` and handled as DataclassPostValidationErrors. + Post-validation steps: Calls the `__post_validate__()` method on the dataclass object (if it is defined). """ + # Post validation using the custom __post_validate__() method in the dataclass (if defined) + if hasattr(validated_object, '__post_validate__'): + # Only pass context arguments if __post_validate__() accepts them + if inspect.getfullargspec(validated_object.__post_validate__).varkw is not None: + validated_object.__post_validate__(**kwargs) + else: + validated_object.__post_validate__() + return validated_object diff --git a/tests/validators/dataclass_validator_test.py b/tests/validators/dataclass_validator_test.py index d5a05c6..e041bc1 100644 --- a/tests/validators/dataclass_validator_test.py +++ b/tests/validators/dataclass_validator_test.py @@ -94,25 +94,13 @@ class UnitTestContextSensitiveDataclass: name: str = UnitTestContextValidator() value: Optional[int] = IntegerValidator(), Default(None) - def __post_validate__(self, *, value_required: bool = False, **kwargs): + def __post_validate__(self, *, value_required: bool = False, **_kwargs): if value_required and self.value is None: raise DataclassPostValidationError(field_errors={ 'value': RequiredValueError(reason='Value is required in this context.'), }) -# Subclassed DataclassValidator with post_validate() method - -class SubclassedDataclassValidator(DataclassValidator[UnitTestDataclass]): - dataclass_cls = UnitTestDataclass - - def post_validate(self, validated_object: UnitTestDataclass) -> UnitTestDataclass: - # Do some consistency check and raise a ValidationError if necessary - if validated_object.amount == 0 and validated_object.weight != 0: - raise ValidationError(code='contradictory_values', reason='Amount is 0, but weight is not 0. This does not make sense!') - return validated_object - - class DataclassValidatorTest: # Tests for DataclassValidator with a simple dataclass @@ -463,47 +451,6 @@ def test_dataclass_with_context_sensitive_post_validate_invalid(): }, } - # Tests for subclassed DataclassValidators (with post_validate() method) - - @staticmethod - def test_subclassed_dataclass_validator(): - """ Test subclassing of DataclassValidator. """ - - validator = SubclassedDataclassValidator() - validated_data = validator.validate({ - 'name': 'banana', - 'color': 'yellow', - 'amount': 10, - 'weight': '1.234', - }) - - assert type(validated_data) is UnitTestDataclass - assert validated_data.name == 'banana' - assert validated_data.color == 'yellow' - assert validated_data.amount == 10 - assert validated_data.weight == Decimal('1.234') - - @staticmethod - def test_subclassed_dataclass_validator_post_validation_error(): - """ Test post_validate() checks in subclassed DataclassValidator with errors. """ - - validator = SubclassedDataclassValidator() - with pytest.raises(DataclassPostValidationError) as exception_info: - validator.validate({ - 'name': 'banana', - # Inconsistent data: amount is 0, but weight is not 0 - 'amount': 0, - 'weight': '1.234', - }) - - assert exception_info.value.to_dict() == { - 'code': 'post_validation_errors', - 'error': { - 'code': 'contradictory_values', - 'reason': 'Amount is 0, but weight is not 0. This does not make sense!', - }, - } - # Test invalid validator options @staticmethod From 002793b2acb463d17139edd8d26c6b5864855695 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Tue, 20 Sep 2022 19:21:38 +0200 Subject: [PATCH 4/6] Add documentation about context-sensitive post-validation --- docs/05-dataclasses.md | 151 +++++++++++++++--- .../validators/dataclass_validator.py | 39 ++++- 2 files changed, 165 insertions(+), 25 deletions(-) diff --git a/docs/05-dataclasses.md b/docs/05-dataclasses.md index b6a5470..6d4d652 100644 --- a/docs/05-dataclasses.md +++ b/docs/05-dataclasses.md @@ -639,31 +639,34 @@ class SubClass(BaseB, BaseA): ## Post-validation -Post-validation is everything that happens **after** all fields have been validated individually, but **before** the `DataclassValidator` -returns the dataclass object. +Post-validation is everything that happens **after** all fields have been validated individually, but **before** the +`DataclassValidator` returns the dataclass object. -For example, you could implement additional validation criteria that depend on the values of multiple fields of the dataclass. One common -use case for this are fields that are optional by default, but can be **required** under certain conditions, e.g. fields A and B are not -required, but if one of them exists, the other must exist too (or conversely, only one of the fields is allowed to be set at the same -time). +For example, you could implement additional validation criteria that depend on the values of multiple fields of the +dataclass. One common use case for this are fields that are optional by default, but can be **required** under certain +conditions, e.g. fields A and B are not required, but if one of them exists, the other must exist too (or conversely, +only one of the fields is allowed to be set at the same time). -Another use case are "integrity constraints". Imagine you have a dataclass with two datetime fields `begin_time` and `end_time` that -specify the start and end of a time interval. You might want to ensure that `begin_time <= end_time` is always true, because a time -interval cannot end before it starts. This cannot be done using the field validators alone, so you need to do this integrity check -**post validation**. +Another use case are "integrity constraints". Imagine you have a dataclass with two datetime fields `begin_time` and +`end_time` that specify the start and end of a time interval. You might want to ensure that `begin_time <= end_time` is +always true, because a time interval cannot end before it starts. This cannot be done using the field validators alone, +so you need to do this integrity check **post validation**. -Of course, you can do any sort of post-validation on a validated object after it was returned by the `DataclassValidator`. But a more -elegant way is to **integrate** the post-validation logic into the validator and/or dataclass itself. +Of course, you can do any sort of post-validation on a validated object after it was returned by the `DataclassValidator`. +But a more elegant way is to **integrate** the post-validation logic into the `DataclassValidator` and dataclass itself. -There are two ways to do this: One way is to **subclass** the `DataclassValidator` for your dataclass and override the `post_validate()` -method (which by default doesn't do anything with the object). Another way is to use the `__post_init__()` special method of dataclasses. -We will only demonstrate the latter for now, because it's a bit more easy (doesn't require subclassing the validator). +There are two ways to implement post-validation in a validataclass: First, there is the `__post_init__()` special method +which is automatically called as part of the `__init__()` method of a dataclass. It is a feature of regular dataclasses, +so this post-validation is also applied when instantiating the dataclass without using validataclass. -Dataclasses can have the special method `__post_init__()` which will be automatically called after the `__init__()` special method. -It does not receive any arguments (unless so called `InitVar` fields are used, which are currently unsupported by this library), but it -can access the objects fields as usual with `self.field_name`. +The other way is to implement the `__post_validate__()` method. This method is called by the `DataclassValidator` right +after creating the object. It is a feature of validataclass, so it is **not** called when instantiating the dataclass +manually (although you can of course just call `obj.__post_validate__()` manually as well). -Let's see how this can be done for the datetime example from above: +The `__post_validate__()` method additionally supports another special feature: **context-sensitive validation**, which +will be discussed shortly. + +This example implements the datetime post-validation example from above: ```python from datetime import datetime @@ -678,7 +681,8 @@ class ExampleClass: begin_time: datetime = DateTimeValidator(DateTimeFormat.REQUIRE_UTC) end_time: datetime = DateTimeValidator(DateTimeFormat.REQUIRE_UTC) - def __post_init__(self): + # Note: In this case, __post_init__() would look exactly the same. + def __post_validate__(self): # Ensure that begin_time is always before end_time if self.begin_time > self.end_time: raise ValidationError( @@ -740,7 +744,7 @@ class ExampleClass: # This field is required only if enable_something is True. Otherwise it will be ignored. some_value: Optional[int] = IntegerValidator(), Default(None) - def __post_init__(self): + def __post_validate__(self): # If enable_something is True, ensure that some_value is set! if self.enable_something is True and self.some_value is None: raise DataclassPostValidationError(field_errors={ @@ -773,6 +777,111 @@ The `DataclassPostValidationError` from this example will look like this after c ``` +### Context-sensitive post-validation + +As mentioned earlier, the `__post_validate__()` method supports a nice feature called **context-sensitive validation**. + +In general, this means that the validation can depend on the **context** it is used in. Usually, the output of a +validator is always determined by a) the options set at the time the validator was created and b) the input value. +Context-sensitive validation means that you pass additional parameters to the validator at runtime, i.e. at the time +the `validate()` method is called to validate a piece of input. + +These so called **context arguments** are passed to the `validate()` call as arbitrary keyword arguments. Whether and +how the validator actually uses these arguments depends on the implementation of the validator. Most validators don't +do anything with it except for passing it to sub-validators (e.g. the `ListValidator` passes the context arguments to +the specified item validator). + +The `DataclassValidator` supports these context arguments and uses them in two ways: First, it passes them as they are +to any field validator (which might pass them to other validators as well). Second, it also passes them to the +`__post_validate__()` method of the dataclass. + +However, for this to work, the method MUST accept arbitrary keyword arguments, i.e. it needs to be declared with a +`**kwargs` parameter (the parameter name doesn't matter). You can of course declare specific keyword arguments that you +want to use for post-validation (make sure to define them as optional!), but you still need to accept any other keyword +argument as well, otherwise the context arguments will not be passed to the method at all. + +Example: + +```python +from typing import Optional + +from validataclass.dataclasses import validataclass, Default +from validataclass.exceptions import RequiredValueError, DataclassPostValidationError +from validataclass.validators import DataclassValidator, BooleanValidator, IntegerValidator + +@validataclass +class ContextSensitiveExampleClass: + # This field is optional, unless the context says otherwise. + some_value: Optional[int] = IntegerValidator(), Default(None) + + # Note: Prefix the kwargs parameter with an underscore to avoid "unused parameter" notices. + def __post_validate__(self, *, require_some_value: bool = False, **_kwargs): + # If require_some_value was set at validation time, ensure that some_value is set! + if require_some_value and self.some_value is None: + raise DataclassPostValidationError(field_errors={ + 'some_value': RequiredValueError(reason='Must be set in this context.'), + }) + +# Create a validator for this dataclass +validator = DataclassValidator(ContextSensitiveExampleClass) + +# Without context arguments: The field is optional. +validator.validate({}) # -> ContextSensitiveExampleClass(some_value=None) +validator.validate({"some_value": 42}) # -> ContextSensitiveExampleClass(some_value=42) + +# With the context argument "require_some_value" set: The field is now required! +validator.validate({}, require_some_value=True) # will raise a DataclassPostValidationError +validator.validate({"some_value": 42}, require_some_value=True) # -> ContextSensitiveExampleClass(some_value=42) +``` + +**One important note about the `validate()` method:** + +For backwards compatibility, `Validator` classes currently are **not** required to accept arbitrary keyword arguments. +Custom validators that were created before this feature was implemented (version 0.7.0) will not support this, so +calling their `validate()` method with keyword arguments will raise an error. + +To avoid this, there is a helper method that wraps the `validate()` call: `Validator.validate_with_context()` will check +whether the validator class supports context arguments, then call the `validate()` method either with or without them. + +In cases where you don't know whether your validator class already supports context arguments (especially when writing +generic code that can use arbitrary validators), you should therefore use the `validate_with_context()` method. + +Example: + +```python +from validataclass.validators import Validator + +validator: Validator = ... # This can be any validator class +input_data = ... + +validated_data = validator.validate_with_context(input_data, my_context_var=42) +``` + +This method will become obsolete and eventually removed in the future (possibly in version 1.0.0), when every validator +class will be required to support context arguments. + +**Therefore, you should upgrade your custom validator classes to support context arguments, and also to pass them to +any underlying base validator.** + +To do this, simply add a `**kwargs` argument to your `validate()` call. For example: + +```python +from typing import Any +from validataclass.validators import StringValidator + +class UppercaseStringValidator(StringValidator): + # BEFORE: + # def validate(self, input_data: Any) -> str: + # validated_str = super().validate(input_data) + # return validated_str.upper() + + # AFTER: + def validate(self, input_data: Any, **kwargs) -> str: + validated_str = super().validate(input_data, **kwargs) + return validated_str.upper() +``` + + ### Post-initialization variables Another thing you can do at post-validation time is setting "post-initialization fields". These are fields in a dataclass that are diff --git a/src/validataclass/validators/dataclass_validator.py b/src/validataclass/validators/dataclass_validator.py index eb6a7ae..db9b6a9 100644 --- a/src/validataclass/validators/dataclass_validator.py +++ b/src/validataclass/validators/dataclass_validator.py @@ -63,10 +63,41 @@ class ExampleDataclass: All fields that do NOT specify a default value (or explicitly use the special value `NoDefault`) are required. - Post-validation checks can be implemented either as a `__post_init__()` method in the dataclass or by subclassing - DataclassValidator and overriding the `post_validate()` method. In both cases, you can either raise - `DataclassPostValidationError` exceptions directly or raise normal `ValidationError` exceptions, which will be - wrapped inside a `DataclassPostValidationError` automatically. + Post-validation checks can be implemented in the dataclass either using the `__post_init__()` special method (which + is part of regular dataclasses and thus also works without validataclass) or using a `__post_validate__()` method + (which is called by the DataclassValidator after creating the object). The latter also supports *context-sensitive* + validation, which means you can pass extra arguments to the `validate()` call that will be passed both to all field + validators and to the `__post_validate__()` method (as long as it is defined with a `**kwargs` argument). + + In post-validation you can either raise regular `ValidationError` exceptions, which will be automatically wrapped + inside a `DataclassPostValidationError` exception, or raise such an exception directly (in which case you can + also specify errors for individual fields, which provides more precise errors to the user). + + Here is an example for such a `__post_validate__()` method that also happens to be context-sensitive: + + ``` + @validataclass + class ExampleDataclass: + optional_field: str = StringValidator(), Default('') + + # Note: The method MUST accept arbitrary keyword arguments (**kwargs), not just the parameter you defined, + # otherwise no context arguments will be passed to it at all. To avoid "unused parameter" notices, you can + # prepend the variable name with an underscore. + def __post_validate__(self, *, require_optional_field: bool = False, **_kwargs): + if require_optional_field and not self.optional_field: + raise DataclassPostValidationError(field_errors={ + 'value': RequiredValueError(reason='The optional field is required for some reason.'), + }) + ``` + + In this example, the field "optional_field" is usually optional, but there are cases where you need the field to be + set, which is only determined at runtime, i.e. when calling the validate() method of DataclassValidator. For this + you can now set the context argument `require_optional_field` (as defined in the `__post_validate__` method): + + ``` + validator = DataclassValidator(ExampleDataclass) + obj = validator.validate(input_data, require_optional_field=True) + ``` """ # Dataclass type that the validated dictionary will be converted to From f1ff3a4fb3155b57c82700585adb635f07494046 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Wed, 21 Sep 2022 16:17:06 +0200 Subject: [PATCH 5/6] Add deprecation warning for validators without kwargs; add kwargs parameter to all validators --- .../validators/any_of_validator.py | 2 +- .../validators/anything_validator.py | 2 +- .../validators/boolean_validator.py | 2 +- .../validators/date_validator.py | 4 +-- .../validators/datetime_validator.py | 4 +-- .../validators/decimal_validator.py | 4 +-- .../validators/email_validator.py | 4 +-- .../validators/enum_validator.py | 4 +-- .../validators/float_to_decimal_validator.py | 4 +-- .../validators/float_validator.py | 2 +- .../validators/integer_validator.py | 2 +- .../validators/regex_validator.py | 4 +-- .../validators/reject_validator.py | 2 +- .../validators/string_validator.py | 2 +- .../validators/time_validator.py | 4 +-- src/validataclass/validators/url_validator.py | 4 +-- src/validataclass/validators/validator.py | 24 +++++++++++++-- tests/validators/dataclass_validator_test.py | 2 +- tests/validators/validator_test.py | 30 +++++++++++++++++++ 19 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 tests/validators/validator_test.py diff --git a/src/validataclass/validators/any_of_validator.py b/src/validataclass/validators/any_of_validator.py index d684a41..a3fd7c0 100644 --- a/src/validataclass/validators/any_of_validator.py +++ b/src/validataclass/validators/any_of_validator.py @@ -62,7 +62,7 @@ def __init__(self, allowed_values: List[Any], *, allowed_types: Union[type, List self.allowed_values = allowed_values self.allowed_types = allowed_types - def validate(self, input_data: Any) -> Any: + def validate(self, input_data: Any, **kwargs) -> Any: """ Validate that input is in the list of allowed values. Returns the value unmodified. """ diff --git a/src/validataclass/validators/anything_validator.py b/src/validataclass/validators/anything_validator.py index ea1d957..1bd019f 100644 --- a/src/validataclass/validators/anything_validator.py +++ b/src/validataclass/validators/anything_validator.py @@ -137,7 +137,7 @@ def _normalize_allowed_types( return list(allowed_types) - def validate(self, input_data: Any) -> Any: + def validate(self, input_data: Any, **kwargs) -> Any: """ Validate input data. Accepts anything (or only specific types) and returns data unmodified. """ diff --git a/src/validataclass/validators/boolean_validator.py b/src/validataclass/validators/boolean_validator.py index 6adffc4..700508c 100644 --- a/src/validataclass/validators/boolean_validator.py +++ b/src/validataclass/validators/boolean_validator.py @@ -47,7 +47,7 @@ def __init__(self, *, allow_strings: bool = False): """ self.allow_strings = allow_strings - def validate(self, input_data: Any) -> bool: + def validate(self, input_data: Any, **kwargs) -> bool: """ Validate type of input data. Returns a boolean. """ diff --git a/src/validataclass/validators/date_validator.py b/src/validataclass/validators/date_validator.py index 1db1d26..3092883 100644 --- a/src/validataclass/validators/date_validator.py +++ b/src/validataclass/validators/date_validator.py @@ -40,12 +40,12 @@ def __init__(self): # Initialize StringValidator without any parameters super().__init__() - def validate(self, input_data: Any) -> date: + def validate(self, input_data: Any, **kwargs) -> date: """ Validate input as a valid date string and convert it to a `datetime.date` object. """ # First, validate input data as string - date_string = super().validate(input_data) + date_string = super().validate(input_data, **kwargs) # Try to create date object from string (only accepts "YYYY-MM-DD") try: diff --git a/src/validataclass/validators/datetime_validator.py b/src/validataclass/validators/datetime_validator.py index 16cbda6..347ca10 100644 --- a/src/validataclass/validators/datetime_validator.py +++ b/src/validataclass/validators/datetime_validator.py @@ -225,12 +225,12 @@ def __init__( # Precompile regular expression for datetime format self.datetime_format_regex = re.compile(self.datetime_format.regex_str) - def validate(self, input_data: Any) -> datetime: + def validate(self, input_data: Any, **kwargs) -> datetime: """ Validate input as a valid datetime string and convert it to a `datetime.datetime` object. """ # First, validate input data as string - datetime_string = super().validate(input_data) + datetime_string = super().validate(input_data, **kwargs) # Validate string format with a regular expression if not self.datetime_format_regex.fullmatch(datetime_string): diff --git a/src/validataclass/validators/decimal_validator.py b/src/validataclass/validators/decimal_validator.py index b32f954..c1f487d 100644 --- a/src/validataclass/validators/decimal_validator.py +++ b/src/validataclass/validators/decimal_validator.py @@ -119,12 +119,12 @@ def __init__( raise InvalidValidatorOptionException('Parameter "output_places" cannot be negative.') self.output_quantum = Decimal('0.1') ** output_places - def validate(self, input_data: Any) -> Decimal: + def validate(self, input_data: Any, **kwargs) -> Decimal: """ Validate input data as a string, convert it to a Decimal object and check optional constraints. """ # First, validate input data as string - decimal_string = super().validate(input_data) + decimal_string = super().validate(input_data, **kwargs) # Validate string with a regular expression if not self.decimal_regex.fullmatch(decimal_string): diff --git a/src/validataclass/validators/email_validator.py b/src/validataclass/validators/email_validator.py index 6509900..a783b12 100644 --- a/src/validataclass/validators/email_validator.py +++ b/src/validataclass/validators/email_validator.py @@ -54,12 +54,12 @@ def __init__(self): # Initialize StringValidator with some length requirements super().__init__(min_length=1, max_length=256) - def validate(self, input_data: Any) -> str: + def validate(self, input_data: Any, **kwargs) -> str: """ Validate that input is a valid email address string. Returns unmodified string. """ # Validate input data as string - input_email = super().validate(input_data) + input_email = super().validate(input_data, **kwargs) # Validate string with regular expression regex_match = self.email_regex.fullmatch(input_email) diff --git a/src/validataclass/validators/enum_validator.py b/src/validataclass/validators/enum_validator.py index 763707c..ab1d2b6 100644 --- a/src/validataclass/validators/enum_validator.py +++ b/src/validataclass/validators/enum_validator.py @@ -80,12 +80,12 @@ def __init__(self, enum_cls: Type[Enum], *, allowed_values: List[Any] = None, al # Initialize base AnyOfValidator super().__init__(allowed_values=any_of_values, allowed_types=allowed_types) - def validate(self, input_data: Any) -> Enum: + def validate(self, input_data: Any, **kwargs) -> Enum: """ Validate input to be a valid value of the specified Enum. Returns the Enum member. """ # Validate input using the AnyOfValidator first - input_data = super().validate(input_data) + input_data = super().validate(input_data, **kwargs) # Try to convert value to enum member try: diff --git a/src/validataclass/validators/float_to_decimal_validator.py b/src/validataclass/validators/float_to_decimal_validator.py index 998de83..72a671c 100644 --- a/src/validataclass/validators/float_to_decimal_validator.py +++ b/src/validataclass/validators/float_to_decimal_validator.py @@ -107,7 +107,7 @@ def __init__( if allow_strings: self.allowed_types.append(str) - def validate(self, input_data: Any) -> Decimal: + def validate(self, input_data: Any, **kwargs) -> Decimal: """ Validate input data as a float (optionally also as integer or string), then convert it to a Decimal object. """ @@ -122,4 +122,4 @@ def validate(self, input_data: Any) -> Decimal: input_str = str(input_data) # Parse decimal strings using the base DecimalValidator - return super().validate(input_str) + return super().validate(input_str, **kwargs) diff --git a/src/validataclass/validators/float_validator.py b/src/validataclass/validators/float_validator.py index d1803cd..d0b6c1c 100644 --- a/src/validataclass/validators/float_validator.py +++ b/src/validataclass/validators/float_validator.py @@ -78,7 +78,7 @@ def __init__( self.max_value = float(max_value) if max_value is not None else None self.allow_integers = allow_integers - def validate(self, input_data: Any) -> float: + def validate(self, input_data: Any, **kwargs) -> float: """ Validate type (and optionally value) of input data. Returns unmodified float. """ diff --git a/src/validataclass/validators/integer_validator.py b/src/validataclass/validators/integer_validator.py index fdf7b77..13631d1 100644 --- a/src/validataclass/validators/integer_validator.py +++ b/src/validataclass/validators/integer_validator.py @@ -88,7 +88,7 @@ def __init__( self.max_value = max_value self.allow_strings = allow_strings - def validate(self, input_data: Any) -> int: + def validate(self, input_data: Any, **kwargs) -> int: """ Validate type (and optionally value) of input data. Returns unmodified integer. """ diff --git a/src/validataclass/validators/regex_validator.py b/src/validataclass/validators/regex_validator.py index 67178e3..73fc4dc 100644 --- a/src/validataclass/validators/regex_validator.py +++ b/src/validataclass/validators/regex_validator.py @@ -131,14 +131,14 @@ def __init__( self.custom_error_class = custom_error_class self.custom_error_code = custom_error_code - def validate(self, input_data: Any) -> str: + def validate(self, input_data: Any, **kwargs) -> str: """ Validate input as string and match full string against regular expression. Returns unmodified string, unless when output template was supplied. """ # Validate input with base StringValidator (checks length requirements) - output = super().validate(input_data) + output = super().validate(input_data, **kwargs) # Match full string against Regex pattern match = self.regex_pattern.fullmatch(input_data) diff --git a/src/validataclass/validators/reject_validator.py b/src/validataclass/validators/reject_validator.py index 7f6e58d..750305a 100644 --- a/src/validataclass/validators/reject_validator.py +++ b/src/validataclass/validators/reject_validator.py @@ -92,7 +92,7 @@ def __init__( self.error_code = error_code self.error_reason = error_reason - def validate(self, input_data: Any) -> None: + def validate(self, input_data: Any, **kwargs) -> None: """ Validate input data. In this case, reject any value (except for `None` if allow_none is set). """ diff --git a/src/validataclass/validators/string_validator.py b/src/validataclass/validators/string_validator.py index 75957ed..c16d7cc 100644 --- a/src/validataclass/validators/string_validator.py +++ b/src/validataclass/validators/string_validator.py @@ -99,7 +99,7 @@ def __init__( self.allow_multiline = multiline self.unsafe = unsafe - def validate(self, input_data: Any) -> str: + def validate(self, input_data: Any, **kwargs) -> str: """ Validate input data to be a valid string, optionally checking length and allowed characters. diff --git a/src/validataclass/validators/time_validator.py b/src/validataclass/validators/time_validator.py index 14f0dcf..9b6ebf0 100644 --- a/src/validataclass/validators/time_validator.py +++ b/src/validataclass/validators/time_validator.py @@ -92,12 +92,12 @@ def __init__(self, time_format: TimeFormat = TimeFormat.WITH_SECONDS): self.time_format = time_format self.time_format_regex = re.compile(self.time_format.regex_str) - def validate(self, input_data: Any) -> time: + def validate(self, input_data: Any, **kwargs) -> time: """ Validate input as a valid time string and convert it to a `datetime.time` object. """ # First, validate input data as string - time_string = super().validate(input_data) + time_string = super().validate(input_data, **kwargs) # Validate string format with a regular expression if not self.time_format_regex.fullmatch(time_string): diff --git a/src/validataclass/validators/url_validator.py b/src/validataclass/validators/url_validator.py index 49035d1..171281e 100644 --- a/src/validataclass/validators/url_validator.py +++ b/src/validataclass/validators/url_validator.py @@ -128,12 +128,12 @@ def __init__( self.allow_userinfo = allow_userinfo self.allow_empty = allow_empty - def validate(self, input_data: Any) -> str: + def validate(self, input_data: Any, **kwargs) -> str: """ Validate that input is a valid URL string. Returns unmodified string. """ # Validate input data as string - input_url = super().validate(input_data) + input_url = super().validate(input_data, **kwargs) # Short-circuit: check if the string is empty and return it if allow_empty is True if input_url == "" and self.allow_empty: diff --git a/src/validataclass/validators/validator.py b/src/validataclass/validators/validator.py index 9a641ba..bc049be 100644 --- a/src/validataclass/validators/validator.py +++ b/src/validataclass/validators/validator.py @@ -5,6 +5,7 @@ """ import inspect +import warnings from abc import ABC, abstractmethod from typing import Any, Union, List @@ -20,11 +21,28 @@ class Validator(ABC): Base class for building extendable validator classes that validate, sanitize and transform input. """ + def __init_subclass__(cls, **kwargs): + # Check if subclasses are future-proof + if inspect.getfullargspec(cls.validate).varkw is None: + warnings.warn( + "Validator classes will be required to accept arbitrary keyword arguments in their validate() method " + f"in the future. Please add **kwargs to the list of parameters of {cls.__name__}.validate().", + DeprecationWarning + ) + + super().__init_subclass__(**kwargs) + @abstractmethod # pragma: nocover - def validate(self, input_data: Any): + def validate(self, input_data: Any, **kwargs): """ - Validates any input data with the given Validator class. - Returns sanitized data or raises a `ValidationError` (or any subclass). + Validates input data. Returns sanitized data or raises a `ValidationError` (or any subclass). + + This method must be implemented in validator class. + + Note: When implementing a validator class, make sure to add `**kwargs` to the method parameters. This base + method currently does not have this parameter for compatibility reasons. However, this will change in the + future, making it mandatory for a Validator subclass to accept arbitrary keyword arguments (which can be used + for context-sensitive validation). """ raise NotImplementedError() diff --git a/tests/validators/dataclass_validator_test.py b/tests/validators/dataclass_validator_test.py index e041bc1..f32f40a 100644 --- a/tests/validators/dataclass_validator_test.py +++ b/tests/validators/dataclass_validator_test.py @@ -92,7 +92,7 @@ class UnitTestContextSensitiveDataclass: when the context argument "value_required" is set. """ name: str = UnitTestContextValidator() - value: Optional[int] = IntegerValidator(), Default(None) + value: Optional[int] = (IntegerValidator(), Default(None)) def __post_validate__(self, *, value_required: bool = False, **_kwargs): if value_required and self.value is None: diff --git a/tests/validators/validator_test.py b/tests/validators/validator_test.py new file mode 100644 index 0000000..860daad --- /dev/null +++ b/tests/validators/validator_test.py @@ -0,0 +1,30 @@ +""" +validataclass +Copyright (c) 2022, binary butterfly GmbH and contributors +Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. +""" + +from typing import Any + +import pytest + +from validataclass.validators import Validator + + +class ValidatorTest: + """ + Unit tests for the Validator base class. + """ + + @staticmethod + def test_validate_without_kwargs_deprecation(): + """ Test that creating a Validator subclass that does not accept context arguments raises a deprecration warning. """ + # Ensure that Validator creation causes a DeprecationWarning + with pytest.deprecated_call(): + class ValidatorWithoutKwargs(Validator): + def validate(self, input_data: Any) -> Any: # noqa (missing parameter) + return input_data + + # Check that validate_with_context() calls validate() without errors + validator = ValidatorWithoutKwargs() + assert validator.validate_with_context('banana', foo=42, bar=13) == 'banana' From 9bf7e569181fb880a98ae5f9c272f1a59cff0371 Mon Sep 17 00:00:00 2001 From: Lexi Stelter Date: Wed, 21 Sep 2022 17:26:34 +0200 Subject: [PATCH 6/6] Fix linter error --- tests/test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_utils.py b/tests/test_utils.py index f12d661..446c717 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,6 +8,7 @@ from validataclass.validators import Validator + def unpack_params(*args) -> List[tuple]: """ Returns a list containing tuples build from the arguments. Arguments that are lists are "unpacked" by combining the other elements