clankloop 0.0.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.
- clankloop/__init__.py +47 -0
- clankloop/cli.py +267 -0
- clankloop/core/__init__.py +6 -0
- clankloop/core/bash.py +176 -0
- clankloop/core/env.py +292 -0
- clankloop/core/errors.py +79 -0
- clankloop/core/graph.py +272 -0
- clankloop/core/lts.py +330 -0
- clankloop/core/types.py +230 -0
- clankloop/logger.py +179 -0
- clankloop/loopfile/__init__.py +53 -0
- clankloop/loopfile/v1/__init__.py +4 -0
- clankloop/loopfile/v1/compiler.py +247 -0
- clankloop/loopfile/v1/loopfile.py +107 -0
- clankloop/loopfile/v1/paths.py +47 -0
- clankloop/loopfile/v2/__init__.py +7 -0
- clankloop/loopfile/v2/clankshed_module.py +121 -0
- clankloop/loopfile/v2/compiler.py +331 -0
- clankloop/loopfile/v2/git_module.py +72 -0
- clankloop/loopfile/v2/loopfile.py +152 -0
- clankloop/loopfile/v2/module.py +69 -0
- clankloop/loopfile/v2/module_registry.py +53 -0
- clankloop/loopfile/v2/paths.py +38 -0
- clankloop/loopfile/v2/toposort.py +77 -0
- clankloop/loopfile/v2/workdir_module.py +40 -0
- clankloop/loopfile/versions.py +19 -0
- clankloop/plantuml.py +88 -0
- clankloop/runner.py +188 -0
- clankloop/tracer.py +74 -0
- clankloop-0.0.0.dist-info/METADATA +20 -0
- clankloop-0.0.0.dist-info/RECORD +34 -0
- clankloop-0.0.0.dist-info/WHEEL +4 -0
- clankloop-0.0.0.dist-info/entry_points.txt +2 -0
- clankloop-0.0.0.dist-info/licenses/LICENSE +674 -0
clankloop/core/lts.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Labeled transition system analysis of an execution graph.
|
|
3
|
+
|
|
4
|
+
Walks every reachable ``(task, retry-budget-vector, assigned-paths)`` state of
|
|
5
|
+
a compiled :class:`~clankloop.core.graph.ExecutionGraph`, materializing the
|
|
6
|
+
full state space as a labelled transition system (:class:`LabeledTransitionSystem`).
|
|
7
|
+
Both the data-dependency check (:func:`validate_data_dependencies`) and the
|
|
8
|
+
PlantUML renderer project from this generic structure rather than re-walking
|
|
9
|
+
the graph.
|
|
10
|
+
|
|
11
|
+
The LTS is unbuildable for an ill-formed graph: :func:`analyze` raises
|
|
12
|
+
:class:`~clankloop.core.errors.UnknownTaskError` for a dangling branch target
|
|
13
|
+
and :class:`~clankloop.core.errors.InfiniteLoopError` for an unbounded cycle
|
|
14
|
+
detected during the DFS. These propagate to the caller, which can apply
|
|
15
|
+
user-facing context.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from itertools import takewhile
|
|
22
|
+
from typing import TYPE_CHECKING, Iterator, Mapping, Sequence
|
|
23
|
+
|
|
24
|
+
from clankloop.core.env import assigned_paths, data_dependencies as env_data_dependencies
|
|
25
|
+
from clankloop.core.errors import (
|
|
26
|
+
InfiniteLoopError,
|
|
27
|
+
UnassignedDataDependencyError,
|
|
28
|
+
UnknownTaskError,
|
|
29
|
+
)
|
|
30
|
+
from clankloop.core.types import (
|
|
31
|
+
Action,
|
|
32
|
+
AlwaysCondition,
|
|
33
|
+
Condition,
|
|
34
|
+
NextAction,
|
|
35
|
+
OnFailureCondition,
|
|
36
|
+
OnRetryCondition,
|
|
37
|
+
OnSuccessCondition,
|
|
38
|
+
SetAction,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from clankloop.core.graph import ExecutionGraph
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class LTSNode:
|
|
47
|
+
"""A node in the labeled transition system.
|
|
48
|
+
|
|
49
|
+
The state is the task the walker is at, plus the remaining retry budget
|
|
50
|
+
for each :class:`OnRetryCondition` in the graph (budgets only decrement
|
|
51
|
+
and are bounded, so the product of tasks and budget vectors is finite and
|
|
52
|
+
the walk terminates), plus the set of env paths known to be assigned on
|
|
53
|
+
the path that reached this state. ``assigned`` grows monotonically as
|
|
54
|
+
producers (SetActions on matched branches) fire, and is part of the
|
|
55
|
+
state so the data-dependency check is path-sensitive.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
task: str
|
|
59
|
+
counters: tuple[int, ...]
|
|
60
|
+
assigned: frozenset[str] = frozenset()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class LTSEdge:
|
|
65
|
+
"""A materialized transition in the LTS, labelled with its cause."""
|
|
66
|
+
|
|
67
|
+
source: LTSNode
|
|
68
|
+
target: LTSNode
|
|
69
|
+
label: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class LabeledTransitionSystem:
|
|
74
|
+
"""The full reachable state space of a graph as a labeled transition system.
|
|
75
|
+
|
|
76
|
+
Built by :func:`analyze`; consumed by :func:`validate_data_dependencies`
|
|
77
|
+
(checks) and the PlantUML renderer (rendering). Both project from this
|
|
78
|
+
generic structure rather than re-walking the graph.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
start: LTSNode
|
|
82
|
+
nodes: frozenset[LTSNode]
|
|
83
|
+
edges: frozenset[LTSEdge]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _validate_targets(graph: "ExecutionGraph") -> None:
|
|
87
|
+
"""Every NextAction on every task must reference a task in the graph.
|
|
88
|
+
|
|
89
|
+
Independent of reachability — a dangling target on an unreachable task is
|
|
90
|
+
still an unbuildable graph. Once this passes, the walker's own target
|
|
91
|
+
check is a pure safety net that can never fire.
|
|
92
|
+
"""
|
|
93
|
+
next_actions = (
|
|
94
|
+
(task_name, action)
|
|
95
|
+
for task_name, branches in graph.exec_actions.items()
|
|
96
|
+
for _condition, actions in branches
|
|
97
|
+
for action in actions
|
|
98
|
+
if isinstance(action, NextAction)
|
|
99
|
+
)
|
|
100
|
+
for task_name, action in next_actions:
|
|
101
|
+
if action.next_task not in graph.execs:
|
|
102
|
+
raise UnknownTaskError(
|
|
103
|
+
f"Task {task_name!r} in pipeline {graph.name!r} "
|
|
104
|
+
f"branches to unknown task {action.next_task!r}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _ConditionSimulator:
|
|
109
|
+
"""Simulates condition matching against synthetic return codes.
|
|
110
|
+
|
|
111
|
+
Closes over the retry-condition slot mapping so callers don't thread it
|
|
112
|
+
through every call. Returns ``(matched, decrement_slot)`` — the slot is
|
|
113
|
+
the index into the budget vector to decrement on this transition, or
|
|
114
|
+
``None`` when the condition consumes no budget.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(self, retry_conditions: list[OnRetryCondition]):
|
|
118
|
+
self._slot_of = {id(c): i for i, c in enumerate(retry_conditions)}
|
|
119
|
+
|
|
120
|
+
def matches(
|
|
121
|
+
self, condition: Condition, returncode: int, counters: tuple[int, ...]
|
|
122
|
+
) -> tuple[bool, int | None]:
|
|
123
|
+
if isinstance(condition, OnSuccessCondition):
|
|
124
|
+
return (returncode == 0, None)
|
|
125
|
+
if isinstance(condition, OnFailureCondition):
|
|
126
|
+
return (returncode != 0, None)
|
|
127
|
+
if isinstance(condition, AlwaysCondition):
|
|
128
|
+
return (True, None)
|
|
129
|
+
if isinstance(condition, OnRetryCondition):
|
|
130
|
+
slot = self._slot_of[id(condition)]
|
|
131
|
+
if returncode != 0 and counters[slot] > 0:
|
|
132
|
+
return (True, slot)
|
|
133
|
+
return (False, None)
|
|
134
|
+
return (False, None)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _Walker:
|
|
138
|
+
"""DFS over the reachable state space, materializing the LTS.
|
|
139
|
+
|
|
140
|
+
Holds the mutable accumulators (visited nodes, materialized edges, the
|
|
141
|
+
DFS stack) and the condition simulator. The DFS skeleton in :meth:`_dfs`
|
|
142
|
+
is pure graph traversal; :meth:`_transitions` names the "emulate both
|
|
143
|
+
outcomes" step; :meth:`_resolve_outcome` names the "first matching
|
|
144
|
+
condition wins" step that mirrors
|
|
145
|
+
:meth:`ExecutionGraph.execute` branch resolution.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, graph: "ExecutionGraph", sim: _ConditionSimulator):
|
|
149
|
+
self.graph = graph
|
|
150
|
+
self.sim = sim
|
|
151
|
+
self.nodes: set[LTSNode] = set()
|
|
152
|
+
self.edges: set[LTSEdge] = set()
|
|
153
|
+
self.on_stack: set[LTSNode] = set()
|
|
154
|
+
self.trail: list[str] = []
|
|
155
|
+
|
|
156
|
+
def walk(self, start: LTSNode) -> LabeledTransitionSystem:
|
|
157
|
+
self._dfs(start)
|
|
158
|
+
return LabeledTransitionSystem(
|
|
159
|
+
start=start,
|
|
160
|
+
nodes=frozenset(self.nodes),
|
|
161
|
+
edges=frozenset(self.edges),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _dfs(self, state: LTSNode) -> None:
|
|
165
|
+
if state in self.on_stack:
|
|
166
|
+
raise InfiniteLoopError(
|
|
167
|
+
f"Pipeline {self.graph.name!r} has an unbounded cycle: "
|
|
168
|
+
f"{' -> '.join(self.trail + [state.task])}"
|
|
169
|
+
)
|
|
170
|
+
if state in self.nodes:
|
|
171
|
+
return
|
|
172
|
+
self.nodes.add(state)
|
|
173
|
+
self.on_stack.add(state)
|
|
174
|
+
self.trail.append(state.task)
|
|
175
|
+
for label, target in self._transitions(state):
|
|
176
|
+
self.edges.add(LTSEdge(state, target, label))
|
|
177
|
+
self._dfs(target)
|
|
178
|
+
self.trail.pop()
|
|
179
|
+
self.on_stack.discard(state)
|
|
180
|
+
|
|
181
|
+
def _transitions(self, state: LTSNode) -> Iterator[tuple[str, LTSNode]]:
|
|
182
|
+
"""Yield ``(label, target)`` for each outcome this state transitions on."""
|
|
183
|
+
for success in (True, False):
|
|
184
|
+
resolved = self._resolve_outcome(state, success)
|
|
185
|
+
if resolved is not None:
|
|
186
|
+
yield resolved
|
|
187
|
+
|
|
188
|
+
def _resolve_outcome(
|
|
189
|
+
self, state: LTSNode, success: bool
|
|
190
|
+
) -> tuple[str, LTSNode] | None:
|
|
191
|
+
"""Find the first matching condition and resolve its successor.
|
|
192
|
+
|
|
193
|
+
Mirrors :meth:`ExecutionGraph.execute`: the first matching condition
|
|
194
|
+
wins; SetActions before the NextAction fold their write-paths into
|
|
195
|
+
the successor's assigned set; actions after the NextAction never run.
|
|
196
|
+
A branch that matches but produces no NextAction falls through to the
|
|
197
|
+
next branch.
|
|
198
|
+
"""
|
|
199
|
+
returncode = 0 if success else 1
|
|
200
|
+
for condition, actions in self.graph.exec_actions.get(state.task, []):
|
|
201
|
+
matched, slot = self.sim.matches(condition, returncode, state.counters)
|
|
202
|
+
if not matched:
|
|
203
|
+
continue
|
|
204
|
+
next_task = self._redirect_target(actions)
|
|
205
|
+
if next_task is None:
|
|
206
|
+
continue # matched but no redirect; try the next branch
|
|
207
|
+
if next_task not in self.graph.execs:
|
|
208
|
+
raise UnknownTaskError(
|
|
209
|
+
f"Task {state.task!r} in pipeline {self.graph.name!r} "
|
|
210
|
+
f"branches to unknown task {next_task!r}"
|
|
211
|
+
)
|
|
212
|
+
return (
|
|
213
|
+
condition.label,
|
|
214
|
+
LTSNode(
|
|
215
|
+
task=next_task,
|
|
216
|
+
counters=self._decrement(slot, state.counters),
|
|
217
|
+
assigned=state.assigned | self._collect_writes(actions),
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _decrement(slot: int | None, counters: tuple[int, ...]) -> tuple[int, ...]:
|
|
224
|
+
"""Return *counters* with the budget at *slot* decremented, or unchanged."""
|
|
225
|
+
if slot is None:
|
|
226
|
+
return counters
|
|
227
|
+
mutated = list(counters)
|
|
228
|
+
mutated[slot] -= 1
|
|
229
|
+
return tuple(mutated)
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _collect_writes(actions: Sequence[Action]) -> frozenset[str]:
|
|
233
|
+
"""Env paths written by SetActions before the first NextAction."""
|
|
234
|
+
return frozenset(
|
|
235
|
+
str(action.var_name)
|
|
236
|
+
for action in takewhile(lambda a: not isinstance(a, NextAction), actions)
|
|
237
|
+
if isinstance(action, SetAction)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _redirect_target(actions: Sequence[Action]) -> str | None:
|
|
242
|
+
"""The task name the first NextAction redirects to, or None."""
|
|
243
|
+
for action in actions:
|
|
244
|
+
if isinstance(action, NextAction):
|
|
245
|
+
return action.next_task
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def analyze(
|
|
250
|
+
graph: "ExecutionGraph",
|
|
251
|
+
entry_task: str,
|
|
252
|
+
parameter_paths: frozenset[str] = frozenset(),
|
|
253
|
+
) -> LabeledTransitionSystem:
|
|
254
|
+
"""Walk every reachable state, materializing the labeled transition system.
|
|
255
|
+
|
|
256
|
+
Emulates both outcomes (success, failure) per task. State is
|
|
257
|
+
``(task, retry-budget-vector, assigned-paths)``. Transitions are
|
|
258
|
+
materialized as :class:`LTSEdge` with the matched condition's label.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
UnknownTaskError: If *entry_task* is not in the graph, or a branch
|
|
262
|
+
targets a task not in the graph (the LTS is unbuildable).
|
|
263
|
+
InfiniteLoopError: If the DFS revisits a state on its current stack
|
|
264
|
+
(an unbounded cycle — no budget decrements, so it repeats
|
|
265
|
+
forever).
|
|
266
|
+
"""
|
|
267
|
+
if entry_task not in graph.execs:
|
|
268
|
+
raise UnknownTaskError(
|
|
269
|
+
f"Entry task {entry_task!r} is not in pipeline {graph.name!r}"
|
|
270
|
+
)
|
|
271
|
+
_validate_targets(graph)
|
|
272
|
+
|
|
273
|
+
retry_conditions = [
|
|
274
|
+
cond
|
|
275
|
+
for branches in graph.exec_actions.values()
|
|
276
|
+
for cond, _ in branches
|
|
277
|
+
if isinstance(cond, OnRetryCondition)
|
|
278
|
+
]
|
|
279
|
+
sim = _ConditionSimulator(retry_conditions)
|
|
280
|
+
start = LTSNode(
|
|
281
|
+
task=entry_task,
|
|
282
|
+
counters=tuple(c.retries for c in retry_conditions),
|
|
283
|
+
assigned=assigned_paths(graph.env) | parameter_paths,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return _Walker(graph, sim).walk(start)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def data_dependencies(
|
|
290
|
+
graph: "ExecutionGraph",
|
|
291
|
+
) -> dict[str, frozenset[str]]:
|
|
292
|
+
"""Resolve each task's transitive read set against the graph's env.
|
|
293
|
+
|
|
294
|
+
A static fact per task, independent of state. Consumed by
|
|
295
|
+
:func:`validate_data_dependencies` together with the LTS.
|
|
296
|
+
"""
|
|
297
|
+
return {
|
|
298
|
+
name: env_data_dependencies(graph.env, execution.consumes())
|
|
299
|
+
for name, execution in graph.execs.items()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def validate_data_dependencies(
|
|
304
|
+
lts: LabeledTransitionSystem,
|
|
305
|
+
reads: Mapping[str, frozenset[str]],
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Check the LTS against per-task read sets.
|
|
308
|
+
|
|
309
|
+
Raises :class:`UnassignedDataDependencyError` if any task's read set is
|
|
310
|
+
not contained in the intersection of ``assigned`` across all LTS nodes
|
|
311
|
+
for that task. The intersection is the set of paths guaranteed to reach
|
|
312
|
+
the task: a key set on some paths but not all is not guaranteed, so a
|
|
313
|
+
read of it could render against an unassigned value on the unset path.
|
|
314
|
+
"""
|
|
315
|
+
by_task: dict[str, list[LTSNode]] = defaultdict(list)
|
|
316
|
+
for node in lts.nodes:
|
|
317
|
+
by_task[node.task].append(node)
|
|
318
|
+
violations = [
|
|
319
|
+
(task, reads.get(task, frozenset()) - frozenset.intersection(*[n.assigned for n in nodes]))
|
|
320
|
+
for task, nodes in by_task.items()
|
|
321
|
+
]
|
|
322
|
+
missing = [(task, deps) for task, deps in violations if deps]
|
|
323
|
+
if missing:
|
|
324
|
+
descriptions = ", ".join(
|
|
325
|
+
f"{task!r} reads {sorted(deps)}"
|
|
326
|
+
for task, deps in missing
|
|
327
|
+
)
|
|
328
|
+
raise UnassignedDataDependencyError(
|
|
329
|
+
f"Task(s) read env path(s) not assigned on every path reaching them: {descriptions}"
|
|
330
|
+
)
|
clankloop/core/types.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Domain types and protocols for the execution graph.
|
|
3
|
+
|
|
4
|
+
Pure data and structural contracts with zero runtime logic: the result of
|
|
5
|
+
executing a task, the IO channels the core tees through, the per-execution
|
|
6
|
+
context, the :class:`Execution` task protocol, and the :class:`Condition` /
|
|
7
|
+
:class:`Action` branching protocols together with their concrete classes.
|
|
8
|
+
|
|
9
|
+
Nothing here imports the runtime graph or the static analyser — this is the
|
|
10
|
+
vocabulary module everything else builds on.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Mapping, Protocol, TextIO, runtime_checkable
|
|
17
|
+
|
|
18
|
+
from clankloop.core.env import EnvPath, Object, UnassignedValueError
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("clankloop")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ExecutionResult:
|
|
25
|
+
"""Result of executing a task.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
stdout: Captured standard output.
|
|
29
|
+
stderr: Captured standard error.
|
|
30
|
+
returncode: Process exit code (0 = success).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
stdout: str
|
|
34
|
+
stderr: str
|
|
35
|
+
returncode: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class IOChannels:
|
|
40
|
+
"""CLI-owned streams the core tees subprocess output through.
|
|
41
|
+
|
|
42
|
+
``None`` fields mean "discard forwarded output for that stream" (the
|
|
43
|
+
subprocess is still captured for SetActions/conditions). The default
|
|
44
|
+
``IOChannels()`` is non-interactive + discard-forward + capture — today's
|
|
45
|
+
``capture_output=True`` behaviour. The CLI supplies real streams for
|
|
46
|
+
``run``; ``taskcar`` uses the default so its stdout stays reserved for the
|
|
47
|
+
JSON result.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
stdin: TextIO | None = None
|
|
51
|
+
stdout: TextIO | None = None
|
|
52
|
+
stderr: TextIO | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ExecutionContext:
|
|
57
|
+
"""Per-execution context passed to :meth:`Execution.execute`."""
|
|
58
|
+
|
|
59
|
+
env: Object
|
|
60
|
+
environ: Mapping[str, str]
|
|
61
|
+
io: IOChannels = IOChannels()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@runtime_checkable
|
|
65
|
+
class Execution(Protocol):
|
|
66
|
+
"""A task the graph engine can execute.
|
|
67
|
+
|
|
68
|
+
Structural protocol: any object with a ``name`` plus :meth:`consumes` and
|
|
69
|
+
:meth:`execute` qualifies. Implementations are not required to inherit
|
|
70
|
+
from this class; the protocol merely documents the contract.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
|
|
75
|
+
def consumes(self) -> frozenset[str]:
|
|
76
|
+
"""Direct template identifiers in the command.
|
|
77
|
+
|
|
78
|
+
Paths are dotted root-relative strings (e.g. ``pipeline.globals.workdir``).
|
|
79
|
+
Read unconditionally at execute time when the command template renders,
|
|
80
|
+
independent of which branch the graph takes afterwards, so the set is a
|
|
81
|
+
static fact about the task. Export key-paths are deliberately excluded:
|
|
82
|
+
they are extracted under a tolerant try/except, so an unassigned export
|
|
83
|
+
is by design not a hard dependency.
|
|
84
|
+
"""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
def execute(self, context: ExecutionContext) -> ExecutionResult:
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def extract_env(env: Object, exports: dict[str, EnvPath]) -> dict[str, str]:
|
|
92
|
+
"""Extract values from the environment for subprocess execution."""
|
|
93
|
+
result = {}
|
|
94
|
+
for bash_name, key_path in exports.items():
|
|
95
|
+
try:
|
|
96
|
+
value = env.evaluate(key_path)
|
|
97
|
+
result[bash_name] = str(value)
|
|
98
|
+
except UnassignedValueError:
|
|
99
|
+
logger.debug(
|
|
100
|
+
"Failed to extract env variable", extra={"key_path": str(key_path)}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@runtime_checkable
|
|
107
|
+
class Condition(Protocol):
|
|
108
|
+
"""A condition that decides whether a branch fires.
|
|
109
|
+
|
|
110
|
+
Structural protocol: any object with :meth:`matches` qualifies.
|
|
111
|
+
|
|
112
|
+
``label`` is the one presentation member: a short, human-readable cause
|
|
113
|
+
(e.g. ``"On Success"``) carried on the type so consumers (the LTS walker,
|
|
114
|
+
the PlantUML renderer) never need ``isinstance`` to name a transition.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
label: str
|
|
118
|
+
|
|
119
|
+
def matches(self, result: ExecutionResult) -> bool:
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class OnSuccessCondition:
|
|
124
|
+
"""Condition that matches when the previous task succeeded (return code 0)."""
|
|
125
|
+
|
|
126
|
+
label: str = "On Success"
|
|
127
|
+
|
|
128
|
+
def matches(self, result: ExecutionResult) -> bool:
|
|
129
|
+
return result.returncode == 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class OnFailureCondition:
|
|
133
|
+
"""Condition that matches when the previous task failed (non-zero return code)."""
|
|
134
|
+
|
|
135
|
+
label: str = "On Failure"
|
|
136
|
+
|
|
137
|
+
def matches(self, result: ExecutionResult) -> bool:
|
|
138
|
+
return result.returncode != 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AlwaysCondition:
|
|
142
|
+
"""Condition that always matches, regardless of the previous task's result."""
|
|
143
|
+
|
|
144
|
+
label: str = "Always"
|
|
145
|
+
|
|
146
|
+
def matches(self, result: ExecutionResult) -> bool:
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class OnRetryCondition:
|
|
151
|
+
"""Condition that matches when the previous task failed and retries remain.
|
|
152
|
+
|
|
153
|
+
Holds a mutable decrementing counter initialized at compile time from
|
|
154
|
+
:class:`~clankloop.loopfile.v2.loopfile.RetryFromAction.retries`. Each
|
|
155
|
+
successful match consumes one retry; when the counter reaches zero the
|
|
156
|
+
condition stops matching and execution falls through to the failure branch.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
label: str = "On Retry"
|
|
160
|
+
|
|
161
|
+
def __init__(self, retries: int):
|
|
162
|
+
self._budget = retries
|
|
163
|
+
self._remaining = retries
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def retries(self) -> int:
|
|
167
|
+
"""The configured retry budget (immutable); the live counter is _remaining."""
|
|
168
|
+
return self._budget
|
|
169
|
+
|
|
170
|
+
def matches(self, result: ExecutionResult) -> bool:
|
|
171
|
+
if result.returncode == 0:
|
|
172
|
+
return False
|
|
173
|
+
if self._remaining > 0:
|
|
174
|
+
self._remaining -= 1
|
|
175
|
+
return True
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@runtime_checkable
|
|
180
|
+
class Action(Protocol):
|
|
181
|
+
"""An action that runs when its branch matches.
|
|
182
|
+
|
|
183
|
+
Structural protocol: any object with :meth:`next` qualifies.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def next(self, env: Object, result: ExecutionResult) -> str | None:
|
|
187
|
+
...
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class SetAction:
|
|
191
|
+
"""Action that sets a variable in the graph's environment."""
|
|
192
|
+
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
var_name: EnvPath,
|
|
196
|
+
value: str | None = None,
|
|
197
|
+
stdout: bool = False,
|
|
198
|
+
stderr: bool = False,
|
|
199
|
+
):
|
|
200
|
+
self.var_name = var_name
|
|
201
|
+
self.value = value
|
|
202
|
+
self.stdout = stdout
|
|
203
|
+
self.stderr = stderr
|
|
204
|
+
|
|
205
|
+
def next(self, env: Object, result: ExecutionResult) -> str | None:
|
|
206
|
+
value = None
|
|
207
|
+
if self.value is not None:
|
|
208
|
+
value = self.value
|
|
209
|
+
elif self.stdout:
|
|
210
|
+
value = result.stdout
|
|
211
|
+
elif self.stderr:
|
|
212
|
+
value = result.stderr
|
|
213
|
+
assert value is not None, "SetAction must specify value, stdout, or stderr"
|
|
214
|
+
logger.debug(
|
|
215
|
+
"Setting variable",
|
|
216
|
+
extra={
|
|
217
|
+
"variable": self.var_name,
|
|
218
|
+
"value": value,
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
env.set_value(self.var_name, value)
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class NextAction:
|
|
226
|
+
def __init__(self, next_task: str):
|
|
227
|
+
self.next_task = next_task
|
|
228
|
+
|
|
229
|
+
def next(self, env: Object, result: ExecutionResult) -> str | None:
|
|
230
|
+
return self.next_task
|