tasklane 0.1.0__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.
- tasklane/__init__.py +40 -0
- tasklane/_core.py +492 -0
- tasklane/_lane.py +100 -0
- tasklane/_progress.py +55 -0
- tasklane/_ratelimit.py +44 -0
- tasklane/_retry.py +89 -0
- tasklane/py.typed +0 -0
- tasklane-0.1.0.dist-info/METADATA +226 -0
- tasklane-0.1.0.dist-info/RECORD +11 -0
- tasklane-0.1.0.dist-info/WHEEL +4 -0
- tasklane-0.1.0.dist-info/licenses/LICENSE +21 -0
tasklane/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""tasklane: bounded-concurrency async for Python.
|
|
2
|
+
|
|
3
|
+
Run, map, and stream awaitables with a concurrency limit, retries, backoff,
|
|
4
|
+
rate limiting, and progress reporting — in one typed call.
|
|
5
|
+
|
|
6
|
+
Quickstart::
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import tasklane
|
|
10
|
+
|
|
11
|
+
async def fetch(url: str) -> int:
|
|
12
|
+
await asyncio.sleep(0.1)
|
|
13
|
+
return len(url)
|
|
14
|
+
|
|
15
|
+
async def main() -> None:
|
|
16
|
+
urls = ["https://example.com"] * 100
|
|
17
|
+
# At most 10 concurrent calls, each retried up to 3 times.
|
|
18
|
+
sizes = await tasklane.amap(fetch, urls, limit=10, retries=3)
|
|
19
|
+
print(sum(sizes))
|
|
20
|
+
|
|
21
|
+
asyncio.run(main())
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from tasklane._core import amap, gather, stream
|
|
27
|
+
from tasklane._lane import Lane
|
|
28
|
+
from tasklane._progress import Progress
|
|
29
|
+
from tasklane._retry import Backoff
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"Backoff",
|
|
33
|
+
"Lane",
|
|
34
|
+
"Progress",
|
|
35
|
+
"amap",
|
|
36
|
+
"gather",
|
|
37
|
+
"stream",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
tasklane/_core.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""Core engine: a bounded worker pool over an async queue.
|
|
2
|
+
|
|
3
|
+
Both :func:`amap` and :func:`stream` are thin policy layers over the private
|
|
4
|
+
``_imap_unordered`` generator, which runs ``limit`` workers that pull items off
|
|
5
|
+
a bounded input queue, apply retries/timeout/rate-limiting, and emit completions
|
|
6
|
+
as they finish. The bounded input queue gives natural backpressure, so even an
|
|
7
|
+
infinite async iterable is processed in constant memory.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections.abc import (
|
|
14
|
+
AsyncGenerator,
|
|
15
|
+
AsyncIterable,
|
|
16
|
+
AsyncIterator,
|
|
17
|
+
Awaitable,
|
|
18
|
+
Callable,
|
|
19
|
+
Iterable,
|
|
20
|
+
Sized,
|
|
21
|
+
)
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from time import monotonic
|
|
24
|
+
from typing import Any, Generic, Literal, TypeVar, overload
|
|
25
|
+
|
|
26
|
+
from tasklane._progress import Progress
|
|
27
|
+
from tasklane._ratelimit import RateLimiter
|
|
28
|
+
from tasklane._retry import Backoff
|
|
29
|
+
|
|
30
|
+
__all__ = ["amap", "gather", "stream"]
|
|
31
|
+
|
|
32
|
+
T = TypeVar("T")
|
|
33
|
+
R = TypeVar("R")
|
|
34
|
+
|
|
35
|
+
#: A type, a tuple of types, or a predicate deciding whether to retry an error.
|
|
36
|
+
RetryOn = type[BaseException] | tuple[type[BaseException], ...] | Callable[[BaseException], bool]
|
|
37
|
+
|
|
38
|
+
DEFAULT_LIMIT = 16
|
|
39
|
+
_DEFAULT_BACKOFF = Backoff()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _WorkerExit:
|
|
43
|
+
"""Sentinel a worker emits when it has consumed its stop signal."""
|
|
44
|
+
|
|
45
|
+
__slots__ = ()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_WORKER_EXIT = _WorkerExit()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(slots=True)
|
|
52
|
+
class _Completed:
|
|
53
|
+
index: int
|
|
54
|
+
value: Any
|
|
55
|
+
exc: BaseException | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(slots=True)
|
|
59
|
+
class _Counters:
|
|
60
|
+
#: Incremented by workers as they pick up items; read by the consumer to
|
|
61
|
+
#: derive ``in_flight``. Completed/succeeded/failed are counted consumer-side
|
|
62
|
+
#: so progress snapshots advance one-per-completion instead of in worker-batches.
|
|
63
|
+
started: int = 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True)
|
|
67
|
+
class _Settings(Generic[T, R]):
|
|
68
|
+
func: Callable[[T], Awaitable[R]]
|
|
69
|
+
limit: int
|
|
70
|
+
retries: int
|
|
71
|
+
backoff: Backoff
|
|
72
|
+
retry_on: RetryOn
|
|
73
|
+
timeout: float | None
|
|
74
|
+
rate_limiter: RateLimiter | None
|
|
75
|
+
on_progress: Callable[[Progress], None] | None = field(default=None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _validate(limit: int, retries: int, timeout: float | None, rate_limit: float | None) -> None:
|
|
79
|
+
if limit < 1:
|
|
80
|
+
raise ValueError("limit must be >= 1")
|
|
81
|
+
if retries < 0:
|
|
82
|
+
raise ValueError("retries must be >= 0")
|
|
83
|
+
if timeout is not None and timeout <= 0:
|
|
84
|
+
raise ValueError("timeout must be > 0")
|
|
85
|
+
if rate_limit is not None and rate_limit <= 0:
|
|
86
|
+
raise ValueError("rate_limit must be > 0")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _make_settings(
|
|
90
|
+
func: Callable[[T], Awaitable[R]],
|
|
91
|
+
*,
|
|
92
|
+
limit: int,
|
|
93
|
+
retries: int,
|
|
94
|
+
backoff: Backoff | None,
|
|
95
|
+
retry_on: RetryOn,
|
|
96
|
+
timeout: float | None,
|
|
97
|
+
rate_limit: float | None,
|
|
98
|
+
on_progress: Callable[[Progress], None] | None,
|
|
99
|
+
) -> _Settings[T, R]:
|
|
100
|
+
_validate(limit, retries, timeout, rate_limit)
|
|
101
|
+
return _Settings(
|
|
102
|
+
func=func,
|
|
103
|
+
limit=limit,
|
|
104
|
+
retries=retries,
|
|
105
|
+
backoff=backoff if backoff is not None else _DEFAULT_BACKOFF,
|
|
106
|
+
retry_on=retry_on,
|
|
107
|
+
timeout=timeout,
|
|
108
|
+
rate_limiter=RateLimiter(rate_limit) if rate_limit is not None else None,
|
|
109
|
+
on_progress=on_progress,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _len_or_none(items: Iterable[T] | AsyncIterable[T]) -> int | None:
|
|
114
|
+
return len(items) if isinstance(items, Sized) else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def _aiter(items: Iterable[T] | AsyncIterable[T]) -> AsyncIterator[T]:
|
|
118
|
+
if isinstance(items, AsyncIterable):
|
|
119
|
+
async for item in items:
|
|
120
|
+
yield item
|
|
121
|
+
else:
|
|
122
|
+
for item in items:
|
|
123
|
+
yield item
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _should_retry(exc: BaseException, retry_on: RetryOn) -> bool:
|
|
127
|
+
if isinstance(retry_on, (tuple, type)):
|
|
128
|
+
return isinstance(exc, retry_on)
|
|
129
|
+
return bool(retry_on(exc))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _normalize_timeout(exc: BaseException) -> BaseException:
|
|
133
|
+
"""Surface asyncio timeouts as the builtin ``TimeoutError`` on every version.
|
|
134
|
+
|
|
135
|
+
On Python 3.10, ``asyncio.wait_for`` raises ``asyncio.TimeoutError``, which is
|
|
136
|
+
a distinct type from the builtin ``TimeoutError`` (the two were unified in
|
|
137
|
+
3.11). Re-mapping it means ``except TimeoutError`` and ``retry_on=TimeoutError``
|
|
138
|
+
behave identically across the supported 3.10-3.14 range.
|
|
139
|
+
"""
|
|
140
|
+
if isinstance(exc, asyncio.TimeoutError) and not isinstance(exc, TimeoutError):
|
|
141
|
+
normalized = TimeoutError(str(exc))
|
|
142
|
+
normalized.__cause__ = exc.__cause__
|
|
143
|
+
return normalized
|
|
144
|
+
return exc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def _run_one(item: T, s: _Settings[T, R]) -> tuple[Any, BaseException | None]:
|
|
148
|
+
"""Run ``func(item)`` with retries, backoff, timeout, and rate limiting."""
|
|
149
|
+
attempt = 0
|
|
150
|
+
while True:
|
|
151
|
+
try:
|
|
152
|
+
if s.rate_limiter is not None:
|
|
153
|
+
await s.rate_limiter.acquire()
|
|
154
|
+
if s.timeout is not None:
|
|
155
|
+
return await asyncio.wait_for(s.func(item), s.timeout), None
|
|
156
|
+
return await s.func(item), None
|
|
157
|
+
except asyncio.CancelledError:
|
|
158
|
+
raise
|
|
159
|
+
except BaseException as exc:
|
|
160
|
+
error = _normalize_timeout(exc)
|
|
161
|
+
if attempt >= s.retries or not _should_retry(error, s.retry_on):
|
|
162
|
+
return None, error
|
|
163
|
+
delay = s.backoff.delay_for(attempt)
|
|
164
|
+
attempt += 1
|
|
165
|
+
if delay > 0:
|
|
166
|
+
await asyncio.sleep(delay)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _imap_unordered(
|
|
170
|
+
items: Iterable[T] | AsyncIterable[T],
|
|
171
|
+
s: _Settings[T, R],
|
|
172
|
+
) -> AsyncGenerator[_Completed, None]:
|
|
173
|
+
"""Yield completions in the order tasks finish (not input order)."""
|
|
174
|
+
start = monotonic()
|
|
175
|
+
total = _len_or_none(items)
|
|
176
|
+
counters = _Counters()
|
|
177
|
+
input_q: asyncio.Queue[tuple[int, T] | None] = asyncio.Queue(maxsize=s.limit)
|
|
178
|
+
output_q: asyncio.Queue[_Completed | _WorkerExit] = asyncio.Queue()
|
|
179
|
+
feeder_error: BaseException | None = None
|
|
180
|
+
|
|
181
|
+
async def feeder() -> None:
|
|
182
|
+
nonlocal feeder_error
|
|
183
|
+
index = 0
|
|
184
|
+
try:
|
|
185
|
+
async for item in _aiter(items):
|
|
186
|
+
await input_q.put((index, item))
|
|
187
|
+
index += 1
|
|
188
|
+
except asyncio.CancelledError:
|
|
189
|
+
raise
|
|
190
|
+
except BaseException as exc:
|
|
191
|
+
feeder_error = exc
|
|
192
|
+
finally:
|
|
193
|
+
for _ in range(s.limit):
|
|
194
|
+
await input_q.put(None)
|
|
195
|
+
|
|
196
|
+
async def worker() -> None:
|
|
197
|
+
while True:
|
|
198
|
+
got = await input_q.get()
|
|
199
|
+
if got is None:
|
|
200
|
+
await output_q.put(_WORKER_EXIT)
|
|
201
|
+
return
|
|
202
|
+
index, item = got
|
|
203
|
+
counters.started += 1
|
|
204
|
+
value, exc = await _run_one(item, s)
|
|
205
|
+
await output_q.put(_Completed(index, value, exc))
|
|
206
|
+
|
|
207
|
+
tasks = [asyncio.create_task(feeder())]
|
|
208
|
+
tasks.extend(asyncio.create_task(worker()) for _ in range(s.limit))
|
|
209
|
+
exited = 0
|
|
210
|
+
completed = 0
|
|
211
|
+
succeeded = 0
|
|
212
|
+
failed = 0
|
|
213
|
+
try:
|
|
214
|
+
while exited < s.limit:
|
|
215
|
+
msg = await output_q.get()
|
|
216
|
+
if isinstance(msg, _WorkerExit):
|
|
217
|
+
exited += 1
|
|
218
|
+
continue
|
|
219
|
+
completed += 1
|
|
220
|
+
if msg.exc is None:
|
|
221
|
+
succeeded += 1
|
|
222
|
+
else:
|
|
223
|
+
failed += 1
|
|
224
|
+
if s.on_progress is not None:
|
|
225
|
+
s.on_progress(
|
|
226
|
+
Progress(
|
|
227
|
+
completed=completed,
|
|
228
|
+
total=total,
|
|
229
|
+
succeeded=succeeded,
|
|
230
|
+
failed=failed,
|
|
231
|
+
in_flight=counters.started - completed,
|
|
232
|
+
elapsed=monotonic() - start,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
yield msg
|
|
236
|
+
if feeder_error is not None:
|
|
237
|
+
raise feeder_error
|
|
238
|
+
finally:
|
|
239
|
+
for task in tasks:
|
|
240
|
+
task.cancel()
|
|
241
|
+
while not input_q.empty():
|
|
242
|
+
try:
|
|
243
|
+
input_q.get_nowait()
|
|
244
|
+
except asyncio.QueueEmpty: # pragma: no cover - defensive
|
|
245
|
+
break
|
|
246
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# --------------------------------------------------------------------------- #
|
|
250
|
+
# Public API
|
|
251
|
+
# --------------------------------------------------------------------------- #
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@overload
|
|
255
|
+
async def amap(
|
|
256
|
+
func: Callable[[T], Awaitable[R]],
|
|
257
|
+
items: Iterable[T] | AsyncIterable[T],
|
|
258
|
+
*,
|
|
259
|
+
limit: int = ...,
|
|
260
|
+
retries: int = ...,
|
|
261
|
+
backoff: Backoff | None = ...,
|
|
262
|
+
retry_on: RetryOn = ...,
|
|
263
|
+
timeout: float | None = ...,
|
|
264
|
+
return_exceptions: Literal[False] = ...,
|
|
265
|
+
rate_limit: float | None = ...,
|
|
266
|
+
on_progress: Callable[[Progress], None] | None = ...,
|
|
267
|
+
) -> list[R]: ...
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@overload
|
|
271
|
+
async def amap(
|
|
272
|
+
func: Callable[[T], Awaitable[R]],
|
|
273
|
+
items: Iterable[T] | AsyncIterable[T],
|
|
274
|
+
*,
|
|
275
|
+
limit: int = ...,
|
|
276
|
+
retries: int = ...,
|
|
277
|
+
backoff: Backoff | None = ...,
|
|
278
|
+
retry_on: RetryOn = ...,
|
|
279
|
+
timeout: float | None = ...,
|
|
280
|
+
return_exceptions: Literal[True],
|
|
281
|
+
rate_limit: float | None = ...,
|
|
282
|
+
on_progress: Callable[[Progress], None] | None = ...,
|
|
283
|
+
) -> list[R | BaseException]: ...
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def amap(
|
|
287
|
+
func: Callable[[T], Awaitable[R]],
|
|
288
|
+
items: Iterable[T] | AsyncIterable[T],
|
|
289
|
+
*,
|
|
290
|
+
limit: int = DEFAULT_LIMIT,
|
|
291
|
+
retries: int = 0,
|
|
292
|
+
backoff: Backoff | None = None,
|
|
293
|
+
retry_on: RetryOn = Exception,
|
|
294
|
+
timeout: float | None = None,
|
|
295
|
+
return_exceptions: bool = False,
|
|
296
|
+
rate_limit: float | None = None,
|
|
297
|
+
on_progress: Callable[[Progress], None] | None = None,
|
|
298
|
+
) -> list[Any]:
|
|
299
|
+
"""Apply ``func`` to every item concurrently and return results in input order.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
func: An async function called once per item.
|
|
303
|
+
items: A sync or async iterable of inputs.
|
|
304
|
+
limit: Maximum number of tasks running at once.
|
|
305
|
+
retries: How many times to retry a failing task (0 disables retries).
|
|
306
|
+
backoff: Delay strategy between retries (defaults to exponential w/ jitter).
|
|
307
|
+
retry_on: Exception type(s) or a predicate selecting which errors retry.
|
|
308
|
+
timeout: Per-attempt timeout in seconds.
|
|
309
|
+
return_exceptions: If true, failures are returned in place of values
|
|
310
|
+
instead of being raised (mirrors ``asyncio.gather``).
|
|
311
|
+
rate_limit: Maximum task starts per second across the whole run.
|
|
312
|
+
on_progress: Callback invoked with a :class:`Progress` snapshot after
|
|
313
|
+
each task finishes.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
A list of results aligned with ``items``. With ``return_exceptions=True``
|
|
317
|
+
the list may contain exception instances.
|
|
318
|
+
"""
|
|
319
|
+
s = _make_settings(
|
|
320
|
+
func,
|
|
321
|
+
limit=limit,
|
|
322
|
+
retries=retries,
|
|
323
|
+
backoff=backoff,
|
|
324
|
+
retry_on=retry_on,
|
|
325
|
+
timeout=timeout,
|
|
326
|
+
rate_limit=rate_limit,
|
|
327
|
+
on_progress=on_progress,
|
|
328
|
+
)
|
|
329
|
+
total = _len_or_none(items)
|
|
330
|
+
out: list[Any] = [None] * total if total is not None else []
|
|
331
|
+
buffer: dict[int, Any] = {}
|
|
332
|
+
gen = _imap_unordered(items, s)
|
|
333
|
+
try:
|
|
334
|
+
async for c in gen:
|
|
335
|
+
result: Any
|
|
336
|
+
if c.exc is not None:
|
|
337
|
+
if not return_exceptions:
|
|
338
|
+
raise c.exc
|
|
339
|
+
result = c.exc
|
|
340
|
+
else:
|
|
341
|
+
result = c.value
|
|
342
|
+
if total is not None:
|
|
343
|
+
out[c.index] = result
|
|
344
|
+
else:
|
|
345
|
+
buffer[c.index] = result
|
|
346
|
+
finally:
|
|
347
|
+
await gen.aclose()
|
|
348
|
+
if total is None:
|
|
349
|
+
out = [buffer[i] for i in range(len(buffer))]
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
@overload
|
|
354
|
+
def stream(
|
|
355
|
+
func: Callable[[T], Awaitable[R]],
|
|
356
|
+
items: Iterable[T] | AsyncIterable[T],
|
|
357
|
+
*,
|
|
358
|
+
limit: int = ...,
|
|
359
|
+
retries: int = ...,
|
|
360
|
+
backoff: Backoff | None = ...,
|
|
361
|
+
retry_on: RetryOn = ...,
|
|
362
|
+
timeout: float | None = ...,
|
|
363
|
+
return_exceptions: Literal[False] = ...,
|
|
364
|
+
rate_limit: float | None = ...,
|
|
365
|
+
on_progress: Callable[[Progress], None] | None = ...,
|
|
366
|
+
) -> AsyncIterator[R]: ...
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@overload
|
|
370
|
+
def stream(
|
|
371
|
+
func: Callable[[T], Awaitable[R]],
|
|
372
|
+
items: Iterable[T] | AsyncIterable[T],
|
|
373
|
+
*,
|
|
374
|
+
limit: int = ...,
|
|
375
|
+
retries: int = ...,
|
|
376
|
+
backoff: Backoff | None = ...,
|
|
377
|
+
retry_on: RetryOn = ...,
|
|
378
|
+
timeout: float | None = ...,
|
|
379
|
+
return_exceptions: Literal[True],
|
|
380
|
+
rate_limit: float | None = ...,
|
|
381
|
+
on_progress: Callable[[Progress], None] | None = ...,
|
|
382
|
+
) -> AsyncIterator[R | BaseException]: ...
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
async def stream(
|
|
386
|
+
func: Callable[[T], Awaitable[R]],
|
|
387
|
+
items: Iterable[T] | AsyncIterable[T],
|
|
388
|
+
*,
|
|
389
|
+
limit: int = DEFAULT_LIMIT,
|
|
390
|
+
retries: int = 0,
|
|
391
|
+
backoff: Backoff | None = None,
|
|
392
|
+
retry_on: RetryOn = Exception,
|
|
393
|
+
timeout: float | None = None,
|
|
394
|
+
return_exceptions: bool = False,
|
|
395
|
+
rate_limit: float | None = None,
|
|
396
|
+
on_progress: Callable[[Progress], None] | None = None,
|
|
397
|
+
) -> AsyncIterator[Any]:
|
|
398
|
+
"""Like :func:`amap`, but yield each result as soon as it is ready.
|
|
399
|
+
|
|
400
|
+
Results arrive in completion order (not input order), which lets you react to
|
|
401
|
+
fast tasks without waiting for slow ones. Memory stays bounded even for very
|
|
402
|
+
large or infinite inputs.
|
|
403
|
+
"""
|
|
404
|
+
s = _make_settings(
|
|
405
|
+
func,
|
|
406
|
+
limit=limit,
|
|
407
|
+
retries=retries,
|
|
408
|
+
backoff=backoff,
|
|
409
|
+
retry_on=retry_on,
|
|
410
|
+
timeout=timeout,
|
|
411
|
+
rate_limit=rate_limit,
|
|
412
|
+
on_progress=on_progress,
|
|
413
|
+
)
|
|
414
|
+
gen = _imap_unordered(items, s)
|
|
415
|
+
try:
|
|
416
|
+
async for c in gen:
|
|
417
|
+
if c.exc is not None:
|
|
418
|
+
if not return_exceptions:
|
|
419
|
+
raise c.exc
|
|
420
|
+
yield c.exc
|
|
421
|
+
else:
|
|
422
|
+
yield c.value
|
|
423
|
+
finally:
|
|
424
|
+
await gen.aclose()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@overload
|
|
428
|
+
async def gather(
|
|
429
|
+
*coros: Awaitable[T],
|
|
430
|
+
limit: int = ...,
|
|
431
|
+
timeout: float | None = ...,
|
|
432
|
+
rate_limit: float | None = ...,
|
|
433
|
+
return_exceptions: Literal[False] = ...,
|
|
434
|
+
on_progress: Callable[[Progress], None] | None = ...,
|
|
435
|
+
) -> list[T]: ...
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@overload
|
|
439
|
+
async def gather(
|
|
440
|
+
*coros: Awaitable[T],
|
|
441
|
+
limit: int = ...,
|
|
442
|
+
timeout: float | None = ...,
|
|
443
|
+
rate_limit: float | None = ...,
|
|
444
|
+
return_exceptions: Literal[True],
|
|
445
|
+
on_progress: Callable[[Progress], None] | None = ...,
|
|
446
|
+
) -> list[T | BaseException]: ...
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
async def gather(
|
|
450
|
+
*coros: Awaitable[T],
|
|
451
|
+
limit: int = DEFAULT_LIMIT,
|
|
452
|
+
timeout: float | None = None,
|
|
453
|
+
rate_limit: float | None = None,
|
|
454
|
+
return_exceptions: bool = False,
|
|
455
|
+
on_progress: Callable[[Progress], None] | None = None,
|
|
456
|
+
) -> list[Any]:
|
|
457
|
+
"""A drop-in for :func:`asyncio.gather` with a concurrency ``limit``.
|
|
458
|
+
|
|
459
|
+
Awaits the given awaitables with at most ``limit`` running at once, preserving
|
|
460
|
+
result order. On fail-fast (the default), remaining awaitables are cancelled
|
|
461
|
+
and closed so no "coroutine was never awaited" warnings leak.
|
|
462
|
+
"""
|
|
463
|
+
pending = list(coros)
|
|
464
|
+
|
|
465
|
+
async def _await(c: Awaitable[T]) -> T:
|
|
466
|
+
return await c
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
if return_exceptions:
|
|
470
|
+
return await amap(
|
|
471
|
+
_await,
|
|
472
|
+
pending,
|
|
473
|
+
limit=limit,
|
|
474
|
+
timeout=timeout,
|
|
475
|
+
rate_limit=rate_limit,
|
|
476
|
+
return_exceptions=True,
|
|
477
|
+
on_progress=on_progress,
|
|
478
|
+
)
|
|
479
|
+
return await amap(
|
|
480
|
+
_await,
|
|
481
|
+
pending,
|
|
482
|
+
limit=limit,
|
|
483
|
+
timeout=timeout,
|
|
484
|
+
rate_limit=rate_limit,
|
|
485
|
+
return_exceptions=False,
|
|
486
|
+
on_progress=on_progress,
|
|
487
|
+
)
|
|
488
|
+
finally:
|
|
489
|
+
for c in pending:
|
|
490
|
+
close = getattr(c, "close", None)
|
|
491
|
+
if callable(close):
|
|
492
|
+
close()
|
tasklane/_lane.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""A reusable, immutable bundle of concurrency settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Iterable
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
from tasklane import _core
|
|
10
|
+
from tasklane._core import DEFAULT_LIMIT, RetryOn
|
|
11
|
+
from tasklane._progress import Progress
|
|
12
|
+
from tasklane._retry import Backoff
|
|
13
|
+
|
|
14
|
+
__all__ = ["Lane"]
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
R = TypeVar("R")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass(frozen=True, slots=True)
|
|
21
|
+
class Lane:
|
|
22
|
+
"""Configure concurrency once, then reuse it across many calls.
|
|
23
|
+
|
|
24
|
+
A ``Lane`` is an immutable bundle of the knobs you would otherwise repeat on
|
|
25
|
+
every :func:`tasklane.amap` call — handy when, say, one downstream API should
|
|
26
|
+
always be hit with the same concurrency, retry, and rate-limit policy::
|
|
27
|
+
|
|
28
|
+
api = Lane(limit=8, retries=3, rate_limit=20)
|
|
29
|
+
users = await api.map(fetch_user, user_ids)
|
|
30
|
+
async for post in api.stream(fetch_post, post_ids):
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
Lanes are frozen; use :meth:`replace` to derive a tweaked copy. Each call runs
|
|
34
|
+
its own independent worker pool.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
limit: int = DEFAULT_LIMIT
|
|
38
|
+
retries: int = 0
|
|
39
|
+
backoff: Backoff | None = None
|
|
40
|
+
retry_on: RetryOn = Exception
|
|
41
|
+
timeout: float | None = None
|
|
42
|
+
rate_limit: float | None = None
|
|
43
|
+
on_progress: Callable[[Progress], None] | None = None
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
_core._validate(self.limit, self.retries, self.timeout, self.rate_limit)
|
|
47
|
+
|
|
48
|
+
async def map(
|
|
49
|
+
self,
|
|
50
|
+
func: Callable[[T], Awaitable[R]],
|
|
51
|
+
items: Iterable[T] | AsyncIterable[T],
|
|
52
|
+
) -> list[R]:
|
|
53
|
+
"""Run :func:`tasklane.amap` with this lane's settings (fail-fast)."""
|
|
54
|
+
return await _core.amap(
|
|
55
|
+
func,
|
|
56
|
+
items,
|
|
57
|
+
limit=self.limit,
|
|
58
|
+
retries=self.retries,
|
|
59
|
+
backoff=self.backoff,
|
|
60
|
+
retry_on=self.retry_on,
|
|
61
|
+
timeout=self.timeout,
|
|
62
|
+
rate_limit=self.rate_limit,
|
|
63
|
+
on_progress=self.on_progress,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def stream(
|
|
67
|
+
self,
|
|
68
|
+
func: Callable[[T], Awaitable[R]],
|
|
69
|
+
items: Iterable[T] | AsyncIterable[T],
|
|
70
|
+
) -> AsyncIterator[R]:
|
|
71
|
+
"""Run :func:`tasklane.stream` with this lane's settings (fail-fast)."""
|
|
72
|
+
return _core.stream(
|
|
73
|
+
func,
|
|
74
|
+
items,
|
|
75
|
+
limit=self.limit,
|
|
76
|
+
retries=self.retries,
|
|
77
|
+
backoff=self.backoff,
|
|
78
|
+
retry_on=self.retry_on,
|
|
79
|
+
timeout=self.timeout,
|
|
80
|
+
rate_limit=self.rate_limit,
|
|
81
|
+
on_progress=self.on_progress,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
async def gather(self, *coros: Awaitable[T]) -> list[T]:
|
|
85
|
+
"""Run :func:`tasklane.gather` with this lane's limit/timeout/rate policy.
|
|
86
|
+
|
|
87
|
+
Note that ``retries`` and ``backoff`` do not apply: a coroutine can only
|
|
88
|
+
be awaited once, so failed coroutines cannot be retried.
|
|
89
|
+
"""
|
|
90
|
+
return await _core.gather(
|
|
91
|
+
*coros,
|
|
92
|
+
limit=self.limit,
|
|
93
|
+
timeout=self.timeout,
|
|
94
|
+
rate_limit=self.rate_limit,
|
|
95
|
+
on_progress=self.on_progress,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def replace(self, **changes: Any) -> Lane:
|
|
99
|
+
"""Return a copy of this lane with the given fields overridden."""
|
|
100
|
+
return dataclasses.replace(self, **changes)
|
tasklane/_progress.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Progress reporting types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
__all__ = ["Progress"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Progress:
|
|
12
|
+
"""A snapshot of progress, passed to an ``on_progress`` callback.
|
|
13
|
+
|
|
14
|
+
A new snapshot is produced each time a task finishes (successfully or not).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
completed: int
|
|
18
|
+
"""Number of tasks that have finished (succeeded + failed)."""
|
|
19
|
+
|
|
20
|
+
total: int | None
|
|
21
|
+
"""Total number of tasks, or ``None`` if the input size is not known
|
|
22
|
+
(e.g. an unsized iterator or async iterable)."""
|
|
23
|
+
|
|
24
|
+
succeeded: int
|
|
25
|
+
"""Number of tasks that finished without raising."""
|
|
26
|
+
|
|
27
|
+
failed: int
|
|
28
|
+
"""Number of tasks that finished by raising (after exhausting retries)."""
|
|
29
|
+
|
|
30
|
+
in_flight: int
|
|
31
|
+
"""Number of tasks currently running."""
|
|
32
|
+
|
|
33
|
+
elapsed: float
|
|
34
|
+
"""Seconds elapsed since the run started (monotonic clock)."""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def remaining(self) -> int | None:
|
|
38
|
+
"""Tasks not yet completed, or ``None`` if ``total`` is unknown."""
|
|
39
|
+
if self.total is None:
|
|
40
|
+
return None
|
|
41
|
+
return self.total - self.completed
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def fraction(self) -> float | None:
|
|
45
|
+
"""Completion ratio in ``[0, 1]``, or ``None`` if ``total`` is unknown."""
|
|
46
|
+
if self.total is None or self.total == 0:
|
|
47
|
+
return None
|
|
48
|
+
return self.completed / self.total
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def rate(self) -> float:
|
|
52
|
+
"""Average completed tasks per second since the run started."""
|
|
53
|
+
if self.elapsed <= 0:
|
|
54
|
+
return 0.0
|
|
55
|
+
return self.completed / self.elapsed
|
tasklane/_ratelimit.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""An async token-bucket rate limiter used to cap how fast tasks start."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from time import monotonic
|
|
7
|
+
|
|
8
|
+
__all__ = ["RateLimiter"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RateLimiter:
|
|
12
|
+
"""Admits at most ``rate`` acquisitions per second on average.
|
|
13
|
+
|
|
14
|
+
Implemented as a token bucket. ``burst`` controls how many acquisitions may
|
|
15
|
+
happen back-to-back before the steady rate kicks in; the default of ``1``
|
|
16
|
+
enforces strict, evenly spaced starts.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
__slots__ = ("_capacity", "_lock", "_rate", "_tokens", "_updated")
|
|
20
|
+
|
|
21
|
+
def __init__(self, rate: float, *, burst: int = 1) -> None:
|
|
22
|
+
if rate <= 0:
|
|
23
|
+
raise ValueError("rate must be > 0")
|
|
24
|
+
if burst < 1:
|
|
25
|
+
raise ValueError("burst must be >= 1")
|
|
26
|
+
self._rate = rate
|
|
27
|
+
self._capacity = float(burst)
|
|
28
|
+
self._tokens = float(burst)
|
|
29
|
+
self._updated = monotonic()
|
|
30
|
+
self._lock = asyncio.Lock()
|
|
31
|
+
|
|
32
|
+
async def acquire(self) -> None:
|
|
33
|
+
"""Block until a token is available, then consume it."""
|
|
34
|
+
async with self._lock:
|
|
35
|
+
while True:
|
|
36
|
+
now = monotonic()
|
|
37
|
+
self._tokens = min(
|
|
38
|
+
self._capacity, self._tokens + (now - self._updated) * self._rate
|
|
39
|
+
)
|
|
40
|
+
self._updated = now
|
|
41
|
+
if self._tokens >= 1:
|
|
42
|
+
self._tokens -= 1
|
|
43
|
+
return
|
|
44
|
+
await asyncio.sleep((1 - self._tokens) / self._rate)
|
tasklane/_retry.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Retry backoff strategies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
__all__ = ["Backoff"]
|
|
10
|
+
|
|
11
|
+
Mode = Literal["exponential", "linear", "constant"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Backoff:
|
|
16
|
+
"""Computes the delay between retry attempts.
|
|
17
|
+
|
|
18
|
+
The default is exponential backoff with full jitter, which is a sane choice
|
|
19
|
+
for most network-bound work. Use the :meth:`constant` and :meth:`linear`
|
|
20
|
+
constructors for the other common strategies.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
base: The base delay in seconds.
|
|
24
|
+
factor: Growth factor for ``"exponential"`` mode.
|
|
25
|
+
max_delay: Upper bound applied to every computed delay.
|
|
26
|
+
jitter: If true, the delay is randomized in ``[0, delay]`` (full
|
|
27
|
+
jitter), which spreads out retries from many callers.
|
|
28
|
+
mode: How the delay grows with the attempt number.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
base: float = 0.1
|
|
32
|
+
factor: float = 2.0
|
|
33
|
+
max_delay: float = 30.0
|
|
34
|
+
jitter: bool = True
|
|
35
|
+
mode: Mode = "exponential"
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if self.base < 0:
|
|
39
|
+
raise ValueError("base must be >= 0")
|
|
40
|
+
if self.factor <= 0:
|
|
41
|
+
raise ValueError("factor must be > 0")
|
|
42
|
+
if self.max_delay < 0:
|
|
43
|
+
raise ValueError("max_delay must be >= 0")
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def exponential(
|
|
47
|
+
cls,
|
|
48
|
+
base: float = 0.1,
|
|
49
|
+
*,
|
|
50
|
+
factor: float = 2.0,
|
|
51
|
+
max_delay: float = 30.0,
|
|
52
|
+
jitter: bool = True,
|
|
53
|
+
) -> Backoff:
|
|
54
|
+
"""Exponential backoff: ``base * factor ** attempt`` (capped)."""
|
|
55
|
+
return cls(base=base, factor=factor, max_delay=max_delay, jitter=jitter, mode="exponential")
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def linear(
|
|
59
|
+
cls,
|
|
60
|
+
step: float = 0.1,
|
|
61
|
+
*,
|
|
62
|
+
max_delay: float = 30.0,
|
|
63
|
+
jitter: bool = True,
|
|
64
|
+
) -> Backoff:
|
|
65
|
+
"""Linear backoff: ``step * (attempt + 1)`` (capped)."""
|
|
66
|
+
return cls(base=step, max_delay=max_delay, jitter=jitter, mode="linear")
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def constant(cls, delay: float = 0.1, *, jitter: bool = False) -> Backoff:
|
|
70
|
+
"""Constant delay between every attempt."""
|
|
71
|
+
return cls(base=delay, max_delay=delay, jitter=jitter, mode="constant")
|
|
72
|
+
|
|
73
|
+
def delay_for(self, attempt: int) -> float:
|
|
74
|
+
"""Return the delay in seconds before retry ``attempt`` (0-indexed).
|
|
75
|
+
|
|
76
|
+
``attempt=0`` is the first retry (i.e. after the initial call failed).
|
|
77
|
+
"""
|
|
78
|
+
if attempt < 0:
|
|
79
|
+
raise ValueError("attempt must be >= 0")
|
|
80
|
+
if self.mode == "exponential":
|
|
81
|
+
raw = self.base * (self.factor**attempt)
|
|
82
|
+
elif self.mode == "linear":
|
|
83
|
+
raw = self.base * (attempt + 1)
|
|
84
|
+
else: # constant
|
|
85
|
+
raw = self.base
|
|
86
|
+
delay = min(raw, self.max_delay)
|
|
87
|
+
if self.jitter:
|
|
88
|
+
delay = random.uniform(0, delay)
|
|
89
|
+
return delay
|
tasklane/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tasklane
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bounded-concurrency async for Python: run, map, and stream awaitables with limits, retries, rate limiting, and progress — in one typed call.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jpwm2/tasklane
|
|
6
|
+
Project-URL: Repository, https://github.com/jpwm2/tasklane
|
|
7
|
+
Project-URL: Issues, https://github.com/jpwm2/tasklane/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/jpwm2/tasklane/blob/main/CHANGELOG.md
|
|
9
|
+
Author: jpwm2
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: async,asyncio,concurrency,gather,parallel,rate-limit,retry,semaphore,task-pool,throttle
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# tasklane
|
|
29
|
+
|
|
30
|
+
**Bounded-concurrency async for Python — run, map, and stream awaitables with a
|
|
31
|
+
concurrency limit, retries, backoff, rate limiting, and progress, in one typed call.**
|
|
32
|
+
|
|
33
|
+
[](https://github.com/jpwm2/tasklane/actions/workflows/ci.yml)
|
|
34
|
+
[](https://pypi.org/project/tasklane/)
|
|
35
|
+
[](https://pypi.org/project/tasklane/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](src/tasklane/py.typed)
|
|
38
|
+
[](https://github.com/astral-sh/ruff)
|
|
39
|
+
|
|
40
|
+
Every Python project that fans out async work eventually rewrites the same block:
|
|
41
|
+
an `asyncio.Semaphore` to cap concurrency, a `try/except` retry loop, a counter
|
|
42
|
+
for progress, maybe a sleep to stay under a rate limit. `tasklane` is that block,
|
|
43
|
+
done once — correct, fully typed, and **zero runtime dependencies**.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import asyncio
|
|
47
|
+
import httpx
|
|
48
|
+
import tasklane
|
|
49
|
+
|
|
50
|
+
async def fetch(url: str) -> int:
|
|
51
|
+
async with httpx.AsyncClient() as client:
|
|
52
|
+
return len((await client.get(url)).text)
|
|
53
|
+
|
|
54
|
+
async def main() -> None:
|
|
55
|
+
urls = [f"https://example.com/{i}" for i in range(1000)]
|
|
56
|
+
|
|
57
|
+
sizes = await tasklane.amap(
|
|
58
|
+
fetch, urls,
|
|
59
|
+
limit=20, # at most 20 requests in flight
|
|
60
|
+
retries=3, # retry failures up to 3x with exponential backoff
|
|
61
|
+
rate_limit=50, # start at most 50 requests per second
|
|
62
|
+
timeout=10, # per-attempt timeout (seconds)
|
|
63
|
+
)
|
|
64
|
+
print(sum(sizes))
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install tasklane
|
|
73
|
+
# or
|
|
74
|
+
uv add tasklane
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Requires Python 3.10+. No third-party dependencies.
|
|
78
|
+
|
|
79
|
+
## Why not just `asyncio.gather`?
|
|
80
|
+
|
|
81
|
+
`asyncio.gather` starts **everything at once**. Fan out 10,000 requests and you
|
|
82
|
+
open 10,000 sockets, trip rate limits, and OOM. The usual fixes are scattered
|
|
83
|
+
across the stdlib and third-party libs; `tasklane` brings them together:
|
|
84
|
+
|
|
85
|
+
| | `asyncio.gather` | `Semaphore` + `gather` | `aiometer` | **tasklane** |
|
|
86
|
+
| ---------------------------- | :--------------: | :--------------------: | :--------: | :----------: |
|
|
87
|
+
| Concurrency limit | ✗ | manual | ✓ | ✓ |
|
|
88
|
+
| Results in input order | ✓ | ✓ | ✓ | ✓ |
|
|
89
|
+
| Stream results as completed | `as_completed` | manual | ✓ | ✓ |
|
|
90
|
+
| Retries + backoff | ✗ | ✗ | ✗ | ✓ |
|
|
91
|
+
| Rate limiting (per second) | ✗ | ✗ | ✓ | ✓ |
|
|
92
|
+
| Progress callbacks | ✗ | ✗ | ✗ | ✓ |
|
|
93
|
+
| Per-task timeout | ✗ | manual | ✗ | ✓ |
|
|
94
|
+
| Backpressure on huge inputs | ✗ | manual | ✓ | ✓ |
|
|
95
|
+
| Runtime dependencies | stdlib | stdlib | `anyio` | **none** |
|
|
96
|
+
|
|
97
|
+
## Features
|
|
98
|
+
|
|
99
|
+
### `amap` — concurrent map, results in order
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
results = await tasklane.amap(fetch, urls, limit=10)
|
|
103
|
+
# results[i] corresponds to urls[i]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Accepts both sync and **async** iterables, and works in constant memory thanks to
|
|
107
|
+
a bounded internal queue — you can map over a million-item generator without
|
|
108
|
+
materializing a million tasks.
|
|
109
|
+
|
|
110
|
+
### `stream` — react to results as they finish
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
async for size in tasklane.stream(fetch, urls, limit=10):
|
|
114
|
+
print(size) # arrives in completion order, fastest first
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### `gather` — a drop-in `asyncio.gather` with a limit
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
results = await tasklane.gather(*(fetch(u) for u in urls), limit=10)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
On fail-fast, the remaining coroutines are cancelled **and closed**, so you never
|
|
124
|
+
see a `coroutine was never awaited` warning.
|
|
125
|
+
|
|
126
|
+
### Retries with backoff
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from tasklane import Backoff
|
|
130
|
+
|
|
131
|
+
await tasklane.amap(
|
|
132
|
+
fetch, urls,
|
|
133
|
+
retries=5,
|
|
134
|
+
backoff=Backoff.exponential(0.2, factor=2, max_delay=30), # 0.2, 0.4, 0.8, ... + jitter
|
|
135
|
+
retry_on=(TimeoutError, ConnectionError), # type, tuple, or predicate
|
|
136
|
+
)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`Backoff.exponential()` (the default when `retries > 0`), `Backoff.linear()`, and
|
|
140
|
+
`Backoff.constant()` cover the common cases. `retry_on` accepts an exception type,
|
|
141
|
+
a tuple of types, or a `Callable[[BaseException], bool]` predicate.
|
|
142
|
+
|
|
143
|
+
### Rate limiting
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
# Never start more than 100 tasks per second, regardless of the concurrency limit.
|
|
147
|
+
await tasklane.amap(call_api, items, limit=50, rate_limit=100)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Progress
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from tasklane import Progress
|
|
154
|
+
|
|
155
|
+
def show(p: Progress) -> None:
|
|
156
|
+
print(f"{p.completed}/{p.total} ({p.failed} failed) {p.rate:.0f}/s")
|
|
157
|
+
|
|
158
|
+
await tasklane.amap(fetch, urls, limit=10, on_progress=show)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`Progress` carries `completed`, `total`, `succeeded`, `failed`, `in_flight`, and
|
|
162
|
+
`elapsed`, plus `remaining`, `fraction`, and `rate` helpers. Plug it into `tqdm`,
|
|
163
|
+
a logger, or a web UI — no progress-bar dependency is imposed on you.
|
|
164
|
+
|
|
165
|
+
### Collect errors instead of raising
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
results = await tasklane.amap(fetch, urls, return_exceptions=True)
|
|
169
|
+
ok = [r for r in results if not isinstance(r, Exception)]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### `Lane` — configure once, reuse everywhere
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from tasklane import Lane
|
|
176
|
+
|
|
177
|
+
# One policy for a specific downstream API.
|
|
178
|
+
github = Lane(limit=8, retries=3, rate_limit=20, timeout=10)
|
|
179
|
+
|
|
180
|
+
repos = await github.map(fetch_repo, repo_names)
|
|
181
|
+
async for issue in github.stream(fetch_issue, issue_ids):
|
|
182
|
+
...
|
|
183
|
+
|
|
184
|
+
# Lanes are immutable; derive a variant with .replace()
|
|
185
|
+
bulk = github.replace(limit=32)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## How it works
|
|
189
|
+
|
|
190
|
+
`tasklane` runs a fixed pool of `limit` worker coroutines that pull items off a
|
|
191
|
+
bounded `asyncio.Queue`. The bounded queue is what gives you backpressure and
|
|
192
|
+
constant memory; the worker pool is what enforces the concurrency limit exactly.
|
|
193
|
+
Retries, per-attempt timeouts, and rate limiting are applied inside each worker,
|
|
194
|
+
and completions are streamed back to the caller — collected into order for
|
|
195
|
+
`amap`, or yielded as-they-finish for `stream`. On any early exit (fail-fast,
|
|
196
|
+
`break`, or external cancellation) every in-flight task is cancelled and awaited,
|
|
197
|
+
so nothing leaks.
|
|
198
|
+
|
|
199
|
+
## API reference
|
|
200
|
+
|
|
201
|
+
| Symbol | Description |
|
|
202
|
+
| ------ | ----------- |
|
|
203
|
+
| `amap(func, items, *, limit, retries, backoff, retry_on, timeout, return_exceptions, rate_limit, on_progress)` | Concurrent map; returns a list in input order. |
|
|
204
|
+
| `stream(func, items, *, ...)` | Async iterator yielding results in completion order. |
|
|
205
|
+
| `gather(*coros, limit, timeout, rate_limit, return_exceptions, on_progress)` | Concurrency-limited `asyncio.gather`. |
|
|
206
|
+
| `Lane(...)` | Reusable, immutable bundle of settings with `.map`, `.stream`, `.gather`, `.replace`. |
|
|
207
|
+
| `Backoff` | Retry delay strategy: `.exponential`, `.linear`, `.constant`. |
|
|
208
|
+
| `Progress` | Immutable progress snapshot passed to `on_progress`. |
|
|
209
|
+
|
|
210
|
+
Full signatures and docstrings ship with the package and are surfaced by your
|
|
211
|
+
editor (the library is fully typed and marked with `py.typed`).
|
|
212
|
+
|
|
213
|
+
## Contributing
|
|
214
|
+
|
|
215
|
+
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md). In short:
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
uv sync
|
|
219
|
+
uv run pytest # tests
|
|
220
|
+
uv run ruff check . # lint
|
|
221
|
+
uv run mypy # types
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
[MIT](LICENSE) © tasklane contributors
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
tasklane/__init__.py,sha256=grvnKw3iu1eY13DAVCHoMHGqXKw2wa87SmJFpMLiLIE,921
|
|
2
|
+
tasklane/_core.py,sha256=s-ySP5bqO3VN9yQR_KhGY0ZKiUx_apXlmSptJrymjD0,15201
|
|
3
|
+
tasklane/_lane.py,sha256=gIZZeLSFioEsHTNU6_Ijmcesd7GebD6LycsLJQFLJ0k,3293
|
|
4
|
+
tasklane/_progress.py,sha256=s2EWlys2DrfNy7HEKnYTlg55NwgubLmoRQJJJKGSFa4,1598
|
|
5
|
+
tasklane/_ratelimit.py,sha256=HiYKMNu1dCWDfjSUCH4BesKapIjJxBPqmBRkTLVppOE,1472
|
|
6
|
+
tasklane/_retry.py,sha256=aBiDn4J1qCgURP3Yivowf5-7KQqxwj15Zm4vkPXjznA,2887
|
|
7
|
+
tasklane/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
tasklane-0.1.0.dist-info/METADATA,sha256=nxPbWpMHxWN_gQjs1M9TjGY1biTvtwC0Y8hIkOG8M5o,8931
|
|
9
|
+
tasklane-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
tasklane-0.1.0.dist-info/licenses/LICENSE,sha256=Zgg_QI7BvWGwAqwvJ5jjpVkloDuW68XS3e956_LwFUk,1078
|
|
11
|
+
tasklane-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tasklane contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|