assertpy 2.35.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
assertpy/dict.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
4
|
+
|
|
5
|
+
from ._engine._mixin_base import _MixinBase
|
|
6
|
+
from ._engine._ordering import REFUSALS, equals, lookup, member
|
|
7
|
+
from ._engine._require import argument, refuse
|
|
8
|
+
from .contains import _entry_not_found, _value_not_found
|
|
9
|
+
from .errors import _capped
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ._engine._compat import Self
|
|
13
|
+
|
|
14
|
+
__tracebackhide__ = True
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DictMixin(_MixinBase):
|
|
18
|
+
"""Dict assertions mixin."""
|
|
19
|
+
|
|
20
|
+
def contains_key(self, *keys: object) -> Self:
|
|
21
|
+
"""Asserts the val is a dict and contains the given key or keys.
|
|
22
|
+
|
|
23
|
+
Alias for [`contains()`][assertpy.contains.ContainsMixin.contains].
|
|
24
|
+
|
|
25
|
+
Checks if the dict contains the given key or keys using ``in`` operator.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
*keys: the key or keys expected to be contained
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
Usage:
|
|
32
|
+
|
|
33
|
+
assert_that({'a': 1, 'b': 2}).contains_key('a')
|
|
34
|
+
assert_that({'a': 1, 'b': 2}).contains_key('a', 'b')
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
AssertionError: if val does **not** contain the key or keys
|
|
41
|
+
"""
|
|
42
|
+
self._require_dict_like(self.val, check_values=False, check_getitem=False)
|
|
43
|
+
return self.contains(*keys)
|
|
44
|
+
|
|
45
|
+
def does_not_contain_key(self, *keys: object) -> Self:
|
|
46
|
+
"""Asserts the val is a dict and does not contain the given key or keys.
|
|
47
|
+
|
|
48
|
+
Alias for [`does_not_contain()`][assertpy.contains.ContainsMixin.does_not_contain].
|
|
49
|
+
|
|
50
|
+
Checks if the dict excludes the given key or keys using ``in`` operator.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
*keys: the key or keys expected to be excluded
|
|
54
|
+
|
|
55
|
+
Examples:
|
|
56
|
+
Usage:
|
|
57
|
+
|
|
58
|
+
assert_that({'a': 1, 'b': 2}).does_not_contain_key('x')
|
|
59
|
+
assert_that({'a': 1, 'b': 2}).does_not_contain_key('x', 'y')
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
AssertionError: if val **does** contain the key or keys
|
|
66
|
+
"""
|
|
67
|
+
self._require_dict_like(self.val, check_values=False, check_getitem=False)
|
|
68
|
+
return self.does_not_contain(*keys)
|
|
69
|
+
|
|
70
|
+
def contains_value(self, *values: object) -> Self:
|
|
71
|
+
"""Asserts that val is a dict and contains the given value or values.
|
|
72
|
+
|
|
73
|
+
Checks if the dict contains the given value or values in *any* key.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
*values: the value or values expected to be contained
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
Usage:
|
|
80
|
+
|
|
81
|
+
assert_that({'a': 1, 'b': 2}).contains_value(1)
|
|
82
|
+
assert_that({'a': 1, 'b': 2}).contains_value(1, 2)
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
AssertionError: if val does **not** contain the value or values
|
|
89
|
+
"""
|
|
90
|
+
self._require_dict_like(self.val, check_getitem=False)
|
|
91
|
+
if len(values) == 0:
|
|
92
|
+
raise ValueError("one or more value args must be given")
|
|
93
|
+
missing = [value for value in values if not member(value, self.val.values())]
|
|
94
|
+
if missing:
|
|
95
|
+
return self.error(
|
|
96
|
+
f"Expected <{_capped(self.val)}> to contain values {self._fmt_items(values)},"
|
|
97
|
+
f" but did not contain {self._fmt_items(missing)}." + _value_not_found(self.val, missing),
|
|
98
|
+
expected=values,
|
|
99
|
+
)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def does_not_contain_value(self, *values: object) -> Self:
|
|
103
|
+
"""Asserts that val is a dict and does not contain the given value or values.
|
|
104
|
+
|
|
105
|
+
Checks if the dict excludes the given value or values across *all* keys.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
*values: the value or values expected to be excluded
|
|
109
|
+
|
|
110
|
+
Examples:
|
|
111
|
+
Usage:
|
|
112
|
+
|
|
113
|
+
assert_that({'a': 1, 'b': 2}).does_not_contain_value(3)
|
|
114
|
+
assert_that({'a': 1, 'b': 2}).does_not_contain_value(3, 4)
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
AssertionError: if val **does** contain the value or values
|
|
121
|
+
"""
|
|
122
|
+
self._require_dict_like(self.val, check_getitem=False)
|
|
123
|
+
if len(values) == 0:
|
|
124
|
+
raise ValueError("one or more value args must be given")
|
|
125
|
+
else:
|
|
126
|
+
found = [value for value in values if member(value, self.val.values())]
|
|
127
|
+
if found:
|
|
128
|
+
return self.error(
|
|
129
|
+
f"Expected <{_capped(self.val)}> to not contain values {self._fmt_items(values)},"
|
|
130
|
+
f" but did contain {self._fmt_items(found)}."
|
|
131
|
+
)
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def contains_entry(self, *args: object, **kwargs: object) -> Self:
|
|
135
|
+
"""Asserts that val is a dict and contains the given entry or entries.
|
|
136
|
+
|
|
137
|
+
Checks if the dict contains the given key-value pair or pairs.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
*args (object): the entry or entries expected to be contained (as ``{k: v}`` args)
|
|
141
|
+
**kwargs (object): the entry or entries expected to be contained (as ``k=v`` kwargs)
|
|
142
|
+
|
|
143
|
+
Examples:
|
|
144
|
+
Usage:
|
|
145
|
+
|
|
146
|
+
# using args
|
|
147
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).contains_entry({'a': 1})
|
|
148
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).contains_entry({'a': 1}, {'b': 2})
|
|
149
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).contains_entry({'a': 1}, {'b': 2}, {'c': 3})
|
|
150
|
+
|
|
151
|
+
# using kwargs
|
|
152
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).contains_entry(a=1)
|
|
153
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).contains_entry(a=1, b=2)
|
|
154
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).contains_entry(a=1, b=2, c=3)
|
|
155
|
+
|
|
156
|
+
# or args and kwargs
|
|
157
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).contains_entry({'c': 3}, a=1, b=2)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
AssertionError: if val does **not** contain the entry or entries
|
|
165
|
+
|
|
166
|
+
Note:
|
|
167
|
+
Where one entry is not found, the failure says what its key holds, or that the key is not there, and
|
|
168
|
+
on a line under the message why the value held is not the one expected, where one fact says so.
|
|
169
|
+
"""
|
|
170
|
+
self._require_dict_like(self.val, check_values=False)
|
|
171
|
+
entries = list(args) + [{key: value} for key, value in kwargs.items()]
|
|
172
|
+
if len(entries) == 0:
|
|
173
|
+
raise ValueError("one or more entry args must be given")
|
|
174
|
+
missing = []
|
|
175
|
+
for entry in entries:
|
|
176
|
+
if type(entry) is not dict:
|
|
177
|
+
refuse(entry, "a dict", subject=argument("entry"))
|
|
178
|
+
# `object` not `dict`: the check above is `type(entry) is dict`, so a subclass is refused here
|
|
179
|
+
pair = cast("dict[Any, Any]", entry)
|
|
180
|
+
if len(pair) != 1:
|
|
181
|
+
raise ValueError("given entry args must contain exactly one key-value pair")
|
|
182
|
+
entry_key = next(iter(pair))
|
|
183
|
+
try:
|
|
184
|
+
if entry_key in self.val and equals(self.val[entry_key], pair[entry_key]):
|
|
185
|
+
continue
|
|
186
|
+
except REFUSALS as refusal:
|
|
187
|
+
found, held = lookup(self.val, entry_key, refusal)
|
|
188
|
+
if found and equals(held, pair[entry_key]):
|
|
189
|
+
continue
|
|
190
|
+
missing.append(pair)
|
|
191
|
+
if missing:
|
|
192
|
+
return self.error(
|
|
193
|
+
f"Expected <{_capped(self.val)}> to contain entries {self._fmt_items(entries)},"
|
|
194
|
+
f" but did not contain {self._fmt_items(missing)}." + _entry_not_found(self.val, missing),
|
|
195
|
+
expected=entries,
|
|
196
|
+
)
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def does_not_contain_entry(self, *args: object, **kwargs: object) -> Self:
|
|
200
|
+
"""Asserts that val is a dict and does not contain the given entry or entries.
|
|
201
|
+
|
|
202
|
+
Checks if the dict excludes the given key-value pair or pairs.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
*args (object): the entry or entries expected to be excluded (as ``{k: v}`` args)
|
|
206
|
+
**kwargs (object): the entry or entries expected to be excluded (as ``k=v`` kwargs)
|
|
207
|
+
|
|
208
|
+
Examples:
|
|
209
|
+
Usage:
|
|
210
|
+
|
|
211
|
+
# using args
|
|
212
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).does_not_contain_entry({'a': 2})
|
|
213
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).does_not_contain_entry({'a': 2}, {'x': 4})
|
|
214
|
+
|
|
215
|
+
# using kwargs
|
|
216
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).does_not_contain_entry(a=2)
|
|
217
|
+
assert_that({'a': 1, 'b': 2, 'c': 3}).does_not_contain_entry(a=2, x=4)
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
AssertionBuilder: returns this instance to chain to the next assertion
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
AssertionError: if val **does** contain the entry or entries
|
|
224
|
+
"""
|
|
225
|
+
self._require_dict_like(self.val, check_values=False)
|
|
226
|
+
entries = list(args) + [{key: value} for key, value in kwargs.items()]
|
|
227
|
+
if len(entries) == 0:
|
|
228
|
+
raise ValueError("one or more entry args must be given")
|
|
229
|
+
found = []
|
|
230
|
+
for entry in entries:
|
|
231
|
+
if type(entry) is not dict:
|
|
232
|
+
refuse(entry, "a dict", subject=argument("entry"))
|
|
233
|
+
pair = cast("dict[Any, Any]", entry)
|
|
234
|
+
if len(pair) != 1:
|
|
235
|
+
raise ValueError("given entry args must contain exactly one key-value pair")
|
|
236
|
+
entry_key = next(iter(pair))
|
|
237
|
+
try:
|
|
238
|
+
present = entry_key in self.val and equals(self.val[entry_key], pair[entry_key])
|
|
239
|
+
except REFUSALS as refusal:
|
|
240
|
+
held_there, held = lookup(self.val, entry_key, refusal)
|
|
241
|
+
present = held_there and equals(held, pair[entry_key])
|
|
242
|
+
if present:
|
|
243
|
+
found.append(pair)
|
|
244
|
+
if found:
|
|
245
|
+
return self.error(
|
|
246
|
+
f"Expected <{_capped(self.val)}> to not contain entries {self._fmt_items(entries)},"
|
|
247
|
+
f" but did contain {self._fmt_items(found)}."
|
|
248
|
+
)
|
|
249
|
+
return self
|
assertpy/dynamic.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import collections.abc
|
|
4
|
+
import inspect
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from ._engine._diff import _build_equality_diff, _pair_itself
|
|
8
|
+
from ._engine._equality import mapping_shaped
|
|
9
|
+
from ._engine._introspection import is_namedtuple
|
|
10
|
+
from ._engine._mixin_base import _MixinBase
|
|
11
|
+
from ._engine._path import _ROOT
|
|
12
|
+
from ._hints import Roles, not_found
|
|
13
|
+
from .errors import DiffResult, _first_difference, _formatted, _parted, _told_apart, _truncated
|
|
14
|
+
from .helpers import _both_list_like, _elided_seq_repr, _elided_walks
|
|
15
|
+
from .http_mixin import response_of
|
|
16
|
+
from .outcome import MISSING, Requirement
|
|
17
|
+
|
|
18
|
+
__tracebackhide__ = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_ONE_OPERAND = inspect.Signature([inspect.Parameter("other", inspect.Parameter.POSITIONAL_OR_KEYWORD)])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _one_operand(args: tuple[object, ...], kwargs: dict[str, object]) -> dict[str, object]:
|
|
25
|
+
"""The operand by name, or the call as given when the arity is what the wrapper is about to refuse."""
|
|
26
|
+
try:
|
|
27
|
+
return dict(_ONE_OPERAND.bind_partial(*args, **kwargs).arguments)
|
|
28
|
+
except TypeError:
|
|
29
|
+
return {"args": args, "kwargs": kwargs}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _near(name: str, value: object, *, keyed: bool) -> str:
|
|
33
|
+
"""A line, on a line of its own, on a key or an attribute of *value* that reads almost as *name*, or nothing.
|
|
34
|
+
|
|
35
|
+
The keys are read by walking the value once, as `contains_key` walks it. The attributes are the public
|
|
36
|
+
names its ``__dir__`` lists that the object does hold, looked up without calling a property: a ``__dir__``
|
|
37
|
+
of the value's own may list what is not there. Asked of the type and not through `dir`, which sorts what
|
|
38
|
+
it is given, and sorting compares names that may be no plain text.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
if keyed:
|
|
42
|
+
names = cast("collections.abc.Iterable[object]", value)
|
|
43
|
+
line = not_found(name, names, Roles("the key not found", "a key", named=True))
|
|
44
|
+
else:
|
|
45
|
+
listed = type.__getattribute__(type(value), "__dir__")(value)
|
|
46
|
+
public = [each for each in listed if type(each) is str and not each.startswith("_")]
|
|
47
|
+
held = [each for each in public if inspect.getattr_static(value, each, MISSING) is not MISSING]
|
|
48
|
+
line = not_found(name, held, Roles("the attribute not found", "an attribute", named=True))
|
|
49
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
50
|
+
return ""
|
|
51
|
+
return "" if line is None else f"\n{line}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read_again(builder: Any, actual: object, expected: object) -> tuple[tuple[str, str] | None, DiffResult]:
|
|
55
|
+
"""What `is_equal_to` reads of two values found unequal: their two collapsed texts, where they have any, and
|
|
56
|
+
their diff.
|
|
57
|
+
|
|
58
|
+
Two mappings, as the pair stands once ``==`` has answered, by the call `is_equal_to` fails with on two
|
|
59
|
+
mappings (`_failure_views`): it renders each side and then builds the entries, three readings of the
|
|
60
|
+
pair, and where the values refuse any of them the three are given up together, for no texts and the
|
|
61
|
+
pair itself. Two plain sequences are collapsed as `is_equal_to` collapses them, and there the texts
|
|
62
|
+
and the diff are given up each on its own. ``==`` of two lists stops at the first pair that differs,
|
|
63
|
+
and every one of these readings goes on past it, to an element the verdict never asked.
|
|
64
|
+
"""
|
|
65
|
+
if mapping_shaped(actual, check_values=False) and mapping_shaped(expected, check_values=False):
|
|
66
|
+
try:
|
|
67
|
+
kind, entries, actual_text, expected_text, _copies = builder._failure_views(
|
|
68
|
+
actual, expected, None, None, None, *_elided_walks(None)
|
|
69
|
+
)
|
|
70
|
+
except Exception: # a diagnostic must never outrank the failure it is describing
|
|
71
|
+
return None, _pair_itself(actual, expected)
|
|
72
|
+
diff = DiffResult(kind=kind, entries=entries or [_ROOT.leaf_entry(actual=actual, expected=expected)])
|
|
73
|
+
return (actual_text, expected_text), diff
|
|
74
|
+
texts = None
|
|
75
|
+
if _both_list_like(actual, expected):
|
|
76
|
+
try:
|
|
77
|
+
texts = _truncated(_elided_seq_repr(actual, expected)), _truncated(_elided_seq_repr(expected, actual))
|
|
78
|
+
except Exception: # as above
|
|
79
|
+
texts = None
|
|
80
|
+
try:
|
|
81
|
+
return texts, _build_equality_diff(actual, expected)
|
|
82
|
+
except Exception: # as above
|
|
83
|
+
return texts, _pair_itself(actual, expected)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _as_equality_fails(builder: Any, actual: object, expected: object) -> tuple[str, str, DiffResult]:
|
|
87
|
+
"""The two sides of a failed ``has_<name>()`` as its message prints them, and the diff it carries.
|
|
88
|
+
|
|
89
|
+
Collapsed as `is_equal_to` collapses them where they are two mappings or two plain sequences
|
|
90
|
+
(`_read_again`). Any other pair as two texts cut around the place they part, which is also what those two
|
|
91
|
+
fall back to where the collapsed texts read alike or could not be made. So the message differs from the
|
|
92
|
+
one `is_equal_to` prints exactly there: it keeps two texts that read alike.
|
|
93
|
+
"""
|
|
94
|
+
collapsed, diff = _read_again(builder, actual, expected)
|
|
95
|
+
if collapsed is not None and collapsed[0] != collapsed[1]:
|
|
96
|
+
return *collapsed, diff
|
|
97
|
+
actual_text, expected_text = _formatted(actual), _formatted(expected)
|
|
98
|
+
parted = _parted(actual_text, expected_text, _first_difference(actual_text, expected_text))
|
|
99
|
+
return *_told_apart(*parted, actual, expected), diff
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DynamicMixin(_MixinBase):
|
|
103
|
+
"""Dynamic assertions mixin.
|
|
104
|
+
|
|
105
|
+
When testing attributes of an object (or the contents of a dict), the
|
|
106
|
+
[`is_equal_to()`][assertpy.base.BaseMixin.is_equal_to] assertion can be a bit verbose:
|
|
107
|
+
|
|
108
|
+
fred = Person('Fred', 'Smith')
|
|
109
|
+
|
|
110
|
+
assert_that(fred.first_name).is_equal_to('Fred')
|
|
111
|
+
assert_that(fred.name).is_equal_to('Fred Smith')
|
|
112
|
+
assert_that(fred.say_hello()).is_equal_to('Hello, Fred!')
|
|
113
|
+
|
|
114
|
+
Instead, use dynamic assertions in the form of ``has_<name>()`` where ``<name>`` is the name of
|
|
115
|
+
any attribute, property, or zero-argument method on the given object. Dynamic equality
|
|
116
|
+
assertions test if actual is equal to expected using the ``==`` operator. Using dynamic
|
|
117
|
+
assertions, we can rewrite the above example as:
|
|
118
|
+
|
|
119
|
+
assert_that(fred).has_first_name('Fred')
|
|
120
|
+
assert_that(fred).has_name('Fred Smith')
|
|
121
|
+
assert_that(fred).has_say_hello('Hello, Fred!')
|
|
122
|
+
|
|
123
|
+
Similarly, dynamic assertions also work on any *dict-like* object:
|
|
124
|
+
|
|
125
|
+
fred = {
|
|
126
|
+
'first_name': 'Fred',
|
|
127
|
+
'last_name': 'Smith',
|
|
128
|
+
'shoe_size': 12
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
assert_that(fred).has_first_name('Fred')
|
|
132
|
+
assert_that(fred).has_last_name('Smith')
|
|
133
|
+
assert_that(fred).has_shoe_size(12)
|
|
134
|
+
|
|
135
|
+
A name the value does not have fails with ``not_`` as well: negation inverts the comparison, never
|
|
136
|
+
whether there is a field to compare, so ``not_.has_frist_name('Joe')`` does not pass on a typo.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __getattr__(self, attr: str) -> Any:
|
|
140
|
+
"""Asserts that val has attribute attr and that its value is equal to other via a dynamic
|
|
141
|
+
assertion of the form ``has_<attr>()``.
|
|
142
|
+
|
|
143
|
+
The return stays ``Any`` on purpose, and the reason is written down because the annotation looks
|
|
144
|
+
like an oversight worth fixing. Two unrelated things resolve through this hook: a ``has_``
|
|
145
|
+
wrapper, which takes one argument and hands back the builder, and any name registered with
|
|
146
|
+
`add_extension`, which takes and returns whatever it declares. No single signature is true of
|
|
147
|
+
both. Pinning the argument list reported a correct zero-argument extension call as too few
|
|
148
|
+
arguments, and pinning the return type reported a correct extension result as the wrong type
|
|
149
|
+
where it was used. Both were measured against mypy, pyright and ty.
|
|
150
|
+
|
|
151
|
+
The narrower annotation also bought less than it looked like. It could not catch an assertion
|
|
152
|
+
chained onto a dynamic step, because that access resolves back through this same hook; the only
|
|
153
|
+
thing it added was a verdict in assignment position, which is where it was wrong for extensions.
|
|
154
|
+
"""
|
|
155
|
+
if not attr.startswith("has_"):
|
|
156
|
+
raise AttributeError(f"assertpy has no assertion <{attr}()>")
|
|
157
|
+
|
|
158
|
+
attr_name = attr[4:]
|
|
159
|
+
err_msg: str | None = None
|
|
160
|
+
val_is_namedtuple = is_namedtuple(self.val)
|
|
161
|
+
# a django response reads its headers by key, yet `has_status_code()` means the attribute, as on every client
|
|
162
|
+
is_dict = (
|
|
163
|
+
isinstance(self.val, collections.abc.Iterable)
|
|
164
|
+
and hasattr(self.val, "__getitem__")
|
|
165
|
+
and response_of(self.val) is None
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if is_dict and not val_is_namedtuple:
|
|
169
|
+
# dict-likes are read by key below, so a real method absent as a key would skip this gate and raise KeyError
|
|
170
|
+
if attr_name not in self.val:
|
|
171
|
+
err_msg = f"Expected key <{attr_name}>, but val has no key <{attr_name}>."
|
|
172
|
+
elif not hasattr(self.val, attr_name):
|
|
173
|
+
err_msg = f"Expected attribute <{attr_name}>, but val has no attribute <{attr_name}>."
|
|
174
|
+
|
|
175
|
+
def _wrapper(*args, **kwargs):
|
|
176
|
+
# named here rather than read off the stack: the operation is the attribute, not this closure
|
|
177
|
+
asked = Requirement(attr, _one_operand(args, kwargs))
|
|
178
|
+
if err_msg:
|
|
179
|
+
# ok to raise now that we are inside wrapper
|
|
180
|
+
near = _near(attr_name, self.val, keyed=is_dict and not val_is_namedtuple)
|
|
181
|
+
self._unmet(err_msg + near, expected=asked.parameters.get("other", MISSING), requirement=asked)
|
|
182
|
+
return self
|
|
183
|
+
else:
|
|
184
|
+
try:
|
|
185
|
+
# bound rather than counted: the signature says `other`, and writing it was refused
|
|
186
|
+
bound = _ONE_OPERAND.bind(*args, **kwargs)
|
|
187
|
+
except TypeError:
|
|
188
|
+
given = len(args) + len(kwargs)
|
|
189
|
+
raise TypeError(f"assertion <{attr}()> takes exactly 1 argument ({given} given)") from None
|
|
190
|
+
|
|
191
|
+
val_attr = self.val[attr_name] if is_dict and not val_is_namedtuple else getattr(self.val, attr_name)
|
|
192
|
+
|
|
193
|
+
if callable(val_attr):
|
|
194
|
+
try:
|
|
195
|
+
inspect.signature(val_attr).bind()
|
|
196
|
+
except TypeError: # the method needs arguments, so it is not a zero-arg method
|
|
197
|
+
raise TypeError(f"val does not have zero-arg method <{attr_name}()>") from None
|
|
198
|
+
except ValueError: # some builtins expose no introspectable signature; just call it
|
|
199
|
+
pass
|
|
200
|
+
actual = val_attr() # a TypeError from here comes from the method body, not arity
|
|
201
|
+
else:
|
|
202
|
+
actual = val_attr
|
|
203
|
+
|
|
204
|
+
expected = bound.arguments["other"]
|
|
205
|
+
if actual == expected:
|
|
206
|
+
return self
|
|
207
|
+
kind = "key" if is_dict else "attribute"
|
|
208
|
+
actual_text, expected_text, diff = _as_equality_fails(self, actual, expected)
|
|
209
|
+
self._compared = (actual, expected)
|
|
210
|
+
return self.error(
|
|
211
|
+
f"Expected <{actual_text}> to be equal to <{expected_text}> on {kind} <{attr_name}>, but was not.",
|
|
212
|
+
actual=actual,
|
|
213
|
+
expected=expected,
|
|
214
|
+
diff=diff,
|
|
215
|
+
requirement=asked,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# the arity check above is the wrapper's own, so its signature says what a caller writes and
|
|
219
|
+
# `Requirement.parameters` reads the same key whether the call was negated or not. Through
|
|
220
|
+
# `setattr` because typeshed declares no `__signature__` on a function, which both gates report
|
|
221
|
+
setattr(_wrapper, "__signature__", _ONE_OPERAND) # noqa: B010 - see above
|
|
222
|
+
return _wrapper
|