Skip to content

Commit fbbe941

Browse files
authored
feat: match args leniently according to spec signature (#89)
Closes #78
1 parent be6c780 commit fbbe941

File tree

6 files changed

+119
-86
lines changed

6 files changed

+119
-86
lines changed

decoy/spy.py

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
"""
66
from inspect import getattr_static, isclass, iscoroutinefunction, isfunction, signature
77
from functools import partial
8+
from warnings import warn
89
from typing import get_type_hints, Any, Callable, Dict, NamedTuple, Optional
910

1011
from .spy_calls import SpyCall
11-
12+
from .warnings import IncorrectCallWarning
1213

1314
CallHandler = Callable[[SpyCall], Any]
1415

@@ -83,13 +84,25 @@ def __class__(self) -> Any:
8384

8485
return type(self)
8586

86-
@property
87-
def _call_name(self) -> str:
88-
"""Get the name of the spy for the call log."""
89-
if self._name:
90-
return self._name
91-
else:
92-
return f"{type(self).__module__}.{type(self).__qualname__}"
87+
def _call(self, *args: Any, **kwargs: Any) -> Any:
88+
spy_id = id(self)
89+
spy_name = (
90+
self._name
91+
if self._name
92+
else f"{type(self).__module__}.{type(self).__qualname__}"
93+
)
94+
95+
if hasattr(self, "__signature__"):
96+
try:
97+
bound_args = self.__signature__.bind(*args, **kwargs)
98+
except TypeError as e:
99+
# stacklevel: 3 ensures warning is linked to call location
100+
warn(IncorrectCallWarning(e), stacklevel=3)
101+
else:
102+
args = bound_args.args
103+
kwargs = bound_args.kwargs
104+
105+
return self._handle_call(SpyCall(spy_id, spy_name, args, kwargs))
93106

94107
def __repr__(self) -> str:
95108
"""Get a helpful string representation of the spy."""
@@ -160,15 +173,15 @@ class Spy(BaseSpy):
160173

161174
def __call__(self, *args: Any, **kwargs: Any) -> Any:
162175
"""Handle a call to the spy."""
163-
return self._handle_call(SpyCall(id(self), self._call_name, args, kwargs))
176+
return self._call(*args, **kwargs)
164177

165178

166179
class AsyncSpy(BaseSpy):
167180
"""An object that records all async. calls made to itself and its children."""
168181

169182
async def __call__(self, *args: Any, **kwargs: Any) -> Any:
170183
"""Handle a call to the spy asynchronously."""
171-
return self._handle_call(SpyCall(id(self), self._call_name, args, kwargs))
184+
return self._call(*args, **kwargs)
172185

173186

174187
SpyFactory = Callable[[SpyConfig], Any]

decoy/warnings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,15 @@ def __init__(self, rehearsal: VerifyRehearsal) -> None:
8484
)
8585
super().__init__(message)
8686
self.rehearsal = rehearsal
87+
88+
89+
class IncorrectCallWarning(DecoyWarning):
90+
"""A warning raised if a Decoy mock with a spec is called incorrectly.
91+
92+
If a call to a Decoy mock is incorrect according to `inspect.signature`,
93+
this warning will be raised.
94+
95+
See the [IncorrectCallWarning guide][] for more details.
96+
97+
[IncorrectCallWarning guide]: ../usage/errors-and-warnings/#incorrectcallwarning
98+
"""

docs/usage/errors-and-warnings.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,24 @@ Adding those `verify`s at the end may give you a feeling of "ok, good, now I'm c
151151

152152
If Decoy detects a `verify` with the same configuration of a `when`, it will raise a `RedundantVerifyWarning` to encourage you to remove the redundant, over-constraining `verify` call.
153153

154+
### IncorrectCallWarning
155+
156+
If you provide a Decoy mock with a specification `cls` or `func`, any calls to that mock will be checked according to `inspect.signature`. If the call does not match the signature, Decoy will raise a `IncorrectCalWarning`.
157+
158+
Decoy limits this to a warning, but in real life, this call would likely cause the Python engine to error at run time.
159+
160+
```python
161+
def some_func(val: string) -> int:
162+
...
163+
164+
spy = decoy.mock(func=some_func)
165+
166+
spy("hello") # ok
167+
spy(val="world") # ok
168+
spy(wrong_name="ah!") # triggers an IncorrectCallWarning
169+
spy("too", "many", "args") # triggers an IncorrectCallWarning
170+
```
171+
154172
[warnings system]: https://docs.python.org/3/library/warnings.html
155173
[warning filters]: https://docs.pytest.org/en/latest/how-to/capture-warnings.html
156174
[unittest.mock]: https://docs.python.org/3/library/unittest.mock.html

tests/common.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def bar(self, a: int, b: float, c: str) -> bool:
1313
"""Get the bar bool based on a few inputs."""
1414
...
1515

16-
def do_the_thing(self, flag: bool) -> None:
16+
def do_the_thing(self, *, flag: bool) -> None:
1717
"""Perform a side-effect without a return value."""
1818
...
1919

@@ -42,7 +42,7 @@ async def bar(self, a: int, b: float, c: str) -> bool:
4242
"""Get the bar bool based on a few inputs."""
4343
...
4444

45-
async def do_the_thing(self, flag: bool) -> None:
45+
async def do_the_thing(self, *, flag: bool) -> None:
4646
"""Perform a side-effect without a return value."""
4747
...
4848

tests/test_decoy.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ def test_when_then_return(decoy: Decoy) -> None:
6767
result = subject("hello")
6868
assert result == "hello world"
6969

70+
result = subject(val="hello")
71+
assert result == "hello world"
72+
7073
result = subject("asdfghjkl")
7174
assert result is None
7275

@@ -119,6 +122,7 @@ def test_verify(decoy: Decoy) -> None:
119122
subject("hello")
120123

121124
decoy.verify(subject("hello"))
125+
decoy.verify(subject(val="hello"))
122126

123127
with pytest.raises(errors.VerifyError):
124128
decoy.verify(subject("goodbye"))

0 commit comments

Comments
 (0)