Skip to content

Commit 0e75094

Browse files
authored
feat(matchers): add HasAttributes and DictMatching matchers (#21)
Closes #18
1 parent 47e328c commit 0e75094

File tree

2 files changed

+156
-9
lines changed

2 files changed

+156
-9
lines changed

decoy/matchers.py

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def test_logger_called(decoy: Decoy):
2525
equality comparisons for stubbing and verification.
2626
"""
2727
from re import compile as compile_re
28-
from typing import cast, Any, List, Optional, Pattern, Type
28+
from typing import cast, Any, List, Mapping, Optional, Pattern, Type
2929

3030

3131
__all__ = [
@@ -62,34 +62,54 @@ def Anything() -> Any:
6262

6363
class _IsA:
6464
_match_type: type
65-
66-
def __init__(self, match_type: type) -> None:
67-
"""Initialize the matcher with a type."""
65+
_attributes: Optional[Mapping[str, Any]]
66+
67+
def __init__(
68+
self,
69+
match_type: type,
70+
attributes: Optional[Mapping[str, Any]] = None,
71+
) -> None:
72+
"""Initialize the matcher with a type and optional attributes."""
6873
self._match_type = match_type
74+
self._attributes = attributes
6975

7076
def __eq__(self, target: object) -> bool:
71-
"""Return true if target is a self._match_type."""
72-
return type(target) == self._match_type
77+
"""Return true if target is the correct type and matches attributes."""
78+
matches_type = type(target) == self._match_type
79+
matches_attrs = target == HasAttributes(self._attributes or {})
80+
81+
return matches_type and matches_attrs
7382

7483
def __repr__(self) -> str:
7584
"""Return a string representation of the matcher."""
76-
return "<IsA {self._match_type.__name__}>"
85+
if self._attributes is None:
86+
return f"<IsA {self._match_type.__name__}>"
87+
else:
88+
return f"<IsA {self._match_type.__name__} {repr(self._attributes)}>"
7789

7890

79-
def IsA(match_type: type) -> Any:
91+
def IsA(match_type: type, attributes: Optional[Mapping[str, Any]] = None) -> Any:
8092
"""Match anything that satisfies the passed in type.
8193
8294
Arguments:
8395
match_type: Type to match.
96+
attributes: Optional set of attributes to match
8497
8598
Example:
8699
```python
87100
assert "foobar" == IsA(str)
88101
assert datetime.now() == IsA(datetime)
89102
assert 42 == IsA(int)
103+
104+
@dataclass
105+
class HelloWorld:
106+
hello: str = "world"
107+
goodby: str = "so long"
108+
109+
assert HelloWorld() == IsA(HelloWorld, {"hello": "world"})
90110
```
91111
"""
92-
return _IsA(match_type)
112+
return _IsA(match_type, attributes)
93113

94114

95115
class _IsNot:
@@ -124,6 +144,86 @@ def IsNot(value: object) -> Any:
124144
return _IsNot(value)
125145

126146

147+
class _HasAttributes:
148+
_attributes: Mapping[str, Any]
149+
150+
def __init__(self, attributes: Mapping[str, Any]) -> None:
151+
self._attributes = attributes
152+
153+
def __eq__(self, target: object) -> bool:
154+
"""Return true if target matches all given attributes."""
155+
is_match = True
156+
for attr_name, value in self._attributes.items():
157+
if is_match:
158+
is_match = (
159+
hasattr(target, attr_name) and getattr(target, attr_name) == value
160+
)
161+
162+
return is_match
163+
164+
def __repr__(self) -> str:
165+
"""Return a string representation of the matcher."""
166+
return f"<HasAttributes {repr(self._attributes)}>"
167+
168+
169+
def HasAttributes(attributes: Mapping[str, Any]) -> Any:
170+
"""Match anything with the passed in attributes.
171+
172+
Arguments:
173+
attributes: Attribute values to check.
174+
175+
Example:
176+
```python
177+
@dataclass
178+
class HelloWorld:
179+
hello: str = "world"
180+
goodby: str = "so long"
181+
182+
assert HelloWorld() == matchers.HasAttributes({"hello": "world"})
183+
```
184+
"""
185+
return _HasAttributes(attributes)
186+
187+
188+
class _DictMatching:
189+
_values: Mapping[str, Any]
190+
191+
def __init__(self, values: Mapping[str, Any]) -> None:
192+
self._values = values
193+
194+
def __eq__(self, target: object) -> bool:
195+
"""Return true if target matches all given keys/values."""
196+
is_match = True
197+
198+
for key, value in self._values.items():
199+
if is_match:
200+
try:
201+
is_match = key in target and target[key] == value # type: ignore[index,operator] # noqa: E501
202+
except TypeError:
203+
is_match = False
204+
205+
return is_match
206+
207+
def __repr__(self) -> str:
208+
"""Return a string representation of the matcher."""
209+
return f"<DictMatching {repr(self._values)}>"
210+
211+
212+
def DictMatching(values: Mapping[str, Any]) -> Any:
213+
"""Match any dictionary with the passed in keys / values.
214+
215+
Arguments:
216+
values: Keys and values to check.
217+
218+
Example:
219+
```python
220+
value = {"hello": "world", "goodbye": "so long"}
221+
assert value == matchers.DictMatching({"hello": "world"})
222+
```
223+
"""
224+
return _DictMatching(values)
225+
226+
127227
class _StringMatching:
128228
_pattern: Pattern[str]
129229

tests/test_matchers.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
"""Matcher tests."""
22
import pytest
3+
from collections import namedtuple
4+
from dataclasses import dataclass
35
from decoy import matchers
46
from typing import Any, List
57
from .common import SomeClass
68

79

10+
@dataclass
11+
class _HelloClass:
12+
hello: str = "world"
13+
14+
@property
15+
def goodbye(self) -> str:
16+
return "so long"
17+
18+
19+
_HelloTuple = namedtuple("_HelloTuple", ["hello"])
20+
21+
822
def test_any_matcher() -> None:
923
"""It should have an "anything except None" matcher."""
1024
assert 1 == matchers.Anything()
@@ -23,6 +37,10 @@ def test_is_a_matcher() -> None:
2337
assert [] == matchers.IsA(list)
2438
assert ("hello", "world") == matchers.IsA(tuple)
2539
assert SomeClass() == matchers.IsA(SomeClass)
40+
assert _HelloClass() == matchers.IsA(_HelloClass, {"hello": "world"})
41+
42+
assert _HelloClass() != matchers.IsA(_HelloClass, {"hello": "warld"})
43+
assert _HelloClass() != matchers.IsA(_HelloClass, {"hella": "world"})
2644

2745

2846
def test_is_not_matcher() -> None:
@@ -40,6 +58,35 @@ def test_is_not_matcher() -> None:
4058
assert ("hello", "world") != matchers.IsNot(("hello", "world"))
4159

4260

61+
def test_has_attribute_matcher() -> None:
62+
"""It should have an "anything with these attributes" matcher."""
63+
assert _HelloTuple("world") == matchers.HasAttributes({"hello": "world"})
64+
assert _HelloClass() == matchers.HasAttributes({"hello": "world"})
65+
assert _HelloClass() == matchers.HasAttributes({"goodbye": "so long"})
66+
67+
assert {"hello": "world"} != matchers.HasAttributes({"hello": "world"})
68+
assert _HelloTuple("world") != matchers.HasAttributes({"goodbye": "so long"})
69+
assert 1 != matchers.HasAttributes({"hello": "world"})
70+
assert False != matchers.HasAttributes({"hello": "world"}) # noqa[E712]
71+
assert [] != matchers.HasAttributes({"hello": "world"})
72+
73+
74+
def test_dict_matching_matcher() -> None:
75+
"""It should have an "anything with these attributes" matcher."""
76+
assert {"hello": "world"} == matchers.DictMatching({"hello": "world"})
77+
assert {"hello": "world", "goodbye": "so long"} == matchers.DictMatching(
78+
{"hello": "world"}
79+
)
80+
assert {"hello": "world", "goodbye": "so long"} == matchers.DictMatching(
81+
{"goodbye": "so long"}
82+
)
83+
84+
assert {"hello": "world"} != matchers.DictMatching({"goodbye": "so long"})
85+
assert 1 != matchers.DictMatching({"hello": "world"})
86+
assert False != matchers.DictMatching({"hello": "world"}) # noqa[E712]
87+
assert [] != matchers.DictMatching({"hello": "world"})
88+
89+
4390
def test_string_matching_matcher() -> None:
4491
"""It should have an "any string that matches" matcher."""
4592
assert "hello" == matchers.StringMatching("ello")

0 commit comments

Comments
 (0)