Skip to content

Commit 5674133

Browse files
committed
feat(v3): add improved matcher class to v3 preview
1 parent b3f1309 commit 5674133

File tree

11 files changed

+714
-1
lines changed

11 files changed

+714
-1
lines changed

decoy/errors.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,12 @@ class VerifyOrderError(VerifyError):
125125
126126
[spying with verify]: usage/verify.md
127127
"""
128+
129+
130+
class NoMatcherValueCapturedError(ValueError):
131+
"""An error raised if a [decoy.next.Matcher][] has not captured any matching values.
132+
133+
See the [matchers guide][] for more details.
134+
135+
[matchers guide]: ./v3/matchers.md
136+
"""

decoy/next/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
"""
66

77
from ._internal.decoy import Decoy
8+
from ._internal.matcher import Matcher
89
from ._internal.mock import AsyncMock, Mock
910
from ._internal.verify import Verify
1011
from ._internal.when import Stub, When
1112

1213
__all__ = [
1314
"AsyncMock",
1415
"Decoy",
16+
"Matcher",
1517
"Mock",
1618
"Stub",
1719
"Verify",

decoy/next/_internal/errors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,9 @@ def createVerifyOrderError(
9595
)
9696

9797
return errors.VerifyOrderError(message)
98+
99+
100+
def createNoMatcherValueCapturedError(
101+
message: str,
102+
) -> errors.NoMatcherValueCapturedError:
103+
return errors.NoMatcherValueCapturedError(message)

decoy/next/_internal/matcher.py

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import collections.abc
2+
import re
3+
import sys
4+
from typing import Any, Callable, Generic, TypeVar, cast, overload
5+
6+
if sys.version_info >= (3, 13):
7+
from typing import TypeIs
8+
else:
9+
from typing_extensions import TypeIs
10+
11+
from .errors import createNoMatcherValueCapturedError
12+
13+
ValueT = TypeVar("ValueT")
14+
MatchT = TypeVar("MatchT")
15+
DictT = TypeVar("DictT", bound=collections.abc.Mapping[Any, Any])
16+
ListT = TypeVar("ListT", bound=collections.abc.Sequence[Any])
17+
ErrorT = TypeVar("ErrorT", bound=BaseException)
18+
19+
TypedMatch = Callable[[object], TypeIs[MatchT]]
20+
UntypedMatch = Callable[[object], bool]
21+
22+
23+
class Matcher(Generic[ValueT]):
24+
"""Create a matcher from a comparison function.
25+
26+
Arguments:
27+
match: A comparison function that returns bool or `TypeIs` guard.
28+
name: Optional name for the matcher's repr; defaults to `match.__name__`
29+
description: Optional extra description for the matcher's repr.
30+
31+
Example:
32+
Matchers can be constructed from built-in inspection functions, like `callable`.
33+
34+
```python
35+
callable_matcher = Matcher(callable)
36+
```
37+
"""
38+
39+
@overload
40+
def __init__(
41+
self: "Matcher[MatchT]",
42+
match: TypedMatch[MatchT],
43+
name: str | None = None,
44+
description: str | None = None,
45+
) -> None: ...
46+
47+
@overload
48+
def __init__(
49+
self: "Matcher[Any]",
50+
match: UntypedMatch,
51+
name: str | None = None,
52+
description: str | None = None,
53+
) -> None: ...
54+
55+
def __init__(
56+
self,
57+
match: TypedMatch[ValueT] | UntypedMatch,
58+
name: str | None = None,
59+
description: str | None = None,
60+
) -> None:
61+
self._match = match
62+
self._name = name or match.__name__
63+
self._description = description
64+
self._values: list[ValueT] = []
65+
66+
def __eq__(self, target: object) -> bool:
67+
if self._match(target):
68+
self._values.append(cast(ValueT, target)) # type: ignore[redundant-cast]
69+
return True
70+
71+
return False
72+
73+
def __repr__(self) -> str:
74+
matcher_name = f"Matcher.{self._name}"
75+
if self._description:
76+
return f"<{matcher_name} {self._description.strip()}>"
77+
78+
return f"<{matcher_name}>"
79+
80+
@property
81+
def arg(self) -> ValueT:
82+
"""Type-cast the matcher as the expected value.
83+
84+
Example:
85+
If the mock expects a `str` argument, using `arg` prevents the type-checker from raising an error.
86+
87+
```python
88+
decoy
89+
.when(mock)
90+
.called_with(Matcher.string("^hello").arg)
91+
.then_return("world")
92+
```
93+
"""
94+
return cast(ValueT, self)
95+
96+
@property
97+
def value(self) -> ValueT:
98+
"""The latest matching compared value.
99+
100+
Raises:
101+
NoMatcherValueCapturedError: the matcher has not been compared with any matching value.
102+
103+
Example:
104+
You can use `value` to trigger a callback passed to your mock.
105+
106+
```python
107+
callback_matcher = Matcher(callable)
108+
decoy.verify(mock).called_with(callback_matcher)
109+
callback_matcher.value("value")
110+
```
111+
"""
112+
if len(self._values) == 0:
113+
raise createNoMatcherValueCapturedError(
114+
f"{self} has not matched any values"
115+
)
116+
117+
return self._values[-1]
118+
119+
@property
120+
def values(self) -> list[ValueT]:
121+
"""All matching compared values."""
122+
return self._values.copy()
123+
124+
@staticmethod
125+
def any() -> "Matcher[Any]":
126+
"""Match everything, including `None`."""
127+
return Matcher(lambda _: True, name="any")
128+
129+
@staticmethod
130+
def something() -> "Matcher[Any]":
131+
"""Match everything except `None`."""
132+
return Matcher(lambda t: t is not None, name="something")
133+
134+
@staticmethod
135+
def is_a(
136+
match_type: type[MatchT],
137+
attributes: collections.abc.Mapping[str, object] | None = None,
138+
) -> "Matcher[MatchT]":
139+
"""Match if `isinstance` matches the given type.
140+
141+
Can optionally also match a set of attributes on the target object.
142+
143+
Arguments:
144+
match_type: Type to match.
145+
attributes: Optional set of attributes to match.
146+
"""
147+
description = match_type.__name__
148+
149+
if attributes is not None:
150+
description = f"{description} {attributes!r}"
151+
152+
return Matcher(
153+
lambda t: isinstance(t, match_type) and _has_attrs(t, attributes),
154+
name="is_a",
155+
description=description,
156+
)
157+
158+
@staticmethod
159+
def is_not(value: object) -> "Matcher[Any]":
160+
"""Match any value that does not `==` the given value.
161+
162+
Arguments:
163+
value: The value that the matcher rejects.
164+
"""
165+
return Matcher(
166+
lambda t: t != value,
167+
name="is_not",
168+
description=repr(value),
169+
)
170+
171+
@staticmethod
172+
def has_attrs(attributes: collections.abc.Mapping[str, object]) -> "Matcher[Any]":
173+
"""Match a partial set of attributes on the target object.
174+
175+
Arguments:
176+
attributes: Partial set of attributes to match.
177+
"""
178+
return Matcher(
179+
lambda t: _has_attrs(t, attributes),
180+
name="has_attrs",
181+
description=repr(attributes),
182+
)
183+
184+
@staticmethod
185+
def dict_containing(values: DictT) -> "Matcher[DictT]":
186+
"""Match a mapping containing at least the given values.
187+
188+
Arguments:
189+
values: Partial mapping to match.
190+
"""
191+
return Matcher(
192+
lambda t: _dict_containing(t, values),
193+
name="dict_containing",
194+
description=repr(values),
195+
)
196+
197+
@staticmethod
198+
def list_containing(values: ListT, in_order: bool = False) -> "Matcher[ListT]":
199+
"""Match a sequence containing at least the given values.
200+
201+
Arguments:
202+
values: Partial sequence to match.
203+
in_order: Match sequence order
204+
"""
205+
description = repr(values)
206+
207+
if in_order:
208+
description = f"{description} {in_order=}"
209+
210+
return Matcher(
211+
lambda t: _list_containing(t, values, in_order),
212+
name="list_containing",
213+
description=description,
214+
)
215+
216+
@staticmethod
217+
def string(pattern: str) -> "Matcher[str]":
218+
"""Match a string by a pattern.
219+
220+
Arguments:
221+
pattern: Regular expression pattern.
222+
"""
223+
pattern_re = re.compile(pattern)
224+
225+
return Matcher(
226+
lambda t: isinstance(t, str) and pattern_re.search(t) is not None,
227+
name="string",
228+
description=repr(pattern),
229+
)
230+
231+
@staticmethod
232+
def error(type: type[ErrorT], message: str | None = None) -> "Matcher[ErrorT]":
233+
"""Match an exception object.
234+
235+
Arguments:
236+
type: The type of exception to match.
237+
message: An optional regular expression pattern to match.
238+
"""
239+
message_re = re.compile(message or "")
240+
description = type.__name__
241+
242+
if message:
243+
description = f"{description} {message!r}"
244+
245+
return Matcher(
246+
lambda t: isinstance(t, type) and message_re.search(str(t)) is not None,
247+
name="error",
248+
description=description,
249+
)
250+
251+
252+
def _has_attrs(
253+
target: object,
254+
attributes: collections.abc.Mapping[str, object] | None,
255+
) -> bool:
256+
attributes = attributes or {}
257+
258+
return all(
259+
hasattr(target, attr_name) and getattr(target, attr_name) == attr_value
260+
for attr_name, attr_value in attributes.items()
261+
)
262+
263+
264+
def _dict_containing(
265+
target: object,
266+
values: collections.abc.Mapping[object, object],
267+
) -> bool:
268+
try:
269+
return all(
270+
attr_name in target and target[attr_name] == attr_value # type: ignore[index,operator]
271+
for attr_name, attr_value in values.items()
272+
)
273+
except TypeError:
274+
return False
275+
276+
277+
def _list_containing(
278+
target: object,
279+
values: collections.abc.Sequence[object],
280+
in_order: bool,
281+
) -> bool:
282+
target_index = 0
283+
284+
try:
285+
for value in values:
286+
if in_order:
287+
target = target[target_index:] # type: ignore[index]
288+
289+
target_index = target.index(value) # type: ignore[attr-defined]
290+
291+
except (AttributeError, TypeError, ValueError):
292+
return False
293+
294+
return True

0 commit comments

Comments
 (0)