async-timer 1.0.1__tar.gz → 1.0.3__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.0.1 → async_timer-1.0.3}/PKG-INFO +49 -3
- {async_timer-1.0.1 → async_timer-1.0.3}/README.md +48 -2
- {async_timer-1.0.1 → async_timer-1.0.3}/pyproject.toml +2 -2
- {async_timer-1.0.1 → async_timer-1.0.3}/src/async_timer/timer.py +59 -22
- {async_timer-1.0.1 → async_timer-1.0.3}/src/async_timer/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: async-timer
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: The missing Python async timer.
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: async,timer
|
|
@@ -48,16 +48,62 @@ This package is particularly useful for tasks like automatically updating caches
|
|
|
48
48
|
|
|
49
49
|
## Example Usage
|
|
50
50
|
|
|
51
|
+
### FastAPI
|
|
52
|
+
|
|
53
|
+
This snippet starts fastapi webserver with the `refresh_db` function being executed every 5 seconds, refresing a shared `DB_CACHE` object.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
|
|
57
|
+
import contextlib
|
|
58
|
+
import time
|
|
59
|
+
|
|
60
|
+
import uvicorn
|
|
61
|
+
from fastapi import FastAPI
|
|
62
|
+
|
|
63
|
+
import async_timer
|
|
64
|
+
|
|
65
|
+
DB_CACHE = {"initialised": False}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def refresh_db():
|
|
69
|
+
global DB_CACHE
|
|
70
|
+
DB_CACHE |= {"initialised": True, "cur_value": time.time()}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@contextlib.asynccontextmanager
|
|
74
|
+
async def lifespan(_app: FastAPI):
|
|
75
|
+
async with async_timer.Timer(delay=5, target=refresh_db) as timer:
|
|
76
|
+
await timer.wait(hit_count=1) # block until the timer triggers at least once
|
|
77
|
+
yield
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
app = FastAPI(lifespan=lifespan)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.get("/")
|
|
84
|
+
async def root():
|
|
85
|
+
return {"message": "Hello World", "db_cache": DB_CACHE}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### join()
|
|
51
94
|
```python
|
|
52
95
|
|
|
53
96
|
import async_timer
|
|
54
97
|
|
|
55
|
-
# Simple timer example
|
|
56
98
|
timer = async_timer.Timer(12, target=lambda: 42)
|
|
57
99
|
timer.start()
|
|
58
|
-
val = await timer.join() # `val` will be 42 after 12 seconds
|
|
100
|
+
val = await timer.join() # `val` will be set to 42 after 12 seconds
|
|
101
|
+
```
|
|
59
102
|
|
|
103
|
+
### for loop
|
|
104
|
+
```python
|
|
60
105
|
# Async for loop example
|
|
106
|
+
import async_timer
|
|
61
107
|
import time
|
|
62
108
|
with async_timer.Timer(14, target=time.time) as timer:
|
|
63
109
|
async for time_rv in timer:
|
|
@@ -26,16 +26,62 @@ This package is particularly useful for tasks like automatically updating caches
|
|
|
26
26
|
|
|
27
27
|
## Example Usage
|
|
28
28
|
|
|
29
|
+
### FastAPI
|
|
30
|
+
|
|
31
|
+
This snippet starts fastapi webserver with the `refresh_db` function being executed every 5 seconds, refresing a shared `DB_CACHE` object.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
|
|
35
|
+
import contextlib
|
|
36
|
+
import time
|
|
37
|
+
|
|
38
|
+
import uvicorn
|
|
39
|
+
from fastapi import FastAPI
|
|
40
|
+
|
|
41
|
+
import async_timer
|
|
42
|
+
|
|
43
|
+
DB_CACHE = {"initialised": False}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def refresh_db():
|
|
47
|
+
global DB_CACHE
|
|
48
|
+
DB_CACHE |= {"initialised": True, "cur_value": time.time()}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextlib.asynccontextmanager
|
|
52
|
+
async def lifespan(_app: FastAPI):
|
|
53
|
+
async with async_timer.Timer(delay=5, target=refresh_db) as timer:
|
|
54
|
+
await timer.wait(hit_count=1) # block until the timer triggers at least once
|
|
55
|
+
yield
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
app = FastAPI(lifespan=lifespan)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.get("/")
|
|
62
|
+
async def root():
|
|
63
|
+
return {"message": "Hello World", "db_cache": DB_CACHE}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### join()
|
|
29
72
|
```python
|
|
30
73
|
|
|
31
74
|
import async_timer
|
|
32
75
|
|
|
33
|
-
# Simple timer example
|
|
34
76
|
timer = async_timer.Timer(12, target=lambda: 42)
|
|
35
77
|
timer.start()
|
|
36
|
-
val = await timer.join() # `val` will be 42 after 12 seconds
|
|
78
|
+
val = await timer.join() # `val` will be set to 42 after 12 seconds
|
|
79
|
+
```
|
|
37
80
|
|
|
81
|
+
### for loop
|
|
82
|
+
```python
|
|
38
83
|
# Async for loop example
|
|
84
|
+
import async_timer
|
|
39
85
|
import time
|
|
40
86
|
with async_timer.Timer(14, target=time.time) as timer:
|
|
41
87
|
async for time_rv in timer:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "async-timer"
|
|
3
|
-
version = "v1.0.
|
|
3
|
+
version = "v1.0.3"
|
|
4
4
|
description = "The missing Python async timer."
|
|
5
5
|
authors = ["Ilya O. <vrghost@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -66,7 +66,7 @@ line-length = 88
|
|
|
66
66
|
|
|
67
67
|
[tool.ruff.isort]
|
|
68
68
|
order-by-type = true
|
|
69
|
-
known-first-party = ["
|
|
69
|
+
known-first-party = ["async_timer", ]
|
|
70
70
|
forced-separate = ["tests"]
|
|
71
71
|
|
|
72
72
|
[tool.black]
|
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
import asyncio
|
|
3
3
|
import inspect
|
|
4
4
|
import logging
|
|
5
|
-
import sys
|
|
6
5
|
import typing
|
|
7
6
|
|
|
8
7
|
logger = logging.getLogger(__name__)
|
|
9
8
|
T = typing.TypeVar("T")
|
|
9
|
+
TimerMainTaskT = typing.Union[
|
|
10
|
+
typing.Callable[[], T],
|
|
11
|
+
typing.Callable[[], typing.Coroutine[typing.Any, typing.Any, T]],
|
|
12
|
+
typing.Callable[[], typing.AsyncGenerator[T, typing.Any]],
|
|
13
|
+
typing.Callable[[], typing.Generator[T, typing.Any, typing.Any]],
|
|
14
|
+
]
|
|
15
|
+
TimerCallbackT = typing.Callable[["Timer[T]", TimerMainTaskT[T]], None]
|
|
10
16
|
|
|
11
17
|
|
|
12
|
-
class FanoutRv:
|
|
18
|
+
class FanoutRv(typing.Generic[T]):
|
|
13
19
|
"""An object that shares a result actoss all waiters"""
|
|
14
20
|
|
|
15
21
|
lock: asyncio.Lock
|
|
@@ -19,20 +25,20 @@ class FanoutRv:
|
|
|
19
25
|
self.futures = []
|
|
20
26
|
self.lock = asyncio.Lock()
|
|
21
27
|
|
|
22
|
-
async def wait(self):
|
|
28
|
+
async def wait(self) -> T:
|
|
23
29
|
"""Wait for result to be posted"""
|
|
24
30
|
future = asyncio.get_running_loop().create_future()
|
|
25
31
|
async with self.lock:
|
|
26
32
|
self.futures.append(future)
|
|
27
33
|
return await future
|
|
28
34
|
|
|
29
|
-
async def send_result(self, result):
|
|
35
|
+
async def send_result(self, result: T):
|
|
30
36
|
async with self.lock:
|
|
31
37
|
for future in self.futures:
|
|
32
38
|
future.set_result(result)
|
|
33
39
|
self.futures.clear()
|
|
34
40
|
|
|
35
|
-
async def send_exception(self, exc):
|
|
41
|
+
async def send_exception(self, exc: Exception):
|
|
36
42
|
async with self.lock:
|
|
37
43
|
for future in self.futures:
|
|
38
44
|
future.set_exception(exc)
|
|
@@ -45,30 +51,36 @@ class FanoutRv:
|
|
|
45
51
|
self.futures.clear()
|
|
46
52
|
|
|
47
53
|
|
|
48
|
-
def
|
|
54
|
+
def _noop_cb(*_, **__):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _default_main_loop_exception_callback(*_, **__):
|
|
49
59
|
logger.exception("An unexpected exception in the timer loop.")
|
|
50
60
|
|
|
51
61
|
|
|
52
|
-
class Timer:
|
|
62
|
+
class Timer(typing.Generic[T]):
|
|
53
63
|
delay: float
|
|
54
|
-
|
|
64
|
+
hit_count: int = 0 # Number of times the timer has run so far
|
|
65
|
+
target: TimerMainTaskT[T]
|
|
55
66
|
|
|
56
|
-
result_fanout: FanoutRv
|
|
67
|
+
result_fanout: FanoutRv[T]
|
|
57
68
|
main_task: typing.Optional[asyncio.Task] = None
|
|
58
|
-
exception_callback:
|
|
69
|
+
exception_callback: TimerCallbackT[T]
|
|
70
|
+
cancel_callback: TimerCallbackT[T]
|
|
59
71
|
|
|
60
72
|
def __init__(
|
|
61
73
|
self,
|
|
62
74
|
delay: float,
|
|
63
|
-
target:
|
|
64
|
-
exc_cb:
|
|
65
|
-
|
|
66
|
-
] = _default_main_loop_exception_callback,
|
|
75
|
+
target: TimerMainTaskT[T],
|
|
76
|
+
exc_cb: TimerCallbackT[T] = _default_main_loop_exception_callback,
|
|
77
|
+
cancel_cb: TimerCallbackT[T] = _noop_cb,
|
|
67
78
|
):
|
|
68
79
|
self.delay = delay
|
|
69
80
|
self.target = target
|
|
70
81
|
self.result_fanout = FanoutRv()
|
|
71
82
|
self.exception_callback = exc_cb
|
|
83
|
+
self.cancel_callback = cancel_cb
|
|
72
84
|
|
|
73
85
|
def start(self):
|
|
74
86
|
"""Schedule the timer to run."""
|
|
@@ -82,17 +94,17 @@ class Timer:
|
|
|
82
94
|
"""Return `True` if the timer is currently scheduled"""
|
|
83
95
|
return (self.main_task is not None) and (not self.main_task.done())
|
|
84
96
|
|
|
85
|
-
async def __aenter__(self):
|
|
97
|
+
async def __aenter__(self) -> "Timer[T]":
|
|
86
98
|
self.start()
|
|
87
99
|
return self
|
|
88
100
|
|
|
89
101
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
90
102
|
await self.cancel()
|
|
91
103
|
|
|
92
|
-
def __aiter__(self):
|
|
104
|
+
def __aiter__(self) -> typing.AsyncIterator[T]:
|
|
93
105
|
return self
|
|
94
106
|
|
|
95
|
-
async def join(self):
|
|
107
|
+
async def join(self) -> T:
|
|
96
108
|
"""Wait for the next tick of the timer"""
|
|
97
109
|
if not self.is_running():
|
|
98
110
|
raise asyncio.CancelledError("The timer is not running.")
|
|
@@ -100,7 +112,30 @@ class Timer:
|
|
|
100
112
|
await self.result_fanout.wait()
|
|
101
113
|
) # this can raise `asyncio.CancelledError`
|
|
102
114
|
|
|
103
|
-
async def
|
|
115
|
+
async def wait(
|
|
116
|
+
self, /, hit_count: int = None, hits: int = None
|
|
117
|
+
) -> typing.Optional[T]:
|
|
118
|
+
"""
|
|
119
|
+
Wait for the timer to reach certain hit count
|
|
120
|
+
or wait for a certain number of hits.
|
|
121
|
+
|
|
122
|
+
Returns the last generated result IF there was a need to wait.
|
|
123
|
+
Returns `None` otherwise.
|
|
124
|
+
"""
|
|
125
|
+
if hit_count is not None:
|
|
126
|
+
target_hit_count = max(0, hit_count)
|
|
127
|
+
elif hits is not None:
|
|
128
|
+
target_hit_count = self.hit_count + max(0, hits)
|
|
129
|
+
else:
|
|
130
|
+
raise RuntimeError("Please provide either `hits` or `hit_count`")
|
|
131
|
+
need_to_wait_for = target_hit_count - self.hit_count
|
|
132
|
+
last_rv = None
|
|
133
|
+
while need_to_wait_for > 0:
|
|
134
|
+
last_rv = await self.join()
|
|
135
|
+
need_to_wait_for -= 1
|
|
136
|
+
return last_rv
|
|
137
|
+
|
|
138
|
+
async def __anext__(self) -> T:
|
|
104
139
|
try:
|
|
105
140
|
return await self.join()
|
|
106
141
|
except asyncio.CancelledError as err:
|
|
@@ -108,7 +143,7 @@ class Timer:
|
|
|
108
143
|
|
|
109
144
|
def _maybe_detect_generator(
|
|
110
145
|
self, target_rv
|
|
111
|
-
) -> typing.Tuple[
|
|
146
|
+
) -> typing.Tuple[T, typing.Callable[[], T]]:
|
|
112
147
|
"""Check if the value returned by the `self.target` call is a
|
|
113
148
|
kind of generator (sync or async).
|
|
114
149
|
|
|
@@ -149,19 +184,21 @@ class Timer:
|
|
|
149
184
|
rv = await next_val
|
|
150
185
|
else:
|
|
151
186
|
rv = next_val
|
|
152
|
-
|
|
153
|
-
|
|
187
|
+
except (StopIteration, StopAsyncIteration):
|
|
188
|
+
break
|
|
154
189
|
except Exception as err:
|
|
155
190
|
await self.result_fanout.send_exception(err)
|
|
156
|
-
self.exception_callback(
|
|
191
|
+
self.exception_callback(self, self.target)
|
|
157
192
|
break
|
|
158
193
|
else:
|
|
159
194
|
await self.result_fanout.send_result(rv)
|
|
160
195
|
first_iter = False
|
|
196
|
+
self.hit_count += 1
|
|
161
197
|
await asyncio.sleep(self.delay)
|
|
162
198
|
finally:
|
|
163
199
|
# Main loop finished - cancel all watchers
|
|
164
200
|
await self.result_fanout.cancel()
|
|
201
|
+
self.cancel_callback(self, self.target)
|
|
165
202
|
|
|
166
203
|
async def cancel(self):
|
|
167
204
|
"""Unshedule the timer"""
|
|
File without changes
|