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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Clankshed module — expands ``clankshed``-type module tasks into bash tasks.
|
|
3
|
+
|
|
4
|
+
Each clankshed task is expanded into four sub-tasks: work, repair, static-check,
|
|
5
|
+
and review. The flow is: work → static-check → review → next; on failure,
|
|
6
|
+
static-check or review retries from repair.
|
|
7
|
+
"""
|
|
8
|
+
from clankloop.loopfile.v2.module import Module, ModuleDependency
|
|
9
|
+
|
|
10
|
+
from clankloop.loopfile.v2.loopfile import (
|
|
11
|
+
Loopfile,
|
|
12
|
+
BashTask,
|
|
13
|
+
ModuleTask,
|
|
14
|
+
Task,
|
|
15
|
+
NextTaskAction,
|
|
16
|
+
Action,
|
|
17
|
+
RetryFromAction,
|
|
18
|
+
make_converter,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from attrs import frozen
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@frozen
|
|
25
|
+
class ClankshedSubTask:
|
|
26
|
+
clankshed_cmd: str
|
|
27
|
+
on_success: list[Action] | None = None
|
|
28
|
+
on_failure: list[Action] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@frozen
|
|
32
|
+
class CheckSubTask:
|
|
33
|
+
cmd: str
|
|
34
|
+
on_success: list[Action] | None = None
|
|
35
|
+
on_failure: list[Action] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@frozen
|
|
39
|
+
class ClankshedTaskExtra:
|
|
40
|
+
work_task: ClankshedSubTask
|
|
41
|
+
repair_task: ClankshedSubTask
|
|
42
|
+
review_task: ClankshedSubTask
|
|
43
|
+
static_check: CheckSubTask
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_converter = make_converter()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ClankshedModule(Module):
|
|
50
|
+
VERSION = "1.0.0"
|
|
51
|
+
DEPENDENCIES: tuple[ModuleDependency, ...] = ()
|
|
52
|
+
APPLY_BEFORE: tuple[str, ...] = ()
|
|
53
|
+
APPLY_AFTER: tuple[str, ...] = ()
|
|
54
|
+
|
|
55
|
+
def __init__(self, **module_args):
|
|
56
|
+
self.session = module_args.get("session", "default")
|
|
57
|
+
self.clankshed_file = module_args.get("clankshed_file", "clankshed.yaml")
|
|
58
|
+
|
|
59
|
+
def transform_loopfile(self, src: Loopfile) -> Loopfile:
|
|
60
|
+
new_tasks: list[Task] = []
|
|
61
|
+
|
|
62
|
+
for task in src.tasks:
|
|
63
|
+
if isinstance(task, BashTask):
|
|
64
|
+
new_tasks.append(task)
|
|
65
|
+
elif isinstance(task, ModuleTask) and task.type == "clankshed":
|
|
66
|
+
new_tasks.extend(self.transform_clankshed_task(task))
|
|
67
|
+
|
|
68
|
+
return Loopfile(
|
|
69
|
+
name=src.name,
|
|
70
|
+
constants=src.constants,
|
|
71
|
+
locals=src.locals,
|
|
72
|
+
tasks=new_tasks,
|
|
73
|
+
parameters=src.parameters,
|
|
74
|
+
modules=src.modules,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def transform_clankshed_task(self, task: ModuleTask) -> list[BashTask]:
|
|
78
|
+
"""
|
|
79
|
+
Generate:
|
|
80
|
+
1. Initial task for starting task
|
|
81
|
+
2. Repair task
|
|
82
|
+
3. Static check task
|
|
83
|
+
4. Review task
|
|
84
|
+
|
|
85
|
+
on success: 1 -> 3 -> 4 -> next task in pipeline
|
|
86
|
+
2 -> 3 -> 4 -> next task in pipeline
|
|
87
|
+
|
|
88
|
+
on retry: 3 -> 2, 4 -> 2
|
|
89
|
+
"""
|
|
90
|
+
extra = _converter.structure(task.extra_fields, ClankshedTaskExtra)
|
|
91
|
+
|
|
92
|
+
work_name = f"{task.name}-work"
|
|
93
|
+
repair_name = f"{task.name}-repair"
|
|
94
|
+
static_check_name = f"{task.name}-static-check"
|
|
95
|
+
review_name = f"{task.name}-review"
|
|
96
|
+
|
|
97
|
+
return [
|
|
98
|
+
BashTask(
|
|
99
|
+
name=work_name,
|
|
100
|
+
cmds=f"clankshed run -f {self.clankshed_file} -- {extra.work_task.clankshed_cmd}",
|
|
101
|
+
constants=task.constants,
|
|
102
|
+
on_success=[NextTaskAction(goto=static_check_name)],
|
|
103
|
+
),
|
|
104
|
+
BashTask(
|
|
105
|
+
name=repair_name,
|
|
106
|
+
cmds=f"clankshed run -f {self.clankshed_file} -- {extra.repair_task.clankshed_cmd}",
|
|
107
|
+
constants=task.constants,
|
|
108
|
+
),
|
|
109
|
+
BashTask(
|
|
110
|
+
name=static_check_name,
|
|
111
|
+
cmds=extra.static_check.cmd,
|
|
112
|
+
constants=task.constants,
|
|
113
|
+
on_failure=[RetryFromAction(retry_from=repair_name, retries=2)],
|
|
114
|
+
),
|
|
115
|
+
BashTask(
|
|
116
|
+
name=review_name,
|
|
117
|
+
cmds=f"clankshed run -f {self.clankshed_file} -- {extra.review_task.clankshed_cmd}",
|
|
118
|
+
constants=task.constants,
|
|
119
|
+
on_failure=[RetryFromAction(retry_from=repair_name, retries=2)],
|
|
120
|
+
),
|
|
121
|
+
]
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Loopfile v2 compiler — transforms a v2 loopfile spec into a Pipeline.
|
|
3
|
+
|
|
4
|
+
Applies registered modules to transform the loopfile, then compiles the
|
|
5
|
+
resulting bash tasks into an :class:`~clankloop.core.graph.ExecutionGraph`
|
|
6
|
+
with a structured environment (``pipeline.name``, ``pipeline.constants``,
|
|
7
|
+
``pipeline.parameters``, ``pipeline.globals``).
|
|
8
|
+
"""
|
|
9
|
+
import clankloop.core.graph as graph
|
|
10
|
+
import clankloop.loopfile.v2.loopfile as loopfile
|
|
11
|
+
from clankloop.core.env import Object, Value, ClankTemplate
|
|
12
|
+
from clankloop.loopfile.v2 import paths as v2_paths
|
|
13
|
+
from clankloop.loopfile.v2.module_registry import ModuleRegistry, MissingModuleDependencyError
|
|
14
|
+
from clankloop.loopfile.v2.toposort import CycleError, OrderedEdge, topological_sort
|
|
15
|
+
from clankloop.runner import ParameterBinding, Pipeline
|
|
16
|
+
from typing import Any, Sequence
|
|
17
|
+
from itertools import takewhile
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("clankloop")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CircularModuleOrderError(ValueError):
|
|
24
|
+
"""Raised when merged module ordering constraints form a cycle."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compile_pipeline(
|
|
28
|
+
data: dict[str, Any],
|
|
29
|
+
pipeline_name: str | None = None,
|
|
30
|
+
module_registry: ModuleRegistry | None = None,
|
|
31
|
+
) -> Pipeline:
|
|
32
|
+
"""Compile a v2 loopfile into a :class:`~clankloop.runner.Pipeline`.
|
|
33
|
+
|
|
34
|
+
Parses the raw YAML data, applies registered modules to transform the
|
|
35
|
+
loopfile, then compiles bash tasks into an execution graph.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
data: Parsed YAML dictionary.
|
|
39
|
+
pipeline_name: Ignored — a v2 loopfile defines exactly one pipeline,
|
|
40
|
+
named by the loopfile's ``name`` field.
|
|
41
|
+
module_registry: Optional custom module registry. If ``None``, a
|
|
42
|
+
fresh registry with builtins is used.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A compiled pipeline ready for execution.
|
|
46
|
+
"""
|
|
47
|
+
loopfile_raw = loopfile.parse(data)
|
|
48
|
+
|
|
49
|
+
loopfile_spec = apply_modules(loopfile_raw, module_registry)
|
|
50
|
+
|
|
51
|
+
execs: dict[str, graph.Execution] = {
|
|
52
|
+
t.name: compile_bash_task(t) for t in loopfile_spec.tasks
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
exec_actions: dict[
|
|
56
|
+
str, Sequence[tuple[graph.Condition, Sequence[graph.Action]]]
|
|
57
|
+
] = {
|
|
58
|
+
task.name: compile_task_actions(task, _next_task_name(loopfile_spec, i))
|
|
59
|
+
for i, task in enumerate(loopfile_spec.tasks)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
root_env = Object(
|
|
63
|
+
{
|
|
64
|
+
"pipeline": Object(
|
|
65
|
+
{
|
|
66
|
+
"name": Value(
|
|
67
|
+
loopfile_spec.name, immutable=True, assigned=True
|
|
68
|
+
),
|
|
69
|
+
"constants": Object(
|
|
70
|
+
{
|
|
71
|
+
k: Value(ClankTemplate(v), immutable=True, assigned=True)
|
|
72
|
+
for k, v in loopfile_spec.constants.items()
|
|
73
|
+
}
|
|
74
|
+
),
|
|
75
|
+
"parameters": Object(
|
|
76
|
+
{
|
|
77
|
+
ps.name: Value(
|
|
78
|
+
"", immutable=False, assigned=False
|
|
79
|
+
)
|
|
80
|
+
for ps in loopfile_spec.parameters
|
|
81
|
+
}
|
|
82
|
+
),
|
|
83
|
+
"globals": Object(
|
|
84
|
+
{
|
|
85
|
+
name: Value(
|
|
86
|
+
"", immutable=False, assigned=False
|
|
87
|
+
)
|
|
88
|
+
for name in loopfile_spec.locals
|
|
89
|
+
}
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
parameter_bindings = tuple(
|
|
97
|
+
ParameterBinding(
|
|
98
|
+
name=ps.name,
|
|
99
|
+
path=v2_paths.parameter(ps.name),
|
|
100
|
+
required=ps.required,
|
|
101
|
+
default=ps.default,
|
|
102
|
+
)
|
|
103
|
+
for ps in loopfile_spec.parameters
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
execution_graph = graph.ExecutionGraph(
|
|
107
|
+
loopfile_spec.name, execs, exec_actions, root_env
|
|
108
|
+
)
|
|
109
|
+
execution_graph.validate(
|
|
110
|
+
loopfile_spec.tasks[0].name,
|
|
111
|
+
parameter_paths=frozenset(str(b.path) for b in parameter_bindings),
|
|
112
|
+
check_data_dependencies=True,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return Pipeline(
|
|
116
|
+
loopfile_spec.name,
|
|
117
|
+
execution_graph,
|
|
118
|
+
loopfile_spec.tasks[0].name, # start_task
|
|
119
|
+
parameter_bindings,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def apply_modules(
|
|
124
|
+
loopfile_spec: loopfile.Loopfile, module_registry: ModuleRegistry | None
|
|
125
|
+
) -> loopfile.Loopfile:
|
|
126
|
+
"""Apply registered modules to a loopfile spec in resolved order.
|
|
127
|
+
|
|
128
|
+
Resolves the module application order (merging module-declared and
|
|
129
|
+
user-declared ordering constraints), validates the dependency closure,
|
|
130
|
+
then applies each module's transform in sequence.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
loopfile_spec: The parsed loopfile spec.
|
|
134
|
+
module_registry: The module registry to use (falls back to builtins
|
|
135
|
+
if ``None``).
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The transformed loopfile spec (all module tasks resolved to bash
|
|
139
|
+
tasks).
|
|
140
|
+
"""
|
|
141
|
+
if module_registry is None:
|
|
142
|
+
module_registry = ModuleRegistry.with_builtins()
|
|
143
|
+
|
|
144
|
+
module_order = resolve_modules(loopfile_spec.modules, module_registry)
|
|
145
|
+
|
|
146
|
+
spec = loopfile_spec
|
|
147
|
+
for module_spec in module_order:
|
|
148
|
+
module = module_registry.get(module_spec.name)(**module_spec.extra_fields)
|
|
149
|
+
spec = module.transform_loopfile(spec)
|
|
150
|
+
return spec
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def resolve_modules(
|
|
154
|
+
modules_spec: list[loopfile.ModuleSpec],
|
|
155
|
+
registry: ModuleRegistry,
|
|
156
|
+
) -> list[loopfile.ModuleSpec]:
|
|
157
|
+
"""Validate dependencies and topologically sort modules by merged ordering.
|
|
158
|
+
|
|
159
|
+
Three sources of ordering edges are merged into one graph:
|
|
160
|
+
|
|
161
|
+
1. Module-declared ``APPLY_BEFORE`` / ``APPLY_AFTER`` (wrapping semantics).
|
|
162
|
+
2. User-declared ``after`` on ``ModuleSpec`` (supplemental ordering).
|
|
163
|
+
3. Dependencies are *not* ordering edges — they're presence constraints
|
|
164
|
+
validated separately.
|
|
165
|
+
|
|
166
|
+
If the merged graph has a cycle, the error attributes each edge to its
|
|
167
|
+
source (module-declared or user-declared) so the conflict is traceable.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
modules_spec: The user's module list from the loopfile.
|
|
171
|
+
registry: The module registry (source of module-declared facts).
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Modules in application order.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValueError: If modules are repeated.
|
|
178
|
+
MissingModuleDependencyError: If a module's dependency is not in the
|
|
179
|
+
user's module list.
|
|
180
|
+
CircularModuleOrderError: If the merged ordering constraints form a
|
|
181
|
+
cycle.
|
|
182
|
+
"""
|
|
183
|
+
names = [m.name for m in modules_spec]
|
|
184
|
+
if len(set(names)) != len(names):
|
|
185
|
+
raise ValueError("Modules are repeated")
|
|
186
|
+
|
|
187
|
+
user_modules = {m.name: m for m in modules_spec}
|
|
188
|
+
|
|
189
|
+
_validate_dependencies(user_modules, registry)
|
|
190
|
+
|
|
191
|
+
edges = _collect_ordering_edges(user_modules, registry)
|
|
192
|
+
try:
|
|
193
|
+
ordered = topological_sort(names, edges)
|
|
194
|
+
except CycleError as exc:
|
|
195
|
+
raise CircularModuleOrderError(str(exc)) from exc
|
|
196
|
+
return [user_modules[n] for n in ordered]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _validate_dependencies(
|
|
200
|
+
user_modules: dict[str, loopfile.ModuleSpec], registry: ModuleRegistry
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Check every module's declared dependencies are in the user's list."""
|
|
203
|
+
missing = list(
|
|
204
|
+
(name, dep)
|
|
205
|
+
for name in user_modules
|
|
206
|
+
for dep in registry.get(name).DEPENDENCIES
|
|
207
|
+
if dep.name not in user_modules
|
|
208
|
+
)
|
|
209
|
+
if missing:
|
|
210
|
+
descriptions = ", ".join(
|
|
211
|
+
f"{name!r} requires {dep.name!r} (version {dep.version})"
|
|
212
|
+
for name, dep in missing
|
|
213
|
+
)
|
|
214
|
+
raise MissingModuleDependencyError(
|
|
215
|
+
f"Missing module dependencies: {descriptions}"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _collect_ordering_edges(
|
|
220
|
+
user_modules: dict[str, loopfile.ModuleSpec], registry: ModuleRegistry
|
|
221
|
+
) -> list[OrderedEdge]:
|
|
222
|
+
"""Merge module-declared and user-declared ordering into one edge list.
|
|
223
|
+
|
|
224
|
+
An edge ``(before, after)`` means *before* must be applied before *after*.
|
|
225
|
+
"""
|
|
226
|
+
apply_before = [
|
|
227
|
+
OrderedEdge(before=name, after=target, source=f"{name} APPLY_BEFORE {target}")
|
|
228
|
+
for name in user_modules
|
|
229
|
+
for target in registry.get(name).APPLY_BEFORE
|
|
230
|
+
]
|
|
231
|
+
apply_after = [
|
|
232
|
+
OrderedEdge(before=target, after=name, source=f"{name} APPLY_AFTER {target}")
|
|
233
|
+
for name in user_modules
|
|
234
|
+
for target in registry.get(name).APPLY_AFTER
|
|
235
|
+
]
|
|
236
|
+
user_declared = [
|
|
237
|
+
OrderedEdge(before=target, after=spec.name, source=f"user: {spec.name} after {target}")
|
|
238
|
+
for spec in user_modules.values()
|
|
239
|
+
for target in spec.after
|
|
240
|
+
]
|
|
241
|
+
return apply_before + apply_after + user_declared
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def compile_bash_task(task: loopfile.Task) -> graph.BashExecution:
|
|
245
|
+
if not isinstance(task, loopfile.BashTask):
|
|
246
|
+
raise ValueError(f"Task {task.name!r} is not a BashTask")
|
|
247
|
+
if isinstance(task.cmds, str):
|
|
248
|
+
cmds_tpl = ClankTemplate(task.cmds)
|
|
249
|
+
else:
|
|
250
|
+
cmds_tpl = task.cmds
|
|
251
|
+
exports = dict(task.exports)
|
|
252
|
+
return graph.BashExecution(task.name, cmds_tpl, exports)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def compile_task_actions(
|
|
256
|
+
task: loopfile.Task,
|
|
257
|
+
next_task: str | None,
|
|
258
|
+
) -> list[tuple[graph.Condition, list[graph.Action]]]:
|
|
259
|
+
if not isinstance(task, loopfile.BashTask):
|
|
260
|
+
raise ValueError(f"Task {task.name!r} is not a BashTask")
|
|
261
|
+
branches: list[tuple[graph.Condition, list[graph.Action]]] = []
|
|
262
|
+
|
|
263
|
+
# 1. Compile OnSuccess branch
|
|
264
|
+
success_actions = compile_actions_list(task.on_success or [], is_success=True)
|
|
265
|
+
|
|
266
|
+
# If no flow action is present and next task exists, append default NextAction
|
|
267
|
+
has_flow_action = any(isinstance(act, graph.NextAction) for act in success_actions)
|
|
268
|
+
if not has_flow_action and next_task is not None:
|
|
269
|
+
# Check if setup or any set action terminated early via an Abort check
|
|
270
|
+
aborted = any(
|
|
271
|
+
isinstance(act, loopfile.AbortAction) for act in (task.on_success or [])
|
|
272
|
+
)
|
|
273
|
+
if not aborted:
|
|
274
|
+
success_actions.append(graph.NextAction(next_task=next_task))
|
|
275
|
+
|
|
276
|
+
if success_actions:
|
|
277
|
+
branches.append((graph.OnSuccessCondition(), success_actions))
|
|
278
|
+
|
|
279
|
+
# 2. Compile OnFailure/Retry branches
|
|
280
|
+
failure_actions = compile_actions_list(task.on_failure or [], is_success=False)
|
|
281
|
+
|
|
282
|
+
# Check for RetryFromAction setup
|
|
283
|
+
retry_actions = [
|
|
284
|
+
act
|
|
285
|
+
for act in (task.on_failure or [])
|
|
286
|
+
if isinstance(act, loopfile.RetryFromAction)
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
if retry_actions:
|
|
290
|
+
# If retry condition is hit and we have retries remaining, loop back
|
|
291
|
+
retry = retry_actions[0]
|
|
292
|
+
branches.append(
|
|
293
|
+
(graph.OnRetryCondition(retries=retry.retries), [graph.NextAction(next_task=retry.retry_from)])
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
if failure_actions:
|
|
297
|
+
branches.append((graph.OnFailureCondition(), failure_actions))
|
|
298
|
+
|
|
299
|
+
return branches
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def compile_actions_list(
|
|
303
|
+
actions: list[loopfile.Action],
|
|
304
|
+
is_success: bool,
|
|
305
|
+
) -> list[graph.Action]:
|
|
306
|
+
return [
|
|
307
|
+
_compile_action(action)
|
|
308
|
+
for action in takewhile(lambda a: not isinstance(a, loopfile.AbortAction), actions)
|
|
309
|
+
if not isinstance(action, loopfile.RetryFromAction)
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _compile_action(action: loopfile.Action) -> graph.Action:
|
|
314
|
+
if isinstance(action, loopfile.SetAction):
|
|
315
|
+
stdout = action.value == "stdout"
|
|
316
|
+
stderr = action.value == "stderr"
|
|
317
|
+
logger.debug("Creating SetAction %s", action.set)
|
|
318
|
+
return graph.SetAction(
|
|
319
|
+
var_name=v2_paths.glob(action.set),
|
|
320
|
+
value=None if stdout or stderr else action.value,
|
|
321
|
+
stdout=stdout,
|
|
322
|
+
stderr=stderr,
|
|
323
|
+
)
|
|
324
|
+
assert isinstance(action, loopfile.NextTaskAction)
|
|
325
|
+
return graph.NextAction(next_task=action.goto)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _next_task_name(loopfile_spec: loopfile.Loopfile, index: int) -> str | None:
|
|
329
|
+
if index + 1 >= len(loopfile_spec.tasks):
|
|
330
|
+
return None
|
|
331
|
+
return loopfile_spec.tasks[index + 1].name
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Git module — injects git clone/commit/push tasks around the pipeline.
|
|
3
|
+
|
|
4
|
+
Adds a ``git-checkout`` task (clones the repository, checks out branches) at
|
|
5
|
+
the start and a ``git-push`` task (commits and pushes) at the end. Configured
|
|
6
|
+
via ``work_repository``, ``src_branch``, ``dst_branch``, and ``commit_template``.
|
|
7
|
+
"""
|
|
8
|
+
from clankloop.loopfile.v2.module import Module, ModuleDependency
|
|
9
|
+
|
|
10
|
+
from clankloop.loopfile.v2.loopfile import Loopfile, BashTask, NextTaskAction
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GitModule(Module):
|
|
14
|
+
VERSION = "1.0.0"
|
|
15
|
+
DEPENDENCIES = (
|
|
16
|
+
ModuleDependency(name="workdir", version=">=1.0"),
|
|
17
|
+
)
|
|
18
|
+
APPLY_BEFORE = ("workdir",)
|
|
19
|
+
APPLY_AFTER: tuple[str, ...] = ()
|
|
20
|
+
|
|
21
|
+
def __init__(self, **module_args):
|
|
22
|
+
self.repo_url = module_args.get("work_repository")
|
|
23
|
+
self.src_branch = module_args.get("src_branch", "master")
|
|
24
|
+
self.dst_branch = module_args.get("dst_branch", "master")
|
|
25
|
+
self.commit_template = module_args.get("commit_template", "Clankloop commit")
|
|
26
|
+
|
|
27
|
+
def transform_loopfile(self, src: Loopfile) -> Loopfile:
|
|
28
|
+
new_constants = {
|
|
29
|
+
**src.constants,
|
|
30
|
+
"git_work_repository": self.repo_url,
|
|
31
|
+
"git_repo_name": self._git_repo_name(),
|
|
32
|
+
"git_repo_dir": "${pipeline.globals.workdir}/${pipeline.constants.git_repo_name}",
|
|
33
|
+
"git_src_branch": self.src_branch,
|
|
34
|
+
"git_dst_branch": self.dst_branch,
|
|
35
|
+
"git_commit_template": self.commit_template,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
new_tasks = [
|
|
39
|
+
BashTask(
|
|
40
|
+
name="git-checkout",
|
|
41
|
+
cmds="""
|
|
42
|
+
cd ${pipeline.globals.workdir}
|
|
43
|
+
git clone ${pipeline.constants.git_work_repository}
|
|
44
|
+
cd ${pipeline.constants.git_repo_dir}
|
|
45
|
+
git checkout ${pipeline.constants.git_src_branch}
|
|
46
|
+
git checkout -b ${pipeline.constants.git_dst_branch} || true
|
|
47
|
+
""",
|
|
48
|
+
on_failure=[NextTaskAction(goto="workdir-remove")],
|
|
49
|
+
),
|
|
50
|
+
*src.tasks,
|
|
51
|
+
BashTask(
|
|
52
|
+
name="git-push",
|
|
53
|
+
cmds="""
|
|
54
|
+
git commit -m "${pipeline.constants.git_commit_template}"
|
|
55
|
+
git push --set-upstream origin ${pipeline.constants.git_dst_branch}
|
|
56
|
+
""",
|
|
57
|
+
),
|
|
58
|
+
]
|
|
59
|
+
return Loopfile(
|
|
60
|
+
name=src.name,
|
|
61
|
+
constants=new_constants,
|
|
62
|
+
locals=src.locals,
|
|
63
|
+
tasks=new_tasks,
|
|
64
|
+
parameters=src.parameters,
|
|
65
|
+
modules=src.modules,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def _git_repo_name(self) -> str:
|
|
69
|
+
if self.repo_url.endswith(".git"):
|
|
70
|
+
return self.repo_url.split("/")[-1][:-4]
|
|
71
|
+
else:
|
|
72
|
+
return self.repo_url.split("/")[-1]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Loopfile v2 schema — dataclasses and YAML parsing for v2 loopfiles.
|
|
3
|
+
|
|
4
|
+
A v2 loopfile defines a single pipeline with bash tasks and module tasks.
|
|
5
|
+
Module tasks are placeholders that are transformed into bash tasks by
|
|
6
|
+
registered modules (e.g. workdir, git, clankshed) before compilation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import attrs
|
|
13
|
+
from attrs import frozen, fields
|
|
14
|
+
import cattrs
|
|
15
|
+
from cattrs import Converter
|
|
16
|
+
|
|
17
|
+
from clankloop.core.env import EnvPath
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@frozen
|
|
21
|
+
class ParameterSpec:
|
|
22
|
+
name: str
|
|
23
|
+
required: bool = False
|
|
24
|
+
default: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@frozen
|
|
28
|
+
class SetAction:
|
|
29
|
+
set: str
|
|
30
|
+
value: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@frozen
|
|
34
|
+
class RetryFromAction:
|
|
35
|
+
retry_from: str
|
|
36
|
+
retries: int
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@frozen
|
|
40
|
+
class NextTaskAction:
|
|
41
|
+
goto: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@frozen
|
|
45
|
+
class AbortAction:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
Action = SetAction | RetryFromAction | NextTaskAction | AbortAction
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@frozen
|
|
53
|
+
class ModuleTask:
|
|
54
|
+
name: str
|
|
55
|
+
type: str
|
|
56
|
+
constants: dict[str, Any] = attrs.Factory(dict)
|
|
57
|
+
extra_fields: dict[str, Any] = attrs.Factory(dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@frozen
|
|
61
|
+
class BashTask:
|
|
62
|
+
name: str
|
|
63
|
+
cmds: str
|
|
64
|
+
constants: dict[str, Any] = attrs.Factory(dict)
|
|
65
|
+
exports: dict[str, EnvPath] = attrs.Factory(dict)
|
|
66
|
+
on_success: list[Action] | None = None
|
|
67
|
+
on_failure: list[Action] | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
Task = BashTask | ModuleTask
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@frozen
|
|
74
|
+
class ModuleSpec:
|
|
75
|
+
name: str
|
|
76
|
+
after: list[str] = attrs.Factory(list)
|
|
77
|
+
extra_fields: dict[str, Any] = attrs.Factory(dict)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@frozen
|
|
81
|
+
class Loopfile:
|
|
82
|
+
name: str
|
|
83
|
+
constants: dict[str, Any] = attrs.Factory(dict)
|
|
84
|
+
locals: list[str] = attrs.Factory(list)
|
|
85
|
+
tasks: list[Task] = attrs.Factory(list)
|
|
86
|
+
parameters: list[ParameterSpec] = attrs.Factory(list)
|
|
87
|
+
modules: list[ModuleSpec] = attrs.Factory(list)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _structure_action(data: Any, _type: Any, converter: Converter) -> Action:
|
|
91
|
+
if not isinstance(data, dict):
|
|
92
|
+
raise ValueError(f"Expected dict for Action, got {type(data).__name__}")
|
|
93
|
+
if "abort" in data:
|
|
94
|
+
return AbortAction()
|
|
95
|
+
if "retry_from" in data:
|
|
96
|
+
return converter.structure(data, RetryFromAction)
|
|
97
|
+
if "set" in data:
|
|
98
|
+
return converter.structure(data, SetAction)
|
|
99
|
+
if "goto" in data:
|
|
100
|
+
return converter.structure(data, NextTaskAction)
|
|
101
|
+
|
|
102
|
+
raise ValueError(f"Unrecognised action keys: {set(data.keys())}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def make_converter() -> Converter:
|
|
106
|
+
converter = cattrs.Converter()
|
|
107
|
+
|
|
108
|
+
def structure_frozen_with_extra(data: dict, cls):
|
|
109
|
+
known_keys = {f.name for f in fields(cls)}
|
|
110
|
+
known_data = {}
|
|
111
|
+
extra_data = {}
|
|
112
|
+
for key, value in data.items():
|
|
113
|
+
if key in known_keys:
|
|
114
|
+
known_data[key] = value
|
|
115
|
+
else:
|
|
116
|
+
extra_data[key] = value
|
|
117
|
+
if "extra_fields" in known_keys:
|
|
118
|
+
known_data["extra_fields"] = extra_data
|
|
119
|
+
return converter.structure_attrs_fromdict(known_data, cls)
|
|
120
|
+
|
|
121
|
+
converter.register_structure_hook_func(
|
|
122
|
+
lambda t: t is EnvPath,
|
|
123
|
+
lambda data, t: EnvPath.parse(data) if isinstance(data, str) else data,
|
|
124
|
+
)
|
|
125
|
+
converter.register_structure_hook_func(
|
|
126
|
+
lambda t: t is Action,
|
|
127
|
+
lambda data, t: _structure_action(data, t, converter),
|
|
128
|
+
)
|
|
129
|
+
converter.register_structure_hook_func(
|
|
130
|
+
lambda t: t is Task,
|
|
131
|
+
lambda data, t: (
|
|
132
|
+
converter.structure(data, ModuleTask)
|
|
133
|
+
if "type" in data
|
|
134
|
+
else converter.structure(data, BashTask)
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
converter.register_structure_hook(ModuleSpec, structure_frozen_with_extra)
|
|
138
|
+
converter.register_structure_hook(ModuleTask, structure_frozen_with_extra)
|
|
139
|
+
return converter
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse(data: dict[str, Any]) -> Loopfile:
|
|
143
|
+
"""Parse raw YAML data into a v2 :class:`Loopfile` spec.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
data: Parsed YAML dictionary.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
A structured :class:`Loopfile` instance.
|
|
150
|
+
"""
|
|
151
|
+
converter = make_converter()
|
|
152
|
+
return converter.structure(data, Loopfile)
|