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 +705 -0
- camas/__main__.py +7 -0
- camas/effect/__init__.py +2 -0
- camas/effect/summary.py +122 -0
- camas/effect/termtree.py +428 -0
- camas/main.py +524 -0
- camas/py.typed +0 -0
- camas-0.1.0.dist-info/METADATA +98 -0
- camas-0.1.0.dist-info/RECORD +12 -0
- camas-0.1.0.dist-info/WHEEL +4 -0
- camas-0.1.0.dist-info/entry_points.txt +2 -0
- camas-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|