python-wrap 0.2.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.
python_wrap/aio.py ADDED
@@ -0,0 +1,620 @@
1
+ """Small asyncio helpers that every async project ends up writing.
2
+
3
+ * `gather_limit` — `asyncio.gather` with at most `limit` awaitables running at once.
4
+ * `map_async` — apply an async function to every item of a (possibly huge or async)
5
+ iterable, at most `limit` at a time, and collect the results.
6
+ * `amap_iter` — the streaming form of `map_async`: an async generator of results.
7
+ * `race` — the first awaitable to succeed wins; the others are cancelled.
8
+ * `run_sync` — run a coroutine from synchronous code (never inside a running loop).
9
+
10
+ Every helper owns the tasks it creates: when it returns, raises, is cancelled or (for
11
+ `amap_iter`) is closed early, every task it started has finished, so no task is left
12
+ pending behind your back.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import inspect
19
+ import sys
20
+ from collections.abc import (
21
+ AsyncGenerator,
22
+ AsyncIterable,
23
+ AsyncIterator,
24
+ Awaitable,
25
+ Callable,
26
+ Coroutine,
27
+ Iterable,
28
+ Iterator,
29
+ )
30
+ from contextlib import aclosing
31
+ from typing import Any, Literal, ParamSpec, TypeVar, overload
32
+
33
+ from python_wrap import _core
34
+ from python_wrap.errors import PythonWrapError, TimeoutExpired
35
+
36
+ __all__ = [
37
+ "RaceFailed",
38
+ "amap_iter",
39
+ "gather_limit",
40
+ "map_async",
41
+ "race",
42
+ "run_sync",
43
+ ]
44
+
45
+ T = TypeVar("T")
46
+ R = TypeVar("R")
47
+ P = ParamSpec("P")
48
+
49
+ # ---------------------------------------------------------------------------------------
50
+ # shared plumbing
51
+ # ---------------------------------------------------------------------------------------
52
+
53
+
54
+ def _check_limit(limit: int) -> None:
55
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
56
+ raise ValueError(f"limit must be a positive integer, got {limit!r}")
57
+
58
+
59
+ async def _await(aw: Awaitable[T]) -> T:
60
+ return await aw
61
+
62
+
63
+ def _start(aw: Awaitable[T]) -> asyncio.Future[T]:
64
+ """Schedule ``aw``. Coroutines become tasks; anything else is awaited in a new task.
65
+
66
+ Wrapping futures in a task of our own means cancelling that task also cancels the
67
+ future, and the same future may appear more than once.
68
+ """
69
+ if asyncio.iscoroutine(aw):
70
+ return asyncio.ensure_future(aw)
71
+ return asyncio.ensure_future(_await(aw))
72
+
73
+
74
+ async def _settle(futures: Iterable[asyncio.Future[Any]]) -> None:
75
+ """Wait until every future is done, even if we are cancelled meanwhile.
76
+
77
+ A cancellation that arrives while waiting is re-raised once everything has finished,
78
+ so cleanup never leaves tasks behind. Exceptions of the futures are marked as
79
+ retrieved, so asyncio does not log "exception was never retrieved".
80
+ """
81
+ futures = list(futures)
82
+ cancelled = False
83
+ while True:
84
+ waiting = [f for f in futures if not f.done()]
85
+ if not waiting:
86
+ break
87
+ try:
88
+ await asyncio.wait(waiting)
89
+ except asyncio.CancelledError:
90
+ cancelled = True
91
+ for f in futures:
92
+ if not f.cancelled():
93
+ f.exception()
94
+ if cancelled:
95
+ raise asyncio.CancelledError
96
+
97
+
98
+ async def _cancel_and_settle(futures: Iterable[asyncio.Future[Any]]) -> None:
99
+ futures = list(futures)
100
+ for f in futures:
101
+ f.cancel()
102
+ await _settle(futures)
103
+
104
+
105
+ _Next = Callable[[], Awaitable["Awaitable[Any] | None"]]
106
+
107
+
108
+ async def _drive(
109
+ next_awaitable: _Next, limit: int, ordered: bool
110
+ ) -> AsyncGenerator[tuple[int, asyncio.Future[Any]], None]:
111
+ """Start awaitables pulled from ``next_awaitable`` and yield ``(index, done_future)``.
112
+
113
+ At most ``limit`` futures are in flight. With ``ordered`` they are yielded in input
114
+ order and finished-but-not-yet-yielded futures count towards ``limit``, so the reorder
115
+ buffer is bounded. Without it they are yielded as they finish (ties in input order).
116
+ Closing the generator cancels and awaits everything still running.
117
+ """
118
+ pending: dict[asyncio.Future[Any], int] = {}
119
+ ready: dict[int, asyncio.Future[Any]] = {}
120
+ count = 0
121
+ next_out = 0
122
+ exhausted = False
123
+ try:
124
+ while True:
125
+ while not exhausted and len(pending) + len(ready) < limit:
126
+ aw = await next_awaitable()
127
+ if aw is None:
128
+ exhausted = True
129
+ else:
130
+ pending[_start(aw)] = count
131
+ count += 1
132
+ while next_out in ready:
133
+ yield next_out, ready.pop(next_out)
134
+ next_out += 1
135
+ if not pending:
136
+ if exhausted:
137
+ return
138
+ continue # everything finished was yielded: pull more input
139
+ done, _ = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
140
+ for fut in sorted(done, key=pending.__getitem__):
141
+ index = pending.pop(fut)
142
+ if ordered:
143
+ ready[index] = fut
144
+ else:
145
+ yield index, fut
146
+ finally:
147
+ await _cancel_and_settle([*pending, *ready.values()])
148
+
149
+
150
+ def _outcome(fut: asyncio.Future[Any], return_exceptions: bool) -> Any:
151
+ if not return_exceptions:
152
+ return fut.result()
153
+ if fut.cancelled():
154
+ return asyncio.CancelledError()
155
+ return fut.exception() or fut.result()
156
+
157
+
158
+ def _discard(aw: Awaitable[Any], leftovers: list[asyncio.Future[Any]]) -> None:
159
+ """Dispose of an awaitable that was never started."""
160
+ if asyncio.iscoroutine(aw):
161
+ aw.close() # no "coroutine was never awaited" warning
162
+ elif asyncio.isfuture(aw):
163
+ leftovers.append(aw)
164
+
165
+
166
+ # ---------------------------------------------------------------------------------------
167
+ # gather_limit
168
+ # ---------------------------------------------------------------------------------------
169
+
170
+
171
+ @overload
172
+ async def gather_limit(
173
+ *aws: Awaitable[T], limit: int, return_exceptions: Literal[False] = False
174
+ ) -> list[T]: ...
175
+ @overload
176
+ async def gather_limit(
177
+ *aws: Awaitable[T], limit: int, return_exceptions: bool
178
+ ) -> list[T | BaseException]: ...
179
+ async def gather_limit(
180
+ *aws: Awaitable[Any], limit: int, return_exceptions: bool = False
181
+ ) -> list[Any]:
182
+ """Like `asyncio.gather`, but at most `limit` awaitables run at the same time.
183
+
184
+ Coroutines are started only when a slot is free. Tasks and futures are already
185
+ running when you pass them, so `limit` cannot hold them back; they are simply awaited
186
+ (and cancelled with the rest on failure).
187
+
188
+ Args:
189
+ *aws: Coroutines, tasks or futures (any awaitable).
190
+ limit: Maximum number of awaitables running at once (>= 1).
191
+ return_exceptions: If true, exceptions (and `asyncio.CancelledError` for a child
192
+ cancelled from outside) are returned in the result list instead of raised.
193
+
194
+ Returns:
195
+ The results, in the order of `aws` (not completion order).
196
+
197
+ Raises:
198
+ ValueError: `limit` is not a positive integer.
199
+ Exception: With `return_exceptions=False`, the first exception raised by any
200
+ awaitable. Everything still running is cancelled and awaited first, and
201
+ coroutines that never started are closed.
202
+
203
+ Example:
204
+ ```python
205
+ import asyncio
206
+ from python_wrap.aio import gather_limit
207
+
208
+ async def square(n: int) -> int:
209
+ await asyncio.sleep(0)
210
+ return n * n
211
+
212
+ async def main() -> None:
213
+ results = await gather_limit(*(square(n) for n in range(10)), limit=3)
214
+ assert results == [n * n for n in range(10)]
215
+
216
+ asyncio.run(main())
217
+ ```
218
+ """
219
+ _check_limit(limit)
220
+ results: list[Any] = [None] * len(aws)
221
+ source: Iterator[Awaitable[Any]] = iter(aws)
222
+
223
+ async def next_awaitable() -> Awaitable[Any] | None:
224
+ return next(source, None)
225
+
226
+ leftovers: list[asyncio.Future[Any]] = []
227
+ try:
228
+ async with aclosing(_drive(next_awaitable, limit, ordered=False)) as finished:
229
+ async for index, fut in finished:
230
+ results[index] = _outcome(fut, return_exceptions)
231
+ finally:
232
+ for aw in source:
233
+ _discard(aw, leftovers)
234
+ if leftovers:
235
+ await _cancel_and_settle(leftovers)
236
+ return results
237
+
238
+
239
+ # ---------------------------------------------------------------------------------------
240
+ # map_async / amap_iter
241
+ # ---------------------------------------------------------------------------------------
242
+
243
+
244
+ async def _call(fn: Callable[[T], Awaitable[R]], item: T) -> R:
245
+ return await fn(item)
246
+
247
+
248
+ def _items_source(fn: Callable[[T], Awaitable[R]], items: Iterable[T] | AsyncIterable[T]) -> _Next:
249
+ if isinstance(items, AsyncIterable):
250
+ ait: AsyncIterator[T] = aiter(items)
251
+
252
+ async def next_async() -> Awaitable[Any] | None:
253
+ try:
254
+ item = await anext(ait)
255
+ except StopAsyncIteration:
256
+ return None
257
+ return _call(fn, item)
258
+
259
+ return next_async
260
+
261
+ it: Iterator[T] = iter(items)
262
+ sentinel = object()
263
+
264
+ async def next_sync() -> Awaitable[Any] | None:
265
+ item = next(it, sentinel)
266
+ return None if item is sentinel else _call(fn, item) # type: ignore[arg-type]
267
+
268
+ return next_sync
269
+
270
+
271
+ @overload
272
+ def amap_iter(
273
+ fn: Callable[[T], Awaitable[R]],
274
+ items: Iterable[T] | AsyncIterable[T],
275
+ *,
276
+ limit: int,
277
+ ordered: bool = True,
278
+ return_exceptions: Literal[False] = False,
279
+ ) -> AsyncGenerator[R, None]: ...
280
+ @overload
281
+ def amap_iter(
282
+ fn: Callable[[T], Awaitable[R]],
283
+ items: Iterable[T] | AsyncIterable[T],
284
+ *,
285
+ limit: int,
286
+ ordered: bool = True,
287
+ return_exceptions: bool,
288
+ ) -> AsyncGenerator[R | BaseException, None]: ...
289
+ def amap_iter(
290
+ fn: Callable[[T], Awaitable[R]],
291
+ items: Iterable[T] | AsyncIterable[T],
292
+ *,
293
+ limit: int,
294
+ ordered: bool = True,
295
+ return_exceptions: bool = False,
296
+ ) -> AsyncGenerator[Any, None]:
297
+ """Apply `fn` to every item, at most `limit` at a time, yielding results as they come.
298
+
299
+ The input is consumed lazily: an item is pulled only when a slot is free, so an
300
+ infinite generator is fine. `fn` is called inside the new task, so even an exception
301
+ raised synchronously by `fn` counts as that item's failure.
302
+
303
+ * `ordered=True`: results come out in input order. Results that finish early wait in
304
+ a buffer, and buffered results count towards `limit`, so memory stays bounded; a
305
+ slow item holds back the ones behind it. An exception surfaces when its turn comes.
306
+ * `ordered=False`: results come out as soon as they finish; an exception surfaces at
307
+ once.
308
+
309
+ Breaking out of the loop, calling `aclose()`, or cancelling the consuming task cancels
310
+ every call still running and waits for it. To make cleanup immediate, close the
311
+ generator explicitly (`contextlib.aclosing`) rather than relying on garbage
312
+ collection. The input iterator itself is not closed.
313
+
314
+ Args:
315
+ fn: Async function (or any callable returning an awaitable) of one item.
316
+ items: A sync or async iterable.
317
+ limit: Maximum number of calls in flight (>= 1).
318
+ ordered: Yield in input order (default) or in completion order.
319
+ return_exceptions: Yield exceptions as values instead of raising them.
320
+
321
+ Returns:
322
+ An async generator of results.
323
+
324
+ Raises:
325
+ ValueError: `limit` is not a positive integer.
326
+
327
+ Example:
328
+ ```python
329
+ import asyncio
330
+ from contextlib import aclosing
331
+ from itertools import count
332
+ from python_wrap.aio import amap_iter
333
+
334
+ async def double(n: int) -> int:
335
+ await asyncio.sleep(0)
336
+ return 2 * n
337
+
338
+ async def main() -> None:
339
+ seen = []
340
+ async with aclosing(amap_iter(double, count(), limit=4)) as results:
341
+ async for value in results: # an infinite input is fine
342
+ seen.append(value)
343
+ if len(seen) == 5:
344
+ break
345
+ assert seen == [0, 2, 4, 6, 8]
346
+
347
+ asyncio.run(main())
348
+ ```
349
+ """
350
+ _check_limit(limit)
351
+ source = _items_source(fn, items)
352
+
353
+ async def generate() -> AsyncGenerator[Any, None]:
354
+ async with aclosing(_drive(source, limit, ordered)) as finished:
355
+ async for _, fut in finished:
356
+ yield _outcome(fut, return_exceptions)
357
+
358
+ return generate()
359
+
360
+
361
+ @overload
362
+ async def map_async(
363
+ fn: Callable[[T], Awaitable[R]],
364
+ items: Iterable[T] | AsyncIterable[T],
365
+ *,
366
+ limit: int,
367
+ ordered: bool = True,
368
+ return_exceptions: Literal[False] = False,
369
+ ) -> list[R]: ...
370
+ @overload
371
+ async def map_async(
372
+ fn: Callable[[T], Awaitable[R]],
373
+ items: Iterable[T] | AsyncIterable[T],
374
+ *,
375
+ limit: int,
376
+ ordered: bool = True,
377
+ return_exceptions: bool,
378
+ ) -> list[R | BaseException]: ...
379
+ async def map_async(
380
+ fn: Callable[[T], Awaitable[R]],
381
+ items: Iterable[T] | AsyncIterable[T],
382
+ *,
383
+ limit: int,
384
+ ordered: bool = True,
385
+ return_exceptions: bool = False,
386
+ ) -> list[Any]:
387
+ """Apply `fn` to every item, at most `limit` at a time, and return all the results.
388
+
389
+ Items are pulled lazily, so at most `limit` calls (and items) are in flight however
390
+ long the input is. Unlike `amap_iter`, an exception is raised as soon as any call
391
+ fails (with `return_exceptions=False`): the other calls are cancelled and awaited and
392
+ no further items are pulled.
393
+
394
+ Args:
395
+ fn: Async function (or any callable returning an awaitable) of one item.
396
+ items: A sync or async iterable.
397
+ limit: Maximum number of calls in flight (>= 1).
398
+ ordered: Return results in input order (default) or in completion order.
399
+ return_exceptions: Put exceptions in the list instead of raising them.
400
+
401
+ Returns:
402
+ A list with one result per item.
403
+
404
+ Raises:
405
+ ValueError: `limit` is not a positive integer.
406
+ Exception: The first exception raised by `fn` (with `return_exceptions=False`).
407
+
408
+ Example:
409
+ ```python
410
+ import asyncio
411
+ from python_wrap.aio import map_async
412
+
413
+ async def fetch(url: str) -> int:
414
+ await asyncio.sleep(0)
415
+ return len(url)
416
+
417
+ async def main() -> None:
418
+ urls = ["https://a.example", "https://bb.example"]
419
+ sizes = await map_async(fetch, urls, limit=10)
420
+ assert sizes == [17, 18]
421
+
422
+ asyncio.run(main())
423
+ ```
424
+ """
425
+ _check_limit(limit)
426
+ source = _items_source(fn, items)
427
+ by_index: dict[int, Any] = {}
428
+ async with aclosing(_drive(source, limit, ordered=False)) as finished:
429
+ async for index, fut in finished:
430
+ by_index[index] = _outcome(fut, return_exceptions)
431
+ if ordered:
432
+ return [by_index[i] for i in range(len(by_index))]
433
+ return list(by_index.values())
434
+
435
+
436
+ # ---------------------------------------------------------------------------------------
437
+ # race
438
+ # ---------------------------------------------------------------------------------------
439
+
440
+ if sys.version_info >= (3, 11):
441
+
442
+ class RaceFailed(ExceptionGroup[Exception], PythonWrapError): # noqa: F821
443
+ """Every contestant of `race` failed.
444
+
445
+ On Python 3.11+ this is an `ExceptionGroup` (so `except*` works) and a
446
+ `PythonWrapError`. On 3.10 it is a plain `PythonWrapError`. On every version,
447
+ `.exceptions` is a tuple of the errors in input order.
448
+ """
449
+
450
+ def __new__(cls, message: str, exceptions: Iterable[Exception]) -> RaceFailed:
451
+ return super().__new__(cls, message, list(exceptions))
452
+
453
+ def __init__(self, message: str, exceptions: Iterable[Exception]) -> None:
454
+ super().__init__(message, list(exceptions))
455
+
456
+ def derive(self, excs: Any) -> Any:
457
+ return RaceFailed(self.message, excs)
458
+
459
+ else:
460
+
461
+ class RaceFailed(PythonWrapError):
462
+ """Every contestant of `race` failed.
463
+
464
+ On Python 3.11+ this is an `ExceptionGroup` (so `except*` works) and a
465
+ `PythonWrapError`. On 3.10 it is a plain `PythonWrapError`. On every version,
466
+ `.exceptions` is a tuple of the errors in input order.
467
+ """
468
+
469
+ def __init__(self, message: str, exceptions: Iterable[Exception]) -> None:
470
+ super().__init__(message)
471
+ self.message = message
472
+ self.exceptions: tuple[Exception, ...] = tuple(exceptions)
473
+
474
+
475
+ async def race(*aws: Awaitable[T], timeout: float | None = None) -> T:
476
+ """Run every awaitable at once and return the result of the first to succeed.
477
+
478
+ As soon as one succeeds, the others are cancelled and awaited before `race`
479
+ returns. A contestant that fails does not end the race; the others keep running.
480
+ If several finish in the same event-loop step, the earliest in `aws` wins.
481
+
482
+ Args:
483
+ *aws: Coroutines, tasks or futures; at least one.
484
+ timeout: Give up after this many seconds of real time (`fast_mode()` and
485
+ `fake_clock()` do not shorten it). `None` waits forever.
486
+
487
+ Returns:
488
+ The winner's result.
489
+
490
+ Raises:
491
+ ValueError: No awaitables were given.
492
+ RaceFailed: Every contestant raised an exception; `.exceptions` holds them in
493
+ input order. Contestants cancelled from outside are left out of it (if all
494
+ of them were cancelled, `asyncio.CancelledError` is raised instead).
495
+ TimeoutExpired: Nobody succeeded within `timeout` (also a `TimeoutError`).
496
+ BaseException: A contestant raised `KeyboardInterrupt`/`SystemExit` (or similar):
497
+ the others are cancelled and it propagates at once.
498
+
499
+ Example:
500
+ ```python
501
+ import asyncio
502
+ from python_wrap.aio import race
503
+
504
+ async def mirror(name: str, delay: float) -> str:
505
+ await asyncio.sleep(delay)
506
+ return name
507
+
508
+ async def main() -> None:
509
+ winner = await race(mirror("slow", 1), mirror("fast", 0), timeout=5)
510
+ assert winner == "fast"
511
+
512
+ asyncio.run(main())
513
+ ```
514
+ """
515
+ if not aws:
516
+ raise ValueError("race() needs at least one awaitable")
517
+ contestants: list[asyncio.Future[T]] = []
518
+ timer: asyncio.Future[None] | None = None
519
+ errors: dict[int, Exception] = {}
520
+ try:
521
+ for aw in aws:
522
+ contestants.append(_start(aw))
523
+ order: dict[asyncio.Future[Any], int] = {fut: i for i, fut in enumerate(contestants)}
524
+ if timeout is not None:
525
+ timer = asyncio.ensure_future(asyncio.sleep(timeout)) # real time
526
+ pending: set[asyncio.Future[Any]] = set(contestants)
527
+ while pending:
528
+ watch = pending if timer is None else pending | {timer}
529
+ done, _ = await asyncio.wait(watch, return_when=asyncio.FIRST_COMPLETED)
530
+ finished = sorted((f for f in done if f is not timer), key=order.__getitem__)
531
+ for fut in finished:
532
+ pending.discard(fut)
533
+ if fut.cancelled():
534
+ continue
535
+ exc = fut.exception()
536
+ if exc is None:
537
+ return contestants[order[fut]].result()
538
+ if not isinstance(exc, Exception):
539
+ raise exc
540
+ errors[order[fut]] = exc
541
+ if timer is not None and timer in done and pending:
542
+ raise TimeoutExpired(
543
+ f"race() found no winner within {timeout}s", seconds=float(timeout or 0)
544
+ )
545
+ finally:
546
+ await _cancel_and_settle([*contestants, *([timer] if timer else [])])
547
+ if not errors:
548
+ raise asyncio.CancelledError
549
+ failures = [errors[i] for i in sorted(errors)]
550
+ raise RaceFailed(f"all {len(failures)} contestants of race() failed", failures)
551
+
552
+
553
+ # ---------------------------------------------------------------------------------------
554
+ # run_sync
555
+ # ---------------------------------------------------------------------------------------
556
+
557
+
558
+ @overload
559
+ def run_sync(coro: Coroutine[Any, Any, T], /) -> T: ...
560
+ @overload
561
+ def run_sync(fn: Callable[P, Awaitable[T]], /, *args: P.args, **kwargs: P.kwargs) -> T: ...
562
+ def run_sync(coro_or_fn: Any, /, *args: Any, **kwargs: Any) -> Any:
563
+ """Run async code from synchronous code and return its result.
564
+
565
+ Uses `asyncio.run`, so a fresh event loop is created and closed for the call. There
566
+ is deliberately no "nested event loop" support: calling this while an event loop is
567
+ running in the current thread raises `RuntimeError` (use `await` there instead, or
568
+ run the sync caller in a worker thread with `asyncio.to_thread`).
569
+
570
+ Args:
571
+ coro_or_fn: A coroutine object, or an async function to call with `args`/`kwargs`.
572
+ *args: Positional arguments for the function.
573
+ **kwargs: Keyword arguments for the function.
574
+
575
+ Returns:
576
+ Whatever the coroutine returns.
577
+
578
+ Raises:
579
+ RuntimeError: An event loop is already running in this thread.
580
+ TypeError: Arguments were given with a coroutine object, or the function did not
581
+ return an awaitable.
582
+
583
+ Example:
584
+ ```python
585
+ import asyncio
586
+ from python_wrap.aio import run_sync
587
+
588
+ async def add(a: int, b: int) -> int:
589
+ await asyncio.sleep(0)
590
+ return a + b
591
+
592
+ assert run_sync(add, 1, 2) == 3
593
+ assert run_sync(add(3, 4)) == 7
594
+ ```
595
+ """
596
+ is_coro = asyncio.iscoroutine(coro_or_fn)
597
+ try:
598
+ asyncio.get_running_loop()
599
+ except RuntimeError:
600
+ pass
601
+ else:
602
+ if is_coro:
603
+ coro_or_fn.close()
604
+ raise RuntimeError(
605
+ "run_sync() cannot be called while an event loop is running in this thread; "
606
+ "await the coroutine instead"
607
+ )
608
+ if is_coro:
609
+ if args or kwargs:
610
+ coro_or_fn.close()
611
+ raise TypeError("run_sync() takes no extra arguments when given a coroutine")
612
+ return asyncio.run(coro_or_fn)
613
+ if not callable(coro_or_fn):
614
+ raise TypeError(f"run_sync() needs a coroutine or an async callable, got {coro_or_fn!r}")
615
+ result = coro_or_fn(*args, **kwargs)
616
+ if asyncio.iscoroutine(result):
617
+ return asyncio.run(result)
618
+ if inspect.isawaitable(result):
619
+ return asyncio.run(_await(result))
620
+ raise TypeError(f"{_core.qualname(coro_or_fn)}() did not return an awaitable")