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
34 changes: 29 additions & 5 deletions docs/03-basic-validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/validataclass/exceptions/misc_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand 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)
67 changes: 52 additions & 15 deletions src/validataclass/validators/any_of_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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
14 changes: 13 additions & 1 deletion src/validataclass/validators/enum_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down Expand Up @@ -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.
Expand All @@ -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):
Expand All @@ -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:
"""
Expand Down
90 changes: 84 additions & 6 deletions tests/validators/any_of_validator_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']])
Expand Down Expand Up @@ -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]])
Expand All @@ -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']])
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading