Skip to content

Commit 5ee2de7

Browse files
authored
feat: add property mocking (#121)
1 parent b2d373a commit 5ee2de7

29 files changed

+2044
-589
lines changed

decoy/__init__.py

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
ContextManager,
99
GeneratorContextManager,
1010
)
11-
from .core import DecoyCore, StubCore
11+
from .core import DecoyCore, StubCore, PropCore
1212
from .types import ClassT, ContextValueT, FuncT, ReturnT
1313

1414
# ensure decoy does not pollute pytest tracebacks
@@ -19,7 +19,7 @@ class Decoy:
1919
"""Decoy mock factory and state container.
2020
2121
You should create a new Decoy instance before each test and call
22-
[reset][decoy.Decoy.reset] after each test. If you use the
22+
[`reset`][decoy.Decoy.reset] after each test. If you use the
2323
[`decoy` pytest fixture][decoy.pytest_plugin.decoy], this is done
2424
automatically. See the [setup guide](../#setup) for more details.
2525
@@ -92,7 +92,7 @@ def create_decoy(
9292
"""Create a class mock for `spec`.
9393
9494
!!! warning "Deprecated since v1.6.0"
95-
Use [decoy.Decoy.mock][] with the `cls` parameter, instead.
95+
Use [`mock`][decoy.Decoy.mock] with the `cls` parameter, instead.
9696
"""
9797
warn(
9898
"decoy.create_decoy is deprecated; use decoy.mock(cls=...) instead.",
@@ -111,7 +111,7 @@ def create_decoy_func(
111111
"""Create a function mock for `spec`.
112112
113113
!!! warning "Deprecated since v1.6.0"
114-
Use [decoy.Decoy.mock][] with the `func` parameter, instead.
114+
Use [`mock`][decoy.Decoy.mock] with the `func` parameter, instead.
115115
"""
116116
warn(
117117
"decoy.create_decoy_func is deprecated; use decoy.mock(func=...) instead.",
@@ -127,7 +127,7 @@ def when(
127127
*,
128128
ignore_extra_args: bool = False,
129129
) -> "Stub[ReturnT]":
130-
"""Create a [Stub][decoy.Stub] configuration using a rehearsal call.
130+
"""Create a [`Stub`][decoy.Stub] configuration using a rehearsal call.
131131
132132
See [stubbing usage guide](../usage/when/) for more details.
133133
@@ -138,8 +138,9 @@ def when(
138138
ignoring unspecified arguments.
139139
140140
Returns:
141-
A stub to configure using `then_return`, `then_raise`, `then_do`, or
142-
`then_enter_with`.
141+
A stub to configure using [`then_return`][decoy.Stub.then_return],
142+
[`then_raise`][decoy.Stub.then_raise], [`then_do`][decoy.Stub.then_do],
143+
or [`then_enter_with`][decoy.Stub.then_enter_with].
143144
144145
Example:
145146
```python
@@ -202,12 +203,27 @@ def test_create_something(decoy: Decoy):
202203
ignore_extra_args=ignore_extra_args,
203204
)
204205

206+
def prop(self, _rehearsal_result: ReturnT) -> "Prop[ReturnT]":
207+
"""Create property setter and deleter rehearsals.
208+
209+
See [property mocking guide](../advanced/properties/) for more details.
210+
211+
Arguments:
212+
_rehearsal_result: The property to mock, for typechecking.
213+
214+
Returns:
215+
A prop rehearser on which you can call [`set`][decoy.Prop.set] or
216+
[`delete`][decoy.Prop.delete] to create property rehearsals.
217+
"""
218+
prop_core = self._core.prop(_rehearsal_result)
219+
return Prop(core=prop_core)
220+
205221
def reset(self) -> None:
206222
"""Reset all mock state.
207223
208224
This method should be called after every test to ensure spies and stubs
209-
don't leak between tests. The `decoy` fixture provided by the pytest plugin
210-
will call `reset` automatically.
225+
don't leak between tests. The [`decoy`][decoy.pytest_plugin.decoy] fixture
226+
provided by the pytest plugin will call `reset` automatically.
211227
212228
The `reset` method may also trigger warnings if Decoy detects any questionable
213229
mock usage. See [decoy.warnings][] for more details.
@@ -243,7 +259,8 @@ def then_raise(self, error: Exception) -> None:
243259
Note:
244260
Setting a stub to raise will prevent you from writing new
245261
rehearsals, because they will raise. If you need to make more calls
246-
to `when`, you'll need to wrap your rehearsal in a `try`.
262+
to [`when`][decoy.Decoy.when], you'll need to wrap your rehearsal
263+
in a `try`.
247264
"""
248265
self._core.then_raise(error)
249266

@@ -299,4 +316,50 @@ def then_enter_with(
299316
self._core.then_enter_with(value)
300317

301318

302-
__all__ = ["Decoy", "Stub", "matchers", "warnings", "errors"]
319+
class Prop(Generic[ReturnT]):
320+
"""Rehearsal creator for mocking property setters and deleters.
321+
322+
See [property mocking guide](../advanced/properties/) for more details.
323+
"""
324+
325+
def __init__(self, core: PropCore) -> None:
326+
self._core = core
327+
328+
def set(self, value: ReturnT) -> None:
329+
"""Create a property setter rehearsal.
330+
331+
By wrapping `set` in a call to [`when`][decoy.Decoy.when] or
332+
[`verify`][decoy.Decoy.verify], you can stub or verify a call
333+
to a property setter.
334+
335+
Arguments:
336+
value: The value
337+
338+
Example:
339+
```python
340+
some_obj = decoy.mock()
341+
some_obj.prop = 42
342+
decoy.verify(decoy.prop(some_obj.prop).set(42))
343+
```
344+
"""
345+
self._core.set(value)
346+
347+
def delete(self) -> None:
348+
"""Create a property deleter rehearsal.
349+
350+
By wrapping `delete` in a call to [`when`][decoy.Decoy.when] or
351+
[`verify`][decoy.Decoy.verify], you can stub or verify a call
352+
to a property deleter.
353+
354+
355+
Example:
356+
```python
357+
some_obj = decoy.mock()
358+
del some_obj.prop
359+
decoy.verify(decoy.prop(some_obj.prop).delete())
360+
```
361+
"""
362+
self._core.delete()
363+
364+
365+
__all__ = ["Decoy", "Stub", "Prop", "matchers", "warnings", "errors"]

decoy/call_handler.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
"""Spy call handling."""
2-
from typing import Any
2+
from typing import Any, NamedTuple, Optional
33

44
from .spy_log import SpyLog
55
from .context_managers import ContextWrapper
6-
from .spy_calls import SpyCall
6+
from .spy_events import SpyCall, SpyEvent
77
from .stub_store import StubStore
88

99

10+
class CallHandlerResult(NamedTuple):
11+
"""A return value from a call."""
12+
13+
value: Any
14+
15+
1016
class CallHandler:
1117
"""An interface to handle calls to spies."""
1218

1319
def __init__(self, spy_log: SpyLog, stub_store: StubStore) -> None:
14-
"""Initialize the CallHandler with access to SpyCalls and Stubs."""
20+
"""Initialize the CallHandler with access to SpyEvents and Stubs."""
1521
self._spy_log = spy_log
1622
self._stub_store = stub_store
1723

18-
def handle(self, call: SpyCall) -> Any:
24+
def handle(self, call: SpyEvent) -> Optional[CallHandlerResult]:
1925
"""Handle a Spy's call, triggering stub behavior if necessary."""
2026
behavior = self._stub_store.get_by_call(call)
2127
self._spy_log.push(call)
@@ -26,10 +32,21 @@ def handle(self, call: SpyCall) -> Any:
2632
if behavior.error:
2733
raise behavior.error
2834

