Skip to content

Commit 468d51e

Browse files
committed
feat(v3): add improved matcher class to v3 preview
1 parent 5435762 commit 468d51e

File tree

13 files changed

+748
-8
lines changed

13 files changed

+748
-8
lines changed

decoy/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,7 @@ def create(
9898

9999
class VerifyOrderError(VerifyError):
100100
"""A [`Decoy.verify_order`][decoy.next.Decoy.verify_order] assertion failed."""
101+
102+
103+
class NoMatcherValueCapturedError(ValueError):
104+
"""An error raised if a [decoy.next.Matcher][] has not captured any matching values."""

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: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
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 NoMatcherValueCapturedError
11+
12+
ValueT = TypeVar("ValueT")
13+
MatchT = TypeVar("MatchT")
14+
MappingT = TypeVar("MappingT", bound=collections.abc.Mapping[Any, Any])
15+
SequenceT = TypeVar("SequenceT", 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 an [argument matcher](./matchers.md).
24+
25+
Arguments:
26+
match: A comparison function that returns a bool or `TypeIs` guard.
27+
name: Optional name for the matcher; defaults to `match.__name__`
28+
description: Optional extra description for the matcher's repr.
29+
30+
Example:
31+
Use a function to create a custom matcher.
32+
33+
```python
34+
def is_even(target: object) -> TypeIs[int]:
35+
return isinstance(target, int) and target % 2 == 0
36+
37+
is_even_matcher = Matcher(is_even)
38+
```
39+
40+
Matchers can also be constructed from built-in inspection functions, like `callable`.
41+
42+
```python
43+
callable_matcher = Matcher(callable)
44+
```
45+
"""
46+
47+
@overload
48+
def __init__(
49+
self: "Matcher[MatchT]",
50+
match: TypedMatch[MatchT],
51+
name: str | None = None,
52+
description: str | None = None,
53+
) -> None: ...
54+
55+
@overload
56+
def __init__(
57+
self: "Matcher[Any]",
58+
match: UntypedMatch,
59+
name: str | None = None,
60+
description: str | None = None,
61+
) -> None: ...
62+
63+
def __init__(
64+
self,
65+
match: TypedMatch[ValueT] | UntypedMatch,
66+
name: str | None = None,
67+
description: str | None = None,
68+
) -> None:
69+
self._match = match
70+
self._name = name or match.__name__
71+
self._description = description
72+
self._values: list[ValueT] = []
73+
74+
def __eq__(self, target: object) -> bool:
75+
if self._match(target):
76+
self._values.append(cast(ValueT, target)) # type: ignore[redundant-cast]
77+
return True
78+
79+
return False
80+
81+
def __repr__(self) -> str:
82+
matcher_name = f"Matcher.{self._name}"
83+
if self._description:
84+
return f"<{matcher_name} {self._description.strip()}>"
85+
86+
return f"<{matcher_name}>"
87+
88+
@property
89+
def arg(self) -> ValueT:
90+
"""Type-cast the matcher as the expected value.
91+
92+
Example:
93+
If the mock expects a `str` argument, using `arg` prevents the type-checker from raising an error.
94+
95+
```python
96+
decoy
97+
.when(mock)
98+
.called_with(Matcher.matches("^(hello|hi)$").arg)
99+
.then_return("world")
100+
```
101+
"""
102+
return cast(ValueT, self)
103+
104+
@property
105+
def value(self) -> ValueT:
106+
"""The latest matching compared value.
107+
108+
Raises:
109+
NoMatcherValueCapturedError: the matcher has not been compared with any matching value.
110+
111+
Example:
112+
You can use `value` to trigger a callback passed to your mock.
113+
114+
```python
115+
callback_matcher = Matcher(callable)
116+
decoy.verify(mock).called_with(callback_matcher)
117+
callback_matcher.value("value")
118+
```
119+
"""
120+
if len(self._values) == 0:
121+
raise NoMatcherValueCapturedError(f"{self} has not matched any values")
122+
123+
return self._values[-1]
124+
125+
@property
126+
def values(self) -> list[ValueT]:
127+
"""All matching compared values."""
128+
return self._values.copy()
129+
130+
@staticmethod
131+
@overload
132+
def any(
133+
type: type[MatchT],
134+
attrs: collections.abc.Mapping[str, object] | None = None,
135+
) -> "Matcher[MatchT]": ...
136+
137+
@staticmethod
138+
@overload
139+
def any(
140+
type: None = None,
141+
attrs: collections.abc.Mapping[str, object] | None = None,
142+
) -> "Matcher[Any]": ...
143+
144+
@staticmethod
145+
def any(
146+
type: type[MatchT] | None = None,
147+
attrs: collections.abc.Mapping[str, object] | None = None,
148+
) -> "Matcher[MatchT] | Matcher[Any]":
149+
"""Match an argument, optionally by type and/or attributes.
150+
151+
If type and attributes are omitted, will match everything,
152+
including `None`.
153+
154+
Arguments:
155+
type: Type to match, if any.
156+
attrs: Set of attributes to match, if any.
157+
"""
158+
description = ""
159+
160+
if type:
161+
description = type.__name__
162+
163+
if attrs:
164+
description = f"{description} {attrs!r}"
165+
166+
return Matcher(
167+
lambda t: _any(t, type, attrs),
168+
name="any",
169+
description=description,
170+
)
171+
172+
@staticmethod
173+
def is_not(value: object) -> "Matcher[Any]":
174+
"""Match any value that does not `==` the given value.
175+
176+
Arguments:
177+
value: The value that the matcher rejects.
178+
"""
179+
return Matcher(
180+
lambda t: t != value,
181+
name="is_not",
182+
description=repr(value),
183+
)
184+
185+
@staticmethod
186+
@overload
187+
def contains(values: MappingT) -> "Matcher[MappingT]": ...
188+
189+
@staticmethod
190+
@overload
191+
def contains(values: SequenceT, in_order: bool = False) -> "Matcher[SequenceT]": ...
192+
193+
@staticmethod
194+
def contains(
195+
values: MappingT | SequenceT | str,
196+
in_order: bool = False,
197+
) -> "Matcher[MappingT] | Matcher[SequenceT] | Matcher[str]":
198+
"""Match a dict, list, or string with a partial value.
199+
200+
Arguments:
201+
values: Partial value to match.
202+
in_order: Match list values in order.
203+
"""
204+
description = repr(values)
205+
206+
if in_order:
207+
description = f"{description} {in_order=}"
208+
209+
return Matcher(
210+
lambda t: _contains(t, values, in_order),
211+
name="contains",
212+
description=description,
213+
)
214+
215+
@staticmethod
216+
def matches(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="matches",
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 _any(
252+
target: object,
253+
match_type: type[Any] | None,
254+
attrs: collections.abc.Mapping[str, object] | None,
255+
) -> bool:
256+
return (match_type is None or isinstance(target, match_type)) and (
257+
attrs is None or _has_attrs(target, attrs)
258+
)
259+
260+
261+
def _has_attrs(
262+
target: object,
263+
attributes: collections.abc.Mapping[str, object],
264+
) -> bool:
265+
return all(
266+
hasattr(target, attr_name) and getattr(target, attr_name) == attr_value
267+
for attr_name, attr_value in attributes.items()
268+
)
269+
270+
271+
def _contains(
272+
target: object,
273+
values: collections.abc.Mapping[object, object] | collections.abc.Sequence[object],
274+
in_order: bool,
275+
) -> bool:
276+
if isinstance(values, str):
277+
return isinstance(target, str) and values in target
278+
if isinstance(values, collections.abc.Mapping):
279+
return _dict_containing(target, values)
280+
if isinstance(values, collections.abc.Sequence):
281+
return _list_containing(target, values, in_order)
282+
283+
284+
def _dict_containing(
285+
target: object,
286+
values: collections.abc.Mapping[object, object],
287+
) -> bool:
288+
try:
289+
return all(
290+
attr_name in target and target[attr_name] == attr_value # type: ignore[index,operator]
291+
for attr_name, attr_value in values.items()
292+
)
293+
except TypeError:
294+
return False
295+
296+
297+
def _list_containing(
298+
target: object,
299+
values: collections.abc.Sequence[object],
300+
in_order: bool,
301+
) -> bool:
302+
target_index = 0
303+
304+
try:
305+
for value in values:
306+
if in_order:
307+
target = target[target_index:] # type: ignore[index]
308+
309+
target_index = target.index(value) # type: ignore[attr-defined]
310+
311+
except (AttributeError, TypeError, ValueError):
312+
return False
313+
314+
return True

0 commit comments

Comments
 (0)