ttasks 0.2.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.
@@ -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
+ ```
@@ -0,0 +1,13 @@
1
+ ttasks/__init__.py,sha256=rVCer-0TXz8fIH5Oa-DSuG4WEIeZDrMLE5svNcfmNJY,1469
2
+ ttasks/_events.py,sha256=Lsh9P5o23UHAcieQwlH6y4e69cta3rPmg9EhBMhkEOo,2886
3
+ ttasks/_exceptions.py,sha256=YHJCh34VP8c4hBn8DEl2NenUbXsWqNNjsXXaSPA6MF4,1438
4
+ ttasks/_executor.py,sha256=ByBte_jO1i1El4cuQmQzIPGsQ7h7_dwaMn4gu21rL7I,24119
5
+ ttasks/_graph.py,sha256=k7Tp6ijYgczlO9DUzfjGd7jrC9NLYjjX8mvapeFcUUE,16608
6
+ ttasks/_sqlite.py,sha256=SETX7YGt8p_tDWmzKk86OI3eZwqQU77rh4LsJ8hfRc0,21943
7
+ ttasks/_store.py,sha256=S-sz5cUOuzutvJEqCek7DEHroHNczny2f6-W-6-42x8,6352
8
+ ttasks/_task.py,sha256=XYU-XRTbDxbbwiQtxhNfDezR6KlXrYgHZ8RpKGUGbsQ,13675
9
+ ttasks/_version.py,sha256=s9U3X54Pdr-Jlh9GP6LBaa_VRos7qs_wtrVefUHHNIA,520
10
+ ttasks/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ ttasks-0.2.0.dist-info/METADATA,sha256=AuX2Z6PYzpCTNjbqCSTMqpnDbRTW_MCcdWnn3TtvxGU,13530
12
+ ttasks-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ ttasks-0.2.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