35+
return_value: Any
36+
2937
if behavior.action:
30-
return behavior.action(*call.args, **call.kwargs)
38+
if isinstance(call.payload, SpyCall):
39+
return_value = behavior.action(
40+
*call.payload.args,
41+
**call.payload.kwargs,
42+
)
43+
else:
44+
return_value = behavior.action()
45+
46+
elif behavior.context_value:
47+
return_value = ContextWrapper(behavior.context_value)
3148

32-
if behavior.context_value:
33-
return ContextWrapper(behavior.context_value)
49+
else:
50+
return_value = behavior.return_value
3451

35-
return behavior.return_value
52+
return CallHandlerResult(return_value)

decoy/core.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from .call_handler import CallHandler
55
from .spy import SpyCreator
6-
from .spy_calls import WhenRehearsal
6+
from .spy_events import WhenRehearsal, PropAccessType, SpyEvent, SpyPropAccess
77
from .spy_log import SpyLog
88
from .stub_store import StubBehavior, StubStore
99
from .types import ContextValueT, ReturnT
@@ -65,10 +65,21 @@ def verify(
6565
count=len(_rehearsals),
6666
ignore_extra_args=ignore_extra_args,
6767
)
68-
calls = self._spy_log.get_by_rehearsals(rehearsals)
68+
calls = self._spy_log.get_calls_to_verify([r.spy_id for r in rehearsals])
6969

