async-timer 1.1.6__tar.gz → 1.2.0__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.
- async_timer-1.2.0/LICENSE +21 -0
- async_timer-1.2.0/PKG-INFO +265 -0
- async_timer-1.2.0/README.md +217 -0
- async_timer-1.2.0/pyproject.toml +137 -0
- async_timer-1.2.0/src/async_timer/__init__.py +14 -0
- async_timer-1.2.0/src/async_timer/decorators.py +72 -0
- async_timer-1.2.0/src/async_timer/group.py +88 -0
- async_timer-1.2.0/src/async_timer/pacemaker.py +223 -0
- async_timer-1.2.0/src/async_timer/py.typed +0 -0
- async_timer-1.2.0/src/async_timer/subscription.py +218 -0
- async_timer-1.1.6/src/async_timer/traget_caller.py → async_timer-1.2.0/src/async_timer/target_caller.py +30 -18
- async_timer-1.2.0/src/async_timer/timer.py +516 -0
- {async_timer-1.1.6 → async_timer-1.2.0}/src/mock_async_timer/__init__.py +1 -0
- async_timer-1.2.0/src/mock_async_timer/py.typed +0 -0
- async_timer-1.2.0/src/mock_async_timer/timer.py +57 -0
- async_timer-1.2.0/tests/async_timer/conftest.py +51 -0
- async_timer-1.2.0/tests/async_timer/test_audit_fixes.py +219 -0
- async_timer-1.2.0/tests/async_timer/test_decorators.py +158 -0
- async_timer-1.2.0/tests/async_timer/test_group.py +145 -0
- async_timer-1.2.0/tests/async_timer/test_pacemaker.py +77 -0
- async_timer-1.2.0/tests/async_timer/test_pacemaker_features.py +253 -0
- async_timer-1.2.0/tests/async_timer/test_simple.py +16 -0
- async_timer-1.2.0/tests/async_timer/test_subscription.py +606 -0
- async_timer-1.2.0/tests/async_timer/test_target_caller.py +63 -0
- async_timer-1.2.0/tests/async_timer/test_threadsafe.py +300 -0
- async_timer-1.2.0/tests/async_timer/test_timer.py +403 -0
- async_timer-1.2.0/tests/async_timer/test_timer_async_cancel.py +55 -0
- async_timer-1.2.0/tests/async_timer/test_timer_features.py +250 -0
- async_timer-1.2.0/tests/async_timer/test_timer_lifecycle.py +352 -0
- async_timer-1.2.0/tests/mock_async_timer/test_mock_timer_lifecycle.py +162 -0
- async_timer-1.2.0/tests/mock_async_timer/test_mock_timer_pacemaker.py +41 -0
- async_timer-1.2.0/tests/mock_async_timer/test_mock_timer_simple.py +75 -0
- async_timer-1.2.0/tests/typing_probe.py +96 -0
- async_timer-1.1.6/PKG-INFO +0 -114
- async_timer-1.1.6/README.md +0 -92
- async_timer-1.1.6/pyproject.toml +0 -86
- async_timer-1.1.6/src/async_timer/__init__.py +0 -2
- async_timer-1.1.6/src/async_timer/pacemaker.py +0 -71
- async_timer-1.1.6/src/async_timer/timer.py +0 -236
- 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,265 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: async-timer
|
|
3
|
+
Version: 1.2.0
|
|
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
|
+
[](docs/badges/tests.svg)
|
|
54
|
+
[](docs/badges/coverage.svg)
|
|
55
|
+
[](https://github.com/IljaOrlovs/async-timer/actions/workflows/main.yml)
|
|
56
|
+
|
|
57
|
+
Run something repeatedly on an interval in asyncio — useful for cache refresh, periodic polling, metrics emission, and similar background work.
|
|
58
|
+
|
|
59
|
+
## Features
|
|
60
|
+
|
|
61
|
+
* **Zero runtime dependencies.**
|
|
62
|
+
* **Any callable shape.** Sync or async functions, generators, async generators, or callables returning any of those.
|
|
63
|
+
* **Two delivery models.** `join()` / `wait()` / `async for self` is single-shot fan-out (latest value, may drop intermediate ticks under slow consumers). `subscribe()` gives each consumer a buffered queue (every tick, optional `maxsize` for bounded drop-oldest).
|
|
64
|
+
* **Scheduling modes.** `fixed_delay` (default; next tick fires `delay` after the previous one finishes) or `fixed_rate` (anchored to wall clock; missed slots skipped + logged). Optional `initial_delay` and `jitter`.
|
|
65
|
+
* **Trigger on demand.** `await timer.trigger()` fires now and resumes the schedule.
|
|
66
|
+
* **Last-value cache.** `timer.last_result` / `timer.last_tick_at` — no blocking.
|
|
67
|
+
* **Cancel anytime.** Explicit `cancel()` or constructor `cancel_aws` (awaitables that stop the timer when they resolve). `await cancel()` waits for cleanup before returning; safe from inside the target/callbacks.
|
|
68
|
+
* **Restartable.** `start()` after `cancel()` works (raises if `cancel_aws` was used — those are single-shot).
|
|
69
|
+
* **Decorator.** `@async_timer.every(5)` wraps a function into a Timer; original on `.func`.
|
|
70
|
+
* **Groups.** `TimerGroup()` starts/cancels a set of timers together.
|
|
71
|
+
* **Named.** `name="db_refresh"` shows in `repr()` and scopes the logger.
|
|
72
|
+
* **Test-friendly.** `mock_async_timer.MockTimer` replaces real sleeps with an `AsyncMock`.
|
|
73
|
+
|
|
74
|
+
## Requirements
|
|
75
|
+
|
|
76
|
+
Python 3.9+.
|
|
77
|
+
|
|
78
|
+
## Installation
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install async-timer
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Examples
|
|
85
|
+
|
|
86
|
+
### FastAPI lifespan
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import contextlib
|
|
90
|
+
import time
|
|
91
|
+
import uvicorn
|
|
92
|
+
from fastapi import FastAPI
|
|
93
|
+
import async_timer
|
|
94
|
+
|
|
95
|
+
DB_CACHE = {"initialised": False}
|
|
96
|
+
|
|
97
|
+
async def refresh_db():
|
|
98
|
+
DB_CACHE.update(initialised=True, cur_value=time.time())
|
|
99
|
+
|
|
100
|
+
@contextlib.asynccontextmanager
|
|
101
|
+
async def lifespan(_app: FastAPI):
|
|
102
|
+
async with async_timer.Timer(delay=5, target=refresh_db) as timer:
|
|
103
|
+
await timer.wait(hit_count=1) # wait for first tick
|
|
104
|
+
yield
|
|
105
|
+
|
|
106
|
+
app = FastAPI(lifespan=lifespan)
|
|
107
|
+
|
|
108
|
+
@app.get("/")
|
|
109
|
+
async def root():
|
|
110
|
+
return {"db_cache": DB_CACHE}
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### `join()`
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
import asyncio
|
|
120
|
+
import async_timer
|
|
121
|
+
|
|
122
|
+
async def main():
|
|
123
|
+
timer = async_timer.Timer(12, target=lambda: 42)
|
|
124
|
+
timer.start()
|
|
125
|
+
val = await timer.join() # 42, after the first tick
|
|
126
|
+
await timer.cancel()
|
|
127
|
+
|
|
128
|
+
asyncio.run(main())
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### `async for`
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
import asyncio, time
|
|
135
|
+
import async_timer
|
|
136
|
+
|
|
137
|
+
async def main():
|
|
138
|
+
async with async_timer.Timer(14, target=time.time) as timer:
|
|
139
|
+
async for t in timer:
|
|
140
|
+
print(t) # current time every 14 seconds
|
|
141
|
+
|
|
142
|
+
asyncio.run(main())
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Decorator
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
import async_timer
|
|
149
|
+
|
|
150
|
+
@async_timer.every(5, mode="fixed_rate", name="db_refresh")
|
|
151
|
+
async def refresh_db():
|
|
152
|
+
...
|
|
153
|
+
|
|
154
|
+
await refresh_db.func() # call the undecorated fn (tests)
|
|
155
|
+
|
|
156
|
+
async def main():
|
|
157
|
+
refresh_db.start()
|
|
158
|
+
await refresh_db.join()
|
|
159
|
+
await refresh_db.cancel()
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### `TimerGroup`
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import async_timer
|
|
166
|
+
|
|
167
|
+
async def lifespan():
|
|
168
|
+
async with async_timer.TimerGroup() as group:
|
|
169
|
+
group.add(async_timer.Timer(5, target=refresh_db))
|
|
170
|
+
group.add(async_timer.Timer(60, target=prune_cache))
|
|
171
|
+
yield # both running; both cancelled on exit
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Trigger now
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
async def force_refresh(timer):
|
|
178
|
+
return await timer.trigger()
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Latest value, no blocking
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
@async_timer.every(5)
|
|
185
|
+
async def refresh_db():
|
|
186
|
+
return await db.fetch()
|
|
187
|
+
|
|
188
|
+
def get_cached():
|
|
189
|
+
return refresh_db.last_result # None until the first tick
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Every-tick delivery via `subscribe()`
|
|
193
|
+
|
|
194
|
+
`join()` / `async for self` drop ticks under slow consumers (single-shot fan-out). Use `subscribe()` when you need every tick:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
async with timer.subscribe() as feed:
|
|
198
|
+
async for value in feed:
|
|
199
|
+
await log_it(value) # never misses a tick from subscribe-time
|
|
200
|
+
await asyncio.sleep(3.0) # even though the consumer is slow
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Bounded queue (drop oldest + log when full):
|
|
204
|
+
|
|
205
|
+
```python
|
|
206
|
+
async with timer.subscribe(maxsize=10, name="metrics-sink") as feed:
|
|
207
|
+
async for value in feed:
|
|
208
|
+
await slow_export(value)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Multiple subscribers each get an independent copy:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
async with timer.subscribe() as a, timer.subscribe() as b:
|
|
215
|
+
...
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Consumer-side load shedding:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
async with timer.subscribe() as feed:
|
|
222
|
+
async for value in feed:
|
|
223
|
+
if feed.qsize > 100:
|
|
224
|
+
feed.drop_oldest(feed.qsize - 1) # keep only the newest
|
|
225
|
+
log.warning("shed %d ticks", feed.dropped_count)
|
|
226
|
+
await slow_export(value)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`drop_oldest()` never swallows end-of-stream / exception sentinels. Target exceptions re-raise from the subscriber's iteration.
|
|
230
|
+
|
|
231
|
+
## Thread safety
|
|
232
|
+
|
|
233
|
+
A `Timer` runs in a single asyncio event loop. Most state-mutating
|
|
234
|
+
operations must be called from the loop's thread. The following are
|
|
235
|
+
explicitly safe to use from any thread:
|
|
236
|
+
|
|
237
|
+
**Read-only attributes** (atomic under CPython's GIL):
|
|
238
|
+
|
|
239
|
+
* `timer.last_result`, `timer.last_tick_at`, `timer.hit_count`
|
|
240
|
+
* `timer.is_running()`, `timer.delay`, `timer.name`
|
|
241
|
+
* `subscription.qsize`, `subscription.dropped_count`
|
|
242
|
+
|
|
243
|
+
**`set_delay(new_delay)`** is a single attribute write — safe from any
|
|
244
|
+
thread; takes effect on the next sleep.
|
|
245
|
+
|
|
246
|
+
**Cross-thread control methods** — marshal the operation back to the
|
|
247
|
+
timer's loop and block for completion:
|
|
248
|
+
|
|
249
|
+
```python
|
|
250
|
+
# From a sync REST handler, signal handler, worker thread, etc.:
|
|
251
|
+
timer.cancel_threadsafe(timeout=5.0) # raises TimeoutError if exceeded
|
|
252
|
+
result = timer.trigger_threadsafe(timeout=5.0)
|
|
253
|
+
feed.close_threadsafe()
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
These raise `RuntimeError` with a clear message if called from the
|
|
257
|
+
timer's own loop thread (use `await cancel()` / `await trigger()`
|
|
258
|
+
instead), or if the timer has not been started yet, or if the bound
|
|
259
|
+
event loop has been closed.
|
|
260
|
+
|
|
261
|
+
Anything else (`subscribe()`, awaiting `join()` / `wait()`, iterating
|
|
262
|
+
`async for` over the timer or a subscription, reading from a
|
|
263
|
+
subscription queue) must happen on the loop's thread. From other
|
|
264
|
+
threads, use `asyncio.run_coroutine_threadsafe(coro, loop)` to
|
|
265
|
+
dispatch.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Async timer
|
|
2
|
+
|
|
3
|
+
The missing Python async timer.
|
|
4
|
+
|
|
5
|
+
[](docs/badges/tests.svg)
|
|
6
|
+
[](docs/badges/coverage.svg)
|
|
7
|
+
[](https://github.com/IljaOrlovs/async-timer/actions/workflows/main.yml)
|
|
8
|
+
|
|
9
|
+
Run something repeatedly on an interval in asyncio — useful for cache refresh, periodic polling, metrics emission, and similar background work.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
* **Zero runtime dependencies.**
|
|
14
|
+
* **Any callable shape.** Sync or async functions, generators, async generators, or callables returning any of those.
|
|
15
|
+
* **Two delivery models.** `join()` / `wait()` / `async for self` is single-shot fan-out (latest value, may drop intermediate ticks under slow consumers). `subscribe()` gives each consumer a buffered queue (every tick, optional `maxsize` for bounded drop-oldest).
|
|
16
|
+
* **Scheduling modes.** `fixed_delay` (default; next tick fires `delay` after the previous one finishes) or `fixed_rate` (anchored to wall clock; missed slots skipped + logged). Optional `initial_delay` and `jitter`.
|
|
17
|
+
* **Trigger on demand.** `await timer.trigger()` fires now and resumes the schedule.
|
|
18
|
+
* **Last-value cache.** `timer.last_result` / `timer.last_tick_at` — no blocking.
|
|
19
|
+
* **Cancel anytime.** Explicit `cancel()` or constructor `cancel_aws` (awaitables that stop the timer when they resolve). `await cancel()` waits for cleanup before returning; safe from inside the target/callbacks.
|
|
20
|
+
* **Restartable.** `start()` after `cancel()` works (raises if `cancel_aws` was used — those are single-shot).
|
|
21
|
+
* **Decorator.** `@async_timer.every(5)` wraps a function into a Timer; original on `.func`.
|
|
22
|
+
* **Groups.** `TimerGroup()` starts/cancels a set of timers together.
|
|
23
|
+
* **Named.** `name="db_refresh"` shows in `repr()` and scopes the logger.
|
|
24
|
+
* **Test-friendly.** `mock_async_timer.MockTimer` replaces real sleeps with an `AsyncMock`.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
Python 3.9+.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install async-timer
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Examples
|
|
37
|
+
|
|
38
|
+
### FastAPI lifespan
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import contextlib
|
|
42
|
+
import time
|
|
43
|
+
import uvicorn
|
|
44
|
+
from fastapi import FastAPI
|
|
45
|
+
import async_timer
|
|
46
|
+
|
|
47
|
+
DB_CACHE = {"initialised": False}
|
|
48
|
+
|
|
49
|
+
async def refresh_db():
|
|
50
|
+
DB_CACHE.update(initialised=True, cur_value=time.time())
|
|
51
|
+
|
|
52
|
+
@contextlib.asynccontextmanager
|
|
53
|
+
async def lifespan(_app: FastAPI):
|
|
54
|
+
async with async_timer.Timer(delay=5, target=refresh_db) as timer:
|
|
55
|
+
await timer.wait(hit_count=1) # wait for first tick
|
|
56
|
+
yield
|
|
57
|
+
|
|
58
|
+
app = FastAPI(lifespan=lifespan)
|
|
59
|
+
|
|
60
|
+
@app.get("/")
|
|
61
|
+
async def root():
|
|
62
|
+
return {"db_cache": DB_CACHE}
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### `join()`
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import asyncio
|
|
72
|
+
import async_timer
|
|
73
|
+
|
|
74
|
+
async def main():
|
|
75
|
+
timer = async_timer.Timer(12, target=lambda: 42)
|
|
76
|
+
timer.start()
|
|
77
|
+
val = await timer.join() # 42, after the first tick
|
|
78
|
+
await timer.cancel()
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `async for`
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import asyncio, time
|
|
87
|
+
import async_timer
|
|
88
|
+
|
|
89
|
+
async def main():
|
|
90
|
+
async with async_timer.Timer(14, target=time.time) as timer:
|
|
91
|
+
async for t in timer:
|
|
92
|
+
print(t) # current time every 14 seconds
|
|
93
|
+
|
|
94
|
+
asyncio.run(main())
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Decorator
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import async_timer
|
|
101
|
+
|
|
102
|
+
@async_timer.every(5, mode="fixed_rate", name="db_refresh")
|
|
103
|
+
async def refresh_db():
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
await refresh_db.func() # call the undecorated fn (tests)
|
|
107
|
+
|
|
108
|
+
async def main():
|
|
109
|
+
refresh_db.start()
|
|
110
|
+
await refresh_db.join()
|
|
111
|
+
await refresh_db.cancel()
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### `TimerGroup`
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import async_timer
|
|
118
|
+
|
|
119
|
+
async def lifespan():
|
|
120
|
+
async with async_timer.TimerGroup() as group:
|
|
121
|
+
group.add(async_timer.Timer(5, target=refresh_db))
|
|
122
|
+
group.add(async_timer.Timer(60, target=prune_cache))
|
|
123
|
+
yield # both running; both cancelled on exit
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Trigger now
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
async def force_refresh(timer):
|
|
130
|
+
return await timer.trigger()
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Latest value, no blocking
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
@async_timer.every(5)
|
|
137
|
+
async def refresh_db():
|
|
138
|
+
return await db.fetch()
|
|
139
|
+
|
|
140
|
+
def get_cached():
|
|
141
|
+
return refresh_db.last_result # None until the first tick
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### Every-tick delivery via `subscribe()`
|
|
145
|
+
|
|
146
|
+
`join()` / `async for self` drop ticks under slow consumers (single-shot fan-out). Use `subscribe()` when you need every tick:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
async with timer.subscribe() as feed:
|
|
150
|
+
async for value in feed:
|
|
151
|
+
await log_it(value) # never misses a tick from subscribe-time
|
|
152
|
+
await asyncio.sleep(3.0) # even though the consumer is slow
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Bounded queue (drop oldest + log when full):
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
async with timer.subscribe(maxsize=10, name="metrics-sink") as feed:
|
|
159
|
+
async for value in feed:
|
|
160
|
+
await slow_export(value)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Multiple subscribers each get an independent copy:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
async with timer.subscribe() as a, timer.subscribe() as b:
|
|
167
|
+
...
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Consumer-side load shedding:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
async with timer.subscribe() as feed:
|
|
174
|
+
async for value in feed:
|
|
175
|
+
if feed.qsize > 100:
|
|
176
|
+
feed.drop_oldest(feed.qsize - 1) # keep only the newest
|
|
177
|
+
log.warning("shed %d ticks", feed.dropped_count)
|
|
178
|
+
await slow_export(value)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`drop_oldest()` never swallows end-of-stream / exception sentinels. Target exceptions re-raise from the subscriber's iteration.
|
|
182
|
+
|
|
183
|
+
## Thread safety
|
|
184
|
+
|
|
185
|
+
A `Timer` runs in a single asyncio event loop. Most state-mutating
|
|
186
|
+
operations must be called from the loop's thread. The following are
|
|
187
|
+
explicitly safe to use from any thread:
|
|
188
|
+
|
|
189
|
+
**Read-only attributes** (atomic under CPython's GIL):
|
|
190
|
+
|
|
191
|
+
* `timer.last_result`, `timer.last_tick_at`, `timer.hit_count`
|
|
192
|
+
* `timer.is_running()`, `timer.delay`, `timer.name`
|
|
193
|
+
* `subscription.qsize`, `subscription.dropped_count`
|
|
194
|
+
|
|
195
|
+
**`set_delay(new_delay)`** is a single attribute write — safe from any
|
|
196
|
+
thread; takes effect on the next sleep.
|
|
197
|
+
|
|
198
|
+
**Cross-thread control methods** — marshal the operation back to the
|
|
199
|
+
timer's loop and block for completion:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
# From a sync REST handler, signal handler, worker thread, etc.:
|
|
203
|
+
timer.cancel_threadsafe(timeout=5.0) # raises TimeoutError if exceeded
|
|
204
|
+
result = timer.trigger_threadsafe(timeout=5.0)
|
|
205
|
+
feed.close_threadsafe()
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
These raise `RuntimeError` with a clear message if called from the
|
|
209
|
+
timer's own loop thread (use `await cancel()` / `await trigger()`
|
|
210
|
+
instead), or if the timer has not been started yet, or if the bound
|
|
211
|
+
event loop has been closed.
|
|
212
|
+
|
|
213
|
+
Anything else (`subscribe()`, awaiting `join()` / `wait()`, iterating
|
|
214
|
+
`async for` over the timer or a subscription, reading from a
|
|
215
|
+
subscription queue) must happen on the loop's thread. From other
|
|
216
|
+
threads, use `asyncio.run_coroutine_threadsafe(coro, loop)` to
|
|
217
|
+
dispatch.
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "async-timer"
|
|
3
|
+
dynamic = []
|
|
4
|
+
description = "The missing Python async timer."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Ilja Orlovs", email = "vrghost@gmail.com" },
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
keywords = [
|
|
11
|
+
"async",
|
|
12
|
+
"asyncio",
|
|
13
|
+
"timer",
|
|
14
|
+
"scheduler",
|
|
15
|
+
"periodic",
|
|
16
|
+
"interval",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 5 - Production/Stable",
|
|
20
|
+
"Framework :: AsyncIO",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3.9",
|
|
26
|
+
"Programming Language :: Python :: 3.10",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Topic :: Software Development :: Libraries",
|
|
31
|
+
"Typing :: Typed",
|
|
32
|
+
]
|
|
33
|
+
dependencies = []
|
|
34
|
+
version = "1.2.0"
|
|
35
|
+
|
|
36
|
+
[project.license]
|
|
37
|
+
file = "LICENSE"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/IljaOrlovs/async-timer"
|
|
41
|
+
Source = "https://github.com/IljaOrlovs/async-timer"
|
|
42
|
+
Issues = "https://github.com/IljaOrlovs/async-timer/issues"
|
|
43
|
+
Changelog = "https://github.com/IljaOrlovs/async-timer/blob/main/CHANGELOG.md"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.3",
|
|
48
|
+
"pytest-asyncio>=0.24",
|
|
49
|
+
"pytest-local-badge>=1.0.3",
|
|
50
|
+
"pytest-cov>=5.0",
|
|
51
|
+
"pytest-timeout>=2.3",
|
|
52
|
+
"ruff>=0.8",
|
|
53
|
+
"asyncstdlib>=3.12",
|
|
54
|
+
"pyright>=1.1.409",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
[build-system]
|
|
58
|
+
requires = [
|
|
59
|
+
"pdm-backend",
|
|
60
|
+
]
|
|
61
|
+
build-backend = "pdm.backend"
|
|
62
|
+
|
|
63
|
+
[tool.pdm]
|
|
64
|
+
distribution = true
|
|
65
|
+
|
|
66
|
+
[tool.pdm.version]
|
|
67
|
+
source = "scm"
|
|
68
|
+
tag_regex = "^v(?P<version>\\d+\\.\\d+\\.\\d+.*)$"
|
|
69
|
+
fallback_version = "0.0.0.dev0"
|
|
70
|
+
|
|
71
|
+
[tool.pdm.build]
|
|
72
|
+
package-dir = "src"
|
|
73
|
+
includes = [
|
|
74
|
+
"src/async_timer",
|
|
75
|
+
"src/mock_async_timer",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.ruff]
|
|
79
|
+
target-version = "py39"
|
|
80
|
+
line-length = 88
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint]
|
|
83
|
+
select = [
|
|
84
|
+
"A",
|
|
85
|
+
"B",
|
|
86
|
+
"C",
|
|
87
|
+
"E",
|
|
88
|
+
"F",
|
|
89
|
+
"I",
|
|
90
|
+
"W",
|
|
91
|
+
"N",
|
|
92
|
+
"C4",
|
|
93
|
+
"T20",
|
|
94
|
+
"PTH",
|
|
95
|
+
]
|
|
96
|
+
ignore = [
|
|
97
|
+
"N802",
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
[tool.ruff.lint.per-file-ignores]
|
|
101
|
+
"__init__.py" = [
|
|
102
|
+
"F401",
|
|
103
|
+
"E402",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
[tool.ruff.lint.isort]
|
|
107
|
+
order-by-type = true
|
|
108
|
+
known-first-party = [
|
|
109
|
+
"async_timer",
|
|
110
|
+
"mock_async_timer",
|
|
111
|
+
]
|
|
112
|
+
forced-separate = [
|
|
113
|
+
"tests",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
[tool.ruff.format]
|
|
117
|
+
quote-style = "double"
|
|
118
|
+
|
|
119
|
+
[tool.pyright]
|
|
120
|
+
include = [
|
|
121
|
+
"src",
|
|
122
|
+
"tests",
|
|
123
|
+
]
|
|
124
|
+
exclude = [
|
|
125
|
+
"**/__pycache__",
|
|
126
|
+
".venv",
|
|
127
|
+
"docs",
|
|
128
|
+
]
|
|
129
|
+
reportIncompatibleVariableOverride = false
|
|
130
|
+
|
|
131
|
+
[tool.pytest.ini_options]
|
|
132
|
+
addopts = "-v -l --color=yes --cov=async_timer --cov=mock_async_timer --cov-report term-missing --no-cov-on-fail --local-badge-output-dir docs/badges/"
|
|
133
|
+
testpaths = [
|
|
134
|
+
"tests",
|
|
135
|
+
]
|
|
136
|
+
timeout = 3
|
|
137
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from importlib import metadata
|
|
2
|
+
|
|
3
|
+
from . import decorators, group, pacemaker, subscription, target_caller, timer
|
|
4
|
+
from .decorators import every
|
|
5
|
+
from .group import TimerGroup
|
|
6
|
+
from .subscription import Subscription
|
|
7
|
+
from .timer import Timer
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = metadata.version("async-timer")
|
|
11
|
+
except metadata.PackageNotFoundError: # pragma: no cover - editable w/o dist
|
|
12
|
+
__version__ = "0.0.0+unknown"
|
|
13
|
+
|
|
14
|
+
del metadata
|