Skip to content

Commit be940f3

Browse files
committed
feat: add decoy.next preview API
1 parent 07ea0d6 commit be940f3

21 files changed

+2167
-228
lines changed

decoy/errors.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ def create(cls) -> "MockNameRequiredError":
2525
return cls("Mocks without `cls` or `func` require a `name`.")
2626

2727

28+
class MockSpecInvalidError(TypeError):
29+
"""An value passed as a mock spec is not valid."""
30+
31+
2832
class MissingRehearsalError(ValueError):
2933
"""An error raised when a Decoy method is called without rehearsal(s).
3034
@@ -44,6 +48,14 @@ def create(cls) -> "MissingRehearsalError":
4448
return cls("Rehearsal not found.")
4549

4650

51+
class NotAMockError(TypeError):
52+
"""A Decoy method was called without a mock."""
53+
54+
55+
class ThenDoActionNotCallableError(TypeError):
56+
"""A value passed to `then_do` is not callable."""
57+
58+
4759
class MockNotAsyncError(TypeError):
4860
"""An error raised when an asynchronous function is used with a synchronous mock.
4961
@@ -55,6 +67,10 @@ class MockNotAsyncError(TypeError):
5567
"""
5668

5769

70+
class SignatureMismatchError(TypeError):
71+
"""Arguments did not match the signature of the mock."""
72+
73+
5874
class VerifyError(AssertionError):
5975
"""An error raised when actual calls do not match rehearsals given to `verify`.
6076
@@ -100,3 +116,12 @@ def create(
100116
result.times = times
101117

102118
return result
119+
120+
121+
class VerifyOrderError(VerifyError):
122+
"""An error raised when the order of calls do not match expectations.
123+
124+
See [spying with verify][] for more details.
125+
126+
[spying with verify]: usage/verify.md
127+
"""

decoy/next/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Decoy mocking library.
2+
3+
Use Decoy to create stubs and spies
4+
to isolate your code under test.
5+
"""
6+
7+
from ._internal.decoy import Decoy
8+
from ._internal.mock import AsyncMock, Mock
9+
from ._internal.verify import Verify
10+
from ._internal.when import Stub, When
11+
12+
__all__ = [
13+
"AsyncMock",
14+
"Decoy",
15+
"Mock",
16+
"Stub",
17+
"Verify",
18+
"When",
19+
]

decoy/next/_internal/__init__.py

Whitespace-only changes.

decoy/next/_internal/compare.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
from .values import (
2+
AttributeEvent,
3+
AttributeEventType,
4+
BehaviorEntry,
5+
CallEvent,
6+
Event,
7+
EventEntry,
8+
EventMatcher,
9+
EventState,
10+
MockInfo,
11+
VerificationEntry,
12+
)
13+
14+
15+
def is_event_from_mock(event_entry: EventEntry, mock: MockInfo) -> bool:
16+
return mock.id == event_entry.mock.id
17+
18+
19+
def is_verifiable_mock_event(event_entry: EventEntry, mock: MockInfo) -> bool:
20+
return is_event_from_mock(event_entry, mock) and (
21+
isinstance(event_entry.event, CallEvent)
22+
or event_entry.event.type != AttributeEventType.GET
23+
)
24+
25+
26+
def is_matching_behavior(
27+
event_entry: EventEntry,
28+
behavior_entry: BehaviorEntry,
29+
) -> bool:
30+
return is_event_from_mock(
31+
event_entry,
32+
behavior_entry.mock,
33+
) and is_matching_event(
34+
event_entry,
35+
behavior_entry.matcher,
36+
)
37+
38+
39+
def is_matching_event(event_entry: EventEntry, matcher: EventMatcher) -> bool:
40+
"""Check if an expected call matches an actual call."""
41+
event_matches = _match_event(event_entry.event, matcher)
42+
state_matches = _match_state(event_entry.state, matcher)
43+
44+
return event_matches and state_matches
45+
46+
47+
def is_matching_count(usage_count: int, matcher: EventMatcher) -> bool:
48+
return matcher.options.times is None or usage_count < matcher.options.times
49+
50+
51+
def is_successful_verify(verification: VerificationEntry) -> bool:
52+
if verification.matcher.options.times is not None:
53+
return len(verification.matching_events) == verification.matcher.options.times
54+
55+
return len(verification.matching_events) > 0
56+
57+
58+
def is_successful_verify_order(verifications: list[VerificationEntry]) -> bool:
59+
matching_events: list[tuple[EventEntry, VerificationEntry]] = []
60+
verification_index = 0
61+
event_index = 0
62+
63+
for verification in verifications:
64+
for matching_event in verification.matching_events:
65+
matching_events.append((matching_event, verification))
66+
67+
matching_events.sort(key=lambda e: e[0].order)
68+
69+
while event_index < len(matching_events) and verification_index < len(
70+
verifications
71+
):
72+
_, event_verification = matching_events[event_index]
73+
expected_verification = verifications[verification_index]
74+
expected_times = expected_verification.matcher.options.times
75+
remaining_events = len(matching_events) - event_index
76+
77+
if event_verification is expected_verification:
78+
verification_index += 1
79+
80+
if expected_times is None or expected_times == 1:
81+
event_index += 1
82+
else:
83+
for times_index in range(1, expected_times):
84+
_, later_verification = matching_events[event_index + times_index]
85+
if later_verification is not expected_verification:
86+
return False
87+
88+
event_index += expected_times
89+
90+
elif remaining_events >= len(verifications) and verification_index > 0:
91+
verification_index = 0
92+
else:
93+
return False
94+
95+
return True
96+
97+
98+
def is_redundant_verify(
99+
verification: VerificationEntry,
100+
behaviors: list[BehaviorEntry],
101+
) -> bool:
102+
return any(
103+
behavior
104+
for behavior in behaviors
105+
if verification.mock.id == behavior.mock.id
106+
and verification.matcher.options == behavior.matcher.options
107+
and _match_event(verification.matcher.event, behavior.matcher)
108+
)
109+
110+
111+
def _match_event(event: Event, matcher: EventMatcher) -> bool:
112+
if (
113+
matcher.options.ignore_extra_args is False
114+
or isinstance(event, AttributeEvent)
115+
or isinstance(matcher.event, AttributeEvent)
116+
):
117+
return event == matcher.event
118+
119+
try:
120+
args_match = all(
121+
value == event.args[i] for i, value in enumerate(matcher.event.args)
122+
)
123+
kwargs_match = all(
124+
value == event.kwargs[key] for key, value in matcher.event.kwargs.items()
125+
)
126+
127+
return args_match and kwargs_match
128+
129+
except (IndexError, KeyError):
130+
pass
131+
132+
return False
133+
134+
135+
def _match_state(event_state: EventState, matcher: EventMatcher) -> bool:
136+
return (
137+
matcher.options.is_entered is None
138+
or event_state.is_entered == matcher.options.is_entered
139+
)

0 commit comments

Comments
 (0)