async-timer 1.1.6__tar.gz → 1.3.2__tar.gz

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.
Files changed (48) hide show
  1. async_timer-1.3.2/LICENSE +21 -0
  2. async_timer-1.3.2/PKG-INFO +398 -0
  3. async_timer-1.3.2/README.md +350 -0
  4. async_timer-1.3.2/pyproject.toml +151 -0
  5. async_timer-1.3.2/src/async_timer/__init__.py +49 -0
  6. async_timer-1.3.2/src/async_timer/_common.py +72 -0
  7. async_timer-1.3.2/src/async_timer/decorators.py +76 -0
  8. async_timer-1.3.2/src/async_timer/exceptions.py +36 -0
  9. async_timer-1.3.2/src/async_timer/group.py +246 -0
  10. async_timer-1.3.2/src/async_timer/pacemaker.py +222 -0
  11. async_timer-1.3.2/src/async_timer/py.typed +0 -0
  12. async_timer-1.3.2/src/async_timer/subscription.py +218 -0
  13. async_timer-1.3.2/src/async_timer/target_caller.py +90 -0
  14. async_timer-1.3.2/src/async_timer/timer.py +505 -0
  15. {async_timer-1.1.6 → async_timer-1.3.2}/src/mock_async_timer/__init__.py +3 -0
  16. async_timer-1.3.2/src/mock_async_timer/py.typed +0 -0
  17. async_timer-1.3.2/src/mock_async_timer/timer.py +70 -0
  18. async_timer-1.3.2/tests/async_timer/conftest.py +51 -0
  19. async_timer-1.3.2/tests/async_timer/test_audit_fixes.py +219 -0
  20. async_timer-1.3.2/tests/async_timer/test_decorators.py +158 -0
  21. async_timer-1.3.2/tests/async_timer/test_group.py +579 -0
  22. async_timer-1.3.2/tests/async_timer/test_pacemaker.py +77 -0
  23. async_timer-1.3.2/tests/async_timer/test_pacemaker_features.py +253 -0
  24. async_timer-1.3.2/tests/async_timer/test_pacemaker_mutation_guards.py +115 -0
  25. async_timer-1.3.2/tests/async_timer/test_simple.py +16 -0
  26. async_timer-1.3.2/tests/async_timer/test_subscription.py +606 -0
  27. async_timer-1.3.2/tests/async_timer/test_target_caller.py +63 -0
  28. async_timer-1.3.2/tests/async_timer/test_threadsafe.py +300 -0
  29. async_timer-1.3.2/tests/async_timer/test_timer.py +403 -0
  30. async_timer-1.3.2/tests/async_timer/test_timer_async_cancel.py +55 -0
  31. async_timer-1.3.2/tests/async_timer/test_timer_features.py +318 -0
  32. async_timer-1.3.2/tests/async_timer/test_timer_lifecycle.py +352 -0
  33. async_timer-1.3.2/tests/mock_async_timer/test_mock_timer_lifecycle.py +162 -0
  34. async_timer-1.3.2/tests/mock_async_timer/test_mock_timer_pacemaker.py +41 -0
  35. async_timer-1.3.2/tests/mock_async_timer/test_mock_timer_simple.py +75 -0
  36. async_timer-1.3.2/tests/property/__init__.py +0 -0
  37. async_timer-1.3.2/tests/property/test_common_properties.py +38 -0
  38. async_timer-1.3.2/tests/property/test_pacemaker_properties.py +111 -0
  39. async_timer-1.3.2/tests/property/test_subscription_properties.py +89 -0
  40. async_timer-1.3.2/tests/typing_probe.py +96 -0
  41. async_timer-1.1.6/PKG-INFO +0 -114
  42. async_timer-1.1.6/README.md +0 -92
  43. async_timer-1.1.6/pyproject.toml +0 -86
  44. async_timer-1.1.6/src/async_timer/__init__.py +0 -2
  45. async_timer-1.1.6/src/async_timer/pacemaker.py +0 -71
  46. async_timer-1.1.6/src/async_timer/timer.py +0 -236
  47. async_timer-1.1.6/src/async_timer/traget_caller.py +0 -70
  48. async_timer-1.1.6/src/mock_async_timer/timer.py +0 -46
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Ilja Orlovs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,398 @@
1
+ Metadata-Version: 2.1
2
+ Name: async-timer
3
+ Version: 1.3.2
4
+ Summary: The missing Python async timer.
5
+ Keywords: async,asyncio,timer,scheduler,periodic,interval
6
+ Author-Email: Ilja Orlovs <vrghost@gmail.com>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2023 Ilja Orlovs
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ Classifier: Development Status :: 5 - Production/Stable
30
+ Classifier: Framework :: AsyncIO
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Operating System :: OS Independent
34
+ Classifier: Programming Language :: Python :: 3 :: Only
35
+ Classifier: Programming Language :: Python :: 3.9
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Programming Language :: Python :: 3.13
40
+ Classifier: Topic :: Software Development :: Libraries
41
+ Classifier: Typing :: Typed
42
+ Project-URL: Homepage, https://github.com/IljaOrlovs/async-timer
43
+ Project-URL: Source, https://github.com/IljaOrlovs/async-timer
44
+ Project-URL: Issues, https://github.com/IljaOrlovs/async-timer/issues
45
+ Project-URL: Changelog, https://github.com/IljaOrlovs/async-timer/blob/main/CHANGELOG.md
46
+ Requires-Python: >=3.9
47
+ Description-Content-Type: text/markdown
48
+
49
+ # async-timer
50
+
51
+ The missing Python async timer.
52
+
53
+ [![Tests](docs/badges/tests.svg)](docs/badges/tests.svg)
54
+ [![Coverage](docs/badges/coverage.svg)](docs/badges/coverage.svg)
55
+ [![CI](https://github.com/IljaOrlovs/async-timer/actions/workflows/main.yml/badge.svg)](https://github.com/IljaOrlovs/async-timer/actions/workflows/main.yml)
56
+
57
+ ## The problem
58
+
59
+ The obvious way to run something on an interval in asyncio:
60
+
61
+ ```python
62
+ async def refresh():
63
+ while True:
64
+ await do_the_thing()
65
+ await asyncio.sleep(5)
66
+ ```
67
+
68
+ This works until it doesn't:
69
+
70
+ * **It drifts.** `sleep(5)` runs *after* `do_the_thing()` finishes, so
71
+ a 2-second call gives you a 7-second period. Drift compounds.
72
+ * **It has no cancellation story.** Cancelling the wrapping task
73
+ during `do_the_thing()` may interrupt mid-write; you need explicit
74
+ shielding and cleanup to handle this safely.
75
+ * **The first call isn't observable.** Want to wait until the cache is
76
+ populated before serving traffic? You have to bolt on an `Event`.
77
+ * **There's nowhere for consumers to subscribe.** If something else
78
+ wants the latest value (or every value), you're hand-rolling a queue
79
+ or a fanout.
80
+ * **It doesn't compose.** Running ten of these on startup and
81
+ cancelling them together on shutdown needs a `TaskGroup` plus
82
+ bookkeeping.
83
+
84
+ `async-timer` is what you actually want: a recurring async call with
85
+ proper lifecycle, two delivery models, and the operational knobs
86
+ (fixed-rate vs fixed-delay, jitter, trigger-now, cross-thread control)
87
+ that real production code ends up needing.
88
+
89
+ Zero runtime dependencies. Python 3.9+.
90
+
91
+ ## Install
92
+
93
+ ```bash
94
+ pip install async-timer
95
+ ```
96
+
97
+ ## The 30-second example: FastAPI cache warmup
98
+
99
+ ```python
100
+ import contextlib
101
+ import time
102
+ import uvicorn
103
+ from fastapi import FastAPI
104
+ import async_timer
105
+
106
+ DB_CACHE = {"initialised": False}
107
+
108
+ async def refresh_db():
109
+ DB_CACHE.update(initialised=True, cur_value=time.time())
110
+
111
+ @contextlib.asynccontextmanager
112
+ async def lifespan(_app: FastAPI):
113
+ async with async_timer.Timer(delay=5, target=refresh_db) as timer:
114
+ await timer.wait(hit_count=1) # block startup until the first refresh
115
+ yield # serve traffic; timer keeps refreshing
116
+
117
+ app = FastAPI(lifespan=lifespan)
118
+
119
+ @app.get("/")
120
+ async def root():
121
+ return {"db_cache": DB_CACHE}
122
+ ```
123
+
124
+ Two lines do the heavy lifting:
125
+
126
+ * `async with Timer(...) as timer` starts on enter, cancels on exit
127
+ (and *awaits* cleanup — no orphan task).
128
+ * `await timer.wait(hit_count=1)` gates startup on the first
129
+ successful refresh.
130
+
131
+ More recipes: [docs/recipes/](docs/recipes/). Runnable scripts:
132
+ [docs/examples/](docs/examples/).
133
+
134
+ ## Features
135
+
136
+ * **Zero runtime dependencies.**
137
+ * **Any callable shape.** Sync or async functions, generators, async
138
+ generators, or callables returning any of those.
139
+ * **Two delivery models.** `join()` / `wait()` / `async for self` is
140
+ single-shot fan-out (latest value, may drop intermediate ticks under
141
+ slow consumers). `subscribe()` gives each consumer a buffered queue
142
+ (every tick, optional `maxsize` for bounded drop-oldest).
143
+ * **Scheduling modes.** `fixed_delay` (default; next tick fires
144
+ `delay` after the previous one finishes) or `fixed_rate` (anchored
145
+ to wall clock; missed slots skipped + logged). Optional
146
+ `initial_delay` and `jitter`.
147
+ * **Trigger on demand.** `await timer.trigger()` fires now and
148
+ resumes the schedule.
149
+ * **Last-value cache.** `timer.last_result` / `timer.last_tick_at` —
150
+ no blocking.
151
+ * **Cancel anytime.** Explicit `cancel()` or constructor `cancel_aws`
152
+ (awaitables that stop the timer when they resolve). `await cancel()`
153
+ waits for cleanup before returning; safe from inside the
154
+ target/callbacks.
155
+ * **Restartable.** `start()` after `cancel()` works (raises
156
+ `TimerRestartError` if `cancel_aws` was used — those are single-shot).
157
+ * **Decorator.** `@async_timer.every(5)` wraps a function into a
158
+ Timer; original on `.func`.
159
+ * **Groups.** `TimerGroup()` starts/cancels a set of timers together.
160
+ * **Named.** `name="db_refresh"` shows in `repr()` and scopes the
161
+ logger.
162
+ * **Test-friendly.** `mock_async_timer.MockTimer` replaces real
163
+ sleeps with an `AsyncMock`.
164
+
165
+ ## When to use this — and when not to
166
+
167
+ `async-timer` is for **in-process recurring work driven by asyncio**:
168
+ cache refresh, periodic polling, metrics sampling, heartbeats,
169
+ fan-out feeds. Its model is one process, one loop, many timers.
170
+
171
+ | You want | Reach for |
172
+ | --- | --- |
173
+ | Periodic work in an asyncio app | **`async-timer`** |
174
+ | Cron-style wall-clock scheduling ("every Monday 9am") | [`APScheduler`](https://github.com/agronholm/apscheduler), [`aiocron`](https://github.com/gawel/aiocron) |
175
+ | Background *jobs* with retries, queues, persistence | [`arq`](https://github.com/python-arq/arq), [`dramatiq`](https://github.com/Bogdanp/dramatiq), Celery + beat |
176
+ | Recurring tasks across many processes | A scheduler + broker (Celery beat, arq cron) |
177
+ | One-shot `setTimeout`-equivalent | `loop.call_later` (stdlib) |
178
+
179
+ If you need durability or cross-process coordination, you need a
180
+ broker. `async-timer` doesn't try to be that.
181
+
182
+ ## More examples
183
+
184
+ ### `join()`
185
+
186
+ ```python
187
+ import asyncio
188
+ import async_timer
189
+
190
+ async def main():
191
+ timer = async_timer.Timer(12, target=lambda: 42)
192
+ timer.start()
193
+ val = await timer.join() # 42, after the first tick
194
+ await timer.cancel()
195
+
196
+ asyncio.run(main())
197
+ ```
198
+
199
+ ### `async for`
200
+
201
+ ```python
202
+ import asyncio, time
203
+ import async_timer
204
+
205
+ async def main():
206
+ async with async_timer.Timer(14, target=time.time) as timer:
207
+ async for t in timer:
208
+ print(t) # current time every 14 seconds
209
+
210
+ asyncio.run(main())
211
+ ```
212
+
213
+ ### Decorator
214
+
215
+ ```python
216
+ import async_timer
217
+
218
+ @async_timer.every(5, mode="fixed_rate", name="db_refresh")
219
+ async def refresh_db():
220
+ ...
221
+
222
+ await refresh_db.func() # call the undecorated fn (tests)
223
+
224
+ async def main():
225
+ refresh_db.start()
226
+ await refresh_db.join()
227
+ await refresh_db.cancel()
228
+ ```
229
+
230
+ ### `TimerGroup`
231
+
232
+ ```python
233
+ import async_timer
234
+
235
+ async def lifespan():
236
+ async with async_timer.TimerGroup(name="caches") as group:
237
+ group.add(async_timer.Timer(5, target=refresh_db))
238
+ group.add(async_timer.Timer(60, target=prune_cache))
239
+ # Block until every cache has been populated at least once,
240
+ # then serve traffic.
241
+ await group.wait(hit_count=1)
242
+ yield # both running; both cancelled on exit
243
+ ```
244
+
245
+ `TimerGroup` mirrors `Timer`'s surface across a set of timers; each
246
+ group method fans out to its members (AND-combined) and returns
247
+ `[(timer, rv), ...]` in iteration order:
248
+
249
+ * `group.wait(hit_count=...)` / `wait(hits=...)` — block until every
250
+ member satisfies the condition.
251
+ * `group.trigger()` — fire every member's target now (cache-invalidate-all).
252
+ * `group.is_running()` — True iff active and every member is running.
253
+ * `group.start()` / `await group.cancel_all()` — explicit lifecycle
254
+ for use outside `async with`.
255
+ * `group.cancel_threadsafe(timeout=5.0)` — cancel from a non-loop
256
+ thread (signal handler, sync REST endpoint, worker thread).
257
+
258
+ Each of `wait()` and `trigger()` accepts `timeout=` (whole-group
259
+ wall-clock bound) and `return_exceptions=True` (per-member errors
260
+ appear in the result list instead of propagating, mirroring
261
+ `asyncio.gather`).
262
+
263
+ ### Trigger now
264
+
265
+ ```python
266
+ async def force_refresh(timer):
267
+ return await timer.trigger()
268
+ ```
269
+
270
+ ### Latest value, no blocking
271
+
272
+ ```python
273
+ @async_timer.every(5)
274
+ async def refresh_db():
275
+ return await db.fetch()
276
+
277
+ def get_cached():
278
+ return refresh_db.last_result # None until the first tick
279
+ ```
280
+
281
+ ### Every-tick delivery via `subscribe()`
282
+
283
+ `join()` / `async for self` drop ticks under slow consumers (single-shot
284
+ fan-out). Use `subscribe()` when you need every tick:
285
+
286
+ ```python
287
+ async with timer.subscribe() as feed:
288
+ async for value in feed:
289
+ await log_it(value) # never misses a tick from subscribe-time
290
+ await asyncio.sleep(3.0) # even though the consumer is slow
291
+ ```
292
+
293
+ Bounded queue (drop oldest + log when full):
294
+
295
+ ```python
296
+ async with timer.subscribe(maxsize=10, name="metrics-sink") as feed:
297
+ async for value in feed:
298
+ await slow_export(value)
299
+ ```
300
+
301
+ Multiple subscribers each get an independent copy:
302
+
303
+ ```python
304
+ async with timer.subscribe() as a, timer.subscribe() as b:
305
+ ...
306
+ ```
307
+
308
+ Consumer-side load shedding:
309
+
310
+ ```python
311
+ async with timer.subscribe() as feed:
312
+ async for value in feed:
313
+ if feed.qsize > 100:
314
+ feed.drop_oldest(feed.qsize - 1) # keep only the newest
315
+ log.warning("shed %d ticks", feed.dropped_count)
316
+ await slow_export(value)
317
+ ```
318
+
319
+ `drop_oldest()` never swallows end-of-stream / exception sentinels.
320
+ Target exceptions re-raise from the subscriber's iteration.
321
+
322
+ ### Tests with `MockTimer`
323
+
324
+ `mock_async_timer.MockTimer` is a drop-in `Timer` subclass that
325
+ replaces the real sleep with an `AsyncMock`, so ticks fire as fast as
326
+ the loop can schedule them — no wall-clock waits in tests:
327
+
328
+ ```python
329
+ from mock_async_timer import MockTimer
330
+
331
+ async def test_periodic_refresh():
332
+ calls = 0
333
+ def tick():
334
+ nonlocal calls
335
+ calls += 1
336
+ async with MockTimer(0.1, tick) as t:
337
+ await t.wait(hits=3)
338
+ assert calls == 3
339
+ ```
340
+
341
+ Same surface as `Timer` — `join`, `wait`, `trigger`, `subscribe`,
342
+ `TimerGroup`, decorator wrapping all work the same way.
343
+
344
+ ## Exceptions
345
+
346
+ All library-raised errors derive from `async_timer.TimerError`, which
347
+ itself inherits from `RuntimeError` for back-compat with existing
348
+ `except RuntimeError` clauses:
349
+
350
+ | Exception | Raised when |
351
+ | --- | --- |
352
+ | `TimerAlreadyRunningError` | `start()` called on a running timer |
353
+ | `TimerNotRunningError` | `trigger()` / `join()` on a stopped timer |
354
+ | `TimerRestartError` | `start()` after `cancel()` on a Timer built with `cancel_aws` (single-shot) |
355
+ | `ThreadsafeDispatchError` | `*_threadsafe` called from the bound loop thread, before start, or after the loop closed |
356
+
357
+ Catch `TimerError` to filter only library-originated errors; catch a
358
+ specific subclass for finer control.
359
+
360
+ ## Thread safety
361
+
362
+ A `Timer` runs in a single asyncio event loop. Most state-mutating
363
+ operations must be called from the loop's thread. The following are
364
+ explicitly safe to use from any thread:
365
+
366
+ **Read-only attributes** (atomic under CPython's GIL):
367
+
368
+ * `timer.last_result`, `timer.last_tick_at`, `timer.hit_count`
369
+ * `timer.is_running()`, `timer.delay`, `timer.name`
370
+ * `subscription.qsize`, `subscription.dropped_count`
371
+
372
+ **`set_delay(new_delay)`** is a single attribute write — safe from any
373
+ thread; takes effect on the next sleep.
374
+
375
+ **Cross-thread control methods** — marshal the operation back to the
376
+ timer's loop and block for completion:
377
+
378
+ ```python
379
+ # From a sync REST handler, signal handler, worker thread, etc.:
380
+ timer.cancel_threadsafe(timeout=5.0) # raises TimeoutError if exceeded
381
+ result = timer.trigger_threadsafe(timeout=5.0)
382
+ feed.close_threadsafe()
383
+ ```
384
+
385
+ These raise `ThreadsafeDispatchError` (a `RuntimeError` subclass) with
386
+ a clear message if called from the timer's own loop thread (use
387
+ `await cancel()` / `await trigger()` instead), or if the timer has
388
+ not been started yet, or if the bound event loop has been closed.
389
+
390
+ Anything else (`subscribe()`, awaiting `join()` / `wait()`, iterating
391
+ `async for` over the timer or a subscription, reading from a
392
+ subscription queue) must happen on the loop's thread. From other
393
+ threads, use `asyncio.run_coroutine_threadsafe(coro, loop)` to
394
+ dispatch.
395
+
396
+ ## License
397
+
398
+ MIT.