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.
@@ -0,0 +1,491 @@
1
+ """Run a function every N seconds in the background.
2
+
3
+ `periodic` is a tiny background loop, not a scheduler: no cron expressions, no
4
+ persistence, no catch-up. The decorated function stays directly callable and gains
5
+ ``start()``, ``stop()`` and a few read-only attributes.
6
+
7
+ Timing rules:
8
+
9
+ * The schedule is **real time**. ``fast_mode()`` and ``fake_clock()`` never shorten
10
+ it (they only affect backoff waits, see `python_wrap.testing`); a periodic loop that
11
+ ran "instantly" would spin. Test with tiny intervals, or call the function directly.
12
+ * Intervals are measured **start to start** and do not drift: the n-th tick is due at
13
+ ``first + n * interval``. If a run is still going when the next tick is due, the missed
14
+ ticks are **skipped** (never run in a burst) and the loop resumes at the next tick.
15
+ * ``jitter`` moves each run by a uniformly random offset of up to ``± jitter * interval``
16
+ around its tick. Jitter never accumulates, because ticks are computed without it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import contextlib
23
+ import contextvars
24
+ import functools
25
+ import inspect
26
+ import logging
27
+ import math
28
+ import random
29
+ import threading
30
+ import time
31
+ from collections.abc import AsyncIterator, Callable, Coroutine, Iterator
32
+ from typing import Any, Generic, Literal, ParamSpec, Protocol, TypeVar, overload
33
+
34
+ from python_wrap import _core
35
+ from python_wrap._core import LoggerLike
36
+
37
+ __all__ = ["AsyncPeriodic", "Periodic", "periodic"]
38
+
39
+ P = ParamSpec("P")
40
+ R = TypeVar("R")
41
+
42
+ OnError = Literal["log", "stop", "raise"]
43
+ _ON_ERROR = ("log", "stop", "raise")
44
+
45
+ # Patched by tests; the schedule is real time on purpose (see the module docstring).
46
+ _clock: Callable[[], float] = time.monotonic
47
+ _uniform: Callable[[float, float], float] = random.uniform
48
+
49
+
50
+ def _next_tick(due: float, now: float, interval: float) -> float:
51
+ """The tick after ``due``, skipping every tick that is already in the past."""
52
+ nxt = due + interval
53
+ if nxt <= now:
54
+ nxt += (math.floor((now - nxt) / interval) + 1) * interval
55
+ return nxt
56
+
57
+
58
+ def _delay_until(due: float, now: float, interval: float, jitter: float) -> float:
59
+ """Seconds to wait until ``due``, moved by the jitter and never negative."""
60
+ offset = _uniform(-jitter, jitter) * interval if jitter else 0.0
61
+ return max(0.0, due + offset - now)
62
+
63
+
64
+ class _Base(Generic[P, R]):
65
+ """State and policy shared by the sync and async variants."""
66
+
67
+ def __init__(
68
+ self,
69
+ func: Callable[..., Any],
70
+ interval: float,
71
+ jitter: float,
72
+ initial_delay: float,
73
+ max_runs: int | None,
74
+ on_error: OnError,
75
+ name: str | None,
76
+ logger: LoggerLike,
77
+ level: int,
78
+ ) -> None:
79
+ self._func = func
80
+ self._interval = interval
81
+ self._jitter = jitter
82
+ self._initial_delay = initial_delay
83
+ self._max_runs = max_runs
84
+ self._on_error = on_error
85
+ self._name = name or _core.qualname(func)
86
+ self._logger = logger
87
+ self._level = level
88
+ self._runs = 0
89
+ self._last_error: BaseException | None = None
90
+ self._pending: BaseException | None = None # on_error="raise", surfaced by stop()
91
+ self._lock = threading.Lock()
92
+ functools.update_wrapper(self, func) # type: ignore[arg-type]
93
+
94
+ def __reduce__(self) -> str:
95
+ # Pickle by reference, like a function: the object at ``module.qualname`` is
96
+ # this one. The background loop and its counters stay in this process.
97
+ return str(self.__dict__["__qualname__"]) # set by update_wrapper
98
+
99
+ @property
100
+ def runs(self) -> int:
101
+ """How many times the function has been run since `start` (failed runs count)."""
102
+ return self._runs
103
+
104
+ @property
105
+ def last_error(self) -> BaseException | None:
106
+ """The exception of the most recent failed run, or ``None``."""
107
+ return self._last_error
108
+
109
+ def _ran(self, seconds: float) -> None:
110
+ _core.emit("periodic.run", self._name, run=self._runs, seconds=seconds)
111
+
112
+ def _failed(self, exc: Exception) -> bool:
113
+ """Record a failed run. Returns True if the loop should continue."""
114
+ self._last_error = exc
115
+ _core.emit("periodic.failed", self._name, run=self._runs, exception=exc)
116
+ if self._on_error == "log":
117
+ _core.get_logger(self._logger).log(
118
+ self._level, "periodic %s failed (run %d)", self._name, self._runs, exc_info=exc
119
+ )
120
+ return True
121
+ if self._on_error == "stop":
122
+ _core.get_logger(self._logger).log(
123
+ self._level,
124
+ "periodic %s failed (run %d); stopping",
125
+ self._name,
126
+ self._runs,
127
+ exc_info=exc,
128
+ )
129
+ else:
130
+ self._pending = exc
131
+ return False
132
+
133
+ def _done(self) -> bool:
134
+ return self._max_runs is not None and self._runs >= self._max_runs
135
+
136
+ def _surface(self) -> None:
137
+ exc, self._pending = self._pending, None
138
+ if exc is not None:
139
+ raise exc
140
+
141
+ def _reset(self) -> None:
142
+ if self._pending is not None:
143
+ # on_error="raise", but stop() never collected it: do not let a restart lose it.
144
+ _core.get_logger(self._logger).error(
145
+ "periodic %s: restarted before stop() raised the error that ended the "
146
+ "previous loop; dropping it",
147
+ self._name,
148
+ exc_info=self._pending,
149
+ )
150
+ self._runs = 0
151
+ self._last_error = None
152
+ self._pending = None
153
+
154
+
155
+ class Periodic(_Base[P, R]):
156
+ """A function that runs in a background daemon thread; see `periodic`.
157
+
158
+ Calling the object calls the wrapped function directly. Attributes: `runs`,
159
+ `last_error`, `running`.
160
+ """
161
+
162
+ def __init__(self, func: Callable[P, R], *config: Any) -> None:
163
+ super().__init__(func, *config)
164
+ self._stop_event = threading.Event()
165
+ self._thread: threading.Thread | None = None
166
+
167
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
168
+ return self._func(*args, **kwargs) # type: ignore[no-any-return]
169
+
170
+ @property
171
+ def running(self) -> bool:
172
+ """True while the background thread is alive."""
173
+ thread = self._thread
174
+ return thread is not None and thread.is_alive()
175
+
176
+ def start(self, *args: P.args, **kwargs: P.kwargs) -> Periodic[P, R]:
177
+ """Start the background thread; ``args`` and ``kwargs`` go to every run.
178
+
179
+ Returns:
180
+ This object, so ``f.start()`` can be chained.
181
+
182
+ Raises:
183
+ RuntimeError: If it is already running.
184
+ """
185
+ with self._lock:
186
+ if self.running:
187
+ raise RuntimeError(f"periodic {self._name} is already running")
188
+ self._reset()
189
+ self._stop_event = threading.Event()
190
+ ctx = contextvars.copy_context()
191
+ thread = threading.Thread(
192
+ target=ctx.run,
193
+ args=(self._loop, self._stop_event, args, kwargs),
194
+ name=f"python-wrap-periodic:{self._name}",
195
+ daemon=True,
196
+ )
197
+ self._thread = thread
198
+ thread.start()
199
+ return self
200
+
201
+ def stop(self, timeout: float | None = 5.0) -> bool:
202
+ """Stop the loop and wait up to ``timeout`` seconds for the thread to end.
203
+
204
+ A run that is in progress is allowed to finish (a thread cannot be
205
+ interrupted). Calling it again, or on a loop that never started, is harmless.
206
+ Called from inside the function itself it only requests the stop.
207
+
208
+ Args:
209
+ timeout: Seconds to wait for the thread; ``None`` waits forever.
210
+
211
+ Returns:
212
+ True if the thread has ended, False if it is still finishing a run.
213
+
214
+ Raises:
215
+ Exception: With ``on_error="raise"``, the exception that ended the loop
216
+ (raised once).
217
+ """
218
+ self._stop_event.set()
219
+ thread = self._thread
220
+ if thread is not None and thread is not threading.current_thread():
221
+ thread.join(timeout)
222
+ if thread.is_alive():
223
+ _core.get_logger(self._logger).warning(
224
+ "periodic %s did not stop within %s seconds", self._name, timeout
225
+ )
226
+ return False
227
+ self._surface()
228
+ return True
229
+
230
+ @contextlib.contextmanager
231
+ def running_in_background(self, *args: P.args, **kwargs: P.kwargs) -> Iterator[Periodic[P, R]]:
232
+ """Start the loop for the duration of a ``with`` block, then `stop` it."""
233
+ self.start(*args, **kwargs)
234
+ try:
235
+ yield self
236
+ finally:
237
+ self.stop()
238
+
239
+ def _loop(
240
+ self,
241
+ stop: threading.Event,
242
+ args: tuple[Any, ...],
243
+ kwargs: dict[str, Any],
244
+ ) -> None:
245
+ due = _clock() + self._initial_delay
246
+ if stop.wait(self._initial_delay):
247
+ return
248
+ while True:
249
+ began = _clock()
250
+ self._runs += 1
251
+ try:
252
+ self._func(*args, **kwargs)
253
+ except Exception as exc:
254
+ if not self._failed(exc):
255
+ return
256
+ else:
257
+ self._ran(_clock() - began)
258
+ if self._done() or stop.is_set():
259
+ return
260
+ now = _clock()
261
+ due = _next_tick(due, now, self._interval)
262
+ if stop.wait(_delay_until(due, now, self._interval, self._jitter)):
263
+ return
264
+
265
+
266
+ class AsyncPeriodic(_Base[P, R]):
267
+ """An ``async def`` function that runs as a task on the current loop; see `periodic`.
268
+
269
+ Calling the object calls the coroutine function directly. Attributes: `runs`,
270
+ `last_error`, `running`.
271
+ """
272
+
273
+ def __init__(self, func: Callable[P, Coroutine[Any, Any, R]], *config: Any) -> None:
274
+ super().__init__(func, *config)
275
+ self._task: asyncio.Task[None] | None = None
276
+ self._stopping = False
277
+ mark = getattr(inspect, "markcoroutinefunction", None) # Python 3.12+
278
+ if mark is not None:
279
+ mark(self)
280
+
281
+ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[Any, Any, R]:
282
+ return self._func(*args, **kwargs) # type: ignore[no-any-return]
283
+
284
+ @property
285
+ def running(self) -> bool:
286
+ """True while the background task has not finished."""
287
+ task = self._task
288
+ return task is not None and not task.done()
289
+
290
+ def start(self, *args: P.args, **kwargs: P.kwargs) -> AsyncPeriodic[P, R]:
291
+ """Start the background task on the running loop; arguments go to every run.
292
+
293
+ Returns:
294
+ This object, so ``f.start()`` can be chained.
295
+
296
+ Raises:
297
+ RuntimeError: If called outside a running event loop, or if already running.
298
+ """
299
+ try:
300
+ loop = asyncio.get_running_loop()
301
+ except RuntimeError:
302
+ raise RuntimeError(
303
+ f"periodic {self._name}: start() must be called from a running event loop "
304
+ "(inside an async function). Use a plain def function for a thread-based loop."
305
+ ) from None
306
+ if self.running:
307
+ raise RuntimeError(f"periodic {self._name} is already running")
308
+ self._reset()
309
+ self._stopping = False
310
+ self._task = loop.create_task(self._loop(args, kwargs), name=f"periodic:{self._name}")
311
+ return self
312
+
313
+ async def stop(self) -> bool:
314
+ """Cancel the task (including a run in progress) and wait for it to end.
315
+
316
+ Calling it again, or on a loop that never started, is harmless. Called from
317
+ inside the function itself it ends the loop as soon as that run returns. If the
318
+ task awaiting it is cancelled, the cancellation propagates at once and the loop
319
+ task finishes in the background.
320
+
321
+ Returns:
322
+ True once the task has ended.
323
+
324
+ Raises:
325
+ Exception: With ``on_error="raise"``, the exception that ended the loop
326
+ (raised once).
327
+ """
328
+ task = self._task
329
+ if task is not None and not task.done():
330
+ if task is asyncio.current_task():
331
+ self._stopping = True
332
+ return True
333
+ self._stopping = True # also ends a loop whose run swallows the cancel
334
+ task.cancel()
335
+ try:
336
+ # Not ``await task``: a CancelledError from it could not be told apart
337
+ # from one aimed at the caller, so the caller's would be swallowed.
338
+ await asyncio.wait({task})
339
+ except BaseException:
340
+ task.add_done_callback(_retrieve) # the caller was cancelled: re-raise
341
+ raise
342
+ if not task.cancelled():
343
+ task.result() # an exception the loop itself raised (not a cancel)
344
+ self._surface()
345
+ return True
346
+
347
+ @contextlib.asynccontextmanager
348
+ async def running_in_background(
349
+ self, *args: P.args, **kwargs: P.kwargs
350
+ ) -> AsyncIterator[AsyncPeriodic[P, R]]:
351
+ """Start the loop for the duration of an ``async with`` block, then `stop` it."""
352
+ self.start(*args, **kwargs)
353
+ try:
354
+ yield self
355
+ finally:
356
+ await self.stop()
357
+
358
+ async def _loop(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> None:
359
+ due = _clock() + self._initial_delay
360
+ await asyncio.sleep(self._initial_delay)
361
+ while not self._stopping:
362
+ began = _clock()
363
+ self._runs += 1
364
+ try:
365
+ await self._func(*args, **kwargs)
366
+ except Exception as exc:
367
+ if not self._failed(exc):
368
+ return
369
+ else:
370
+ self._ran(_clock() - began)
371
+ if self._done() or self._stopping:
372
+ return
373
+ now = _clock()
374
+ due = _next_tick(due, now, self._interval)
375
+ await asyncio.sleep(_delay_until(due, now, self._interval, self._jitter))
376
+
377
+
378
+ def _retrieve(task: asyncio.Task[None]) -> None:
379
+ """Mark a finished loop task's outcome as retrieved so asyncio does not log it."""
380
+ if not task.cancelled():
381
+ task.exception()
382
+
383
+
384
+ class _Decorator(Protocol):
385
+ @overload
386
+ def __call__( # type: ignore[overload-overlap]
387
+ self, func: Callable[P, Coroutine[Any, Any, R]], /
388
+ ) -> AsyncPeriodic[P, R]: ...
389
+ @overload
390
+ def __call__(self, func: Callable[P, R], /) -> Periodic[P, R]: ...
391
+
392
+
393
+ def periodic(
394
+ interval: float,
395
+ *,
396
+ jitter: float = 0.0,
397
+ initial_delay: float = 0.0,
398
+ max_runs: int | None = None,
399
+ on_error: OnError = "log",
400
+ name: str | None = None,
401
+ logger: LoggerLike = None,
402
+ level: int = logging.ERROR,
403
+ ) -> _Decorator:
404
+ """Run the decorated function every ``interval`` seconds in the background.
405
+
406
+ The function stays directly callable. The decorated object adds ``start()``,
407
+ ``stop()``, ``running``, ``runs``, ``last_error`` and ``running_in_background()``
408
+ (a context manager that starts and stops cleanly). A plain function runs in a daemon
409
+ thread; an ``async def`` function runs as a task on the current event loop
410
+ (``start()`` must be called inside it, and ``await f.stop()`` ends it). ``start()``
411
+ arguments are passed to every run, and the caller's ``contextvars`` are copied.
412
+
413
+ Runs start every ``interval`` seconds measured start to start, without drift. A run
414
+ that outlasts the interval makes the loop skip the missed ticks instead of running
415
+ in a burst. The schedule is real time: `fast_mode` and `fake_clock` do not shorten it.
416
+
417
+ To use it on a method, decorate the bound method: ``periodic(5)(self.refresh)``.
418
+ Decorating a function that takes ``self`` or ``cls`` first is rejected.
419
+
420
+ Args:
421
+ interval: Seconds between run starts. Must be greater than zero.
422
+ jitter: Fraction of ``interval`` (``0`` to less than ``1``) by which each run is
423
+ randomly moved earlier or later, e.g. ``0.1`` is +/-10 percent.
424
+ initial_delay: Seconds before the first run (no jitter). Default: run at once.
425
+ max_runs: Stop by itself after this many runs (failed runs count). Default: forever.
426
+ on_error: What an exception in a run does. ``"log"`` logs it and carries on;
427
+ ``"stop"`` logs it and ends the loop; ``"raise"`` ends the loop and re-raises
428
+ the exception, once, from `stop()` (so it never vanishes in a thread; if
429
+ `start()` is called again first, it is logged at ``ERROR`` instead). In every
430
+ mode it is kept in ``last_error`` and published as a ``periodic.failed`` event.
431
+ name: Name for logs, events and the thread. Default: the function's qualified name.
432
+ logger: Logger, logger name or ``None`` for the package logger.
433
+ level: Level for failure logs. Default: ``logging.ERROR``.
434
+
435
+ Returns:
436
+ A decorator producing a `Periodic` (plain function) or `AsyncPeriodic`
437
+ (``async def``).
438
+
439
+ Raises:
440
+ ValueError: For an invalid ``interval``, ``jitter``, ``initial_delay``,
441
+ ``max_runs`` or ``on_error``.
442
+ TypeError: When decorating something that is not callable, or an unbound method.
443
+
444
+ Events:
445
+ ``periodic.run`` (fields ``run``, ``seconds``) and ``periodic.failed`` (fields
446
+ ``run``, ``exception``).
447
+
448
+ Example:
449
+ ```python
450
+ from python_wrap.periodic import periodic
451
+
452
+ @periodic(0.01, max_runs=3)
453
+ def tick() -> None:
454
+ ...
455
+
456
+ with tick.running_in_background():
457
+ pass
458
+ ```
459
+ """
460
+ if not isinstance(interval, (int, float)) or isinstance(interval, bool) or not interval > 0:
461
+ raise ValueError("interval must be a number greater than 0")
462
+ if not isinstance(jitter, (int, float)) or not 0 <= jitter < 1:
463
+ raise ValueError("jitter must be at least 0 and less than 1")
464
+ if not isinstance(initial_delay, (int, float)) or not initial_delay >= 0:
465
+ raise ValueError("initial_delay must be a number >= 0")
466
+ if max_runs is not None and (not isinstance(max_runs, int) or max_runs < 1):
467
+ raise ValueError("max_runs must be None or an integer >= 1")
468
+ if on_error not in _ON_ERROR:
469
+ raise ValueError(f"on_error must be one of {', '.join(map(repr, _ON_ERROR))}")
470
+
471
+ def decorate(func: Callable[..., Any]) -> Any:
472
+ if not callable(func):
473
+ raise TypeError("periodic() decorates a function")
474
+ _reject_unbound_method(func)
475
+ config = (interval, jitter, initial_delay, max_runs, on_error, name, logger, level)
476
+ if _core.is_async(func):
477
+ return AsyncPeriodic(func, *config)
478
+ return Periodic(func, *config)
479
+
480
+ return decorate
481
+
482
+
483
+ def _reject_unbound_method(func: Callable[..., Any]) -> None:
484
+ if not inspect.isfunction(func):
485
+ return
486
+ params = list(inspect.signature(func).parameters)
487
+ if params and params[0] in ("self", "cls"):
488
+ raise TypeError(
489
+ f"periodic cannot decorate the method {func.__qualname__} in a class body: "
490
+ "decorate the bound method instead, e.g. periodic(5)(self.refresh)"
491
+ )