7070
self._verifier.verify(rehearsals=rehearsals, calls=calls, times=times)
7171

72+
def prop(self, _rehearsal: ReturnT) -> "PropCore":
73+
"""Get a property setter/deleter rehearser."""
74+
spy_id, spy_name, payload = self._spy_log.consume_prop_rehearsal()
75+
76+
return PropCore(
77+
spy_id=spy_id,
78+
spy_name=spy_name,
79+
prop_name=payload.prop_name,
80+
spy_log=self._spy_log,
81+
)
82+
7283
def reset(self) -> None:
7384
"""Reset and remove all stored spies and stubs."""
7485
calls = self._spy_log.get_all()
@@ -116,3 +127,44 @@ def then_enter_with(self, value: ContextValueT) -> None:
116127
rehearsal=self._rehearsal,
117128
behavior=StubBehavior(context_value=value),
118129
)
130+
131+
132+
class PropCore:
133+
"""Main logic of a property access rehearser."""
134+
135+
def __init__(
136+
self,
137+
spy_id: int,
138+
spy_name: str,
139+
prop_name: str,
140+
spy_log: SpyLog,
141+
) -> None:
142+
self._spy_id = spy_id
143+
self._spy_name = spy_name
144+
self._prop_name = prop_name
145+
self._spy_log = spy_log
146+
147+
def set(self, value: Any) -> None:
148+
"""Create a property setter rehearsal."""
149+
event = SpyEvent(
150+
spy_id=self._spy_id,
151+
spy_name=self._spy_name,
152+
payload=SpyPropAccess(
153+
prop_name=self._prop_name,
154+
access_type=PropAccessType.SET,
155+
value=value,
156+
),
157+
)
158+
self._spy_log.push(event)
159+
160+
def delete(self) -> None:
161+
"""Create a property deleter rehearsal."""
162+
event = SpyEvent(
163+
spy_id=self._spy_id,
164+
spy_name=self._spy_name,
165+
payload=SpyPropAccess(
166+
prop_name=self._prop_name,
167+
access_type=PropAccessType.DELETE,
168+
),
169+
)
170+
self._spy_log.push(event)

decoy/errors.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,25 @@
66
"""
77
from typing import Optional, Sequence
88

9-
from .spy_calls import SpyCall, VerifyRehearsal
9+
from .spy_events import SpyEvent, VerifyRehearsal
1010
from .stringify import count, stringify_error_message
1111

1212

1313
class MissingRehearsalError(ValueError):
14-
"""An error raised when `when` or `verify` is called without rehearsal(s).
14+
"""An error raised when a Decoy method is called without rehearsal(s).
1515
16-
This error is raised if you use Decoy incorrectly in your tests. When
17-
using async/await, this error can be triggered if you forget to include
18-
`await` with your rehearsal.
16+
This error is raised if you use [`when`][decoy.Decoy.when],
17+
[`verify`][decoy.Decoy.verify], or [`prop`][decoy.Decoy.prop] incorrectly
18+
in your tests. When using async/await, this error can be triggered if you
19+
forget to include `await` with your rehearsal.
1920
2021
See the [MissingRehearsalError guide][] for more details.
2122
2223
[MissingRehearsalError guide]: ../usage/errors-and-warnings/#missingrehearsalerror
2324
"""
2425

2526
def __init__(self) -> None:
26-
super().__init__("Rehearsal not found for when/verify.")
27+
super().__init__("Rehearsal not found.")
2728

2829

2930
class VerifyError(AssertionError):
@@ -40,13 +41,13 @@ class VerifyError(AssertionError):
4041
"""
4142

4243
rehearsals: Sequence[VerifyRehearsal]
43-
calls: Sequence[SpyCall]
44+
calls: Sequence[SpyEvent]
4445
times: Optional[int]
4546

4647
def __init__(
4748
self,
4849
rehearsals: Sequence[VerifyRehearsal],
49-
calls: Sequence[SpyCall],
50+
calls: Sequence[SpyEvent],
5051
times: Optional[int],
5152
) -> None:
5253
if times is not None:

0 commit comments

Comments
 (0)