Skip to content

Commit 88de366

Browse files
committed
feat(v3): add improved matcher class to v3 preview
1 parent ae04611 commit 88de366

File tree

15 files changed

+786
-24
lines changed

15 files changed

+786
-24
lines changed

codebook.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
words = [
2+
"matchers",
3+
"mundo",
4+
"stubbings",
5+
"verden",
6+
]

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

docs/v3/about.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Decoy v3 Preview
22

3-
The next major version of Decoy is a ground-up rebuild of the library. In the years since Decoy was first written, the Python typing system has advanced, especially when it comes to typing functions. These advancements, especially the addition of [ParamSpec][], unblocked a much simpler API (as well as internal implementation) for Decoy.
3+
The next major version of Decoy is a ground-up rebuild of the library. In the years since Decoy was first written, the Python typing system has advanced, especially when it comes to typing functions. These advancements, especially the addition of [`ParamSpec`][paramspec], unblocked a much simpler API (as well as internal implementation) for Decoy.
44

55
In order to ease the migration to the new API, the v3 API has been added as a preview to the v2 release (starting with `v2.4.0`) as `decoy.next`.
66

@@ -25,28 +25,30 @@ In order to use the v3 preview, you must be using:
2525
Then, start trying out the new API!
2626

2727
```diff
28+
- from decoy import Decoy
29+
+ from decoy.next import Decoy
30+
31+
2832
+ @pytest.fixture()
29-
+ def decoy_next() -> collections.abc.Iterator[Decoy]:
30-
+ """Create a Decoy instance for testing."""
33+
+ def decoy() -> collections.abc.Iterator[Decoy]:
34+
+ """Create a Decoy v3 preview instance for testing."""
3135
+ with Decoy.create() as decoy:
3236
+ yield decoy
3337

3438

35-
- def test_when(decoy: Decoy) -> None:
36-
+ def test_when(decoy_next: Decoy) -> None:
39+
def test_when(decoy: Decoy) -> None:
3740
mock = decoy.mock(cls=SomeClass)
3841
- decoy.when(mock.foo("hello")).then_return("world")
3942
+ decoy.when(mock.foo).called_with("hello").then_return("world")
4043

4144

42-
- def test_verify(decoy: Decoy) -> None:
43-
+ def test_verify(decoy_next: Decoy) -> None:
45+
def test_verify(decoy: Decoy) -> None:
4446
mock = decoy.mock(cls=SomeClass)
4547
mock.foo("hello")
4648
- decoy.verify(mock.foo("hello"))
4749
+ decoy.verify(mock.foo).called_with("hello")
4850
```
4951

50-
See the [v2 migration guide](./v2-migration.md) for more details.
52+
See the [migration guide](./migration.md) for more details.
5153

5254
[paramspec]: https://docs.python.org/3/library/typing.html#typing.ParamSpec

0 commit comments

Comments
 (0)