pyteleflow 0.1.1__py3-none-any.whl

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.
pyteleflow/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from ._strategy import ConcurrencyStrategy
2
+
3
+ from ._app import FlowApp
4
+ from ._flow import Context
pyteleflow/_app.py ADDED
@@ -0,0 +1,439 @@
1
+ import asyncio as aio
2
+ import logging
3
+ import platform
4
+ import secrets
5
+ import signal
6
+ import time
7
+ from asyncio import Lock, Queue, Task
8
+ from collections import deque
9
+ from collections.abc import Callable, Coroutine, Sequence
10
+ from datetime import timedelta
11
+ from types import MappingProxyType
12
+ from typing import Any
13
+
14
+ import telegram.error
15
+ from telegram import Update
16
+ from telegram.constants import UpdateType
17
+ from telegram.ext import ExtBot, filters
18
+ from telegram.ext.filters import BaseFilter
19
+ from tenacity import Retrying, retry_if_exception_type, wait_exponential
20
+
21
+ from ._helpers import cancel_tasks
22
+ from ._flow import Context, Flow
23
+ from ._helpers import TaskStatus, get_task_status
24
+ from ._strategy import BaseConcurrencyStrategy, ConcurrencyStrategy
25
+ from ._types import FlowKey
26
+ from .errors import (
27
+ FlowAlreadySpawned,
28
+ FlowCancellationFailed,
29
+ FlowKeyError,
30
+ UnexpectedError,
31
+ PyteleflowError,
32
+ )
33
+ from .middleware import AsyncMiddleware, Middleware
34
+
35
+ _logger = logging.getLogger(__name__)
36
+
37
+
38
+ type FlowCallback = Callable[[Context], Coroutine[Any, Any, None]]
39
+
40
+ DEFAULT_SPAWN_TRIGGER = filters.Text(["/start"])
41
+
42
+
43
+ class FlowApp:
44
+ def __init__(
45
+ self,
46
+ bot: ExtBot,
47
+ entry_coro: FlowCallback,
48
+ concurrency_limit: int | None = None,
49
+ spawn_trigger: BaseFilter = DEFAULT_SPAWN_TRIGGER, # Default: "/start" command
50
+ respawn_trigger: BaseFilter | None = DEFAULT_SPAWN_TRIGGER,
51
+ concurrency_strategy: BaseConcurrencyStrategy = ConcurrencyStrategy.CHAT,
52
+ flow_inactivity_timeout: timedelta | float = timedelta(minutes=5),
53
+ flow_cancellation_timeout: timedelta | float = timedelta(seconds=3),
54
+ shared_data: dict | None = None,
55
+ ):
56
+ """
57
+ Args:
58
+ bot: Bot instance (`ExtBot`) used to receive updates and send
59
+ requests to Telegram.
60
+
61
+ entry_coro: Coroutine launched when a new Flow is spawned. Receives the
62
+ `Context` of that Flow and runs as a separate task.
63
+
64
+ concurrency_limit:
65
+ The maximum number of `Flow` instances that can run concurrently.
66
+ If `None`, there is no limit. When the limit is reached, no new `Flow` is spawned,
67
+ and the corresponding `Update` is silently dropped. A warning is logged in this case.
68
+
69
+ spawn_trigger:
70
+ A trigger is a standard `Filter` from `telegram.ext.filters` or a custom filter implementation.
71
+ When an incoming `Update` matches the filter, a new `Flow` instance is spawned,
72
+ and the `Update` that triggered the spawn is available in the context through
73
+ the `ctx.initial_update` property.
74
+ For example, if `spawn_trigger = filters.Text(["Hello"]) | filters.PHOTO`,
75
+ a new `Flow` is spawned only when a user sends a message with the text `Hello` or sends a photo.
76
+
77
+ respawn_trigger:
78
+ The filter that determines whether an update belonging to an existing `Flow`
79
+ cancels that `Flow` and restarts it with the new update.
80
+
81
+ concurrency_strategy:
82
+ The concurrency strategy defines how incoming updates are processed concurrently.
83
+ For example, it determines whether a single `Flow` instance handles all incoming updates
84
+ or whether a separate `Flow` instance is spawned for each chat or each user within a chat.
85
+ Available strategies:
86
+ `ConcurrencyStrategy.CHAT`
87
+ `ConcurrencyStrategy.USER_IN_GLOBAL`
88
+ `ConcurrencyStrategy.USER_IN_CHAT`
89
+ `ConcurrencyStrategy.THREAD_IN_CHAT`
90
+ `ConcurrencyStrategy.SINGLE_FLOW`
91
+ For a description of each strategy, see the `ConcurrencyStrategy` class.
92
+
93
+ flow_inactivity_timeout:
94
+ The maximum period of inactivity allowed for a `Flow`. If this timeout expires,
95
+ the `Flow` is canceled.
96
+
97
+ A `Flow` is considered active whenever it receives a new `Update` through methods
98
+ such as `ctx.update()`, `ctx.message()`, and others.
99
+
100
+ The `flow_inactivity_timeout` parameter sets the default inactivity timeout for all `Flow` instances.
101
+ You can override this value for an individual `Flow` instance from within the `Flow`
102
+ using the context: `ctx.inactivity_timeout = timedelta(minutes=15)`.
103
+
104
+ When a `Flow` is canceled, its associated `asyncio.Task` is also canceled,
105
+ causing an `asyncio.CancelledError` exception to be raised inside the coroutine.
106
+
107
+ flow_cancellation_timeout:
108
+ The amount of time allowed to handle `asyncio.CancelledError` when a Flow is
109
+ canceled due to inactivity or respawning.
110
+
111
+ For example:
112
+
113
+ async def your_flow(ctx: Context):
114
+ try:
115
+ msg = await ctx.message()
116
+ except asyncio.CancelledError:
117
+ # If sleep exceeds flow_cancellation_timeout, another
118
+ # CancelledError will be raised in this block.
119
+ await asyncio.sleep(999)
120
+
121
+ shared_data:
122
+ Shared data passed to all Flow instances. This data is accessible through the Flow context.
123
+ """
124
+
125
+ self._bot: ExtBot = bot
126
+ self._entry_coro = entry_coro
127
+ self._concurrency_strategy: BaseConcurrencyStrategy = concurrency_strategy
128
+ self._concurrency_limit: int | None = concurrency_limit
129
+ self._shared_data = shared_data or {}
130
+ self._respawn_trigger = respawn_trigger
131
+ self._last_update_id: int | None = None
132
+ self._poll_interval: int = 0
133
+ self._spawn_trigger: BaseFilter = spawn_trigger
134
+ self._allowed_updates: tuple[UpdateType, ...] | None = None
135
+ self._middlewares: list[Middleware | AsyncMiddleware] = []
136
+ self._lock: Lock = Lock()
137
+ self._is_running: bool = False
138
+ self._bg_tasks: set[Task] = set() # background tasks
139
+ self._startup_coroutines: list[tuple[str | None, Coroutine]] = []
140
+
141
+ self._flow_inactivity_timeout: float
142
+ if isinstance(flow_inactivity_timeout, timedelta):
143
+ self._flow_inactivity_timeout = float(flow_inactivity_timeout.total_seconds())
144
+ else:
145
+ self._flow_inactivity_timeout = float(flow_inactivity_timeout)
146
+
147
+ self._flow_cancellation_timeout: float
148
+ if isinstance(flow_cancellation_timeout, timedelta):
149
+ self._flow_cancellation_timeout = float(flow_cancellation_timeout.total_seconds())
150
+ else:
151
+ self._flow_cancellation_timeout = float(flow_cancellation_timeout)
152
+
153
+ self._spawned_flows: dict[FlowKey, Flow] = {}
154
+
155
+ async def _get_updates(self) -> tuple[Update, ...]:
156
+ # Skip updates on first launch
157
+ if self._last_update_id is None:
158
+ updates = await self._bot.get_updates(
159
+ offset=-1, timeout=0, allowed_updates=self._allowed_updates
160
+ )
161
+ self._last_update_id = updates[-1].update_id + 1 if len(updates) > 0 else 0
162
+
163
+ updates = await self._bot.get_updates(
164
+ offset=self._last_update_id,
165
+ timeout=self._poll_interval,
166
+ allowed_updates=self._allowed_updates,
167
+ )
168
+ if len(updates) > 0:
169
+ self._last_update_id = updates[-1].update_id + 1
170
+
171
+ return updates
172
+
173
+ def _spawn_new_flow(self, flow_key: FlowKey, initial_update: Update) -> None:
174
+ if flow_key in self._spawned_flows:
175
+ raise FlowAlreadySpawned("The flow with such an KEY has already been spawned")
176
+
177
+ if self._concurrency_limit is not None and len(self._spawned_flows) >= self._concurrency_limit:
178
+ _logger.warning("Concurrency limit reached. Unable to spawn a new flow.")
179
+ return
180
+
181
+ context = Context(
182
+ bot=self._bot,
183
+ initial_update=initial_update,
184
+ inactivity_timeout=self._flow_inactivity_timeout,
185
+ shared_data=self._shared_data,
186
+ app=self,
187
+ )
188
+
189
+ task = aio.create_task(
190
+ self._entry_coro(context),
191
+ name=f"flow_{flow_key.chat_id}_{flow_key.user_id}_{flow_key.thread_id}_{flow_key.update_id}",
192
+ )
193
+ task.add_done_callback(
194
+ lambda _task: self._done_flow_task_callback(_task, flow_key),
195
+ )
196
+
197
+ new_flow = Flow(
198
+ task=task,
199
+ context=context,
200
+ key=flow_key,
201
+ )
202
+ self._spawned_flows[flow_key] = new_flow
203
+
204
+ _logger.debug(
205
+ f"Flow spawned (chat_id={flow_key.chat_id}, user_id={flow_key.user_id}, "
206
+ f"thread_id={flow_key.thread_id}, update_id={flow_key.update_id}), "
207
+ f"limit {len(self._spawned_flows)}/{self._concurrency_limit or '∞'}"
208
+ )
209
+
210
+ async def _cancel_inactive_flows(self):
211
+ """
212
+ Cancel Flows that haven't received Updates for a long time
213
+ """
214
+ inactive_keys: deque[FlowKey] = deque()
215
+
216
+ for flow in self._spawned_flows.values():
217
+ delta = time.monotonic() - flow.context.last_activity_at
218
+ if (
219
+ delta > flow.context.inactivity_timeout and get_task_status(flow.task) == TaskStatus.RUNNING
220
+ ):
221
+ inactive_keys.append(flow.key)
222
+
223
+ await cancel_tasks(
224
+ [self._spawned_flows[key].task for key in inactive_keys], self._flow_cancellation_timeout
225
+ )
226
+
227
+ for key in inactive_keys:
228
+ if self._spawned_flows.get(key, None) is not None:
229
+ flow = self._spawned_flows[key]
230
+ status = get_task_status(flow.task)
231
+ if status == TaskStatus.EXCEPTION:
232
+ e = flow.task.exception()
233
+ _logger.error(e, exc_info=e)
234
+ if status == TaskStatus.RUNNING:
235
+ raise FlowCancellationFailed("Failed to cancel the task associated with the Flow.")
236
+
237
+ self._spawned_flows.pop(key, None)
238
+
239
+ async def _respawn_flow(self, key: FlowKey, initial_update: Update):
240
+ flow = self._spawned_flows[key]
241
+
242
+ if get_task_status(flow.task) == TaskStatus.RUNNING:
243
+ await cancel_tasks([flow.task], self._flow_cancellation_timeout)
244
+
245
+ if get_task_status(flow.task) != TaskStatus.RUNNING:
246
+ self._spawned_flows.pop(key, None)
247
+ self._spawn_new_flow(key, initial_update)
248
+ else:
249
+ raise UnexpectedError("Failed to cancel the task during flow respawn.")
250
+
251
+ def _done_flow_task_callback(self, task: Task, key: FlowKey):
252
+ flow = self._spawned_flows.get(key, None)
253
+ if flow is None:
254
+ return
255
+
256
+ status = get_task_status(flow.task)
257
+ if status == TaskStatus.EXCEPTION:
258
+ e = flow.task.exception()
259
+ _logger.error(e, exc_info=e)
260
+ elif status == TaskStatus.RUNNING:
261
+ raise UnexpectedError("The Flow task is still running")
262
+
263
+ self._spawned_flows.pop(key, None)
264
+
265
+ def _done_bg_task_callback(self, task: Task):
266
+ if get_task_status(task) == TaskStatus.EXCEPTION:
267
+ e = task.exception()
268
+ _logger.error(e, exc_info=e)
269
+ self._bg_tasks.discard(task)
270
+
271
+ def create_bg_task(self, coroutine: Coroutine, name: str | None = None):
272
+ """Create a background asyncio task."""
273
+ task = aio.create_task(coroutine, name=name or f"background_task_{secrets.token_urlsafe(8)}")
274
+ task.add_done_callback(self._done_bg_task_callback)
275
+ self._bg_tasks.add(task)
276
+
277
+ async def process_update(self, update: Update):
278
+ async with self._lock:
279
+ await self._cancel_inactive_flows()
280
+
281
+ await aio.sleep(0)
282
+
283
+ for middleware in self._middlewares:
284
+ try:
285
+ if isinstance(middleware, Middleware):
286
+ update = middleware(update) # type: ignore
287
+ elif isinstance(middleware, AsyncMiddleware):
288
+ update = await middleware(update) # type: ignore
289
+ except Exception as e:
290
+ _logger.error(e, exc_info=e)
291
+ return
292
+ if update is None:
293
+ return
294
+
295
+ try:
296
+ flow_key = self._concurrency_strategy.get_flow_key(update)
297
+ except FlowKeyError as e:
298
+ _logger.error(e, exc_info=e)
299
+ return
300
+
301
+ if flow_key in self._spawned_flows:
302
+ if self._respawn_trigger is not None and self._respawn_trigger.check_update(update):
303
+ await self._respawn_flow(flow_key, update)
304
+ else:
305
+ await self._spawned_flows[flow_key].context.put_update(update)
306
+ else:
307
+ if self._spawn_trigger.check_update(update):
308
+ self._spawn_new_flow(flow_key, update)
309
+
310
+ await aio.sleep(0)
311
+
312
+ @staticmethod
313
+ def _raise_system_exit():
314
+ raise SystemExit()
315
+
316
+ async def _long_polling_loop(self):
317
+ retrying = Retrying(
318
+ wait=wait_exponential(multiplier=1, min=1, max=64),
319
+ retry=retry_if_exception_type(telegram.error.TelegramError),
320
+ )
321
+ while True:
322
+ for attempt in retrying:
323
+ with attempt:
324
+ try:
325
+ updates = await self._get_updates()
326
+ except Exception as e:
327
+ _logger.error(f"Error in the long polling loop ({e.__class__.__name__}). {e}")
328
+ raise
329
+
330
+ if len(updates) > 0:
331
+ _logger.debug(f"Updates received: {len(updates)}")
332
+ for update in updates:
333
+ await self.process_update(update)
334
+ else:
335
+ await self._cancel_inactive_flows()
336
+
337
+ async def initialize(self):
338
+ await self._bot.initialize()
339
+
340
+ def add_startup_task(self, coroutine: Coroutine, name: str | None = None):
341
+ """Coroutine that run as background task alongside long polling."""
342
+ self._startup_coroutines.append((name, coroutine))
343
+
344
+ async def shutdown(self, max_timeout: timedelta | float = 10.0):
345
+ """Graceful shutdown.
346
+ Args:
347
+ max_timeout:
348
+ The maximum amount of time allowed for all Flow instances and background tasks to
349
+ handle asyncio.CancelledError and shutdown.
350
+
351
+ Example:
352
+ try:
353
+ ...
354
+ await app.run_polling()
355
+ finally:
356
+ await app.shutdown(max_timeout=10.0)
357
+ """
358
+ if isinstance(max_timeout, timedelta):
359
+ _max_timeout = float(max_timeout.total_seconds())
360
+ else:
361
+ _max_timeout = float(max_timeout)
362
+
363
+ tasks = [flow.task for flow in self._spawned_flows.values()]
364
+ flow_task_count = len(tasks)
365
+ bg_task_count = len(self._bg_tasks)
366
+
367
+ tasks.extend(self._bg_tasks)
368
+ if len(tasks) > 0:
369
+ _logger.info(
370
+ f"Waiting for tasks to be cancelled. Flow tasks: {flow_task_count}, background tasks: {bg_task_count}"
371
+ )
372
+ await cancel_tasks(tasks, _max_timeout)
373
+
374
+ await self._bot.shutdown()
375
+ self._is_running = False
376
+ _logger.info("Shutdown completed")
377
+
378
+ async def run_polling(
379
+ self,
380
+ poll_interval: int = 30,
381
+ allowed_updates: tuple[UpdateType, ...] = (
382
+ UpdateType.MESSAGE,
383
+ UpdateType.CALLBACK_QUERY,
384
+ ),
385
+ handle_sys_signals: bool = True,
386
+ with_bg_tasks: Sequence[Coroutine] | None = None,
387
+ ) -> None:
388
+ """Starts the long polling loop and processes incoming updates.
389
+
390
+ Fetches updates from Telegram via long polling and dispatches each update
391
+ to the matching Flow. Blocks until the loop is stopped (e.g. by a system
392
+ signal when ``handle_sys_signals`` is ``True``).
393
+
394
+ Args:
395
+ poll_interval: Timeout in seconds for long polling.
396
+
397
+ allowed_updates: Update types the bot subscribes to.
398
+
399
+ handle_sys_signals: If `True`, installs handlers for `SIGINT`,
400
+ `SIGTERM` and `SIGABRT` to stop polling on system signals. Has no
401
+ effect on Windows.
402
+
403
+ Example:
404
+ app = FlowApp(...)
405
+ try:
406
+ await app.run_polling(...)
407
+ finally:
408
+ await app.shutdown()
409
+ """
410
+ if self._is_running:
411
+ raise PyteleflowError(f"{self.__class__.__name__} is already running.")
412
+
413
+ self._poll_interval = poll_interval
414
+ self._allowed_updates = allowed_updates
415
+
416
+ if handle_sys_signals and platform.system() != "Windows":
417
+ loop = aio.get_event_loop()
418
+ for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGABRT):
419
+ loop.add_signal_handler(sig, self._raise_system_exit)
420
+
421
+ await self.initialize()
422
+
423
+ for name, coro in self._startup_coroutines:
424
+ self.create_bg_task(coro, name)
425
+ await aio.sleep(0)
426
+
427
+ _logger.info("Run long polling")
428
+ try:
429
+ self._is_running = True
430
+ await self._long_polling_loop()
431
+ except aio.CancelledError:
432
+ _logger.info("Stop long polling")
433
+
434
+ def add_pre_middlewares(self, middlewares: list[Middleware | AsyncMiddleware]):
435
+ self._middlewares.extend(middlewares)
436
+
437
+ @property
438
+ def spawned_flows(self) -> MappingProxyType[FlowKey, Flow]:
439
+ return MappingProxyType(self._spawned_flows)
pyteleflow/_flow.py ADDED
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from asyncio import Queue, Task
5
+ from collections.abc import Coroutine
6
+ from datetime import timedelta
7
+ from re import Pattern
8
+ from typing import TYPE_CHECKING
9
+
10
+ from telegram import Bot, CallbackQuery, Message, Update
11
+ from telegram.ext import filters
12
+ from telegram.ext.filters import BaseFilter, MessageFilter
13
+
14
+ from . import filters_ext
15
+ from ._types import FlowKey
16
+
17
+ if TYPE_CHECKING:
18
+ from ._app import FlowApp
19
+
20
+
21
+ class Context:
22
+ def __init__(
23
+ self,
24
+ bot: Bot,
25
+ initial_update: Update,
26
+ inactivity_timeout: float,
27
+ shared_data: dict,
28
+ app: FlowApp,
29
+ ):
30
+ self._updates: Queue[Update] = Queue()
31
+ self.bot = bot
32
+ self._app = app
33
+ self._shared_data = shared_data
34
+ self._last_activity_at: float = time.monotonic()
35
+
36
+ self._inactivity_timeout: float = float(inactivity_timeout)
37
+
38
+ self._initial_update: Update = initial_update
39
+ self._last_update: Update = initial_update
40
+
41
+ @property
42
+ def inactivity_timeout(self) -> float:
43
+ return self._inactivity_timeout
44
+
45
+ @inactivity_timeout.setter
46
+ def inactivity_timeout(self, timeout: timedelta | float):
47
+ if isinstance(timeout, timedelta):
48
+ self._inactivity_timeout = float(timeout.total_seconds())
49
+ else:
50
+ self._inactivity_timeout = float(timeout)
51
+
52
+ @property
53
+ def initial_update(self) -> Update:
54
+ """The initial Update that triggered the Flow"""
55
+ return self._initial_update
56
+
57
+ @property
58
+ def shared_data(self) -> dict:
59
+ return self._shared_data
60
+
61
+ async def _get_update(self, _filter: BaseFilter | None = None) -> Update:
62
+ while True:
63
+ update = await self._updates.get()
64
+ if _filter is None or _filter.check_update(update):
65
+ self._last_update = update
66
+ return update
67
+
68
+ def create_bg_task(self, coroutine: Coroutine, name: str | None = None):
69
+ """ Create a background asyncio task. """
70
+ self._app.create_bg_task(coroutine, name)
71
+
72
+ async def update(self, _filter: BaseFilter | None = None) -> Update:
73
+ """Waits for and returns the next Update from Flow context queue.
74
+
75
+ Args:
76
+ _filter: Optional filter to match the Update against. If provided,
77
+ Updates that do not match the filter are ignored. The Update will wait until
78
+ a matching Update appears in the queue.
79
+ """
80
+ return await self._get_update(_filter)
81
+
82
+ async def message(self, _filter: MessageFilter | None = None) -> Message:
83
+ while True:
84
+ if _filter:
85
+ update = await self._get_update(filters.UpdateType.MESSAGE & _filter)
86
+ else:
87
+ update = await self._get_update(filters.UpdateType.MESSAGE)
88
+
89
+ if update.message is not None:
90
+ return update.message
91
+
92
+ async def callback_query(self, data_pattern: str | Pattern | None = None) -> CallbackQuery:
93
+ while True:
94
+ update = await self._get_update(filters_ext.CallbackQuery(data_pattern))
95
+ if update.callback_query is not None:
96
+ return update.callback_query
97
+
98
+ async def put_update(self, update: Update):
99
+ """Adds an Update to the Flow context queue."""
100
+ self._last_activity_at = time.monotonic()
101
+ await self._updates.put(update)
102
+
103
+ @property
104
+ def last_activity_at(self) -> float:
105
+ return self._last_activity_at
106
+
107
+ @property
108
+ def last_update(self) -> Update:
109
+ """Returns the last Update retrieved from the Flow context queue."""
110
+ return self._last_update
111
+
112
+
113
+ class Flow:
114
+ def __init__(self, task: Task, context: Context, key: FlowKey):
115
+ self.task = task
116
+ self.context = context
117
+ self.key = key
pyteleflow/_helpers.py ADDED
@@ -0,0 +1,56 @@
1
+ import asyncio as aio
2
+ import logging
3
+ from asyncio import Task
4
+ from enum import IntEnum
5
+
6
+ from telegram import Update
7
+ from telegram.constants import UpdateType
8
+
9
+ from .errors import UnexpectedError
10
+
11
+ _logger = logging.getLogger(__name__)
12
+
13
+
14
+ class TaskStatus(IntEnum):
15
+ RUNNING = 10
16
+ CANCELLED = 20
17
+ EXCEPTION = 30
18
+ FINISHED = 40
19
+
20
+
21
+ def get_task_status(task: Task) -> TaskStatus:
22
+ if task.cancelled():
23
+ return TaskStatus.CANCELLED
24
+ elif task.done():
25
+ if task.exception() is not None:
26
+ return TaskStatus.EXCEPTION
27
+ else:
28
+ return TaskStatus.FINISHED
29
+ else:
30
+ return TaskStatus.RUNNING
31
+
32
+
33
+ def get_update_type(update: Update) -> UpdateType:
34
+ for update_type in UpdateType:
35
+ if getattr(update, update_type.value) is not None:
36
+ return update_type
37
+ raise UnexpectedError("Fields that define the Update type are not set in the Update object.")
38
+
39
+
40
+ async def cancel_tasks(tasks: list[Task], cancellation_timeout: float):
41
+ if len(tasks) == 0:
42
+ return
43
+
44
+ for task in tasks:
45
+ task.cancel()
46
+
47
+ _, pending = await aio.wait(tasks, timeout=cancellation_timeout, return_when=aio.ALL_COMPLETED)
48
+
49
+ if len(pending) > 0:
50
+ _logger.warning(f"{len(pending)} tasks did not complete within the cancellation timeout.")
51
+
52
+ # Retry stopping hung tasks
53
+ for task in pending:
54
+ task.cancel()
55
+
56
+ await aio.sleep(0)