async-timer 1.0.3__tar.gz → 1.1.1__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.3 → async_timer-1.1.1}/PKG-INFO +2 -1
- {async_timer-1.0.3 → async_timer-1.1.1}/README.md +1 -0
- {async_timer-1.0.3 → async_timer-1.1.1}/pyproject.toml +1 -1
- async_timer-1.1.1/src/async_timer/__init__.py +2 -0
- async_timer-1.1.1/src/async_timer/pacemaker.py +71 -0
- {async_timer-1.0.3 → async_timer-1.1.1}/src/async_timer/timer.py +74 -54
- async_timer-1.1.1/src/async_timer/traget_caller.py +70 -0
- async_timer-1.0.3/src/async_timer/__init__.py +0 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: async-timer
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.1
|
|
4
4
|
Summary: The missing Python async timer.
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: async,timer
|
|
@@ -45,6 +45,7 @@ This package is particularly useful for tasks like automatically updating caches
|
|
|
45
45
|
* Asynchronous generators
|
|
46
46
|
* **Wait for the Next Tick**: You can set it up so your program waits for the timer to do its thing, and then continues.
|
|
47
47
|
* **Keep Getting Updates**: You can use it in a loop to keep getting updates every time the timer goes off.
|
|
48
|
+
* **Cancel anytime**: The timer object can be stopped at any time either explicitly by calling `stop()`/`cancel()` method OR it can stop automatically on an awaitable resolving (the `cancel_aws` constructor artument)
|
|
48
49
|
|
|
49
50
|
## Example Usage
|
|
50
51
|
|
|
@@ -23,6 +23,7 @@ This package is particularly useful for tasks like automatically updating caches
|
|
|
23
23
|
* Asynchronous generators
|
|
24
24
|
* **Wait for the Next Tick**: You can set it up so your program waits for the timer to do its thing, and then continues.
|
|
25
25
|
* **Keep Getting Updates**: You can use it in a loop to keep getting updates every time the timer goes off.
|
|
26
|
+
* **Cancel anytime**: The timer object can be stopped at any time either explicitly by calling `stop()`/`cancel()` method OR it can stop automatically on an awaitable resolving (the `cancel_aws` constructor artument)
|
|
26
27
|
|
|
27
28
|
## Example Usage
|
|
28
29
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import dataclasses
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclasses.dataclass()
|
|
7
|
+
class ConfigurationChanged:
|
|
8
|
+
"""An internal object that is returned when internal pacemaker state has changed"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TimerPacemaker:
|
|
12
|
+
"""A helper object that controls the timers' iterations."""
|
|
13
|
+
|
|
14
|
+
delay: float
|
|
15
|
+
_first_iter: bool = True
|
|
16
|
+
_running: bool = True
|
|
17
|
+
_cancel_futs: typing.List[asyncio.futures.Future]
|
|
18
|
+
_cancel_evt: asyncio.Event
|
|
19
|
+
|
|
20
|
+
def __init__(self, delay: float):
|
|
21
|
+
self.delay = delay
|
|
22
|
+
self._cancel_futs = []
|
|
23
|
+
self._cancel_evt = asyncio.Event()
|
|
24
|
+
|
|
25
|
+
def stop_on(self, aws: typing.Sequence[asyncio.Future]):
|
|
26
|
+
for el in aws:
|
|
27
|
+
fut = asyncio.ensure_future(el)
|
|
28
|
+
fut.add_done_callback(lambda _fut: self.stop())
|
|
29
|
+
self._cancel_futs.append(fut)
|
|
30
|
+
|
|
31
|
+
def stop(self):
|
|
32
|
+
"""Stop the iterator."""
|
|
33
|
+
for fut in self._cancel_futs:
|
|
34
|
+
fut.cancel()
|
|
35
|
+
self._cancel_futs.clear()
|
|
36
|
+
self._cancel_evt.set()
|
|
37
|
+
self._running = False
|
|
38
|
+
|
|
39
|
+
def __aiter__(self):
|
|
40
|
+
"""The core funtionality - return the iterator"""
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
async def __anext__(self):
|
|
44
|
+
# Do not sleep at the first iter
|
|
45
|
+
# (so the timer hits the target function at startup)
|
|
46
|
+
if not self._running:
|
|
47
|
+
raise StopAsyncIteration()
|
|
48
|
+
elif self._first_iter:
|
|
49
|
+
self._first_iter = False
|
|
50
|
+
else:
|
|
51
|
+
try:
|
|
52
|
+
await self._try_wait(self.delay)
|
|
53
|
+
except StopAsyncIteration:
|
|
54
|
+
self.stop()
|
|
55
|
+
raise
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
async def _try_wait(self, delay: float):
|
|
59
|
+
"""Try waiting for the `delay`.
|
|
60
|
+
|
|
61
|
+
Raises `StopAsyncIteration` if the sleep was cancelled
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
await asyncio.wait_for(self._cancel_evt.wait(), timeout=delay)
|
|
65
|
+
except asyncio.TimeoutError:
|
|
66
|
+
# Sleep succeeded
|
|
67
|
+
return None
|
|
68
|
+
# the cancel event was triggered if no timout was raised
|
|
69
|
+
assert self._cancel_evt.is_set()
|
|
70
|
+
# So, raise StopIteration
|
|
71
|
+
raise StopAsyncIteration()
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Utility async io functions"""
|
|
2
2
|
import asyncio
|
|
3
|
-
import inspect
|
|
4
3
|
import logging
|
|
4
|
+
import time
|
|
5
5
|
import typing
|
|
6
6
|
|
|
7
|
+
import async_timer
|
|
8
|
+
|
|
7
9
|
logger = logging.getLogger(__name__)
|
|
8
10
|
T = typing.TypeVar("T")
|
|
9
11
|
TimerMainTaskT = typing.Union[
|
|
@@ -11,6 +13,8 @@ TimerMainTaskT = typing.Union[
|
|
|
11
13
|
typing.Callable[[], typing.Coroutine[typing.Any, typing.Any, T]],
|
|
12
14
|
typing.Callable[[], typing.AsyncGenerator[T, typing.Any]],
|
|
13
15
|
typing.Callable[[], typing.Generator[T, typing.Any, typing.Any]],
|
|
16
|
+
typing.AsyncGenerator[T, typing.Any],
|
|
17
|
+
typing.Generator[T, typing.Any, typing.Any],
|
|
14
18
|
]
|
|
15
19
|
TimerCallbackT = typing.Callable[["Timer[T]", TimerMainTaskT[T]], None]
|
|
16
20
|
|
|
@@ -57,10 +61,13 @@ def _noop_cb(*_, **__):
|
|
|
57
61
|
|
|
58
62
|
def _default_main_loop_exception_callback(*_, **__):
|
|
59
63
|
logger.exception("An unexpected exception in the timer loop.")
|
|
64
|
+
raise
|
|
60
65
|
|
|
61
66
|
|
|
62
67
|
class Timer(typing.Generic[T]):
|
|
63
|
-
|
|
68
|
+
"""The main Timer object"""
|
|
69
|
+
|
|
70
|
+
iterator: "async_timer.pacemaker.TimerPacemaker"
|
|
64
71
|
hit_count: int = 0 # Number of times the timer has run so far
|
|
65
72
|
target: TimerMainTaskT[T]
|
|
66
73
|
|
|
@@ -75,12 +82,33 @@ class Timer(typing.Generic[T]):
|
|
|
75
82
|
target: TimerMainTaskT[T],
|
|
76
83
|
exc_cb: TimerCallbackT[T] = _default_main_loop_exception_callback,
|
|
77
84
|
cancel_cb: TimerCallbackT[T] = _noop_cb,
|
|
85
|
+
cancel_aws: typing.Union[typing.Sequence[typing.Awaitable], None] = None,
|
|
86
|
+
start: bool = False,
|
|
78
87
|
):
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
"""Create the Timer object.
|
|
89
|
+
|
|
90
|
+
Parameters:
|
|
91
|
+
`delay` - number of seconds between timer incovations
|
|
92
|
+
`target` - the callable the timer will be invoking at the `delay` period
|
|
93
|
+
`exc_cb` - a callback that the timer will call on exception
|
|
94
|
+
`cancel_cb` - callback the timer will call at cancellation
|
|
95
|
+
`cancel_aws` - a list of awaitables, where any
|
|
96
|
+
one resolving cancels the timer
|
|
97
|
+
"""
|
|
98
|
+
self.iterator = async_timer.pacemaker.TimerPacemaker(delay)
|
|
99
|
+
self.target_caller = async_timer.traget_caller.Caller(target)
|
|
81
100
|
self.result_fanout = FanoutRv()
|
|
82
101
|
self.exception_callback = exc_cb
|
|
83
102
|
self.cancel_callback = cancel_cb
|
|
103
|
+
if cancel_aws:
|
|
104
|
+
self.iterator.stop_on(list(cancel_aws))
|
|
105
|
+
if start:
|
|
106
|
+
self.start()
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def delay(self) -> float:
|
|
110
|
+
"""A shorthand to access timer firing delay"""
|
|
111
|
+
return self.iterator.delay
|
|
84
112
|
|
|
85
113
|
def start(self):
|
|
86
114
|
"""Schedule the timer to run."""
|
|
@@ -113,26 +141,47 @@ class Timer(typing.Generic[T]):
|
|
|
113
141
|
) # this can raise `asyncio.CancelledError`
|
|
114
142
|
|
|
115
143
|
async def wait(
|
|
116
|
-
self, /, hit_count: int = None, hits: int = None
|
|
144
|
+
self, /, hit_count: int = None, hits: int = None, timeout: float = None
|
|
117
145
|
) -> typing.Optional[T]:
|
|
118
146
|
"""
|
|
119
147
|
Wait for the timer to reach certain hit count
|
|
120
148
|
or wait for a certain number of hits.
|
|
121
149
|
|
|
150
|
+
Can raise `asyncio.TimeoutError` if there was a wait condition
|
|
151
|
+
and timeout specified and the wait did not manage to hit
|
|
152
|
+
the condition in time
|
|
153
|
+
|
|
154
|
+
Waits for the timer to stop if neither parameter is present.
|
|
155
|
+
|
|
122
156
|
Returns the last generated result IF there was a need to wait.
|
|
123
157
|
Returns `None` otherwise.
|
|
124
158
|
"""
|
|
159
|
+
start_time = time.monotonic()
|
|
160
|
+
timeout_left = timeout
|
|
161
|
+
infinite_wait = False
|
|
125
162
|
if hit_count is not None:
|
|
126
163
|
target_hit_count = max(0, hit_count)
|
|
127
164
|
elif hits is not None:
|
|
128
165
|
target_hit_count = self.hit_count + max(0, hits)
|
|
129
166
|
else:
|
|
130
|
-
|
|
167
|
+
target_hit_count = 0
|
|
168
|
+
infinite_wait = True
|
|
131
169
|
need_to_wait_for = target_hit_count - self.hit_count
|
|
132
170
|
last_rv = None
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
171
|
+
try:
|
|
172
|
+
while infinite_wait or need_to_wait_for > 0:
|
|
173
|
+
last_rv = await asyncio.wait_for(self.join(), timeout_left)
|
|
174
|
+
need_to_wait_for -= 1
|
|
175
|
+
if timeout is not None:
|
|
176
|
+
time_passed = time.monotonic() - start_time
|
|
177
|
+
timeout_left = timeout - time_passed
|
|
178
|
+
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
179
|
+
# Cancelled/Timeout error is what we were waiting
|
|
180
|
+
# for in the infinite wait mode
|
|
181
|
+
if infinite_wait:
|
|
182
|
+
pass
|
|
183
|
+
else:
|
|
184
|
+
raise
|
|
136
185
|
return last_rv
|
|
137
186
|
|
|
138
187
|
async def __anext__(self) -> T:
|
|
@@ -141,72 +190,43 @@ class Timer(typing.Generic[T]):
|
|
|
141
190
|
except asyncio.CancelledError as err:
|
|
142
191
|
raise StopAsyncIteration() from err
|
|
143
192
|
|
|
144
|
-
def _maybe_detect_generator(
|
|
145
|
-
self, target_rv
|
|
146
|
-
) -> typing.Tuple[T, typing.Callable[[], T]]:
|
|
147
|
-
"""Check if the value returned by the `self.target` call is a
|
|
148
|
-
kind of generator (sync or async).
|
|
149
|
-
|
|
150
|
-
Returns a (this_iter_rv, new_callable) tuple
|
|
151
|
-
"""
|
|
152
|
-
if inspect.isgenerator(target_rv):
|
|
153
|
-
|
|
154
|
-
def _lock_sync_gen_ctx(gen_src):
|
|
155
|
-
return lambda: next(gen_src)
|
|
156
|
-
|
|
157
|
-
get_next_val = _lock_sync_gen_ctx(target_rv)
|
|
158
|
-
rv = (get_next_val(), get_next_val)
|
|
159
|
-
elif inspect.isasyncgen(target_rv):
|
|
160
|
-
|
|
161
|
-
def _lock_async_gen_ctx(gen_src):
|
|
162
|
-
return lambda: gen_src.__anext__()
|
|
163
|
-
|
|
164
|
-
get_next_val = _lock_async_gen_ctx(target_rv)
|
|
165
|
-
rv = (get_next_val(), get_next_val)
|
|
166
|
-
else:
|
|
167
|
-
rv = (target_rv, None)
|
|
168
|
-
return rv
|
|
169
|
-
|
|
170
193
|
async def _loop_callback_routine(self):
|
|
171
|
-
get_next_val = self.target
|
|
172
|
-
first_iter = True
|
|
173
194
|
try:
|
|
174
|
-
|
|
195
|
+
async for _ in self.iterator:
|
|
175
196
|
try:
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
(next_val, updated_get_next_val) = self._maybe_detect_generator(
|
|
179
|
-
next_val
|
|
180
|
-
)
|
|
181
|
-
if updated_get_next_val is not None:
|
|
182
|
-
get_next_val = updated_get_next_val
|
|
183
|
-
if inspect.isawaitable(next_val):
|
|
184
|
-
rv = await next_val
|
|
185
|
-
else:
|
|
186
|
-
rv = next_val
|
|
187
|
-
except (StopIteration, StopAsyncIteration):
|
|
197
|
+
rv = await self.target_caller.next()
|
|
198
|
+
except StopAsyncIteration:
|
|
188
199
|
break
|
|
189
200
|
except Exception as err:
|
|
190
201
|
await self.result_fanout.send_exception(err)
|
|
191
|
-
self.exception_callback(self, self.target)
|
|
202
|
+
self.exception_callback(self, self.target_caller.target)
|
|
192
203
|
break
|
|
193
204
|
else:
|
|
194
205
|
await self.result_fanout.send_result(rv)
|
|
195
|
-
first_iter = False
|
|
196
206
|
self.hit_count += 1
|
|
197
|
-
await asyncio.sleep(self.delay)
|
|
198
207
|
finally:
|
|
199
208
|
# Main loop finished - cancel all watchers
|
|
200
209
|
await self.result_fanout.cancel()
|
|
201
|
-
self.cancel_callback(self, self.target)
|
|
210
|
+
self.cancel_callback(self, self.target_caller.target)
|
|
202
211
|
|
|
203
212
|
async def cancel(self):
|
|
204
213
|
"""Unshedule the timer"""
|
|
205
214
|
if self.main_task:
|
|
206
215
|
self.main_task.cancel()
|
|
207
216
|
await self.result_fanout.cancel()
|
|
217
|
+
self.iterator.stop()
|
|
208
218
|
self.main_task = None
|
|
209
219
|
|
|
210
220
|
async def stop(self):
|
|
211
221
|
"""An alias to `cancel()`"""
|
|
212
222
|
return await self.cancel()
|
|
223
|
+
|
|
224
|
+
def __repr__(self) -> str:
|
|
225
|
+
return (
|
|
226
|
+
f"<{self.__class__.__name__} target={self.target_caller.target!r}"
|
|
227
|
+
f" delay={self.delay!r}"
|
|
228
|
+
f" hit_count={self.hit_count!r}"
|
|
229
|
+
f" exception_callback={self.exception_callback!r}"
|
|
230
|
+
f" cancel_callback={self.cancel_callback!r}"
|
|
231
|
+
">"
|
|
232
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""This module is responsible for the magic behaviour calling the `target` function."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Caller:
|
|
8
|
+
target = None
|
|
9
|
+
get_next_val = None
|
|
10
|
+
first_call: bool = True
|
|
11
|
+
|
|
12
|
+
def __init__(self, target):
|
|
13
|
+
self.target = target
|
|
14
|
+
|
|
15
|
+
def _wrap_generator(self, maybe_gen):
|
|
16
|
+
if inspect.isgenerator(maybe_gen):
|
|
17
|
+
|
|
18
|
+
def _lock_sync_gen_ctx():
|
|
19
|
+
return lambda: next(maybe_gen)
|
|
20
|
+
|
|
21
|
+
gen_next_val = _lock_sync_gen_ctx()
|
|
22
|
+
elif inspect.isasyncgen(maybe_gen):
|
|
23
|
+
|
|
24
|
+
def _lock_async_gen_ctx():
|
|
25
|
+
return lambda: maybe_gen.__anext__()
|
|
26
|
+
|
|
27
|
+
gen_next_val = _lock_async_gen_ctx()
|
|
28
|
+
elif isinstance(maybe_gen, Iterator):
|
|
29
|
+
|
|
30
|
+
def _lock_iterator_ctx():
|
|
31
|
+
return next(maybe_gen)
|
|
32
|
+
|
|
33
|
+
gen_next_val = _lock_iterator_ctx
|
|
34
|
+
else:
|
|
35
|
+
gen_next_val = None
|
|
36
|
+
return gen_next_val
|
|
37
|
+
|
|
38
|
+
def _setup(self, target):
|
|
39
|
+
"""Configure `get_next_val` to return next value.
|
|
40
|
+
|
|
41
|
+
Return the first such next value.
|
|
42
|
+
"""
|
|
43
|
+
self.get_next_val = self._wrap_generator(target)
|
|
44
|
+
if self.get_next_val:
|
|
45
|
+
# `target` is a generator and we now have the
|
|
46
|
+
# `get_next_val`
|
|
47
|
+
return self.get_next_val()
|
|
48
|
+
assert callable(target), "Otherwise target must be callable"
|
|
49
|
+
target_rv = target()
|
|
50
|
+
self.get_next_val = self._wrap_generator(target_rv)
|
|
51
|
+
if self.get_next_val:
|
|
52
|
+
# Tartget is a callable that returned a generator.
|
|
53
|
+
return self.get_next_val()
|
|
54
|
+
# Otherwise, target is just a callable that returns values
|
|
55
|
+
self.get_next_val = target
|
|
56
|
+
return target_rv
|
|
57
|
+
|
|
58
|
+
async def next(self):
|
|
59
|
+
"""Call `target` one more time."""
|
|
60
|
+
try:
|
|
61
|
+
if self.first_call:
|
|
62
|
+
rv = self._setup(self.target)
|
|
63
|
+
self.first_call = False
|
|
64
|
+
else:
|
|
65
|
+
rv = self.get_next_val()
|
|
66
|
+
except StopIteration as _err:
|
|
67
|
+
raise StopAsyncIteration() from _err
|
|
68
|
+
if inspect.isawaitable(rv):
|
|
69
|
+
rv = await rv
|
|
70
|
+
return rv
|