From f41d5dca3c82ebdd128448680edff73c7e631963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Abdussamet=20Ko=C3=A7ak?= Date: Thu, 15 Aug 2019 14:06:21 +0300 Subject: [PATCH] Support assert statements inside validators (#653) * Support assert statements inside validators * Add a validator example that uses assert * Add warning about consequences of using -O optimization flag * Fix a typo * Fix incomplete validator * Extend exception name generation * Improve tests * Clarify pytest behaviour * handle assertion error name, fix build * Address feedback * docs cleanup * Incorporate feedback * fix quotes --- Makefile | 1 + changes/653-abdusco.rst | 1 + docs/examples/validators_simple.py | 6 ++++++ docs/index.rst | 10 ++++++++- pydantic/error_wrappers.py | 2 ++ pydantic/fields.py | 2 +- tests/test_error_wrappers.py | 1 + tests/test_validators.py | 19 +++++++++++++++++ tests/try_assert.py | 33 ++++++++++++++++++++++++++++++ 9 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 changes/653-abdusco.rst create mode 100644 tests/try_assert.py diff --git a/Makefile b/Makefile index c34c7b6..25e512a 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,7 @@ mypy: .PHONY: test test: pytest --cov=pydantic + @python tests/try_assert.py .PHONY: external-mypy external-mypy: diff --git a/changes/653-abdusco.rst b/changes/653-abdusco.rst new file mode 100644 index 0000000..a07d03b --- /dev/null +++ b/changes/653-abdusco.rst @@ -0,0 +1 @@ +add support for ``assert`` statements inside validators \ No newline at end of file diff --git a/docs/examples/validators_simple.py b/docs/examples/validators_simple.py index 48956df..2095036 100644 --- a/docs/examples/validators_simple.py +++ b/docs/examples/validators_simple.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, ValidationError, validator class UserModel(BaseModel): name: str + username: str password1: str password2: str @@ -18,6 +19,11 @@ class UserModel(BaseModel): raise ValueError('passwords do not match') return v + @validator('username') + def username_alphanumeric(cls, v): + assert v.isalpha(), 'must be alphanumeric' + return v + print(UserModel(name='samuel colvin', password1='zxcvbn', password2='zxcvbn')) # > UserModel name='Samuel Colvin' password1='zxcvbn' password2='zxcvbn' diff --git a/docs/index.rst b/docs/index.rst index fe445f7..fd48fe3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -194,7 +194,15 @@ A few things to note on validators: * their signature can be ``(cls, value)`` or ``(cls, value, values, config, field)``. As of **v0.20**, any subset of ``values``, ``config`` and ``field`` is also permitted, eg. ``(cls, value, field)``, however due to the way validators are inspected, the variadic key word argument ("``**kwargs``") **must** be called ``kwargs``. -* validator should either return the new value or raise a ``ValueError`` or ``TypeError`` +* validators should either return the new value or raise a ``ValueError``, ``TypeError``, or ``AssertionError`` + (``assert`` statements may be used). + +.. warning:: + + If you make use of ``assert`` statements, keep in mind that running + Python with the ``-O`` `optimization flag `_ + disables ``assert`` statements, and **validators will stop working**. + * where validators rely on other values, you should be aware that: - Validation is done in the order fields are defined, eg. here ``password2`` has access to ``password1`` diff --git a/pydantic/error_wrappers.py b/pydantic/error_wrappers.py index 3b83b67..12e4d46 100644 --- a/pydantic/error_wrappers.py +++ b/pydantic/error_wrappers.py @@ -112,6 +112,8 @@ def flatten_errors( @lru_cache() def get_exc_type(cls: Type[Exception]) -> str: + if issubclass(cls, AssertionError): + return 'assertion_error' base_name = 'type_error' if issubclass(cls, TypeError) else 'value_error' if cls in (TypeError, ValueError): diff --git a/pydantic/fields.py b/pydantic/fields.py index be2772e..e9ea395 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -448,7 +448,7 @@ class Field: for validator in validators: try: v = validator(cls, v, values, self, self.model_config) - except (ValueError, TypeError) as exc: + except (ValueError, TypeError, AssertionError) as exc: return v, ErrorWrapper(exc, loc=loc, config=self.model_config) return v, None diff --git a/tests/test_error_wrappers.py b/tests/test_error_wrappers.py index fc7bfce..be28626 100644 --- a/tests/test_error_wrappers.py +++ b/tests/test_error_wrappers.py @@ -207,6 +207,7 @@ def test_errors_unknown_error_object(): ( (TypeError(), 'type_error'), (ValueError(), 'value_error'), + (AssertionError(), 'assertion_error'), (errors.DecimalIsNotFiniteError(), 'value_error.decimal.not_finite'), ), ) diff --git a/tests/test_validators.py b/tests/test_validators.py index 9d920be..b1f6746 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -637,3 +637,22 @@ def test_make_generic_validator_self(): with pytest.raises(ConfigError) as exc_info: make_generic_validator(test_validator) assert ': (self, v), "self" not permitted as first argument, should be: (cls, value' in str(exc_info.value) + + +def test_assert_raises_validation_error(): + class Model(BaseModel): + a: str + + @validator('a') + def check_a(cls, v): + assert v == 'a', 'invalid a' + return v + + Model(a='a') + + with pytest.raises(ValidationError) as exc_info: + Model(a='snap') + injected_by_pytest = "\nassert 'snap' == 'a'\n - snap\n + a" + assert exc_info.value.errors() == [ + {'loc': ('a',), 'msg': f'invalid a{injected_by_pytest}', 'type': 'assertion_error'} + ] diff --git a/tests/try_assert.py b/tests/try_assert.py new file mode 100644 index 0000000..efb26d1 --- /dev/null +++ b/tests/try_assert.py @@ -0,0 +1,33 @@ +""" +This test is executed separately due to pytest's assertion-rewriting +""" +from pydantic import BaseModel, ValidationError, validator + + +def test_assert_raises_validation_error(): + test_name = test_assert_raises_validation_error.__name__ + + class Model(BaseModel): + a: str + + @validator('a') + def check_a(cls, v): + assert v == 'a', 'invalid a' + return v + + Model(a='a') + expected_errors = [{'loc': ('a',), 'msg': f'invalid a', 'type': 'assertion_error'}] + + try: + Model(a='snap') + except ValidationError as exc: + actual_errors = exc.errors() + if actual_errors != expected_errors: + raise RuntimeError(f'{test_name}:\nActual errors: {actual_errors}\nExpected errors: {expected_errors}') + else: + raise RuntimeError(f'{test_name}: ValidationError was not raised') + + +if __name__ == '__main__': + test_assert_raises_validation_error() + print('Non-pytest assert tests passed')