Skip to content

Commit

Permalink
Merge c79a1a5 into 29ea4b0
Browse files Browse the repository at this point in the history
  • Loading branch information
orsinium committed Apr 20, 2020
2 parents 29ea4b0 + c79a1a5 commit 205e8c5
Show file tree
Hide file tree
Showing 7 changed files with 169 additions and 25 deletions.
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
47 changes: 41 additions & 6 deletions deal/_decorators/base.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -18,13 +21,27 @@ 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) == {'_'}:
return vaa.simple(validator, error=message)

return validator

def validate(self, *args, **kwargs) -> None:
"""
Step 4. Process contract (validator)
Expand All @@ -37,24 +54,42 @@ 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
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:
del params[bad_name]

# validate
validator = self.validator(data=params)
if validator.is_valid():
return

# if no errors returned, raise the default exception
errors = validator.errors
if not errors:
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:
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)
Expand Down Expand Up @@ -101,6 +136,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)
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
validators
testing
linter
recipes
.. toctree::
:maxdepth: 1
Expand Down
65 changes: 65 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 21 additions & 4 deletions docs/validators.md
Original file line number Diff line number Diff line change
@@ -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`:

Expand All @@ -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

Expand All @@ -43,3 +58,5 @@ func('Chris')
func(123)
# PreContractError: {'name': ['Not a valid string.']}
```

See [vaa](https://github.com/life4/vaa) documentation for details.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,22 @@ python = ">=3.5"
astroid = "*"
hypothesis = "*"
typeguard = "*"
vaa = ">=0.2.1"

[tool.poetry.dev-dependencies]
coverage = "*"
marshmallow = "*"
pytest = "*"
pytest-cov = "*"
urllib3 = "*"
vaa = ">=0.1.4"

m2r = "*"
recommonmark = "*"
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"]
Expand Down
34 changes: 30 additions & 4 deletions tests/test_schemes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -129,4 +129,30 @@ 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.')]


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()

0 comments on commit 205e8c5

Please sign in to comment.