Skip to content

Commit 4ae00e5

Browse files
committed
feat(when): allow then_do to take an async function
Closes #136
1 parent ed5fca1 commit 4ae00e5

23 files changed

+518
-263
lines changed

decoy/__init__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
TYPE_CHECKING,
55
Any,
66
Callable,
7+
Coroutine,
78
Generic,
89
Optional,
910
Union,
@@ -195,6 +196,9 @@ def verify(
195196
the actual call. Decoy will compare and match any given arguments,
196197
ignoring unspecified arguments.
197198
199+
Raises:
200+
VerifyError: The verification was not satisfied.
201+
198202
Example:
199203
```python
200204
def test_create_something(decoy: Decoy):
@@ -278,12 +282,20 @@ def then_raise(self, error: Exception) -> None:
278282
"""
279283
self._core.then_raise(error)
280284

281-
def then_do(self, action: Callable[..., ReturnT]) -> None:
285+
def then_do(
286+
self,
287+
action: Callable[..., Union[ReturnT, Coroutine[Any, Any, ReturnT]]],
288+
) -> None:
282289
"""Configure the stub to trigger an action.
283290
284291
Arguments:
285292
action: The function to call. Called with whatever arguments
286-
are actually passed to the stub.
293+
are actually passed to the stub. May be an `async def`
294+
function if the mock is also asynchronous.
295+
296+
Raises:
297+
MockNotAsyncError: `action` was an `async def` function,
298+
but the mock is synchronous.
287299
"""
288300
self._core.then_do(action)
289301

decoy/core.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Decoy implementation logic."""
2+
import inspect
23
from typing import Any, Callable, Optional
34

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

112114
def then_do(self, action: Callable[..., ReturnT]) -> None:
113115
"""Set the stub to perform an action."""
116+
spy_info = self._rehearsal.spy
117+
118+
if inspect.iscoroutinefunction(action) and not spy_info.is_async:
119+
raise MockNotAsyncError(
120+
f"Cannot configure {spy_info.name} to call {action}"
121+
f" because {spy_info.name} is not asynchronous."
122+
)
123+
114124
self._stub_store.add(
115125
rehearsal=self._rehearsal,
116126
behavior=StubBehavior(action=action),

decoy/errors.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ def __init__(self) -> None:
2727
super().__init__("Rehearsal not found.")
2828

2929

30+
class MockNotAsyncError(TypeError):
31+
"""An error raised when an asynchronous function is used with a synchronous mock.
32+
33+
This error is raised if you pass an `async def` function
34+
to a synchronous stub's `then_do` method.
35+
See the [MockNotAsyncError guide][] for more details.
36+
37+
[MockNotAsyncError guide]: ../usage/errors-and-warnings/#mocknotasyncerror
38+
"""
39+
40+
3041
class VerifyError(AssertionError):
3142
"""An error raised when actual calls do not match rehearsals given to `verify`.
3243

decoy/spy.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Classes in this module are heavily inspired by the
44
[unittest.mock library](https://docs.python.org/3/library/unittest.mock.html).
55
"""
6+
import inspect
67
from types import TracebackType
78
from typing import Any, ContextManager, Dict, Optional, Type, Union, cast, overload
89

@@ -155,7 +156,8 @@ class AsyncSpy(BaseSpy):
155156

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

160162

161163
class Spy(BaseSpy):

decoy/spy_core.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Mock specification."""
1+
"""Core spy logic."""
22
import inspect
33
import functools
44
import warnings
@@ -27,7 +27,7 @@ class BoundArgs(NamedTuple):
2727

2828

2929
class SpyCore:
30-
"""Spy configuration values.
30+
"""Core spy logic for mimicing a given `source` object.
3131
3232
Arguments:
3333
source: The source object the Spy is mimicing.
@@ -55,10 +55,10 @@ def __init__(
5555
self._full_name = (
5656
f"{self._module_name}.{self._name}" if self._module_name else self._name
5757
)
58-
self._info = SpyInfo(id=id(self), name=self._name)
5958
self._class_type = self._source if inspect.isclass(self._source) else None
6059
self._signature = _get_signature(source)
6160
self._is_async = is_async or _get_is_async(source)
61+
self._info = SpyInfo(id=id(self), name=self._name, is_async=self._is_async)
6262

6363
@property
6464
def info(self) -> SpyInfo:

decoy/spy_events.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class SpyInfo(NamedTuple):
1616

1717
id: int
1818
name: str
19+
is_async: bool
1920

2021

2122
class SpyCall(NamedTuple):

docs/usage/errors-and-warnings.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,24 @@ decoy.when().then_return(42) # raises a MissingRehearsalError
2727
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.
2828

2929
```python
30-
decoy.when(some_async_func("hello")).then_return("world") # will raise
3130
decoy.when(await some_async_func("hello")).then_return("world") # all good
31+
decoy.when(some_async_func("hello")).then_return("world") # will raise
32+
```
33+
34+
### MockNotAsyncError
35+
36+
A [decoy.errors.MockNotAsyncError][] will be raised if you pass an `async def` function to [decoy.Stub.then_do][] of a non-synchronous mock.
37+
38+
```python
39+
async_mock = decoy.mock(name="async_mock", is_async=True)
40+
async_mock = decoy.mock(name="sync_mock")
41+
42+
async def _handle_call(input: str) -> str:
43+
print(input)
44+
return "world"
45+
46+
decoy.when(await async_mock("hello")).then_do(_handle_call) # all good
47+
decoy.when(sync_mock("hello")).then_do(_handle_call) # will raise
3248
```
3349

3450
## Warnings

tests/common.py renamed to tests/fixtures.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Common test interfaces."""
1+
"""Common test fixtures."""
22
from functools import lru_cache
33
from typing import Any
44

tests/test_call_handler.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ def test_handle_call_with_no_stubbing(
4141
) -> None:
4242
"""It should noop and add the call to the stack if no stubbing is configured."""
4343
spy_call = SpyEvent(
44-
spy=SpyInfo(id=42, name="spy_name"), payload=SpyCall(args=(), kwargs={})
44+
spy=SpyInfo(id=42, name="spy_name", is_async=False),
45+
payload=SpyCall(args=(), kwargs={}),
4546
)
4647
behavior = None
4748

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

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

@@ -102,7 +105,7 @@ def test_handle_call_with_action(
102105
"""It should trigger a stub's configured action."""
103106
action = decoy.mock()
104107
spy_call = SpyEvent(
105-
spy=SpyInfo(id=42, name="spy_name"),
108+
spy=SpyInfo(id=42, name="spy_name", is_async=False),
106109
payload=SpyCall(args=(1,), kwargs={"foo": "bar"}),
107110
)
108111
behavior = StubBehavior(action=action)
@@ -124,7 +127,7 @@ def test_handle_prop_get_with_action(
124127
"""It should trigger a prop get stub's configured action."""
125128
action = decoy.mock()
126129
spy_call = SpyEvent(
127-
spy=SpyInfo(id=42, name="spy_name"),
130+
spy=SpyInfo(id=42, name="spy_name", is_async=False),
128131
payload=SpyPropAccess(prop_name="prop", access_type=PropAccessType.GET),
129132
)
130133
behavior = StubBehavior(action=action)
@@ -145,7 +148,7 @@ def test_handle_call_with_context_enter(
145148
) -> None:
146149
"""It should return a Stub's configured context value."""
147150
spy_call = SpyEvent(
148-
spy=SpyInfo(id=42, name="spy_name"),
151+
spy=SpyInfo(id=42, name="spy_name", is_async=False),
149152
payload=SpyCall(args=(), kwargs={}),
150153
)
151154
behavior = StubBehavior(context_value="hello world")
@@ -166,7 +169,7 @@ def test_handle_call_with_context_enter_none(
166169
) -> None:
167170
"""It should allow a configured context value to be None."""
168171
spy_call = SpyEvent(
169-
spy=SpyInfo(id=42, name="spy_name"),
172+
spy=SpyInfo(id=42, name="spy_name", is_async=False),
170173
payload=SpyCall(args=(), kwargs={}),
171174
)
172175
behavior = StubBehavior(context_value=None)

0 commit comments

Comments
 (0)