assertpy 2.35.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- assertpy/__init__.py +87 -0
- assertpy/_clustering.py +511 -0
- assertpy/_dangling.py +505 -0
- assertpy/_engine/__init__.py +1 -0
- assertpy/_engine/_builder_check_typing.py +1365 -0
- assertpy/_engine/_capable_typing.py +772 -0
- assertpy/_engine/_check_typing.py +753 -0
- assertpy/_engine/_compare.py +983 -0
- assertpy/_engine/_compat.py +38 -0
- assertpy/_engine/_contract.py +1926 -0
- assertpy/_engine/_diff.py +1058 -0
- assertpy/_engine/_equality.py +843 -0
- assertpy/_engine/_introspection.py +729 -0
- assertpy/_engine/_membership.py +522 -0
- assertpy/_engine/_mixin_base.py +258 -0
- assertpy/_engine/_negated_typing.py +2176 -0
- assertpy/_engine/_operations.py +75 -0
- assertpy/_engine/_ordering.py +884 -0
- assertpy/_engine/_pairing.py +70 -0
- assertpy/_engine/_path.py +158 -0
- assertpy/_engine/_poll_typing.py +6039 -0
- assertpy/_engine/_require.py +312 -0
- assertpy/_engine/_size.py +31 -0
- assertpy/_engine/_text.py +39 -0
- assertpy/_engine/_typing.py +1226 -0
- assertpy/_hints.py +1213 -0
- assertpy/_inline.py +484 -0
- assertpy/_matcher_impls.py +1876 -0
- assertpy/_passes.py +249 -0
- assertpy/_renamed.py +64 -0
- assertpy/_satisfies.py +835 -0
- assertpy/_snapshot_codec.py +290 -0
- assertpy/assertpy.py +2640 -0
- assertpy/async_assertions.py +864 -0
- assertpy/base.py +862 -0
- assertpy/behave_matchers.py +96 -0
- assertpy/bytes_mixin.py +158 -0
- assertpy/collection.py +528 -0
- assertpy/contains.py +1068 -0
- assertpy/dataframe.py +200 -0
- assertpy/date.py +327 -0
- assertpy/dict.py +249 -0
- assertpy/dynamic.py +222 -0
- assertpy/errors.py +782 -0
- assertpy/exception.py +838 -0
- assertpy/extracting.py +266 -0
- assertpy/file.py +324 -0
- assertpy/helpers.py +747 -0
- assertpy/http_mixin.py +218 -0
- assertpy/json_mixin.py +1265 -0
- assertpy/matchers.py +639 -0
- assertpy/numeric.py +696 -0
- assertpy/outcome.py +163 -0
- assertpy/py.typed +0 -0
- assertpy/pytest_plugin.py +1650 -0
- assertpy/snapshot.py +1031 -0
- assertpy/string.py +803 -0
- assertpy/warning.py +137 -0
- assertpy-2.35.0.dist-info/METADATA +324 -0
- assertpy-2.35.0.dist-info/RECORD +63 -0
- assertpy-2.35.0.dist-info/WHEEL +4 -0
- assertpy-2.35.0.dist-info/entry_points.txt +2 -0
- assertpy-2.35.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,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
|