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/env.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Environment and templating — key-path navigation, values, and ClankTemplate rendering.
|
|
3
|
+
|
|
4
|
+
The environment is a tree of :class:`Object` nodes holding :class:`Value` leaves
|
|
5
|
+
and :class:`Locator` aliases. Values are accessed via dotted key paths (e.g.
|
|
6
|
+
``pipeline.globals.workdir``).
|
|
7
|
+
|
|
8
|
+
:class:`ClankTemplate` allows recursive rendering of templates with values from
|
|
9
|
+
the environment.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, Iterable, Iterator, Protocol, runtime_checkable
|
|
16
|
+
from string import Template
|
|
17
|
+
|
|
18
|
+
from clankloop.core.errors import (
|
|
19
|
+
ImmutableValueError,
|
|
20
|
+
UnassignedValueError,
|
|
21
|
+
UnknownKeyError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("clankloop")
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Object",
|
|
28
|
+
"Value",
|
|
29
|
+
"EnvPath",
|
|
30
|
+
"ClankTemplate",
|
|
31
|
+
"data_dependencies",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
RESULT_T = str | int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class EnvPath:
|
|
40
|
+
"""A first-class environment path, stored as segments.
|
|
41
|
+
|
|
42
|
+
The dotted-string form (e.g. ``pipeline.globals.workdir``) is the
|
|
43
|
+
serialization, produced by :meth:`__str__` and consumed by :meth:`parse`.
|
|
44
|
+
The env module stores and navigates paths structurally via segments held
|
|
45
|
+
privately; callers interact through semantic accessors (:meth:`head`,
|
|
46
|
+
:meth:`tail`, :attr:`leaf`) rather than indexing.
|
|
47
|
+
|
|
48
|
+
``Object.evaluate`` / ``set_value`` / ``locate`` take ``EnvPath``;
|
|
49
|
+
callers parse dotted strings at the boundary via :meth:`parse`, or
|
|
50
|
+
construct from segments via :meth:`of`.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_segments: tuple[str, ...]
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def parse(cls, dotted: str) -> "EnvPath":
|
|
57
|
+
return cls(tuple(dotted.split("."))) if dotted else cls(())
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def of(cls, *segments: str) -> "EnvPath":
|
|
61
|
+
"""Construct a path from its segments."""
|
|
62
|
+
return cls(segments)
|
|
63
|
+
|
|
64
|
+
def __str__(self) -> str:
|
|
65
|
+
return ".".join(self._segments)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def leaf(self) -> str:
|
|
69
|
+
return self._segments[-1]
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def head(self) -> str:
|
|
73
|
+
return self._segments[0]
|
|
74
|
+
|
|
75
|
+
def tail(self) -> "EnvPath | None":
|
|
76
|
+
"""Return the path minus its first segment, or ``None`` if there is no
|
|
77
|
+
segment beyond the head."""
|
|
78
|
+
if len(self._segments) < 2:
|
|
79
|
+
return None
|
|
80
|
+
return EnvPath(self._segments[1:])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ClankTemplate(Template):
|
|
84
|
+
idpattern = r"(?a:[_a-z][_a-z0-9\.]*)"
|
|
85
|
+
|
|
86
|
+
def render(self, root: "Object") -> str:
|
|
87
|
+
"""Substitute every identifier by evaluating it against *root*."""
|
|
88
|
+
variables: dict[str, str] = {}
|
|
89
|
+
for parameter in self.get_identifiers():
|
|
90
|
+
variables[parameter] = str(root.evaluate(EnvPath.parse(parameter)))
|
|
91
|
+
return self.substitute(variables)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
#: What a :class:`Value` stores: an evaluated literal or an (immutable)
|
|
95
|
+
#: template re-rendered on read. Extends :data:`RESULT_T` with the template
|
|
96
|
+
#: case; runtime writes (``set_value``) take only literals.
|
|
97
|
+
VALUE_T = RESULT_T | ClankTemplate
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@runtime_checkable
|
|
101
|
+
class Locator(Protocol):
|
|
102
|
+
"""Protocol for objects that can locate values by key path."""
|
|
103
|
+
|
|
104
|
+
def locate(self, key_path: EnvPath, root: "Object") -> "Object": ...
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class Value:
|
|
109
|
+
"""A leaf in the environment holding a literal or a render template.
|
|
110
|
+
|
|
111
|
+
The stored ``value`` is either a literal (``str`` or ``int``) returned
|
|
112
|
+
verbatim, or a :class:`ClankTemplate` re-rendered against the env on each
|
|
113
|
+
read. Which it is is determined by type, not a flag.
|
|
114
|
+
|
|
115
|
+
A mutable value (``immutable=False``) cannot hold a template: rendering
|
|
116
|
+
re-interprets the stored string as a template at read time, so a mutable
|
|
117
|
+
template value would let a runtime write (e.g. a task's stdout captured
|
|
118
|
+
into a global) inject template identifiers whose transitive read set is
|
|
119
|
+
not statically knowable. Restricting templates to immutable values keeps
|
|
120
|
+
the transitive read set a compile-time property.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
value: VALUE_T
|
|
124
|
+
immutable: bool = True
|
|
125
|
+
assigned: bool = False
|
|
126
|
+
|
|
127
|
+
def __post_init__(self) -> None:
|
|
128
|
+
if isinstance(self.value, ClankTemplate) and not self.immutable:
|
|
129
|
+
raise ImmutableValueError(
|
|
130
|
+
"A mutable Value cannot hold a render template "
|
|
131
|
+
"(immutable=False with a ClankTemplate value is not allowed)"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def evaluate(self, context: "Object") -> RESULT_T:
|
|
135
|
+
if not self.assigned:
|
|
136
|
+
raise UnassignedValueError("Accessed unassigned literal value")
|
|
137
|
+
|
|
138
|
+
if isinstance(self.value, ClankTemplate):
|
|
139
|
+
return self.value.render(context)
|
|
140
|
+
|
|
141
|
+
return self.value
|
|
142
|
+
|
|
143
|
+
def set_value(self, new_value: RESULT_T) -> None:
|
|
144
|
+
if self.immutable and self.assigned:
|
|
145
|
+
raise ImmutableValueError("Cannot modify assigned immutable literal value")
|
|
146
|
+
|
|
147
|
+
self.assigned = True
|
|
148
|
+
self.value = new_value
|
|
149
|
+
|
|
150
|
+
def template_identifiers(self) -> frozenset[str]:
|
|
151
|
+
"""Identifiers embedded in this value's template, or empty if literal."""
|
|
152
|
+
if isinstance(self.value, ClankTemplate):
|
|
153
|
+
return frozenset(self.value.get_identifiers())
|
|
154
|
+
return frozenset()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class Alias(Locator):
|
|
159
|
+
"""Represents an alias to another object in the environment."""
|
|
160
|
+
|
|
161
|
+
alias_fn: Callable[["Object"], Locator]
|
|
162
|
+
|
|
163
|
+
def locate(self, key_path: EnvPath, root: "Object") -> "Object":
|
|
164
|
+
return self.alias_fn(root).locate(key_path, root)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Object(Locator):
|
|
168
|
+
"""Represents a node in the environment tree, holding values and locators."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, entries: dict[str, Value | Locator]) -> None:
|
|
171
|
+
self.entries = entries
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def evaluate(self, key_path: EnvPath) -> RESULT_T:
|
|
175
|
+
"""Evaluate the value at the given key path, resolving any aliases and
|
|
176
|
+
rendering templates as needed."""
|
|
177
|
+
|
|
178
|
+
value = self.locate(key_path, self)
|
|
179
|
+
logger.debug(
|
|
180
|
+
"Evaluating key_path",
|
|
181
|
+
extra={"key_path": str(key_path), "value": value},
|
|
182
|
+
)
|
|
183
|
+
leaf = value.entries[key_path.leaf]
|
|
184
|
+
assert isinstance(leaf, Value), f"Leaf at {key_path!s} is not a Value"
|
|
185
|
+
return leaf.evaluate(self)
|
|
186
|
+
|
|
187
|
+
def locate(self, key_path: EnvPath, root: "Object") -> "Object":
|
|
188
|
+
"""Locate the object corresponding to the given key path, resolving any aliases."""
|
|
189
|
+
item_key = key_path.head
|
|
190
|
+
tail = key_path.tail()
|
|
191
|
+
|
|
192
|
+
logger.debug("Locating key", extra={"item_key": item_key, "tail": tail})
|
|
193
|
+
if item_key not in self.entries:
|
|
194
|
+
logger.debug("Key %s not in %s", item_key, root)
|
|
195
|
+
raise UnknownKeyError(f"Object has no attribute {item_key!r}")
|
|
196
|
+
|
|
197
|
+
if tail is None:
|
|
198
|
+
logger.debug(
|
|
199
|
+
"Returning value for key",
|
|
200
|
+
extra={"item_key": item_key, "value": self.entries[item_key]},
|
|
201
|
+
)
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
logger.debug(
|
|
205
|
+
"Navigating to nested key",
|
|
206
|
+
extra={"tail": tail, "item_key": item_key},
|
|
207
|
+
)
|
|
208
|
+
next_locator = self.entries[item_key]
|
|
209
|
+
assert isinstance(
|
|
210
|
+
next_locator, Locator
|
|
211
|
+
), f"Intermediate at {item_key!r} is not a Locator"
|
|
212
|
+
return next_locator.locate(tail, root)
|
|
213
|
+
|
|
214
|
+
def set_value(self, key_path: EnvPath, value: RESULT_T) -> None:
|
|
215
|
+
"""Set the value at the given key path, resolving any aliases and
|
|
216
|
+
ensuring immutability rules are respected."""
|
|
217
|
+
|
|
218
|
+
logger.debug(
|
|
219
|
+
"Setting value for key_path",
|
|
220
|
+
extra={"key_path": str(key_path), "value": value},
|
|
221
|
+
)
|
|
222
|
+
value_obj = self.locate(key_path, self)
|
|
223
|
+
leaf = value_obj.entries[key_path.leaf]
|
|
224
|
+
assert isinstance(leaf, Value), f"Leaf at {key_path!s} is not a Value"
|
|
225
|
+
leaf.set_value(value)
|
|
226
|
+
|
|
227
|
+
def __repr__(self) -> str:
|
|
228
|
+
def build_paths(
|
|
229
|
+
current: Object, prefix: str = ""
|
|
230
|
+
) -> list[tuple[str, Value | Locator | str]]:
|
|
231
|
+
paths = []
|
|
232
|
+
if len(current.entries) == 0:
|
|
233
|
+
return [(prefix, "Empty")]
|
|
234
|
+
|
|
235
|
+
for key, val in current.entries.items():
|
|
236
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
237
|
+
if isinstance(val, Object):
|
|
238
|
+
paths.extend(build_paths(val, path))
|
|
239
|
+
else:
|
|
240
|
+
paths.append((path, val))
|
|
241
|
+
return paths
|
|
242
|
+
|
|
243
|
+
paths = build_paths(self)
|
|
244
|
+
paths.sort(key=lambda x: x[0])
|
|
245
|
+
return "\n".join(f"{p[0]}: {p[1]}" for p in paths)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def locator_values(root: Locator, prefix: str = "") -> Iterator[tuple[str, Value]]:
|
|
249
|
+
"""Yield ``(dotted_path, Value)`` for each Value leaf declared under *root*.
|
|
250
|
+
|
|
251
|
+
A container walk over the env's declared values, emitting root-relative
|
|
252
|
+
dotted paths. Aliases are pointers, not containers -- they declare no
|
|
253
|
+
values of their own -- so the walk never recurses through one (which would
|
|
254
|
+
otherwise duplicate the target's leaves under the alias's path, or loop).
|
|
255
|
+
"""
|
|
256
|
+
if not isinstance(root, Object):
|
|
257
|
+
return
|
|
258
|
+
for key, val in root.entries.items():
|
|
259
|
+
path = f"{prefix}.{key}" if prefix else key
|
|
260
|
+
if isinstance(val, Object):
|
|
261
|
+
yield from locator_values(val, path)
|
|
262
|
+
elif isinstance(val, Value):
|
|
263
|
+
yield (path, val)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def data_dependencies(root: Locator, roots: Iterable[str]) -> frozenset[str]:
|
|
267
|
+
"""Close a set of env paths through render templates.
|
|
268
|
+
|
|
269
|
+
Each path plus the identifiers embedded in any render value reached,
|
|
270
|
+
recursively. Recursion bottoms out because only immutable values can
|
|
271
|
+
hold a template, so a reached value's template contents are statically
|
|
272
|
+
known. Paths that resolve to no declared value are included as leaves
|
|
273
|
+
(they are read, even if reading would fail); assignment is not checked
|
|
274
|
+
here.
|
|
275
|
+
"""
|
|
276
|
+
leaves = dict(locator_values(root))
|
|
277
|
+
seen: set[str] = set()
|
|
278
|
+
stack: list[str] = list(roots)
|
|
279
|
+
while stack:
|
|
280
|
+
path_str = stack.pop()
|
|
281
|
+
if path_str in seen:
|
|
282
|
+
continue
|
|
283
|
+
seen.add(path_str)
|
|
284
|
+
leaf = leaves.get(path_str)
|
|
285
|
+
if leaf is not None:
|
|
286
|
+
stack.extend(leaf.template_identifiers())
|
|
287
|
+
return frozenset(seen)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def assigned_paths(root: Locator) -> frozenset[str]:
|
|
291
|
+
"""Dotted paths to every assigned Value leaf declared under *root*."""
|
|
292
|
+
return frozenset(path for path, v in locator_values(root) if v.assigned)
|
clankloop/core/errors.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Exception hierarchy for clankloop.
|
|
3
|
+
|
|
4
|
+
One root — :class:`ClankloopError` — so callers can catch any clankloop-raised
|
|
5
|
+
exception with a single ``except``. The three subtrees mirror the package's
|
|
6
|
+
concerns: :class:`EnvError` (environment/templating), :class:`GraphError`
|
|
7
|
+
(control-flow analysis and runtime), and :class:`ParameterError` (caller-side
|
|
8
|
+
parameter binding).
|
|
9
|
+
|
|
10
|
+
All exceptions live here so the error surface is greppable in one place.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ClankloopError(Exception):
|
|
16
|
+
"""Root of every exception clankloop raises."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EnvError(ClankloopError):
|
|
20
|
+
"""Base for environment/templating errors."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UnassignedValueError(EnvError):
|
|
24
|
+
"""Raised when trying to evaluate an unassigned value."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ImmutableValueError(EnvError):
|
|
28
|
+
"""Raised when trying to modify an already-assigned immutable value."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnknownKeyError(EnvError):
|
|
32
|
+
"""Raised when a key path references a key that does not exist."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GraphError(ClankloopError):
|
|
36
|
+
"""Base for execution-graph errors (static analysis and runtime)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnexpectedTerminationError(GraphError):
|
|
40
|
+
"""Raised when a task exits with a non-zero code and no failure branch matched."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UnknownTaskError(GraphError):
|
|
44
|
+
"""A branch targets a task that is not in the graph.
|
|
45
|
+
|
|
46
|
+
Raised at compile time by the graph validator (a configuration error) and at
|
|
47
|
+
runtime by execute() as a safety net against dangling branch targets.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class InfiniteLoopError(GraphError):
|
|
52
|
+
"""A reachable execution path never terminates.
|
|
53
|
+
|
|
54
|
+
Raised when the state-space walk revisits a (task, retry-budget-vector)
|
|
55
|
+
state already on its DFS stack. Because retry budgets only decrement,
|
|
56
|
+
such a revisit means the path repeats forever without exhausting a budget.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class UnassignedDataDependencyError(GraphError):
|
|
61
|
+
"""A task reads an env path that no reachable producer assigns.
|
|
62
|
+
|
|
63
|
+
Raised when the state-space walk reaches a task whose read set includes a
|
|
64
|
+
path not in the assigned set on the current path. Assignment grows
|
|
65
|
+
monotonically along a path as producers (SetActions on matched branches)
|
|
66
|
+
fire; the check is therefore path-sensitive.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ParameterError(ClankloopError):
|
|
71
|
+
"""Base for parameter-binding errors."""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MissingParameterError(ParameterError):
|
|
75
|
+
"""Raised when a required parameter is not supplied for a pipeline run."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class UnknownParameterError(ParameterError):
|
|
79
|
+
"""Raised when a supplied parameter name is not declared in the pipeline."""
|
clankloop/core/graph.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Execution graph engine — tasks, conditions, actions, and graph execution.
|
|
3
|
+
|
|
4
|
+
The execution graph (:class:`ExecutionGraph`) is the core runtime structure. It
|
|
5
|
+
holds a set of :class:`~clankloop.core.types.Execution` tasks, their branching
|
|
6
|
+
logic (conditions and actions), and a shared environment
|
|
7
|
+
(:class:`~clankloop.core.env.Object`).
|
|
8
|
+
|
|
9
|
+
This module is the public surface of the ``clankloop.core`` package: it
|
|
10
|
+
re-exports the domain types (:mod:`clankloop.core.types`), the error classes
|
|
11
|
+
(:mod:`clankloop.core.errors`), the bash execution backend
|
|
12
|
+
(:mod:`clankloop.core.bash`), and the LTS analyser
|
|
13
|
+
(:mod:`clankloop.core.lts`) so callers can import everything from
|
|
14
|
+
``clankloop.core.graph``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Mapping, Sequence
|
|
20
|
+
|
|
21
|
+
from clankloop.core.env import ClankTemplate # noqa: F401
|
|
22
|
+
from clankloop.core.env import Object
|
|
23
|
+
from clankloop.core.errors import ( # noqa: F401
|
|
24
|
+
InfiniteLoopError,
|
|
25
|
+
UnexpectedTerminationError,
|
|
26
|
+
UnassignedDataDependencyError,
|
|
27
|
+
UnknownTaskError,
|
|
28
|
+
)
|
|
29
|
+
# BashExecution is the one Execution backend shipped in core.
|
|
30
|
+
from clankloop.core.bash import BashExecution # noqa: F401
|
|
31
|
+
# LTS analyser — the public entry points the facade re-exports.
|
|
32
|
+
from clankloop.core.lts import (
|
|
33
|
+
LabeledTransitionSystem,
|
|
34
|
+
analyze,
|
|
35
|
+
data_dependencies as _data_dependencies,
|
|
36
|
+
validate_data_dependencies as _validate_data_dependencies,
|
|
37
|
+
)
|
|
38
|
+
# Domain types — the public surface re-exported from the types module.
|
|
39
|
+
from clankloop.core.types import (
|
|
40
|
+
Action,
|
|
41
|
+
AlwaysCondition,
|
|
42
|
+
Condition,
|
|
43
|
+
Execution,
|
|
44
|
+
ExecutionContext,
|
|
45
|
+
ExecutionResult,
|
|
46
|
+
IOChannels,
|
|
47
|
+
NextAction,
|
|
48
|
+
OnFailureCondition,
|
|
49
|
+
OnRetryCondition,
|
|
50
|
+
OnSuccessCondition,
|
|
51
|
+
SetAction,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger("clankloop")
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"ExecutionGraph",
|
|
58
|
+
"BashExecution",
|
|
59
|
+
"Execution",
|
|
60
|
+
"Condition",
|
|
61
|
+
"Action",
|
|
62
|
+
"OnSuccessCondition",
|
|
63
|
+
"OnFailureCondition",
|
|
64
|
+
"AlwaysCondition",
|
|
65
|
+
"OnRetryCondition",
|
|
66
|
+
"SetAction",
|
|
67
|
+
"NextAction",
|
|
68
|
+
"ExecutionResult",
|
|
69
|
+
"ExecutionContext",
|
|
70
|
+
"IOChannels",
|
|
71
|
+
"UnexpectedTerminationError",
|
|
72
|
+
"UnknownTaskError",
|
|
73
|
+
"InfiniteLoopError",
|
|
74
|
+
"UnassignedDataDependencyError",
|
|
75
|
+
"analyze",
|
|
76
|
+
"LabeledTransitionSystem",
|
|
77
|
+
"ClankTemplate",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ExecutionGraph:
|
|
82
|
+
"""A directed execution graph of tasks with conditional branching.
|
|
83
|
+
|
|
84
|
+
The graph holds named executions, their action branches (condition → actions),
|
|
85
|
+
and a shared environment. Execution starts at the entry task and walks
|
|
86
|
+
forward, evaluating conditions to pick the next task, set variables, or abort.
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
name: The pipeline name.
|
|
90
|
+
execs: Mapping of task name to :class:`Execution`.
|
|
91
|
+
exec_actions: Mapping of task name to branches (condition, actions).
|
|
92
|
+
env: The shared environment object.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
name: str,
|
|
98
|
+
execs: dict[str, "Execution"],
|
|
99
|
+
exec_actions: dict[str, Sequence[tuple[Condition, Sequence[Action]]]],
|
|
100
|
+
env: Object,
|
|
101
|
+
):
|
|
102
|
+
self.name = name
|
|
103
|
+
self.execs = execs
|
|
104
|
+
self.exec_actions = exec_actions
|
|
105
|
+
self.env = env
|
|
106
|
+
|
|
107
|
+
def captures_stdio(self, task: str) -> bool:
|
|
108
|
+
"""Whether any branch of *task* captures stdout/stderr into a SetAction.
|
|
109
|
+
|
|
110
|
+
Such tasks must run captured even under ``--interactive``: making them
|
|
111
|
+
interactive would starve the capture their SetActions depend on.
|
|
112
|
+
"""
|
|
113
|
+
return any(
|
|
114
|
+
isinstance(action, SetAction) and (action.stdout or action.stderr)
|
|
115
|
+
for _cond, actions in self.exec_actions.get(task, [])
|
|
116
|
+
for action in actions
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def validate(
|
|
120
|
+
self,
|
|
121
|
+
entry_task: str,
|
|
122
|
+
*,
|
|
123
|
+
parameter_paths: frozenset[str] = frozenset(),
|
|
124
|
+
check_data_dependencies: bool = False,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Statically verify the graph is well-formed before execution.
|
|
127
|
+
|
|
128
|
+
Builds the labeled transition system (raising ``UnknownTaskError`` for
|
|
129
|
+
a dangling branch target, ``InfiniteLoopError`` for an unbounded cycle
|
|
130
|
+
detected during construction) and, when ``check_data_dependencies`` is
|
|
131
|
+
set, runs the path-sensitive data-dependency check
|
|
132
|
+
(``UnassignedDataDependencyError``). Graph-only: no subprocess, no
|
|
133
|
+
env rendering.
|
|
134
|
+
|
|
135
|
+
The data-dependency check requires a loopfile format whose env layout
|
|
136
|
+
makes read sets statically determinable; formats that cannot support
|
|
137
|
+
it (e.g. v1) leave it off and rely on the runtime
|
|
138
|
+
``UnassignedValueError`` safety net.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
entry_task: The task the walk starts from.
|
|
142
|
+
parameter_paths: Env paths of declared parameters, which count as
|
|
143
|
+
assigned from the walk's start.
|
|
144
|
+
check_data_dependencies: Run the data-dependency phase.
|
|
145
|
+
"""
|
|
146
|
+
lts = analyze(self, entry_task, parameter_paths=parameter_paths)
|
|
147
|
+
if check_data_dependencies:
|
|
148
|
+
_validate_data_dependencies(lts, _data_dependencies(self))
|
|
149
|
+
|
|
150
|
+
def execute(
|
|
151
|
+
self,
|
|
152
|
+
exec_name: str,
|
|
153
|
+
*,
|
|
154
|
+
environ: Mapping[str, str],
|
|
155
|
+
io: IOChannels | None = None,
|
|
156
|
+
interactive: bool = False,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Walk the graph from *exec_name* against a pre-bound environment.
|
|
159
|
+
|
|
160
|
+
Parameters must be bound before this is called — via
|
|
161
|
+
:meth:`~clankloop.runner.Pipeline.bind`, which translates,
|
|
162
|
+
validates, and writes caller-supplied values into the env. The graph
|
|
163
|
+
executes a bound env; it never sees bare parameter names.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
exec_name: The name of the entry task.
|
|
167
|
+
environ: Base process environment for subprocess execution;
|
|
168
|
+
pipeline-declared values override it. Supplied by the caller
|
|
169
|
+
(the CLI reads ``os.environ`` once at the boundary).
|
|
170
|
+
io: CLI-owned streams the core tees subprocess output through.
|
|
171
|
+
Defaults to discard-forward + capture (today's behaviour).
|
|
172
|
+
interactive: When set, tasks whose branches capture no stdio
|
|
173
|
+
inherit the tty (stdin/stdout/stderr from *io*) so interactive
|
|
174
|
+
commands work; tasks that capture stdio always run captured.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
UnexpectedTerminationError: If a task fails and no failure branch
|
|
178
|
+
matches.
|
|
179
|
+
"""
|
|
180
|
+
if io is None:
|
|
181
|
+
io = IOChannels()
|
|
182
|
+
current_execution: Execution | None = self.execs[exec_name]
|
|
183
|
+
|
|
184
|
+
while current_execution is not None:
|
|
185
|
+
current_task_name = current_execution.name
|
|
186
|
+
logger.info(
|
|
187
|
+
"Executing task",
|
|
188
|
+
extra={"pipeline": self.name, "task": current_task_name},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
task_io = self._select_task_io(current_task_name, io, interactive)
|
|
192
|
+
context = ExecutionContext(env=self.env, environ=environ, io=task_io)
|
|
193
|
+
out = current_execution.execute(context)
|
|
194
|
+
|
|
195
|
+
logger.debug(
|
|
196
|
+
"Execution result",
|
|
197
|
+
extra={
|
|
198
|
+
"pipeline": self.name,
|
|
199
|
+
"task": current_task_name,
|
|
200
|
+
"stdout": out.stdout,
|
|
201
|
+
"stderr": out.stderr,
|
|
202
|
+
"returncode": out.returncode,
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
matched, next_exec_name = self._resolve_branch(current_task_name, out)
|
|
207
|
+
|
|
208
|
+
if out.returncode != 0 and not matched:
|
|
209
|
+
raise UnexpectedTerminationError(
|
|
210
|
+
f"Task {current_task_name!r} in pipeline {self.name!r} "
|
|
211
|
+
f"terminated unexpectedly with exit code {out.returncode}."
|
|
212
|
+
f"Stdout: {out.stdout}\nStderr: {out.stderr}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if next_exec_name is None:
|
|
216
|
+
current_execution = None
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
logger.debug(
|
|
220
|
+
"Next task determined",
|
|
221
|
+
extra={"pipeline": self.name, "next_task": next_exec_name},
|
|
222
|
+
)
|
|
223
|
+
if next_exec_name not in self.execs:
|
|
224
|
+
raise UnknownTaskError(
|
|
225
|
+
f"Task {current_task_name!r} in pipeline {self.name!r} "
|
|
226
|
+
f"branches to unknown task {next_exec_name!r}"
|
|
227
|
+
)
|
|
228
|
+
current_execution = self.execs[next_exec_name]
|
|
229
|
+
logger.debug(
|
|
230
|
+
"Execution loop iteration complete",
|
|
231
|
+
extra={"pipeline": self.name},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def _select_task_io(
|
|
235
|
+
self, task: str, io: IOChannels, interactive: bool
|
|
236
|
+
) -> IOChannels:
|
|
237
|
+
"""Pick the IO channels for *task* under the current interactivity mode.
|
|
238
|
+
|
|
239
|
+
A task inherits the tty only under ``--interactive`` AND when it
|
|
240
|
+
captures no stdio into a SetAction (capturing tasks must run
|
|
241
|
+
captured). stdin presence is the signal :class:`BashExecution` reads.
|
|
242
|
+
"""
|
|
243
|
+
if interactive and not self.captures_stdio(task):
|
|
244
|
+
return io
|
|
245
|
+
return IOChannels(stdin=None, stdout=io.stdout, stderr=io.stderr)
|
|
246
|
+
|
|
247
|
+
def _resolve_branch(
|
|
248
|
+
self, task: str, result: ExecutionResult
|
|
249
|
+
) -> tuple[bool, str | None]:
|
|
250
|
+
"""Resolve the branch outcome for *task* given *result*.
|
|
251
|
+
|
|
252
|
+
Mirrors the LTS walker's ``_resolve_outcome``: the first matching
|
|
253
|
+
condition wins; its actions run in order, and the first NextAction
|
|
254
|
+
redirects. Returns ``(matched, next_task)`` — ``matched`` is True if
|
|
255
|
+
any condition matched, ``next_task`` is the redirect target or None.
|
|
256
|
+
"""
|
|
257
|
+
for condition, actions in self.exec_actions.get(task, []):
|
|
258
|
+
if not condition.matches(result):
|
|
259
|
+
continue
|
|
260
|
+
logger.debug(
|
|
261
|
+
"Condition matched",
|
|
262
|
+
extra={
|
|
263
|
+
"pipeline": self.name,
|
|
264
|
+
"condition": condition.__class__.__name__,
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
for action in actions:
|
|
268
|
+
next_name = action.next(self.env, result)
|
|
269
|
+
if next_name is not None:
|
|
270
|
+
return (True, next_name)
|
|
271
|
+
return (True, None)
|
|
272
|
+
return (False, None)
|