├── tests ├── __init__.py └── test_validator.py ├── requirements.in ├── requirements.txt ├── .flake8 ├── .github └── workflows │ └── actions.yml ├── setup.py ├── LICENSE ├── Makefile ├── .gitignore ├── README.md └── dataclass_type_validator └── __init__.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile --output-file=requirements.txt requirements.in 6 | # 7 | attrs==20.3.0 8 | # via pytest 9 | iniconfig==1.1.1 10 | # via pytest 11 | packaging==20.9 12 | # via pytest 13 | pluggy==0.13.1 14 | # via pytest 15 | py==1.10.0 16 | # via pytest 17 | pyparsing==2.4.7 18 | # via packaging 19 | pytest==6.2.3 20 | # via -r requirements.in 21 | toml==0.10.2 22 | # via pytest 23 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = 4 | # Exclude generated code. 5 | **/proto/** 6 | **/gapic/** 7 | *_pb2.py 8 | 9 | # Standard linting exemptions. 10 | __pycache__, 11 | .git, 12 | *.pyc, 13 | conf.py 14 | 15 | # E266: Too many leading '#' for block comment 16 | # H306: imports not in alphabetical order 17 | # H404: multi line docstring should start without a leading new line 18 | # H405: multi line docstring summary not separated with an empty line 19 | ignore = E266,H306,H404,H405 20 | -------------------------------------------------------------------------------- /.github/workflows/actions.yml: -------------------------------------------------------------------------------- 1 | name: Python application 2 | 3 | on: [push] 4 | 5 | jobs: 6 | pytest: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [ '3.8', '3.9' ] 11 | name: Python ${{ matrix.python-version }} pytest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | architecture: x64 20 | - run: pip install -r requirements.txt 21 | - name: pytest 22 | run: pytest -v --junit-xml=test-reports/results.xml tests 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | 4 | name = 'dataclass-type-validator' 5 | version = '0.1.2' 6 | description = 'Dataclass Type Validator Library' 7 | dependencies = [] 8 | 9 | with open("README.md", "r") as fh: 10 | long_description = fh.read() 11 | 12 | packages = [ 13 | package for package in setuptools.find_packages(exclude=["tests"]) 14 | ] 15 | 16 | setuptools.setup( 17 | name=name, 18 | version=version, 19 | author="Levii, inc", 20 | author_email="contact+oss@levii.co.jp", 21 | description=description, 22 | long_description=long_description, 23 | long_description_content_type="text/markdown", 24 | url="https://github.com/levii/dataclass-type-validator", 25 | packages=packages, 26 | python_requires=">=3.8", 27 | classifiers=[ 28 | "Programming Language :: Python :: 3", 29 | "License :: OSI Approved :: MIT License", 30 | "Operating System :: OS Independent", 31 | ], 32 | install_requires=dependencies, 33 | ) 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 levii 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | package_name = dataclass-type-validator 2 | 3 | export PATH := venv/bin:$(shell echo ${PATH}) 4 | 5 | .PHONY: setup 6 | setup: 7 | [ -d venv ] || python3 -m venv venv 8 | pip3 install twine wheel pytest 9 | pip3 install -r requirements.txt 10 | 11 | .PHONY: release 12 | release: clean build 13 | python3 -m twine upload \ 14 | --repository-url https://upload.pypi.org/legacy/ \ 15 | dist/* 16 | 17 | .PHONY: test-release 18 | test-release: clean build 19 | python3 -m twine upload \ 20 | --repository-url https://test.pypi.org/legacy/ \ 21 | dist/* 22 | 23 | .PHONY: test-install 24 | test-install: 25 | pip3 --no-cache-dir install --upgrade \ 26 | -i https://test.pypi.org/simple/ \ 27 | ${package_name} 28 | 29 | .PHONY: build 30 | build: pip-compile 31 | python3 setup.py sdist bdist_wheel 32 | 33 | .PHONY: clean 34 | clean: 35 | rm -rf $(subst -,_,${package_name}).egg-info dist build 36 | 37 | .PHONY: pip-compile 38 | pip-compile: 39 | pip-compile --output-file=requirements.txt requirements.in 40 | pip3 install -r requirements.txt 41 | 42 | .PHONY: test 43 | test: build 44 | pip3 install dist/${package_name}*.tar.gz 45 | pytest -v --junit-xml=test-reports/results.xml tests 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # IDE 107 | .idea/ 108 | 109 | test-reports/ 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dataclass-type-validator 2 | 3 | The `dataclass-type-validator` is a type validation library for the properties of `dataclasses.dataclass` using Python type hint information. 4 | 5 | ## Installation 6 | 7 | `pip install dataclass-type-validator` or add `dataclass-type-validator` line to `requirements.txt` 8 | 9 | ## A Simple Example 10 | 11 | ### Explicitly calling dataclass_type_validator from within your dataclass 12 | ```python 13 | from dataclasses import dataclass 14 | from typing import List 15 | from dataclass_type_validator import dataclass_type_validator 16 | from dataclass_type_validator import TypeValidationError 17 | 18 | @dataclass() 19 | class User: 20 | id: int 21 | name: str 22 | friend_ids: List[int] 23 | 24 | def __post_init__(self): 25 | dataclass_type_validator(self) 26 | 27 | 28 | # Valid User 29 | User(id=10, name='John Smith', friend_ids=[1, 2]) 30 | # => User(id=10, name='John Smith', friend_ids=[1, 2]) 31 | 32 | # Invalid User 33 | try: 34 | User(id='a', name=['John', 'Smith'], friend_ids=['a']) 35 | except TypeValidationError as e: 36 | print(e) 37 | # => TypeValidationError: Dataclass Type Validation (errors = { 38 | # 'id': "must be an instance of , but received ", 39 | # 'name': "must be an instance of , but received ", 40 | # 'friend_ids': 'must be an instance of typing.List[int], but there are some errors: 41 | # ["must be an instance of , but received "]'}) 42 | ``` 43 | 44 | ### The same, but using the class decorator instead 45 | ```python 46 | from dataclasses import dataclass 47 | from typing import List 48 | from dataclass_type_validator import dataclass_validate 49 | from dataclass_type_validator import TypeValidationError 50 | 51 | @dataclass_validate 52 | @dataclass() 53 | class User: 54 | id: int 55 | name: str 56 | friend_ids: List[int] 57 | 58 | 59 | # Valid User 60 | User(id=10, name='John Smith', friend_ids=[1, 2]) 61 | # => User(id=10, name='John Smith', friend_ids=[1, 2]) 62 | 63 | # Invalid User 64 | try: 65 | User(id='a', name=['John', 'Smith'], friend_ids=['a']) 66 | except TypeValidationError as e: 67 | print(e) 68 | # => TypeValidationError: Dataclass Type Validation (errors = { 69 | # 'id': "must be an instance of , but received ", 70 | # 'name': "must be an instance of , but received ", 71 | # 'friend_ids': 'must be an instance of typing.List[int], but there are some errors: 72 | # ["must be an instance of , but received "]'}) 73 | ``` 74 | You can also pass the `strict` param (which defaults to False) to the decorator: 75 | ```python 76 | @dataclass_validate(strict=True) 77 | @dataclass(frozen=True) 78 | class SomeList: 79 | values: List[str] 80 | 81 | # Invalid item contained in typed List 82 | try: 83 | SomeList(values=["one", "two", 3]) 84 | except TypeValidationError as e: 85 | print(e) 86 | # => TypeValidationError: Dataclass Type Validation Error (errors = { 87 | # 'x': 'must be an instance of typing.List[str], but there are some errors: 88 | # ["must be an instance of , but received "]'}) 89 | ``` 90 | 91 | You can also pass the `before_post_init` param (which defaults to False) to the decorator, 92 | to force the type validation to occur before `__post_init__()` is called. This can be used 93 | to ensure the types of the field values have been validated before your higher-level semantic 94 | validation is performed in `__post_init__()`. 95 | ```python 96 | @dataclass_validate(before_post_init=True) 97 | @dataclass 98 | class User: 99 | id: int 100 | name: str 101 | 102 | def __post_init__(self): 103 | # types of id and name have already been checked before this is called. 104 | # Otherwise, the following check will throw a TypeError if user passed 105 | # `id` as a string or other type that cannot be compared to int. 106 | if id < 1: 107 | raise ValueError("superuser not allowed") 108 | ``` 109 | -------------------------------------------------------------------------------- /dataclass_type_validator/__init__.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing 3 | import functools 4 | import sys 5 | from typing import Any 6 | from typing import Optional 7 | from typing import Dict 8 | 9 | GlobalNS_T = Dict[str, Any] 10 | 11 | 12 | class TypeValidationError(Exception): 13 | """Exception raised on type validation errors. 14 | """ 15 | 16 | def __init__(self, *args, target: dataclasses.dataclass, errors: dict): 17 | super(TypeValidationError, self).__init__(*args) 18 | self.class_ = target.__class__ 19 | self.errors = errors 20 | 21 | def __repr__(self): 22 | cls = self.class_ 23 | cls_name = ( 24 | f"{cls.__module__}.{cls.__name__}" 25 | if cls.__module__ != "__main__" 26 | else cls.__name__ 27 | ) 28 | attrs = ", ".join([repr(v) for v in self.args]) 29 | return f"{cls_name}({attrs}, errors={repr(self.errors)})" 30 | 31 | def __str__(self): 32 | cls = self.class_ 33 | cls_name = ( 34 | f"{cls.__module__}.{cls.__name__}" 35 | if cls.__module__ != "__main__" 36 | else cls.__name__ 37 | ) 38 | s = cls_name 39 | return f"{s} (errors = {self.errors})" 40 | 41 | 42 | def _validate_type(expected_type: type, value: Any) -> Optional[str]: 43 | if not isinstance(value, expected_type): 44 | return f'must be an instance of {expected_type}, but received {type(value)}' 45 | 46 | 47 | def _validate_iterable_items(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: 48 | expected_item_type = expected_type.__args__[0] 49 | errors = [_validate_types(expected_type=expected_item_type, value=v, strict=strict, globalns=globalns) 50 | for v in value] 51 | errors = [x for x in errors if x] 52 | if len(errors) > 0: 53 | return f'must be an instance of {expected_type}, but there are some errors: {errors}' 54 | 55 | 56 | def _validate_typing_list(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: 57 | if not isinstance(value, list): 58 | return f'must be an instance of list, but received {type(value)}' 59 | return _validate_iterable_items(expected_type, value, strict, globalns) 60 | 61 | 62 | def _validate_typing_tuple(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: 63 | if not isinstance(value, tuple): 64 | return f'must be an instance of tuple, but received {type(value)}' 65 | return _validate_iterable_items(expected_type, value, strict, globalns) 66 | 67 | 68 | def _validate_typing_frozenset(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: 69 | if not isinstance(value, frozenset): 70 | return f'must be an instance of frozenset, but received {type(value)}' 71 | return _validate_iterable_items(expected_type, value, strict, globalns) 72 | 73 | 74 | def _validate_typing_dict(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: 75 | if not isinstance(value, dict): 76 | return f'must be an instance of dict, but received {type(value)}' 77 | 78 | expected_key_type = expected_type.__args__[0] 79 | expected_value_type = expected_type.__args__[1] 80 | 81 | key_errors = [_validate_types(expected_type=expected_key_type, value=k, strict=strict, globalns=globalns) 82 | for k in value.keys()] 83 | key_errors = [k for k in key_errors if k] 84 | 85 | val_errors = [_validate_types(expected_type=expected_value_type, value=v, strict=strict, globalns=globalns) 86 | for v in value.values()] 87 | val_errors = [v for v in val_errors if v] 88 | 89 | if len(key_errors) > 0 and len(val_errors) > 0: 90 | return f'must be an instance of {expected_type}, but there are some errors in keys and values. '\ 91 | f'key errors: {key_errors}, value errors: {val_errors}' 92 | elif len(key_errors) > 0: 93 | return f'must be an instance of {expected_type}, but there are some errors in keys: {key_errors}' 94 | elif len(val_errors) > 0: 95 | return f'must be an instance of {expected_type}, but there are some errors in values: {val_errors}' 96 | 97 | 98 | def _validate_typing_callable(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: 99 | _ = strict 100 | if not isinstance(value, type(lambda a: a)): 101 | return f'must be an instance of {expected_type._name}, but received {type(value)}' 102 | 103 | 104 | def _validate_typing_literal(expected_type: type, value: Any, strict: bool) -> Optional[str]: 105 | _ = strict 106 | if value not in expected_type.__args__: 107 | return f'must be one of [{", ".join(expected_type.__args__)}] but received {value}' 108 | 109 | 110 | _validate_typing_mappings = { 111 | 'List': _validate_typing_list, 112 | 'Tuple': _validate_typing_tuple, 113 | 'FrozenSet': _validate_typing_frozenset, 114 | 'Dict': _validate_typing_dict, 115 | 'Callable': _validate_typing_callable, 116 | } 117 | 118 | 119 | def _validate_sequential_types(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: 120 | validate_func = _validate_typing_mappings.get(expected_type._name) 121 | if validate_func is not None: 122 | return validate_func(expected_type, value, strict, globalns) 123 | 124 | if str(expected_type).startswith('typing.Literal'): 125 | return _validate_typing_literal(expected_type, value, strict) 126 | 127 | if str(expected_type).startswith('typing.Union') or str(expected_type).startswith('typing.Optional'): 128 | is_valid = any(_validate_types(expected_type=t, value=value, strict=strict, globalns=globalns) is None 129 | for t in expected_type.__args__) 130 | if not is_valid: 131 | return f'must be an instance of {expected_type}, but received {value}' 132 | return 133 | 134 | if strict: 135 | raise RuntimeError(f'Unknown type of {expected_type} (_name = {expected_type._name})') 136 | 137 | 138 | def _validate_types(expected_type: type, value: Any, strict: bool, globalns: GlobalNS_T) -> Optional[str]: 139 | if isinstance(expected_type, type): 140 | return _validate_type(expected_type=expected_type, value=value) 141 | 142 | if isinstance(expected_type, typing._GenericAlias): 143 | return _validate_sequential_types(expected_type=expected_type, value=value, 144 | strict=strict, globalns=globalns) 145 | 146 | if isinstance(expected_type, typing.ForwardRef): 147 | referenced_type = _evaluate_forward_reference(expected_type, globalns) 148 | return _validate_type(expected_type=referenced_type, value=value) 149 | 150 | 151 | def _evaluate_forward_reference(ref_type: typing.ForwardRef, globalns: GlobalNS_T): 152 | """ Support evaluating ForwardRef types on both Python 3.8 and 3.9. """ 153 | if sys.version_info < (3, 9): 154 | return ref_type._evaluate(globalns, None) 155 | return ref_type._evaluate(globalns, None, set()) 156 | 157 | 158 | def dataclass_type_validator(target, strict: bool = False): 159 | fields = dataclasses.fields(target) 160 | globalns = sys.modules[target.__module__].__dict__.copy() 161 | 162 | errors = {} 163 | for field in fields: 164 | field_name = field.name 165 | expected_type = field.type 166 | value = getattr(target, field_name) 167 | 168 | err = _validate_types(expected_type=expected_type, value=value, strict=strict, globalns=globalns) 169 | if err is not None: 170 | errors[field_name] = err 171 | 172 | if len(errors) > 0: 173 | raise TypeValidationError( 174 | "Dataclass Type Validation Error", target=target, errors=errors 175 | ) 176 | 177 | 178 | def dataclass_validate(cls=None, *, strict: bool = False, before_post_init: bool = False): 179 | """Dataclass decorator to automatically add validation to a dataclass. 180 | 181 | So you don't have to add a __post_init__ method, or if you have one, you don't have 182 | to remember to add the dataclass_type_validator(self) call to it; just decorate your 183 | dataclass with this instead. 184 | 185 | :param strict: bool 186 | :param before_post_init: bool - if True, force the validation logic to occur before 187 | __post_init__ is called. Only has effect if the class defines __post_init__. 188 | This setting allows you to ensure the field values are already validated to 189 | be the correct type before any additional logic in __post_init__ does further 190 | validation. Default: False. 191 | """ 192 | if cls is None: 193 | return functools.partial(dataclass_validate, strict=strict, before_post_init=before_post_init) 194 | 195 | if not hasattr(cls, "__post_init__"): 196 | # No post-init method, so no processing. Wrap the constructor instead. 197 | wrapped_method_name = "__init__" 198 | else: 199 | # Normally make validation take place at the end of __post_init__ 200 | wrapped_method_name = "__post_init__" 201 | 202 | orig_method = getattr(cls, wrapped_method_name) 203 | 204 | if wrapped_method_name == "__post_init__" and before_post_init: 205 | # User wants to force validation to run before __post_init__, so call it 206 | # before the wrapped function. 207 | @functools.wraps(orig_method) 208 | def method_wrapper(self, *args, **kwargs): 209 | dataclass_type_validator(self, strict=strict) 210 | return orig_method(self, *args, **kwargs) 211 | else: 212 | # Normal case - call validator at the end of __init__ or __post_init__. 213 | @functools.wraps(orig_method) 214 | def method_wrapper(self, *args, **kwargs): 215 | x = orig_method(self, *args, **kwargs) 216 | dataclass_type_validator(self, strict=strict) 217 | return x 218 | setattr(cls, wrapped_method_name, method_wrapper) 219 | 220 | return cls 221 | -------------------------------------------------------------------------------- /tests/test_validator.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import dataclasses 3 | import typing 4 | import sys 5 | 6 | from dataclass_type_validator import dataclass_type_validator, dataclass_validate 7 | from dataclass_type_validator import TypeValidationError 8 | 9 | 10 | @dataclasses.dataclass(frozen=True) 11 | class DataclassTestNumber: 12 | number: int 13 | optional_number: typing.Optional[int] = None 14 | 15 | def __post_init__(self): 16 | dataclass_type_validator(self) 17 | 18 | 19 | class TestTypeValidationNumber: 20 | def test_build_success(self): 21 | assert isinstance(DataclassTestNumber( 22 | number=1, 23 | optional_number=None, 24 | ), DataclassTestNumber) 25 | assert isinstance(DataclassTestNumber( 26 | number=1, 27 | optional_number=1 28 | ), DataclassTestNumber) 29 | 30 | def test_build_failure_on_number(self): 31 | with pytest.raises(TypeValidationError): 32 | assert isinstance(DataclassTestNumber( 33 | number=1, 34 | optional_number='string' 35 | ), DataclassTestNumber) 36 | 37 | 38 | @dataclasses.dataclass(frozen=True) 39 | class DataclassTestString: 40 | string: str 41 | optional_string: typing.Optional[str] = None 42 | 43 | def __post_init__(self): 44 | dataclass_type_validator(self) 45 | 46 | 47 | class TestTypeValidationString: 48 | def test_build_success(self): 49 | assert isinstance(DataclassTestString( 50 | string='string', 51 | optional_string=None 52 | ), DataclassTestString) 53 | assert isinstance(DataclassTestString( 54 | string='string', 55 | optional_string='string' 56 | ), DataclassTestString) 57 | 58 | def test_build_failure_on_string(self): 59 | with pytest.raises(TypeValidationError): 60 | assert isinstance(DataclassTestString( 61 | string='str', 62 | optional_string=123 63 | ), DataclassTestString) 64 | 65 | 66 | @dataclasses.dataclass(frozen=True) 67 | class DataclassTestList: 68 | array_of_numbers: typing.List[int] 69 | array_of_strings: typing.List[str] 70 | array_of_optional_strings: typing.List[typing.Optional[str]] 71 | 72 | def __post_init__(self): 73 | dataclass_type_validator(self) 74 | 75 | 76 | class TestTypeValidationList: 77 | def test_build_success(self): 78 | assert isinstance(DataclassTestList( 79 | array_of_numbers=[], 80 | array_of_strings=[], 81 | array_of_optional_strings=[], 82 | ), DataclassTestList) 83 | assert isinstance(DataclassTestList( 84 | array_of_numbers=[1, 2], 85 | array_of_strings=['abc'], 86 | array_of_optional_strings=['abc', None] 87 | ), DataclassTestList) 88 | 89 | def test_build_failure_on_array_numbers(self): 90 | with pytest.raises(TypeValidationError, match='must be an instance of typing.List\\[int\\]'): 91 | assert isinstance(DataclassTestList( 92 | array_of_numbers=['abc'], 93 | array_of_strings=['abc'], 94 | array_of_optional_strings=['abc', None] 95 | ), DataclassTestList) 96 | 97 | def test_build_failure_on_array_strings(self): 98 | with pytest.raises(TypeValidationError, match='must be an instance of typing.List\\[str\\]'): 99 | assert isinstance(DataclassTestList( 100 | array_of_numbers=[1, 2], 101 | array_of_strings=[123], 102 | array_of_optional_strings=['abc', None] 103 | ), DataclassTestList) 104 | 105 | def test_build_failure_on_array_optional_strings(self): 106 | with pytest.raises(TypeValidationError, 107 | match=f"must be an instance of typing.List\\[{optional_type_name('str')}\\]"): 108 | assert isinstance(DataclassTestList( 109 | array_of_numbers=[1, 2], 110 | array_of_strings=['abc'], 111 | array_of_optional_strings=[123, None] 112 | ), DataclassTestList) 113 | 114 | 115 | @dataclasses.dataclass(frozen=True) 116 | class DataclassTestUnion: 117 | string_or_number: typing.Union[str, int] 118 | optional_string: typing.Optional[str] 119 | 120 | def __post_init__(self): 121 | dataclass_type_validator(self) 122 | 123 | 124 | class TestTypeValidationUnion: 125 | def test_build_success(self): 126 | assert isinstance(DataclassTestUnion( 127 | string_or_number='abc', 128 | optional_string='abc' 129 | ), DataclassTestUnion) 130 | assert isinstance(DataclassTestUnion( 131 | string_or_number=123, 132 | optional_string=None 133 | ), DataclassTestUnion) 134 | 135 | def test_build_failure(self): 136 | with pytest.raises(TypeValidationError, match='must be an instance of typing.Union\\[str, int\\]'): 137 | assert isinstance(DataclassTestUnion( 138 | string_or_number=None, 139 | optional_string=None 140 | ), DataclassTestUnion) 141 | 142 | with pytest.raises(TypeValidationError, match=f'must be an instance of {optional_type_name("str")}'): 143 | assert isinstance(DataclassTestUnion( 144 | string_or_number=123, 145 | optional_string=123 146 | ), DataclassTestUnion) 147 | 148 | 149 | @dataclasses.dataclass(frozen=True) 150 | class DataclassTestLiteral: 151 | restricted_value: typing.Literal['foo', 'bar'] 152 | 153 | def __post_init__(self): 154 | dataclass_type_validator(self, strict=True) 155 | 156 | 157 | class TestTypeValidationLiteral: 158 | def test_build_success(self): 159 | assert isinstance(DataclassTestLiteral( 160 | restricted_value='foo' 161 | ), DataclassTestLiteral) 162 | assert isinstance(DataclassTestLiteral( 163 | restricted_value='bar' 164 | ), DataclassTestLiteral) 165 | 166 | def test_build_failure(self): 167 | with pytest.raises(TypeValidationError, match='must be one of \\[foo, bar\\] but received fizz'): 168 | assert isinstance(DataclassTestLiteral( 169 | restricted_value='fizz' 170 | ), DataclassTestLiteral) 171 | 172 | with pytest.raises(TypeValidationError, match='must be one of \\[foo, bar\\] but received None'): 173 | assert isinstance(DataclassTestLiteral( 174 | restricted_value=None, 175 | ), DataclassTestLiteral) 176 | 177 | 178 | @dataclasses.dataclass(frozen=True) 179 | class DataclassTestDict: 180 | str_to_str: typing.Dict[str, str] 181 | str_to_any: typing.Dict[str, typing.Any] 182 | 183 | def __post_init__(self): 184 | dataclass_type_validator(self) 185 | 186 | 187 | class TestTypeValidationDict: 188 | def test_build_success(self): 189 | assert isinstance(DataclassTestDict( 190 | str_to_str={'str': 'str'}, 191 | str_to_any={'str': 'str', 'str2': 123} 192 | ), DataclassTestDict) 193 | 194 | def test_build_failure(self): 195 | with pytest.raises(TypeValidationError, match='must be an instance of typing.Dict\\[str, str\\]'): 196 | assert isinstance(DataclassTestDict( 197 | str_to_str={'str': 123}, 198 | str_to_any={'key': []} 199 | ), DataclassTestDict) 200 | 201 | 202 | @dataclasses.dataclass(frozen=True) 203 | class DataclassTestCallable: 204 | func: typing.Callable[[int, int], int] 205 | 206 | def __post_init__(self): 207 | dataclass_type_validator(self) 208 | 209 | 210 | class TestTypeValidationCallable: 211 | def test_build_success(self): 212 | assert isinstance(DataclassTestCallable( 213 | func=lambda a, b: a * b 214 | ), DataclassTestCallable) 215 | 216 | def test_build_failure(self): 217 | with pytest.raises(TypeValidationError, match='must be an instance of Callable'): 218 | assert isinstance(DataclassTestCallable( 219 | func=None, 220 | ), DataclassTestCallable) 221 | 222 | 223 | @dataclasses.dataclass(frozen=True) 224 | class DataclassTestForwardRef: 225 | number: 'int' 226 | ref: typing.Optional['DataclassTestForwardRef'] = None 227 | 228 | def __post_init__(self): 229 | dataclass_type_validator(self) 230 | 231 | 232 | class TestTypeValidationForwardRef: 233 | def test_build_success(self): 234 | assert isinstance(DataclassTestForwardRef( 235 | number=1, 236 | ref=None, 237 | ), DataclassTestForwardRef) 238 | assert isinstance(DataclassTestForwardRef( 239 | number=1, 240 | ref=DataclassTestForwardRef(2, None) 241 | ), DataclassTestForwardRef) 242 | 243 | def test_build_failure_on_number(self): 244 | with pytest.raises(TypeValidationError): 245 | assert isinstance(DataclassTestForwardRef( 246 | number=1, 247 | ref='string' 248 | ), DataclassTestForwardRef) 249 | 250 | 251 | @dataclasses.dataclass(frozen=True) 252 | class ChildValue: 253 | child: str 254 | 255 | def __post_init__(self): 256 | dataclass_type_validator(self) 257 | 258 | 259 | @dataclasses.dataclass(frozen=True) 260 | class ParentValue: 261 | child: ChildValue 262 | 263 | def __post_init__(self): 264 | dataclass_type_validator(self) 265 | 266 | 267 | class TestNestedDataclass: 268 | def test_build_success(self): 269 | assert isinstance(ParentValue( 270 | child=ChildValue(child='string') 271 | ), ParentValue) 272 | 273 | def test_build_failure(self): 274 | with pytest.raises(TypeValidationError, 275 | match="must be an instance of "): 276 | assert isinstance(ParentValue( 277 | child=None 278 | ), ParentValue) 279 | 280 | 281 | @dataclass_validate 282 | @dataclasses.dataclass(frozen=True) 283 | class DataclassWithPostInitTestDecorator: 284 | number: int 285 | optional_number: typing.Optional[int] = None 286 | 287 | def __post_init__(self): 288 | dataclass_type_validator(self) 289 | 290 | 291 | class TestDecoratorWithPostInit: 292 | def test_build_success(self): 293 | assert isinstance(DataclassWithPostInitTestDecorator( 294 | number=1, 295 | optional_number=None, 296 | ), DataclassWithPostInitTestDecorator) 297 | assert isinstance(DataclassWithPostInitTestDecorator( 298 | number=1, 299 | optional_number=1 300 | ), DataclassWithPostInitTestDecorator) 301 | 302 | def test_build_failure_on_number(self): 303 | with pytest.raises(TypeValidationError): 304 | _ = DataclassWithPostInitTestDecorator( 305 | number=1, 306 | optional_number='string' 307 | ) 308 | 309 | 310 | @dataclass_validate 311 | @dataclasses.dataclass(frozen=True) 312 | class DataclassWithoutPostInitTestDecorator: 313 | number: int 314 | optional_number: typing.Optional[int] = None 315 | 316 | 317 | class TestDecoratorWithoutPostInit: 318 | def test_build_success(self): 319 | assert isinstance(DataclassWithoutPostInitTestDecorator( 320 | number=1, 321 | optional_number=None, 322 | ), DataclassWithoutPostInitTestDecorator) 323 | assert isinstance(DataclassWithoutPostInitTestDecorator( 324 | number=1, 325 | optional_number=1 326 | ), DataclassWithoutPostInitTestDecorator) 327 | 328 | def test_build_failure_on_number(self): 329 | with pytest.raises(TypeValidationError): 330 | _ = DataclassWithoutPostInitTestDecorator( 331 | number=1, 332 | optional_number='string' 333 | ) 334 | 335 | 336 | @dataclass_validate(strict=True) 337 | @dataclasses.dataclass(frozen=True) 338 | class DataclassWithStrictChecking: 339 | values: typing.List[int] 340 | 341 | 342 | class TestDecoratorStrict: 343 | def test_build_success(self): 344 | assert isinstance(DataclassWithStrictChecking( 345 | values=[1, 2, 3], 346 | ), DataclassWithStrictChecking) 347 | 348 | def test_build_failure_on_number(self): 349 | with pytest.raises(TypeValidationError): 350 | _ = DataclassWithStrictChecking( 351 | values=[1, 2, "three"], 352 | ) 353 | 354 | 355 | def optional_type_name(arg_type_name): 356 | """ Gets the typename string for an typing.Optional. 357 | On python 3.8 an Optional[int] is converted to a typing.Union[int, NoneType]. 358 | On python 3.9 it remains unchanged as Optional[int]. 359 | """ 360 | if sys.version_info < (3, 9): 361 | return f"typing.Union\\[({arg_type_name}, NoneType|NoneType, {arg_type_name})\\]" 362 | 363 | return f"typing.Optional\\[{arg_type_name}\\]" 364 | --------------------------------------------------------------------------------