Skip to content

Commit

Permalink
feat(when): allow then_do to take an async function
Browse files Browse the repository at this point in the history
Closes #136
  • Loading branch information
mcous committed Jun 5, 2022
1 parent ed5fca1 commit 4ae00e5
Show file tree
Hide file tree
Showing 23 changed files with 518 additions and 263 deletions.
16 changes: 14 additions & 2 deletions decoy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
TYPE_CHECKING,
Any,
Callable,
Coroutine,
Generic,
Optional,
Union,
Expand Down Expand Up @@ -195,6 +196,9 @@ def verify(
the actual call. Decoy will compare and match any given arguments,
ignoring unspecified arguments.
Raises:
VerifyError: The verification was not satisfied.
Example:
```python
def test_create_something(decoy: Decoy):
Expand Down Expand Up @@ -278,12 +282,20 @@ def then_raise(self, error: Exception) -> None:
"""
self._core.then_raise(error)

def then_do(self, action: Callable[..., ReturnT]) -> None:
def then_do(
self,
action: Callable[..., Union[ReturnT, Coroutine[Any, Any, ReturnT]]],
) -> None:
"""Configure the stub to trigger an action.
Arguments:
action: The function to call. Called with whatever arguments
are actually passed to the stub.
are actually passed to the stub. May be an `async def`
function if the mock is also asynchronous.
Raises:
MockNotAsyncError: `action` was an `async def` function,
but the mock is synchronous.
"""
self._core.then_do(action)

Expand Down
10 changes: 10 additions & 0 deletions decoy/core.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""Decoy implementation logic."""
import inspect
from typing import Any, Callable, Optional

from .call_handler import CallHandler
from .errors import MockNotAsyncError
from .spy import SpyCreator
from .spy_events import WhenRehearsal, PropAccessType, SpyEvent, SpyInfo, SpyPropAccess
from .spy_log import SpyLog
Expand Down Expand Up @@ -111,6 +113,14 @@ def then_raise(self, error: Exception) -> None:

def then_do(self, action: Callable[..., ReturnT]) -> None:
"""Set the stub to perform an action."""
spy_info = self._rehearsal.spy

if inspect.iscoroutinefunction(action) and not spy_info.is_async:
raise MockNotAsyncError(
f"Cannot configure {spy_info.name} to call {action}"
f" because {spy_info.name} is not asynchronous."
)

self._stub_store.add(
rehearsal=self._rehearsal,
behavior=StubBehavior(action=action),
Expand Down
11 changes: 11 additions & 0 deletions decoy/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ def __init__(self) -> None:
super().__init__("Rehearsal not found.")


class MockNotAsyncError(TypeError):
"""An error raised when an asynchronous function is used with a synchronous mock.
This error is raised if you pass an `async def` function
to a synchronous stub's `then_do` method.
See the [MockNotAsyncError guide][] for more details.
[MockNotAsyncError guide]: ../usage/errors-and-warnings/#mocknotasyncerror
"""


class VerifyError(AssertionError):
"""An error raised when actual calls do not match rehearsals given to `verify`.
Expand Down
4 changes: 3 additions & 1 deletion decoy/spy.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Classes in this module are heavily inspired by the
[unittest.mock library](https://docs.python.org/3/library/unittest.mock.html).
"""
import inspect
from types import TracebackType
from typing import Any, ContextManager, Dict, Optional, Type, Union, cast, overload

Expand Down Expand Up @@ -155,7 +156,8 @@ class AsyncSpy(BaseSpy):

async def __call__(self, *args: Any, **kwargs: Any) -> Any:
"""Handle a call to the spy asynchronously."""
return self._call(*args, **kwargs)
result = self._call(*args, **kwargs)
return (await result) if inspect.iscoroutine(result) else result


class Spy(BaseSpy):
Expand Down
6 changes: 3 additions & 3 deletions decoy/spy_core.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Mock specification."""
"""Core spy logic."""
import inspect
import functools
import warnings
Expand Down Expand Up @@ -27,7 +27,7 @@ class BoundArgs(NamedTuple):


class SpyCore:
"""Spy configuration values.
"""Core spy logic for mimicing a given `source` object.
Arguments:
source: The source object the Spy is mimicing.
Expand Down Expand Up @@ -55,10 +55,10 @@ def __init__(
self._full_name = (
f"{self._module_name}.{self._name}" if self._module_name else self._name
)
self._info = SpyInfo(id=id(self), name=self._name)
self._class_type = self._source if inspect.isclass(self._source) else None
self._signature = _get_signature(source)
self._is_async = is_async or _get_is_async(source)
self._info = SpyInfo(id=id(self), name=self._name, is_async=self._is_async)

@property
def info(self) -> SpyInfo:
Expand Down
1 change: 1 addition & 0 deletions decoy/spy_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class SpyInfo(NamedTuple):

id: int
name: str
is_async: bool


class SpyCall(NamedTuple):
Expand Down
18 changes: 17 additions & 1 deletion docs/usage/errors-and-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,24 @@ decoy.when().then_return(42) # raises a MissingRehearsalError
If you're working with async/await code, this can also happen if you forget to include `await` in your rehearsal, because the `await` is necessary for the spy's call handler to add the call to the stack.

```python
decoy.when(some_async_func("hello")).then_return("world") # will raise
decoy.when(await some_async_func("hello")).then_return("world") # all good
decoy.when(some_async_func("hello")).then_return("world") # will raise
```

### MockNotAsyncError

A [decoy.errors.MockNotAsyncError][] will be raised if you pass an `async def` function to [decoy.Stub.then_do][] of a non-synchronous mock.

```python
async_mock = decoy.mock(name="async_mock", is_async=True)
async_mock = decoy.mock(name="sync_mock")

async def _handle_call(input: str) -> str:
print(input)
return "world"

decoy.when(await async_mock("hello")).then_do(_handle_call) # all good
decoy.when(sync_mock("hello")).then_do(_handle_call) # will raise
```

## Warnings
Expand Down
2 changes: 1 addition & 1 deletion tests/common.py → tests/fixtures.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Common test interfaces."""
"""Common test fixtures."""
from functools import lru_cache
from typing import Any

Expand Down
17 changes: 10 additions & 7 deletions tests/test_call_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ def test_handle_call_with_no_stubbing(
) -> None:
"""It should noop and add the call to the stack if no stubbing is configured."""
spy_call = SpyEvent(
spy=SpyInfo(id=42, name="spy_name"), payload=SpyCall(args=(), kwargs={})
spy=SpyInfo(id=42, name="spy_name", is_async=False),
payload=SpyCall(args=(), kwargs={}),
)
behavior = None

Expand All @@ -61,7 +62,8 @@ def test_handle_call_with_return(
) -> None:
"""It return a Stub's configured return value."""
spy_call = SpyEvent(
spy=SpyInfo(id=42, name="spy_name"), payload=SpyCall(args=(), kwargs={})
spy=SpyInfo(id=42, name="spy_name", is_async=False),
payload=SpyCall(args=(), kwargs={}),
)
behavior = StubBehavior(return_value="hello world")

Expand All @@ -81,7 +83,8 @@ def test_handle_call_with_raise(
) -> None:
"""It raise a Stub's configured error."""
spy_call = SpyEvent(
spy=SpyInfo(id=42, name="spy_name"), payload=SpyCall(args=(), kwargs={})
spy=SpyInfo(id=42, name="spy_name", is_async=False),
payload=SpyCall(args=(), kwargs={}),
)
behavior = StubBehavior(error=RuntimeError("oh no"))

Expand All @@ -102,7 +105,7 @@ def test_handle_call_with_action(
"""It should trigger a stub's configured action."""
action = decoy.mock()
spy_call = SpyEvent(
spy=SpyInfo(id=42, name="spy_name"),
spy=SpyInfo(id=42, name="spy_name", is_async=False),
payload=SpyCall(args=(1,), kwargs={"foo": "bar"}),
)
behavior = StubBehavior(action=action)
Expand All @@ -124,7 +127,7 @@ def test_handle_prop_get_with_action(
"""It should trigger a prop get stub's configured action."""
action = decoy.mock()
spy_call = SpyEvent(
spy=SpyInfo(id=42, name="spy_name"),
spy=SpyInfo(id=42, name="spy_name", is_async=False),
payload=SpyPropAccess(prop_name="prop", access_type=PropAccessType.GET),
)
behavior = StubBehavior(action=action)
Expand All @@ -145,7 +148,7 @@ def test_handle_call_with_context_enter(
) -> None:
"""It should return a Stub's configured context value."""
spy_call = SpyEvent(
spy=SpyInfo(id=42, name="spy_name"),
spy=SpyInfo(id=42, name="spy_name", is_async=False),
payload=SpyCall(args=(), kwargs={}),
)
behavior = StubBehavior(context_value="hello world")
Expand All @@ -166,7 +169,7 @@ def test_handle_call_with_context_enter_none(
) -> None:
"""It should allow a configured context value to be None."""
spy_call = SpyEvent(
spy=SpyInfo(id=42, name="spy_name"),
spy=SpyInfo(id=42, name="spy_name", is_async=False),
payload=SpyCall(args=(), kwargs={}),
)
behavior = StubBehavior(context_value=None)
Expand Down

0 comments on commit 4ae00e5

Please sign in to comment.