From 5f84abf9ed189a4d5dadfd99d919d9de2310c842 Mon Sep 17 00:00:00 2001 From: Devon Peticolas Date: Mon, 4 Dec 2023 15:34:45 -0500 Subject: [PATCH 1/4] Add support for generic aliases such as list and dict --- dataclass_type_validator/__init__.py | 27 +++++++--- tests/test_validator.py | 76 ++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/dataclass_type_validator/__init__.py b/dataclass_type_validator/__init__.py index ebf4e1a..aa9e050 100644 --- a/dataclass_type_validator/__init__.py +++ b/dataclass_type_validator/__init__.py @@ -5,10 +5,10 @@ from typing import Any from typing import Optional from typing import Dict +import types GlobalNS_T = Dict[str, Any] - class TypeValidationError(Exception): """Exception raised on type validation errors. """ @@ -109,15 +109,22 @@ def _validate_typing_literal(expected_type: type, value: Any, strict: bool) -> O _validate_typing_mappings = { 'List': _validate_typing_list, + 'list': _validate_typing_list, 'Tuple': _validate_typing_tuple, + 'tuple': _validate_typing_tuple, 'FrozenSet': _validate_typing_frozenset, 'Dict': _validate_typing_dict, + 'dict': _validate_typing_dict, 'Callable': _validate_typing_callable, } +def _type_name(t: type) -> str: + return t._name if hasattr(t, '_name') else t.__name__ + + def _validate_sequential_types(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: - validate_func = _validate_typing_mappings.get(expected_type._name) + validate_func = _validate_typing_mappings.get(_type_name(expected_type)) if validate_func is not None: return validate_func(expected_type, value, strict, globalns) @@ -132,16 +139,22 @@ def _validate_sequential_types(expected_type: type, value: Any, strict: bool, gl return if strict: - raise RuntimeError(f'Unknown type of {expected_type} (_name = {expected_type._name})') + raise RuntimeError(f'Unknown type of {expected_type} (_name = {_type_name(expected_type)}') +def _is_generic_alias(expected_type: type) -> bool: + if sys.version_info < (3, 9): + return isinstance(expected_type, typing._GenericAlias) + else: + return isinstance(expected_type, (typing._GenericAlias, types.GenericAlias)) -def _validate_types(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: - if isinstance(expected_type, type): - return _validate_type(expected_type=expected_type, value=value) - if isinstance(expected_type, typing._GenericAlias): +def _validate_types(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: + if _is_generic_alias(expected_type): return _validate_sequential_types(expected_type=expected_type, value=value, strict=strict, globalns=globalns) + + if isinstance(expected_type, type): + return _validate_type(expected_type=expected_type, value=value) if isinstance(expected_type, typing.ForwardRef): referenced_type = _evaluate_forward_reference(expected_type, globalns) diff --git a/tests/test_validator.py b/tests/test_validator.py index b54d493..0b443a0 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -199,6 +199,8 @@ def test_build_failure(self): ), DataclassTestDict) + + @dataclasses.dataclass(frozen=True) class DataclassTestCallable: func: typing.Callable[[int, int], int] @@ -361,3 +363,77 @@ def optional_type_name(arg_type_name): return f"typing.Union\\[({arg_type_name}, NoneType|NoneType, {arg_type_name})\\]" return f"typing.Optional\\[{arg_type_name}\\]" + +# Tests for generic types, only in 3.9+ +if sys.version_info >= (3, 9): + + @dataclasses.dataclass(frozen=True) + class DataclassTestGenericList: + array_of_numbers: list[int] + array_of_strings: list[str] + array_of_optional_strings: list[typing.Optional[str]] + + def __post_init__(self): + dataclass_type_validator(self) + + + class TestTypeValidationGenericList: + def test_build_success(self): + assert isinstance(DataclassTestGenericList( + array_of_numbers=[], + array_of_strings=[], + array_of_optional_strings=[], + ), DataclassTestGenericList) + assert isinstance(DataclassTestGenericList( + array_of_numbers=[1, 2], + array_of_strings=['abc'], + array_of_optional_strings=['abc', None] + ), DataclassTestGenericList) + + def test_build_failure_on_array_numbers(self): + with pytest.raises(TypeValidationError, match='must be an instance of list\\[int\\]'): + assert isinstance(DataclassTestGenericList( + array_of_numbers=['abc'], + array_of_strings=['abc'], + array_of_optional_strings=['abc', None] + ), DataclassTestGenericList) + + def test_build_failure_on_array_strings(self): + with pytest.raises(TypeValidationError, match='must be an instance of list\\[str\\]'): + assert isinstance(DataclassTestGenericList( + array_of_numbers=[1, 2], + array_of_strings=[123], + array_of_optional_strings=['abc', None] + ), DataclassTestGenericList) + + def test_build_failure_on_array_optional_strings(self): + with pytest.raises(TypeValidationError, + match=f"must be an instance of list\\[{optional_type_name('str')}\\]"): + assert isinstance(DataclassTestGenericList( + array_of_numbers=[1, 2], + array_of_strings=['abc'], + array_of_optional_strings=[123, None] + ), DataclassTestGenericList) + + @dataclasses.dataclass(frozen=True) + class DataclassTestGenericDict: + str_to_str: dict[str, str] + str_to_any: dict[str, typing.Any] + + def __post_init__(self): + dataclass_type_validator(self) + + + class TestTypeValidationGenericDict: + def test_build_success(self): + assert isinstance(DataclassTestGenericDict( + str_to_str={'str': 'str'}, + str_to_any={'str': 'str', 'str2': 123} + ), DataclassTestGenericDict) + + def test_build_failure(self): + with pytest.raises(TypeValidationError, match='must be an instance of dict\\[str, str\\]'): + assert isinstance(DataclassTestGenericDict( + str_to_str={'str': 123}, + str_to_any={'key': []} + ), DataclassTestGenericDict) From 97149d8a30c95680b20a8b02aeeecf5fa2f44755 Mon Sep 17 00:00:00 2001 From: Devon Peticolas Date: Mon, 4 Dec 2023 15:51:03 -0500 Subject: [PATCH 2/4] Add frozenset --- dataclass_type_validator/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dataclass_type_validator/__init__.py b/dataclass_type_validator/__init__.py index aa9e050..0a418e7 100644 --- a/dataclass_type_validator/__init__.py +++ b/dataclass_type_validator/__init__.py @@ -113,6 +113,7 @@ def _validate_typing_literal(expected_type: type, value: Any, strict: bool) -> O 'Tuple': _validate_typing_tuple, 'tuple': _validate_typing_tuple, 'FrozenSet': _validate_typing_frozenset, + 'frozenset': _validate_typing_frozenset, 'Dict': _validate_typing_dict, 'dict': _validate_typing_dict, 'Callable': _validate_typing_callable, From 996973a95df20573e3f16995cbc475ea0cf8f0f2 Mon Sep 17 00:00:00 2001 From: Devon Peticolas Date: Mon, 4 Dec 2023 16:09:18 -0500 Subject: [PATCH 3/4] whitespace --- dataclass_type_validator/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dataclass_type_validator/__init__.py b/dataclass_type_validator/__init__.py index 0a418e7..5a09dca 100644 --- a/dataclass_type_validator/__init__.py +++ b/dataclass_type_validator/__init__.py @@ -9,6 +9,7 @@ GlobalNS_T = Dict[str, Any] + class TypeValidationError(Exception): """Exception raised on type validation errors. """ From 2a997bd2728a948f24ec89835c42fb145022e582 Mon Sep 17 00:00:00 2001 From: Devon Peticolas Date: Mon, 4 Dec 2023 16:11:49 -0500 Subject: [PATCH 4/4] whitespace --- tests/test_validator.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_validator.py b/tests/test_validator.py index 0b443a0..5614a3b 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -199,8 +199,6 @@ def test_build_failure(self): ), DataclassTestDict) - - @dataclasses.dataclass(frozen=True) class DataclassTestCallable: func: typing.Callable[[int, int], int]