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.
Files changed (63) hide show
  1. assertpy/__init__.py +87 -0
  2. assertpy/_clustering.py +511 -0
  3. assertpy/_dangling.py +505 -0
  4. assertpy/_engine/__init__.py +1 -0
  5. assertpy/_engine/_builder_check_typing.py +1365 -0
  6. assertpy/_engine/_capable_typing.py +772 -0
  7. assertpy/_engine/_check_typing.py +753 -0
  8. assertpy/_engine/_compare.py +983 -0
  9. assertpy/_engine/_compat.py +38 -0
  10. assertpy/_engine/_contract.py +1926 -0
  11. assertpy/_engine/_diff.py +1058 -0
  12. assertpy/_engine/_equality.py +843 -0
  13. assertpy/_engine/_introspection.py +729 -0
  14. assertpy/_engine/_membership.py +522 -0
  15. assertpy/_engine/_mixin_base.py +258 -0
  16. assertpy/_engine/_negated_typing.py +2176 -0
  17. assertpy/_engine/_operations.py +75 -0
  18. assertpy/_engine/_ordering.py +884 -0
  19. assertpy/_engine/_pairing.py +70 -0
  20. assertpy/_engine/_path.py +158 -0
  21. assertpy/_engine/_poll_typing.py +6039 -0
  22. assertpy/_engine/_require.py +312 -0
  23. assertpy/_engine/_size.py +31 -0
  24. assertpy/_engine/_text.py +39 -0
  25. assertpy/_engine/_typing.py +1226 -0
  26. assertpy/_hints.py +1213 -0
  27. assertpy/_inline.py +484 -0
  28. assertpy/_matcher_impls.py +1876 -0
  29. assertpy/_passes.py +249 -0
  30. assertpy/_renamed.py +64 -0
  31. assertpy/_satisfies.py +835 -0
  32. assertpy/_snapshot_codec.py +290 -0
  33. assertpy/assertpy.py +2640 -0
  34. assertpy/async_assertions.py +864 -0
  35. assertpy/base.py +862 -0
  36. assertpy/behave_matchers.py +96 -0
  37. assertpy/bytes_mixin.py +158 -0
  38. assertpy/collection.py +528 -0
  39. assertpy/contains.py +1068 -0
  40. assertpy/dataframe.py +200 -0
  41. assertpy/date.py +327 -0
  42. assertpy/dict.py +249 -0
  43. assertpy/dynamic.py +222 -0
  44. assertpy/errors.py +782 -0
  45. assertpy/exception.py +838 -0
  46. assertpy/extracting.py +266 -0
  47. assertpy/file.py +324 -0
  48. assertpy/helpers.py +747 -0
  49. assertpy/http_mixin.py +218 -0
  50. assertpy/json_mixin.py +1265 -0
  51. assertpy/matchers.py +639 -0
  52. assertpy/numeric.py +696 -0
  53. assertpy/outcome.py +163 -0
  54. assertpy/py.typed +0 -0
  55. assertpy/pytest_plugin.py +1650 -0
  56. assertpy/snapshot.py +1031 -0
  57. assertpy/string.py +803 -0
  58. assertpy/warning.py +137 -0
  59. assertpy-2.35.0.dist-info/METADATA +324 -0
  60. assertpy-2.35.0.dist-info/RECORD +63 -0
  61. assertpy-2.35.0.dist-info/WHEEL +4 -0
  62. assertpy-2.35.0.dist-info/entry_points.txt +2 -0
  63. 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