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
|
@@ -0,0 +1,1876 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import sys
|
|
5
|
+
import uuid as _uuid_mod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from fractions import Fraction
|
|
10
|
+
from types import UnionType
|
|
11
|
+
from typing import (
|
|
12
|
+
TYPE_CHECKING,
|
|
13
|
+
Any,
|
|
14
|
+
ClassVar,
|
|
15
|
+
Final,
|
|
16
|
+
NamedTuple,
|
|
17
|
+
NoReturn,
|
|
18
|
+
Protocol,
|
|
19
|
+
SupportsIndex,
|
|
20
|
+
TypeVar,
|
|
21
|
+
Union,
|
|
22
|
+
cast,
|
|
23
|
+
get_origin,
|
|
24
|
+
runtime_checkable,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from ._engine._compare import (
|
|
28
|
+
_build_compare_config,
|
|
29
|
+
_config_note,
|
|
30
|
+
_guarded_equal,
|
|
31
|
+
_is_real_number,
|
|
32
|
+
_keyed_types_differ,
|
|
33
|
+
_non_finite,
|
|
34
|
+
_resolve_comparator,
|
|
35
|
+
plainly_within,
|
|
36
|
+
require_rel,
|
|
37
|
+
within_either,
|
|
38
|
+
zero_of,
|
|
39
|
+
)
|
|
40
|
+
from ._engine._equality import (
|
|
41
|
+
IncludeKeysMissingError,
|
|
42
|
+
filtered_differs,
|
|
43
|
+
key_specs_given,
|
|
44
|
+
mapping_differs,
|
|
45
|
+
mapping_shaped,
|
|
46
|
+
values_differ,
|
|
47
|
+
)
|
|
48
|
+
from ._engine._introspection import (
|
|
49
|
+
MappingLike,
|
|
50
|
+
is_attrs_instance,
|
|
51
|
+
is_mapping_like,
|
|
52
|
+
is_model_dump_object,
|
|
53
|
+
is_own_iterator,
|
|
54
|
+
materialized,
|
|
55
|
+
)
|
|
56
|
+
from ._engine._membership import (
|
|
57
|
+
MembershipRefusedError,
|
|
58
|
+
is_searchable,
|
|
59
|
+
is_walkable,
|
|
60
|
+
missing_items,
|
|
61
|
+
only_faults,
|
|
62
|
+
searchable,
|
|
63
|
+
subset_faults,
|
|
64
|
+
)
|
|
65
|
+
from ._engine._ordering import (
|
|
66
|
+
REFUSALS,
|
|
67
|
+
UnorderableError,
|
|
68
|
+
equal_past,
|
|
69
|
+
equals,
|
|
70
|
+
first_out_of_order,
|
|
71
|
+
first_plainly_out_of_order,
|
|
72
|
+
holds,
|
|
73
|
+
lookup,
|
|
74
|
+
may_broadcast,
|
|
75
|
+
member,
|
|
76
|
+
numpy_duration,
|
|
77
|
+
plainly_sortable,
|
|
78
|
+
require_integer,
|
|
79
|
+
whole_number,
|
|
80
|
+
)
|
|
81
|
+
from ._engine._path import _ROOT, _Path
|
|
82
|
+
from ._engine._require import (
|
|
83
|
+
NON_MATCHER_TYPES,
|
|
84
|
+
VerdictError,
|
|
85
|
+
argument,
|
|
86
|
+
raised_inside,
|
|
87
|
+
raised_on_purpose,
|
|
88
|
+
refuse,
|
|
89
|
+
reject_unknown_kwargs,
|
|
90
|
+
require_pattern,
|
|
91
|
+
verdict,
|
|
92
|
+
)
|
|
93
|
+
from ._engine._size import length_of
|
|
94
|
+
from ._engine._text import contains as text_contains
|
|
95
|
+
from ._engine._text import ends_with as text_ends_with
|
|
96
|
+
from ._engine._text import starts_with as text_starts_with
|
|
97
|
+
from ._hints import matcher_for_a_class, type_beside_a_class
|
|
98
|
+
from .errors import _capped, _capped_format, _capped_repr, _safe_repr, _told_apart, _type_expression_name
|
|
99
|
+
|
|
100
|
+
if TYPE_CHECKING:
|
|
101
|
+
import re
|
|
102
|
+
from collections.abc import Callable
|
|
103
|
+
from typing import TypeAlias
|
|
104
|
+
|
|
105
|
+
from typing_extensions import TypeIs
|
|
106
|
+
|
|
107
|
+
from ._engine._compare import _CompareConfig
|
|
108
|
+
from .errors import DiffResult
|
|
109
|
+
|
|
110
|
+
# nested because `isinstance()` accepts tuples nested to any depth. Only the recursive reference is
|
|
111
|
+
# quoted: ty ignores an alias whose whole right-hand side is a string, and `is_instance_of("int")`
|
|
112
|
+
# stopped being a type error. A PEP 695 `type` statement is read by all three and is a SyntaxError on 3.10
|
|
113
|
+
ClassInfo: TypeAlias = type | UnionType | tuple[type | UnionType | tuple["ClassInfo", ...], ...]
|
|
114
|
+
_ExceptionClasses: TypeAlias = type[BaseException] | UnionType | tuple["_ExceptionClasses", ...]
|
|
115
|
+
_WarningClasses: TypeAlias = type[Warning] | UnionType | tuple["_WarningClasses", ...]
|
|
116
|
+
_RetriedClasses: TypeAlias = type[Exception] | UnionType | tuple["_RetriedClasses", ...]
|
|
117
|
+
# several classes, any one of which will do; one class is spelled beside these, where it can stay typed as itself
|
|
118
|
+
ExceptionInfo: TypeAlias = UnionType | tuple[_ExceptionClasses, ...]
|
|
119
|
+
WarningInfo: TypeAlias = UnionType | tuple[_WarningClasses, ...]
|
|
120
|
+
RetriedInfo: TypeAlias = UnionType | tuple[_RetriedClasses, ...]
|
|
121
|
+
|
|
122
|
+
__tracebackhide__ = raised_on_purpose
|
|
123
|
+
|
|
124
|
+
_M_contra = TypeVar("_M_contra", contravariant=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@runtime_checkable
|
|
128
|
+
class Matcher(Protocol[_M_contra]):
|
|
129
|
+
"""Protocol for composable matcher objects, parameterised by the value they judge.
|
|
130
|
+
|
|
131
|
+
Contravariant, because a matcher *consumes* the value: one written for `object` answers about an
|
|
132
|
+
`int`, and one written for `str` does not. That is the direction that makes
|
|
133
|
+
`satisfies(match.starts_with("a"))` on an `int` a type error while keeping `match.is_none()` usable
|
|
134
|
+
anywhere.
|
|
135
|
+
|
|
136
|
+
Written without a PEP 696 default on purpose: that spelling needs `typing_extensions` on everything
|
|
137
|
+
before 3.13, and this package declares it only for 3.10. A bare `Matcher` in an annotation is read
|
|
138
|
+
as `Matcher[Any]` by all four checkers anyway, so existing annotations keep their meaning.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def matches(self, value: _M_contra) -> bool: ...
|
|
142
|
+
|
|
143
|
+
def describe(self) -> str: ...
|
|
144
|
+
|
|
145
|
+
def describe_mismatch(self, value: _M_contra) -> str: ...
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _Composable(Matcher[_M_contra], Protocol[_M_contra]):
|
|
149
|
+
"""A built-in matcher bound to the value it judges that keeps the operators of `BaseMatcher`.
|
|
150
|
+
|
|
151
|
+
A factory typed as the bare `Matcher` lost ``&``, ``|`` and ``~`` to every checker, though its
|
|
152
|
+
runtime object has them. The operators are declared the way `BaseMatcher` declares them, so what
|
|
153
|
+
they return judges anything, as every composition does.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __and__(self, other: Matcher[Any]) -> AllOfMatcher: ...
|
|
157
|
+
|
|
158
|
+
def __or__(self, other: Matcher[Any]) -> AnyOfMatcher: ...
|
|
159
|
+
|
|
160
|
+
def __invert__(self) -> NotMatcher: ...
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _refused(matcher: Matcher[Any], value: object) -> MatchResult:
|
|
164
|
+
"""The result for a value a matcher has already turned down, without asking it again.
|
|
165
|
+
|
|
166
|
+
The verdict and the reason used to come from two calls, and a matcher that carries state, or reads a
|
|
167
|
+
one-shot source, answers the second one differently: one returning False and then True turned a
|
|
168
|
+
refused value into a passing assertion.
|
|
169
|
+
"""
|
|
170
|
+
return MatchResult(matched=False, description=matcher.describe(), mismatch=matcher.describe_mismatch(value))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _stated_reason(matcher: Matcher[Any], value: object) -> str:
|
|
174
|
+
"""What *matcher* says about a value it has already refused, without asking for its verdict again.
|
|
175
|
+
|
|
176
|
+
A value that is its own iterator was spent on the verdict, so it gets the requirement alone: read
|
|
177
|
+
again, its remainder was described as the value, and an endless one was read forever.
|
|
178
|
+
"""
|
|
179
|
+
if is_own_iterator(value):
|
|
180
|
+
return matcher.describe()
|
|
181
|
+
refused = _refused(matcher, value)
|
|
182
|
+
return f"{refused.description} ({refused.mismatch})"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _has_own_evaluate(matcher: object) -> bool:
|
|
186
|
+
"""Whether *matcher* declares `evaluate()` itself instead of inheriting the composed default.
|
|
187
|
+
|
|
188
|
+
Named after what it checks rather than after why it is asked. The reason is usually that the
|
|
189
|
+
matcher walks its value, but a matcher may override `evaluate()` for a structured result without
|
|
190
|
+
walking anything, and a name promising a walk would invite an optimisation that is not sound.
|
|
191
|
+
|
|
192
|
+
One that does has to be asked once: the default asks `matches()` and then `describe_mismatch()`,
|
|
193
|
+
which walks the value again and, over a one-shot value, sees what the first walk left. One that
|
|
194
|
+
answers from a type check gains nothing, and a `MatchResult` per leaf of a structure is an
|
|
195
|
+
allocation and a rendered description per leaf, measured at 59% of the structural benchmark.
|
|
196
|
+
|
|
197
|
+
`getattr` with a default rather than an attribute access: a duck-typed matcher satisfies the
|
|
198
|
+
protocol with three methods and need not have `evaluate` at all.
|
|
199
|
+
|
|
200
|
+
A composite answers for what it holds rather than for its own class, since it walks a value only as
|
|
201
|
+
far as its parts do. Asked by class, `greater_than(0) & less_than(10)` would take the one-call path
|
|
202
|
+
for nothing and pay a `MatchResult` and a recursive `describe()` per structural leaf: 0.35 ms became
|
|
203
|
+
0.75 ms over 200 records.
|
|
204
|
+
|
|
205
|
+
A subclass of a composite is a matcher of its own, asked as it answers: the `evaluate()` it inherits
|
|
206
|
+
from the composite would disagree with a `matches()` it overrides, so only one it declares counts.
|
|
207
|
+
"""
|
|
208
|
+
if isinstance(matcher, _COMPOSITES):
|
|
209
|
+
if _composed(matcher):
|
|
210
|
+
return matcher.walks_its_value
|
|
211
|
+
inherited = (BaseMatcher.evaluate, AllOfMatcher.evaluate, AnyOfMatcher.evaluate, NotMatcher.evaluate)
|
|
212
|
+
return getattr(type(matcher), "evaluate", BaseMatcher.evaluate) not in inherited
|
|
213
|
+
return getattr(type(matcher), "evaluate", BaseMatcher.evaluate) is not BaseMatcher.evaluate
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _is_matcher(obj: object) -> TypeIs[Matcher[Any]]:
|
|
217
|
+
"""Fast membership test for the runtime_checkable ``Matcher`` protocol.
|
|
218
|
+
|
|
219
|
+
``isinstance(x, Matcher)`` is expensive: the runtime_checkable check walks every protocol member
|
|
220
|
+
through ``getattr_static``. Two fast paths skip it: a builtin operand is never a matcher (frozenset
|
|
221
|
+
membership), and a ``BaseMatcher`` subclass instance is taken as one (a cheap C-level ``isinstance``,
|
|
222
|
+
~5x faster than the protocol walk). Any other object - including a duck-typed custom matcher that
|
|
223
|
+
does not inherit ``BaseMatcher`` - falls through to the full protocol check unchanged.
|
|
224
|
+
|
|
225
|
+
The second path takes a subclass by inheritance. One that sets a member to ``None``, which the
|
|
226
|
+
protocol refuses and mypy, Pyright and Pyrefly refuse where it is written, is still a matcher here:
|
|
227
|
+
one that raises ``TypeError`` when the member is called, treated as any matcher that raises, a
|
|
228
|
+
non-match in the any-order pairing. Asking the three members statically measured about 2.4 us
|
|
229
|
+
against 40 ns per call on a `BaseMatcher`.
|
|
230
|
+
"""
|
|
231
|
+
if type(obj) in NON_MATCHER_TYPES:
|
|
232
|
+
return False
|
|
233
|
+
if isinstance(obj, BaseMatcher):
|
|
234
|
+
return True
|
|
235
|
+
return isinstance(obj, Matcher)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _require_matcher(operand: object, operator: str) -> None:
|
|
239
|
+
"""Reject a non-matcher operand where the combinator is built, not where it is later applied.
|
|
240
|
+
|
|
241
|
+
Combining silently would hand back a matcher that raises ``AttributeError: 'int' object has no
|
|
242
|
+
attribute 'matches'`` at assertion time, far from the line that built it.
|
|
243
|
+
"""
|
|
244
|
+
if not _is_matcher(operand):
|
|
245
|
+
named = matcher_for_a_class(operand)
|
|
246
|
+
message = f"cannot combine a Matcher with <{type(operand).__name__}> using '{operator}'{named}"
|
|
247
|
+
_assertpy_refusal = True
|
|
248
|
+
raise TypeError(message)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _require_matcher_argument(value: object) -> Matcher[Any]:
|
|
252
|
+
"""The matcher a factory was handed, refused where the factory is called, for the reason above."""
|
|
253
|
+
if not _is_matcher(value):
|
|
254
|
+
refuse(value, "a Matcher", subject=argument("matcher"), note=matcher_for_a_class(value))
|
|
255
|
+
return value
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
259
|
+
class MatchResult:
|
|
260
|
+
"""What a matcher decided about one value, in one object instead of three calls.
|
|
261
|
+
|
|
262
|
+
Deliberately not an [`AssertionOutcome`][assertpy.outcome.AssertionOutcome], which is the record of
|
|
263
|
+
a *failed assertion*. A matcher is asked about every leaf of a structure and about every element of
|
|
264
|
+
a collection, so its result has to stay cheap: four fields, no location, no group, nothing that has
|
|
265
|
+
to be computed before it is known whether anyone will read it.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
matched: bool
|
|
269
|
+
"""Whether the value matched."""
|
|
270
|
+
|
|
271
|
+
description: str
|
|
272
|
+
"""What the matcher requires, in the words it uses in a failure message: ``a positive value``."""
|
|
273
|
+
|
|
274
|
+
mismatch: str = ""
|
|
275
|
+
"""Why this value did not match, in the words a failure message continues with: ``was <-1>``.
|
|
276
|
+
|
|
277
|
+
Empty when it matched. There is nothing to say about a value that satisfied the matcher, and the
|
|
278
|
+
text would have to be invented.
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
diff: DiffResult | None = None
|
|
282
|
+
"""A structured diff, from a matcher that compares rather than tests: equality has one, ``is_odd``
|
|
283
|
+
does not."""
|
|
284
|
+
|
|
285
|
+
def __bool__(self) -> bool:
|
|
286
|
+
"""The verdict, so a result reads the same way the ``matches()`` it can replace did."""
|
|
287
|
+
return self.matched
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# the options `is_equal_to` takes: with `strict_types` alone a spec could not say "close enough"
|
|
291
|
+
_EQUAL_TO_OPTIONS = frozenset({"ignore", "include", "tolerance", "rel", "comparators", "ignore_null"})
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class BaseMatcher:
|
|
295
|
+
"""Abstract base for all matchers with operator support.
|
|
296
|
+
|
|
297
|
+
A subclass implements either `matches()` or `evaluate()`, and gets the other one from here.
|
|
298
|
+
`matches()` is the cheap primitive: it is what ``==`` calls, and matchers are used as dict values in
|
|
299
|
+
`matches_structure` and as snapshot placeholders, so a comparison must not have to build a result
|
|
300
|
+
object. `evaluate()` is the whole answer, for a caller that would otherwise ask the same value
|
|
301
|
+
three questions in a row.
|
|
302
|
+
"""
|
|
303
|
+
|
|
304
|
+
reads_items: ClassVar[bool] = True
|
|
305
|
+
"""Whether a one-shot iterator is handed to this matcher as its items rather than as itself.
|
|
306
|
+
|
|
307
|
+
An iterator hands its items out once, so a matcher combined with others, or asked after an earlier
|
|
308
|
+
link of a chain read the value, would judge what is left of it. A matcher that reads the items gets
|
|
309
|
+
them in a list, the same list for every matcher and every link reading the same value. One that asks
|
|
310
|
+
about the value itself, its type, identity, length or an attribute, sets this to ``False`` and is
|
|
311
|
+
handed the iterator, as a predicate is. ``True`` by default: a matcher written before this was
|
|
312
|
+
handed the items by `satisfies()`, and goes on being handed them.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def matches(self, value: Any) -> bool:
|
|
316
|
+
if type(self).evaluate is BaseMatcher.evaluate:
|
|
317
|
+
_assertpy_refusal = True
|
|
318
|
+
raise NotImplementedError("a matcher must implement matches() or evaluate()")
|
|
319
|
+
return self.evaluate(value).matched
|
|
320
|
+
|
|
321
|
+
def evaluate(self, value: Any) -> MatchResult:
|
|
322
|
+
"""The verdict, the requirement and the reason, from one look at *value*.
|
|
323
|
+
|
|
324
|
+
The default composes the three older methods, so a matcher written before this existed answers
|
|
325
|
+
it without changing. Overriding it is for a matcher whose reason costs what the verdict already
|
|
326
|
+
paid for: the alternative is `matches()` and `describe_mismatch()` walking the same value twice,
|
|
327
|
+
which is how a matcher over a one-shot iterator used to name the wrong element.
|
|
328
|
+
"""
|
|
329
|
+
if type(self).matches is BaseMatcher.matches:
|
|
330
|
+
_assertpy_refusal = True
|
|
331
|
+
raise NotImplementedError("a matcher must implement matches() or evaluate()")
|
|
332
|
+
if verdict(self.matches(value), subject="the matcher"):
|
|
333
|
+
return MatchResult(matched=True, description=self.describe())
|
|
334
|
+
return _refused(self, value)
|
|
335
|
+
|
|
336
|
+
def describe(self) -> str:
|
|
337
|
+
_assertpy_refusal = True
|
|
338
|
+
raise NotImplementedError
|
|
339
|
+
|
|
340
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
341
|
+
return f"was <{_capped(value)}>"
|
|
342
|
+
|
|
343
|
+
def __and__(self, other: Matcher[Any]) -> AllOfMatcher:
|
|
344
|
+
_require_matcher(other, "&")
|
|
345
|
+
left = list(self.matchers) if isinstance(self, AllOfMatcher) else [self]
|
|
346
|
+
right = list(other.matchers) if isinstance(other, AllOfMatcher) else [other]
|
|
347
|
+
return AllOfMatcher(*left, *right)
|
|
348
|
+
|
|
349
|
+
def __or__(self, other: Matcher[Any]) -> AnyOfMatcher:
|
|
350
|
+
_require_matcher(other, "|")
|
|
351
|
+
left = list(self.matchers) if isinstance(self, AnyOfMatcher) else [self]
|
|
352
|
+
right = list(other.matchers) if isinstance(other, AnyOfMatcher) else [other]
|
|
353
|
+
return AnyOfMatcher(*left, *right)
|
|
354
|
+
|
|
355
|
+
def __invert__(self) -> NotMatcher:
|
|
356
|
+
return NotMatcher(self)
|
|
357
|
+
|
|
358
|
+
def __eq__(self, other: object) -> bool:
|
|
359
|
+
# an operand the predicate cannot evaluate means "no match"; ``==`` must never raise
|
|
360
|
+
try:
|
|
361
|
+
return bool(verdict(self.matches(other), subject="the matcher"))
|
|
362
|
+
except VerdictError: # never a non-match, always a mistake in the test
|
|
363
|
+
raise
|
|
364
|
+
except (TypeError, ValueError):
|
|
365
|
+
return False
|
|
366
|
+
|
|
367
|
+
def __hash__(self) -> int:
|
|
368
|
+
return id(self)
|
|
369
|
+
|
|
370
|
+
def __repr__(self) -> str:
|
|
371
|
+
return self.describe()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class _OfTheValueItself(BaseMatcher):
|
|
375
|
+
"""A matcher of this library that asks about the value itself and takes nothing from it.
|
|
376
|
+
|
|
377
|
+
Its type, identity, truthiness, length, an attribute, its equality to an operand. A one-shot
|
|
378
|
+
iterator is handed to it as it is, and without the guard a predicate gets, which refuses the links
|
|
379
|
+
after it once a part of the value was read: nothing here walks what it is handed. A subclass defined
|
|
380
|
+
elsewhere gets the guard, since its `matches()` may walk it, and so does one whose operand is code of
|
|
381
|
+
the caller's, an operand `==` hands the value to or a comparator (`_hands_the_value_on`).
|
|
382
|
+
"""
|
|
383
|
+
|
|
384
|
+
reads_items: ClassVar[bool] = False
|
|
385
|
+
|
|
386
|
+
def _hands_the_value_on(self) -> bool:
|
|
387
|
+
"""Whether code of the caller's is handed the value: the `__eq__` of an operand, or a comparator.
|
|
388
|
+
|
|
389
|
+
A built-in operand (`NON_MATCHER_TYPES`) compares a foreign value without reading it. Any other one,
|
|
390
|
+
a matcher among them, is asked through its own `__eq__`, which may walk the value.
|
|
391
|
+
"""
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
_UNREAD: Final = object()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _reads_items(matcher: object) -> bool:
|
|
399
|
+
"""Whether *matcher* is handed a one-shot value's items rather than the value: what `reads_items` says, else so."""
|
|
400
|
+
return getattr(type(matcher), "reads_items", True) is not False
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
class _ReadOnce:
|
|
404
|
+
"""The value one question is asked about, as each matcher asking it should see it.
|
|
405
|
+
|
|
406
|
+
A one-shot value handed whole to several matchers is consumed by the first of them, so the rest judge
|
|
407
|
+
what is left of it. Draining it for every one of them instead is also wrong: a type or identity check
|
|
408
|
+
would be asked about a list the caller never passed. So a matcher that reads items gets them, drained
|
|
409
|
+
at most once and the same list for every one of them, and one that asks about the value gets the value.
|
|
410
|
+
|
|
411
|
+
*items* supplies the items where the value is the subject of a chain, which keeps what every link
|
|
412
|
+
reads, and otherwise they are drained from the value. *itself* hands the value over to a matcher of
|
|
413
|
+
somebody else's that asks about it, which may still walk it, as a predicate may.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
__slots__ = ("_items", "_itself", "_supply", "value")
|
|
417
|
+
|
|
418
|
+
def __init__(
|
|
419
|
+
self, value: Any, items: Callable[[], Any] | None = None, itself: Callable[[], Any] | None = None
|
|
420
|
+
) -> None:
|
|
421
|
+
self.value = value
|
|
422
|
+
self._supply = items
|
|
423
|
+
self._itself = itself
|
|
424
|
+
self._items: Any = _UNREAD
|
|
425
|
+
|
|
426
|
+
def items(self) -> Any:
|
|
427
|
+
if self._items is _UNREAD:
|
|
428
|
+
self._items = materialized(self.value) if self._supply is None else self._supply()
|
|
429
|
+
return self._items
|
|
430
|
+
|
|
431
|
+
def handed(self, matcher: object) -> Any:
|
|
432
|
+
"""What a matcher that is not a composite is asked about."""
|
|
433
|
+
if _reads_items(matcher):
|
|
434
|
+
return self.items()
|
|
435
|
+
if self._itself is None or (
|
|
436
|
+
isinstance(matcher, _OfTheValueItself)
|
|
437
|
+
and type(matcher).__module__ == __name__
|
|
438
|
+
and not matcher._hands_the_value_on()
|
|
439
|
+
):
|
|
440
|
+
return self.value
|
|
441
|
+
return self._itself()
|
|
442
|
+
|
|
443
|
+
def shown(self) -> Any:
|
|
444
|
+
"""The value as a failure names it: its items once a matcher read them, the value itself before that."""
|
|
445
|
+
return self.value if self._items is _UNREAD else self._items
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _verdict_of(matcher: Matcher[Any], read: _ReadOnce) -> bool:
|
|
449
|
+
"""`matches()` of *matcher*, asked about the value *read* holds as that matcher should see it."""
|
|
450
|
+
if isinstance(matcher, _COMPOSITES) and _composed(matcher):
|
|
451
|
+
return matcher._holds(read)
|
|
452
|
+
return bool(verdict(matcher.matches(read.handed(matcher)), subject="the matcher"))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _outcome_of(matcher: Matcher[Any], read: _ReadOnce) -> MatchResult | None:
|
|
456
|
+
"""Why *matcher* refused the value *read* holds, as a result, or `None` where it held.
|
|
457
|
+
|
|
458
|
+
One look through `evaluate()` where the matcher has its own, and otherwise the verdict from
|
|
459
|
+
`matches()` and the reason after it, so a passing value builds no result at all.
|
|
460
|
+
"""
|
|
461
|
+
if _has_own_evaluate(matcher):
|
|
462
|
+
if isinstance(matcher, _COMPOSITES) and _composed(matcher):
|
|
463
|
+
outcome = matcher._evaluated(read)
|
|
464
|
+
else:
|
|
465
|
+
outcome = cast("BaseMatcher", matcher).evaluate(read.handed(matcher))
|
|
466
|
+
return None if outcome.matched else outcome
|
|
467
|
+
if _verdict_of(matcher, read):
|
|
468
|
+
return None
|
|
469
|
+
if isinstance(matcher, _COMPOSITES) and _composed(matcher):
|
|
470
|
+
return matcher._refusal(read)
|
|
471
|
+
return _refused(matcher, read.handed(matcher))
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _failure(matcher: Matcher[Any], read: _ReadOnce) -> str | None:
|
|
475
|
+
"""Why *matcher* turned the value *read* holds down, or `None` when it held.
|
|
476
|
+
|
|
477
|
+
What this recovers is the reason. The composites used to list what a failing child *requires* and
|
|
478
|
+
drop what the child had computed about the value, so `an instance of <str>` arrived where the child
|
|
479
|
+
would have said `was <50> of type <int>`. A composite answers for its own failing part, so a nested
|
|
480
|
+
one reaches a leaf rather than restating a sub-expression `describe()` has already shown.
|
|
481
|
+
|
|
482
|
+
One look per leaf, through `evaluate()` where the leaf has its own, for the reason in `_refused`: a
|
|
483
|
+
matcher over a one-shot value answers a second call from what the first walk left.
|
|
484
|
+
"""
|
|
485
|
+
if isinstance(matcher, _COMPOSITES) and _composed(matcher):
|
|
486
|
+
held, failure = matcher._decided(read)
|
|
487
|
+
return None if held else failure or matcher.describe()
|
|
488
|
+
value = read.handed(matcher)
|
|
489
|
+
if _has_own_evaluate(matcher):
|
|
490
|
+
outcome = cast("BaseMatcher", matcher).evaluate(value)
|
|
491
|
+
elif verdict(matcher.matches(value), subject="the matcher"):
|
|
492
|
+
return None
|
|
493
|
+
else:
|
|
494
|
+
outcome = _refused(matcher, value)
|
|
495
|
+
return None if outcome.matched else f"{outcome.description} ({outcome.mismatch})"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class AllOfMatcher(BaseMatcher):
|
|
499
|
+
"""Matches when all sub-matchers match (``&`` operator)."""
|
|
500
|
+
|
|
501
|
+
def __init__(self, *matchers: Matcher[Any]):
|
|
502
|
+
for one in matchers:
|
|
503
|
+
_require_matcher_argument(one)
|
|
504
|
+
self.matchers: tuple[Matcher[Any], ...] = matchers
|
|
505
|
+
self.walks_its_value: bool = any(_has_own_evaluate(one) for one in matchers)
|
|
506
|
+
"""Whether anything under here has to be asked once, which is what `_has_own_evaluate` reads."""
|
|
507
|
+
|
|
508
|
+
def matches(self, value: Any) -> bool:
|
|
509
|
+
return self._holds(_ReadOnce(value))
|
|
510
|
+
|
|
511
|
+
def _holds(self, read: _ReadOnce) -> bool:
|
|
512
|
+
return all(_verdict_of(matcher, read) for matcher in self.matchers)
|
|
513
|
+
|
|
514
|
+
def describe(self) -> str:
|
|
515
|
+
return f"({' and '.join(matcher.describe() for matcher in self.matchers)})"
|
|
516
|
+
|
|
517
|
+
def _decided(self, read: _ReadOnce) -> tuple[bool, str]:
|
|
518
|
+
"""The verdict and the reason from one pass, so the two cannot disagree.
|
|
519
|
+
|
|
520
|
+
Deriving the verdict from whether there was a reason instead read a satisfied child as a failure
|
|
521
|
+
and, on a matcher answering two calls differently, an unsatisfied one as a pass.
|
|
522
|
+
"""
|
|
523
|
+
failed = [why for one in self.matchers if (why := _failure(one, read)) is not None]
|
|
524
|
+
return not failed, ", ".join(failed)
|
|
525
|
+
|
|
526
|
+
def evaluate(self, value: Any) -> MatchResult:
|
|
527
|
+
return self._evaluated(_ReadOnce(value))
|
|
528
|
+
|
|
529
|
+
def _evaluated(self, read: _ReadOnce) -> MatchResult:
|
|
530
|
+
held, failure = self._decided(read)
|
|
531
|
+
if held:
|
|
532
|
+
return MatchResult(matched=True, description=self.describe())
|
|
533
|
+
return MatchResult(matched=False, description=self.describe(), mismatch=self._line(read.shown(), failure))
|
|
534
|
+
|
|
535
|
+
def _refusal(self, read: _ReadOnce) -> MatchResult:
|
|
536
|
+
"""The result for a value already refused, with the reason a second look gives, or the requirement."""
|
|
537
|
+
return MatchResult(
|
|
538
|
+
matched=False, description=self.describe(), mismatch=self._line(read.shown(), self._decided(read)[1])
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
542
|
+
return self._refusal(_ReadOnce(value)).mismatch
|
|
543
|
+
|
|
544
|
+
def _line(self, value: Any, failure: str) -> str:
|
|
545
|
+
return f"<{_capped(value)}> did not satisfy: {failure or self.describe()}"
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class AnyOfMatcher(BaseMatcher):
|
|
549
|
+
"""Matches when at least one sub-matcher matches (``|`` operator)."""
|
|
550
|
+
|
|
551
|
+
def __init__(self, *matchers: Matcher[Any]):
|
|
552
|
+
for one in matchers:
|
|
553
|
+
_require_matcher_argument(one)
|
|
554
|
+
self.matchers: tuple[Matcher[Any], ...] = matchers
|
|
555
|
+
self.walks_its_value: bool = any(_has_own_evaluate(one) for one in matchers)
|
|
556
|
+
"""Whether anything under here has to be asked once, which is what `_has_own_evaluate` reads."""
|
|
557
|
+
|
|
558
|
+
def matches(self, value: Any) -> bool:
|
|
559
|
+
return self._holds(_ReadOnce(value))
|
|
560
|
+
|
|
561
|
+
def _holds(self, read: _ReadOnce) -> bool:
|
|
562
|
+
return any(_verdict_of(matcher, read) for matcher in self.matchers)
|
|
563
|
+
|
|
564
|
+
def describe(self) -> str:
|
|
565
|
+
return f"({' or '.join(matcher.describe() for matcher in self.matchers)})"
|
|
566
|
+
|
|
567
|
+
def _decided(self, read: _ReadOnce) -> tuple[bool, str]:
|
|
568
|
+
"""Held as soon as one alternative holds, and otherwise every alternative with its own reason."""
|
|
569
|
+
reasons = []
|
|
570
|
+
for one in self.matchers:
|
|
571
|
+
why = _failure(one, read)
|
|
572
|
+
if why is None:
|
|
573
|
+
return True, ""
|
|
574
|
+
reasons.append(why)
|
|
575
|
+
return False, " or ".join(reasons)
|
|
576
|
+
|
|
577
|
+
def evaluate(self, value: Any) -> MatchResult:
|
|
578
|
+
return self._evaluated(_ReadOnce(value))
|
|
579
|
+
|
|
580
|
+
def _evaluated(self, read: _ReadOnce) -> MatchResult:
|
|
581
|
+
held, failure = self._decided(read)
|
|
582
|
+
if held:
|
|
583
|
+
return MatchResult(matched=True, description=self.describe())
|
|
584
|
+
return MatchResult(matched=False, description=self.describe(), mismatch=self._line(read.shown(), failure))
|
|
585
|
+
|
|
586
|
+
def _refusal(self, read: _ReadOnce) -> MatchResult:
|
|
587
|
+
"""The result for a value already refused, with the reason a second look gives, or the requirement."""
|
|
588
|
+
return MatchResult(
|
|
589
|
+
matched=False, description=self.describe(), mismatch=self._line(read.shown(), self._decided(read)[1])
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
593
|
+
return self._refusal(_ReadOnce(value)).mismatch
|
|
594
|
+
|
|
595
|
+
def _line(self, value: Any, failure: str) -> str:
|
|
596
|
+
return f"<{_capped(value)}> satisfied none of: {failure or self.describe()}"
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
class NotMatcher(BaseMatcher):
|
|
600
|
+
"""Matches when the wrapped matcher does not match (``~`` operator)."""
|
|
601
|
+
|
|
602
|
+
def __init__(self, matcher: Matcher[Any]):
|
|
603
|
+
self.matcher: Matcher[Any] = _require_matcher_argument(matcher)
|
|
604
|
+
self.walks_its_value: bool = _has_own_evaluate(matcher)
|
|
605
|
+
"""Whether the wrapped matcher has to be asked once, which is what `_has_own_evaluate` reads."""
|
|
606
|
+
|
|
607
|
+
def matches(self, value: Any) -> bool:
|
|
608
|
+
return self._holds(_ReadOnce(value))
|
|
609
|
+
|
|
610
|
+
def _holds(self, read: _ReadOnce) -> bool:
|
|
611
|
+
return not _verdict_of(self.matcher, read)
|
|
612
|
+
|
|
613
|
+
def describe(self) -> str:
|
|
614
|
+
return f"not {self.matcher.describe()}"
|
|
615
|
+
|
|
616
|
+
def _decided(self, read: _ReadOnce) -> tuple[bool, str]:
|
|
617
|
+
"""Held when the wrapped matcher refused. What failed, otherwise, is that the wrapped one held."""
|
|
618
|
+
held = _failure(self.matcher, read) is not None
|
|
619
|
+
return held, "" if held else f"{self.describe()} (it matched)"
|
|
620
|
+
|
|
621
|
+
def evaluate(self, value: Any) -> MatchResult:
|
|
622
|
+
return self._evaluated(_ReadOnce(value))
|
|
623
|
+
|
|
624
|
+
def _evaluated(self, read: _ReadOnce) -> MatchResult:
|
|
625
|
+
if self._decided(read)[0]:
|
|
626
|
+
return MatchResult(matched=True, description=self.describe())
|
|
627
|
+
return self._refusal(read)
|
|
628
|
+
|
|
629
|
+
def _refusal(self, read: _ReadOnce) -> MatchResult:
|
|
630
|
+
"""The result for a value already refused: the wrapped matcher held, which needs no second look."""
|
|
631
|
+
return MatchResult(matched=False, description=self.describe(), mismatch=self._line(read.shown()))
|
|
632
|
+
|
|
633
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
634
|
+
return self._line(value)
|
|
635
|
+
|
|
636
|
+
def _line(self, value: Any) -> str:
|
|
637
|
+
return f"<{_capped(value)}> unexpectedly matched {self.matcher.describe()}"
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
_COMPOSITES: Final = (AllOfMatcher, AnyOfMatcher, NotMatcher)
|
|
641
|
+
"""The matchers built from others: they are handed the reader of a value rather than the value, and pass it on."""
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _composed(matcher: AllOfMatcher | AnyOfMatcher | NotMatcher) -> bool:
|
|
645
|
+
"""Whether a composite answers with a composite's own `matches()` and `evaluate()`: one of them, or a subclass
|
|
646
|
+
that overrides neither, which is handed the reader as they are. One that overrides either is asked as it is,
|
|
647
|
+
told by identity: a wrapper `functools.wraps` makes carries the composite's module and name."""
|
|
648
|
+
kind = type(matcher)
|
|
649
|
+
if kind in _COMPOSITES:
|
|
650
|
+
return True
|
|
651
|
+
composite = next(base for base in _COMPOSITES if isinstance(matcher, base))
|
|
652
|
+
return kind.matches is composite.matches and kind.evaluate is composite.evaluate
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
class EqualToMatcher(_OfTheValueItself):
|
|
656
|
+
"""Equality, optionally requiring the same type.
|
|
657
|
+
|
|
658
|
+
``strict_types`` is the same relation ``is_equal_to(..., strict_types=True)`` applies, spelled for a
|
|
659
|
+
structural spec: one matcher covering value and type, rather than an `IsTypeOfMatcher` combined with
|
|
660
|
+
a second matcher on every field that needs both.
|
|
661
|
+
"""
|
|
662
|
+
|
|
663
|
+
def __init__(self, expected: object, strict_types: bool = False, **options: object):
|
|
664
|
+
if options: # the plain `equal_to(value)` builds nothing: it is constructed inside loops
|
|
665
|
+
reject_unknown_kwargs(options, _EQUAL_TO_OPTIONS, "equal_to")
|
|
666
|
+
self.expected = expected
|
|
667
|
+
self.strict_types = strict_types
|
|
668
|
+
self.ignore: object = options.get("ignore")
|
|
669
|
+
self.include: object = options.get("include")
|
|
670
|
+
# `equal_to(value)` alone is what `matches_structure` and the loops use, so it is decided once here
|
|
671
|
+
self.guarded: bool = not (strict_types or options) and _sequence_or_numpy(expected)
|
|
672
|
+
self.plain: bool = not (strict_types or options or self.guarded)
|
|
673
|
+
self.config: _CompareConfig | None = (
|
|
674
|
+
_build_compare_config(
|
|
675
|
+
options.get("tolerance"),
|
|
676
|
+
options.get("comparators"),
|
|
677
|
+
bool(options.get("ignore_null", False)),
|
|
678
|
+
strict_types,
|
|
679
|
+
options.get("rel"),
|
|
680
|
+
)
|
|
681
|
+
if options or strict_types
|
|
682
|
+
else None
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
def _hands_the_value_on(self) -> bool:
|
|
686
|
+
return type(self.expected) not in NON_MATCHER_TYPES or bool(self.config is not None and self.config.comparators)
|
|
687
|
+
|
|
688
|
+
def matches(self, value: Any) -> bool:
|
|
689
|
+
if self.plain:
|
|
690
|
+
try:
|
|
691
|
+
return bool(value == self.expected) # the hot path stays a plain comparison
|
|
692
|
+
except REFUSALS as refusal:
|
|
693
|
+
return equal_past(value, self.expected, refusal)
|
|
694
|
+
if self.guarded:
|
|
695
|
+
return equals(value, self.expected)
|
|
696
|
+
return self._configured_match(value)
|
|
697
|
+
|
|
698
|
+
def _configured_match(self, value: Any) -> bool:
|
|
699
|
+
"""The comparison under options: a comparator owning the root, strict types, a key filter, then the walk."""
|
|
700
|
+
if self.config is not None and _resolve_comparator(value, self.config, field=None) is not None:
|
|
701
|
+
return not values_differ(value, self.expected, self.config, at_root=True)
|
|
702
|
+
if self.strict_types and type(value) is not type(self.expected):
|
|
703
|
+
return False
|
|
704
|
+
if mapping_shaped(value, check_values=False) and mapping_shaped(self.expected, check_values=False):
|
|
705
|
+
try:
|
|
706
|
+
return not mapping_differs(
|
|
707
|
+
value, self.expected, ignore=self.ignore, include=self.include, config=self.config
|
|
708
|
+
)
|
|
709
|
+
except IncludeKeysMissingError:
|
|
710
|
+
return False # an include naming an absent key: a mistake in the spec, so nothing matches
|
|
711
|
+
if self.strict_types and _keyed_types_differ(value, self.expected):
|
|
712
|
+
# the flag walks to the leaves: a composite whose `==` is true says nothing about the types inside
|
|
713
|
+
return False # a set the walker does not decompose, and a mapping key it walks straight past
|
|
714
|
+
if key_specs_given(self.ignore) or key_specs_given(self.include):
|
|
715
|
+
try:
|
|
716
|
+
return not filtered_differs(
|
|
717
|
+
value, self.expected, ignore=self.ignore, include=self.include, config=self.config
|
|
718
|
+
)
|
|
719
|
+
except IncludeKeysMissingError:
|
|
720
|
+
return False
|
|
721
|
+
return not values_differ(value, self.expected, self.config, at_root=True)
|
|
722
|
+
|
|
723
|
+
def _settings(self) -> str:
|
|
724
|
+
"""The comparison settings in force, in the same words the assertion uses for them.
|
|
725
|
+
|
|
726
|
+
Without this the matcher computed with a tolerance and then reported a plain "not equal", so a
|
|
727
|
+
reader questioning why a field they thought was tolerated still failed had nothing to read.
|
|
728
|
+
`_config_note` renders the same line for `is_equal_to`; `ignore`/`include` are named here too,
|
|
729
|
+
because the assertion puts them in its own sentence and a matcher has no sentence to put them in.
|
|
730
|
+
"""
|
|
731
|
+
selectors = []
|
|
732
|
+
if self.ignore is not None:
|
|
733
|
+
selectors.append(f"ignoring {self.ignore!r}")
|
|
734
|
+
if self.include is not None:
|
|
735
|
+
selectors.append(f"including {self.include!r}")
|
|
736
|
+
note = _config_note(self.config).strip()
|
|
737
|
+
parts = [*selectors, note.removeprefix("compared with ")] if note else selectors
|
|
738
|
+
return f" ({', '.join(parts)})" if parts else ""
|
|
739
|
+
|
|
740
|
+
def describe(self) -> str:
|
|
741
|
+
if self.strict_types:
|
|
742
|
+
return (
|
|
743
|
+
f"a value equal to <{_capped_format(self.expected)}>"
|
|
744
|
+
f" of type <{type(self.expected).__name__}>{self._settings()}"
|
|
745
|
+
)
|
|
746
|
+
return f"a value equal to <{_capped_format(self.expected)}>{self._settings()}"
|
|
747
|
+
|
|
748
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
749
|
+
if self.strict_types and type(value) is not type(self.expected):
|
|
750
|
+
return f"was <{_capped(value)}> of type <{type(value).__name__}>"
|
|
751
|
+
return f"was <{_capped(value)}>"
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
class GreaterThanMatcher(_OfTheValueItself):
|
|
755
|
+
def __init__(self, boundary: object):
|
|
756
|
+
self.boundary = boundary
|
|
757
|
+
|
|
758
|
+
def matches(self, value: Any) -> bool:
|
|
759
|
+
try:
|
|
760
|
+
return holds(value, self.boundary, "gt")
|
|
761
|
+
except UnorderableError:
|
|
762
|
+
return False # an operand this cannot be ordered against is a non-match, not an error
|
|
763
|
+
|
|
764
|
+
def describe(self) -> str:
|
|
765
|
+
return f"a value greater than <{_capped_format(self.boundary)}>"
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
class GreaterThanOrEqualToMatcher(_OfTheValueItself):
|
|
769
|
+
def __init__(self, boundary: object):
|
|
770
|
+
self.boundary = boundary
|
|
771
|
+
|
|
772
|
+
def matches(self, value: Any) -> bool:
|
|
773
|
+
try:
|
|
774
|
+
return holds(value, self.boundary, "ge")
|
|
775
|
+
except UnorderableError:
|
|
776
|
+
return False # an operand this cannot be ordered against is a non-match, not an error
|
|
777
|
+
|
|
778
|
+
def describe(self) -> str:
|
|
779
|
+
return f"a value greater than or equal to <{_capped_format(self.boundary)}>"
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
class LessThanMatcher(_OfTheValueItself):
|
|
783
|
+
def __init__(self, boundary: object):
|
|
784
|
+
self.boundary = boundary
|
|
785
|
+
|
|
786
|
+
def matches(self, value: Any) -> bool:
|
|
787
|
+
try:
|
|
788
|
+
return holds(value, self.boundary, "lt")
|
|
789
|
+
except UnorderableError:
|
|
790
|
+
return False # an operand this cannot be ordered against is a non-match, not an error
|
|
791
|
+
|
|
792
|
+
def describe(self) -> str:
|
|
793
|
+
return f"a value less than <{_capped_format(self.boundary)}>"
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
class LessThanOrEqualToMatcher(_OfTheValueItself):
|
|
797
|
+
def __init__(self, boundary: object):
|
|
798
|
+
self.boundary = boundary
|
|
799
|
+
|
|
800
|
+
def matches(self, value: Any) -> bool:
|
|
801
|
+
try:
|
|
802
|
+
return holds(value, self.boundary, "le")
|
|
803
|
+
except UnorderableError:
|
|
804
|
+
return False # an operand this cannot be ordered against is a non-match, not an error
|
|
805
|
+
|
|
806
|
+
def describe(self) -> str:
|
|
807
|
+
return f"a value less than or equal to <{_capped_format(self.boundary)}>"
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _sequence_or_numpy(value: object) -> bool:
|
|
811
|
+
"""Whether *value* can meet a `numpy` scalar's element-wise ``==``: a list or a tuple, or a `numpy` scalar.
|
|
812
|
+
|
|
813
|
+
Decided when the matcher is built, so every other value keeps the plain comparison as its hot path. A list or a
|
|
814
|
+
tuple counts whether or not `numpy` is loaded yet; a `numpy` scalar, of any subclass, exists only once it is.
|
|
815
|
+
"""
|
|
816
|
+
return issubclass(type(value), (list, tuple)) or may_broadcast(value)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _ordered_kind(value: object) -> str | None:
|
|
820
|
+
"""``"plain"`` or ``"duration"`` where asking a value's order at construction runs no code of anybody's.
|
|
821
|
+
|
|
822
|
+
A builtin's order is plain, and so is a `numpy` number's, whose tolerance below zero was never refused and
|
|
823
|
+
covered every distance. A `numpy` duration orders only against another.
|
|
824
|
+
"""
|
|
825
|
+
kind = id(type(value))
|
|
826
|
+
if kind in _BUILT_IN_ORDERED:
|
|
827
|
+
return "plain"
|
|
828
|
+
numpy = sys.modules.get("numpy")
|
|
829
|
+
if numpy is None or kind not in _numpy_ordered_types(numpy):
|
|
830
|
+
return None
|
|
831
|
+
return "duration" if type(value) is numpy.timedelta64 else "plain"
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
_BUILT_IN_ORDERED = frozenset(map(id, (int, float, Decimal, Fraction, datetime, date, time, timedelta)))
|
|
835
|
+
"""By id: a membership test of the classes themselves asks a metaclass's `__eq__`, which is anybody's code."""
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
@functools.cache
|
|
839
|
+
def _numpy_ordered_types(numpy: Any) -> frozenset[int]:
|
|
840
|
+
"""The ids of `numpy`'s own integer, floating and duration scalar types.
|
|
841
|
+
|
|
842
|
+
Told by identity, which neither a subclass naming its module `numpy` nor a metaclass answering attribute
|
|
843
|
+
reads can claim, and without hashing a class of anybody's.
|
|
844
|
+
"""
|
|
845
|
+
codes = numpy.typecodes["AllInteger"] + numpy.typecodes["Float"] + "m"
|
|
846
|
+
return frozenset(id(numpy.dtype(code).type) for code in codes)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _swapped(low: object, high: object) -> bool:
|
|
850
|
+
"""Whether these two are the wrong way round, asked only where the answer costs nothing.
|
|
851
|
+
|
|
852
|
+
Two values may each order plainly and still not order against each other, a `datetime` against a
|
|
853
|
+
`date` among them, and a pair with no ordering is judged where it is used rather than here.
|
|
854
|
+
"""
|
|
855
|
+
low_kind = _ordered_kind(low)
|
|
856
|
+
if low_kind is None or low_kind != _ordered_kind(high):
|
|
857
|
+
return False
|
|
858
|
+
try:
|
|
859
|
+
return holds(low, high, "gt")
|
|
860
|
+
except UnorderableError:
|
|
861
|
+
return False
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
class BetweenMatcher(_OfTheValueItself):
|
|
865
|
+
def __init__(self, low: object, high: object):
|
|
866
|
+
if _swapped(low, high):
|
|
867
|
+
_assertpy_refusal = True
|
|
868
|
+
raise ValueError("given low arg must be less than given high arg")
|
|
869
|
+
self.low = low
|
|
870
|
+
self.high = high
|
|
871
|
+
|
|
872
|
+
def matches(self, value: Any) -> bool:
|
|
873
|
+
try:
|
|
874
|
+
return holds(value, self.low, "ge") and holds(value, self.high, "le")
|
|
875
|
+
except UnorderableError:
|
|
876
|
+
return False # an operand this cannot be ordered against is a non-match, not an error
|
|
877
|
+
|
|
878
|
+
def describe(self) -> str:
|
|
879
|
+
return f"a value between <{_capped_format(self.low)}> and <{_capped_format(self.high)}>"
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _measured_under(operand: object, tolerance: object, duration: bool) -> bool:
|
|
883
|
+
"""Whether `is_close_to` measures *operand* under *tolerance*: a datetime under a duration, else a number.
|
|
884
|
+
|
|
885
|
+
Any number but a complex one or a `bool`, as `is_close_to` reads it, and a `numpy` duration only under
|
|
886
|
+
another, which *duration* says the tolerance is. Outside that domain an equal pair would count as close,
|
|
887
|
+
since equality answers before any distance does.
|
|
888
|
+
"""
|
|
889
|
+
if duration:
|
|
890
|
+
return numpy_duration(operand)
|
|
891
|
+
if isinstance(tolerance, timedelta):
|
|
892
|
+
return isinstance(operand, datetime)
|
|
893
|
+
return type(operand) in (int, float) or _is_real_number(operand)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
class CloseToMatcher(_OfTheValueItself):
|
|
897
|
+
def __init__(self, expected: object, tolerance: object = None, rel: object = None):
|
|
898
|
+
if tolerance is None and rel is None:
|
|
899
|
+
refuse(tolerance, "a number, or a rel given instead", subject=argument("tolerance"))
|
|
900
|
+
if tolerance is not None:
|
|
901
|
+
zero = timedelta(0) if isinstance(tolerance, timedelta) else zero_of(tolerance)
|
|
902
|
+
if _swapped(zero, tolerance):
|
|
903
|
+
_assertpy_refusal = True
|
|
904
|
+
raise ValueError("given tolerance arg must not be negative")
|
|
905
|
+
self._duration: bool = type(tolerance) not in (int, float) and numpy_duration(tolerance)
|
|
906
|
+
if rel is not None:
|
|
907
|
+
require_rel(rel)
|
|
908
|
+
if isinstance(tolerance, timedelta) or self._duration:
|
|
909
|
+
refuse(rel, "None beside a duration, which has no size to take a fraction of", subject=argument("rel"))
|
|
910
|
+
self.expected = expected
|
|
911
|
+
self.tolerance = tolerance
|
|
912
|
+
self.rel = rel
|
|
913
|
+
# a tolerance that is no ordered distance has nothing within it, not even the value itself
|
|
914
|
+
self._measures = (
|
|
915
|
+
(tolerance is None or _is_real_number(tolerance) or isinstance(tolerance, timedelta) or self._duration)
|
|
916
|
+
and _non_finite(tolerance) != "nan"
|
|
917
|
+
and _measured_under(expected, tolerance, self._duration)
|
|
918
|
+
)
|
|
919
|
+
self._plain = (
|
|
920
|
+
self._measures and rel is None and type(expected) in (int, float) and type(tolerance) in (int, float)
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
def matches(self, value: Any) -> bool:
|
|
924
|
+
if self._plain and type(value) in (int, float):
|
|
925
|
+
within = plainly_within(value, self.expected, self.tolerance)
|
|
926
|
+
if within is not None:
|
|
927
|
+
return within
|
|
928
|
+
if not self._measures or not _measured_under(value, self.tolerance, self._duration):
|
|
929
|
+
return False
|
|
930
|
+
try:
|
|
931
|
+
return within_either(value, self.expected, self.tolerance, self.rel)
|
|
932
|
+
except UnorderableError:
|
|
933
|
+
return False
|
|
934
|
+
|
|
935
|
+
def describe(self) -> str:
|
|
936
|
+
relative = f"relative tolerance <{_capped_format(self.rel)}>"
|
|
937
|
+
if self.tolerance is None:
|
|
938
|
+
return f"a value within {relative} of <{_capped_format(self.expected)}>"
|
|
939
|
+
absolute = f"<{_capped_format(self.tolerance)}>"
|
|
940
|
+
within = absolute if self.rel is None else f"{absolute} or {relative}"
|
|
941
|
+
return f"a value within {within} of <{_capped_format(self.expected)}>"
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
class IsNoneMatcher(_OfTheValueItself):
|
|
945
|
+
def matches(self, value: Any) -> bool:
|
|
946
|
+
return value is None
|
|
947
|
+
|
|
948
|
+
def describe(self) -> str:
|
|
949
|
+
return "None"
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
class IsNotNoneMatcher(_OfTheValueItself):
|
|
953
|
+
def matches(self, value: Any) -> bool:
|
|
954
|
+
return value is not None
|
|
955
|
+
|
|
956
|
+
def describe(self) -> str:
|
|
957
|
+
return "a non-None value"
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
_UNION_ORIGINS: Final = (Union, UnionType)
|
|
961
|
+
"""What `get_origin` answers for a union, which is one class on 3.14+ and two below it."""
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _require_class_info(
|
|
965
|
+
expected: object,
|
|
966
|
+
*,
|
|
967
|
+
name: str,
|
|
968
|
+
expectation: str = "a class",
|
|
969
|
+
probe: object = None,
|
|
970
|
+
check: Callable[[Any, Any], bool] = isinstance,
|
|
971
|
+
) -> None:
|
|
972
|
+
"""Refuse what *check* cannot take, member by member, since it stops at the first match.
|
|
973
|
+
|
|
974
|
+
A union or a tuple holding `list[str]` is otherwise answered by whichever member comes first: taken
|
|
975
|
+
when an earlier one matches, a `TypeError` when none does, and on 3.10 a union refused either way.
|
|
976
|
+
*check* is `isinstance`, or `issubclass`, which refuses more: a runtime protocol with data members
|
|
977
|
+
answers `isinstance` and not `issubclass`.
|
|
978
|
+
|
|
979
|
+
Every member is asked `check(probe, member)`, left to right as *check* reads them, so each one meets
|
|
980
|
+
only a value it would have met had the members before it not matched. An assertion passes the value
|
|
981
|
+
it holds. A matcher has none yet and passes `None`. Asking every member is the point, so a member
|
|
982
|
+
after one that matched is asked too, and a member that is not a plain class is asked again by the
|
|
983
|
+
check that follows. Answering from these probes instead would change the answer: below 3.14
|
|
984
|
+
`isinstance(x, Optional[int])` reads `issubclass(type(x), int)`, which a `Mock(spec=int)` fails and
|
|
985
|
+
`int | None` passes.
|
|
986
|
+
|
|
987
|
+
Unions and tuples are flattened to any depth, both union spellings included: below 3.14
|
|
988
|
+
`typing.Union[A, B]` is not a `types.UnionType`, so `get_origin` reads it. A tuple is read from its
|
|
989
|
+
storage, as *check* reads it, never through an `__iter__` a subclass overrides. A class whose
|
|
990
|
+
metaclass is `type` itself is not asked, since nothing in it can override either hook. The
|
|
991
|
+
walk keeps its own stack so that every probe runs in this frame, which is the frame `raised_inside`
|
|
992
|
+
reads. A recursive flatten probing every member took `is_instance_of((int, str))` from 0.19 to
|
|
993
|
+
0.72 us, and this walk takes it to 0.48.
|
|
994
|
+
"""
|
|
995
|
+
pending: list[Any] = [expected]
|
|
996
|
+
try:
|
|
997
|
+
while pending:
|
|
998
|
+
member = pending.pop()
|
|
999
|
+
if type(member) is type:
|
|
1000
|
+
continue
|
|
1001
|
+
if isinstance(member, tuple):
|
|
1002
|
+
pending.extend(tuple.__getitem__(member, slice(None, None, -1)))
|
|
1003
|
+
elif isinstance(member, UnionType) or get_origin(member) in _UNION_ORIGINS:
|
|
1004
|
+
pending.extend(member.__args__[::-1])
|
|
1005
|
+
else:
|
|
1006
|
+
check(probe, member)
|
|
1007
|
+
except TypeError as exc:
|
|
1008
|
+
if raised_inside(exc): # their operator raised: that is a bug in the value, not a non-match
|
|
1009
|
+
raise
|
|
1010
|
+
refuse(expected, expectation, subject=argument(name))
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
class IsInstanceOfMatcher(_OfTheValueItself):
|
|
1014
|
+
def __init__(self, expected_type: ClassInfo):
|
|
1015
|
+
# eagerly, like the regex matcher
|
|
1016
|
+
_require_class_info(expected_type, name="class")
|
|
1017
|
+
self.expected_type = expected_type
|
|
1018
|
+
|
|
1019
|
+
def matches(self, value: Any) -> bool:
|
|
1020
|
+
return isinstance(value, self.expected_type)
|
|
1021
|
+
|
|
1022
|
+
def describe(self) -> str:
|
|
1023
|
+
return f"an instance of <{_type_expression_name(self.expected_type)}>"
|
|
1024
|
+
|
|
1025
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1026
|
+
return f"was <{_capped(value)}> of type <{type(value).__name__}>"
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
class IsTypeOfMatcher(_OfTheValueItself):
|
|
1030
|
+
"""Exact type, mirroring the ``is_type_of`` assertion.
|
|
1031
|
+
|
|
1032
|
+
`IsInstanceOfMatcher` accepts a subclass, which makes it unable to say "an ``int``, not a ``bool``"
|
|
1033
|
+
- the distinction a structural spec needs most, since ``bool`` subclasses ``int`` and JSON turns
|
|
1034
|
+
one into the other.
|
|
1035
|
+
"""
|
|
1036
|
+
|
|
1037
|
+
def __init__(self, expected_type: type):
|
|
1038
|
+
# `type(value) is <a union>` is never true, so accepting one makes a matcher that silently never matches
|
|
1039
|
+
if type(expected_type) is not type and not issubclass(type(expected_type), type):
|
|
1040
|
+
refuse(expected_type, "a type", subject=argument("type"))
|
|
1041
|
+
self.expected_type = expected_type
|
|
1042
|
+
|
|
1043
|
+
def matches(self, value: Any) -> bool:
|
|
1044
|
+
return type(value) is self.expected_type
|
|
1045
|
+
|
|
1046
|
+
def describe(self) -> str:
|
|
1047
|
+
return f"exactly type <{_type_expression_name(self.expected_type)}>"
|
|
1048
|
+
|
|
1049
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1050
|
+
return f"was <{_capped(value)}> of type <{type(value).__name__}>"
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
class IsTruthyMatcher(_OfTheValueItself):
|
|
1054
|
+
def matches(self, value: Any) -> bool:
|
|
1055
|
+
return bool(value)
|
|
1056
|
+
|
|
1057
|
+
def describe(self) -> str:
|
|
1058
|
+
return "a truthy value"
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
class IsFalsyMatcher(_OfTheValueItself):
|
|
1062
|
+
def matches(self, value: Any) -> bool:
|
|
1063
|
+
return not bool(value)
|
|
1064
|
+
|
|
1065
|
+
def describe(self) -> str:
|
|
1066
|
+
return "a falsy value"
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
class HasLengthMatcher(_OfTheValueItself):
|
|
1070
|
+
def __init__(self, expected_length: SupportsIndex | Matcher[int]):
|
|
1071
|
+
# a matcher is asked about the number `len()` gives, so a bound or a range is one matcher
|
|
1072
|
+
self.of_length: Matcher[int] | None = expected_length if _is_matcher(expected_length) else None
|
|
1073
|
+
if self.of_length is None:
|
|
1074
|
+
self.expected_length: int = require_integer(expected_length, "length")
|
|
1075
|
+
if self.expected_length < 0:
|
|
1076
|
+
_assertpy_refusal = True
|
|
1077
|
+
raise ValueError("given arg must be a positive int")
|
|
1078
|
+
|
|
1079
|
+
def matches(self, value: Any) -> bool:
|
|
1080
|
+
# the core answers `None` for no length, and a raising `__len__` travels out rather than being swallowed
|
|
1081
|
+
length = length_of(value)
|
|
1082
|
+
if self.of_length is None:
|
|
1083
|
+
return length == self.expected_length
|
|
1084
|
+
return length is not None and _verdict_of(self.of_length, _ReadOnce(length))
|
|
1085
|
+
|
|
1086
|
+
def describe(self) -> str:
|
|
1087
|
+
if self.of_length is not None:
|
|
1088
|
+
return f"a value with a length that is {self.of_length.describe()}"
|
|
1089
|
+
return f"a value of length <{self.expected_length}>"
|
|
1090
|
+
|
|
1091
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1092
|
+
length = length_of(value)
|
|
1093
|
+
if length is None:
|
|
1094
|
+
return f"was <{_capped_repr(value)}>, which has no length"
|
|
1095
|
+
return f"was <{_capped(value)}> with length <{length}>"
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
class IsEmptyMatcher(_OfTheValueItself):
|
|
1099
|
+
def matches(self, value: Any) -> bool:
|
|
1100
|
+
return length_of(value) == 0
|
|
1101
|
+
|
|
1102
|
+
def describe(self) -> str:
|
|
1103
|
+
return "an empty value"
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
class IsNotEmptyMatcher(_OfTheValueItself):
|
|
1107
|
+
def matches(self, value: Any) -> bool:
|
|
1108
|
+
length = length_of(value)
|
|
1109
|
+
return length is not None and length > 0
|
|
1110
|
+
|
|
1111
|
+
def describe(self) -> str:
|
|
1112
|
+
return "a non-empty value"
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
class IsPositiveMatcher(_OfTheValueItself):
|
|
1116
|
+
def matches(self, value: Any) -> bool:
|
|
1117
|
+
# through the ordering engine, which answers for a NaN of either kind rather than signalling
|
|
1118
|
+
try:
|
|
1119
|
+
return holds(value, 0, "gt")
|
|
1120
|
+
except UnorderableError:
|
|
1121
|
+
return False
|
|
1122
|
+
|
|
1123
|
+
def describe(self) -> str:
|
|
1124
|
+
return "a positive value"
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
class IsNegativeMatcher(_OfTheValueItself):
|
|
1128
|
+
def matches(self, value: Any) -> bool:
|
|
1129
|
+
try:
|
|
1130
|
+
return holds(value, 0, "lt")
|
|
1131
|
+
except UnorderableError:
|
|
1132
|
+
return False
|
|
1133
|
+
|
|
1134
|
+
def describe(self) -> str:
|
|
1135
|
+
return "a negative value"
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
class IsZeroMatcher(_OfTheValueItself):
|
|
1139
|
+
def matches(self, value: Any) -> bool:
|
|
1140
|
+
return bool(value == 0)
|
|
1141
|
+
|
|
1142
|
+
def describe(self) -> str:
|
|
1143
|
+
return "zero"
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
class IsEvenMatcher(_OfTheValueItself):
|
|
1147
|
+
def matches(self, value: Any) -> bool:
|
|
1148
|
+
whole = value if type(value) is int else whole_number(value)
|
|
1149
|
+
return whole is not None and whole % 2 == 0
|
|
1150
|
+
|
|
1151
|
+
def describe(self) -> str:
|
|
1152
|
+
return "an even integer"
|
|
1153
|
+
|
|
1154
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1155
|
+
if whole_number(value) is None:
|
|
1156
|
+
return f"was <{_capped_repr(value)}> of type <{type(value).__name__}>, not an integer"
|
|
1157
|
+
return f"was <{_capped(value)}>, which is odd"
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
class IsOddMatcher(_OfTheValueItself):
|
|
1161
|
+
def matches(self, value: Any) -> bool:
|
|
1162
|
+
whole = value if type(value) is int else whole_number(value)
|
|
1163
|
+
return whole is not None and whole % 2 != 0
|
|
1164
|
+
|
|
1165
|
+
def describe(self) -> str:
|
|
1166
|
+
return "an odd integer"
|
|
1167
|
+
|
|
1168
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1169
|
+
if whole_number(value) is None:
|
|
1170
|
+
return f"was <{_capped_repr(value)}> of type <{type(value).__name__}>, not an integer"
|
|
1171
|
+
return f"was <{_capped(value)}>, which is even"
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
class IsDivisibleByMatcher(_OfTheValueItself):
|
|
1175
|
+
def __init__(self, divisor: SupportsIndex):
|
|
1176
|
+
self.divisor: int = require_integer(divisor, "divisor")
|
|
1177
|
+
if self.divisor == 0:
|
|
1178
|
+
_assertpy_refusal = True
|
|
1179
|
+
raise ValueError("given divisor arg must not be zero")
|
|
1180
|
+
|
|
1181
|
+
def matches(self, value: Any) -> bool:
|
|
1182
|
+
whole = value if type(value) is int else whole_number(value)
|
|
1183
|
+
return whole is not None and whole % self.divisor == 0
|
|
1184
|
+
|
|
1185
|
+
def describe(self) -> str:
|
|
1186
|
+
return f"an integer divisible by <{self.divisor}>"
|
|
1187
|
+
|
|
1188
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1189
|
+
whole = value if type(value) is int else whole_number(value)
|
|
1190
|
+
if whole is None:
|
|
1191
|
+
return f"was <{_capped_repr(value)}> of type <{type(value).__name__}>, not an integer"
|
|
1192
|
+
return f"was <{_capped(value)}>, which has remainder <{whole % self.divisor}>"
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
class IsCallableMatcher(_OfTheValueItself):
|
|
1196
|
+
def matches(self, value: Any) -> bool:
|
|
1197
|
+
return callable(value)
|
|
1198
|
+
|
|
1199
|
+
def describe(self) -> str:
|
|
1200
|
+
return "a callable"
|
|
1201
|
+
|
|
1202
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1203
|
+
return f"was <{_capped_repr(value)}> of type <{type(value).__name__}>, which is not callable"
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
class _SearchedByMember:
|
|
1207
|
+
"""Candidates searched as `member` searches them, where a `numpy` scalar may meet a list among them."""
|
|
1208
|
+
|
|
1209
|
+
__slots__ = ("values",)
|
|
1210
|
+
|
|
1211
|
+
def __init__(self, values: tuple[object, ...]):
|
|
1212
|
+
self.values = values
|
|
1213
|
+
|
|
1214
|
+
def __contains__(self, item: object) -> bool:
|
|
1215
|
+
return member(item, self.values)
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
class IsInMatcher(_OfTheValueItself):
|
|
1219
|
+
def __init__(self, *values: object):
|
|
1220
|
+
self.values = values
|
|
1221
|
+
broadcasting = any(_sequence_or_numpy(candidate) for candidate in values)
|
|
1222
|
+
self._candidates: tuple[object, ...] | _SearchedByMember = _SearchedByMember(values) if broadcasting else values
|
|
1223
|
+
|
|
1224
|
+
def _hands_the_value_on(self) -> bool:
|
|
1225
|
+
return any(type(candidate) not in NON_MATCHER_TYPES for candidate in self.values)
|
|
1226
|
+
|
|
1227
|
+
def matches(self, value: Any) -> bool:
|
|
1228
|
+
try:
|
|
1229
|
+
return value in self._candidates
|
|
1230
|
+
except REFUSALS:
|
|
1231
|
+
return member(value, self.values)
|
|
1232
|
+
|
|
1233
|
+
def describe(self) -> str:
|
|
1234
|
+
return f"a value in <{_capped_repr(self.values)}>"
|
|
1235
|
+
|
|
1236
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1237
|
+
return f"was <{_capped_repr(value)}>, which is not in <{_capped_repr(self.values)}>"
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
class HasPropertyMatcher(_OfTheValueItself):
|
|
1241
|
+
def __init__(self, name: str, matcher: Matcher[Any] | None = None):
|
|
1242
|
+
self.name = name
|
|
1243
|
+
self.matcher: Matcher[Any] | None = None if matcher is None else _require_matcher_argument(matcher)
|
|
1244
|
+
|
|
1245
|
+
def matches(self, value: Any) -> bool:
|
|
1246
|
+
try:
|
|
1247
|
+
held = getattr(value, self.name)
|
|
1248
|
+
except AttributeError:
|
|
1249
|
+
return False
|
|
1250
|
+
if self.matcher is not None:
|
|
1251
|
+
return bool(verdict(self.matcher.matches(held), subject="the matcher"))
|
|
1252
|
+
return True
|
|
1253
|
+
|
|
1254
|
+
def describe(self) -> str:
|
|
1255
|
+
if self.matcher is not None:
|
|
1256
|
+
return f"an object with property <{self.name}> matching {self.matcher.describe()}"
|
|
1257
|
+
return f"an object with property <{self.name}>"
|
|
1258
|
+
|
|
1259
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1260
|
+
# one read: asked through `hasattr` and then again, a property that counts its reads answered twice
|
|
1261
|
+
try:
|
|
1262
|
+
held = getattr(value, self.name)
|
|
1263
|
+
except AttributeError:
|
|
1264
|
+
return f"<{_capped_repr(value)}> has no property <{self.name}>"
|
|
1265
|
+
if self.matcher is not None:
|
|
1266
|
+
return f"property <{self.name}> <{_capped_repr(held)}> did not match {_stated_reason(self.matcher, held)}"
|
|
1267
|
+
return f"was <{_capped_repr(value)}>"
|
|
1268
|
+
|
|
1269
|
+
|
|
1270
|
+
# `str` and `bytes` are never each other's operands, and accepting `str` alone made a matcher disagree with itself
|
|
1271
|
+
def _textlike_noun(operand: object) -> str:
|
|
1272
|
+
"""What to call the operand in a description, so a bytes match does not read as a string one."""
|
|
1273
|
+
return "bytes" if isinstance(operand, (bytes, bytearray)) else "a string"
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
class ContainsStringMatcher(_OfTheValueItself):
|
|
1277
|
+
def __init__(self, substring: str | bytes):
|
|
1278
|
+
if type(substring) is not str and not issubclass(type(substring), _TEXTS):
|
|
1279
|
+
refuse(substring, "a string or bytes", subject=argument("substring"))
|
|
1280
|
+
self.substring = substring
|
|
1281
|
+
|
|
1282
|
+
def matches(self, value: Any) -> bool:
|
|
1283
|
+
return text_contains(value, self.substring)
|
|
1284
|
+
|
|
1285
|
+
def describe(self) -> str:
|
|
1286
|
+
return f"{_textlike_noun(self.substring)} containing <{_capped_format(self.substring)}>"
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _refuse_empty(name: str) -> NoReturn:
|
|
1290
|
+
"""Refuse an empty text or bytes as the assertion of the same name does: every text starts and ends with it."""
|
|
1291
|
+
message = f"given {name} arg must not be empty"
|
|
1292
|
+
_assertpy_refusal = True
|
|
1293
|
+
raise ValueError(message)
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
_TEXTS = (str, bytes, bytearray)
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
class MatchesRegexMatcher(_OfTheValueItself):
|
|
1300
|
+
def __init__(self, pattern: str | re.Pattern[str]):
|
|
1301
|
+
self.pattern = pattern
|
|
1302
|
+
# compiled eagerly so an invalid pattern raises at the call site, not inside `matches()` or a combinator
|
|
1303
|
+
self._compiled = require_pattern(pattern)
|
|
1304
|
+
|
|
1305
|
+
def matches(self, value: Any) -> bool:
|
|
1306
|
+
if not isinstance(value, str):
|
|
1307
|
+
return False
|
|
1308
|
+
return self._compiled.search(value) is not None
|
|
1309
|
+
|
|
1310
|
+
def describe(self) -> str:
|
|
1311
|
+
return f"a string matching pattern <{self.pattern}>"
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
class StartsWithMatcher(_OfTheValueItself):
|
|
1315
|
+
def __init__(self, prefix: str | bytes):
|
|
1316
|
+
# a list or a number built a matcher that never matched anything, and said nothing
|
|
1317
|
+
if type(prefix) is not str and not issubclass(type(prefix), _TEXTS):
|
|
1318
|
+
refuse(prefix, "a string or bytes", subject=argument("prefix"))
|
|
1319
|
+
if len(prefix) == 0:
|
|
1320
|
+
_refuse_empty("prefix")
|
|
1321
|
+
self.prefix = prefix
|
|
1322
|
+
|
|
1323
|
+
def matches(self, value: Any) -> bool:
|
|
1324
|
+
return text_starts_with(value, self.prefix)
|
|
1325
|
+
|
|
1326
|
+
def describe(self) -> str:
|
|
1327
|
+
return f"{_textlike_noun(self.prefix)} starting with <{_capped_format(self.prefix)}>"
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
class EndsWithMatcher(_OfTheValueItself):
|
|
1331
|
+
def __init__(self, suffix: str | bytes):
|
|
1332
|
+
if type(suffix) is not str and not issubclass(type(suffix), _TEXTS):
|
|
1333
|
+
refuse(suffix, "a string or bytes", subject=argument("suffix"))
|
|
1334
|
+
if len(suffix) == 0:
|
|
1335
|
+
_refuse_empty("suffix")
|
|
1336
|
+
self.suffix = suffix
|
|
1337
|
+
|
|
1338
|
+
def matches(self, value: Any) -> bool:
|
|
1339
|
+
return text_ends_with(value, self.suffix)
|
|
1340
|
+
|
|
1341
|
+
def describe(self) -> str:
|
|
1342
|
+
return f"{_textlike_noun(self.suffix)} ending with <{_capped_format(self.suffix)}>"
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def _listed(items: Any) -> str:
|
|
1346
|
+
"""Items as a reader sees them, with a matcher naming itself rather than showing its repr."""
|
|
1347
|
+
return ", ".join(item.describe() if _is_matcher(item) else _safe_repr(item) for item in items)
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _unsearchable(searched: object, refusal: MembershipRefusedError) -> str:
|
|
1351
|
+
"""Why a membership question could not be asked at all, kept as a mismatch so `~` and `|` still work."""
|
|
1352
|
+
return f"was <{_capped(searched)}>, which cannot be asked about these items: {refusal}"
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
class ContainsMatcher(BaseMatcher):
|
|
1356
|
+
"""Membership, spelled for a structural spec.
|
|
1357
|
+
|
|
1358
|
+
`each_item` says every element matches and `is_in` says the value is one of these, but the plain
|
|
1359
|
+
relation the builder has had all along -- this collection contains that -- could not be written in a
|
|
1360
|
+
spec at all. The decision is the same one `contains()` reaches, asked of `_engine._membership`.
|
|
1361
|
+
"""
|
|
1362
|
+
|
|
1363
|
+
def __init__(self, *items: object):
|
|
1364
|
+
if not items:
|
|
1365
|
+
_assertpy_refusal = True
|
|
1366
|
+
raise ValueError("one or more items must be given")
|
|
1367
|
+
self.items = items
|
|
1368
|
+
|
|
1369
|
+
def matches(self, value: Any) -> bool:
|
|
1370
|
+
searched = searchable(value)
|
|
1371
|
+
if not is_searchable(searched):
|
|
1372
|
+
return False # a value membership cannot be asked of simply does not contain anything
|
|
1373
|
+
try:
|
|
1374
|
+
return not missing_items(searched, self.items, _is_matcher, marked=True)
|
|
1375
|
+
except MembershipRefusedError:
|
|
1376
|
+
return False # an item this value cannot even be searched for is not one it holds
|
|
1377
|
+
|
|
1378
|
+
def describe(self) -> str:
|
|
1379
|
+
return f"a collection containing {_listed(self.items)}"
|
|
1380
|
+
|
|
1381
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1382
|
+
return self.evaluate(value).mismatch
|
|
1383
|
+
|
|
1384
|
+
def evaluate(self, value: Any) -> MatchResult:
|
|
1385
|
+
"""The verdict and the reason from one look, which is the only way to be right about both.
|
|
1386
|
+
|
|
1387
|
+
The default composition asks `matches()` and then `describe_mismatch()`, and each of them walks
|
|
1388
|
+
the value. Over a one-shot iterator the second walk sees what the first left behind, so the
|
|
1389
|
+
message named items that were there and missed the ones that were not.
|
|
1390
|
+
"""
|
|
1391
|
+
searched = searchable(value)
|
|
1392
|
+
if not is_searchable(searched):
|
|
1393
|
+
return MatchResult(
|
|
1394
|
+
matched=False,
|
|
1395
|
+
description=self.describe(),
|
|
1396
|
+
mismatch=f"was <{_capped_repr(value)}>, which cannot be searched",
|
|
1397
|
+
)
|
|
1398
|
+
try:
|
|
1399
|
+
absent = missing_items(searched, self.items, _is_matcher, marked=True)
|
|
1400
|
+
except MembershipRefusedError as refusal:
|
|
1401
|
+
return MatchResult(matched=False, description=self.describe(), mismatch=_unsearchable(searched, refusal))
|
|
1402
|
+
return MatchResult(
|
|
1403
|
+
matched=not absent,
|
|
1404
|
+
description=self.describe(),
|
|
1405
|
+
mismatch="" if not absent else f"was <{_capped(searched)}>, missing {_listed(absent)}",
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
class ContainsOnlyMatcher(BaseMatcher):
|
|
1410
|
+
""" "Nothing but these", spelled for a structural spec.
|
|
1411
|
+
|
|
1412
|
+
Reaches the same decision `contains_only()` reaches: what is present but unwanted, and what is
|
|
1413
|
+
wanted but absent. Either half makes it a non-match.
|
|
1414
|
+
"""
|
|
1415
|
+
|
|
1416
|
+
def __init__(self, *items: object):
|
|
1417
|
+
if not items:
|
|
1418
|
+
_assertpy_refusal = True
|
|
1419
|
+
raise ValueError("one or more items must be given")
|
|
1420
|
+
self.items = items
|
|
1421
|
+
|
|
1422
|
+
def matches(self, value: Any) -> bool:
|
|
1423
|
+
return self.evaluate(value).matched
|
|
1424
|
+
|
|
1425
|
+
def describe(self) -> str:
|
|
1426
|
+
return f"a collection containing only {_listed(self.items)}"
|
|
1427
|
+
|
|
1428
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1429
|
+
return self.evaluate(value).mismatch
|
|
1430
|
+
|
|
1431
|
+
def evaluate(self, value: Any) -> MatchResult:
|
|
1432
|
+
"""One walk: "only these" needs everything that is there, and two walks cannot both see it."""
|
|
1433
|
+
searched = searchable(value)
|
|
1434
|
+
if not is_walkable(searched):
|
|
1435
|
+
# membership is not enough: every element must be seen, and `__contains__` alone cannot be listed
|
|
1436
|
+
return MatchResult(
|
|
1437
|
+
matched=False,
|
|
1438
|
+
description=self.describe(),
|
|
1439
|
+
mismatch=f"was <{_capped_repr(value)}>, which cannot be listed",
|
|
1440
|
+
)
|
|
1441
|
+
try:
|
|
1442
|
+
extra, missing = only_faults(searched, self.items, marked=True)
|
|
1443
|
+
except MembershipRefusedError as refusal:
|
|
1444
|
+
return MatchResult(matched=False, description=self.describe(), mismatch=_unsearchable(searched, refusal))
|
|
1445
|
+
faults = []
|
|
1446
|
+
if extra:
|
|
1447
|
+
faults.append(f"also had {_listed(extra)}")
|
|
1448
|
+
if missing:
|
|
1449
|
+
faults.append(f"lacked {_listed(missing)}")
|
|
1450
|
+
return MatchResult(
|
|
1451
|
+
matched=not faults,
|
|
1452
|
+
description=self.describe(),
|
|
1453
|
+
mismatch="" if not faults else f"was <{_capped_format(searched)}>, which {' and '.join(faults)}",
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
class IsSubsetOfMatcher(BaseMatcher):
|
|
1458
|
+
"""Every element of the value is somewhere in the given collection."""
|
|
1459
|
+
|
|
1460
|
+
def __init__(self, *superset: object):
|
|
1461
|
+
if not superset:
|
|
1462
|
+
_assertpy_refusal = True
|
|
1463
|
+
raise ValueError("one or more items must be given")
|
|
1464
|
+
given = superset[0] if len(superset) == 1 and is_searchable(superset[0]) else superset
|
|
1465
|
+
# drained here, not per call: a generator handed in as the superset was consumed by the first `matches()`
|
|
1466
|
+
self.superset = searchable(given)
|
|
1467
|
+
# the assertion's own supersets: one collection, or several args, each flattened the way it flattens them
|
|
1468
|
+
self.supersets: tuple[object, ...] = superset if len(superset) > 1 else (self.superset,)
|
|
1469
|
+
|
|
1470
|
+
def matches(self, value: Any) -> bool:
|
|
1471
|
+
return self.evaluate(value).matched
|
|
1472
|
+
|
|
1473
|
+
def describe(self) -> str:
|
|
1474
|
+
return f"a collection whose items all appear in <{_capped_format(self.superset)}>"
|
|
1475
|
+
|
|
1476
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1477
|
+
return self.evaluate(value).mismatch
|
|
1478
|
+
|
|
1479
|
+
def evaluate(self, value: Any) -> MatchResult:
|
|
1480
|
+
"""One walk: the subject is listed, while the superset is only asked about membership."""
|
|
1481
|
+
searched = searchable(value)
|
|
1482
|
+
if not is_walkable(searched):
|
|
1483
|
+
return MatchResult(
|
|
1484
|
+
matched=False,
|
|
1485
|
+
description=self.describe(),
|
|
1486
|
+
mismatch=f"was <{_capped_repr(value)}>, which cannot be listed",
|
|
1487
|
+
)
|
|
1488
|
+
absent = subset_faults(searched, self.supersets)
|
|
1489
|
+
return MatchResult(
|
|
1490
|
+
matched=not absent,
|
|
1491
|
+
description=self.describe(),
|
|
1492
|
+
mismatch="" if not absent else f"was <{_capped_format(searched)}>, with {_listed(absent)} outside it",
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def _itself(item: Any) -> Any:
|
|
1497
|
+
"""The key `match.is_sorted()` orders by when it was given none."""
|
|
1498
|
+
return item
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
class IsSortedMatcher(BaseMatcher):
|
|
1502
|
+
"""The value is in order, by the same walk `is_sorted()` uses."""
|
|
1503
|
+
|
|
1504
|
+
def __init__(self, key: Callable[[Any], Any] | None = None, reverse: bool = False):
|
|
1505
|
+
self.key = key or _itself
|
|
1506
|
+
self.reverse = reverse
|
|
1507
|
+
|
|
1508
|
+
def matches(self, value: Any) -> bool:
|
|
1509
|
+
return self.evaluate(value).matched
|
|
1510
|
+
|
|
1511
|
+
def describe(self) -> str:
|
|
1512
|
+
return "a collection sorted in reverse" if self.reverse else "a collection sorted in order"
|
|
1513
|
+
|
|
1514
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1515
|
+
return self.evaluate(value).mismatch
|
|
1516
|
+
|
|
1517
|
+
def evaluate(self, value: Any) -> MatchResult:
|
|
1518
|
+
"""One walk, which also means the user's `key` is called once per element rather than twice."""
|
|
1519
|
+
if not is_walkable(value):
|
|
1520
|
+
return MatchResult(
|
|
1521
|
+
matched=False,
|
|
1522
|
+
description=self.describe(),
|
|
1523
|
+
mismatch=f"was <{_capped_repr(value)}>, which cannot be walked",
|
|
1524
|
+
)
|
|
1525
|
+
items = searchable(value)
|
|
1526
|
+
try:
|
|
1527
|
+
if self.key is _itself and plainly_sortable(items):
|
|
1528
|
+
broken = first_plainly_out_of_order(items, reverse=self.reverse)
|
|
1529
|
+
else:
|
|
1530
|
+
broken = first_out_of_order(items, key=self.key, reverse=self.reverse)
|
|
1531
|
+
except UnorderableError:
|
|
1532
|
+
return MatchResult(
|
|
1533
|
+
matched=False,
|
|
1534
|
+
description=self.describe(),
|
|
1535
|
+
mismatch=f"was <{_capped_format(items)}>, holding items that cannot be ordered against each other",
|
|
1536
|
+
)
|
|
1537
|
+
if broken is None:
|
|
1538
|
+
return MatchResult(matched=True, description=self.describe(), mismatch="")
|
|
1539
|
+
index, earlier, later = broken
|
|
1540
|
+
return MatchResult(
|
|
1541
|
+
matched=False,
|
|
1542
|
+
description=self.describe(),
|
|
1543
|
+
mismatch=f"was <{_capped_format(items)}>, out of order at index {index}:"
|
|
1544
|
+
f" <{_capped_format(earlier)}> then <{_capped_format(later)}>",
|
|
1545
|
+
)
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
class IgnoreMatcher(_OfTheValueItself):
|
|
1549
|
+
def matches(self, value: Any) -> bool:
|
|
1550
|
+
return True
|
|
1551
|
+
|
|
1552
|
+
def describe(self) -> str:
|
|
1553
|
+
return "anything (ignored)"
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
class IsUuidMatcher(_OfTheValueItself):
|
|
1557
|
+
def matches(self, value: Any) -> bool:
|
|
1558
|
+
if not isinstance(value, str):
|
|
1559
|
+
return False
|
|
1560
|
+
try:
|
|
1561
|
+
_uuid_mod.UUID(value)
|
|
1562
|
+
except ValueError:
|
|
1563
|
+
return False
|
|
1564
|
+
return True
|
|
1565
|
+
|
|
1566
|
+
def describe(self) -> str:
|
|
1567
|
+
return "a valid UUID string"
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
class IsNonEmptyStringMatcher(_OfTheValueItself):
|
|
1571
|
+
def matches(self, value: Any) -> bool:
|
|
1572
|
+
return isinstance(value, str) and len(value) > 0
|
|
1573
|
+
|
|
1574
|
+
def describe(self) -> str:
|
|
1575
|
+
return "a non-empty string"
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
class IsNowMatcher(_OfTheValueItself):
|
|
1579
|
+
"""Matches a ``datetime`` within a tolerance of the current time."""
|
|
1580
|
+
|
|
1581
|
+
def __init__(self, delta: timedelta):
|
|
1582
|
+
if isinstance(delta, timedelta) and timedelta.__lt__(delta, timedelta(0)):
|
|
1583
|
+
_assertpy_refusal = True
|
|
1584
|
+
raise ValueError("given delta arg must not be negative")
|
|
1585
|
+
self._delta = delta
|
|
1586
|
+
|
|
1587
|
+
def matches(self, value: Any) -> bool:
|
|
1588
|
+
if not isinstance(value, datetime):
|
|
1589
|
+
return False
|
|
1590
|
+
# as moments: on one zone object the two readings of a repeated hour subtract to nothing, an hour apart
|
|
1591
|
+
now = datetime.now(timezone.utc) if value.utcoffset() is not None else datetime.now()
|
|
1592
|
+
return abs(value - now) <= self._delta
|
|
1593
|
+
|
|
1594
|
+
def describe(self) -> str:
|
|
1595
|
+
return f"a datetime within {self._delta} of now"
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
class IsBeforeMatcher(_OfTheValueItself):
|
|
1599
|
+
"""Matches a ``datetime`` strictly before a reference ``datetime``."""
|
|
1600
|
+
|
|
1601
|
+
def __init__(self, other: datetime):
|
|
1602
|
+
self._other = other
|
|
1603
|
+
|
|
1604
|
+
def matches(self, value: Any) -> bool:
|
|
1605
|
+
try:
|
|
1606
|
+
return isinstance(value, datetime) and value < self._other
|
|
1607
|
+
except TypeError as exc: # naive vs aware operands are not comparable -> no match
|
|
1608
|
+
if raised_inside(exc): # their operator raised: that is a bug in the value, not a non-match
|
|
1609
|
+
raise
|
|
1610
|
+
return False
|
|
1611
|
+
|
|
1612
|
+
def describe(self) -> str:
|
|
1613
|
+
return f"a datetime before {_capped_format(self._other)}"
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
class IsAfterMatcher(_OfTheValueItself):
|
|
1617
|
+
"""Matches a ``datetime`` strictly after a reference ``datetime``."""
|
|
1618
|
+
|
|
1619
|
+
def __init__(self, other: datetime):
|
|
1620
|
+
self._other = other
|
|
1621
|
+
|
|
1622
|
+
def matches(self, value: Any) -> bool:
|
|
1623
|
+
try:
|
|
1624
|
+
return isinstance(value, datetime) and value > self._other
|
|
1625
|
+
except TypeError as exc: # naive vs aware operands are not comparable -> no match
|
|
1626
|
+
if raised_inside(exc): # their operator raised: that is a bug in the value, not a non-match
|
|
1627
|
+
raise
|
|
1628
|
+
return False
|
|
1629
|
+
|
|
1630
|
+
def describe(self) -> str:
|
|
1631
|
+
return f"a datetime after {_capped_format(self._other)}"
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
class EachMatcher(BaseMatcher):
|
|
1635
|
+
"""Matches when every item in an iterable satisfies the wrapped matcher."""
|
|
1636
|
+
|
|
1637
|
+
def __init__(self, matcher: Matcher[Any]):
|
|
1638
|
+
self.matcher: Matcher[Any] = _require_matcher_argument(matcher)
|
|
1639
|
+
|
|
1640
|
+
def matches(self, value: Any) -> bool:
|
|
1641
|
+
try:
|
|
1642
|
+
return all(verdict(self.matcher.matches(item), subject="the matcher") for item in value)
|
|
1643
|
+
except TypeError as exc:
|
|
1644
|
+
if raised_inside(exc): # their operator raised: that is a bug in the value, not a non-match
|
|
1645
|
+
raise
|
|
1646
|
+
return False
|
|
1647
|
+
|
|
1648
|
+
def describe(self) -> str:
|
|
1649
|
+
return f"each item matching {self.matcher.describe()}"
|
|
1650
|
+
|
|
1651
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1652
|
+
return self.evaluate(value).mismatch
|
|
1653
|
+
|
|
1654
|
+
def evaluate(self, value: Any) -> MatchResult:
|
|
1655
|
+
"""One walk: deciding drained a one-shot value, and describing afterwards named its remainder.
|
|
1656
|
+
|
|
1657
|
+
The walk stops at the first item that fails, so an endless value is answered rather than read.
|
|
1658
|
+
The reason is what the item's matcher says about the item that failed, asked without asking for
|
|
1659
|
+
its verdict again. Asking every item for its reason cost 2.2x on a passing walk of ten thousand.
|
|
1660
|
+
"""
|
|
1661
|
+
try:
|
|
1662
|
+
for index, item in enumerate(value):
|
|
1663
|
+
if not verdict(self.matcher.matches(item), subject="the matcher"):
|
|
1664
|
+
return MatchResult(
|
|
1665
|
+
matched=False,
|
|
1666
|
+
description=self.describe(),
|
|
1667
|
+
mismatch=(
|
|
1668
|
+
f"item at index {index} <{_capped(item)}> did not match "
|
|
1669
|
+
f"{_stated_reason(self.matcher, item)}"
|
|
1670
|
+
),
|
|
1671
|
+
)
|
|
1672
|
+
except TypeError as exc:
|
|
1673
|
+
if raised_inside(exc): # their operator raised: that is a bug in the value, not a non-match
|
|
1674
|
+
raise
|
|
1675
|
+
return MatchResult(
|
|
1676
|
+
matched=False, description=self.describe(), mismatch=f"was not iterable: <{_capped(value)}>"
|
|
1677
|
+
)
|
|
1678
|
+
return MatchResult(matched=True, description=self.describe(), mismatch=f"was <{_capped(value)}>")
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
class _MissingSentinel:
|
|
1682
|
+
"""Placeholder recorded for a spec key absent from the value during structural matching."""
|
|
1683
|
+
|
|
1684
|
+
def __repr__(self) -> str:
|
|
1685
|
+
return "<missing>"
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
_MISSING: Final = _MissingSentinel()
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
_A_STRUCTURE: Final = "a mapping matching structure"
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def _describe_spec_value(value: object) -> str:
|
|
1695
|
+
"""Describe a single structure-spec value (matcher, nested dict spec, or raw value)."""
|
|
1696
|
+
if _is_matcher(value):
|
|
1697
|
+
return value.describe()
|
|
1698
|
+
if isinstance(value, dict):
|
|
1699
|
+
parts = [f"{key}: {_describe_spec_value(sub_value)}" for key, sub_value in value.items()]
|
|
1700
|
+
return f"{{{', '.join(parts)}}}"
|
|
1701
|
+
return f"<{_capped(value)}>"
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
class _SpecMismatch(NamedTuple):
|
|
1705
|
+
"""One structural mismatch produced by a single spec walk.
|
|
1706
|
+
|
|
1707
|
+
``expected_desc`` is the public "expected" half (as exposed by ``collect_mismatches``); ``detail``
|
|
1708
|
+
carries a matcher's ``describe_mismatch(actual)`` so fail-fast failure text keeps its per-value
|
|
1709
|
+
detail without a second traversal.
|
|
1710
|
+
"""
|
|
1711
|
+
|
|
1712
|
+
path: _Path
|
|
1713
|
+
actual: object
|
|
1714
|
+
expected_desc: str
|
|
1715
|
+
detail: str | None
|
|
1716
|
+
literal: object = _MISSING
|
|
1717
|
+
"""The raw value the spec holds there, where the mismatch is one of `==` against it."""
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
class StructureMatcher(_OfTheValueItself):
|
|
1721
|
+
"""Matches dicts (or pydantic-style models via ``model_dump()``) against a structure spec whose
|
|
1722
|
+
values are matchers, raw values, or nested dicts."""
|
|
1723
|
+
|
|
1724
|
+
def __init__(self, spec: dict[Any, Any]):
|
|
1725
|
+
self._spec = spec
|
|
1726
|
+
|
|
1727
|
+
@staticmethod
|
|
1728
|
+
def _as_mapping(value: Any) -> Any:
|
|
1729
|
+
"""Normalize a pydantic-style model (``model_dump()``) or an attrs instance to its dict, so it
|
|
1730
|
+
can be matched structurally just like a plain dict; pass other values through."""
|
|
1731
|
+
if is_model_dump_object(value):
|
|
1732
|
+
return value.model_dump()
|
|
1733
|
+
if is_attrs_instance(value):
|
|
1734
|
+
return {field.name: getattr(value, field.name) for field in value.__attrs_attrs__}
|
|
1735
|
+
return value
|
|
1736
|
+
|
|
1737
|
+
def matches(self, value: Any) -> bool:
|
|
1738
|
+
mapping = self._as_mapping(value)
|
|
1739
|
+
if not is_mapping_like(mapping):
|
|
1740
|
+
return False
|
|
1741
|
+
return not self._walk(mapping, self._spec, _ROOT, set())
|
|
1742
|
+
|
|
1743
|
+
def describe(self) -> str:
|
|
1744
|
+
return f"{_A_STRUCTURE} {_describe_spec_value(self._spec)}"
|
|
1745
|
+
|
|
1746
|
+
def describe_mismatch(self, value: Any) -> str:
|
|
1747
|
+
mapping = self._as_mapping(value)
|
|
1748
|
+
if not is_mapping_like(mapping):
|
|
1749
|
+
return f"was not a mapping: <{_capped_format(mapping)}>"
|
|
1750
|
+
mismatches = self._walk(mapping, self._spec, _ROOT, set())
|
|
1751
|
+
return self.render_mismatch(mismatches) if mismatches else f"was <{_capped_format(mapping)}>"
|
|
1752
|
+
|
|
1753
|
+
def evaluate(self, value: Any) -> MatchResult:
|
|
1754
|
+
"""One walk of the spec instead of two.
|
|
1755
|
+
|
|
1756
|
+
`matches()` walks it and `describe_mismatch()` walks it again, which is the whole cost of this
|
|
1757
|
+
matcher on a payload of any size. The rendering is shared with `describe_mismatch()` rather
|
|
1758
|
+
than repeated, so the two cannot answer differently.
|
|
1759
|
+
|
|
1760
|
+
`matches_structure` does not go through here: it needs every mismatch for its diff, not only the
|
|
1761
|
+
first one in words, so it walks once via `walk_mismatches()` and renders from that.
|
|
1762
|
+
"""
|
|
1763
|
+
failure = self._failure(value)
|
|
1764
|
+
return MatchResult(matched=True, description=self.describe()) if failure is None else failure
|
|
1765
|
+
|
|
1766
|
+
def _failure(self, value: Any) -> MatchResult | None:
|
|
1767
|
+
"""What `evaluate()` answers for a value that does not match, from the same walk, and `None` for one that does.
|
|
1768
|
+
|
|
1769
|
+
A pass needs no words. `each` asks this of every item and describes the spec only for the one that
|
|
1770
|
+
failed: on 10 000 passing records the description was 16 of 53 ms.
|
|
1771
|
+
"""
|
|
1772
|
+
mapped = self._as_mapping(value)
|
|
1773
|
+
if not is_mapping_like(mapped):
|
|
1774
|
+
return MatchResult(
|
|
1775
|
+
matched=False, description=self.describe(), mismatch=f"was not a mapping: <{_capped_format(mapped)}>"
|
|
1776
|
+
)
|
|
1777
|
+
mismatches = self._walk(mapped, self._spec, _ROOT, set())
|
|
1778
|
+
if not mismatches:
|
|
1779
|
+
return None
|
|
1780
|
+
return MatchResult(matched=False, description=self.describe(), mismatch=self.render_mismatch(mismatches))
|
|
1781
|
+
|
|
1782
|
+
@staticmethod
|
|
1783
|
+
def render_mismatch(mismatches: list[_SpecMismatch]) -> str:
|
|
1784
|
+
"""The first mismatch in words, from a walk the caller already paid for.
|
|
1785
|
+
|
|
1786
|
+
Requires a non-empty list. "Nothing mismatched" is not a mismatch to render, and the two callers
|
|
1787
|
+
disagree about what to say then: one has the mapped value to report, the other the raw one.
|
|
1788
|
+
"""
|
|
1789
|
+
first = mismatches[0]
|
|
1790
|
+
where = first.path.text
|
|
1791
|
+
if first.actual is _MISSING:
|
|
1792
|
+
return f"missing key <{where}>"
|
|
1793
|
+
if first.expected_desc == "<circular ref>": # cycle sentinel emitted by _walk
|
|
1794
|
+
# the root is unreachable here, so the label only ever names a nested cycle
|
|
1795
|
+
return f"circular reference detected at <{where or 'root'}>"
|
|
1796
|
+
if first.detail is not None:
|
|
1797
|
+
return f"at <{where}>: expected {first.expected_desc}, but {first.detail}"
|
|
1798
|
+
return f"at <{where}>: expected {first.expected_desc}, but was <{_capped_format(first.actual)}>"
|
|
1799
|
+
|
|
1800
|
+
def collect_mismatches(self, value: Any) -> list[tuple[_Path, object, str]]:
|
|
1801
|
+
"""Collect every structural mismatch as ``(path, actual, expected_description)``.
|
|
1802
|
+
|
|
1803
|
+
Unlike `describe_mismatch()`, this does not stop at the first failure and joins nested
|
|
1804
|
+
paths, so callers can build a path-level [`DiffResult`][assertpy.errors.DiffResult].
|
|
1805
|
+
"""
|
|
1806
|
+
return [(mismatch.path, mismatch.actual, mismatch.expected_desc) for mismatch in self.walk_mismatches(value)]
|
|
1807
|
+
|
|
1808
|
+
def walk_mismatches(self, value: Any) -> list[_SpecMismatch]:
|
|
1809
|
+
"""Every mismatch, once, for a caller that needs both the entries and the message.
|
|
1810
|
+
|
|
1811
|
+
`matches_structure` builds a diff entry per mismatch *and* names the first one in its message.
|
|
1812
|
+
Asking `collect_mismatches()` and then `describe_mismatch()` walked the spec twice, and the walk
|
|
1813
|
+
is the whole cost of this matcher on a payload of any size.
|
|
1814
|
+
"""
|
|
1815
|
+
return self._walk(self._as_mapping(value), self._spec, _ROOT, set())
|
|
1816
|
+
|
|
1817
|
+
def _walk(
|
|
1818
|
+
self, value: MappingLike, spec: dict[Any, Any], path: _Path, seen: set[tuple[int, int]]
|
|
1819
|
+
) -> list[_SpecMismatch]:
|
|
1820
|
+
pair_id = (id(value), id(spec))
|
|
1821
|
+
if pair_id in seen:
|
|
1822
|
+
# `path` is empty only on the outermost call, where no cycle can be reported, so this label is a guard
|
|
1823
|
+
return [_SpecMismatch(path, "<circular ref>", "<circular ref>", None)]
|
|
1824
|
+
seen = seen | {pair_id}
|
|
1825
|
+
mismatches: list[_SpecMismatch] = []
|
|
1826
|
+
for key, expected in spec.items():
|
|
1827
|
+
# built where needed: a spec that matches reads no path, and building one per key was the whole added cost
|
|
1828
|
+
try:
|
|
1829
|
+
if key not in value:
|
|
1830
|
+
mismatches.append(_SpecMismatch(path.key(key), _MISSING, _describe_spec_value(expected), None))
|
|
1831
|
+
continue
|
|
1832
|
+
actual = value[key]
|
|
1833
|
+
except REFUSALS as refusal:
|
|
1834
|
+
found, actual = lookup(value, key, refusal)
|
|
1835
|
+
if not found:
|
|
1836
|
+
mismatches.append(_SpecMismatch(path.key(key), _MISSING, _describe_spec_value(expected), None))
|
|
1837
|
+
continue
|
|
1838
|
+
if isinstance(expected, StructureMatcher) and is_mapping_like(normalized := self._as_mapping(actual)):
|
|
1839
|
+
mismatches.extend(self._walk(normalized, expected._spec, path.key(key), seen))
|
|
1840
|
+
elif _is_matcher(expected):
|
|
1841
|
+
# a predicate that cannot evaluate means "no match", asked once so a one-shot leaf keeps its reason
|
|
1842
|
+
try:
|
|
1843
|
+
if _has_own_evaluate(expected):
|
|
1844
|
+
# a matcher that walks its value answers once, so its reason holds for a one-shot iterator
|
|
1845
|
+
outcome = cast("BaseMatcher", expected).evaluate(actual)
|
|
1846
|
+
if not outcome.matched:
|
|
1847
|
+
mismatches.append(
|
|
1848
|
+
_SpecMismatch(path.key(key), actual, outcome.description, outcome.mismatch)
|
|
1849
|
+
)
|
|
1850
|
+
elif not verdict(expected.matches(actual), subject="the matcher"):
|
|
1851
|
+
# nothing is rendered until the leaf has failed, which is what keeps a passing spec cheap
|
|
1852
|
+
mismatches.append(
|
|
1853
|
+
_SpecMismatch(
|
|
1854
|
+
path.key(key), actual, expected.describe(), expected.describe_mismatch(actual)
|
|
1855
|
+
)
|
|
1856
|
+
)
|
|
1857
|
+
except VerdictError: # never a mismatch, always a mistake in the test
|
|
1858
|
+
raise
|
|
1859
|
+
except (TypeError, ValueError):
|
|
1860
|
+
mismatches.append(
|
|
1861
|
+
_SpecMismatch(path.key(key), actual, expected.describe(), f"was <{_capped(actual)}>")
|
|
1862
|
+
)
|
|
1863
|
+
elif isinstance(expected, dict):
|
|
1864
|
+
normalized = self._as_mapping(actual)
|
|
1865
|
+
if is_mapping_like(normalized):
|
|
1866
|
+
mismatches.extend(self._walk(normalized, expected, path.key(key), seen))
|
|
1867
|
+
else:
|
|
1868
|
+
mismatches.append(_SpecMismatch(path.key(key), actual, "a mapping", None))
|
|
1869
|
+
elif not _guarded_equal(actual, expected, method="matches_structure"):
|
|
1870
|
+
# told apart by class where the two print the same: an id held as text read "expected <7>, but was <7>"
|
|
1871
|
+
shown = _capped_format(actual)
|
|
1872
|
+
wanted, was = _told_apart(_capped_format(expected), shown, expected, actual)
|
|
1873
|
+
# two sides told apart by their classes have said the type already
|
|
1874
|
+
kind = type_beside_a_class(actual, expected) if was == shown else ""
|
|
1875
|
+
mismatches.append(_SpecMismatch(path.key(key), actual, f"<{wanted}>", f"was <{was}>{kind}", expected))
|
|
1876
|
+
return mismatches
|