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/__init__.py +272 -0
- python_wrap/_core.py +212 -0
- python_wrap/aio.py +620 -0
- python_wrap/batching.py +556 -0
- python_wrap/bulkhead.py +431 -0
- python_wrap/caching.py +1613 -0
- python_wrap/circuit.py +553 -0
- python_wrap/concurrency.py +1886 -0
- python_wrap/config.py +217 -0
- python_wrap/deadline.py +168 -0
- python_wrap/decorators.py +7 -0
- python_wrap/devtools.py +385 -0
- python_wrap/errors.py +86 -0
- python_wrap/events.py +161 -0
- python_wrap/hedging.py +192 -0
- python_wrap/logs.py +767 -0
- python_wrap/metrics.py +242 -0
- python_wrap/periodic.py +491 -0
- python_wrap/policy.py +263 -0
- python_wrap/py.typed +0 -0
- python_wrap/retry.py +1324 -0
- python_wrap/state.py +500 -0
- python_wrap/testing.py +662 -0
- python_wrap/timing.py +535 -0
- python_wrap/validation.py +662 -0
- python_wrap-0.2.1.dist-info/METADATA +485 -0
- python_wrap-0.2.1.dist-info/RECORD +29 -0
- python_wrap-0.2.1.dist-info/WHEEL +4 -0
- python_wrap-0.2.1.dist-info/licenses/LICENSE +21 -0
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")
|