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.
@@ -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)