ttasks 0.2.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.
- ttasks-0.2.0/.github/workflows/docs.yml +52 -0
- ttasks-0.2.0/.github/workflows/publish.yml +50 -0
- ttasks-0.2.0/.gitignore +27 -0
- ttasks-0.2.0/.python-version +1 -0
- ttasks-0.2.0/.vscode/launch.json +12 -0
- ttasks-0.2.0/.vscode/settings.json +6 -0
- ttasks-0.2.0/PKG-INFO +511 -0
- ttasks-0.2.0/README.md +503 -0
- ttasks-0.2.0/main.py +116 -0
- ttasks-0.2.0/pyproject.toml +60 -0
- ttasks-0.2.0/scripts/preflight.py +201 -0
- ttasks-0.2.0/src/ttasks/__init__.py +53 -0
- ttasks-0.2.0/src/ttasks/_events.py +96 -0
- ttasks-0.2.0/src/ttasks/_exceptions.py +45 -0
- ttasks-0.2.0/src/ttasks/_executor.py +645 -0
- ttasks-0.2.0/src/ttasks/_graph.py +409 -0
- ttasks-0.2.0/src/ttasks/_sqlite.py +587 -0
- ttasks-0.2.0/src/ttasks/_store.py +174 -0
- ttasks-0.2.0/src/ttasks/_task.py +398 -0
- ttasks-0.2.0/src/ttasks/_version.py +24 -0
- ttasks-0.2.0/src/ttasks/py.typed +0 -0
- ttasks-0.2.0/tests/conftest.py +17 -0
- ttasks-0.2.0/tests/test_e2e.py +446 -0
- ttasks-0.2.0/tests/test_events.py +136 -0
- ttasks-0.2.0/tests/test_executor.py +1409 -0
- ttasks-0.2.0/tests/test_public_api.py +77 -0
- ttasks-0.2.0/tests/test_sqlite_store.py +472 -0
- ttasks-0.2.0/tests/test_store.py +178 -0
- ttasks-0.2.0/tests/test_task.py +538 -0
- ttasks-0.2.0/tests/test_task_factories.py +68 -0
- ttasks-0.2.0/tests/test_workflow.py +1121 -0
- ttasks-0.2.0/uv.lock +497 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: Docs
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
pages: write
|
|
11
|
+
id-token: write
|
|
12
|
+
|
|
13
|
+
concurrency:
|
|
14
|
+
group: pages
|
|
15
|
+
cancel-in-progress: false
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
build:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- name: Check out repository
|
|
22
|
+
uses: actions/checkout@v4
|
|
23
|
+
|
|
24
|
+
- name: Set up uv
|
|
25
|
+
uses: astral-sh/setup-uv@v5
|
|
26
|
+
with:
|
|
27
|
+
enable-cache: true
|
|
28
|
+
|
|
29
|
+
- name: Set up Python
|
|
30
|
+
run: uv python install 3.12
|
|
31
|
+
|
|
32
|
+
- name: Install dependencies
|
|
33
|
+
run: uv sync --group dev
|
|
34
|
+
|
|
35
|
+
- name: Build API documentation
|
|
36
|
+
run: uv run pdoc ttasks --output-directory site
|
|
37
|
+
|
|
38
|
+
- name: Upload Pages artifact
|
|
39
|
+
uses: actions/upload-pages-artifact@v3
|
|
40
|
+
with:
|
|
41
|
+
path: site
|
|
42
|
+
|
|
43
|
+
deploy:
|
|
44
|
+
needs: build
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
environment:
|
|
47
|
+
name: github-pages
|
|
48
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
49
|
+
steps:
|
|
50
|
+
- name: Deploy to GitHub Pages
|
|
51
|
+
id: deployment
|
|
52
|
+
uses: actions/deploy-pages@v4
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- name: Check out repository
|
|
13
|
+
uses: actions/checkout@v4
|
|
14
|
+
with:
|
|
15
|
+
fetch-depth: 0
|
|
16
|
+
|
|
17
|
+
- name: Set up uv
|
|
18
|
+
uses: astral-sh/setup-uv@v5
|
|
19
|
+
with:
|
|
20
|
+
enable-cache: true
|
|
21
|
+
|
|
22
|
+
- name: Set up Python
|
|
23
|
+
run: uv python install 3.12
|
|
24
|
+
|
|
25
|
+
- name: Build sdist and wheel
|
|
26
|
+
run: uv build
|
|
27
|
+
|
|
28
|
+
- name: Upload build artifacts
|
|
29
|
+
uses: actions/upload-artifact@v4
|
|
30
|
+
with:
|
|
31
|
+
name: dist
|
|
32
|
+
path: dist/
|
|
33
|
+
|
|
34
|
+
publish:
|
|
35
|
+
needs: build
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
environment:
|
|
38
|
+
name: pypi
|
|
39
|
+
url: https://pypi.org/p/ttasks
|
|
40
|
+
permissions:
|
|
41
|
+
id-token: write
|
|
42
|
+
steps:
|
|
43
|
+
- name: Download build artifacts
|
|
44
|
+
uses: actions/download-artifact@v4
|
|
45
|
+
with:
|
|
46
|
+
name: dist
|
|
47
|
+
path: dist/
|
|
48
|
+
|
|
49
|
+
- name: Publish to PyPI
|
|
50
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
ttasks-0.2.0/.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
# Test / coverage artifacts
|
|
13
|
+
.pytest_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
site/
|
|
17
|
+
|
|
18
|
+
# Tool caches
|
|
19
|
+
.ruff_cache/
|
|
20
|
+
.ty/
|
|
21
|
+
|
|
22
|
+
# Editor / OS noise
|
|
23
|
+
.DS_Store
|
|
24
|
+
|
|
25
|
+
# Demo artifacts
|
|
26
|
+
ttasks-demo.db
|
|
27
|
+
src/ttasks/_version.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
ttasks-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ttasks
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: github-copilot-sdk>=0.1.0
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# ttasks
|
|
10
|
+
|
|
11
|
+
A small Python task ledger, executor, and DAG workflow library.
|
|
12
|
+
|
|
13
|
+
`ttasks` models work as `Task` objects, executes them through a configurable
|
|
14
|
+
`TaskExecutor`, persists them through an optional `Store`, and runs simple
|
|
15
|
+
dependency graphs with `TaskGraph`.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Python 3.12+
|
|
20
|
+
- `uv` for the development workflow used by this repository
|
|
21
|
+
- GitHub Copilot authentication for `TaskType.PROMPT` tasks
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from ttasks import Task, TaskExecutor
|
|
27
|
+
|
|
28
|
+
executor = TaskExecutor()
|
|
29
|
+
task = Task.bash("echo hello", title="Say hello")
|
|
30
|
+
|
|
31
|
+
result = executor.execute(task)
|
|
32
|
+
|
|
33
|
+
assert task.is_done
|
|
34
|
+
assert result.output == "hello\n"
|
|
35
|
+
assert task.result is result
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Core concepts
|
|
39
|
+
|
|
40
|
+
### Task
|
|
41
|
+
|
|
42
|
+
A `Task` is the unit of work tracked by the system.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from ttasks import Task
|
|
46
|
+
|
|
47
|
+
task = Task.bash(
|
|
48
|
+
"ls -la",
|
|
49
|
+
title="List files",
|
|
50
|
+
description="Show files in the current directory",
|
|
51
|
+
)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Convenience factories `Task.bash()`, `Task.powershell()`, `Task.prompt()`, and
|
|
55
|
+
`Task.agent()` create tasks of the corresponding `TaskType` without making
|
|
56
|
+
callers import `TaskType`.
|
|
57
|
+
|
|
58
|
+
Task status is read-only from the outside. Use `transition_to()` for explicit
|
|
59
|
+
state-machine transitions or `cancel()` for cancellation. Once a task reaches
|
|
60
|
+
`DONE`, normal public field assignment is rejected so completed tasks can be
|
|
61
|
+
safely shared by reference with downstream handlers.
|
|
62
|
+
|
|
63
|
+
Valid lifecycle states are:
|
|
64
|
+
|
|
65
|
+
- `PENDING`
|
|
66
|
+
- `RUNNING`
|
|
67
|
+
- `DONE`
|
|
68
|
+
- `FAILED`
|
|
69
|
+
- `CANCELLED`
|
|
70
|
+
|
|
71
|
+
### TaskResult
|
|
72
|
+
|
|
73
|
+
Every terminal execution path attaches a `TaskResult` to `task.result`:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
result = executor.execute(task)
|
|
77
|
+
|
|
78
|
+
print(result.status)
|
|
79
|
+
print(result.output)
|
|
80
|
+
print(result.error)
|
|
81
|
+
print(result.returncode)
|
|
82
|
+
print(result.started_at)
|
|
83
|
+
print(result.finished_at)
|
|
84
|
+
print(result.duration)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`started_at` and `finished_at` are wall-clock `datetime` values. `duration` is
|
|
88
|
+
measured with a monotonic clock and reported in seconds.
|
|
89
|
+
|
|
90
|
+
For subprocess tasks, `TaskResult.raw` is the underlying
|
|
91
|
+
`subprocess.CompletedProcess`.
|
|
92
|
+
|
|
93
|
+
### Store
|
|
94
|
+
|
|
95
|
+
A `Store` is the single seam between live runtime objects (`Task`,
|
|
96
|
+
`TaskGraph`) and any durable backend. It exposes two `MutableMapping`-style
|
|
97
|
+
collections keyed by the object's own immutable ID:
|
|
98
|
+
|
|
99
|
+
- `store.tasks` — task storage
|
|
100
|
+
- `store.graphs` — graph storage
|
|
101
|
+
|
|
102
|
+
`InMemoryStore` keeps live references; reads return the same objects you
|
|
103
|
+
saved:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from ttasks import InMemoryStore, Task, TaskGraph
|
|
107
|
+
|
|
108
|
+
store = InMemoryStore()
|
|
109
|
+
task = Task.bash("echo hi", title="hello")
|
|
110
|
+
store.tasks.save(task)
|
|
111
|
+
assert store.tasks[task.id] is task
|
|
112
|
+
|
|
113
|
+
graph = TaskGraph(title="build")
|
|
114
|
+
graph.add(task)
|
|
115
|
+
store.graphs.save(graph)
|
|
116
|
+
assert graph in store.graphs
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`save()` is the canonical write idiom — it stores the object under its own
|
|
120
|
+
immutable ID. The collections also implement the full `MutableMapping`
|
|
121
|
+
protocol (`__iter__`, `__getitem__`, `__contains__`, `__setitem__`,
|
|
122
|
+
`.values()`, `.items()`, etc.) so comprehensions, `dict(store.tasks)`, and
|
|
123
|
+
membership tests work naturally:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
done = [t for t in store.tasks.values() if t.is_done]
|
|
127
|
+
assert task in store.tasks # id-membership shortcut
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### SQLiteStore
|
|
131
|
+
|
|
132
|
+
`SQLiteStore` is the durable backend with the same surface as
|
|
133
|
+
`InMemoryStore`. A single SQLite file holds both tasks and graphs.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from ttasks import Task, TaskGraph, TaskExecutor
|
|
137
|
+
from ttasks.storage.sqlite import SQLiteStore
|
|
138
|
+
|
|
139
|
+
store = SQLiteStore("ttasks.db")
|
|
140
|
+
|
|
141
|
+
build = Task.bash("echo build", title="build")
|
|
142
|
+
test = Task.bash("echo test", title="test")
|
|
143
|
+
|
|
144
|
+
graph = TaskGraph(title="build pipeline")
|
|
145
|
+
graph.add(build)
|
|
146
|
+
graph.add(test, after=[build])
|
|
147
|
+
|
|
148
|
+
# Atomic: graph metadata + edges + member task snapshots in one transaction.
|
|
149
|
+
store.graphs.save(graph)
|
|
150
|
+
|
|
151
|
+
executor = TaskExecutor(store=store)
|
|
152
|
+
graph.run(executor) # tasks auto-persist on each transition
|
|
153
|
+
|
|
154
|
+
restored = SQLiteStore("ttasks.db").tasks[build.id]
|
|
155
|
+
assert restored.status.value == "done"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Reads return detached snapshots: mutating a loaded object does not write
|
|
159
|
+
through, so call `save()` again after later changes. `TaskResult.raw` is
|
|
160
|
+
intentionally not persisted because raw handler objects are not generally
|
|
161
|
+
serializable.
|
|
162
|
+
|
|
163
|
+
Deleting a graph removes graph metadata and edges but leaves member tasks in
|
|
164
|
+
the task collection so other graphs can still reach them. Graphs are stored
|
|
165
|
+
topology-only; run-scoped views such as `graph.blocked` and `graph.errors`
|
|
166
|
+
are not persisted, but each task's terminal status and `TaskResult` are
|
|
167
|
+
restored through `store.tasks`.
|
|
168
|
+
|
|
169
|
+
### Auto-persistence
|
|
170
|
+
|
|
171
|
+
When `TaskExecutor` is constructed with a `store`, it auto-saves the task to
|
|
172
|
+
`store.tasks` on every lifecycle transition (`RUNNING`, `DONE`, `FAILED`,
|
|
173
|
+
`CANCELLED`). Saves run *before* the corresponding lifecycle event is emitted,
|
|
174
|
+
so subscribers reading the store see a consistent view.
|
|
175
|
+
|
|
176
|
+
Persistence failures are isolated from execution: they are recorded on
|
|
177
|
+
`executor.persistence_errors` and emitted as
|
|
178
|
+
`TaskEventType.PERSISTENCE_FAILED` events. A failing save does not propagate
|
|
179
|
+
out of `execute()` and does not transition the task to `FAILED`.
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
from ttasks import TaskExecutor
|
|
183
|
+
from ttasks.storage.sqlite import SQLiteStore
|
|
184
|
+
|
|
185
|
+
store = SQLiteStore("ttasks.db")
|
|
186
|
+
executor = TaskExecutor(store=store)
|
|
187
|
+
# Every task executed through this executor is durably persisted automatically.
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### TaskExecutor
|
|
191
|
+
|
|
192
|
+
`TaskExecutor` dispatches tasks to handlers registered by `TaskType`. The
|
|
193
|
+
default constructor pre-registers built-in handlers for every `TaskType`:
|
|
194
|
+
|
|
195
|
+
- `TaskType.BASH`
|
|
196
|
+
- `TaskType.POWERSHELL`
|
|
197
|
+
- `TaskType.PROMPT`
|
|
198
|
+
- `TaskType.AGENT`
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from ttasks import TaskExecutor, TaskType
|
|
202
|
+
|
|
203
|
+
executor = TaskExecutor()
|
|
204
|
+
executor.register(TaskType.BASH, lambda context: "handled") # override
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`PROMPT` uses the GitHub Copilot SDK for a no-tools, single-turn text prompt.
|
|
208
|
+
`AGENT` uses the SDK for a tool-capable, single-turn instruction with permission
|
|
209
|
+
requests approved automatically. Treat `AGENT` payloads as trusted executable
|
|
210
|
+
instructions, similar to `BASH` payloads.
|
|
211
|
+
|
|
212
|
+
Use `TaskExecutor.empty()` to construct an executor with no built-in handlers
|
|
213
|
+
when you want a clean slate:
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
executor = TaskExecutor.empty()
|
|
217
|
+
executor.register(TaskType.BASH, my_handler)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Handler contract:
|
|
221
|
+
|
|
222
|
+
- returning a value means success
|
|
223
|
+
- raising `TaskCancelled` means cancellation
|
|
224
|
+
- raising any other exception means failure
|
|
225
|
+
- handlers should not mutate task lifecycle state directly
|
|
226
|
+
- `context.upstream` exposes direct upstream task refs keyed by task ID
|
|
227
|
+
|
|
228
|
+
For single-task execution, upstream refs can be passed manually:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
executor.execute(child_task, upstream={parent_task.id: parent_task})
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Prompt tasks
|
|
235
|
+
|
|
236
|
+
Prompt tasks send `Task.payload` to Copilot and store the assistant message text
|
|
237
|
+
in `TaskResult.output`:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from ttasks import Task, TaskExecutor
|
|
241
|
+
|
|
242
|
+
executor = TaskExecutor()
|
|
243
|
+
task = Task.prompt(
|
|
244
|
+
"Explain a DAG in one concise sentence.",
|
|
245
|
+
title="Explain DAGs",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
result = executor.execute(task)
|
|
249
|
+
print(result.output)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Prompt task behavior:
|
|
253
|
+
|
|
254
|
+
- default model: `gpt-5.4-mini`
|
|
255
|
+
- no tools are exposed to the Copilot session
|
|
256
|
+
- `Task.timeout` overrides the prompt handler's default wait timeout
|
|
257
|
+
- users must already be authenticated with GitHub Copilot
|
|
258
|
+
|
|
259
|
+
Register a custom Copilot prompt handler to choose a different model or default
|
|
260
|
+
timeout:
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
from ttasks import TaskType, make_copilot_prompt_handler
|
|
264
|
+
|
|
265
|
+
executor.register(
|
|
266
|
+
TaskType.PROMPT,
|
|
267
|
+
make_copilot_prompt_handler(model="gpt-5", timeout=120),
|
|
268
|
+
)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Agent tasks
|
|
272
|
+
|
|
273
|
+
Agent tasks send `Task.payload` to Copilot with the SDK's default tools enabled
|
|
274
|
+
and permission requests approved automatically:
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
from ttasks import Task, TaskExecutor
|
|
278
|
+
|
|
279
|
+
executor = TaskExecutor()
|
|
280
|
+
task = Task.agent(
|
|
281
|
+
"Read README.md and summarize this project in one paragraph.",
|
|
282
|
+
title="Inspect README",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
result = executor.execute(task)
|
|
286
|
+
print(result.output)
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Agent task behavior:
|
|
290
|
+
|
|
291
|
+
- default model: `gpt-5.5`
|
|
292
|
+
- Copilot SDK default tools are available
|
|
293
|
+
- permission requests are approved automatically
|
|
294
|
+
- no handler-level timeout is applied unless `Task.timeout` is set
|
|
295
|
+
- users must already be authenticated with GitHub Copilot
|
|
296
|
+
|
|
297
|
+
Register a custom Copilot agent handler to choose a different model:
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
from ttasks import TaskType, make_copilot_agent_handler
|
|
301
|
+
|
|
302
|
+
executor.register(
|
|
303
|
+
TaskType.AGENT,
|
|
304
|
+
make_copilot_agent_handler(model="gpt-5"),
|
|
305
|
+
)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Event stream
|
|
309
|
+
|
|
310
|
+
Every executor has an `EventBus` for task lifecycle events:
|
|
311
|
+
|
|
312
|
+
```python
|
|
313
|
+
from ttasks import TaskEvent, TaskEventType
|
|
314
|
+
|
|
315
|
+
seen: list[TaskEvent] = []
|
|
316
|
+
|
|
317
|
+
with executor.events.subscribed(seen.append):
|
|
318
|
+
executor.execute(task)
|
|
319
|
+
|
|
320
|
+
assert [event.type for event in seen] == [
|
|
321
|
+
TaskEventType.STARTED,
|
|
322
|
+
TaskEventType.SUCCEEDED,
|
|
323
|
+
]
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
For long-lived subscribers, use `executor.events.subscribe(callback)`, which
|
|
327
|
+
returns an idempotent unsubscribe callable.
|
|
328
|
+
|
|
329
|
+
Events include:
|
|
330
|
+
|
|
331
|
+
- `type`: `STARTED`, `SUCCEEDED`, `FAILED`, `CANCELLED`, or `PERSISTENCE_FAILED`
|
|
332
|
+
- `task_id`
|
|
333
|
+
- `task`: the live task object
|
|
334
|
+
- `previous_status`
|
|
335
|
+
- `status`: the task status at event time
|
|
336
|
+
- `timestamp`
|
|
337
|
+
- `error`, when relevant
|
|
338
|
+
|
|
339
|
+
Subscriber exceptions do not fail task execution. They are recorded on
|
|
340
|
+
`executor.events.errors` so observers cannot break the work they observe.
|
|
341
|
+
|
|
342
|
+
## Timeout policy
|
|
343
|
+
|
|
344
|
+
`Task.timeout` defaults to `None` intentionally.
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
Task.bash("sleep 30", title="Long task")
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
`None` means no automatic timeout is applied. The subprocess is allowed to run
|
|
351
|
+
until it exits unless another caller cancels it with:
|
|
352
|
+
|
|
353
|
+
```python
|
|
354
|
+
executor.cancel(task)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Use a positive timeout for bounded subprocess execution:
|
|
358
|
+
|
|
359
|
+
```python
|
|
360
|
+
Task.bash("sleep 30", title="Bounded task", timeout=5)
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
If the timeout is exceeded, the executor terminates the subprocess, marks the
|
|
364
|
+
task `FAILED`, stores the timeout message in `task.error`, attaches a failed
|
|
365
|
+
`TaskResult`, and raises `TaskTimeoutError`.
|
|
366
|
+
|
|
367
|
+
`TaskTimeoutError` subclasses `TimeoutError`, so callers can catch either the
|
|
368
|
+
specific ttasks exception or the standard timeout base class.
|
|
369
|
+
|
|
370
|
+
## Error handling
|
|
371
|
+
|
|
372
|
+
### Subprocess failures
|
|
373
|
+
|
|
374
|
+
A non-zero subprocess exit raises `TaskExecutionError`, marks the task `FAILED`,
|
|
375
|
+
and preserves structured process details:
|
|
376
|
+
|
|
377
|
+
```python
|
|
378
|
+
from ttasks import Task, TaskExecutionError
|
|
379
|
+
|
|
380
|
+
task = Task.bash("echo boom >&2; exit 7", title="Fail")
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
executor.execute(task)
|
|
384
|
+
except TaskExecutionError:
|
|
385
|
+
assert task.is_failed
|
|
386
|
+
assert task.result is not None
|
|
387
|
+
print(task.result.error)
|
|
388
|
+
print(task.result.returncode)
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
For failed subprocesses, `task.result` includes:
|
|
392
|
+
|
|
393
|
+
- captured stdout
|
|
394
|
+
- captured stderr or fallback error text
|
|
395
|
+
- return code
|
|
396
|
+
- raw `CompletedProcess`
|
|
397
|
+
|
|
398
|
+
### Cancellation
|
|
399
|
+
|
|
400
|
+
Cancel a task through the executor to also terminate any active subprocess:
|
|
401
|
+
|
|
402
|
+
```python
|
|
403
|
+
executor.cancel(task)
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Handlers may cooperatively abort by raising `TaskCancelled`. The executor owns
|
|
407
|
+
the transition to `CANCELLED` and records the terminal `TaskResult`.
|
|
408
|
+
|
|
409
|
+
## DAG workflows
|
|
410
|
+
|
|
411
|
+
`TaskGraph` runs tasks as a directed acyclic graph. Dependencies must be
|
|
412
|
+
registered in the graph before `run()`.
|
|
413
|
+
|
|
414
|
+
```python
|
|
415
|
+
from ttasks import TaskGraph, Task, TaskExecutor
|
|
416
|
+
|
|
417
|
+
build = Task.bash("echo build", title="Build")
|
|
418
|
+
test = Task.bash("echo test", title="Test")
|
|
419
|
+
package = Task.bash("echo package", title="Package")
|
|
420
|
+
|
|
421
|
+
graph = TaskGraph(title="build pipeline")
|
|
422
|
+
graph.add(build)
|
|
423
|
+
graph.add(test, after=[build])
|
|
424
|
+
graph.add(package, after=[test])
|
|
425
|
+
|
|
426
|
+
graph.run(TaskExecutor())
|
|
427
|
+
|
|
428
|
+
assert graph.ok
|
|
429
|
+
assert graph.succeeded == [build, test, package]
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Useful graph views:
|
|
433
|
+
|
|
434
|
+
- `graph.succeeded`
|
|
435
|
+
- `graph.failed`
|
|
436
|
+
- `graph.cancelled`
|
|
437
|
+
- `graph.blocked`
|
|
438
|
+
- `graph.errors`
|
|
439
|
+
- `graph.roots()`
|
|
440
|
+
- `graph.leaves()`
|
|
441
|
+
|
|
442
|
+
If a task fails or is cancelled, downstream tasks are blocked and not submitted.
|
|
443
|
+
Executor/setup errors raised by submitted futures are available in
|
|
444
|
+
`graph.errors`, keyed by task ID.
|
|
445
|
+
|
|
446
|
+
Already-`DONE` tasks count as satisfied dependencies, so a graph can be rerun or
|
|
447
|
+
extended after partial completion.
|
|
448
|
+
|
|
449
|
+
When a graph submits a task, its handler receives direct dependency task refs in
|
|
450
|
+
`context.upstream`. The refs come from the graph itself and are keyed by task ID:
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
def handler(context):
|
|
454
|
+
parent = context.upstream[build.id]
|
|
455
|
+
assert parent.result is not None
|
|
456
|
+
return parent.result.output.upper()
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
Only direct dependencies are included. If a task needs an earlier ancestor, add
|
|
460
|
+
that ancestor as an explicit graph dependency.
|
|
461
|
+
|
|
462
|
+
### Finally tasks
|
|
463
|
+
|
|
464
|
+
Use `graph.add(..., finally_=True)` for reporting or cleanup tasks that should
|
|
465
|
+
run after other tasks are no longer active, even when those tasks failed or
|
|
466
|
+
were blocked:
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
recommend = Task.prompt(
|
|
470
|
+
"Summarize preflight output",
|
|
471
|
+
title="Recommend next action",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
graph.add(
|
|
475
|
+
recommend,
|
|
476
|
+
after=[lint, test, docs],
|
|
477
|
+
finally_=True,
|
|
478
|
+
required=False,
|
|
479
|
+
)
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
A finally task receives the listed `after` tasks through `context.upstream`, just
|
|
483
|
+
like normal dependencies. `required=False` makes failures visible in graph views
|
|
484
|
+
without making `graph.ok` false, which is useful for optional reporting tasks
|
|
485
|
+
such as AI recommendations or artifact collection.
|
|
486
|
+
|
|
487
|
+
## Documentation
|
|
488
|
+
|
|
489
|
+
API documentation is generated from docstrings with `pdoc` and published to
|
|
490
|
+
GitHub Pages by `.github/workflows/docs.yml`.
|
|
491
|
+
|
|
492
|
+
Build the docs locally:
|
|
493
|
+
|
|
494
|
+
```bash
|
|
495
|
+
uv run pdoc ttasks --output-directory site
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
## Development
|
|
499
|
+
|
|
500
|
+
Run the full test suite:
|
|
501
|
+
|
|
502
|
+
```bash
|
|
503
|
+
uv run pytest
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Run linting and type checks:
|
|
507
|
+
|
|
508
|
+
```bash
|
|
509
|
+
uv run ruff check .
|
|
510
|
+
uv run ty check
|
|
511
|
+
```
|