Skip to content

Commit 5367121

Browse files
committed
feat(v3): add improved matcher class to v3 preview
1 parent 11a005c commit 5367121

File tree

11 files changed

+712
-1
lines changed

11 files changed

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

0 commit comments

Comments
 (0)