diff --git a/dataclass_type_validator/__init__.py b/dataclass_type_validator/__init__.py index ebf4e1a..5bef55d 100644 --- a/dataclass_type_validator/__init__.py +++ b/dataclass_type_validator/__init__.py @@ -5,13 +5,13 @@ 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. - """ + """Exception raised on type validation errors.""" def __init__(self, *args, target: dataclasses.dataclass, errors: dict): super(TypeValidationError, self).__init__(*args) @@ -41,115 +41,176 @@ def __str__(self): def _validate_type(expected_type: type, value: Any) -> Optional[str]: if not isinstance(value, expected_type): - return f'must be an instance of {expected_type}, but received {type(value)}' + return f"must be an instance of {expected_type}, but received {type(value)}" -def _validate_iterable_items(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: +def _validate_iterable_items( + expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T +) -> Optional[str]: expected_item_type = expected_type.__args__[0] - errors = [_validate_types(expected_type=expected_item_type, value=v, strict=strict, globalns=globalns) - for v in value] + errors = [ + _validate_types( + expected_type=expected_item_type, value=v, strict=strict, globalns=globalns + ) + for v in value + ] errors = [x for x in errors if x] if len(errors) > 0: - return f'must be an instance of {expected_type}, but there are some errors: {errors}' + return f"must be an instance of {expected_type}, but there are some errors: {errors}" -def _validate_typing_list(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: +def _validate_typing_list( + expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T +) -> Optional[str]: if not isinstance(value, list): - return f'must be an instance of list, but received {type(value)}' + return f"must be an instance of list, but received {type(value)}" return _validate_iterable_items(expected_type, value, strict, globalns) -def _validate_typing_tuple(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: +def _validate_typing_tuple( + expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T +) -> Optional[str]: if not isinstance(value, tuple): - return f'must be an instance of tuple, but received {type(value)}' + return f"must be an instance of tuple, but received {type(value)}" return _validate_iterable_items(expected_type, value, strict, globalns) -def _validate_typing_frozenset(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: +def _validate_typing_frozenset( + expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T +) -> Optional[str]: if not isinstance(value, frozenset): - return f'must be an instance of frozenset, but received {type(value)}' + return f"must be an instance of frozenset, but received {type(value)}" return _validate_iterable_items(expected_type, value, strict, globalns) -def _validate_typing_dict(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: +def _validate_typing_dict( + expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T +) -> Optional[str]: if not isinstance(value, dict): - return f'must be an instance of dict, but received {type(value)}' + return f"must be an instance of dict, but received {type(value)}" expected_key_type = expected_type.__args__[0] expected_value_type = expected_type.__args__[1] - key_errors = [_validate_types(expected_type=expected_key_type, value=k, strict=strict, globalns=globalns) - for k in value.keys()] + key_errors = [ + _validate_types( + expected_type=expected_key_type, value=k, strict=strict, globalns=globalns + ) + for k in value.keys() + ] key_errors = [k for k in key_errors if k] - val_errors = [_validate_types(expected_type=expected_value_type, value=v, strict=strict, globalns=globalns) - for v in value.values()] + val_errors = [ + _validate_types( + expected_type=expected_value_type, value=v, strict=strict, globalns=globalns + ) + for v in value.values() + ] val_errors = [v for v in val_errors if v] if len(key_errors) > 0 and len(val_errors) > 0: - return f'must be an instance of {expected_type}, but there are some errors in keys and values. '\ - f'key errors: {key_errors}, value errors: {val_errors}' + return ( + f"must be an instance of {expected_type}, but there are some errors in keys and values. " + f"key errors: {key_errors}, value errors: {val_errors}" + ) elif len(key_errors) > 0: - return f'must be an instance of {expected_type}, but there are some errors in keys: {key_errors}' + return f"must be an instance of {expected_type}, but there are some errors in keys: {key_errors}" elif len(val_errors) > 0: - return f'must be an instance of {expected_type}, but there are some errors in values: {val_errors}' + return f"must be an instance of {expected_type}, but there are some errors in values: {val_errors}" -def _validate_typing_callable(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: +def _validate_typing_callable( + expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T +) -> Optional[str]: _ = strict if not isinstance(value, type(lambda a: a)): - return f'must be an instance of {expected_type._name}, but received {type(value)}' + return ( + f"must be an instance of {expected_type._name}, but received {type(value)}" + ) -def _validate_typing_literal(expected_type: type, value: Any, strict: bool) -> Optional[str]: +def _validate_typing_literal( + expected_type: type, value: Any, strict: bool +) -> Optional[str]: _ = strict if value not in expected_type.__args__: - return f'must be one of [{", ".join(expected_type.__args__)}] but received {value}' + return ( + f"must be one of [{', '.join(expected_type.__args__)}] but received {value}" + ) _validate_typing_mappings = { - 'List': _validate_typing_list, - 'Tuple': _validate_typing_tuple, - 'FrozenSet': _validate_typing_frozenset, - 'Dict': _validate_typing_dict, - 'Callable': _validate_typing_callable, + "List": _validate_typing_list, + "list": _validate_typing_list, + "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, } -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) +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(_type_name(expected_type)) if validate_func is not None: return validate_func(expected_type, value, strict, globalns) - if str(expected_type).startswith('typing.Literal'): + if str(expected_type).startswith("typing.Literal"): return _validate_typing_literal(expected_type, value, strict) - if str(expected_type).startswith('typing.Union') or str(expected_type).startswith('typing.Optional'): - is_valid = any(_validate_types(expected_type=t, value=value, strict=strict, globalns=globalns) is None - for t in expected_type.__args__) + if str(expected_type).startswith("typing.Union") or str(expected_type).startswith( + "typing.Optional" + ): + is_valid = any( + _validate_types( + expected_type=t, value=value, strict=strict, globalns=globalns + ) + is None + for t in expected_type.__args__ + ) if not is_valid: - return f'must be an instance of {expected_type}, but received {value}' + return f"must be an instance of {expected_type}, but received {value}" 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 _validate_types(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: +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 _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._GenericAlias): - return _validate_sequential_types(expected_type=expected_type, value=value, - strict=strict, globalns=globalns) - if isinstance(expected_type, typing.ForwardRef): referenced_type = _evaluate_forward_reference(expected_type, globalns) return _validate_type(expected_type=referenced_type, value=value) def _evaluate_forward_reference(ref_type: typing.ForwardRef, globalns: GlobalNS_T): - """ Support evaluating ForwardRef types on both Python 3.8 and 3.9. """ + """Support evaluating ForwardRef types on both Python 3.8 and 3.9.""" if sys.version_info < (3, 9): return ref_type._evaluate(globalns, None) return ref_type._evaluate(globalns, None, set()) @@ -165,7 +226,9 @@ def dataclass_type_validator(target, strict: bool = False): expected_type = field.type value = getattr(target, field_name) - err = _validate_types(expected_type=expected_type, value=value, strict=strict, globalns=globalns) + err = _validate_types( + expected_type=expected_type, value=value, strict=strict, globalns=globalns + ) if err is not None: errors[field_name] = err @@ -175,7 +238,9 @@ def dataclass_type_validator(target, strict: bool = False): ) -def dataclass_validate(cls=None, *, strict: bool = False, before_post_init: bool = False): +def dataclass_validate( + cls=None, *, strict: bool = False, before_post_init: bool = False +): """Dataclass decorator to automatically add validation to a dataclass. So you don't have to add a __post_init__ method, or if you have one, you don't have @@ -190,7 +255,9 @@ def dataclass_validate(cls=None, *, strict: bool = False, before_post_init: bool validation. Default: False. """ if cls is None: - return functools.partial(dataclass_validate, strict=strict, before_post_init=before_post_init) + return functools.partial( + dataclass_validate, strict=strict, before_post_init=before_post_init + ) if not hasattr(cls, "__post_init__"): # No post-init method, so no processing. Wrap the constructor instead. @@ -215,6 +282,7 @@ def method_wrapper(self, *args, **kwargs): x = orig_method(self, *args, **kwargs) dataclass_type_validator(self, strict=strict) return x + setattr(cls, wrapped_method_name, method_wrapper) return cls diff --git a/src/dataclass_type_validator/__init__.py b/src/dataclass_type_validator/__init__.py new file mode 100644 index 0000000..09f887b --- /dev/null +++ b/src/dataclass_type_validator/__init__.py @@ -0,0 +1,2 @@ +def main() -> None: + print("Hello from dataclass-type-validator!") diff --git a/tests/test_validator.py b/tests/test_validator.py index b54d493..82f019b 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -18,21 +18,23 @@ def __post_init__(self): class TestTypeValidationNumber: def test_build_success(self): - assert isinstance(DataclassTestNumber( - number=1, - optional_number=None, - ), DataclassTestNumber) - assert isinstance(DataclassTestNumber( - number=1, - optional_number=1 - ), DataclassTestNumber) + assert isinstance( + DataclassTestNumber( + number=1, + optional_number=None, + ), + DataclassTestNumber, + ) + assert isinstance( + DataclassTestNumber(number=1, optional_number=1), DataclassTestNumber + ) def test_build_failure_on_number(self): with pytest.raises(TypeValidationError): - assert isinstance(DataclassTestNumber( - number=1, - optional_number='string' - ), DataclassTestNumber) + assert isinstance( + DataclassTestNumber(number=1, optional_number="string"), + DataclassTestNumber, + ) @dataclasses.dataclass(frozen=True) @@ -46,21 +48,21 @@ def __post_init__(self): class TestTypeValidationString: def test_build_success(self): - assert isinstance(DataclassTestString( - string='string', - optional_string=None - ), DataclassTestString) - assert isinstance(DataclassTestString( - string='string', - optional_string='string' - ), DataclassTestString) + assert isinstance( + DataclassTestString(string="string", optional_string=None), + DataclassTestString, + ) + assert isinstance( + DataclassTestString(string="string", optional_string="string"), + DataclassTestString, + ) def test_build_failure_on_string(self): with pytest.raises(TypeValidationError): - assert isinstance(DataclassTestString( - string='str', - optional_string=123 - ), DataclassTestString) + assert isinstance( + DataclassTestString(string="str", optional_string=123), + DataclassTestString, + ) @dataclasses.dataclass(frozen=True) @@ -75,41 +77,62 @@ def __post_init__(self): class TestTypeValidationList: def test_build_success(self): - assert isinstance(DataclassTestList( - array_of_numbers=[], - array_of_strings=[], - array_of_optional_strings=[], - ), DataclassTestList) - assert isinstance(DataclassTestList( - array_of_numbers=[1, 2], - array_of_strings=['abc'], - array_of_optional_strings=['abc', None] - ), DataclassTestList) + assert isinstance( + DataclassTestList( + array_of_numbers=[], + array_of_strings=[], + array_of_optional_strings=[], + ), + DataclassTestList, + ) + assert isinstance( + DataclassTestList( + array_of_numbers=[1, 2], + array_of_strings=["abc"], + array_of_optional_strings=["abc", None], + ), + DataclassTestList, + ) def test_build_failure_on_array_numbers(self): - with pytest.raises(TypeValidationError, match='must be an instance of typing.List\\[int\\]'): - assert isinstance(DataclassTestList( - array_of_numbers=['abc'], - array_of_strings=['abc'], - array_of_optional_strings=['abc', None] - ), DataclassTestList) + with pytest.raises( + TypeValidationError, match="must be an instance of typing.List\\[int\\]" + ): + assert isinstance( + DataclassTestList( + array_of_numbers=["abc"], + array_of_strings=["abc"], + array_of_optional_strings=["abc", None], + ), + DataclassTestList, + ) def test_build_failure_on_array_strings(self): - with pytest.raises(TypeValidationError, match='must be an instance of typing.List\\[str\\]'): - assert isinstance(DataclassTestList( - array_of_numbers=[1, 2], - array_of_strings=[123], - array_of_optional_strings=['abc', None] - ), DataclassTestList) + with pytest.raises( + TypeValidationError, match="must be an instance of typing.List\\[str\\]" + ): + assert isinstance( + DataclassTestList( + array_of_numbers=[1, 2], + array_of_strings=[123], + array_of_optional_strings=["abc", None], + ), + DataclassTestList, + ) def test_build_failure_on_array_optional_strings(self): - with pytest.raises(TypeValidationError, - match=f"must be an instance of typing.List\\[{optional_type_name('str')}\\]"): - assert isinstance(DataclassTestList( - array_of_numbers=[1, 2], - array_of_strings=['abc'], - array_of_optional_strings=[123, None] - ), DataclassTestList) + with pytest.raises( + TypeValidationError, + match=f"must be an instance of typing.List\\[{optional_type_name('str')}\\]", + ): + assert isinstance( + DataclassTestList( + array_of_numbers=[1, 2], + array_of_strings=["abc"], + array_of_optional_strings=[123, None], + ), + DataclassTestList, + ) @dataclasses.dataclass(frozen=True) @@ -123,32 +146,38 @@ def __post_init__(self): class TestTypeValidationUnion: def test_build_success(self): - assert isinstance(DataclassTestUnion( - string_or_number='abc', - optional_string='abc' - ), DataclassTestUnion) - assert isinstance(DataclassTestUnion( - string_or_number=123, - optional_string=None - ), DataclassTestUnion) + assert isinstance( + DataclassTestUnion(string_or_number="abc", optional_string="abc"), + DataclassTestUnion, + ) + assert isinstance( + DataclassTestUnion(string_or_number=123, optional_string=None), + DataclassTestUnion, + ) def test_build_failure(self): - with pytest.raises(TypeValidationError, match='must be an instance of typing.Union\\[str, int\\]'): - assert isinstance(DataclassTestUnion( - string_or_number=None, - optional_string=None - ), DataclassTestUnion) + with pytest.raises( + TypeValidationError, + match="must be an instance of typing.Union\\[str, int\\]", + ): + assert isinstance( + DataclassTestUnion(string_or_number=None, optional_string=None), + DataclassTestUnion, + ) - with pytest.raises(TypeValidationError, match=f'must be an instance of {optional_type_name("str")}'): - assert isinstance(DataclassTestUnion( - string_or_number=123, - optional_string=123 - ), DataclassTestUnion) + with pytest.raises( + TypeValidationError, + match=f"must be an instance of {optional_type_name('str')}", + ): + assert isinstance( + DataclassTestUnion(string_or_number=123, optional_string=123), + DataclassTestUnion, + ) @dataclasses.dataclass(frozen=True) class DataclassTestLiteral: - restricted_value: typing.Literal['foo', 'bar'] + restricted_value: typing.Literal["foo", "bar"] def __post_init__(self): dataclass_type_validator(self, strict=True) @@ -156,23 +185,30 @@ def __post_init__(self): class TestTypeValidationLiteral: def test_build_success(self): - assert isinstance(DataclassTestLiteral( - restricted_value='foo' - ), DataclassTestLiteral) - assert isinstance(DataclassTestLiteral( - restricted_value='bar' - ), DataclassTestLiteral) + assert isinstance( + DataclassTestLiteral(restricted_value="foo"), DataclassTestLiteral + ) + assert isinstance( + DataclassTestLiteral(restricted_value="bar"), DataclassTestLiteral + ) def test_build_failure(self): - with pytest.raises(TypeValidationError, match='must be one of \\[foo, bar\\] but received fizz'): - assert isinstance(DataclassTestLiteral( - restricted_value='fizz' - ), DataclassTestLiteral) + with pytest.raises( + TypeValidationError, match="must be one of \\[foo, bar\\] but received fizz" + ): + assert isinstance( + DataclassTestLiteral(restricted_value="fizz"), DataclassTestLiteral + ) - with pytest.raises(TypeValidationError, match='must be one of \\[foo, bar\\] but received None'): - assert isinstance(DataclassTestLiteral( - restricted_value=None, - ), DataclassTestLiteral) + with pytest.raises( + TypeValidationError, match="must be one of \\[foo, bar\\] but received None" + ): + assert isinstance( + DataclassTestLiteral( + restricted_value=None, + ), + DataclassTestLiteral, + ) @dataclasses.dataclass(frozen=True) @@ -186,17 +222,22 @@ def __post_init__(self): class TestTypeValidationDict: def test_build_success(self): - assert isinstance(DataclassTestDict( - str_to_str={'str': 'str'}, - str_to_any={'str': 'str', 'str2': 123} - ), DataclassTestDict) + assert isinstance( + DataclassTestDict( + str_to_str={"str": "str"}, str_to_any={"str": "str", "str2": 123} + ), + DataclassTestDict, + ) def test_build_failure(self): - with pytest.raises(TypeValidationError, match='must be an instance of typing.Dict\\[str, str\\]'): - assert isinstance(DataclassTestDict( - str_to_str={'str': 123}, - str_to_any={'key': []} - ), DataclassTestDict) + with pytest.raises( + TypeValidationError, + match="must be an instance of typing.Dict\\[str, str\\]", + ): + assert isinstance( + DataclassTestDict(str_to_str={"str": 123}, str_to_any={"key": []}), + DataclassTestDict, + ) @dataclasses.dataclass(frozen=True) @@ -209,21 +250,26 @@ def __post_init__(self): class TestTypeValidationCallable: def test_build_success(self): - assert isinstance(DataclassTestCallable( - func=lambda a, b: a * b - ), DataclassTestCallable) + assert isinstance( + DataclassTestCallable(func=lambda a, b: a * b), DataclassTestCallable + ) def test_build_failure(self): - with pytest.raises(TypeValidationError, match='must be an instance of Callable'): - assert isinstance(DataclassTestCallable( - func=None, - ), DataclassTestCallable) + with pytest.raises( + TypeValidationError, match="must be an instance of Callable" + ): + assert isinstance( + DataclassTestCallable( + func=None, + ), + DataclassTestCallable, + ) @dataclasses.dataclass(frozen=True) class DataclassTestForwardRef: - number: 'int' - ref: typing.Optional['DataclassTestForwardRef'] = None + number: "int" + ref: typing.Optional["DataclassTestForwardRef"] = None def __post_init__(self): dataclass_type_validator(self) @@ -231,21 +277,23 @@ def __post_init__(self): class TestTypeValidationForwardRef: def test_build_success(self): - assert isinstance(DataclassTestForwardRef( - number=1, - ref=None, - ), DataclassTestForwardRef) - assert isinstance(DataclassTestForwardRef( - number=1, - ref=DataclassTestForwardRef(2, None) - ), DataclassTestForwardRef) + assert isinstance( + DataclassTestForwardRef( + number=1, + ref=None, + ), + DataclassTestForwardRef, + ) + assert isinstance( + DataclassTestForwardRef(number=1, ref=DataclassTestForwardRef(2, None)), + DataclassTestForwardRef, + ) def test_build_failure_on_number(self): with pytest.raises(TypeValidationError): - assert isinstance(DataclassTestForwardRef( - number=1, - ref='string' - ), DataclassTestForwardRef) + assert isinstance( + DataclassTestForwardRef(number=1, ref="string"), DataclassTestForwardRef + ) @dataclasses.dataclass(frozen=True) @@ -266,16 +314,14 @@ def __post_init__(self): class TestNestedDataclass: def test_build_success(self): - assert isinstance(ParentValue( - child=ChildValue(child='string') - ), ParentValue) + assert isinstance(ParentValue(child=ChildValue(child="string")), ParentValue) def test_build_failure(self): - with pytest.raises(TypeValidationError, - match="must be an instance of "): - assert isinstance(ParentValue( - child=None - ), ParentValue) + with pytest.raises( + TypeValidationError, + match="must be an instance of ", + ): + assert isinstance(ParentValue(child=None), ParentValue) @dataclass_validate @@ -290,21 +336,21 @@ def __post_init__(self): class TestDecoratorWithPostInit: def test_build_success(self): - assert isinstance(DataclassWithPostInitTestDecorator( - number=1, - optional_number=None, - ), DataclassWithPostInitTestDecorator) - assert isinstance(DataclassWithPostInitTestDecorator( - number=1, - optional_number=1 - ), DataclassWithPostInitTestDecorator) + assert isinstance( + DataclassWithPostInitTestDecorator( + number=1, + optional_number=None, + ), + DataclassWithPostInitTestDecorator, + ) + assert isinstance( + DataclassWithPostInitTestDecorator(number=1, optional_number=1), + DataclassWithPostInitTestDecorator, + ) def test_build_failure_on_number(self): with pytest.raises(TypeValidationError): - _ = DataclassWithPostInitTestDecorator( - number=1, - optional_number='string' - ) + _ = DataclassWithPostInitTestDecorator(number=1, optional_number="string") @dataclass_validate @@ -316,20 +362,22 @@ class DataclassWithoutPostInitTestDecorator: class TestDecoratorWithoutPostInit: def test_build_success(self): - assert isinstance(DataclassWithoutPostInitTestDecorator( - number=1, - optional_number=None, - ), DataclassWithoutPostInitTestDecorator) - assert isinstance(DataclassWithoutPostInitTestDecorator( - number=1, - optional_number=1 - ), DataclassWithoutPostInitTestDecorator) + assert isinstance( + DataclassWithoutPostInitTestDecorator( + number=1, + optional_number=None, + ), + DataclassWithoutPostInitTestDecorator, + ) + assert isinstance( + DataclassWithoutPostInitTestDecorator(number=1, optional_number=1), + DataclassWithoutPostInitTestDecorator, + ) def test_build_failure_on_number(self): with pytest.raises(TypeValidationError): _ = DataclassWithoutPostInitTestDecorator( - number=1, - optional_number='string' + number=1, optional_number="string" ) @@ -341,9 +389,12 @@ class DataclassWithStrictChecking: class TestDecoratorStrict: def test_build_success(self): - assert isinstance(DataclassWithStrictChecking( - values=[1, 2, 3], - ), DataclassWithStrictChecking) + assert isinstance( + DataclassWithStrictChecking( + values=[1, 2, 3], + ), + DataclassWithStrictChecking, + ) def test_build_failure_on_number(self): with pytest.raises(TypeValidationError): @@ -353,11 +404,114 @@ def test_build_failure_on_number(self): def optional_type_name(arg_type_name): - """ Gets the typename string for an typing.Optional. - On python 3.8 an Optional[int] is converted to a typing.Union[int, NoneType]. - On python 3.9 it remains unchanged as Optional[int]. + """Gets the typename string for an typing.Optional. + On python 3.8 an Optional[int] is converted to a typing.Union[int, NoneType]. + On python 3.9 it remains unchanged as Optional[int]. """ if sys.version_info < (3, 9): - return f"typing.Union\\[({arg_type_name}, NoneType|NoneType, {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, + )