From 91d86fa944cdc38e6298b7b61f42e12b72691565 Mon Sep 17 00:00:00 2001 From: Gram Date: Thu, 16 Apr 2020 13:19:47 +0200 Subject: [PATCH 1/6] implicitly wrap validators into vaa --- deal/_decorators/base.py | 27 +++++++++++++++++++++++---- pyproject.toml | 4 ++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/deal/_decorators/base.py b/deal/_decorators/base.py index 30b0f559..9cee13d7 100644 --- a/deal/_decorators/base.py +++ b/deal/_decorators/base.py @@ -1,9 +1,12 @@ # built-in +import inspect from asyncio import iscoroutinefunction +from contextlib import suppress from functools import update_wrapper -from inspect import getcallargs, isgeneratorfunction from typing import Callable, Type +import vaa + # app from .._exceptions import ContractError from .._state import state @@ -18,13 +21,29 @@ def __init__(self, validator: Callable, *, message: str = None, """ Step 1. Set contract (validator). """ - self.validator = validator + self.validator = self._make_validator(validator, message=message) self.debug = debug if exception: self.exception = exception if message: self.exception = self.exception(message) # type: ignore + @staticmethod + def _make_validator(validator, message: str): + # implicitly wrap in vaa all external validators + with suppress(TypeError): + return vaa.wrap(validator, simple=False) + + # implicitly wrap in vaa.simple only funcs with one `_` argument. + if inspect.isfunction(validator): + params = inspect.signature(validator).parameters + if set(params) == {'_'}: + if not message: + message = 'validation error' + return vaa.simple(validator, error=message) + + return validator + def validate(self, *args, **kwargs) -> None: """ Step 4. Process contract (validator) @@ -43,7 +62,7 @@ def _vaa_validation(self, *args, **kwargs) -> None: while hasattr(function, '__wrapped__'): function = function.__wrapped__ # type: ignore # assign *args to real names - params.update(getcallargs(function, *args, **kwargs)) + params.update(inspect.getcallargs(function, *args, **kwargs)) # drop args-kwargs, we already put them on the right places for bad_name in ('args', 'kwargs'): if bad_name in params and bad_name not in kwargs: @@ -101,6 +120,6 @@ def wrapped_generator(*args, **kwargs): if iscoroutinefunction(function): return update_wrapper(async_wrapped, function) - if isgeneratorfunction(function): + if inspect.isgeneratorfunction(function): return update_wrapper(wrapped_generator, function) return update_wrapper(wrapped, function) diff --git a/pyproject.toml b/pyproject.toml index 4e78f381..051e2ee6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ python = ">=3.5" astroid = "*" hypothesis = "*" typeguard = "*" +vaa = ">=0.2.0" [tool.poetry.dev-dependencies] coverage = "*" @@ -73,7 +74,6 @@ marshmallow = "*" pytest = "*" pytest-cov = "*" urllib3 = "*" -vaa = ">=0.1.4" m2r = "*" recommonmark = "*" @@ -81,7 +81,7 @@ sphinx = "*" sphinx-rtd-theme = "*" [tool.poetry.extras] -tests = ["coverage", "marshmallow", "pytest", "pytest-cov", "urllib3", "vaa"] +tests = ["coverage", "marshmallow", "pytest", "pytest-cov", "urllib3"] docs = ["m2r", "recommonmark", "sphinx", "sphinx-rtd-theme", "urllib3"] [tool.poetry.plugins."flake8.extension"] From cb7a0d272cd36183b8d4236c3b978709d037ecd5 Mon Sep 17 00:00:00 2001 From: Gram Date: Thu, 16 Apr 2020 14:04:49 +0200 Subject: [PATCH 2/6] update docs --- docs/validators.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/validators.md b/docs/validators.md index 38570be3..643ce633 100644 --- a/docs/validators.md +++ b/docs/validators.md @@ -1,6 +1,22 @@ # Validators -## Simple contract +## Simplified signature + +The main problem with contracts is that they have to duplicate the original function's signature, including default arguments. While it's not a problem for small examples, things become more complicated when the signature grows. For this case, you can specify a function that accepts only one `_` argument, and deal will pass here a container with arguments of the function call, including default ones: + +```python +@deal.pre(lambda _: _.a + _.b > 0) +def f(a, b=1): + return a + b + +f(1) +# 2 + +f(-2) +# PreContractError: +``` + +## Providing an error Regular contract can return error message instead of `False`: @@ -23,17 +39,16 @@ f(4) ## External validators -For a complex validation you can wrap your contract into [vaa](https://github.com/life4/vaa). It supports Marshmallow, WTForms, PyScheme etc. For example: +Deal supports a lot of external validation libraries, like Marshmallow, WTForms, PyScheme etc. For example: ```python import deal import marshmallow -import vaa class Schema(marshmallow.Schema): name = marshmallow.fields.Str() -@deal.pre(vaa.marshmallow(Schema)) +@deal.pre(Schema) def func(name): return name * 2 @@ -43,3 +58,5 @@ func('Chris') func(123) # PreContractError: {'name': ['Not a valid string.']} ``` + +See [vaa](https://github.com/life4/vaa) documentation for details. From e2c8eebb2b6043334c47713fe3513f37a987d066 Mon Sep 17 00:00:00 2001 From: Gram Date: Thu, 16 Apr 2020 15:50:50 +0200 Subject: [PATCH 3/6] flatten errors from vaa --- deal/_decorators/base.py | 21 +++++++++++++++++++-- pyproject.toml | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/deal/_decorators/base.py b/deal/_decorators/base.py index 9cee13d7..fe4974f1 100644 --- a/deal/_decorators/base.py +++ b/deal/_decorators/base.py @@ -56,6 +56,8 @@ def validate(self, *args, **kwargs) -> None: def _vaa_validation(self, *args, **kwargs) -> None: params = kwargs.copy() + + # if it is a decorator for a function, convert positional args into named ones. if hasattr(self, 'function'): # detect original function function = self.function @@ -68,12 +70,27 @@ def _vaa_validation(self, *args, **kwargs) -> None: if bad_name in params and bad_name not in kwargs: del params[bad_name] + # validate validator = self.validator(data=params) if validator.is_valid(): return + + # process errors + errors = validator.errors + if not errors: + errors = self.message + # Flatten single error without field to one simple str message. + # This is for better readability of simple validators. + if type(errors) is list: + if type(errors[0]) is vaa.Error: + if len(errors) == 1: + if errors[0].field is None: + errors = errors[0].message + + # raise errors if isinstance(self.exception, Exception): - raise type(self.exception)(validator.errors) - raise self.exception(validator.errors) + raise type(self.exception)(errors) + raise self.exception(errors) def _simple_validation(self, *args, **kwargs) -> None: validation_result = self.validator(*args, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index 051e2ee6..ccb9adaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ python = ">=3.5" astroid = "*" hypothesis = "*" typeguard = "*" -vaa = ">=0.2.0" +vaa = ">=0.2.1" [tool.poetry.dev-dependencies] coverage = "*" From fc8b219ff12263fe783bae2f7787c916055108d0 Mon Sep 17 00:00:00 2001 From: Gram Date: Thu, 16 Apr 2020 15:54:57 +0200 Subject: [PATCH 4/6] actualize tests --- tests/test_schemes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_schemes.py b/tests/test_schemes.py index 66e009f5..cba22404 100644 --- a/tests/test_schemes.py +++ b/tests/test_schemes.py @@ -13,7 +13,7 @@ class MarshMallowScheme(marshmallow.Schema): class CustomScheme(deal.Scheme): def is_valid(self): if not isinstance(self.data['name'], str): - self.errors = {'name': ['Not a valid string.']} + self.errors = vaa.Error.parse({'name': ['Not a valid string.']}) return False return True @@ -35,7 +35,7 @@ def func(name): try: func(123) except deal.PreContractError as e: - assert e.args[0] == {'name': ['Not a valid string.']} + assert e.args[0] == [vaa.Error(field='name', message='Not a valid string.')] @pytest.mark.parametrize('scheme', SCHEMES) @@ -70,7 +70,7 @@ class User: try: user.name = 123 except deal.InvContractError as e: - assert e.args[0] == {'name': ['Not a valid string.']} + assert e.args[0] == [vaa.Error(field='name', message='Not a valid string.')] @pytest.mark.parametrize('scheme', SCHEMES) @@ -129,4 +129,4 @@ def func(name): try: func(2) except deal.PreContractError as exc: - assert exc.args[0] == {'name': ['Not a valid string.']} + assert exc.args[0] == [vaa.Error(field='name', message='Not a valid string.')] From fa757c847fc69e1250eb7eecf4dc6f6ff46b2fc8 Mon Sep 17 00:00:00 2001 From: Gram Date: Thu, 16 Apr 2020 16:08:02 +0200 Subject: [PATCH 5/6] test underscore validator --- deal/_decorators/base.py | 7 +++---- tests/test_schemes.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/deal/_decorators/base.py b/deal/_decorators/base.py index fe4974f1..287197c1 100644 --- a/deal/_decorators/base.py +++ b/deal/_decorators/base.py @@ -38,8 +38,6 @@ def _make_validator(validator, message: str): if inspect.isfunction(validator): params = inspect.signature(validator).parameters if set(params) == {'_'}: - if not message: - message = 'validation error' return vaa.simple(validator, error=message) return validator @@ -75,10 +73,11 @@ def _vaa_validation(self, *args, **kwargs) -> None: if validator.is_valid(): return - # process errors + # if no errors returned, raise the default exception errors = validator.errors if not errors: - errors = self.message + raise self.exception + # Flatten single error without field to one simple str message. # This is for better readability of simple validators. if type(errors) is list: diff --git a/tests/test_schemes.py b/tests/test_schemes.py index cba22404..aa284d63 100644 --- a/tests/test_schemes.py +++ b/tests/test_schemes.py @@ -130,3 +130,29 @@ def func(name): func(2) except deal.PreContractError as exc: assert exc.args[0] == [vaa.Error(field='name', message='Not a valid string.')] + + +def test_underscore_validator(): + @deal.pre(lambda _: _.a != _.b, message='actual message') + def func(a, b=1): + return a + b + + func(2) + func(1, 3) + func(a=1, b=3) + with pytest.raises(deal.PreContractError) as exc_info: + func(1) + assert exc_info.value.args == ('actual message',) + + +def test_underscore_validator_default_message(): + @deal.pre(lambda _: _.a != _.b) + def func(a, b=1): + return a + b + + func(2) + func(1, 3) + func(a=1, b=3) + with pytest.raises(deal.PreContractError) as exc_info: + func(1) + assert exc_info.value.args == tuple() From c79a1a5d807f14bb1a2e8c6f6a976497df0998ab Mon Sep 17 00:00:00 2001 From: Gram Date: Mon, 20 Apr 2020 15:01:56 +0200 Subject: [PATCH 6/6] +recipes --- README.md | 18 +++++++------- docs/index.md | 1 + docs/recipes.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 docs/recipes.md diff --git a/README.md b/README.md index 9681d84f..6aec417f 100644 --- a/README.md +++ b/README.md @@ -21,18 +21,18 @@ That's nice `assert` statements in decorators style to validate function input, CLassic DbC: -* [`@deal.pre`](https://deal.readthedocs.io/decorators/pre.html) -- validate function arguments (pre-condition) -* [`@deal.post`](https://deal.readthedocs.io/decorators/post.html) -- validate function return value (post-condition) -* [`@deal.ensure`](https://deal.readthedocs.io/decorators/ensure.html) -- post-condition that accepts not only result, but also function arguments. -* [`@deal.inv`](https://deal.readthedocs.io/decorators/inv.html) -- validate object internal state (invariant). +* [deal.pre](https://deal.readthedocs.io/decorators/pre.html) -- validate function arguments (pre-condition) +* [deal.post](https://deal.readthedocs.io/decorators/post.html) -- validate function return value (post-condition) +* [deal.ensure](https://deal.readthedocs.io/decorators/ensure.html) -- post-condition that accepts not only result, but also function arguments. +* [deal.inv](https://deal.readthedocs.io/decorators/inv.html) -- validate object internal state (invariant). Take more control: -* [`@deal.module_load`](https://deal.readthedocs.io/decorators/module_load.html) -- check contracts at module initialization. -* [`@deal.offline`](https://deal.readthedocs.io/decorators/offline.html) -- forbid network requests -* [`@deal.raises`](https://deal.readthedocs.io/decorators/raises.html) -- allow only list of exceptions -* [`@deal.reason`](https://deal.readthedocs.io/decorators/reason.html) -- check function arguments that caused a given exception. -* [`@deal.silent`](https://deal.readthedocs.io/decorators/silent.html) -- forbid output into stderr/stdout. +* [deal.module_load](https://deal.readthedocs.io/decorators/module_load.html) -- check contracts at module initialization. +* [deal.offline](https://deal.readthedocs.io/decorators/offline.html) -- forbid network requests +* [deal.raises](https://deal.readthedocs.io/decorators/raises.html) -- allow only list of exceptions +* [deal.reason](https://deal.readthedocs.io/decorators/reason.html) -- check function arguments that caused a given exception. +* [deal.silent](https://deal.readthedocs.io/decorators/silent.html) -- forbid output into stderr/stdout. Helpers: diff --git a/docs/index.md b/docs/index.md index 22135867..bd8b3025 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,6 +20,7 @@ validators testing linter + recipes .. toctree:: :maxdepth: 1 diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 00000000..9471d3d8 --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,65 @@ +# Recipes + +Some ideas that are useful in the real world applications. + +## Keep contracts simple + +If a function accepts only a few short arguments, duplicate the original signature (without annotations) for contracts: + +```python +@deal.pre(lambda left, right: right != 0) +def div(left: float, right: float) -> float: + return left / right +``` + +Otherwise, or if a function has default arguments, use simplified signature for contracts: + +```python +@deal.pre(lambda _: _.default is not None or _.right != 0) +def div(left: float, right: float, default: float = None) -> float: + try: + return left / right + except ZeroDivisionError: + if default is not None: + return default + raise +``` + +## Type checks + +Never check types with deal. [Mypy](https://github.com/python/mypy) does it much better. Also, there are [plenty of alternatives](https://github.com/typeddjango/awesome-python-typing) for both static and dynamic validation. Deal is intended to empower types, say a bit more about possible values set than you can do with type annotations, not replace them. However, if you want to play with deal a bit or make types a part of contracts, [PySchemes](https://github.com/spy16/pyschemes)-based contract is the best choice: + +```python +import deal +from pyschemes import Scheme + +@deal.pre(Scheme(dict(left=str, right=str))) +def concat(left, right): + return left + right + +concat('ab', 'cd') +# 'abcd' + +concat(1, 2) +# PreContractError: at key 'left' (expected type: 'str', got 'int') +``` + +## Prefer `pre` and `post` over `ensure` + +If a contract needs only function arguments, use `pre`. If a contract checks only function result, use `post`. And only if a contract need both input and output values at the same time, use `ensure`. Keeping available namespace for contract as small as possible makes the contract signature simpler and helps with partial execution in the linter. + +## Prefer `reason` over `raises` + +Always try your best to tell why exception can be raised. + +## Keep module initialization pure + +Nothing should happen on module load. Create some constants, compile regexpes, and that's all. Make it lazy. + +```python +deal.module_load(deal.pure) +``` + +## Contract shouldn't be important + +Never catch contract errors. Never rely on them in runtime. They are for tests and humans. The shouldn't have an actual logic, only validate it.