From a7dc7d5a59998aba96f56d1aad2bcd61d3fb54f6 Mon Sep 17 00:00:00 2001 From: Michael Lamb Date: Sun, 10 Oct 2021 20:25:27 +1100 Subject: [PATCH 1/4] Added support for ForwardRef types Added code to evaluate typing.ForwardRef types. These are generally used to reference types that haven't been defined yet. Unfortunately to evaluate these types you need to pass a dictionary containing all the globals to all the validate functions so that the type can be evaluated. This has meant that many lines had to updated just to add the additional argument. Also had to add special handling of the Optional type to enable the tests to pass on Python 3.9. --- dataclass_type_validator/__init__.py | 47 +++++++++++++++++----------- tests/test_validator.py | 32 +++++++++++++++++-- 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/dataclass_type_validator/__init__.py b/dataclass_type_validator/__init__.py index 200111e..71af46c 100644 --- a/dataclass_type_validator/__init__.py +++ b/dataclass_type_validator/__init__.py @@ -1,9 +1,12 @@ import dataclasses import typing import functools +import sys from typing import Any from typing import Optional +from typing import Dict +GlobalNS_Type = Dict[str, Any] class TypeValidationError(Exception): """Exception raised on type validation errors. @@ -40,43 +43,43 @@ def _validate_type(expected_type: type, value: Any) -> Optional[str]: return f'must be an instance of {expected_type}, but received {type(value)}' -def _validate_iterable_items(expected_type: type, value: Any, strict: bool) -> Optional[str]: +def _validate_iterable_items(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> Optional[str]: expected_item_type = expected_type.__args__[0] - errors = [_validate_types(expected_type=expected_item_type, value=v, strict=strict) 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}' -def _validate_typing_list(expected_type: type, value: Any, strict: bool) -> Optional[str]: +def _validate_typing_list(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> Optional[str]: if not isinstance(value, list): return f'must be an instance of list, but received {type(value)}' - return _validate_iterable_items(expected_type, value, strict) + return _validate_iterable_items(expected_type, value, strict, globalns) -def _validate_typing_tuple(expected_type: type, value: Any, strict: bool) -> Optional[str]: +def _validate_typing_tuple(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> Optional[str]: if not isinstance(value, tuple): return f'must be an instance of tuple, but received {type(value)}' - return _validate_iterable_items(expected_type, value, strict) + return _validate_iterable_items(expected_type, value, strict, globalns) -def _validate_typing_frozenset(expected_type: type, value: Any, strict: bool) -> Optional[str]: +def _validate_typing_frozenset(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> Optional[str]: if not isinstance(value, frozenset): return f'must be an instance of frozenset, but received {type(value)}' - return _validate_iterable_items(expected_type, value, strict) + return _validate_iterable_items(expected_type, value, strict, globalns) -def _validate_typing_dict(expected_type: type, value: Any, strict: bool) -> Optional[str]: +def _validate_typing_dict(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> Optional[str]: if not isinstance(value, dict): 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) 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) 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: @@ -88,7 +91,7 @@ def _validate_typing_dict(expected_type: type, value: Any, strict: bool) -> Opti 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) -> Optional[str]: +def _validate_typing_callable(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> 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)}' @@ -109,35 +112,41 @@ def _validate_typing_literal(expected_type: type, value: Any, strict: bool) -> O } -def _validate_sequential_types(expected_type: type, value: Any, strict: bool) -> Optional[str]: +def _validate_sequential_types(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> Optional[str]: validate_func = _validate_typing_mappings.get(expected_type._name) if validate_func is not None: - return validate_func(expected_type, value, strict) + return validate_func(expected_type, value, strict, globalns) if str(expected_type).startswith('typing.Literal'): return _validate_typing_literal(expected_type, value, strict) - if str(expected_type).startswith('typing.Union'): - is_valid = any(_validate_types(expected_type=t, value=value, strict=strict) is None + 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 + if strict: raise RuntimeError(f'Unknown type of {expected_type} (_name = {expected_type._name})') -def _validate_types(expected_type: type, value: Any, strict: bool) -> Optional[str]: +def _validate_types(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> Optional[str]: 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) + return _validate_sequential_types(expected_type=expected_type, value=value, strict=strict, globalns=globalns) + + if isinstance(expected_type, typing.ForwardRef): + referenced_type = expected_type._evaluate(globalns, None, set()) + return _validate_type(expected_type=referenced_type, value=value) def dataclass_type_validator(target, strict: bool = False): fields = dataclasses.fields(target) + globalns = sys.modules[target.__module__].__dict__.copy() errors = {} for field in fields: @@ -145,7 +154,7 @@ 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) + err = _validate_types(expected_type=expected_type, value=value, strict=strict, globalns=globalns) if err is not None: errors[field_name] = err diff --git a/tests/test_validator.py b/tests/test_validator.py index 80dda3b..e6a706b 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -103,7 +103,7 @@ def test_build_failure_on_array_strings(self): def test_build_failure_on_array_optional_strings(self): with pytest.raises(TypeValidationError, - match="must be an instance of typing.List\\[typing.Union\\[(str, NoneType|NoneType, str)\\]\\]"): + match="must be an instance of typing.List\\[typing.Optional\\[(str)\\]\\]"): assert isinstance(DataclassTestList( array_of_numbers=[1, 2], array_of_strings=['abc'], @@ -138,7 +138,7 @@ def test_build_failure(self): optional_string=None ), DataclassTestUnion) - with pytest.raises(TypeValidationError, match='must be an instance of typing.Union\\[str, NoneType\\]'): + with pytest.raises(TypeValidationError, match='must be an instance of typing.Optional\\[str\\]'): assert isinstance(DataclassTestUnion( string_or_number=123, optional_string=123 @@ -219,6 +219,34 @@ def test_build_failure(self): ), DataclassTestCallable) +@dataclasses.dataclass(frozen=True) +class DataclassTestForwardRef: + number: 'int' + ref: typing.Optional['DataclassTestForwardRef'] = None + + def __post_init__(self): + dataclass_type_validator(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) + + def test_build_failure_on_number(self): + with pytest.raises(TypeValidationError): + assert isinstance(DataclassTestForwardRef( + number=1, + ref='string' + ), DataclassTestForwardRef) + + @dataclasses.dataclass(frozen=True) class ChildValue: child: str From b25f1b1a1eea94aa39c5941e7cf0d8f4a2c27a9f Mon Sep 17 00:00:00 2001 From: Michael Lamb Date: Sun, 10 Oct 2021 20:41:19 +1100 Subject: [PATCH 2/4] Address comments raised by the sider review --- dataclass_type_validator/__init__.py | 30 ++++++++++++++++------------ 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/dataclass_type_validator/__init__.py b/dataclass_type_validator/__init__.py index 71af46c..61f837f 100644 --- a/dataclass_type_validator/__init__.py +++ b/dataclass_type_validator/__init__.py @@ -6,7 +6,7 @@ from typing import Optional from typing import Dict -GlobalNS_Type = Dict[str, Any] +GlobalNS_T = Dict[str, Any] class TypeValidationError(Exception): """Exception raised on type validation errors. @@ -43,43 +43,46 @@ def _validate_type(expected_type: type, value: Any) -> Optional[str]: 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_Type) -> 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}' -def _validate_typing_list(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> 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 _validate_iterable_items(expected_type, value, strict, globalns) -def _validate_typing_tuple(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> 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 _validate_iterable_items(expected_type, value, strict, globalns) -def _validate_typing_frozenset(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> 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 _validate_iterable_items(expected_type, value, strict, globalns) -def _validate_typing_dict(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> 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)}' 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: @@ -91,7 +94,7 @@ def _validate_typing_dict(expected_type: type, value: Any, strict: bool, globaln 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_Type) -> 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)}' @@ -112,7 +115,7 @@ def _validate_typing_literal(expected_type: type, value: Any, strict: bool) -> O } -def _validate_sequential_types(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> Optional[str]: +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) if validate_func is not None: return validate_func(expected_type, value, strict, globalns) @@ -132,12 +135,13 @@ def _validate_sequential_types(expected_type: type, value: Any, strict: bool, gl raise RuntimeError(f'Unknown type of {expected_type} (_name = {expected_type._name})') -def _validate_types(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_Type) -> Optional[str]: +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): - return _validate_sequential_types(expected_type=expected_type, value=value, strict=strict, globalns=globalns) + return _validate_sequential_types(expected_type=expected_type, value=value, + strict=strict, globalns=globalns) if isinstance(expected_type, typing.ForwardRef): referenced_type = expected_type._evaluate(globalns, None, set()) From 0f52057ad922dfcac81eab4fceac131be992ee16 Mon Sep 17 00:00:00 2001 From: Michael Lamb Date: Sun, 10 Oct 2021 20:46:50 +1100 Subject: [PATCH 3/4] Address all flake8 issues with changes. --- dataclass_type_validator/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dataclass_type_validator/__init__.py b/dataclass_type_validator/__init__.py index 61f837f..76163b3 100644 --- a/dataclass_type_validator/__init__.py +++ b/dataclass_type_validator/__init__.py @@ -8,6 +8,7 @@ GlobalNS_T = Dict[str, Any] + class TypeValidationError(Exception): """Exception raised on type validation errors. """ @@ -45,7 +46,7 @@ def _validate_type(expected_type: type, value: Any) -> 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) + 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: @@ -77,11 +78,11 @@ def _validate_typing_dict(expected_type: type, value: Any, strict: bool, globaln 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) + 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) + 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] @@ -130,7 +131,6 @@ def _validate_sequential_types(expected_type: type, value: Any, strict: bool, gl 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})') From 865651b0d33926dd5fd7eb7a62c2aa920f61741c Mon Sep 17 00:00:00 2001 From: Michael Lamb Date: Mon, 18 Oct 2021 21:45:49 +1100 Subject: [PATCH 4/4] Support ForwardRef on python 3.8 The ForwardRef._evaluate function takes 3 arguments on python 3.9 and only 2 on 3.8. --- dataclass_type_validator/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dataclass_type_validator/__init__.py b/dataclass_type_validator/__init__.py index 76163b3..ebf4e1a 100644 --- a/dataclass_type_validator/__init__.py +++ b/dataclass_type_validator/__init__.py @@ -144,10 +144,17 @@ def _validate_types(expected_type: type, value: Any, strict: bool, globalns: Glo strict=strict, globalns=globalns) if isinstance(expected_type, typing.ForwardRef): - referenced_type = expected_type._evaluate(globalns, None, set()) + 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. """ + if sys.version_info < (3, 9): + return ref_type._evaluate(globalns, None) + return ref_type._evaluate(globalns, None, set()) + + def dataclass_type_validator(target, strict: bool = False): fields = dataclasses.fields(target) globalns = sys.modules[target.__module__].__dict__.copy()