Skip to content

Commit

Permalink
Merge pull request #35 from life4/generators
Browse files Browse the repository at this point in the history
Generators support
  • Loading branch information
orsinium committed Nov 18, 2019
2 parents f310835 + e198dec commit 6a240cd
Show file tree
Hide file tree
Showing 25 changed files with 250 additions and 47 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ That's nice `assert` statements in decorators style to validate function input,

* [Automatic property-based tests](https://deal.readthedocs.io/testing.html).
* [Static analysis](https://deal.readthedocs.io/linter.html).
* Asyncio support.
* Generators and async coroutines support.
* [External validators support](https://deal.readthedocs.io/validators.html#external-validators).
* [Specify allowed exceptions](https://deal.readthedocs.io/decorators/raises.html) for function
* [Invariant](https://deal.readthedocs.io/decorators/inv.html) for all actions with class instances.
Expand Down
10 changes: 9 additions & 1 deletion deal/_decorators/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# built-in
from asyncio import iscoroutinefunction
from functools import update_wrapper
from inspect import getcallargs
from inspect import getcallargs, isgeneratorfunction
from typing import Callable, Type

# app
Expand Down Expand Up @@ -94,6 +94,14 @@ async def async_wrapped(*args, **kwargs):
else:
return await function(*args, **kwargs)

def wrapped_generator(*args, **kwargs):
if self.enabled:
yield from self.patched_generator(*args, **kwargs)
else:
yield from function(*args, **kwargs)

if iscoroutinefunction(function):
return update_wrapper(async_wrapped, function)
if isgeneratorfunction(function):
return update_wrapper(wrapped_generator, function)
return update_wrapper(wrapped, function)
8 changes: 8 additions & 0 deletions deal/_decorators/ensure.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,11 @@ async def async_patched_function(self, *args, **kwargs):
result = await self.function(*args, **kwargs)
self.validate(*args, result=result, **kwargs)
return result

def patched_generator(self, *args, **kwargs):
"""
Step 3. Wrapped function calling.
"""
for result in self.function(*args, **kwargs):
self.validate(*args, result=result, **kwargs)
yield result
32 changes: 26 additions & 6 deletions deal/_decorators/offline.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,43 @@ def patched_function(self, *args, **kwargs):
"""
Step 3. Wrapped function calling.
"""
true_socket = socket.socket
socket.socket = self.fake_socket
self.patch()
try:
return self.function(*args, **kwargs)
finally:
socket.socket = true_socket
self.unpatch()

async def async_patched_function(self, *args, **kwargs):
"""
Step 3. Wrapped function calling.
"""
true_socket = socket.socket
socket.socket = self.fake_socket
self.patch()
try:
return await self.function(*args, **kwargs)
finally:
socket.socket = true_socket
self.unpatch()

def patched_generator(self, *args, **kwargs):
"""
Step 3. Wrapped function calling.
"""
generator = self.function(*args, **kwargs)
while True:
self.patch()
try:
result = next(generator)
except StopIteration:
return
finally:
self.unpatch()
yield result

def patch(self):
self.true_socket = socket.socket
socket.socket = self.fake_socket

def unpatch(self):
socket.socket = self.true_socket

def fake_socket(self, *args, **kwargs):
raise self.exception
8 changes: 8 additions & 0 deletions deal/_decorators/post.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,11 @@ async def async_patched_function(self, *args, **kwargs):
result = await self.function(*args, **kwargs)
self.validate(result)
return result

def patched_generator(self, *args, **kwargs):
"""
Step 3. Wrapped function calling.
"""
for result in self.function(*args, **kwargs):
self.validate(result)
yield result
7 changes: 7 additions & 0 deletions deal/_decorators/pre.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,10 @@ async def async_patched_function(self, *args, **kwargs):
"""
self.validate(*args, **kwargs)
return await self.function(*args, **kwargs)

def patched_generator(self, *args, **kwargs):
"""
Step 3. Wrapped function calling.
"""
self.validate(*args, **kwargs)
yield from self.function(*args, **kwargs)
13 changes: 13 additions & 0 deletions deal/_decorators/raises.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,16 @@ async def async_patched_function(self, *args, **kwargs):
if not isinstance(exc, self.exceptions):
raise self.exception from exc
raise

def patched_generator(self, *args, **kwargs):
"""
Step 3. Wrapped function calling.
"""
try:
yield from self.function(*args, **kwargs)
except ContractError:
raise
except Exception as exc:
if not isinstance(exc, self.exceptions):
raise self.exception from exc
raise
13 changes: 13 additions & 0 deletions deal/_decorators/reason.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,16 @@ async def async_patched_function(self, *args, **kwargs):
except self.exception:
raise self.exception from origin
raise

def patched_generator(self, *args, **kwargs):
"""
Step 3. Wrapped function calling.
"""
try:
yield from self.function(*args, **kwargs)
except self.event as origin:
try:
self.validate(*args, **kwargs)
except self.exception:
raise self.exception from origin
raise
47 changes: 9 additions & 38 deletions deal/_decorators/silent.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# app
from .._exceptions import SilentContractError
from .._types import ExceptionType
from .base import Base
from .offline import Offline


class PatchedStringIO(StringIO):
Expand All @@ -16,44 +16,15 @@ def write(self, *args, **kwargs):
raise self.exception


class Silent(Base):
class Silent(Offline):
exception: ExceptionType = SilentContractError

def __init__(self, *, message: str = None, exception: ExceptionType = None, debug: bool = False):
"""
Step 1. Init params.
"""
super().__init__(
validator=None,
message=message,
exception=exception,
debug=debug,
)

def patched_function(self, *args, **kwargs):
"""
Step 3. Wrapped function calling.
"""
true_stdout = sys.stdout
true_stderr = sys.stderr
def patch(self):
self.true_stdout = sys.stdout
self.true_stderr = sys.stderr
sys.stdout = PatchedStringIO(exception=self.exception)
sys.stderr = PatchedStringIO(exception=self.exception)
try:
return self.function(*args, **kwargs)
finally:
sys.stdout = true_stdout
sys.stderr = true_stderr

async def async_patched_function(self, *args, **kwargs):
"""
Step 3. Wrapped function calling.
"""
true_stdout = sys.stdout
true_stderr = sys.stderr
sys.stdout = PatchedStringIO(exception=self.exception)
sys.stderr = PatchedStringIO(exception=self.exception)
try:
return await self.function(*args, **kwargs)
finally:
sys.stdout = true_stdout
sys.stderr = true_stderr

def unpatch(self):
sys.stdout = self.true_stdout
sys.stderr = self.true_stderr
2 changes: 1 addition & 1 deletion docs/chaining.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ f(12)
# PreContractError:
```

`@deal.post` contracts are chaining from bottom to top. All other contracts are chaining from top to bottom.
`@deal.post` and `@deal.ensure` contracts are resolved from bottom to top. All other contracts are resolved from top to bottom.
11 changes: 11 additions & 0 deletions docs/decorators/ensure.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ double(0)
# PostContractError:
```

For async functions it works the same. For generators validation runs for every yielded value:

```python
@deal.ensure(lambda start, end, result: start <= result < end)
def range(start, end):
step = start
while step < end:
yield step
step += 1
```

## Motivation

Ensure allows you to simplify testing, easier check hypothesis, tell more about the function behavior. It works perfect for [P vs NP](https://en.wikipedia.org/wiki/P_versus_NP_problem) like problems. In other words, for complex task when checking result correctness (even partial checking only for some cases) is much easier then calculation itself. For example:
Expand Down
2 changes: 2 additions & 0 deletions docs/decorators/offline.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ ping('ya.ru')
# OfflineContractError:
```

It works the same for generators. For async functions keep in mind patching.

## Motivation

Sometimes, your code are doing unexpected network requests. Use `@offline` to catch these cases to do code optimization if possible.
Expand Down
9 changes: 9 additions & 0 deletions docs/decorators/post.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ always_positive_sum(2, -3, -4)
# PostContractError:
```

For async functions it works the same. For generators validation runs for every yielded value:

```python
@deal.post(lambda result: result == 2 or result % 2 == 1)
@deal.post(lambda result: result == 3 or result % 3 != 0)
def get_primary_numbers():
yield from (2, 3, 5, 7, 11, 13)
```

## Motivation

Post-condition allows to make additional constraints about function result. Use type annotations to limit types of result and post-conditions to limit possible values inside given types. Let's see a few examples.
Expand Down
2 changes: 2 additions & 0 deletions docs/decorators/pre.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ sum_positive(1, 2, 3, 4)
sum_positive(1, 2, -3, 4)
# PreContractError:
```

It works the same for generators and async functions.
2 changes: 2 additions & 0 deletions docs/decorators/raises.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ divide()
# RaisesContractError:
```

It works the same for generators and async functions.

## Motivation

Exceptions are the most explicit part of Python. Any code can raise any exception. None of the tools can say you which exceptions can be raised in some function. However, sometimes you can infer it yourself and say it to other people. And `@deal.raises` will remain you if function has raised something that you forgot to specify.
Expand Down
2 changes: 2 additions & 0 deletions docs/decorators/reason.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ def divide(a, b):
return a / b
```

It works the same for generators and async functions.

## Motivation

This is the [@deal.ensure](./ensure.html) for exceptions. It works perfect when it's easy to check correctness of conditions when exception is raised.
Expand Down
2 changes: 2 additions & 0 deletions docs/decorators/silent.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ has_access('superuser')
# SilentContractError:
```

It works the same for generators. For async functions keep in mind patching.

## Motivation

If possible, avoid any output from function. Direct output makes debugging and re-usage much more difficult. Of course, there are some exceptions:
Expand Down
12 changes: 12 additions & 0 deletions tests/test_decorators/test_ensure.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,15 @@ async def func(a, b):
assert run_sync(func(1, 2)) == 'different numbers'
with pytest.raises(deal.PostContractError):
run_sync(func(0, 1))


def test_decorating_generator():
@deal.ensure(lambda x, y, result: result > y ** 2)
def multiply(x, y):
yield x * y
yield x * y * 2
yield x * y * 4

assert list(multiply(2, 1)) == [2, 4, 8]
with pytest.raises(deal.PostContractError):
list(multiply(2, 2))
14 changes: 14 additions & 0 deletions tests/test_decorators/test_offline.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,17 @@ async def func(do):
assert run_sync(func(False)) == 1
with pytest.raises(deal.OfflineContractError):
run_sync(func(True))


def test_decorating_generator():
@deal.offline
def func(do):
if not do:
yield 1
return
http = urllib3.PoolManager()
http.request('GET', 'http://httpbin.org/robots.txt')

assert list(func(False)) == [1]
with pytest.raises(deal.OfflineContractError):
list(func(True))
12 changes: 12 additions & 0 deletions tests/test_decorators/test_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,15 @@ async def func(x):
assert run_sync(func(-2)) == 2
with pytest.raises(deal.PostContractError):
run_sync(func(2))


def test_decorating_generator():
@deal.post(lambda x: x <= 8)
def double(x):
yield x
yield x * 2
yield x * 4

assert list(double(2)) == [2, 4, 8]
with pytest.raises(deal.PostContractError):
list(double(4))
12 changes: 12 additions & 0 deletions tests/test_decorators/test_pre.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,15 @@ async def double(x):
assert run_sync(double(2)) == 4
with pytest.raises(deal.PreContractError):
run_sync(double(-2))


def test_decorating_generator():
@deal.pre(lambda x: x > 0)
def double(x):
yield x
yield x * 2
yield x * 4

assert list(double(2)) == [2, 4, 8]
with pytest.raises(deal.PreContractError):
list(double(-2))
30 changes: 30 additions & 0 deletions tests/test_decorators/test_raises.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,33 @@ async def func(do, number):
run_sync(func(True, 1))
with pytest.raises(ZeroDivisionError):
run_sync(func(False, 0))


def test_decorating_generator():
@deal.raises(ZeroDivisionError)
def func(x):
if x == -1:
raise KeyError
yield 1 / x

assert list(func(1)) == [1]
with pytest.raises(ZeroDivisionError):
list(func(0))
with pytest.raises(deal.RaisesContractError):
list(func(-1))


def test_raises_generator():
@deal.raises(ZeroDivisionError)
@deal.offline
def func(do, number):
if do:
http = urllib3.PoolManager()
http.request('GET', 'http://httpbin.org/robots.txt')
yield 1 / number

assert list(func(False, 1)) == [1]
with pytest.raises(deal.OfflineContractError):
list(func(True, 1))
with pytest.raises(ZeroDivisionError):
list(func(False, 0))

0 comments on commit 6a240cd

Please sign in to comment.