Skip to content

Commit d1ba0d3

Browse files
authored
feat: all extra arguments to be ignored (#61)
Closes #60
1 parent bc54925 commit d1ba0d3

15 files changed

+466
-66
lines changed

decoy/__init__.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,24 @@ def create_decoy_func(
8787
spy = self._core.mock(spec=spec, is_async=is_async)
8888
return cast(FuncT, spy)
8989

90-
def when(self, _rehearsal_result: ReturnT) -> "Stub[ReturnT]":
90+
def when(
91+
self,
92+
_rehearsal_result: ReturnT,
93+
*,
94+
ignore_extra_args: bool = False,
95+
) -> "Stub[ReturnT]":
9196
"""Create a [Stub][decoy.Stub] configuration using a rehearsal call.
9297
9398
See [stubbing usage guide](../usage/when/) for more details.
9499
95100
Arguments:
96101
_rehearsal_result: The return value of a rehearsal, used for typechecking.
102+
ignore_extra_args: Allow the rehearsal to specify fewer arguments than
103+
the actual call. Decoy will compare and match any given arguments,
104+
ignoring unspecified arguments.
97105
98106
Returns:
99-
A stub to configure using `then_return` or `then_raise`.
107+
A stub to configure using `then_return`, `then_raise`, or `then_do`.
100108
101109
Example:
102110
```python
@@ -110,10 +118,18 @@ def when(self, _rehearsal_result: ReturnT) -> "Stub[ReturnT]":
110118
is a rehearsal for stub configuration purposes rather than a call
111119
from the code-under-test.
112120
"""
113-
stub_core = self._core.when(_rehearsal_result)
121+
stub_core = self._core.when(
122+
_rehearsal_result,
123+
ignore_extra_args=ignore_extra_args,
124+
)
114125
return Stub(core=stub_core)
115126

116-
def verify(self, *_rehearsal_results: Any, times: Optional[int] = None) -> None:
127+
def verify(
128+
self,
129+
*_rehearsal_results: Any,
130+
times: Optional[int] = None,
131+
ignore_extra_args: bool = False,
132+
) -> None:
117133
"""Verify a decoy was called using one or more rehearsals.
118134
119135
See [verification usage guide](../usage/verify/) for more details.
@@ -125,6 +141,9 @@ def verify(self, *_rehearsal_results: Any, times: Optional[int] = None) -> None:
125141
the call count must match exactly, otherwise the call must appear
126142
at least once. The `times` argument must be used with exactly one
127143
rehearsal.
144+
ignore_extra_args: Allow the rehearsal to specify fewer arguments than
145+
the actual call. Decoy will compare and match any given arguments,
146+
ignoring unspecified arguments.
128147
129148
Example:
130149
```python
@@ -142,7 +161,11 @@ def test_create_something(decoy: Decoy):
142161
API sugar. Decoy will pop the last call(s) to _any_ fake off its
143162
call stack, which will end up being the call inside `verify`.
144163
"""
145-
self._core.verify(*_rehearsal_results, times=times)
164+
self._core.verify(
165+
*_rehearsal_results,
166+
times=times,
167+
ignore_extra_args=ignore_extra_args,
168+
)
146169

147170
def reset(self) -> None:
148171
"""Reset all decoy state.

decoy/call_stack.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def push(self, spy_call: SpyCall) -> None:
1616
"""Add a new spy call to the stack."""
1717
self._stack.append(spy_call)
1818

19-
def consume_when_rehearsal(self) -> WhenRehearsal:
19+
def consume_when_rehearsal(self, ignore_extra_args: bool) -> WhenRehearsal:
2020
"""Consume the last call to a Spy as a `when` rehearsal.
2121
2222
This marks a call as a rehearsal but does not remove it from the stack.
@@ -28,11 +28,15 @@ def consume_when_rehearsal(self) -> WhenRehearsal:
2828
if not isinstance(call, SpyCall):
2929
raise MissingRehearsalError()
3030

31-
rehearsal = WhenRehearsal(*call)
31+
rehearsal = WhenRehearsal(*call)._replace(ignore_extra_args=ignore_extra_args)
3232
self._stack[-1] = rehearsal
3333
return rehearsal
3434

35-
def consume_verify_rehearsals(self, count: int) -> List[VerifyRehearsal]:
35+
def consume_verify_rehearsals(
36+
self,
37+
count: int,
38+
ignore_extra_args: bool,
39+
) -> List[VerifyRehearsal]:
3640
"""Consume the last `count` calls to Spies as rehearsals.
3741
3842
This marks calls as rehearsals but does not remove them from the stack.
@@ -42,7 +46,10 @@ def consume_verify_rehearsals(self, count: int) -> List[VerifyRehearsal]:
4246
if len(calls) != count or not all(isinstance(call, SpyCall) for call in calls):
4347
raise MissingRehearsalError()
4448

45-
rehearsals = [VerifyRehearsal(*call) for call in calls]
49+
rehearsals = [
50+
VerifyRehearsal(*call)._replace(ignore_extra_args=ignore_extra_args)
51+
for call in calls
52+
]
4653
self._stack[-count:] = rehearsals
4754
return rehearsals
4855

decoy/core.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,24 @@ def mock(self, *, spec: Optional[Any] = None, is_async: bool = False) -> Any:
4343
)
4444
return self._create_spy(config)
4545

46-
def when(self, _rehearsal: ReturnT) -> "StubCore":
46+
def when(self, _rehearsal: ReturnT, *, ignore_extra_args: bool) -> "StubCore":
4747
"""Create a new stub from the last spy rehearsal."""
48-
rehearsal = self._call_stack.consume_when_rehearsal()
48+
rehearsal = self._call_stack.consume_when_rehearsal(
49+
ignore_extra_args=ignore_extra_args
50+
)
4951
return StubCore(rehearsal=rehearsal, stub_store=self._stub_store)
5052

51-
def verify(self, *_rehearsals: ReturnT, times: Optional[int] = None) -> None:
53+
def verify(
54+
self,
55+
*_rehearsals: ReturnT,
56+
times: Optional[int],
57+
ignore_extra_args: bool,
58+
) -> None:
5259
"""Verify that a Spy or Spies were called."""
53-
rehearsals = self._call_stack.consume_verify_rehearsals(count=len(_rehearsals))
60+
rehearsals = self._call_stack.consume_verify_rehearsals(
61+
count=len(_rehearsals),
62+
ignore_extra_args=ignore_extra_args,
63+
)
5464
calls = self._call_stack.get_by_rehearsals(rehearsals)
5565

5666
self._verifier.verify(rehearsals=rehearsals, calls=calls, times=times)

decoy/spy_calls.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ class BaseSpyCall(NamedTuple):
1212
spy_name: String name of the spy.
1313
args: Arguments list of the call.
1414
kwargs: Keyword arguments list of the call.
15+
ignore_extra_args: Whether extra arguments to the call should
16+
be ignored during comparison. Only used by rehearsals.
1517
"""
1618

1719
spy_id: int
1820
spy_name: str
1921
args: Tuple[Any, ...]
2022
kwargs: Dict[str, Any]
23+
ignore_extra_args: bool = False
2124

2225

2326
class BaseSpyRehearsal(BaseSpyCall):
@@ -45,3 +48,25 @@ class VerifyRehearsal(BaseSpyRehearsal):
4548
"""A call that has been used as a rehearsal for `when`."""
4649

4750
pass
51+
52+
53+
def match_call(call: BaseSpyCall, rehearsal: BaseSpyRehearsal) -> bool:
54+
"""Check if a call matches a given rehearsal."""
55+
if call.spy_id != rehearsal.spy_id:
56+
return False
57+
58+
if rehearsal.ignore_extra_args:
59+
try:
60+
args_match = all(
61+
call.args[i] == value for i, value in enumerate(rehearsal.args)
62+
)
63+
kwargs_match = all(
64+
call.kwargs[key] == value for key, value in rehearsal.kwargs.items()
65+
)
66+
67+
return args_match and kwargs_match
68+
69+
except (IndexError, KeyError):
70+
return False
71+
72+
return call.args == rehearsal.args and call.kwargs == rehearsal.kwargs

decoy/stringify.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ def stringify_call(call: BaseSpyCall) -> str:
1313
"""
1414
args_list = [repr(arg) for arg in call.args]
1515
kwargs_list = [f"{key}={repr(val)}" for key, val in call.kwargs.items()]
16-
17-
return f"{call.spy_name}({', '.join(args_list + kwargs_list)})"
16+
extra_args_msg = (
17+
" - ignoring unspecified arguments" if call.ignore_extra_args else ""
18+
)
19+
return f"{call.spy_name}({', '.join(args_list + kwargs_list)}){extra_args_msg}"
1820

1921

2022
def stringify_call_list(calls: Sequence[BaseSpyCall]) -> str:

decoy/stub_store.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Stub creation and storage."""
22
from typing import Any, Callable, List, NamedTuple, Optional
33

4-
from .spy_calls import SpyCall, WhenRehearsal
4+
from .spy_calls import SpyCall, WhenRehearsal, match_call
55

66

77
class StubBehavior(NamedTuple):
@@ -38,7 +38,7 @@ def get_by_call(self, call: SpyCall) -> StubBehavior:
3838
for i in reversed_indices:
3939
stub = self._stubs[i]
4040

41-
if stub.rehearsal == call:
41+
if match_call(call, stub.rehearsal):
4242
if stub.behavior.once:
4343
self._stubs.pop(i)
4444

decoy/verifier.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Spy call verification."""
22
from typing import Optional, Sequence
33

4-
from .spy_calls import SpyCall, VerifyRehearsal
4+
from .spy_calls import SpyCall, VerifyRehearsal, match_call
55
from .errors import VerifyError
66

77

@@ -30,8 +30,9 @@ def verify(
3030

3131
for i in range(len(calls)):
3232
calls_subset = calls[i : i + len(rehearsals)]
33+
matches = [match_call(c, r) for c, r in zip(calls_subset, rehearsals)]
3334

34-
if calls_subset == rehearsals:
35+
if all(matches) and len(calls_subset) == len(rehearsals):
3536
match_count = match_count + 1
3637

3738
calls_verified = match_count != 0 if times is None else match_count == times

decoy/warning_checker.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Dict, List, Sequence
33
from warnings import warn
44

5-
from .spy_calls import BaseSpyCall, SpyCall, WhenRehearsal, VerifyRehearsal
5+
from .spy_calls import BaseSpyCall, SpyCall, WhenRehearsal, VerifyRehearsal, match_call
66
from .warnings import MiscalledStubWarning, RedundantVerifyWarning
77

88

@@ -33,12 +33,12 @@ def _check_no_miscalled_stubs(all_calls: Sequence[BaseSpyCall]) -> None:
3333
wr for wr in spy_calls[0:index] if isinstance(wr, WhenRehearsal)
3434
]
3535

36-
matched_past_stubs = [wr for wr in past_stubs if wr == call]
36+
matched_past_stubs = [wr for wr in past_stubs if match_call(call, wr)]
3737

3838
matched_future_verifies = [
3939
vr
4040
for vr in spy_calls[index + 1 :]
41-
if isinstance(vr, VerifyRehearsal) and vr == call
41+
if isinstance(vr, VerifyRehearsal) and match_call(call, vr)
4242
]
4343

4444
if (

docs/usage/verify.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,25 @@ decoy.verify(
7575
```
7676

7777
You may only use the `times` argument with single rehearsal.
78+
79+
## Only specify some arguments
80+
81+
If you don't care about some (or any) of the arguments passed to a spy, you can use the `ignore_extra_args` argument to tell Decoy to only check the arguments you pass.
82+
83+
```python
84+
def log(message: str, meta: Optional[dict] = None) -> None:
85+
...
86+
87+
# ...
88+
log("hello world", meta={"foo": "bar"})
89+
# ...
90+
91+
decoy.verify(log("hello world"), ignore_extra_args=True)
92+
```
93+
94+
This can be combined with `times=0` to say "this dependency was never called," but your typechecker may complain about this:
95+
96+
```python
97+
# verify that something was never called in any way
98+
decoy.verify(do_something(), times=0, ignore_extra_args=True)
99+
```

docs/usage/when.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,24 @@ async def test_my_async_thing(decoy: Decoy) -> None:
130130

131131
assert result == Model(id="some-id")
132132
```
133+
134+
## Only specify some arguments
135+
136+
If you don't care about some (or any) of the arguments passed to a stub, you can yse the `ignore_extra_args` argument to tell Decoy to only check the arguments you pass.
137+
138+
```python
139+
database = decoy.mock(cls=Database)
140+
141+
decoy.when(
142+
database.get("some-id"),
143+
ignore_extra_args=True,
144+
).then_return(
145+
Model(id="some-id")
146+
)
147+
148+
# get_model_by_id called with more args than specified
149+
result = database.get("some-id", SomeDefaultModel())
150+
151+
# stubbed behavior still works
152+
assert result == Model(id="some-id")
153+
```

0 commit comments

Comments
 (0)