Skip to content

Commit

Permalink
feat: match args leniently according to spec signature (#89)
Browse files Browse the repository at this point in the history
Closes #78
  • Loading branch information
mcous authored Dec 1, 2021
1 parent be6c780 commit fbbe941
Show file tree
Hide file tree
Showing 6 changed files with 119 additions and 86 deletions.
33 changes: 23 additions & 10 deletions decoy/spy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
"""
from inspect import getattr_static, isclass, iscoroutinefunction, isfunction, signature
from functools import partial
from warnings import warn
from typing import get_type_hints, Any, Callable, Dict, NamedTuple, Optional

from .spy_calls import SpyCall

from .warnings import IncorrectCallWarning

CallHandler = Callable[[SpyCall], Any]

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

return type(self)

@property
def _call_name(self) -> str:
"""Get the name of the spy for the call log."""
if self._name:
return self._name
else:
return f"{type(self).__module__}.{type(self).__qualname__}"
def _call(self, *args: Any, **kwargs: Any) -> Any:
spy_id = id(self)
spy_name = (
self._name
if self._name
else f"{type(self).__module__}.{type(self).__qualname__}"
)

if hasattr(self, "__signature__"):
try:
bound_args = self.__signature__.bind(*args, **kwargs)
except TypeError as e:
# stacklevel: 3 ensures warning is linked to call location
warn(IncorrectCallWarning(e), stacklevel=3)
else:
args = bound_args.args
kwargs = bound_args.kwargs

return self._handle_call(SpyCall(spy_id, spy_name, args, kwargs))

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

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


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

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


SpyFactory = Callable[[SpyConfig], Any]
Expand Down
12 changes: 12 additions & 0 deletions decoy/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,15 @@ def __init__(self, rehearsal: VerifyRehearsal) -> None:
)
super().__init__(message)
self.rehearsal = rehearsal


class IncorrectCallWarning(DecoyWarning):
"""A warning raised if a Decoy mock with a spec is called incorrectly.
If a call to a Decoy mock is incorrect according to `inspect.signature`,
this warning will be raised.
See the [IncorrectCallWarning guide][] for more details.
[IncorrectCallWarning guide]: ../usage/errors-and-warnings/#incorrectcallwarning
"""
18 changes: 18 additions & 0 deletions docs/usage/errors-and-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,24 @@ Adding those `verify`s at the end may give you a feeling of "ok, good, now I'm c

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.

### IncorrectCallWarning

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`.

Decoy limits this to a warning, but in real life, this call would likely cause the Python engine to error at run time.

```python
def some_func(val: string) -> int:
...

spy = decoy.mock(func=some_func)

spy("hello") # ok
spy(val="world") # ok
spy(wrong_name="ah!") # triggers an IncorrectCallWarning
spy("too", "many", "args") # triggers an IncorrectCallWarning
```

[warnings system]: https://docs.python.org/3/library/warnings.html
[warning filters]: https://docs.pytest.org/en/latest/how-to/capture-warnings.html
[unittest.mock]: https://docs.python.org/3/library/unittest.mock.html
4 changes: 2 additions & 2 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def bar(self, a: int, b: float, c: str) -> bool:
"""Get the bar bool based on a few inputs."""
...

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

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

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

Expand Down
4 changes: 4 additions & 0 deletions tests/test_decoy.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ def test_when_then_return(decoy: Decoy) -> None:
result = subject("hello")
assert result == "hello world"

result = subject(val="hello")
assert result == "hello world"

result = subject("asdfghjkl")
assert result is None

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

decoy.verify(subject("hello"))
decoy.verify(subject(val="hello"))

with pytest.raises(errors.VerifyError):
decoy.verify(subject("goodbye"))
Expand Down
Loading

0 comments on commit fbbe941

Please sign in to comment.