diff --git a/docs/03-basic-validators.md b/docs/03-basic-validators.md index e3a1d60..358394a 100644 --- a/docs/03-basic-validators.md +++ b/docs/03-basic-validators.md @@ -836,17 +836,25 @@ These values can potentially be of any type, although strings and integers are p ### AnyOfValidator The `AnyOfValidator` is defined with a simple list of allowed values and accepts only values that are part of this list. -The values will be returned unmodified. +The values will be returned as defined in the list. + +By default, strings will be matched case-sensitively. To change this, set `case_insensitive=True`. In that case, the +value will always be returned as it is defined in the list of allowed values (e.g. if the allowed values contain +"Apple", then "APPLE" and "apple" will be valid input too, but in all cases "Apple" will be returned). The list of allowed values may contain mixed types (e.g. `['banana', 123, True, None]`). Also the allowed values can be specified with any iterable, not just as a list (e.g. as a set or tuple). -Like most other validators, the validator will first check the type of input data and will raise an `InvalidTypeError` for types that -are not allowed. Those allowed types will be automatically determined from the list of values by default (e.g. with `['foo', 'bar', 'baz']` -only strings will be accepted, while the mixed type example from above will accept all of `str`, `int`, `bool` and `NoneType`). +Like most other validators, the validator will first check the type of input data and will raise an `InvalidTypeError` +for types that are not allowed. Those allowed types will be automatically determined from the list of values by default +(e.g. with `['foo', 'bar', 'baz']` only strings will be accepted, while the mixed type example from above will accept +all of `str`, `int`, `bool` and `NoneType`). Optionally the allowed types can be explicitly specified using the parameter `allowed_types`. +If the input value is not valid (but has the correct type), a `ValueNotAllowedError` will be raised. This error will +include the list of allowed values (as "allowed_values"), as long as this list is not longer than 20 items. + **Examples:** ```python @@ -859,6 +867,12 @@ validator.validate('strawberry') # will return 'strawberry' validator.validate('pineapple') # will raise ValueNotAllowedError() validator.validate(1) # will raise InvalidTypeError(expected_type='str') +# Accept strings with case-insensitive matching +validator = AnyOfValidator(['Apple', 'Banana', 'Strawberry'], case_insensitive=True) +validator.validate('apple') # will return 'Apple' +validator.validate('bAnAnA') # will return 'Banana' +validator.validate('STRAWBERRY') # will return 'Strawberry' + # Accept a list of values of mixed types validator = AnyOfValidator(['banana', 123, True, None]) validator.validate('banana') # will return 'banana' @@ -884,6 +898,8 @@ The `EnumValidator` is an extended `AnyOfValidator` that uses `Enum` classes ins It accepts the **values** of the Enum and converts the input value to the according enum **member**. +Strings will be matched case-sensitively by default. To change this, set `case_insensitive=True`. + By default all values in the Enum are accepted as input. This can be optionally restricted by specifying the `allowed_values` parameter, which will override the list of allowed values. Values in this list that are not valid for the Enum will be silently ignored. @@ -892,10 +908,12 @@ If you just want to disallow certain values without manually specifying all of t `allowed_values` parameter as a set and use some set magic. For example, `allowed_values=set(MyEnum) - {MyEnum.BadValue}` would allow all values of `MyEnum` except for `MyEnum.BadValue`. - The types allowed for input data will be automatically determined from the allowed Enum values by default, unless explicitly specified with the parameter `allowed_types`. +If the input value is not valid (but has the correct type), a `ValueNotAllowedError` will be raised. This error will +include the list of allowed values (as "allowed_values"), as long as this list is not longer than 20 items. + **Examples:** ```python @@ -923,6 +941,12 @@ validator.validate('strawberry') # will return ExampleStringEnum.STRAWBERRY validator.validate('pineapple') # will raise ValueNotAllowedError validator.validate(123) # will raise InvalidTypeError(expected_type='str') +# Accept all values of the ExampleStringEnum with case-insensitive string matching +validator = EnumValidator(ExampleStringEnum, case_insensitive=True) +validator.validate('Apple') # will return ExampleStringEnum.APPLE +validator.validate('bAnAnA') # will return ExampleStringEnum.BANANA +validator.validate('STRAWBERRY') # will return ExampleStringEnum.STRAWBERRY + # Default: Accept all values of the ExampleIntegerEnum validator = EnumValidator(ExampleIntegerEnum) validator.validate(1) # will return ExampleIntegerEnum.FOO diff --git a/src/validataclass/exceptions/misc_exceptions.py b/src/validataclass/exceptions/misc_exceptions.py index 8e3b8fe..c042cf4 100644 --- a/src/validataclass/exceptions/misc_exceptions.py +++ b/src/validataclass/exceptions/misc_exceptions.py @@ -4,6 +4,8 @@ Use of this source code is governed by an MIT-style license that can be found in the LICENSE file. """ +from typing import Optional + from validataclass.exceptions import ValidationError __all__ = [ @@ -15,7 +17,10 @@ class ValueNotAllowedError(ValidationError): """ - Validation error raised by `AnyOfValidator` and `EnumValidator` when the input value is not an element in the specified list of - allowed values. + Validation error raised by `AnyOfValidator` and `EnumValidator` when the input value is not an element in the + specified list of allowed values. """ code = 'value_not_allowed' + + def __init__(self, *, allowed_values: Optional[list] = None, **kwargs): + super().__init__(allowed_values=allowed_values, **kwargs) diff --git a/src/validataclass/validators/any_of_validator.py b/src/validataclass/validators/any_of_validator.py index faa3008..63ca429 100644 --- a/src/validataclass/validators/any_of_validator.py +++ b/src/validataclass/validators/any_of_validator.py @@ -17,38 +17,64 @@ class AnyOfValidator(Validator): """ Validator that checks an input value against a specified list of allowed values. If the value is contained in the - list, the value is returned unmodified. + list, the value is returned. The allowed values can be specified with any iterable (e.g. a list, a set, a tuple, a generator expression, ...). The types allowed for input data will be automatically determined from the list of allowed values by default, unless explicitly specified with the parameter 'allowed_types'. + By default, strings will be matched case-sensitively. To change this, set `case_insensitive=True`. In that case, + the value will always be returned as it is defined in the list of allowed values (e.g. if the allowed values contain + "Apple", then "APPLE" and "apple" will be valid input too, but in all cases "Apple" will be returned). + + If the input value is not valid (but has the correct type), a ValueNotAllowedError (code='value_not_allowed') will + be raised. This error will include the list of allowed values (as "allowed_values"), as long as this list is not + longer than 20 items. (This limit is defined in the attribute `max_allowed_values_in_validation_error`, which cannot + be changed via parameters as of now, but can be changed by subclassing the validator and changing the value.) + Examples: ``` + # Accepts "apple", "banana", "strawberry" (but not "APPLE" or "Banana") AnyOfValidator(['apple', 'banana', 'strawberry']) + + # Accepts the same values, but case-insensitively. Always returns the defined string (e.g. "apple" -> "Apple"). + AnyOfValidator(['Apple', 'Banana', 'Strawberry'], case_insensitive=True) ``` See also: `EnumValidator` (same principle but using Enum classes instead of raw value lists) Valid input: All values contained in allowed_values - Output: Unmodified input (if valid) + Output: Value as defined in allowed_values """ + # If the list of allowed values is longer than this value, do not include allowed values in ValueNotAllowedError + max_allowed_values_in_validation_error: int = 20 + # Values allowed as input allowed_values: List[Any] = None # Types allowed for input data (set by parameter or autodetermined from allowed_values) allowed_types: List[type] = None - def __init__(self, allowed_values: Iterable[Any], *, allowed_types: Optional[Union[type, Iterable[type]]] = None): + # Check strings case-insensitively + case_insensitive: bool = False + + def __init__( + self, + allowed_values: Iterable[Any], + *, + allowed_types: Optional[Union[type, Iterable[type]]] = None, + case_insensitive: bool = False, + ): """ Create an AnyOfValidator with a specified list of allowed values. Parameters: allowed_values: List (or any other iterable) of values that are allowed as input (required) allowed_types: Types that are allowed for input data (default: None, autodetermine types from allowed_values) + case_insensitive: If set, strings will be matched case-insensitively (default: False) """ # Save list of allowed values self.allowed_values = list(allowed_values) @@ -65,9 +91,11 @@ def __init__(self, allowed_values: Iterable[Any], *, allowed_types: Optional[Uni if len(self.allowed_types) == 0: raise InvalidValidatorOptionException('Parameter "allowed_types" is an empty list (or types could not be autodetermined).') + self.case_insensitive = case_insensitive + def validate(self, input_data: Any, **kwargs) -> Any: """ - Validate that input is in the list of allowed values. Returns the value unmodified. + Validate that input is in the list of allowed values. Returns the value (as defined in the list). """ # Special case to allow None as value if None is in the allowed_values list (bypasses _ensure_type()) if None in self.allowed_values and input_data is None: @@ -77,18 +105,27 @@ def validate(self, input_data: Any, **kwargs) -> Any: self._ensure_type(input_data, self.allowed_types) # Check if input is in the list of allowed values - if not self._is_allowed_value(input_data): - raise ValueNotAllowedError() + for allowed_value in self.allowed_values: + if self._compare_values(input_data, allowed_value): + return allowed_value - return input_data + # Invalid input (only include list of allowed values in validation error if the list is not too long) + if len(self.allowed_values) <= self.max_allowed_values_in_validation_error: + raise ValueNotAllowedError(allowed_values=self.allowed_values) + else: + raise ValueNotAllowedError() - def _is_allowed_value(self, input_value: Any): + def _compare_values(self, input_value: Any, allowed_value: Any) -> bool: """ - Checks if an input value is in the list of allowed values. + Returns True if input value and allowed value are equal, in the sense of this validator (e.g. case-insensitively + if that option is set). """ - # Note: We cannot simply use the "in" operator here because it's not fully typesafe for integers and booleans. (See issue #1.) - # (E.g. all of the following expressions are True according to Python: 1 in [True], 0 in [False], True in [1], False in [0]) - for value in self.allowed_values: - if type(input_value) is type(value) and input_value == value: - return True - return False + # We need to make sure the check is typesafe (e.g. because 1 == True and 0 == False) + if type(input_value) is not type(allowed_value): + return False + + # Compare strings case-insensitively (if option is set) + if type(input_value) is str and self.case_insensitive: + return input_value.lower() == allowed_value.lower() + else: + return input_value == allowed_value diff --git a/src/validataclass/validators/enum_validator.py b/src/validataclass/validators/enum_validator.py index d46ef31..0841fd8 100644 --- a/src/validataclass/validators/enum_validator.py +++ b/src/validataclass/validators/enum_validator.py @@ -36,6 +36,12 @@ class EnumValidator(Generic[T_Enum], AnyOfValidator): The types allowed for input data will be automatically determined from the allowed Enum values by default, unless explicitly specified with the parameter `allowed_types`. + By default, strings will be matched case-sensitively. To change this, set `case_insensitive=True`. + + If the input value is not valid (but has the correct type), a ValueNotAllowedError (code='value_not_allowed') will + be raised. This error will include the list of allowed values (as "allowed_values"), as long as this list is not + longer than 20 items. (See `AnyOfValidator`.) + Examples: ``` @@ -67,6 +73,7 @@ def __init__( *, allowed_values: Optional[Iterable[Any]] = None, allowed_types: Optional[Union[type, Iterable[type]]] = None, + case_insensitive: bool = False, ): """ Create a EnumValidator for a specified Enum class, optionally with a restricted list of allowed values. @@ -75,6 +82,7 @@ def __init__( enum_cls: Enum class to use for validation (required) allowed_values: List (or iterable) of values from the Enum that are accepted (default: None, all Enum values allowed) allowed_types: List (or iterable) of types allowed for input data (default: None, autodetermine types from enum values) + case_insensitive: If set, strings will be matched case-insensitively (default: False) """ # Ensure parameter is an Enum class if not isinstance(enum_cls, EnumMeta): @@ -94,7 +102,11 @@ def __init__( any_of_values = enum_values # Initialize base AnyOfValidator - super().__init__(allowed_values=any_of_values, allowed_types=allowed_types) + super().__init__( + allowed_values=any_of_values, + allowed_types=allowed_types, + case_insensitive=case_insensitive, + ) def validate(self, input_data: Any, **kwargs) -> T_Enum: """ diff --git a/tests/validators/any_of_validator_test.py b/tests/validators/any_of_validator_test.py index a227749..274ef53 100644 --- a/tests/validators/any_of_validator_test.py +++ b/tests/validators/any_of_validator_test.py @@ -32,13 +32,16 @@ def test_string_values_valid(): assert validator.validate('strawberry') == 'strawberry' @staticmethod - @pytest.mark.parametrize('input_data', ['', 'bananana', 'red']) + @pytest.mark.parametrize('input_data', ['', 'bananana', 'red', 'STRAWBERRY']) def test_string_values_invalid_value(input_data): """ Test AnyOfValidator with string value list with invalid input. """ validator = AnyOfValidator(['red apple', 'green apple', 'strawberry']) with pytest.raises(ValueNotAllowedError) as exception_info: validator.validate(input_data) - assert exception_info.value.to_dict() == {'code': 'value_not_allowed'} + assert exception_info.value.to_dict() == { + 'code': 'value_not_allowed', + 'allowed_values': ['red apple', 'green apple', 'strawberry'], + } @staticmethod @pytest.mark.parametrize('input_data', [1, 1.234, True, ['red apple']]) @@ -70,7 +73,11 @@ def test_integer_values_invalid_value(input_data): validator = AnyOfValidator({1, -2, 42}) with pytest.raises(ValueNotAllowedError) as exception_info: validator.validate(input_data) - assert exception_info.value.to_dict() == {'code': 'value_not_allowed'} + + # Sets are unordered, so checking the exception dictionary is a bit more annoying here + exception_dict = exception_info.value.to_dict() + assert exception_dict['code'] == 'value_not_allowed' + assert sorted(exception_dict['allowed_values']) == [-2, 1, 42] @staticmethod @pytest.mark.parametrize('input_data', ['banana', 1.234, True, [1]]) @@ -95,13 +102,16 @@ def test_mixed_values_valid(): assert validator.validate(None) is None @staticmethod - @pytest.mark.parametrize('input_data', [0, 13, '', 'banana']) + @pytest.mark.parametrize('input_data', [0, 13, '', 'banana', 'STRAWBERRY']) def test_mixed_values_invalid_value(input_data): """ Test AnyOfValidator with allowed values of mixed types with invalid input. """ validator = AnyOfValidator(allowed_values=('strawberry', 42, None)) with pytest.raises(ValueNotAllowedError) as exception_info: validator.validate(input_data) - assert exception_info.value.to_dict() == {'code': 'value_not_allowed'} + assert exception_info.value.to_dict() == { + 'code': 'value_not_allowed', + 'allowed_values': ['strawberry', 42, None], + } @staticmethod @pytest.mark.parametrize('input_data', [1.234, True, False, [1], ['strawberry']]) @@ -135,7 +145,10 @@ def test_mixed_values_typesafety(value_list, invalid_input): # Check against "false positives" (e.g. don't confuse integer 1 with boolean True, or 0 with False) with pytest.raises(ValueNotAllowedError) as exception_info: validator.validate(invalid_input) - assert exception_info.value.to_dict() == {'code': 'value_not_allowed'} + assert exception_info.value.to_dict() == { + 'code': 'value_not_allowed', + 'allowed_values': value_list, + } # Test AnyOfValidator with explicit allowed_types parameter @@ -168,6 +181,71 @@ def test_with_specified_allowed_type(allowed_types, expected_type_dict, valid_in **expected_type_dict, } + # Test AnyOfValidator with case-insensitive option + + @staticmethod + @pytest.mark.parametrize( + 'case_insensitive, input_data, expected_result', + [ + # Case-sensitive matching + (False, 'Strawberry', 'Strawberry'), + (False, 42, 42), + + # Case-insensitive matching + (True, 'Strawberry', 'Strawberry'), + (True, 'STRAWBERRY', 'Strawberry'), + (True, 'strawberry', 'Strawberry'), + (True, 42, 42), + ] + ) + def test_case_insensitive_valid(case_insensitive, input_data, expected_result): + """ Test AnyOfValidator with case-sensitive and case-insensitive string matching, valid input. """ + validator = AnyOfValidator(allowed_values=['Strawberry', 42], case_insensitive=case_insensitive) + assert validator.validate(input_data) == expected_result + + @staticmethod + @pytest.mark.parametrize( + 'case_insensitive, input_data', + [ + # Case-sensitive matching + (False, 'strawberry'), + (False, 'banana'), + (False, 13), + + # Case-insensitive matching + (True, 'straw_berry'), + (True, 'banana'), + (True, 13), + ] + ) + def test_case_insensitive_invalid(case_insensitive, input_data): + """ Test AnyOfValidator with case-sensitive and case-insensitive string matching, invalid input. """ + validator = AnyOfValidator(allowed_values=['Strawberry', 42], case_insensitive=case_insensitive) + with pytest.raises(ValueNotAllowedError) as exception_info: + validator.validate(input_data) + assert exception_info.value.to_dict() == { + 'code': 'value_not_allowed', + 'allowed_values': ['Strawberry', 42], + } + + # Tests for validation errors + + @staticmethod + def test_value_not_allowed_error_with_too_many_allowed_values(): + """ Test that AnyOfValidator does not include list of allowed values in validation error if too long. """ + validator = AnyOfValidator(allowed_values=range(100)) + + # Valid input + assert validator.validate(0) == 0 + assert validator.validate(99) == 99 + + # Invalid input + with pytest.raises(ValueNotAllowedError) as exception_info: + validator.validate(100) + + # Validation error should NOT contain "allowed_values" + assert exception_info.value.to_dict() == {'code': 'value_not_allowed'} + # Invalid validator parameters @staticmethod diff --git a/tests/validators/enum_validator_test.py b/tests/validators/enum_validator_test.py index 025f89d..506cbe8 100644 --- a/tests/validators/enum_validator_test.py +++ b/tests/validators/enum_validator_test.py @@ -54,13 +54,16 @@ def test_string_enum_valid(): assert validator.validate('strawberry') is UnitTestStringEnum.STRAWBERRY @staticmethod - @pytest.mark.parametrize('input_data', ['', 'bananana', 'APPLE_RED']) + @pytest.mark.parametrize('input_data', ['', 'bananana', 'APPLE_RED', 'STRAWBERRY']) def test_string_enum_invalid_value(input_data): """ Test EnumValidator with string based Enum with invalid enum values. """ validator = EnumValidator(UnitTestStringEnum) with pytest.raises(ValueNotAllowedError) as exception_info: validator.validate(input_data) - assert exception_info.value.to_dict() == {'code': 'value_not_allowed'} + assert exception_info.value.to_dict() == { + 'code': 'value_not_allowed', + 'allowed_values': ['red apple', 'green apple', 'strawberry'], + } @staticmethod @pytest.mark.parametrize('input_data', [1, 1.234, True, ['red apple']]) @@ -91,7 +94,10 @@ def test_integer_enum_invalid_value(input_data): validator = EnumValidator(UnitTestIntegerEnum) with pytest.raises(ValueNotAllowedError) as exception_info: validator.validate(input_data) - assert exception_info.value.to_dict() == {'code': 'value_not_allowed'} + assert exception_info.value.to_dict() == { + 'code': 'value_not_allowed', + 'allowed_values': [1, 42, 13], + } @staticmethod @pytest.mark.parametrize('input_data', ['red apple', 'RED', 1.234, True, [1]]) @@ -121,7 +127,10 @@ def test_mixed_enum_invalid_value(input_data): validator = EnumValidator(UnitTestMixedEnum) with pytest.raises(ValueNotAllowedError) as exception_info: validator.validate(input_data) - assert exception_info.value.to_dict() == {'code': 'value_not_allowed'} + assert exception_info.value.to_dict() == { + 'code': 'value_not_allowed', + 'allowed_values': ['foo', 42], + } @staticmethod @pytest.mark.parametrize('input_data', [1.234, True, [1], ['foo']]) @@ -153,7 +162,10 @@ def test_string_enum_allowed_values_invalid(input_data): validator = EnumValidator(UnitTestStringEnum, allowed_values=['red apple', UnitTestStringEnum.APPLE_GREEN, 'banana']) with pytest.raises(ValueNotAllowedError) as exception_info: validator.validate(input_data) - assert exception_info.value.to_dict() == {'code': 'value_not_allowed'} + assert exception_info.value.to_dict() == { + 'code': 'value_not_allowed', + 'allowed_values': ['red apple', 'green apple'], + } @staticmethod def test_string_enum_allowed_values_as_set(): @@ -193,6 +205,53 @@ def test_with_specified_allowed_type(allowed_types, expected_type_str, valid_inp 'expected_type': expected_type_str, } + # Test EnumValidator with case-insensitive option + + @staticmethod + @pytest.mark.parametrize( + 'case_insensitive, allowed_values, input_data, expected_result', + [ + # Case-sensitive matching + (False, None, 'red apple', UnitTestStringEnum.APPLE_RED), + (False, [UnitTestStringEnum.STRAWBERRY], 'strawberry', UnitTestStringEnum.STRAWBERRY), + + # Case-insensitive matching + (True, None, 'red apple', UnitTestStringEnum.APPLE_RED), + (True, None, 'RED apple', UnitTestStringEnum.APPLE_RED), + (True, [UnitTestStringEnum.STRAWBERRY], 'strawberry', UnitTestStringEnum.STRAWBERRY), + (True, [UnitTestStringEnum.STRAWBERRY], 'Strawberry', UnitTestStringEnum.STRAWBERRY), + (True, [UnitTestStringEnum.STRAWBERRY], 'STRAWBERRY', UnitTestStringEnum.STRAWBERRY), + ] + ) + def test_case_insensitive_valid(case_insensitive, allowed_values, input_data, expected_result): + """ Test EnumValidator with case-sensitive and case-insensitive string matching, valid input. """ + validator = EnumValidator(UnitTestStringEnum, allowed_values=allowed_values, case_insensitive=case_insensitive) + assert validator.validate(input_data) is expected_result + + @staticmethod + @pytest.mark.parametrize( + 'case_insensitive, allowed_values, input_data', + [ + # Case-sensitive matching + (False, None, 'RED APPLE'), + (False, [UnitTestStringEnum.STRAWBERRY], 'red apple'), + (False, [UnitTestStringEnum.STRAWBERRY], 'STRAWberry'), + + # Case-insensitive matching + (True, None, 'banana'), + (True, [UnitTestStringEnum.STRAWBERRY], 'red apple'), + ] + ) + def test_case_insensitive_invalid(case_insensitive, allowed_values, input_data): + """ Test EnumValidator with case-sensitive and case-insensitive string matching, invalid input. """ + validator = EnumValidator(UnitTestStringEnum, allowed_values=allowed_values, case_insensitive=case_insensitive) + with pytest.raises(ValueNotAllowedError) as exception_info: + validator.validate(input_data) + assert exception_info.value.to_dict() == { + 'code': 'value_not_allowed', + 'allowed_values': ['red apple', 'green apple', 'strawberry'] if allowed_values is None else ['strawberry'], + } + # Invalid validator parameters @staticmethod @@ -218,6 +277,5 @@ def test_value_error_in_validate(): validator = EnumValidator(UnitTestStringEnum) validator.allowed_values.append('bananana') - with pytest.raises(ValueNotAllowedError) as exception_info: + with pytest.raises(ValueNotAllowedError): validator.validate('bananana') - assert exception_info.value.to_dict() == {'code': 'value_not_allowed'}