diff --git a/decoy/spy.py b/decoy/spy.py index 4d4223e..ef7cacf 100644 --- a/decoy/spy.py +++ b/decoy/spy.py @@ -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] @@ -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.""" @@ -160,7 +173,7 @@ 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): @@ -168,7 +181,7 @@ class AsyncSpy(BaseSpy): 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] diff --git a/decoy/warnings.py b/decoy/warnings.py index fd1b024..44e0c6f 100644 --- a/decoy/warnings.py +++ b/decoy/warnings.py @@ -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 + """ diff --git a/docs/usage/errors-and-warnings.md b/docs/usage/errors-and-warnings.md index f1cc292..4eaff7c 100644 --- a/docs/usage/errors-and-warnings.md +++ b/docs/usage/errors-and-warnings.md @@ -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 diff --git a/tests/common.py b/tests/common.py index 30eda17..be54644 100644 --- a/tests/common.py +++ b/tests/common.py @@ -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.""" ... @@ -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.""" ... diff --git a/tests/test_decoy.py b/tests/test_decoy.py index ed5d241..3a4471c 100644 --- a/tests/test_decoy.py +++ b/tests/test_decoy.py @@ -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 @@ -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")) diff --git a/tests/test_spy.py b/tests/test_spy.py index 8b33128..50b037b 100644 --- a/tests/test_spy.py +++ b/tests/test_spy.py @@ -4,6 +4,7 @@ from functools import partial from typing import Any, NamedTuple +from decoy.warnings import IncorrectCallWarning from decoy.spy_calls import SpyCall from decoy.spy import create_spy, AsyncSpy, Spy, SpyConfig @@ -52,24 +53,12 @@ def test_create_spy_from_spec_function() -> None: spy = create_spy(SpyConfig(spec=some_func, handle_call=lambda c: calls.append(c))) - spy(1, 2, 3) - spy(four=4, five=5, six=6) - spy(7, eight=8, nine=9) + spy("hello") + spy(val="world") assert calls == [ - SpyCall(spy_id=id(spy), spy_name="some_func", args=(1, 2, 3), kwargs={}), - SpyCall( - spy_id=id(spy), - spy_name="some_func", - args=(), - kwargs={"four": 4, "five": 5, "six": 6}, - ), - SpyCall( - spy_id=id(spy), - spy_name="some_func", - args=(7,), - kwargs={"eight": 8, "nine": 9}, - ), + SpyCall(spy_id=id(spy), spy_name="some_func", args=("hello",), kwargs={}), + SpyCall(spy_id=id(spy), spy_name="some_func", args=("world",), kwargs={}), ] @@ -84,24 +73,12 @@ async def test_create_spy_from_async_spec_function() -> None: ) ) - await spy(1, 2, 3) - await spy(four=4, five=5, six=6) - await spy(7, eight=8, nine=9) + await spy(val="1") + await spy("6") assert calls == [ - SpyCall(spy_id=id(spy), spy_name="some_async_func", args=(1, 2, 3), kwargs={}), - SpyCall( - spy_id=id(spy), - spy_name="some_async_func", - args=(), - kwargs={"four": 4, "five": 5, "six": 6}, - ), - SpyCall( - spy_id=id(spy), - spy_name="some_async_func", - args=(7,), - kwargs={"eight": 8, "nine": 9}, - ), + SpyCall(spy_id=id(spy), spy_name="some_async_func", args=("1",), kwargs={}), + SpyCall(spy_id=id(spy), spy_name="some_async_func", args=("6",), kwargs={}), ] @@ -111,25 +88,23 @@ def test_create_spy_from_spec_class() -> None: spy = create_spy(SpyConfig(spec=SomeClass, handle_call=lambda c: calls.append(c))) - spy.foo(1, 2, 3) - spy.bar(four=4, five=5, six=6) - spy.do_the_thing(7, eight=8, nine=9) + spy.foo(val="1") + spy.bar(a=4, b=5.0, c="6") + spy.do_the_thing(flag=True) assert calls == [ - SpyCall( - spy_id=id(spy.foo), spy_name="SomeClass.foo", args=(1, 2, 3), kwargs={} - ), + SpyCall(spy_id=id(spy.foo), spy_name="SomeClass.foo", args=("1",), kwargs={}), SpyCall( spy_id=id(spy.bar), spy_name="SomeClass.bar", - args=(), - kwargs={"four": 4, "five": 5, "six": 6}, + args=(4, 5.0, "6"), + kwargs={}, ), SpyCall( spy_id=id(spy.do_the_thing), spy_name="SomeClass.do_the_thing", - args=(7,), - kwargs={"eight": 8, "nine": 9}, + args=(), + kwargs={"flag": True}, ), ] @@ -142,25 +117,28 @@ async def test_create_spy_from_async_spec_class() -> None: SpyConfig(spec=SomeAsyncClass, handle_call=lambda c: calls.append(c)) ) - await spy.foo(1, 2, 3) - await spy.bar(four=4, five=5, six=6) - await spy.do_the_thing(7, eight=8, nine=9) + await spy.foo(val="1") + await spy.bar(a=4, b=5.0, c="6") + await spy.do_the_thing(flag=True) assert calls == [ SpyCall( - spy_id=id(spy.foo), spy_name="SomeAsyncClass.foo", args=(1, 2, 3), kwargs={} + spy_id=id(spy.foo), + spy_name="SomeAsyncClass.foo", + args=("1",), + kwargs={}, ), SpyCall( spy_id=id(spy.bar), spy_name="SomeAsyncClass.bar", - args=(), - kwargs={"four": 4, "five": 5, "six": 6}, + args=(4, 5.0, "6"), + kwargs={}, ), SpyCall( spy_id=id(spy.do_the_thing), spy_name="SomeAsyncClass.do_the_thing", - args=(7,), - kwargs={"eight": 8, "nine": 9}, + args=(), + kwargs={"flag": True}, ), ] @@ -173,28 +151,28 @@ def test_create_nested_spy() -> None: SpyConfig(spec=SomeNestedClass, handle_call=lambda c: calls.append(c)) ) - spy.foo(1, 2, 3) - spy.child.bar(four=4, five=5, six=6) - spy.child.do_the_thing(7, eight=8, nine=9) + spy.foo("1") + spy.child.bar(a=4, b=5.0, c="6") + spy.child.do_the_thing(flag=True) assert calls == [ SpyCall( spy_id=id(spy.foo), spy_name="SomeNestedClass.foo", - args=(1, 2, 3), + args=("1",), kwargs={}, ), SpyCall( spy_id=id(spy.child.bar), spy_name="SomeNestedClass.child.bar", - args=(), - kwargs={"four": 4, "five": 5, "six": 6}, + args=(4, 5.0, "6"), + kwargs={}, ), SpyCall( spy_id=id(spy.child.do_the_thing), spy_name="SomeNestedClass.child.do_the_thing", - args=(7,), - kwargs={"eight": 8, "nine": 9}, + args=(), + kwargs={"flag": True}, ), ] @@ -214,21 +192,21 @@ def _sync_child(self) -> SomeClass: calls = [] spy = create_spy(SpyConfig(spec=_SomeClass, handle_call=lambda c: calls.append(c))) - await spy._async_child.bar(four=4, five=5, six=6) - spy._sync_child.do_the_thing(7, eight=8, nine=9) + await spy._async_child.bar(a=4, b=5.0, c="6") + spy._sync_child.do_the_thing(flag=True) assert calls == [ SpyCall( spy_id=id(spy._async_child.bar), spy_name="_SomeClass._async_child.bar", - args=(), - kwargs={"four": 4, "five": 5, "six": 6}, + args=(4, 5.0, "6"), + kwargs={}, ), SpyCall( spy_id=id(spy._sync_child.do_the_thing), spy_name="_SomeClass._sync_child.do_the_thing", - args=(7,), - kwargs={"eight": 8, "nine": 9}, + args=(), + kwargs={"flag": True}, ), ] @@ -243,21 +221,21 @@ class _SomeClass: calls = [] spy = create_spy(SpyConfig(spec=_SomeClass, handle_call=lambda c: calls.append(c))) - await spy._async_child.bar(four=4, five=5, six=6) - spy._sync_child.do_the_thing(7, eight=8, nine=9) + await spy._async_child.bar(a=4, b=5.0, c="6") + spy._sync_child.do_the_thing(flag=False) assert calls == [ SpyCall( spy_id=id(spy._async_child.bar), spy_name="_SomeClass._async_child.bar", - args=(), - kwargs={"four": 4, "five": 5, "six": 6}, + args=(4, 5.0, "6"), + kwargs={}, ), SpyCall( spy_id=id(spy._sync_child.do_the_thing), spy_name="_SomeClass._sync_child.do_the_thing", - args=(7,), - kwargs={"eight": 8, "nine": 9}, + args=(), + kwargs={"flag": False}, ), ] @@ -293,6 +271,14 @@ async def _do_something_async(self) -> None: ] +def test_warn_if_called_incorrectly() -> None: + """It should trigger a warning if the spy is called incorrectly.""" + spy = create_spy(SpyConfig(spec=some_func, handle_call=noop)) + + with pytest.warns(IncorrectCallWarning, match="missing a required argument"): + spy(wrong_arg_name="1") + + async def test_spy_returns_handler_value() -> None: """The spy should return the value from its call handler when called.""" call_count = 0 @@ -306,10 +292,10 @@ def _handle_call(call: Any) -> int: async_spy = create_spy(SpyConfig(spec=some_async_func, handle_call=_handle_call)) assert [ - sync_spy(), - await async_spy(), - sync_spy(), - await async_spy(), + sync_spy("hello"), + await async_spy("from thr"), + sync_spy("other"), + await async_spy("side"), ] == [1, 2, 3, 4]