python-wrap 0.2.1__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.
- python_wrap/__init__.py +272 -0
- python_wrap/_core.py +212 -0
- python_wrap/aio.py +620 -0
- python_wrap/batching.py +556 -0
- python_wrap/bulkhead.py +431 -0
- python_wrap/caching.py +1613 -0
- python_wrap/circuit.py +553 -0
- python_wrap/concurrency.py +1886 -0
- python_wrap/config.py +217 -0
- python_wrap/deadline.py +168 -0
- python_wrap/decorators.py +7 -0
- python_wrap/devtools.py +385 -0
- python_wrap/errors.py +86 -0
- python_wrap/events.py +161 -0
- python_wrap/hedging.py +192 -0
- python_wrap/logs.py +767 -0
- python_wrap/metrics.py +242 -0
- python_wrap/periodic.py +491 -0
- python_wrap/policy.py +263 -0
- python_wrap/py.typed +0 -0
- python_wrap/retry.py +1324 -0
- python_wrap/state.py +500 -0
- python_wrap/testing.py +662 -0
- python_wrap/timing.py +535 -0
- python_wrap/validation.py +662 -0
- python_wrap-0.2.1.dist-info/METADATA +485 -0
- python_wrap-0.2.1.dist-info/RECORD +29 -0
- python_wrap-0.2.1.dist-info/WHEEL +4 -0
- python_wrap-0.2.1.dist-info/licenses/LICENSE +21 -0
python_wrap/testing.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
"""Helpers for testing code that uses python-wrap (and code that waits or retries).
|
|
2
|
+
|
|
3
|
+
Everything here is standard library only. ``pytest`` is optional: the fixtures at the
|
|
4
|
+
bottom exist only when it can be imported, so this module imports fine without it.
|
|
5
|
+
|
|
6
|
+
Enable the fixtures with ``pytest_plugins = ["python_wrap.testing"]`` in a top-level
|
|
7
|
+
``conftest.py``; they are ``pw_fake_clock`` and ``pw_fast_mode``.
|
|
8
|
+
|
|
9
|
+
Nesting semantics of the override helpers
|
|
10
|
+
-----------------------------------------
|
|
11
|
+
Overrides are one process-wide state (tests use threads, so context variables would
|
|
12
|
+
hide them from worker threads). Each field keeps the value it had before the first
|
|
13
|
+
active scope touched it, plus a stack of the values of the scopes currently active on
|
|
14
|
+
it. When a scope exits, the field takes the value of the most recent scope still active,
|
|
15
|
+
or its original value once none is left. So nested scopes restore correctly, and scopes
|
|
16
|
+
from different threads that overlap (enter A, enter B, exit A, exit B) cannot leak a
|
|
17
|
+
value: the field is back to its original value after the last one exits. While scopes
|
|
18
|
+
overlap, the most recently entered one that is still active wins.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import functools
|
|
25
|
+
import inspect
|
|
26
|
+
import math
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
import types
|
|
30
|
+
from collections.abc import Awaitable, Callable, Iterator
|
|
31
|
+
from contextlib import contextmanager
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any, Generic, NoReturn, ParamSpec, TypeVar, overload
|
|
34
|
+
|
|
35
|
+
from python_wrap import _core
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
import pytest as _pytest
|
|
39
|
+
except ImportError: # pragma: no cover - exercised only without pytest installed
|
|
40
|
+
_pytest = None # type: ignore[assignment]
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Call",
|
|
44
|
+
"FakeClock",
|
|
45
|
+
"Spy",
|
|
46
|
+
"disable_caching",
|
|
47
|
+
"disable_rate_limits",
|
|
48
|
+
"disable_retries",
|
|
49
|
+
"eventually",
|
|
50
|
+
"eventually_sync",
|
|
51
|
+
"fake_clock",
|
|
52
|
+
"fast_mode",
|
|
53
|
+
"overrides",
|
|
54
|
+
"spy",
|
|
55
|
+
"spy_on",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
P = ParamSpec("P")
|
|
59
|
+
R = TypeVar("R")
|
|
60
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _Unset:
|
|
64
|
+
def __repr__(self) -> str:
|
|
65
|
+
return "UNSET"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_UNSET = _Unset()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------------------- overrides
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_scope_lock = threading.Lock()
|
|
75
|
+
# Per overridden field: the value before the first active scope, and the stack of
|
|
76
|
+
# (scope token, value) for every scope currently active on it.
|
|
77
|
+
_baseline: dict[str, Any] = {}
|
|
78
|
+
_active: dict[str, list[tuple[object, Any]]] = {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _Scope:
|
|
82
|
+
"""Context manager and decorator that applies override fields and restores them."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, values: dict[str, Any]) -> None:
|
|
85
|
+
self._values = values
|
|
86
|
+
self._tokens: list[object] = []
|
|
87
|
+
|
|
88
|
+
def __enter__(self) -> None:
|
|
89
|
+
token = object()
|
|
90
|
+
with _scope_lock:
|
|
91
|
+
# set_overrides validates first, so a bad value leaves no bookkeeping behind.
|
|
92
|
+
previous = _core.set_overrides(**self._values)
|
|
93
|
+
for name, value in self._values.items():
|
|
94
|
+
stack = _active.setdefault(name, [])
|
|
95
|
+
if not stack:
|
|
96
|
+
_baseline[name] = previous[name]
|
|
97
|
+
stack.append((token, value))
|
|
98
|
+
self._tokens.append(token)
|
|
99
|
+
|
|
100
|
+
def __exit__(self, *exc: object) -> None:
|
|
101
|
+
token = self._tokens.pop()
|
|
102
|
+
with _scope_lock:
|
|
103
|
+
restore: dict[str, Any] = {}
|
|
104
|
+
for name in self._values:
|
|
105
|
+
stack = _active[name]
|
|
106
|
+
stack[:] = [entry for entry in stack if entry[0] is not token]
|
|
107
|
+
if stack:
|
|
108
|
+
restore[name] = stack[-1][1] # a scope is still active: its value wins
|
|
109
|
+
else:
|
|
110
|
+
restore[name] = _baseline.pop(name)
|
|
111
|
+
del _active[name]
|
|
112
|
+
_core.set_overrides(**restore)
|
|
113
|
+
|
|
114
|
+
def __call__(self, func: F) -> F:
|
|
115
|
+
values = self._values
|
|
116
|
+
if _core.is_async(func):
|
|
117
|
+
|
|
118
|
+
@functools.wraps(func)
|
|
119
|
+
async def awrapper(*args: Any, **kwargs: Any) -> Any:
|
|
120
|
+
with _Scope(values):
|
|
121
|
+
return await func(*args, **kwargs)
|
|
122
|
+
|
|
123
|
+
return awrapper # type: ignore[return-value]
|
|
124
|
+
|
|
125
|
+
@functools.wraps(func)
|
|
126
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
127
|
+
with _Scope(values):
|
|
128
|
+
return func(*args, **kwargs)
|
|
129
|
+
|
|
130
|
+
return wrapper # type: ignore[return-value]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def overrides(
|
|
134
|
+
*,
|
|
135
|
+
no_sleep: bool | _Unset = _UNSET,
|
|
136
|
+
max_attempts: int | _Unset | None = _UNSET,
|
|
137
|
+
disable_rate_limits: bool | _Unset = _UNSET,
|
|
138
|
+
disable_caching: bool | _Unset = _UNSET,
|
|
139
|
+
) -> _Scope:
|
|
140
|
+
"""Temporarily change the process-wide test overrides.
|
|
141
|
+
|
|
142
|
+
Use it as a context manager or as a decorator on sync and async functions. Only the
|
|
143
|
+
fields you pass are changed; on exit exactly those are restored. See the module
|
|
144
|
+
docstring for nesting semantics.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
no_sleep: Make `_core.sleep` / `_core.async_sleep` return immediately.
|
|
148
|
+
max_attempts: Cap `retry` at this many attempts (`None` removes the cap).
|
|
149
|
+
disable_rate_limits: Let every call through rate limiters and throttles.
|
|
150
|
+
disable_caching: Make caches always call the wrapped function.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ValueError: If `max_attempts` is not `None` or an integer >= 1.
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
```python
|
|
157
|
+
from python_wrap import _core
|
|
158
|
+
from python_wrap.testing import overrides
|
|
159
|
+
|
|
160
|
+
with overrides(no_sleep=True):
|
|
161
|
+
assert _core.overrides.no_sleep
|
|
162
|
+
assert not _core.overrides.no_sleep
|
|
163
|
+
```
|
|
164
|
+
"""
|
|
165
|
+
given = {
|
|
166
|
+
"no_sleep": no_sleep,
|
|
167
|
+
"max_attempts": max_attempts,
|
|
168
|
+
"disable_rate_limits": disable_rate_limits,
|
|
169
|
+
"disable_caching": disable_caching,
|
|
170
|
+
}
|
|
171
|
+
values = {k: v for k, v in given.items() if not isinstance(v, _Unset)}
|
|
172
|
+
attempts = values.get("max_attempts")
|
|
173
|
+
if attempts is not None and (not isinstance(attempts, int) or attempts < 1):
|
|
174
|
+
# Validate eagerly so a bad value fails at the call, not on entry.
|
|
175
|
+
raise ValueError("max_attempts must be None or an integer >= 1")
|
|
176
|
+
return _Scope(values)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def fast_mode() -> _Scope:
|
|
180
|
+
"""Make every wait instant while keeping retries running.
|
|
181
|
+
|
|
182
|
+
Sets `no_sleep=True` only: `retry` still retries the configured number of times and
|
|
183
|
+
rate limiters still count, but nothing actually sleeps. A blocking rate limiter
|
|
184
|
+
(`block=True`) that would have to wait lets the call through at once and records it,
|
|
185
|
+
so a later non-blocking call still sees the limit; under `fake_clock()` it waits on
|
|
186
|
+
the fake clock instead. Limits on how long to wait — `timeout`, `Bulkhead(timeout=)`,
|
|
187
|
+
`race(timeout=)`, `hedge` delays, `batch_calls` and `debounce` windows — run on real
|
|
188
|
+
time and are not shortened. Combine with `disable_retries()` if you also want a
|
|
189
|
+
single attempt. Context manager and decorator.
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
```python
|
|
193
|
+
import time
|
|
194
|
+
from python_wrap import retry
|
|
195
|
+
from python_wrap.testing import fast_mode
|
|
196
|
+
|
|
197
|
+
calls = []
|
|
198
|
+
|
|
199
|
+
@retry(max_attempts=3, delay=60)
|
|
200
|
+
def flaky() -> str:
|
|
201
|
+
calls.append(1)
|
|
202
|
+
if len(calls) < 3:
|
|
203
|
+
raise ConnectionError
|
|
204
|
+
return "ok"
|
|
205
|
+
|
|
206
|
+
start = time.monotonic()
|
|
207
|
+
with fast_mode():
|
|
208
|
+
assert flaky() == "ok"
|
|
209
|
+
assert time.monotonic() - start < 5
|
|
210
|
+
```
|
|
211
|
+
"""
|
|
212
|
+
return _Scope({"no_sleep": True})
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def disable_retries() -> _Scope:
|
|
216
|
+
"""Cap `retry` at one attempt (`max_attempts=1`). Context manager and decorator."""
|
|
217
|
+
return _Scope({"max_attempts": 1})
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def disable_rate_limits() -> _Scope:
|
|
221
|
+
"""Let every call through rate limiters and throttles. Context manager and decorator."""
|
|
222
|
+
return _Scope({"disable_rate_limits": True})
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def disable_caching() -> _Scope:
|
|
226
|
+
"""Make memoize/cache always call the wrapped function. Context manager and decorator."""
|
|
227
|
+
return _Scope({"disable_caching": True})
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------------------ clock
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass
|
|
234
|
+
class FakeClock:
|
|
235
|
+
"""A controllable clock. Sleeping advances it instead of waiting.
|
|
236
|
+
|
|
237
|
+
Attributes:
|
|
238
|
+
start: The initial reading. Defaults to the real `time.monotonic()` at creation,
|
|
239
|
+
rounded up to a whole second, so entries stamped before the fake clock was
|
|
240
|
+
installed (a cache filled at import time, say) are in the fake clock's past and
|
|
241
|
+
still expire as expected. The rounding keeps arithmetic exact: with an
|
|
242
|
+
arbitrary float start, ``(start + 10) - start`` can come out as ``9.999...``
|
|
243
|
+
and a 10 second threshold would not be reached. Pass `start=` for a fixed
|
|
244
|
+
reading.
|
|
245
|
+
now: The current reading (starts at `start`).
|
|
246
|
+
sleeps: Every duration passed to `sleep` / `async_sleep`, in order.
|
|
247
|
+
|
|
248
|
+
Example:
|
|
249
|
+
```python
|
|
250
|
+
from python_wrap.testing import FakeClock
|
|
251
|
+
|
|
252
|
+
clock = FakeClock(start=10.0)
|
|
253
|
+
clock.advance(5)
|
|
254
|
+
clock.sleep(2)
|
|
255
|
+
assert clock.now == 17.0
|
|
256
|
+
assert clock.sleeps == [2]
|
|
257
|
+
```
|
|
258
|
+
"""
|
|
259
|
+
|
|
260
|
+
start: float = field(default_factory=lambda: float(math.ceil(time.monotonic())))
|
|
261
|
+
now: float = field(init=False)
|
|
262
|
+
sleeps: list[float] = field(default_factory=list)
|
|
263
|
+
_lock: threading.Lock = field(
|
|
264
|
+
default_factory=threading.Lock, init=False, repr=False, compare=False
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def __reduce__(self) -> NoReturn:
|
|
268
|
+
_core.refuse_pickle(self, "Install a fake clock in each process that needs one.")
|
|
269
|
+
|
|
270
|
+
def __post_init__(self) -> None:
|
|
271
|
+
self.now = self.start
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def total_slept(self) -> float:
|
|
275
|
+
"""Sum of all recorded sleeps."""
|
|
276
|
+
return sum(self.sleeps)
|
|
277
|
+
|
|
278
|
+
def time(self) -> float:
|
|
279
|
+
"""The current reading (usable as `monotonic` / `perf_counter`)."""
|
|
280
|
+
return self.now
|
|
281
|
+
|
|
282
|
+
def advance(self, seconds: float) -> None:
|
|
283
|
+
"""Move the clock forward without recording a sleep.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
ValueError: If `seconds` is negative.
|
|
287
|
+
"""
|
|
288
|
+
if seconds < 0:
|
|
289
|
+
raise ValueError("cannot advance a clock backwards")
|
|
290
|
+
with self._lock:
|
|
291
|
+
self.now += seconds
|
|
292
|
+
|
|
293
|
+
def sleep(self, seconds: float) -> None:
|
|
294
|
+
"""Record a sleep and advance the clock by it (negative values advance nothing)."""
|
|
295
|
+
with self._lock:
|
|
296
|
+
self.sleeps.append(seconds)
|
|
297
|
+
self.now += max(seconds, 0.0)
|
|
298
|
+
|
|
299
|
+
async def async_sleep(self, seconds: float) -> None:
|
|
300
|
+
"""Like `sleep`, then yield to the event loop so other tasks can run."""
|
|
301
|
+
self.sleep(seconds)
|
|
302
|
+
await asyncio.sleep(0)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@contextmanager
|
|
306
|
+
def fake_clock(clock: FakeClock | None = None) -> Iterator[FakeClock]:
|
|
307
|
+
"""Patch `_core.monotonic/perf_counter/sleep/async_sleep` with a `FakeClock`.
|
|
308
|
+
|
|
309
|
+
The previous time sources are restored on exit, so it nests.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
clock: An existing clock to install; a fresh `FakeClock()` by default.
|
|
313
|
+
|
|
314
|
+
Yields:
|
|
315
|
+
The installed clock.
|
|
316
|
+
|
|
317
|
+
Example:
|
|
318
|
+
```python
|
|
319
|
+
from python_wrap import retry
|
|
320
|
+
from python_wrap.testing import fake_clock
|
|
321
|
+
|
|
322
|
+
calls = []
|
|
323
|
+
|
|
324
|
+
@retry(max_attempts=3, delay=1.0, backoff=2, on=ValueError)
|
|
325
|
+
def flaky() -> str:
|
|
326
|
+
calls.append(1)
|
|
327
|
+
if len(calls) < 3:
|
|
328
|
+
raise ValueError
|
|
329
|
+
return "ok"
|
|
330
|
+
|
|
331
|
+
with fake_clock() as clock:
|
|
332
|
+
assert flaky() == "ok"
|
|
333
|
+
assert clock.sleeps == [1.0, 2.0]
|
|
334
|
+
```
|
|
335
|
+
"""
|
|
336
|
+
clock = clock if clock is not None else FakeClock()
|
|
337
|
+
names = ("monotonic", "perf_counter", "sleep", "async_sleep")
|
|
338
|
+
with _core.overrides_lock:
|
|
339
|
+
saved = {name: getattr(_core, name) for name in names}
|
|
340
|
+
_core.monotonic = clock.time
|
|
341
|
+
_core.perf_counter = clock.time
|
|
342
|
+
_core.sleep = clock.sleep
|
|
343
|
+
_core.async_sleep = clock.async_sleep
|
|
344
|
+
try:
|
|
345
|
+
yield clock
|
|
346
|
+
finally:
|
|
347
|
+
with _core.overrides_lock:
|
|
348
|
+
for name, value in saved.items():
|
|
349
|
+
setattr(_core, name, value)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ------------------------------------------------------------------------------- spy
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@dataclass(frozen=True)
|
|
356
|
+
class Call:
|
|
357
|
+
"""One recorded call: arguments, outcome and duration (via `_core.perf_counter`)."""
|
|
358
|
+
|
|
359
|
+
args: tuple[Any, ...]
|
|
360
|
+
kwargs: dict[str, Any]
|
|
361
|
+
result: Any = None
|
|
362
|
+
exception: BaseException | None = None
|
|
363
|
+
duration: float = 0.0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class Spy(Generic[P, R]):
|
|
367
|
+
"""A recording wrapper around a function. Create with `spy`.
|
|
368
|
+
|
|
369
|
+
Works as a plain callable, as a decorator and on methods (bound access records
|
|
370
|
+
`self` as the first argument). For `async def` functions the spy is awaitable.
|
|
371
|
+
|
|
372
|
+
Attributes:
|
|
373
|
+
calls: Recorded `Call` objects, oldest first.
|
|
374
|
+
"""
|
|
375
|
+
|
|
376
|
+
def __init__(self, func: Callable[P, R]) -> None:
|
|
377
|
+
self._func = func
|
|
378
|
+
self.calls: list[Call] = []
|
|
379
|
+
self._lock = threading.Lock()
|
|
380
|
+
functools.update_wrapper(self, func)
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def call_count(self) -> int:
|
|
384
|
+
"""Number of recorded calls."""
|
|
385
|
+
return len(self.calls)
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def called(self) -> bool:
|
|
389
|
+
"""True once the spy has been called."""
|
|
390
|
+
return bool(self.calls)
|
|
391
|
+
|
|
392
|
+
def reset(self) -> None:
|
|
393
|
+
"""Forget recorded calls."""
|
|
394
|
+
with self._lock:
|
|
395
|
+
self.calls.clear()
|
|
396
|
+
|
|
397
|
+
def assert_called_with(self, *args: Any, **kwargs: Any) -> None:
|
|
398
|
+
"""Assert the most recent call used exactly these arguments.
|
|
399
|
+
|
|
400
|
+
Raises:
|
|
401
|
+
AssertionError: If never called or the last call differs.
|
|
402
|
+
"""
|
|
403
|
+
if not self.calls:
|
|
404
|
+
raise AssertionError("expected a call, but the spy was never called")
|
|
405
|
+
last = self.calls[-1]
|
|
406
|
+
if last.args != args or last.kwargs != kwargs:
|
|
407
|
+
raise AssertionError(
|
|
408
|
+
f"expected last call with args={args!r} kwargs={kwargs!r}, "
|
|
409
|
+
f"got args={last.args!r} kwargs={last.kwargs!r}"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def _record(self, call: Call) -> None:
|
|
413
|
+
with self._lock:
|
|
414
|
+
self.calls.append(call)
|
|
415
|
+
|
|
416
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
417
|
+
start = _core.perf_counter()
|
|
418
|
+
try:
|
|
419
|
+
result = self._func(*args, **kwargs)
|
|
420
|
+
except BaseException as exc:
|
|
421
|
+
self._record(Call(args, dict(kwargs), None, exc, _core.perf_counter() - start))
|
|
422
|
+
raise
|
|
423
|
+
self._record(Call(args, dict(kwargs), result, None, _core.perf_counter() - start))
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
def __get__(self, obj: object, owner: type | None = None) -> Any:
|
|
427
|
+
if obj is None:
|
|
428
|
+
return self
|
|
429
|
+
return types.MethodType(self, obj)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class _AsyncSpy(Spy[P, R]):
|
|
433
|
+
async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: # type: ignore[override]
|
|
434
|
+
start = _core.perf_counter()
|
|
435
|
+
try:
|
|
436
|
+
result: R = await self._func(*args, **kwargs) # type: ignore[misc]
|
|
437
|
+
except BaseException as exc:
|
|
438
|
+
self._record(Call(args, dict(kwargs), None, exc, _core.perf_counter() - start))
|
|
439
|
+
raise
|
|
440
|
+
self._record(Call(args, dict(kwargs), result, None, _core.perf_counter() - start))
|
|
441
|
+
return result
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@overload
|
|
445
|
+
def spy(func: Callable[P, Awaitable[R]]) -> Spy[P, Awaitable[R]]: ...
|
|
446
|
+
@overload
|
|
447
|
+
def spy(func: Callable[P, R]) -> Spy[P, R]: ...
|
|
448
|
+
def spy(func: Callable[P, Any]) -> Spy[P, Any]:
|
|
449
|
+
"""Wrap `func` so every call is recorded, without changing its behaviour.
|
|
450
|
+
|
|
451
|
+
Sync and async functions are supported (an `async def` spy records the awaited
|
|
452
|
+
result when the coroutine finishes). Exceptions are recorded and re-raised.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
func: The function to observe.
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
A `Spy` with `.calls`, `.call_count`, `.called`, `.reset()` and
|
|
459
|
+
`.assert_called_with(...)`.
|
|
460
|
+
|
|
461
|
+
Example:
|
|
462
|
+
```python
|
|
463
|
+
from python_wrap.testing import spy
|
|
464
|
+
|
|
465
|
+
@spy
|
|
466
|
+
def add(a: int, b: int) -> int:
|
|
467
|
+
return a + b
|
|
468
|
+
|
|
469
|
+
assert add(1, b=2) == 3
|
|
470
|
+
assert add.call_count == 1
|
|
471
|
+
add.assert_called_with(1, b=2)
|
|
472
|
+
assert add.calls[0].result == 3
|
|
473
|
+
```
|
|
474
|
+
"""
|
|
475
|
+
if _core.is_async(func):
|
|
476
|
+
return _AsyncSpy(func)
|
|
477
|
+
return Spy(func)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@contextmanager
|
|
481
|
+
def spy_on(obj: object, name: str) -> Iterator[Spy[..., Any]]:
|
|
482
|
+
"""Replace `obj.name` with a spy for the duration of the block, then restore it.
|
|
483
|
+
|
|
484
|
+
Works on modules, classes (including static and class methods) and instances.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
obj: The object holding the attribute.
|
|
488
|
+
name: The attribute (function or method) name.
|
|
489
|
+
|
|
490
|
+
Yields:
|
|
491
|
+
The `Spy` now installed.
|
|
492
|
+
|
|
493
|
+
Raises:
|
|
494
|
+
AttributeError: If `obj` has no attribute `name`.
|
|
495
|
+
|
|
496
|
+
Example:
|
|
497
|
+
```python
|
|
498
|
+
import math
|
|
499
|
+
from python_wrap.testing import spy_on
|
|
500
|
+
|
|
501
|
+
with spy_on(math, "floor") as s:
|
|
502
|
+
assert math.floor(1.5) == 1
|
|
503
|
+
assert s.call_count == 1
|
|
504
|
+
```
|
|
505
|
+
"""
|
|
506
|
+
raw = inspect.getattr_static(obj, name)
|
|
507
|
+
had_own = name in getattr(obj, "__dict__", {})
|
|
508
|
+
s = spy(getattr(obj, name))
|
|
509
|
+
plain = isinstance(raw, (staticmethod, classmethod))
|
|
510
|
+
setattr(obj, name, staticmethod(s) if plain else s)
|
|
511
|
+
try:
|
|
512
|
+
yield s
|
|
513
|
+
finally:
|
|
514
|
+
if had_own:
|
|
515
|
+
setattr(obj, name, raw)
|
|
516
|
+
else:
|
|
517
|
+
delattr(obj, name)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ------------------------------------------------------------------------- eventually
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _timeout_error(last: AssertionError, waited: float, attempts: int) -> AssertionError:
|
|
524
|
+
return AssertionError(
|
|
525
|
+
f"{last}\n(eventually: still failing after {waited:.3f}s and {attempts} attempt(s))"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# The unpatched sleeps, so polling can tell "fast_mode without a fake clock" apart.
|
|
530
|
+
_REAL_SLEEP = _core.sleep
|
|
531
|
+
_REAL_ASYNC_SLEEP = _core.async_sleep
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _poll_sleep(interval: float) -> None:
|
|
535
|
+
if _core.overrides.no_sleep and _core.sleep is _REAL_SLEEP:
|
|
536
|
+
time.sleep(interval) # fast_mode must not turn polling into a busy loop
|
|
537
|
+
else:
|
|
538
|
+
_core.sleep(interval)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
async def _poll_async_sleep(interval: float) -> None:
|
|
542
|
+
if _core.overrides.no_sleep and _core.async_sleep is _REAL_ASYNC_SLEEP:
|
|
543
|
+
await asyncio.sleep(interval)
|
|
544
|
+
else:
|
|
545
|
+
await _core.async_sleep(interval)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
async def eventually(
|
|
549
|
+
assertion: Callable[[], object | Awaitable[object]],
|
|
550
|
+
*,
|
|
551
|
+
timeout: float = 1.0,
|
|
552
|
+
interval: float = 0.01,
|
|
553
|
+
) -> None:
|
|
554
|
+
"""Retry `assertion` until it stops raising `AssertionError`, or time runs out.
|
|
555
|
+
|
|
556
|
+
`assertion` may be sync or `async def`. Time is read through `_core.monotonic` and
|
|
557
|
+
waited through `_core.async_sleep`, so it is real time by default (it is meant for
|
|
558
|
+
integration tests) and follows `fake_clock()` if one is installed. Under
|
|
559
|
+
`fast_mode()` (and no fake clock) it still pauses `interval` of real time between
|
|
560
|
+
attempts, because the condition it waits for usually needs real time to pass.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
assertion: Callable that raises `AssertionError` while the condition is false.
|
|
564
|
+
timeout: Seconds to keep trying.
|
|
565
|
+
interval: Seconds between attempts.
|
|
566
|
+
|
|
567
|
+
Raises:
|
|
568
|
+
AssertionError: The last failure, annotated with how long it waited. Other
|
|
569
|
+
exceptions propagate immediately.
|
|
570
|
+
|
|
571
|
+
Example:
|
|
572
|
+
```python
|
|
573
|
+
import asyncio
|
|
574
|
+
from python_wrap.testing import eventually
|
|
575
|
+
|
|
576
|
+
state = {"ready": False}
|
|
577
|
+
|
|
578
|
+
def check() -> None:
|
|
579
|
+
assert state["ready"]
|
|
580
|
+
|
|
581
|
+
async def main() -> None:
|
|
582
|
+
asyncio.get_running_loop().call_later(0.02, state.update, {"ready": True})
|
|
583
|
+
await eventually(check)
|
|
584
|
+
|
|
585
|
+
asyncio.run(main())
|
|
586
|
+
```
|
|
587
|
+
"""
|
|
588
|
+
start = _core.monotonic()
|
|
589
|
+
attempts = 0
|
|
590
|
+
while True:
|
|
591
|
+
attempts += 1
|
|
592
|
+
try:
|
|
593
|
+
outcome = assertion()
|
|
594
|
+
if inspect.isawaitable(outcome):
|
|
595
|
+
await outcome
|
|
596
|
+
return
|
|
597
|
+
except AssertionError as exc:
|
|
598
|
+
waited = _core.monotonic() - start
|
|
599
|
+
if waited >= timeout:
|
|
600
|
+
raise _timeout_error(exc, waited, attempts) from exc
|
|
601
|
+
await _poll_async_sleep(interval)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def eventually_sync(
|
|
605
|
+
assertion: Callable[[], object],
|
|
606
|
+
*,
|
|
607
|
+
timeout: float = 1.0,
|
|
608
|
+
interval: float = 0.01,
|
|
609
|
+
) -> None:
|
|
610
|
+
"""Blocking version of `eventually` for plain sync tests.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
assertion: Callable that raises `AssertionError` while the condition is false.
|
|
614
|
+
timeout: Seconds to keep trying.
|
|
615
|
+
interval: Seconds between attempts.
|
|
616
|
+
|
|
617
|
+
Raises:
|
|
618
|
+
AssertionError: The last failure, annotated with how long it waited.
|
|
619
|
+
|
|
620
|
+
Example:
|
|
621
|
+
```python
|
|
622
|
+
import threading
|
|
623
|
+
from python_wrap.testing import eventually_sync
|
|
624
|
+
|
|
625
|
+
done = threading.Event()
|
|
626
|
+
threading.Timer(0.02, done.set).start()
|
|
627
|
+
|
|
628
|
+
def check() -> None:
|
|
629
|
+
assert done.is_set()
|
|
630
|
+
|
|
631
|
+
eventually_sync(check)
|
|
632
|
+
```
|
|
633
|
+
"""
|
|
634
|
+
start = _core.monotonic()
|
|
635
|
+
attempts = 0
|
|
636
|
+
while True:
|
|
637
|
+
attempts += 1
|
|
638
|
+
try:
|
|
639
|
+
assertion()
|
|
640
|
+
return
|
|
641
|
+
except AssertionError as exc:
|
|
642
|
+
waited = _core.monotonic() - start
|
|
643
|
+
if waited >= timeout:
|
|
644
|
+
raise _timeout_error(exc, waited, attempts) from exc
|
|
645
|
+
_poll_sleep(interval)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# ------------------------------------------------------------------------ pytest plugin
|
|
649
|
+
|
|
650
|
+
if _pytest is not None:
|
|
651
|
+
|
|
652
|
+
@_pytest.fixture
|
|
653
|
+
def pw_fake_clock() -> Iterator[FakeClock]:
|
|
654
|
+
"""Install a `FakeClock` for the test and yield it."""
|
|
655
|
+
with fake_clock() as clock:
|
|
656
|
+
yield clock
|
|
657
|
+
|
|
658
|
+
@_pytest.fixture
|
|
659
|
+
def pw_fast_mode() -> Iterator[None]:
|
|
660
|
+
"""Run the test under `fast_mode()`."""
|
|
661
|
+
with fast_mode():
|
|
662
|
+
yield
|