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 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
+ [![CI](https://github.com/jpwm2/tasklane/actions/workflows/ci.yml/badge.svg)](https://github.com/jpwm2/tasklane/actions/workflows/ci.yml)
34
+ [![PyPI](https://img.shields.io/pypi/v/tasklane.svg)](https://pypi.org/project/tasklane/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/tasklane.svg)](https://pypi.org/project/tasklane/)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
37
+ [![Types: typed](https://img.shields.io/badge/types-100%25-blue.svg)](src/tasklane/py.typed)
38
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.