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/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