async-task-pipeline-py 0.1.0__tar.gz

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.
@@ -0,0 +1,30 @@
1
+ # Python bytecode
2
+ __pycache__/
3
+ *.py[cod]
4
+
5
+ # Build artifacts
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+
10
+ # Environments and secrets
11
+ .venv/
12
+ .env
13
+ .local.env
14
+
15
+ # Tool caches
16
+ .mypy_cache/
17
+ .pytest_cache/
18
+ .ruff_cache/
19
+
20
+ # Editors and OS
21
+ .idea/
22
+ .vscode/
23
+ .DS_Store
24
+
25
+ # Coding agents
26
+ .agent/
27
+ .claude/
28
+
29
+ # Local sandbox dir
30
+ .sandbox/
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-09-22
11
+
12
+ ### Added
13
+
14
+ - `TaskPipeline`: an async context manager scope with `start()`, `start_many()`, `gather()` and `results()`.
15
+ - `TaskHandle`: a typed handle per task, with `result()`, `done()` and `await wait()`.
16
+ - `current_pipeline()`: ambient access to the running pipeline from inside a task.
17
+ - Dependency wiring by passing handles as arguments, or through `depends_on`.
18
+ - Bounded concurrency via `concurrency_limit`; a task waiting on another hands its permit back.
19
+ - Wait-cycle detection, and failure handling decided by task ownership.
20
+ - Support for Python 3.12, 3.13 and 3.14.
21
+
22
+ [Unreleased]: https://github.com/a-buryy/async-task-pipeline-py/compare/v0.1.0...HEAD
23
+ [0.1.0]: https://github.com/a-buryy/async-task-pipeline-py/releases/tag/v0.1.0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrii Buryi
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.
@@ -0,0 +1,391 @@
1
+ Metadata-Version: 2.5
2
+ Name: async-task-pipeline-py
3
+ Version: 0.1.0
4
+ Summary: A TaskGroup-style scope for async tasks with dependencies and bounded concurrency.
5
+ Project-URL: Repository, https://github.com/a-buryy/async-task-pipeline-py
6
+ Project-URL: Issues, https://github.com/a-buryy/async-task-pipeline-py/issues
7
+ Project-URL: Changelog, https://github.com/a-buryy/async-task-pipeline-py/blob/main/CHANGELOG.md
8
+ Author-email: Andrii Buryi <Andriy07ua@proton.me>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: asyncio,concurrency,dag,pipeline,task-graph
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Async Task Pipeline
27
+
28
+ [![PyPI](https://img.shields.io/pypi/v/async-task-pipeline-py)](https://pypi.org/project/async-task-pipeline-py/)
29
+ [![Python](https://img.shields.io/pypi/pyversions/async-task-pipeline-py)](https://pypi.org/project/async-task-pipeline-py/)
30
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/a-buryy/async-task-pipeline-py/blob/main/LICENSE)
31
+
32
+ `async-task-pipeline-py` runs async tasks with dependencies inside a `TaskGroup`-style scope: open the pipeline with `async with`, call `start()` where you would call `create_task()`, and leaving the block waits for every task. On top of `TaskGroup` it adds:
33
+
34
+ * **Dependencies**: pass a task's handle as an argument, and the callable receives that task's result.
35
+ * **A concurrency limit** on how many tasks run at once.
36
+ * **Scoped failures**: a failure is held for the task that started it instead of cancelling everything. Only an unhandled failure ends the pipeline, as an `ExceptionGroup`.
37
+
38
+ **When to use it.** `asyncio.gather` and `TaskGroup` are enough for a flat batch of independent coroutines. Reach for a pipeline when tasks feed each other's results, when a fan-out has to be throttled, or when a task discovers more work while it runs.
39
+
40
+ ## Contents
41
+
42
+ * [Installation](#installation)
43
+ * [Quick start](#quick-start)
44
+ * [Guides](#guides)
45
+ * [Wiring individual tasks](#wiring-individual-tasks)
46
+ * [Dynamic tasks](#dynamic-tasks)
47
+ * [Failure semantics](#failure-semantics)
48
+ * [Fanning out over an async generator](#fanning-out-over-an-async-generator)
49
+ * [Extra arguments for a fan-out](#extra-arguments-for-a-fan-out)
50
+ * [Logging](#logging)
51
+ * [API reference](#api-reference)
52
+ * [Development](#development)
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ pip install async-task-pipeline-py
58
+ ```
59
+
60
+ The distribution is named `async-task-pipeline-py`; the import package is `async_task_pipeline`. Requires Python 3.12+ and has no runtime dependencies.
61
+
62
+ ## Quick start
63
+
64
+ ```python
65
+ import asyncio
66
+
67
+ from async_task_pipeline import TaskPipeline
68
+
69
+
70
+ async def fetch(number: int) -> int:
71
+ await asyncio.sleep(0.1)
72
+ return number * 10
73
+
74
+
75
+ async def total(values: list[int]) -> int:
76
+ return sum(values)
77
+
78
+
79
+ async def main() -> None:
80
+ async with TaskPipeline(concurrency_limit=3) as pipeline:
81
+ # five tasks, at most three at once
82
+ values = pipeline.start_many(fetch, range(5), task_name='fetch')
83
+ # runs once every fetch is done
84
+ summary = pipeline.start(total, values, task_name='total')
85
+
86
+ print(summary.result())
87
+ print(pipeline.results())
88
+
89
+
90
+ asyncio.run(main())
91
+ ```
92
+
93
+ ```
94
+ 100
95
+ {'fetch[0]': 0, 'fetch[1]': 10, 'fetch[2]': 20, 'fetch[3]': 30, 'fetch[4]': 40, 'fetch': [0, 10, 20, 30, 40], 'total': 100}
96
+ ```
97
+
98
+ `start()` and `start_many()` return immediately with a `TaskHandle`. Passing `values` to `start(total, ...)` makes `total` depend on it: the pipeline replaces the handle with its result, a list here, before calling `total`. Leaving the `async with` block waits for everything, after which `result()` reads a task's value and `results()` returns all of them by task name.
99
+
100
+ To wait for tasks whose results you don't need, list them in `depends_on`:
101
+
102
+ ```python
103
+ pipeline.start(cleanup, depends_on=[values, summary])
104
+ ```
105
+
106
+ ## Guides
107
+
108
+ The snippets below assume these imports:
109
+
110
+ ```python
111
+ import asyncio
112
+ import functools
113
+ from collections.abc import AsyncIterator
114
+
115
+ from async_task_pipeline import (
116
+ TaskHandle,
117
+ TaskPipeline,
118
+ current_pipeline,
119
+ )
120
+ ```
121
+
122
+ ### Wiring individual tasks
123
+
124
+ `start_many` hands back a single handle for the whole fan-out, so anything consuming it waits for every item. When a downstream task needs only *one* of the results, start the tasks individually and keep their handles:
125
+
126
+ ```python
127
+ async with TaskPipeline(concurrency_limit=5) as pipeline:
128
+ shards = [pipeline.start(fetch_shard, index, task_name=f'shard{index}') for index in range(5)]
129
+
130
+ # Depends on one shard, so it runs as soon as that shard is ready.
131
+ inspected = pipeline.start(inspect_shard, shards[4], task_name='inspect')
132
+
133
+ # Depends on all of them.
134
+ summary = pipeline.start(summarise, *shards, task_name='summary')
135
+ ```
136
+
137
+ `inspect` starts the moment shard 4 lands, rather than waiting for the slowest shard:
138
+
139
+ ```
140
+ fetched 4, inspected 4, fetched 3, fetched 2, fetched 1, fetched 0
141
+ ```
142
+
143
+ This changes *when* work starts, not what happens on failure: a failure nobody holds still ends the pipeline, however the dependency is wired.
144
+
145
+ If a consumer wants the results as one list rather than as separate arguments, collect them with `gather()`. It returns a `TaskHandle[list[T]]` in argument order, the same shape `start_many` produces:
146
+
147
+ ```python
148
+ async with TaskPipeline() as pipeline:
149
+ shards = [pipeline.start(fetch_shard, index) for index in range(5)]
150
+ everything = pipeline.gather(*shards)
151
+ summary = pipeline.start(summarise, everything, task_name='summary')
152
+ ```
153
+
154
+ ### Dynamic tasks
155
+
156
+ A running task starts further tasks the same way, reaching its pipeline with `current_pipeline()`. Use `await handle.wait()` when the task needs a result to decide what to do next. The waiting task gives up its concurrency slot while it waits, so this is safe even when the limit is smaller than the number of waiters.
157
+
158
+ ```python
159
+ async def crawl() -> list[str]:
160
+ pipeline = current_pipeline()
161
+ found: list[str] = []
162
+ cursor = 0
163
+
164
+ while True:
165
+ # Started together so they run concurrently, then awaited one by one.
166
+ batch = [pipeline.start(fetch_page, cursor + offset) for offset in range(3)]
167
+ pages = [await page.wait() for page in batch]
168
+
169
+ found.extend(page for page in pages if page is not None)
170
+
171
+ if None in pages:
172
+ return found
173
+
174
+ cursor += 3
175
+ ```
176
+
177
+ When the task doesn't need the results itself, don't wait at all. Start a successor that consumes them and return, which frees the slot immediately:
178
+
179
+ ```python
180
+ async def parent() -> str:
181
+ pipeline = current_pipeline()
182
+ children = [pipeline.start(work, index) for index in range(4)]
183
+
184
+ pipeline.start(finalize, *children)
185
+
186
+ return 'spawned'
187
+ ```
188
+
189
+ ### Failure semantics
190
+
191
+ A task that starts another can handle its failure, and the pipeline carries on:
192
+
193
+ ```python
194
+ async def fetch(url: str) -> str:
195
+ if 'bad' in url:
196
+ raise ValueError(f'cannot fetch {url}')
197
+ return f'<page {url}>'
198
+
199
+
200
+ async def fetch_or_default(url: str) -> str:
201
+ page = current_pipeline().start(fetch, url)
202
+
203
+ try:
204
+ return await page.wait()
205
+ except ValueError:
206
+ return '<empty>'
207
+
208
+
209
+ async with TaskPipeline() as pipeline:
210
+ pages = pipeline.start_many(fetch_or_default, ['a', 'bad', 'c'])
211
+
212
+ print(pages.result()) # ['<page a>', '<empty>', '<page c>']
213
+ ```
214
+
215
+ Calling `pipeline.start_many(fetch, ...)` directly from the `async with` body instead leaves nobody to handle the failure, so the pipeline ends and the `async with` raises an `ExceptionGroup` containing the `ValueError`.
216
+
217
+ The rule behind this: every task has an **owner**, the task that called `start()` for it, or nobody when it was started from the scope body. The owner is answerable for the failure, and the rule does not depend on timing. Unlike `TaskGroup`, a single failure does not cancel sibling tasks while its owner is running:
218
+
219
+ * While the owner is running, a failure is **held**. It is delivered to whoever reads it, through `await handle.wait()` or `handle.result()`, from the owner or any other task, and the pipeline carries on. That makes `try`/`except` around `await handle.wait()` meaningful whether the wait begins before or after the failure.
220
+ * If the owner finishes without anyone having read the failure, the owner **fails with it**, and the same rule applies one level up. Nothing is lost silently.
221
+ * If the owner finishes while a task it started is still running, that task **passes to the owner's own owner**. A failure is always held by the nearest running ancestor, however late it lands.
222
+ * A failure with **no owner to hold it**, from a task started from the scope body or one whose every ancestor has finished, ends the pipeline at once and surfaces from the `async with` as an `ExceptionGroup`.
223
+ * Tasks downstream of a failure are discarded; their `result()` raises `RuntimeError` explaining why.
224
+ * Every failure carries notes naming the task it came from and, if it was left unhandled, the owner that dropped it, so a callable used by several tasks is still identifiable:
225
+
226
+ ```
227
+ ValueError: bad payload
228
+ Raised by pipeline task 'shards[3]'.
229
+ Not handled by its owner, pipeline task 'collect'.
230
+ ```
231
+
232
+ Waiting cannot deadlock on the concurrency limit, because a waiting task gives its slot back. A *cycle* of waits would still be unfinishable: a task waiting on itself, two waiting on each other, or one waiting on a task that cannot start until the waiter finishes. `wait()` checks for that before blocking and raises instead:
233
+
234
+ ```
235
+ RuntimeError: Task 'parent' cannot wait on task 'later': that would deadlock,
236
+ because 'later' is itself blocked on 'parent'.
237
+ ```
238
+
239
+ To bound a pipeline that might hang for any other reason, wrap it in `asyncio.timeout`:
240
+
241
+ ```python
242
+ async with asyncio.timeout(30):
243
+ async with TaskPipeline() as pipeline:
244
+ ...
245
+ ```
246
+
247
+ ### Fanning out over an async generator
248
+
249
+ `start_many` needs a synchronous iterable. It calls `list(inputs)`, so an async generator raises `TypeError: 'async_generator' object is not iterable`. Iterate it yourself and collect the handles with `gather()`:
250
+
251
+ ```python
252
+ async def pages() -> AsyncIterator[int]:
253
+ ...
254
+
255
+
256
+ async with TaskPipeline() as pipeline:
257
+ handles = [pipeline.start(fetch, page) async for page in pages()]
258
+ everything = pipeline.gather(*handles)
259
+ ```
260
+
261
+ Each task starts as its item arrives, rather than after the whole sequence is produced. Note that the `async for` makes the scope body yield to the event loop, so tasks begin running while the body is still executing, which matters if your code assumes nothing runs until the body ends.
262
+
263
+ `gather(*handles)` is evaluated eagerly, so this drains the generator completely before the combined handle exists. For a source of unknown length, or when downstream tasks need the handle before the items have arrived, do the draining inside a task instead:
264
+
265
+ ```python
266
+ async def collect() -> list[int]:
267
+ pipeline = current_pipeline()
268
+ handles = [pipeline.start(fetch, page) async for page in pages()]
269
+ return [await handle.wait() for handle in handles]
270
+
271
+
272
+ async with TaskPipeline() as pipeline:
273
+ collected = pipeline.start(collect, task_name='collect')
274
+ total = pipeline.start(summarise, collected, task_name='total')
275
+ ```
276
+
277
+ `collected` exists immediately, so `total` can be wired before a single page has been fetched. `collect` gives up its slot on each `wait()`, so it doesn't occupy one while the tasks it started are running.
278
+
279
+ ### Extra arguments for a fan-out
280
+
281
+ `start_many` always calls `fn(item)`, so arguments that are the same for every sub-task go on the callable with `functools.partial`, by keyword or as a positional prefix:
282
+
283
+ ```python
284
+ pipeline.start_many(functools.partial(fetch, retries=3), urls, task_name='fetch')
285
+ pipeline.start_many(functools.partial(prefixed, 'item-'), values, task_name='prefixed')
286
+ ```
287
+
288
+ Arguments that differ per sub-task belong in the item itself (a tuple, a dataclass or a dict) rather than in a partial.
289
+
290
+ A `TaskHandle` bound into a partial is **not** replaced by its result, and no dependency is recorded: only the direct arguments of `start()` and `start_many()` are inspected, and a partial is a container like any other. The callable receives the handle object itself. That object is still usable, and you have three options:
291
+
292
+ **Resolve it a level up**, where it is a direct argument. The sub-tasks then depend on it, and an upstream failure discards them before they run:
293
+
294
+ ```python
295
+ async def fan_out(config: Config) -> list[str]:
296
+ pipeline = current_pipeline()
297
+ return await pipeline.start_many(functools.partial(fetch, config=config), urls).wait()
298
+
299
+
300
+ pipeline.start(fan_out, config_handle)
301
+ ```
302
+
303
+ **Declare the dependency the partial hid**, and read the result directly. The sub-tasks then wait for it, so `result()` is safe:
304
+
305
+ ```python
306
+ pipeline.start_many(functools.partial(fetch, config=config_handle), urls, depends_on=[config_handle])
307
+ ```
308
+
309
+ Without that `depends_on`, nothing orders the two tasks, and `result()` raises `RuntimeError: Task '...' has not completed.` whenever the upstream task hasn't happened to finish first. Note that the handle is now named twice and nothing checks the two agree: bind one and list another, and you silently get ordering against the wrong task.
310
+
311
+ **Await it inside the callable.** `await handle.wait()` works on a bound handle exactly as it does anywhere else. No `depends_on` is needed, because the wait does the ordering:
312
+
313
+ ```python
314
+ async def fetch(url: str, *, config_handle: TaskHandle[Config]) -> str:
315
+ config = await config_handle.wait()
316
+ ...
317
+ ```
318
+
319
+ The three differ on failure, so pick deliberately. The first two make the task a *dependent*: an upstream failure discards it. Awaiting makes the task an *awaiter*: the exception is delivered to it, and `try`/`except` works. Whether the pipeline carries on is decided by who *owns* the failing task (see [Failure semantics](#failure-semantics)). Start the family from inside one task and the failure is held for that task while its awaiters deal with it; start it from the scope body and there is nobody to hold it, so the pipeline ends.
320
+
321
+ ### Logging
322
+
323
+ The library logs its lifecycle at `DEBUG` on the `async_task_pipeline.pipeline` logger: each task started with its dependency count, each task's start and wall-clock duration, failures, discards and a closing tally. It attaches a `NullHandler`, so nothing is emitted until you configure logging yourself.
324
+
325
+ ```python
326
+ import logging
327
+
328
+ logging.basicConfig(level=logging.DEBUG)
329
+ ```
330
+
331
+ ```
332
+ Pipeline opened with concurrency limit 2.
333
+ Started task 'items[0]' with 0 pending dependencies.
334
+ Started task 'items' with 2 pending dependencies.
335
+ Task 'items[0]' started.
336
+ Task 'items[0]' finished in 0.010 s.
337
+ Task 'failing' failed: ValueError('bad payload')
338
+ Pipeline closed: 3 of 4 tasks completed.
339
+ ```
340
+
341
+ ## API reference
342
+
343
+ ### `TaskPipeline(concurrency_limit=10)`
344
+
345
+ An async context manager. `concurrency_limit` bounds how many tasks *execute* at once; a task inside `wait()` does not count against it. A pipeline can be opened once, and starting a task outside its scope raises `RuntimeError`.
346
+
347
+ | Method | Returns | Description |
348
+ |---|---|---|
349
+ | `start(fn, *args, task_name=None, depends_on=(), **kwargs)` | `TaskHandle[T]` | Start `fn(*args, **kwargs)` once its dependencies have resolved. Every `TaskHandle` passed directly in `args` or `kwargs` is a dependency and is replaced by its result; handles nested in lists, dicts or partials are not. |
350
+ | `start_many(fn, inputs, *, task_name=None, depends_on=())` | `TaskHandle[list[T]]` | Start `fn(item)` for each item in `inputs`. The result is the list of results, in input order. |
351
+ | `gather(*handles, task_name=None)` | `TaskHandle[list[T]]` | Combine individually started tasks into one handle whose result is the list of theirs, in argument order. |
352
+ | `results()` | `dict[str, Any]` | Every completed task's result, keyed by task name. Callable at any time: partial while running, and after a failure it still holds whatever was produced. |
353
+
354
+ `depends_on` lists tasks to wait for without receiving their results.
355
+
356
+ **Task names** are optional. A name defaults to the callable's `__name__` (looking through `functools.partial`), made unique with a `#N` suffix, and `start_many` names its sub-tasks `f'{task_name}[{i}]'`. An unnamed fan-out over `fetch` therefore reads `fetch`, `fetch[0]`, `fetch[1]`.
357
+
358
+ ### `TaskHandle[T]`
359
+
360
+ Returned by every `start` call, and generic in the callable's return type: `start(fetch)` on an `async def fetch(...) -> int` gives a `TaskHandle[int]`. Pass it as an argument to another task to wire a dependency.
361
+
362
+ | Member | Description |
363
+ |---|---|
364
+ | `result()` | The task's return value. Re-raises the task's exception if it failed. Raises `RuntimeError` if the task has not completed, was cancelled, or was discarded because a dependency did not complete. |
365
+ | `await wait()` | Wait for the result from inside a running task, handing the concurrency slot back meanwhile. Raises the task's exception if it failed. |
366
+ | `done()` | Whether the task has settled: with a result, a failure or a discard. |
367
+ | `name` | The task's unique name, as used in `results()`. |
368
+
369
+ ### `current_pipeline()`
370
+
371
+ Returns the running pipeline from inside a task, so a task can start further work without being passed the pipeline. Raises `RuntimeError` if no pipeline is open in the current task.
372
+
373
+ ## Development
374
+
375
+ This project was developed with the help of Claude (Anthropic).
376
+
377
+ The project uses [uv](https://docs.astral.sh/uv/). Set up the environment and the pre-commit hooks with:
378
+
379
+ ```bash
380
+ uv sync
381
+ uv run pre-commit install
382
+ ```
383
+
384
+ * **Run tests**: `uv run pytest tests/`
385
+ * **Run linter**: `uv run ruff check .`
386
+ * **Run formatter**: `uv run ruff format --check .`
387
+ * **Run type checker**: `uv run mypy .`
388
+
389
+ ## License
390
+
391
+ Released under the MIT License. The full text is in the `LICENSE` file.