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/periodic.py
ADDED
|
@@ -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
|
+
)
|