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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: async-timer
3
- Version: 1.0.3
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
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "async-timer"
3
- version = "v1.0.3"
3
+ version = "v1.1.1"
4
4
  description = "The missing Python async timer."
5
5
  authors = ["Ilya O. <vrghost@gmail.com>"]
6
6
  license = "MIT"
@@ -0,0 +1,2 @@
1
+ from . import pacemaker, timer, traget_caller
2
+ from .timer import Timer
@@ -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
- delay: float
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
- self.delay = delay
80
- self.target = target
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
- raise RuntimeError("Please provide either `hits` or `hit_count`")
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
- while need_to_wait_for > 0:
134
- last_rv = await self.join()
135
- need_to_wait_for -= 1
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
- while True:
195
+ async for _ in self.iterator:
175
196
  try:
176
- next_val = get_next_val()
177
- if first_iter:
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
@@ -1,2 +0,0 @@
1
- from . import timer
2
- from .timer import Timer