camas 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.
camas/__init__.py ADDED
@@ -0,0 +1,705 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2026 JP Hutchins
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import functools
8
+ import itertools
9
+ import os
10
+ import shlex
11
+ import time
12
+ from collections.abc import Awaitable, Callable, Iterator, Sequence
13
+ from subprocess import STDOUT
14
+ from typing import Any, Final, NamedTuple, Protocol, assert_never
15
+
16
+
17
+ class VarBinding(NamedTuple):
18
+ """A single matrix variable bound to a concrete value.
19
+
20
+ >>> VarBinding("PY", "3.14")
21
+ VarBinding(name='PY', value='3.14')
22
+ """
23
+
24
+ name: str
25
+ value: str
26
+
27
+
28
+ type MatrixBinding = tuple[VarBinding, ...]
29
+
30
+
31
+ class Task(NamedTuple):
32
+ """A leaf task that executes a shell command.
33
+
34
+ >>> Task("echo hi")
35
+ Task(cmd='echo hi', name=None, env={})
36
+ >>> Task(("ruff", "check", "."), name="lint")
37
+ Task(cmd=('ruff', 'check', '.'), name='lint', env={})
38
+ """
39
+
40
+ cmd: str | tuple[str, ...]
41
+ name: str | None = None
42
+ env: dict[str, str] = {}
43
+
44
+
45
+ class Sequential(NamedTuple):
46
+ """A group of tasks that run one after another, short-circuiting on failure.
47
+
48
+ >>> Sequential(tasks=(Task("build"), Task("test")), name="ci")
49
+ Sequential(tasks=(Task(cmd='build', name=None, env={}), Task(cmd='test', name=None, env={})), name='ci', matrix=None)
50
+ """
51
+
52
+ tasks: tuple[TaskNode, ...]
53
+ name: str | None = None
54
+ matrix: dict[str, tuple[str, ...]] | None = None
55
+
56
+
57
+ class Parallel(NamedTuple):
58
+ """A group of tasks that run concurrently.
59
+
60
+ >>> Parallel(tasks=(Task("lint"), Task("typecheck")))
61
+ Parallel(tasks=(Task(cmd='lint', name=None, env={}), Task(cmd='typecheck', name=None, env={})), name=None, matrix=None)
62
+ """
63
+
64
+ tasks: tuple[TaskNode, ...]
65
+ name: str | None = None
66
+ matrix: dict[str, tuple[str, ...]] | None = None
67
+
68
+
69
+ type TaskNode = Task | Sequential | Parallel
70
+
71
+
72
+ class TaskResult(NamedTuple):
73
+ """Result of a single completed task.
74
+
75
+ >>> TaskResult("lint", 0, 1.234, (b"all clean",))
76
+ TaskResult(name='lint', returncode=0, elapsed=1.234, output=(b'all clean',))
77
+ """
78
+
79
+ name: str
80
+ returncode: int
81
+ elapsed: float
82
+ output: Sequence[bytes]
83
+
84
+
85
+ class RunResult(NamedTuple):
86
+ """Result of running an entire task tree.
87
+
88
+ >>> RunResult(0, (TaskResult("a", 0, 0.1, ()),), 0.1)
89
+ RunResult(returncode=0, results=(TaskResult(name='a', returncode=0, elapsed=0.1, output=()),), elapsed=0.1)
90
+ """
91
+
92
+ returncode: int
93
+ results: tuple[TaskResult, ...]
94
+ elapsed: float
95
+
96
+
97
+ class StartedEvent(NamedTuple):
98
+ """Internal event: a task has started execution.
99
+
100
+ >>> StartedEvent(0, 100.0)
101
+ StartedEvent(leaf_index=0, timestamp=100.0)
102
+ """
103
+
104
+ leaf_index: int
105
+ timestamp: float
106
+
107
+
108
+ class OutputEvent(NamedTuple):
109
+ """Internal event: a task produced an output line.
110
+
111
+ >>> OutputEvent(0, b"hello", 100.5)
112
+ OutputEvent(leaf_index=0, line=b'hello', timestamp=100.5)
113
+ """
114
+
115
+ leaf_index: int
116
+ line: bytes
117
+ timestamp: float
118
+
119
+
120
+ class CompletedEvent(NamedTuple):
121
+ """Internal event: a task finished execution.
122
+
123
+ >>> CompletedEvent(0, 0, 1.0, (b"done",))
124
+ CompletedEvent(leaf_index=0, returncode=0, elapsed=1.0, output=(b'done',))
125
+ """
126
+
127
+ leaf_index: int
128
+ returncode: int
129
+ elapsed: float
130
+ output: Sequence[bytes]
131
+
132
+
133
+ type TaskEvent = StartedEvent | OutputEvent | CompletedEvent
134
+
135
+
136
+ class LeafInfo(NamedTuple):
137
+ """A leaf's position in the task tree: depth and last-sibling chain up to it.
138
+
139
+ >>> LeafInfo(Task("echo hi"), 0, ())
140
+ LeafInfo(task=Task(cmd='echo hi', name=None, env={}), depth=0, is_last_chain=())
141
+ """
142
+
143
+ task: Task
144
+ depth: int
145
+ is_last_chain: tuple[bool, ...]
146
+
147
+
148
+ class Waiting(NamedTuple):
149
+ """Leaf state: task has not started yet.
150
+
151
+ >>> Waiting(Task("echo hi"))
152
+ Waiting(task=Task(cmd='echo hi', name=None, env={}))
153
+ """
154
+
155
+ task: Task
156
+
157
+
158
+ class Running(NamedTuple):
159
+ """Leaf state: task is currently executing.
160
+
161
+ >>> Running(Task("echo hi"), 100.0, b"output")
162
+ Running(task=Task(cmd='echo hi', name=None, env={}), start_time=100.0, last_line=b'output')
163
+ """
164
+
165
+ task: Task
166
+ start_time: float
167
+ last_line: bytes
168
+
169
+
170
+ class Done(NamedTuple):
171
+ """Leaf state: task completed with a result.
172
+
173
+ >>> Done(Task("echo hi"), TaskResult("echo hi", 0, 0.5, ()))
174
+ Done(task=Task(cmd='echo hi', name=None, env={}), result=TaskResult(name='echo hi', returncode=0, elapsed=0.5, output=()))
175
+ """
176
+
177
+ task: Task
178
+ result: TaskResult
179
+
180
+
181
+ class Skipped(NamedTuple):
182
+ """Leaf state: task was skipped due to a prior failure in a Sequential.
183
+
184
+ >>> Skipped(Task("echo hi"))
185
+ Skipped(task=Task(cmd='echo hi', name=None, env={}))
186
+ """
187
+
188
+ task: Task
189
+
190
+
191
+ type LeafState = Waiting | Running | Done | Skipped
192
+
193
+
194
+ type EventSink = Callable[[int, TaskEvent], Awaitable[None]]
195
+ """Per-leaf event dispatcher: await sink(leaf_idx, event)."""
196
+
197
+
198
+ class Effect[T](Protocol):
199
+ """Observer over a run's event stream, with a per-leaf context of type T.
200
+
201
+ Each leaf owns an independent chain: setup seeds every leaf's slot,
202
+ on_event advances the slot for the affected leaf, and teardown receives
203
+ one final T per leaf in leaf-index order. Different leaves' on_event
204
+ calls may run concurrently.
205
+ """
206
+
207
+ async def setup(self, task: TaskNode) -> T:
208
+ """Return the initial per-leaf context."""
209
+ ...
210
+
211
+ async def on_event(self, event: TaskEvent, states: Sequence[LeafState], ctx: T) -> T:
212
+ """Return the next context for the leaf identified by event.leaf_index."""
213
+ ...
214
+
215
+ async def teardown(self, ctxs: tuple[T, ...]) -> None:
216
+ """Receive every leaf's final context, in leaf-index order."""
217
+
218
+
219
+ SKIPPED_RETURNCODE: Final = -1
220
+ AUTO_NAME_MAX_WIDTH: Final = 20
221
+
222
+
223
+ def resolve_cmd(cmd: str | tuple[str, ...]) -> tuple[str, ...]:
224
+ """Resolve a command to a tuple of argv tokens, splitting shell strings with shlex.
225
+
226
+ >>> resolve_cmd(("echo", "hi"))
227
+ ('echo', 'hi')
228
+ >>> resolve_cmd("echo hi")
229
+ ('echo', 'hi')
230
+ """
231
+ match cmd:
232
+ case str():
233
+ return tuple(shlex.split(cmd))
234
+ case tuple():
235
+ return cmd
236
+ case _:
237
+ assert_never(cmd)
238
+
239
+
240
+ def substitute_in_str(text: str, binding: MatrixBinding) -> str:
241
+ """Replace {name} placeholders in a string with binding values.
242
+
243
+ >>> substitute_in_str("test --python {PY}", (VarBinding("PY", "3.14"),))
244
+ 'test --python 3.14'
245
+ >>> substitute_in_str("no placeholders", (VarBinding("X", "1"),))
246
+ 'no placeholders'
247
+ """
248
+ return functools.reduce(
249
+ lambda acc, vb: acc.replace(f"{{{vb.name}}}", vb.value),
250
+ binding,
251
+ text,
252
+ )
253
+
254
+
255
+ def substitute_in_tuple(parts: tuple[str, ...], binding: MatrixBinding) -> tuple[str, ...]:
256
+ """Replace {name} placeholders in each element of a tuple.
257
+
258
+ >>> substitute_in_tuple(("test", "--python", "{PY}"), (VarBinding("PY", "3.14"),))
259
+ ('test', '--python', '3.14')
260
+ """
261
+ return functools.reduce(
262
+ lambda acc, vb: tuple(p.replace(f"{{{vb.name}}}", vb.value) for p in acc),
263
+ binding,
264
+ parts,
265
+ )
266
+
267
+
268
+ def truncate_middle(text: str, max_width: int) -> str:
269
+ """Truncate text in the middle with '...' if it exceeds max_width.
270
+
271
+ Always returns a string of length min(len(text), max(max_width, 0)).
272
+
273
+ >>> truncate_middle("hello", 10)
274
+ 'hello'
275
+ >>> truncate_middle("hello world!", 9)
276
+ 'hel...ld!'
277
+ >>> truncate_middle("built", 2)
278
+ '..'
279
+ >>> truncate_middle("built", 4)
280
+ '...t'
281
+ >>> truncate_middle("built", 0)
282
+ ''
283
+ """
284
+ if len(text) <= max_width:
285
+ return text
286
+ if max_width < 3:
287
+ return "..."[: max(max_width, 0)]
288
+ side: Final = (max_width - 3) // 2
289
+ return text[:side] + "..." + text[len(text) - (max_width - 3 - side) :]
290
+
291
+
292
+ def derive_name(cmd: str | tuple[str, ...], width: int) -> str:
293
+ """Derive a display name from a command, truncated to `width`.
294
+
295
+ >>> derive_name("echo hi", 20)
296
+ 'echo hi'
297
+ >>> derive_name(("python", "-c", "pass"), 20)
298
+ 'python -c pass'
299
+ >>> derive_name("a very long command that exceeds twenty characters", 20)
300
+ 'a very l...haracters'
301
+ """
302
+ return truncate_middle(
303
+ cmd if isinstance(cmd, str) else " ".join(cmd),
304
+ width,
305
+ )
306
+
307
+
308
+ def task_display_name(task: Task) -> str:
309
+ """Return the display name for a Task: explicit `name` or auto-derived.
310
+
311
+ >>> task_display_name(Task("echo hi", name="greet"))
312
+ 'greet'
313
+ >>> task_display_name(Task("echo hi"))
314
+ 'echo hi'
315
+ """
316
+ return task.name if task.name is not None else derive_name(task.cmd, AUTO_NAME_MAX_WIDTH)
317
+
318
+
319
+ def specialize_task(task: Task, binding: MatrixBinding, suffix: str) -> Task:
320
+ """Specialize a leaf Task with concrete variable values from a matrix binding.
321
+
322
+ >>> specialize_task(Task("test {PY}"), (VarBinding("PY", "3.14"),), "[PY=3.14]")
323
+ Task(cmd='test 3.14', name='test 3.14 [PY=3.14]', env={'PY': '3.14'})
324
+ >>> specialize_task(Task("go", env={"VENV": ".venv-{PY}"}), (VarBinding("PY", "3.14"),), "[PY=3.14]").env
325
+ {'VENV': '.venv-3.14', 'PY': '3.14'}
326
+ """
327
+ match task.cmd:
328
+ case str():
329
+ new_cmd: str | tuple[str, ...] = substitute_in_str(task.cmd, binding)
330
+ case tuple():
331
+ new_cmd = substitute_in_tuple(task.cmd, binding)
332
+ case _:
333
+ assert_never(task.cmd)
334
+ return Task(
335
+ cmd=new_cmd,
336
+ name=f"{substitute_in_str(task.name if task.name is not None else derive_name(task.cmd, AUTO_NAME_MAX_WIDTH), binding)} {suffix}",
337
+ env={k: substitute_in_str(v, binding) for k, v in task.env.items()} | dict(binding),
338
+ )
339
+
340
+
341
+ def specialize_node(task: TaskNode, binding: MatrixBinding, suffix: str) -> TaskNode:
342
+ """Recursively specialize an entire task tree with concrete variable values.
343
+
344
+ >>> specialize_node(Task("test {X}"), (VarBinding("X", "1"),), "[X=1]")
345
+ Task(cmd='test 1', name='test 1 [X=1]', env={'X': '1'})
346
+ """
347
+ match task:
348
+ case Task():
349
+ return specialize_task(task, binding, suffix)
350
+ case Sequential(tasks=tasks, name=name):
351
+ return Sequential(
352
+ tasks=tuple(specialize_node(t, binding, suffix) for t in tasks),
353
+ name=f"{name} {suffix}" if name is not None else None,
354
+ )
355
+ case Parallel(tasks=tasks, name=name):
356
+ return Parallel(
357
+ tasks=tuple(specialize_node(t, binding, suffix) for t in tasks),
358
+ name=f"{name} {suffix}" if name is not None else None,
359
+ )
360
+ case _:
361
+ assert_never(task)
362
+
363
+
364
+ def matrix_bindings(matrix: dict[str, tuple[str, ...]]) -> tuple[MatrixBinding, ...]:
365
+ """Generate all cartesian product bindings from a matrix definition.
366
+
367
+ >>> matrix_bindings({"PY": ("3.12", "3.13")})
368
+ ((VarBinding(name='PY', value='3.12'),), (VarBinding(name='PY', value='3.13'),))
369
+ >>> len(matrix_bindings({"A": ("1", "2"), "B": ("x", "y")}))
370
+ 4
371
+ """
372
+ keys: Final = tuple(matrix.keys())
373
+ return tuple(
374
+ tuple(VarBinding(k, v) for k, v in zip(keys, vals))
375
+ for vals in itertools.product(*matrix.values())
376
+ )
377
+
378
+
379
+ def binding_suffix(binding: MatrixBinding) -> str:
380
+ """Format a matrix binding as a bracketed suffix.
381
+
382
+ >>> binding_suffix((VarBinding("PY", "3.14"),))
383
+ '[PY=3.14]'
384
+ >>> binding_suffix((VarBinding("OS", "linux"), VarBinding("PY", "3.14")))
385
+ '[OS=linux, PY=3.14]'
386
+ """
387
+ return "[" + ", ".join(f"{vb.name}={vb.value}" for vb in binding) + "]"
388
+
389
+
390
+ def expand_sequential_matrix(
391
+ children: tuple[TaskNode, ...],
392
+ matrix: dict[str, tuple[str, ...]],
393
+ name: str | None,
394
+ ) -> Parallel:
395
+ """Expand a Sequential's matrix into a Parallel of cloned Sequentials.
396
+
397
+ >>> result = expand_sequential_matrix((Task("build"), Task("test")), {"X": ("1", "2")}, "ci")
398
+ >>> len(result.tasks)
399
+ 2
400
+ >>> all(isinstance(t, Sequential) for t in result.tasks)
401
+ True
402
+ """
403
+ return Parallel(
404
+ tasks=tuple(
405
+ Sequential(
406
+ tasks=tuple(
407
+ specialize_node(child, binding, binding_suffix(binding)) for child in children
408
+ ),
409
+ name=(f"{name} {binding_suffix(binding)}" if name is not None else None),
410
+ )
411
+ for binding in matrix_bindings(matrix)
412
+ ),
413
+ name=name,
414
+ )
415
+
416
+
417
+ def expand_parallel_matrix(
418
+ children: tuple[TaskNode, ...],
419
+ matrix: dict[str, tuple[str, ...]],
420
+ name: str | None,
421
+ ) -> Parallel:
422
+ """Expand a Parallel's matrix into a flat Parallel of all binding × child products.
423
+
424
+ >>> result = expand_parallel_matrix((Task("test"),), {"PY": ("3.12", "3.13")}, None)
425
+ >>> len(result.tasks)
426
+ 2
427
+ """
428
+ return Parallel(
429
+ tasks=tuple(
430
+ specialize_node(child, binding, binding_suffix(binding))
431
+ for binding in matrix_bindings(matrix)
432
+ for child in children
433
+ ),
434
+ name=name,
435
+ )
436
+
437
+
438
+ def expand_matrix(task: TaskNode) -> TaskNode:
439
+ """Recursively expand all matrix parameters in a task tree.
440
+
441
+ >>> expand_matrix(Task("echo hi"))
442
+ Task(cmd='echo hi', name=None, env={})
443
+ >>> result = expand_matrix(Parallel(tasks=(Task("test"),), matrix={"X": ("1", "2")}))
444
+ >>> len(result.tasks) # type: ignore[union-attr]
445
+ 2
446
+ """
447
+ match task:
448
+ case Task():
449
+ return task
450
+ case Sequential(tasks=tasks, matrix=matrix):
451
+ expanded = tuple(expand_matrix(t) for t in tasks)
452
+ if matrix is None:
453
+ return Sequential(tasks=expanded, name=task.name)
454
+ return expand_sequential_matrix(expanded, matrix, task.name)
455
+ case Parallel(tasks=tasks, matrix=matrix):
456
+ expanded = tuple(expand_matrix(t) for t in tasks)
457
+ if matrix is None:
458
+ return Parallel(tasks=expanded, name=task.name)
459
+ return expand_parallel_matrix(expanded, matrix, task.name)
460
+ case _:
461
+ assert_never(task)
462
+
463
+
464
+ def iter_leaves(
465
+ node: TaskNode,
466
+ depth: int,
467
+ is_last_chain: tuple[bool, ...],
468
+ ) -> Iterator[LeafInfo]:
469
+ """Walk a task tree depth-first, yielding LeafInfo for each leaf."""
470
+ match node:
471
+ case Task():
472
+ yield LeafInfo(node, depth, is_last_chain)
473
+ case Sequential(tasks=children) | Parallel(tasks=children):
474
+ last_i: Final = len(children) - 1
475
+ for i, child in enumerate(children):
476
+ yield from iter_leaves(child, depth + 1, (*is_last_chain, i == last_i))
477
+ case _:
478
+ assert_never(node)
479
+
480
+
481
+ def flatten_leaves(task: TaskNode) -> tuple[LeafInfo, ...]:
482
+ """Flatten a task tree into a tuple of LeafInfo in depth-first order.
483
+
484
+ >>> [info.task.cmd for info in flatten_leaves(Parallel(tasks=(Task("a"), Task("b"))))]
485
+ ['a', 'b']
486
+ >>> flatten_leaves(Task("echo hi"))[0].depth
487
+ 0
488
+ >>> flatten_leaves(Parallel(tasks=(Task("a"), Task("b"))))[0].is_last_chain
489
+ (False,)
490
+ >>> flatten_leaves(Parallel(tasks=(Task("a"), Task("b"))))[1].is_last_chain
491
+ (True,)
492
+ """
493
+ return tuple(iter_leaves(task, depth=0, is_last_chain=()))
494
+
495
+
496
+ def build_leaf_index_map(task: TaskNode) -> dict[int, int]:
497
+ """Map `id(Task)` to leaf index (depth-first position) for the whole tree.
498
+
499
+ >>> t1, t2 = Task("a"), Task("b")
500
+ >>> m = build_leaf_index_map(Parallel(tasks=(t1, t2)))
501
+ >>> m[id(t1)], m[id(t2)]
502
+ (0, 1)
503
+ """
504
+ return {id(info.task): i for i, info in enumerate(flatten_leaves(task))}
505
+
506
+
507
+ def subtree_leaf_indices(task: TaskNode, index_map: dict[int, int]) -> tuple[int, ...]:
508
+ """Collect all leaf indices within a subtree.
509
+
510
+ >>> t1, t2 = Task("a"), Task("b")
511
+ >>> tree = Parallel(tasks=(t1, t2))
512
+ >>> subtree_leaf_indices(tree, build_leaf_index_map(tree))
513
+ (0, 1)
514
+ """
515
+ match task:
516
+ case Task():
517
+ return (index_map[id(task)],)
518
+ case Sequential(tasks=tasks) | Parallel(tasks=tasks):
519
+ return tuple(i for child in tasks for i in subtree_leaf_indices(child, index_map))
520
+ case _:
521
+ assert_never(task)
522
+
523
+
524
+ def next_state(state: LeafState, event: TaskEvent) -> LeafState:
525
+ """Pure state machine: apply a TaskEvent to a LeafState to produce the next state.
526
+
527
+ >>> t = Task("echo hi")
528
+ >>> next_state(Waiting(t), StartedEvent(0, 100.0))
529
+ Running(task=Task(cmd='echo hi', name=None, env={}), start_time=100.0, last_line=b'')
530
+ >>> next_state(Running(t, 100.0, b""), OutputEvent(0, b"hi", 100.5))
531
+ Running(task=Task(cmd='echo hi', name=None, env={}), start_time=100.0, last_line=b'hi')
532
+ >>> next_state(Running(t, 100.0, b""), CompletedEvent(0, 0, 0.5, (b"done",)))
533
+ Done(task=Task(cmd='echo hi', name=None, env={}), result=TaskResult(name='echo hi', returncode=0, elapsed=0.5, output=(b'done',)))
534
+ >>> next_state(Waiting(t), CompletedEvent(0, SKIPPED_RETURNCODE, 0.0, ()))
535
+ Skipped(task=Task(cmd='echo hi', name=None, env={}))
536
+ """
537
+ match state:
538
+ case Waiting(task=task):
539
+ match event:
540
+ case StartedEvent(timestamp=ts):
541
+ return Running(task, ts, b"")
542
+ case OutputEvent(line=line, timestamp=ts): # pragma: no cover
543
+ return Running(task, ts, line)
544
+ case CompletedEvent(returncode=rc, elapsed=elapsed, output=output):
545
+ if rc == SKIPPED_RETURNCODE:
546
+ return Skipped(task)
547
+ return Done(
548
+ task, TaskResult(task_display_name(task), rc, elapsed, output)
549
+ ) # pragma: no cover
550
+ case _:
551
+ assert_never(event)
552
+ case Running(task=task, start_time=start):
553
+ match event:
554
+ case OutputEvent(line=line):
555
+ return Running(task, start, line)
556
+ case CompletedEvent(returncode=rc, elapsed=elapsed, output=output):
557
+ if rc == SKIPPED_RETURNCODE: # pragma: no cover
558
+ return Skipped(task)
559
+ return Done(task, TaskResult(task_display_name(task), rc, elapsed, output))
560
+ case StartedEvent(): # pragma: no cover
561
+ return state
562
+ case _:
563
+ assert_never(event)
564
+ case Done() | Skipped(): # pragma: no cover
565
+ return state
566
+ case _:
567
+ assert_never(state)
568
+
569
+
570
+ def _color_env(merged: dict[str, str]) -> dict[str, str]:
571
+ """Inject FORCE_COLOR/CLICOLOR_FORCE unless NO_COLOR is set (which also strips them)."""
572
+ if "NO_COLOR" in merged:
573
+ return {k: v for k, v in merged.items() if k not in {"FORCE_COLOR", "CLICOLOR_FORCE"}}
574
+ return merged | {"FORCE_COLOR": "1", "CLICOLOR_FORCE": "1"}
575
+
576
+
577
+ async def run_cmd(task: Task, leaf_index: int, dispatch: EventSink) -> TaskResult:
578
+ """Run one leaf as a subprocess, dispatching Started/Output/Completed events."""
579
+ start: Final = time.perf_counter()
580
+ await dispatch(leaf_index, StartedEvent(leaf_index, start))
581
+ proc: Final = await asyncio.create_subprocess_exec(
582
+ *resolve_cmd(task.cmd),
583
+ stdout=asyncio.subprocess.PIPE,
584
+ stderr=STDOUT,
585
+ env=_color_env(os.environ | task.env),
586
+ )
587
+ output: Final[list[bytes]] = []
588
+ if proc.stdout is not None: # pragma: no branch
589
+ async for line in proc.stdout:
590
+ output.append(line)
591
+ await dispatch(leaf_index, OutputEvent(leaf_index, line, time.perf_counter()))
592
+ await proc.wait()
593
+ elapsed: Final = time.perf_counter() - start
594
+ rc: Final = proc.returncode or 0
595
+ await dispatch(leaf_index, CompletedEvent(leaf_index, rc, elapsed, output))
596
+ return TaskResult(task_display_name(task), rc, elapsed, output)
597
+
598
+
599
+ async def execute(
600
+ node: TaskNode,
601
+ dispatch: EventSink,
602
+ leaves: tuple[Task, ...],
603
+ index_map: dict[int, int],
604
+ ) -> tuple[TaskResult, ...]:
605
+ """Walk a task subtree, returning one TaskResult per leaf in DFS order."""
606
+ match node:
607
+ case Task():
608
+ return (await run_cmd(node, index_map[id(node)], dispatch),)
609
+ case Parallel(tasks=children):
610
+ async with asyncio.TaskGroup() as tg:
611
+ futures: Final = tuple(
612
+ tg.create_task(execute(child, dispatch, leaves, index_map))
613
+ for child in children
614
+ )
615
+ return tuple(r for f in futures for r in f.result())
616
+ case Sequential(tasks=children):
617
+ seq_results: tuple[TaskResult, ...] = ()
618
+ failed = False
619
+ for child in children:
620
+ if failed:
621
+ for skipped_idx in subtree_leaf_indices(child, index_map):
622
+ await dispatch(
623
+ skipped_idx, CompletedEvent(skipped_idx, SKIPPED_RETURNCODE, 0.0, ())
624
+ )
625
+ seq_results = (
626
+ *seq_results,
627
+ TaskResult(
628
+ task_display_name(leaves[skipped_idx]), SKIPPED_RETURNCODE, 0.0, ()
629
+ ),
630
+ )
631
+ else:
632
+ child_results = await execute(child, dispatch, leaves, index_map)
633
+ seq_results = (*seq_results, *child_results)
634
+ if any(r.returncode != 0 for r in child_results):
635
+ failed = True
636
+ return seq_results
637
+ case _:
638
+ assert_never(node)
639
+
640
+
641
+ async def run(task: TaskNode, effects: Sequence[Effect[Any]] = ()) -> RunResult:
642
+ """Execute a task tree, dispatching events to every effect.
643
+
644
+ >>> import asyncio
645
+ >>> asyncio.run(run(Task(("python", "-c", "pass")))).returncode
646
+ 0
647
+ >>> asyncio.run(run(Task(("python", "-c", "raise SystemExit(1)")))).returncode
648
+ 1
649
+ """
650
+ expanded: Final = expand_matrix(task)
651
+ leaf_infos: Final = flatten_leaves(expanded)
652
+ leaves: Final = tuple(info.task for info in leaf_infos)
653
+ index_map: Final = {id(info.task): i for i, info in enumerate(leaf_infos)}
654
+
655
+ wall_start: Final = time.perf_counter()
656
+ setup_results: Final = await asyncio.gather(
657
+ *(effect.setup(expanded) for effect in effects),
658
+ return_exceptions=True,
659
+ )
660
+ active_effects: Final = tuple(
661
+ e for e, r in zip(effects, setup_results) if not isinstance(r, BaseException)
662
+ )
663
+ setup_errors: Final = tuple(r for r in setup_results if isinstance(r, BaseException))
664
+ ctx_grid: Final[list[list[Any]]] = [
665
+ [r for r in setup_results if not isinstance(r, BaseException)] for _ in leaf_infos
666
+ ]
667
+ states: Final[list[LeafState]] = [Waiting(info.task) for info in leaf_infos]
668
+
669
+ async def dispatch(leaf_idx: int, event: TaskEvent) -> None:
670
+ states[leaf_idx] = next_state(states[leaf_idx], event)
671
+ slot: Final = ctx_grid[leaf_idx]
672
+ for effect_idx, effect_ctx in enumerate(
673
+ await asyncio.gather(
674
+ *(
675
+ effect.on_event(event, states, ctx)
676
+ for effect, ctx in zip(active_effects, slot, strict=True)
677
+ )
678
+ )
679
+ ):
680
+ slot[effect_idx] = effect_ctx
681
+
682
+ results: tuple[TaskResult, ...] = ()
683
+ try:
684
+ if setup_errors:
685
+ raise BaseExceptionGroup("setup errors", setup_errors)
686
+ results = await execute(expanded, dispatch, leaves, index_map)
687
+ finally:
688
+ teardown_errors: Final = tuple(
689
+ r
690
+ for r in await asyncio.gather(
691
+ *(
692
+ effect.teardown(tuple(row[effect_idx] for row in ctx_grid))
693
+ for effect_idx, effect in enumerate(active_effects)
694
+ ),
695
+ return_exceptions=True,
696
+ )
697
+ if isinstance(r, BaseException)
698
+ )
699
+ if teardown_errors:
700
+ raise BaseExceptionGroup("teardown errors", teardown_errors)
701
+ return RunResult(
702
+ returncode=1 if any(r.returncode != 0 for r in results) else 0,
703
+ results=results,
704
+ elapsed=time.perf_counter() - wall_start,
705
+ )
camas/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # SPDX-FileCopyrightText: 2026 JP Hutchins
3
+
4
+ from camas.main import main
5
+
6
+ if __name__ == "__main__":
7
+ main()