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,864 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import hashlib
5
+ import inspect
6
+ import math
7
+ import numbers
8
+ import time
9
+ import warnings
10
+ from collections import OrderedDict, deque
11
+ from itertools import pairwise
12
+ from typing import TYPE_CHECKING, Any, Final
13
+
14
+ from . import _passes
15
+ from ._engine._compare import _is_infinite, _is_nan
16
+ from ._engine._operations import DESCRIBES, WITHOUT_A_VERDICT
17
+ from ._engine._require import argument, refuse
18
+ from .errors import AssertionFailure, PollSample, PollTrace, _json_safe, _safe_repr, _safe_str
19
+ from .exception import _InertBuilder, named_classes
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Callable, Generator
23
+
24
+ from ._engine._compat import Self
25
+ from ._matcher_impls import RetriedInfo
26
+
27
+ __tracebackhide__ = True
28
+
29
+ _PROBE_UNSET = object()
30
+
31
+
32
+ _CONTAINERS: Final = (dict, set, frozenset, tuple, list)
33
+ """The kinds `_canonical` looks inside, which are also the only ones that can reach back to themselves."""
34
+
35
+
36
+ def _canonical(value: object, _seen: frozenset[int] = frozenset()) -> str:
37
+ """A type-faithful rendering of *value*, with containers ordered where their type has no order.
38
+
39
+ Not for reading: this exists so one poll's value can be told from the next's. JSON was tried first
40
+ and erases exactly what matters here, since `(1,)` and `[1]` both render as `[1]` and a mapping's
41
+ keys all become strings. A tag carries the container's type instead, and every part is written with
42
+ its own length in front, so nothing a value prints can be read as this rendering's punctuation: a
43
+ leaf whose repr is `[1,2]` used to render exactly as the list `[1, 2]` did.
44
+
45
+ Dicts and sets are sorted by their rendered parts: they have no order to preserve, and two equal ones
46
+ can still iterate differently, since a set built from `[0, 8]` and one built from `[8, 0]` collide
47
+ into the same bucket in opposite orders.
48
+
49
+ The boundary, since a rendering cannot reproduce `==`: two values that are equal and print
50
+ differently, `1` against `1.0`, count as a change, and two that are unequal and print alike, a
51
+ dataclass hiding a field from its own repr, count as one value. A leaf carries no type tag of its
52
+ own for the same reason, or a `str` subclass would differ from the string it equals.
53
+
54
+ One case cannot be got right by any key of this shape, rather than being a choice made here:
55
+ `OrderedDict([a, b]) == {a: ..., b: ...} == OrderedDict([b, a])` while the two `OrderedDict`s are
56
+ unequal, so Python's own relation is not transitive there and nothing that digests one value at a
57
+ time can follow it. The ordered pair is the one honoured, because that is where the order carries
58
+ the meaning.
59
+ """
60
+ if not isinstance(value, _CONTAINERS):
61
+ # a leaf cannot reach back to anything, and the two set operations cost more than rendering it
62
+ rendered = repr(value)
63
+ return f"v{len(rendered)}:{rendered}"
64
+ if id(value) in _seen:
65
+ return "c" # a container that reaches back to its own root, as `repr` marks with `...`
66
+ inner = _seen | {id(value)}
67
+ if isinstance(value, dict):
68
+ pairs = (_framed(_canonical(key, inner)) + _framed(_canonical(held, inner)) for key, held in value.items())
69
+ # sorted for every mapping but `OrderedDict`, the one whose own `==` reads order
70
+ return "o" + "".join(pairs) if isinstance(value, OrderedDict) else "d" + "".join(sorted(pairs))
71
+ if isinstance(value, (set, frozenset)):
72
+ return "s" + "".join(sorted(_framed(_canonical(member, inner)) for member in value))
73
+ if isinstance(value, tuple):
74
+ return "t" + "".join(_framed(_canonical(member, inner)) for member in value)
75
+ return "l" + "".join(_framed(_canonical(member, inner)) for member in value)
76
+
77
+
78
+ def _framed(text: str) -> str:
79
+ """*text* with its own length in front, so no rendering can be mistaken for a longer one."""
80
+ return f"{len(text)}:{text}"
81
+
82
+
83
+ def _change_key(value: object) -> str | None:
84
+ """A bounded stand-in for what a probe returned, for deciding whether the value moved.
85
+
86
+ ``None`` where no faithful one can be made, which is what stops the summary claiming a value moved,
87
+ or held still, on evidence it does not have.
88
+
89
+ Four things have to hold at once. It must be **complete**, because the sample kept per poll is a
90
+ diagnostic snapshot cut after a hundred items, and comparing those called a queue that grew past its
91
+ hundredth entry unchanged for the rest of the run. It must be **type-faithful**, since a probe that
92
+ started returning a list where it returned a tuple did change. It must not depend on **order** a
93
+ value does not have, since two dicts that differ only in insertion order are equal. And it must
94
+ survive a **live** object, because probes commonly mutate and return the same one, so anything that
95
+ compares the object rather than a rendering of it answers "unchanged" whatever it did.
96
+ """
97
+ try:
98
+ text = _canonical(value)
99
+ except Exception: # their `__repr__` raised, or their container did when walked
100
+ return None
101
+ return hashlib.blake2b(text.encode("utf-8", "surrogatepass"), digest_size=16).hexdigest()
102
+
103
+
104
+ # `None` args mark a step that was read rather than called, which is `not_`
105
+ _Step = tuple[str, "tuple[Any, ...] | None", "dict[str, Any] | None"]
106
+
107
+
108
+ _COLLECT_RETRIES: bool = False
109
+ """Whether retried polls are collected at all.
110
+
111
+ Turned on by the pytest plugin, which is the only consumer: it drains the list per test and reports at
112
+ session end. Off everywhere else (unittest, a plain script, ``-p no:assertpy``) so nothing accumulates
113
+ in a process that would never read it.
114
+ """
115
+
116
+ _RETRIES: list[tuple[int, float, float]] = []
117
+ """Polls that only passed after retrying, as ``(attempts, elapsed, budget)``.
118
+
119
+ The recorder already samples every failed poll, so a probe that converges on its third attempt has
120
+ already paid for the first two: collecting them costs a list append on a path that has just spent
121
+ several sleeps. Drained by the pytest plugin, which knows which test they belong to.
122
+ """
123
+
124
+
125
+ class _PollRecorder:
126
+ """Collects per-poll samples, collapsing identical runs and keeping the first and last polls."""
127
+
128
+ def __init__(self, head: int = 5, tail: int = 20):
129
+ self._head: list[PollSample] = []
130
+ self._head_limit = head
131
+ self._tail: deque[PollSample] = deque(maxlen=tail)
132
+ # reading recurrence off the cut snapshots instead called five distinct hundred-item lists a cycle
133
+ self._head_keys: list[str | None] = []
134
+ self._tail_keys: deque[str | None] = deque(maxlen=tail)
135
+ self.dropped = 0
136
+ self.total_polls = 0
137
+ # counted as it happens: the window keeps 25 samples, so a value new on each of 1337 polls read as 24
138
+ self.fail_polls = 0
139
+ self.error_polls = 0
140
+ self.value_changes = 0
141
+ # since the last raising poll, because "value then changed" is a claim about what followed the recovery
142
+ self.changes_after_last_error = 0
143
+ self.uncomparable = False
144
+ self.last_change_elapsed: float | None = None
145
+ self.error_type: type | None = None
146
+ self.mixed_error_types = False
147
+ self.last_outcome: str | None = None
148
+ self._last_fail_key: str | None = None
149
+
150
+ def record(self, elapsed, outcome, value, detail, raw=_PROBE_UNSET, error_type=None):
151
+ self.total_polls += 1
152
+ self.last_outcome = outcome
153
+ key = None
154
+ if outcome == "fail":
155
+ self.fail_polls += 1
156
+ # against the last value a poll returned: a probe that raises in between still moved from A to B
157
+ key = _change_key(value if raw is _PROBE_UNSET else raw)
158
+ if key is None:
159
+ self.uncomparable = True
160
+ elif self._last_fail_key is not None and key != self._last_fail_key:
161
+ self.value_changes += 1
162
+ self.changes_after_last_error += 1
163
+ self.last_change_elapsed = elapsed
164
+ if key is not None:
165
+ self._last_fail_key = key
166
+ else: # the only other outcome is "error"
167
+ self.error_polls += 1
168
+ self.changes_after_last_error = 0
169
+ # the class itself, by identity: two classes can share a `__name__`
170
+ if error_type is not None:
171
+ if self.error_type is None:
172
+ self.error_type = error_type
173
+ elif error_type is not self.error_type:
174
+ self.mixed_error_types = True
175
+ last = self._tail[-1] if self._tail else (self._head[-1] if self._head else None)
176
+ last_key = self._tail_keys[-1] if self._tail_keys else (self._head_keys[-1] if self._head_keys else None)
177
+ # decided by the key: two polls whose values differ past the cut are two polls
178
+ same_value = (
179
+ key == last_key and key is not None if outcome == "fail" else last is not None and last.value == value
180
+ )
181
+ if last is not None and last.outcome == outcome and last.detail == detail and same_value:
182
+ collapsed = dataclasses.replace(last, repeats=last.repeats + 1)
183
+ if self._tail:
184
+ self._tail[-1] = collapsed
185
+ else:
186
+ self._head[-1] = collapsed
187
+ return
188
+ sample = PollSample(elapsed=elapsed, outcome=outcome, value=value, detail=detail)
189
+ if len(self._head) < self._head_limit:
190
+ self._head.append(sample)
191
+ self._head_keys.append(key)
192
+ return
193
+ if len(self._tail) == self._tail.maxlen:
194
+ self.dropped += 1
195
+ self._tail.append(sample)
196
+ self._tail_keys.append(key)
197
+
198
+ def build(self, elapsed) -> PollTrace:
199
+ samples = self._head + list(self._tail)
200
+ self.kept_keys = self._head_keys + list(self._tail_keys)
201
+ return PollTrace(
202
+ samples=samples,
203
+ total_polls=self.total_polls,
204
+ dropped=self.dropped,
205
+ elapsed=elapsed,
206
+ summary=_summarize(samples, self, elapsed),
207
+ )
208
+
209
+
210
+ def _summarize(samples, recorder, elapsed) -> str:
211
+ """Classify the convergence trend of a timed-out poll into one diagnostic sentence.
212
+
213
+ Every count and every type here comes from the recorder, which saw all of the run. The retained
214
+ samples are read for one thing only, the *shape* of what repeated, and that sentence names the polls
215
+ it was read from rather than borrowing the run's total.
216
+ """
217
+ total_polls = recorder.total_polls
218
+ if recorder.fail_polls == 0:
219
+ raised = (
220
+ "exceptions" if recorder.mixed_error_types or recorder.error_type is None else recorder.error_type.__name__
221
+ )
222
+ return f"probe raised {raised} on all {total_polls} polls"
223
+ change_word = "time" if recorder.value_changes == 1 else "times"
224
+ if recorder.error_polls:
225
+ poll_word = "poll" if recorder.error_polls == 1 else "polls"
226
+ since = recorder.changes_after_last_error
227
+ # the run-wide total counts movement from before the last raising poll as well
228
+ if recorder.uncomparable:
229
+ trend = "value could not be compared"
230
+ elif since:
231
+ trend = f"value then changed {since} {'time' if since == 1 else 'times'}"
232
+ else:
233
+ trend = "value then never changed"
234
+ # "recovered" is a claim about order, and a probe that raised, returned and raised again ended raising
235
+ if recorder.last_outcome == "fail":
236
+ return f"probe recovered after {recorder.error_polls} raising {poll_word}; {trend}"
237
+ return f"probe raised on {recorder.error_polls} of {total_polls} polls; {trend}"
238
+ if recorder.uncomparable:
239
+ # no count of movement would be one this recorder took. Reached only where every poll returned
240
+ return f"value could not be compared across {total_polls} polls"
241
+ if not recorder.value_changes:
242
+ return f"value unchanged across {total_polls} polls"
243
+ kept = [(sample, key) for sample, key in zip(samples, recorder.kept_keys, strict=True) if sample.outcome == "fail"]
244
+ fails = [sample for sample, _ in kept]
245
+ keys = [key for _, key in kept]
246
+ kept_changes = [right for left, right in pairwise(keys) if left != right]
247
+ distinct = {key for key in keys if key is not None}
248
+ # a simple path through k values takes k-1 changes, so a surplus means the probe returned to a reported value
249
+ if len(kept_changes) >= len(distinct):
250
+ kept_polls = sum(sample.repeats for sample in fails)
251
+ over = f"in the {kept_polls} polls kept" if recorder.dropped else f"across {total_polls} polls"
252
+ return f"value cycles between {len(distinct)} states {over}"
253
+ # recorded when it happened rather than read back off the retained samples
254
+ last_change = elapsed - (recorder.last_change_elapsed or 0.0)
255
+ return f"value changed {recorder.value_changes} {change_word}; last change {last_change:.1f}s before the deadline"
256
+
257
+
258
+ def _last_failure_text(exc: BaseException) -> str:
259
+ """The inner failure's headline, without whatever its own rendering would append.
260
+
261
+ Outside pytest a failure renders its diff into ``str()``, and the timeout message quotes that text
262
+ while carrying the same diff itself: the reader saw the block twice, once inside the sentence and
263
+ once under it. Under pytest the plugin turns that rendering off, so the doubling only ever showed
264
+ up where the message is all there is, which is the surface it was added for.
265
+ """
266
+ if isinstance(exc, AssertionFailure):
267
+ # our own class, and the only undecorated form of its message
268
+ return exc._message
269
+ # reading an ignored exception's `__repr__` is running the caller's code, and one that raised escaped the poll
270
+ return _safe_str(exc) if isinstance(exc, AssertionError) else _safe_repr(exc)
271
+
272
+
273
+ def _duration(value: object, name: str, *, endless: bool) -> float:
274
+ """*value* as the seconds a poll waits, refused where it is given rather than inside the loop.
275
+
276
+ A NaN timeout compared false against every clock reading and polled forever. A string or a `Decimal`
277
+ failed in the deadline arithmetic, and an interval `time.sleep` cannot take failed in its C code, while
278
+ the async loop slept a negative one as zero. An endless timeout is kept, since waiting until the
279
+ condition holds is a thing to ask for, and one past the float range is endless.
280
+ """
281
+ if isinstance(value, bool) or not isinstance(value, numbers.Real):
282
+ refuse(value, "a number of seconds", subject=argument(name))
283
+ try:
284
+ seconds = float(value)
285
+ except OverflowError:
286
+ seconds = -math.inf if value < 0 else math.inf
287
+ if _is_nan(seconds) or seconds < 0 or (not endless and _is_infinite(seconds)):
288
+ shape = "a non-negative number of seconds" if endless else "a finite, non-negative number of seconds"
289
+ raise ValueError(f"given {name} arg must be {shape}, but was <{value}>")
290
+ return seconds
291
+
292
+
293
+ def _seconds(timeout: float) -> str:
294
+ """The budget as it was given: `:.1f` printed 0.04 as 0.0, and `:g` rounds past six digits."""
295
+ return str(float(timeout))
296
+
297
+
298
+ def _timeout_failure(recorder: _PollRecorder | None, timeout: float, elapsed: float, failure: str):
299
+ """Build the ``(message, trace)`` pair for a timed-out poll; without a recorder there is no trace."""
300
+ if recorder is None:
301
+ return f"Expected condition not met after {_seconds(timeout)} seconds. Last failure: {failure}", None
302
+ trace = recorder.build(elapsed)
303
+ message = f"Expected condition not met after {_seconds(timeout)} seconds ({trace.summary}). Last failure: {failure}"
304
+ return message, trace
305
+
306
+
307
+ def _structured_of(last_error: Exception | None) -> dict[str, Any]:
308
+ """The values and the diff of the assertion that kept failing, as `error()` keyword arguments.
309
+
310
+ Only the ones it actually named: `error()` reads "was this named" from whether the argument is there
311
+ at all, so passing everything would put an empty block under every collected timeout. Which ones
312
+ those were comes from the failure's own record rather than from a test against `None`, because
313
+ `is_equal_to(None)` names an expected value and a probe returning `None` provides an actual one.
314
+ """
315
+ outcome = getattr(last_error, "_outcome", None)
316
+ fields: dict[str, Any] = {}
317
+ if outcome is not None:
318
+ if outcome.actual_provided:
319
+ fields["actual"] = getattr(last_error, "actual", None)
320
+ if outcome.has_expected:
321
+ fields["expected"] = getattr(last_error, "expected", None)
322
+ else: # built by hand, by a snapshot re-wrap or by `eventually()` itself: the values are all there is
323
+ for name in ("actual", "expected"):
324
+ value = getattr(last_error, name, None)
325
+ if value is not None:
326
+ fields[name] = value
327
+ diff = getattr(last_error, "diff", None)
328
+ if diff is not None:
329
+ fields["diff"] = diff
330
+ # the inner assertion's, not the poll's: a timeout is that assertion's failure with a wait in front
331
+ if outcome is not None and outcome.requirement is not None:
332
+ fields["requirement"] = outcome.requirement
333
+ return fields
334
+
335
+
336
+ def _timed_out(message: str, trace: PollTrace | None, last_error: Exception | None) -> AssertionFailure:
337
+ """The timeout failure, carrying the structured payload of the assertion that kept failing.
338
+
339
+ A polling failure *is* that assertion's failure with a waiting line in front of it, so dropping the
340
+ values and the diff on the way out left the two surfaces unequal where it mattered most: under
341
+ pytest the diff section is built from `exc.diff`, and a timed-out equality printed no section at all
342
+ while the same equality outside a poll printed the full one.
343
+
344
+ The record travels with them. Without it the report falls back to reading "was this named" off a
345
+ test against `None`, which is wrong in both directions: `is_equal_to(None)` lost its expected value,
346
+ a probe returning `None` lost its actual one, and `is_not_empty()`, which names neither, gained a
347
+ block of values nobody asked about.
348
+ """
349
+ failure = AssertionFailure(
350
+ message,
351
+ actual=getattr(last_error, "actual", None),
352
+ expected=getattr(last_error, "expected", None),
353
+ diff=getattr(last_error, "diff", None),
354
+ trace=trace,
355
+ requirement=getattr(last_error, "requirement", None),
356
+ )
357
+ failure._outcome = getattr(last_error, "_outcome", None)
358
+ failure._compared = _pair_kept(last_error)
359
+ return failure
360
+
361
+
362
+ def _pair_kept(last_error: Exception | None) -> tuple[object, object] | None:
363
+ """The pair the attempt that kept failing compared, read off a failure of this library's class alone.
364
+
365
+ Anything may sit under the name on another exception, and one that raises when read must not cost the
366
+ timeout it is being read for.
367
+ """
368
+ if not isinstance(last_error, AssertionFailure):
369
+ return None
370
+ try:
371
+ return last_error._compared
372
+ except Exception: # a diagnostic must never outrank the failure it is describing
373
+ return None
374
+
375
+
376
+ def _normalize_ignoring(ignoring) -> tuple[type[Exception], ...]:
377
+ """Normalize an ``ignoring`` spec (one exception type, or a union or a tuple of them) to a flat validated tuple.
378
+
379
+ Only ``Exception`` subclasses are accepted, so ``BaseException``-only classes such as
380
+ ``KeyboardInterrupt`` can never be swallowed by the polling loop. Flat because ``except`` takes a class
381
+ or a tuple of classes and no union, and ``()`` stays the default it always was: nothing is retried.
382
+ """
383
+ expectation = "an Exception subclass, or a union or a tuple of them"
384
+ return named_classes(ignoring, Exception, expectation, subject="ignoring", allow_empty=True)
385
+
386
+
387
+ def _replay(builder_func: Callable, val: object, description: str, steps: tuple[_Step, ...], quiet: int = 0) -> Any:
388
+ """Run a chain's recorded calls against a fresh builder over *val*, returning the last result.
389
+
390
+ The first *quiet* steps report nothing to a `log_passes()` block, having been reported by the call that added them.
391
+ """
392
+ builder = builder_func(val, description)
393
+ # inside an observed call every step is already quiet, and a failure has to mark that call
394
+ if quiet and _passes.open_blocks and _passes.current_call() is None:
395
+ return _replay_quietly(builder, steps, quiet)
396
+ for step, step_args, step_kwargs in steps:
397
+ attribute = getattr(builder, step)
398
+ # `not_` is read, not called, so it has no arguments
399
+ builder = attribute if step_args is None or step_kwargs is None else attribute(*step_args, **step_kwargs)
400
+ return builder
401
+
402
+
403
+ def _replay_quietly(builder: Any, steps: tuple[_Step, ...], quiet: int) -> Any:
404
+ """`_replay`'s loop with the first *quiet* steps run as if inside another assertion, which reports for them."""
405
+ for index, (step, step_args, step_kwargs) in enumerate(steps):
406
+ marker = _passes.Observed() if index < quiet else None
407
+ token = _passes.observed.set(marker) if marker is not None else None
408
+ try:
409
+ attribute = getattr(builder, step)
410
+ builder = attribute if step_args is None or step_kwargs is None else attribute(*step_args, **step_kwargs)
411
+ finally:
412
+ if marker is not None and token is not None:
413
+ marker.alive = False
414
+ _passes.observed.reset(token)
415
+ return builder
416
+
417
+
418
+ def _record_poll(recorder: _PollRecorder | None, exc: Exception, probed: object, elapsed: float) -> str:
419
+ """Record one failed poll, when this chain is recording, and hand back the inner failure's text."""
420
+ failure = _last_failure_text(exc)
421
+ if recorder is not None:
422
+ recorder.record(
423
+ elapsed=elapsed,
424
+ # a probe that raised returned no value, so nothing here failed a check
425
+ outcome="fail" if probed is not _PROBE_UNSET else "error",
426
+ # snapshot now: probes commonly mutate and return the same object
427
+ value=_json_safe(probed) if probed is not _PROBE_UNSET else None,
428
+ detail=failure,
429
+ raw=probed,
430
+ error_type=None if probed is not _PROBE_UNSET else type(exc),
431
+ )
432
+ return failure
433
+
434
+
435
+ _NO_VERDICT_AFTER_A_POLL: Final = (
436
+ "check() cannot follow a poll; a poll delivers its own failure, so there is no verdict to hand back"
437
+ )
438
+
439
+ _NO_VALUE_ON_A_CHAIN: Final = "value cannot be read on a polling chain; read `.val` once an assertion on it has passed"
440
+
441
+
442
+ def _out_of_time(
443
+ chain: AsyncAssertionBuilder | SyncAssertionBuilder,
444
+ recorder: _PollRecorder | None,
445
+ elapsed: float,
446
+ failure: str,
447
+ last_error: Exception | None,
448
+ ) -> Any:
449
+ """Deliver the timeout the way this chain's mode asks for: collected, logged, or raised."""
450
+ message, trace = _timeout_failure(recorder, chain._timeout, elapsed, failure)
451
+ if chain._kind in ("soft", "warn"):
452
+ # empty description: the inner failure already carries it, and two would read as a double prefix
453
+ # over the value the failing assertion saw, as the raised timeout records it: over `None` it recorded `None`
454
+ chain._builder_func(getattr(last_error, "actual", None), "", chain._kind, None, chain._logger).error(
455
+ message, **_structured_of(last_error), trace=trace
456
+ )
457
+ # the poll never reached a value, so anything after it asserted about `None` and failed again.
458
+ # It carries the timeout, which is the failure a reader acts on: without it `check()` answered
459
+ # `passed=False` with an empty message and the refusals named the rule and not the cause
460
+ return _InertBuilder(message)
461
+ raise _timed_out(message, trace, last_error) from last_error
462
+
463
+
464
+ class AsyncAssertionBuilder:
465
+ """Async assertion builder that polls a callable until an assertion passes or timeout expires.
466
+
467
+ Do not instantiate directly; use [`eventually()`][assertpy.assertpy.AssertionBuilder.eventually] instead.
468
+
469
+ A chain records the calls made on it and runs them together when awaited, so the wait covers every
470
+ link rather than only the first. The four coroutine methods below make it a coroutine to
471
+ `asyncio.run()` and `Task`, which take nothing else below 3.15.
472
+
473
+ Args:
474
+ func: a sync or async callable that produces the value to test
475
+ builder_func: factory function to create assertion builders (receives ``val``, ``description``)
476
+ description: optional error description forwarded to the builder
477
+ timeout: maximum seconds to keep retrying
478
+ interval: seconds between retries
479
+ ignoring: exception types the polling loop retries instead of propagating
480
+ kind: the failure mode of the *final* timeout failure (``None``/``"soft"``/``"warn"``);
481
+ polling itself always retries on hard failures
482
+ logger: the logger for ``"warn"`` mode
483
+ trace: record a [`PollTrace`][assertpy.errors.PollTrace] of the polling timeline
484
+ (default ``True``); ``False`` skips the flight recorder entirely
485
+ """
486
+
487
+ def __init__(
488
+ self,
489
+ func: Callable,
490
+ *,
491
+ builder_func: Callable,
492
+ description: str = "",
493
+ timeout: float = 5.0,
494
+ interval: float = 0.5,
495
+ ignoring: tuple[type[Exception], ...] = (),
496
+ kind: str | None = None,
497
+ logger: object = None,
498
+ trace: bool = True,
499
+ steps: tuple[_Step, ...] = (),
500
+ ):
501
+ self._func = func
502
+ self._builder_func = builder_func
503
+ self._description = description
504
+ self._timeout = _duration(timeout, "timeout", endless=True)
505
+ self._interval = _duration(interval, "interval", endless=False)
506
+ self._ignoring = ignoring
507
+ self._kind = kind
508
+ self._logger = logger
509
+ self._trace = trace
510
+ self._steps = steps # replayed as a whole on every poll, see `_replay`
511
+ self._coro: Any = None
512
+ self._awaited = False
513
+ self._superseded = False
514
+
515
+ def _chained(self, steps: tuple[_Step, ...]) -> AsyncAssertionBuilder:
516
+ """A copy carrying one more step, so awaiting the chain waits for all of them together.
517
+
518
+ Marks the link it grew out of as handed on, or `__del__` would call it an abandoned chain.
519
+ """
520
+ self._superseded = True
521
+ return AsyncAssertionBuilder(
522
+ self._func,
523
+ builder_func=self._builder_func,
524
+ description=self._description,
525
+ timeout=self._timeout,
526
+ interval=self._interval,
527
+ ignoring=self._ignoring,
528
+ kind=self._kind,
529
+ logger=self._logger,
530
+ trace=self._trace,
531
+ steps=steps,
532
+ )
533
+
534
+ def within(self, timeout: float) -> Self:
535
+ """Override the timeout (in seconds).
536
+
537
+ Infinity, or a number past the float range, retries until the assertion passes.
538
+
539
+ Raises:
540
+ TypeError: if ``timeout`` is not a real number, or is a bool
541
+ ValueError: if ``timeout`` is negative or NaN
542
+ """
543
+ self._timeout = _duration(timeout, "timeout", endless=True)
544
+ return self
545
+
546
+ def every(self, interval: float) -> Self:
547
+ """Override the polling interval (in seconds).
548
+
549
+ Raises:
550
+ TypeError: if ``interval`` is not a real number, or is a bool
551
+ ValueError: if ``interval`` is negative, NaN or infinite
552
+ """
553
+ self._interval = _duration(interval, "interval", endless=False)
554
+ return self
555
+
556
+ def ignoring(self, *exceptions: type[Exception] | RetriedInfo) -> Self:
557
+ """Replace the exception types the polling loop retries instead of propagating.
558
+
559
+ Examples:
560
+ Usage:
561
+
562
+ await assert_that(get_order).eventually().within(10).ignoring(ConnectionError).has_status("PAID")
563
+
564
+ Raises:
565
+ TypeError: if any argument is not an ``Exception`` subclass
566
+ """
567
+ self._ignoring = _normalize_ignoring(exceptions)
568
+ return self
569
+
570
+ def __getattr__(self, name: str) -> Any:
571
+ # `Any` rather than the inferred union, which made a checker refuse `eventually().is_equal_to(1)`
572
+ if name.startswith("_"):
573
+ raise AttributeError(name)
574
+ # forwarded whole: the state attribute is `cr_suspended` on 3.11 and `cr_state` on 3.14, and `gi_` is refused
575
+ if name.startswith("gi_"):
576
+ raise AttributeError(name)
577
+ if name.startswith("cr_"):
578
+ if not self._steps:
579
+ raise AttributeError(name) # nothing recorded yet, so there is no coroutine to describe
580
+ return getattr(self._coroutine(), name)
581
+ if name == "check":
582
+ raise TypeError(_NO_VERDICT_AFTER_A_POLL)
583
+ if name in ("val", "value"):
584
+ raise AttributeError(f"{name} is available on the builder that awaiting this chain returns")
585
+ if name == "not_":
586
+ return self._chained((*self._steps, (name, None, None))) # read, not called: no arguments
587
+
588
+ def _add(*args: Any, **kwargs: Any) -> AsyncAssertionBuilder:
589
+ return self._chained((*self._steps, (name, args, kwargs)))
590
+
591
+ return _add
592
+
593
+ def _coroutine(self) -> Any:
594
+ """The coroutine this chain runs, made on first use and kept, since a coroutine runs only once."""
595
+ if self._coro is None:
596
+ self._coro = self._poll()
597
+ return self._coro
598
+
599
+ def _started(self) -> Any:
600
+ """The coroutine, marked as somebody's responsibility, which is what running it makes it.
601
+
602
+ A closed chain needs no guard here: `close()` closes the coroutine, and awaiting a closed one
603
+ already raises the "cannot reuse already awaited coroutine" a plain coroutine raises.
604
+ """
605
+ if all(WITHOUT_A_VERDICT.get(name) == DESCRIBES for name, _, _ in self._steps):
606
+ self._awaited = True # refused here, so not also reported as never awaited once it is collected
607
+ raise TypeError("no assertion was called on this eventually() chain, so awaiting it would wait for nothing")
608
+ coro = self._coroutine()
609
+ self._awaited = True
610
+ return coro
611
+
612
+ def __await__(self) -> Generator[Any, None, Any]:
613
+ """Poll until every call recorded on this chain passes together, or the timeout expires.
614
+
615
+ Returns the ordinary builder over the value that passed, so anything asked of it afterwards is
616
+ a plain assertion on that settled value rather than a new wait.
617
+ """
618
+ return self._started().__await__()
619
+
620
+ # these three with `__await__` are what `Coroutine` checks structurally, so `asyncio.run(chain)` works below 3.15
621
+ def send(self, value: Any) -> Any:
622
+ return self._started().send(value)
623
+
624
+ def throw(self, *args: Any, **kwargs: Any) -> Any:
625
+ return self._started().throw(*args, **kwargs)
626
+
627
+ def close(self) -> None:
628
+ self._awaited = True # an asked-for discard, and Python says nothing about a closed coroutine
629
+ if self._steps: # made if it does not exist, so it refuses to run and reads as closed
630
+ self._coroutine().close()
631
+
632
+ def __del__(self) -> None:
633
+ """Warn about a chain built and never awaited: only its end is awaited, so only its end warns."""
634
+ coro = getattr(self, "_coro", None) # a construction that raised leaves none of these fields
635
+ # asked of the coroutine rather than tracked by a flag: `__await__()` can be called and its iterator dropped
636
+ unstarted = False
637
+ if coro is not None and inspect.getcoroutinestate(coro) == inspect.CORO_CREATED:
638
+ coro.close() # or Python warns about it too, naming this module rather than the chain
639
+ unstarted = True
640
+ steps = getattr(self, "_steps", ())
641
+ if not steps or self._superseded or warnings is None: # None once shutdown clears it
642
+ return
643
+ if self._awaited and not unstarted:
644
+ return
645
+ # unguarded: under `filterwarnings = error` this raises, and that raise is the point
646
+ warnings.warn(
647
+ f"an eventually() chain ending in {steps[-1][0]}() was never awaited, so nothing was asserted",
648
+ RuntimeWarning,
649
+ stacklevel=1, # the stack at collection time is not the code that built the chain
650
+ )
651
+
652
+ async def _poll(self) -> Any:
653
+ # not at module level: asyncio costs 21ms of a 59ms import and only this path needs it, already inside a loop
654
+ import asyncio
655
+
656
+ loop = asyncio.get_running_loop()
657
+ start = loop.time()
658
+ deadline = start + self._timeout
659
+ recorder = _PollRecorder() if self._trace else None
660
+ last_error: Exception | None = None
661
+ while True:
662
+ probed = _PROBE_UNSET
663
+ # held across the probe as well, whose own assertions are part of the attempt; released before the backoff
664
+ kept = _passes.holding() if _passes.open_blocks else None
665
+ token = _passes.held.set(kept) if kept is not None else None
666
+ try:
667
+ try:
668
+ val = self._func()
669
+ if inspect.isawaitable(val):
670
+ val = await val
671
+ probed = val
672
+ builder = _replay(self._builder_func, val, self._description, self._steps)
673
+ finally:
674
+ if kept is not None and token is not None:
675
+ _passes.release(kept, token)
676
+ if _COLLECT_RETRIES and recorder is not None and recorder.total_polls:
677
+ # a probe that only converges after retrying is the one that goes flaky in CI
678
+ _RETRIES.append((recorder.total_polls + 1, loop.time() - start, self._timeout))
679
+ except (
680
+ AssertionError,
681
+ *self._ignoring,
682
+ ) as exc: # retry-on-failure needs the try/except per poll iteration
683
+ last_error = exc
684
+ failure = _record_poll(recorder, exc, probed, loop.time() - start)
685
+ now = loop.time()
686
+ if now >= deadline:
687
+ return _out_of_time(self, recorder, now - start, failure, last_error)
688
+ await asyncio.sleep(min(self._interval, deadline - now))
689
+ continue
690
+ if kept is not None:
691
+ _passes.flush(kept)
692
+ return builder
693
+
694
+
695
+ class SyncAssertionBuilder:
696
+ """Blocking assertion builder that polls a sync callable until an assertion passes or timeout expires.
697
+
698
+ Do not instantiate directly; use
699
+ [`eventually_sync()`][assertpy.assertpy.AssertionBuilder.eventually_sync] instead.
700
+
701
+ Args:
702
+ func: a sync callable that produces the value to test (an async probe raises ``TypeError``)
703
+ builder_func: factory function to create assertion builders (receives ``val``, ``description``)
704
+ description: optional error description forwarded to the builder
705
+ timeout: maximum seconds to keep retrying
706
+ interval: seconds between retries
707
+ ignoring: exception types the polling loop retries instead of propagating
708
+ kind: the failure mode of the *final* timeout failure (``None``/``"soft"``/``"warn"``);
709
+ polling itself always retries on hard failures
710
+ logger: the logger for ``"warn"`` mode
711
+ trace: record a [`PollTrace`][assertpy.errors.PollTrace] of the polling timeline
712
+ (default ``True``); ``False`` skips the flight recorder entirely
713
+ """
714
+
715
+ def __init__(
716
+ self,
717
+ func: Callable,
718
+ *,
719
+ builder_func: Callable,
720
+ description: str = "",
721
+ timeout: float = 5.0,
722
+ interval: float = 0.5,
723
+ ignoring: tuple[type[Exception], ...] = (),
724
+ kind: str | None = None,
725
+ logger: object = None,
726
+ trace: bool = True,
727
+ steps: tuple[_Step, ...] = (),
728
+ last: Any = None,
729
+ ):
730
+ self._func = func
731
+ self._builder_func = builder_func
732
+ self._description = description
733
+ self._timeout = _duration(timeout, "timeout", endless=True)
734
+ self._interval = _duration(interval, "interval", endless=False)
735
+ self._ignoring = ignoring
736
+ self._kind = kind
737
+ self._logger = logger
738
+ self._trace = trace
739
+ self._steps = steps # replayed as a whole on every poll, see `_replay`
740
+ self._last = last
741
+
742
+ @property
743
+ def val(self) -> object:
744
+ """The value the last passing poll saw.
745
+
746
+ Declared on the class rather than left to `__getattr__`, which answers every other name with a
747
+ polling call: reading `.val` off a chain would otherwise poll once and hand back a function.
748
+
749
+ Before anything has passed there is no such value, and `__getattr__` says so. A raise here
750
+ would not: Python falls back to `__getattr__` whenever an attribute lookup ends in
751
+ `AttributeError`, property included, so the message would have been swallowed and answered with
752
+ a polling call all the same.
753
+ """
754
+ return self._last.val
755
+
756
+ def _chained(self, steps: tuple[_Step, ...], last: Any = None) -> SyncAssertionBuilder:
757
+ """A copy carrying one more step, so the next call in the chain polls with all of them."""
758
+ return SyncAssertionBuilder(
759
+ self._func,
760
+ builder_func=self._builder_func,
761
+ description=self._description,
762
+ timeout=self._timeout,
763
+ interval=self._interval,
764
+ ignoring=self._ignoring,
765
+ kind=self._kind,
766
+ logger=self._logger,
767
+ trace=self._trace,
768
+ steps=steps,
769
+ last=self._last if last is None else last,
770
+ )
771
+
772
+ def within(self, timeout: float) -> Self:
773
+ """Override the timeout (in seconds).
774
+
775
+ Infinity, or a number past the float range, retries until the assertion passes.
776
+
777
+ Raises:
778
+ TypeError: if ``timeout`` is not a real number, or is a bool
779
+ ValueError: if ``timeout`` is negative or NaN
780
+ """
781
+ self._timeout = _duration(timeout, "timeout", endless=True)
782
+ return self
783
+
784
+ def every(self, interval: float) -> Self:
785
+ """Override the polling interval (in seconds).
786
+
787
+ Raises:
788
+ TypeError: if ``interval`` is not a real number, or is a bool
789
+ ValueError: if ``interval`` is negative, NaN or infinite
790
+ """
791
+ self._interval = _duration(interval, "interval", endless=False)
792
+ return self
793
+
794
+ def ignoring(self, *exceptions: type[Exception] | RetriedInfo) -> Self:
795
+ """Replace the exception types the polling loop retries instead of propagating.
796
+
797
+ Examples:
798
+ Usage:
799
+
800
+ assert_that(get_order).eventually_sync().within(10).ignoring(ConnectionError).has_status("PAID")
801
+
802
+ Raises:
803
+ TypeError: if any argument is not an ``Exception`` subclass
804
+ """
805
+ self._ignoring = _normalize_ignoring(exceptions)
806
+ return self
807
+
808
+ def __getattr__(self, name: str) -> Any:
809
+ # `Any` rather than the inferred union, which made a checker refuse `eventually_sync().is_equal_to(1)`
810
+ if name.startswith("_"):
811
+ raise AttributeError(name)
812
+ if name == "check":
813
+ raise TypeError(_NO_VERDICT_AFTER_A_POLL)
814
+ if name == "value":
815
+ raise TypeError(_NO_VALUE_ON_A_CHAIN)
816
+ if name == "val": # reached only when the property above found no poll to read it from
817
+ raise AttributeError("val is available once an assertion on this chain has passed")
818
+ if name == "not_":
819
+ # a property, so it joins the chain and is re-taken per poll: read straight, `.not_.is_equal_to(1)` died
820
+ return self._chained((*self._steps, (name, None, None)))
821
+
822
+ def _run(*args, **kwargs):
823
+ steps = (*self._steps, (name, args, kwargs))
824
+ start = time.monotonic()
825
+ deadline = start + self._timeout
826
+ recorder = _PollRecorder() if self._trace else None
827
+ last_error: Exception | None = None
828
+ while True:
829
+ probed = _PROBE_UNSET
830
+ kept = _passes.holding() if _passes.open_blocks else None
831
+ token = _passes.held.set(kept) if kept is not None else None
832
+ try:
833
+ try:
834
+ val = self._func()
835
+ if inspect.isawaitable(val):
836
+ if inspect.iscoroutine(val):
837
+ val.close() # an orphaned coroutine would warn "never awaited" at GC time
838
+ raise TypeError(
839
+ "given probe returned an awaitable; use eventually() and await it for async probes"
840
+ )
841
+ probed = val
842
+ # a blocking chain polls all of it at every call, and the call reports the step it added
843
+ builder = _replay(self._builder_func, val, self._description, steps, len(steps) - 1)
844
+ finally:
845
+ if kept is not None and token is not None:
846
+ _passes.release(kept, token)
847
+ if _COLLECT_RETRIES and recorder is not None and recorder.total_polls:
848
+ _RETRIES.append((recorder.total_polls + 1, time.monotonic() - start, self._timeout))
849
+ except (
850
+ AssertionError,
851
+ *self._ignoring,
852
+ ) as exc: # retry-on-failure needs the try/except per poll iteration
853
+ last_error = exc
854
+ failure = _record_poll(recorder, exc, probed, time.monotonic() - start)
855
+ now = time.monotonic()
856
+ if now >= deadline:
857
+ return _out_of_time(self, recorder, now - start, failure, last_error)
858
+ time.sleep(min(self._interval, deadline - now))
859
+ continue
860
+ if kept is not None:
861
+ _passes.flush(kept)
862
+ return self._chained(steps, builder)
863
+
864
+ return _run