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/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
+ )
@@ -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