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,69 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Module system for v2 loopfiles — protocols, dependencies, and ordering.
|
|
3
|
+
|
|
4
|
+
A :class:`Module` receives a :class:`~clankloop.loopfile.v2.loopfile.Loopfile`
|
|
5
|
+
spec and returns a transformed one, typically injecting setup/teardown tasks.
|
|
6
|
+
Modules are registered by name in a :class:`~clankloop.loopfile.v2.module_registry.ModuleRegistry`
|
|
7
|
+
and applied in dependency-resolved order (via the ``after`` field on
|
|
8
|
+
:class:`~clankloop.loopfile.v2.loopfile.ModuleSpec` and module-declared ordering
|
|
9
|
+
constraints).
|
|
10
|
+
|
|
11
|
+
Three sources of ordering constraints are merged into one graph:
|
|
12
|
+
|
|
13
|
+
1. **Dependencies** (``DEPENDENCIES``) — presence only, no ordering edge. A
|
|
14
|
+
module needs its dependencies registered at a compatible version, but
|
|
15
|
+
dependency does not imply application order.
|
|
16
|
+
2. **Module-declared ordering** (``APPLY_BEFORE`` / ``APPLY_AFTER``) — wrapping
|
|
17
|
+
semantics. ``GitModule`` applies before ``workdir`` because git's tasks
|
|
18
|
+
go inside workdir's wrapper tasks.
|
|
19
|
+
3. **User-declared ordering** (``after`` on ``ModuleSpec``) — supplemental
|
|
20
|
+
edges for cases the module author didn't anticipate.
|
|
21
|
+
|
|
22
|
+
Sources 2 and 3 are equal-weight edges in the same directed graph. If they
|
|
23
|
+
conflict (cycle), the error attributes the cycle to its source constraints.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Protocol
|
|
30
|
+
|
|
31
|
+
from clankloop.loopfile.v2.loopfile import Loopfile
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ModuleDependency:
|
|
36
|
+
"""A module's declaration that another module must be present.
|
|
37
|
+
|
|
38
|
+
A dependency is a *presence* constraint, not an *ordering* constraint.
|
|
39
|
+
The depending module needs its dependency registered at a compatible
|
|
40
|
+
version, but the application order is determined separately by
|
|
41
|
+
ordering constraints.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
name: The required module's name.
|
|
45
|
+
version: A semantic version range (e.g. ``\">=1.0"``).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
version: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Module(Protocol):
|
|
53
|
+
"""A transform applied to a loopfile spec before compilation.
|
|
54
|
+
|
|
55
|
+
Concrete modules declare three class-level facts:
|
|
56
|
+
|
|
57
|
+
- ``VERSION`` — the module's semantic version string.
|
|
58
|
+
- ``DEPENDENCIES`` — a tuple of :class:`ModuleDependency` (presence
|
|
59
|
+
constraints, no ordering implied).
|
|
60
|
+
- ``APPLY_BEFORE`` / ``APPLY_AFTER`` — tuples of module names that this
|
|
61
|
+
module must be applied before / after (wrapping semantics).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
VERSION: str
|
|
65
|
+
DEPENDENCIES: tuple[ModuleDependency, ...]
|
|
66
|
+
APPLY_BEFORE: tuple[str, ...]
|
|
67
|
+
APPLY_AFTER: tuple[str, ...]
|
|
68
|
+
|
|
69
|
+
def transform_loopfile(self, src: Loopfile) -> Loopfile: ...
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Module registry — maps module names to their implementing classes.
|
|
3
|
+
|
|
4
|
+
A :class:`ModuleRegistry` holds the set of available v2 loopfile transform modules
|
|
5
|
+
and is consulted by the compiler to resolve ``modules:`` entries.
|
|
6
|
+
|
|
7
|
+
When a module is registered, its dependencies (declared via
|
|
8
|
+
``DEPENDENCIES`` on the module class) must already be in the registry at
|
|
9
|
+
compatible versions. This ensures the registry is always a closed, valid
|
|
10
|
+
dependency set.
|
|
11
|
+
"""
|
|
12
|
+
from typing import Self
|
|
13
|
+
|
|
14
|
+
from clankloop.loopfile.v2.module import Module
|
|
15
|
+
from clankloop.loopfile.v2.clankshed_module import ClankshedModule
|
|
16
|
+
from clankloop.loopfile.v2.git_module import GitModule
|
|
17
|
+
from clankloop.loopfile.v2.workdir_module import WorkdirModule
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ModuleRegistry:
|
|
21
|
+
"""Registry of available modules by name.
|
|
22
|
+
|
|
23
|
+
Modules are registered by name and retrieved during compilation to
|
|
24
|
+
transform loopfile specs. Registration validates that the module's
|
|
25
|
+
declared dependencies are already registered at compatible versions.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._modules: dict[str, type[Module]] = {}
|
|
30
|
+
|
|
31
|
+
def register(self, name: str, module_cls: type[Module]) -> None:
|
|
32
|
+
for dep in module_cls.DEPENDENCIES:
|
|
33
|
+
if dep.name not in self._modules:
|
|
34
|
+
raise MissingModuleDependencyError(
|
|
35
|
+
f"Cannot register module {name!r}: it requires "
|
|
36
|
+
f"{dep.name!r} which is not registered"
|
|
37
|
+
)
|
|
38
|
+
self._modules[name] = module_cls
|
|
39
|
+
|
|
40
|
+
def get(self, name: str) -> type[Module]:
|
|
41
|
+
return self._modules[name]
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def with_builtins(cls) -> Self:
|
|
45
|
+
registry = cls()
|
|
46
|
+
registry.register("workdir", WorkdirModule)
|
|
47
|
+
registry.register("git", GitModule)
|
|
48
|
+
registry.register("clankshed", ClankshedModule)
|
|
49
|
+
return registry
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MissingModuleDependencyError(ValueError):
|
|
53
|
+
"""Raised when registering a module whose dependencies are not present."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Env-path builders for the v2 loopfile layout.
|
|
3
|
+
|
|
4
|
+
v2 uses a *namespaced* environment under a =pipeline= root:
|
|
5
|
+
- =pipeline.name= — the pipeline identifier.
|
|
6
|
+
- =pipeline.constants.<k>= — declared constants.
|
|
7
|
+
- =pipeline.parameters.<n>= — declared parameters.
|
|
8
|
+
- =pipeline.globals.<n>= — pipeline locals / mutable scratch space.
|
|
9
|
+
|
|
10
|
+
The builders here are the single source of truth for that layout — callers
|
|
11
|
+
supply the leaf name, the builder supplies the prefix (the namespace is
|
|
12
|
+
implied by *which* builder was called). This is the place to review the v2
|
|
13
|
+
path schema. Future layout features (a =pipeline.current_action= alias,
|
|
14
|
+
array/stack segments) extend this module.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from clankloop.core.env import EnvPath
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pipeline_name() -> EnvPath:
|
|
22
|
+
"""The pipeline identifier path: =pipeline.name=."""
|
|
23
|
+
return EnvPath.of("pipeline", "name")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def constant(name: str) -> EnvPath:
|
|
27
|
+
"""A declared constant's path: =pipeline.constants.<name>=."""
|
|
28
|
+
return EnvPath.of("pipeline", "constants", name)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parameter(name: str) -> EnvPath:
|
|
32
|
+
"""A declared parameter's path: =pipeline.parameters.<name>=."""
|
|
33
|
+
return EnvPath.of("pipeline", "parameters", name)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def glob(name: str) -> EnvPath:
|
|
37
|
+
"""A pipeline global (local) path: =pipeline.globals.<name>=."""
|
|
38
|
+
return EnvPath.of("pipeline", "globals", name)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Topological sort with edge-source attribution.
|
|
3
|
+
|
|
4
|
+
A generic graph utility — knows nothing about modules. Returns nodes in
|
|
5
|
+
application order given a node list and labelled edges. Cycle errors carry
|
|
6
|
+
the source labels of the edges that form the cycle.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class OrderedEdge:
|
|
16
|
+
"""A directed edge ``(before, after)`` with a source label.
|
|
17
|
+
|
|
18
|
+
``before`` must appear before ``after`` in the result. ``source`` is a
|
|
19
|
+
human-readable description of where the constraint came from, used in
|
|
20
|
+
cycle error messages for attribution.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
before: str
|
|
24
|
+
after: str
|
|
25
|
+
source: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CycleError(ValueError):
|
|
29
|
+
"""Raised when the ordering constraints form a cycle.
|
|
30
|
+
|
|
31
|
+
The message attributes the cycle to the edges (by their source labels)
|
|
32
|
+
that form it.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, nodes: set[str], edges: list[OrderedEdge]) -> None:
|
|
36
|
+
sources = ", ".join(e.source for e in edges)
|
|
37
|
+
super().__init__(
|
|
38
|
+
f"Circular ordering detected among {sorted(nodes)}; "
|
|
39
|
+
f"conflicting constraints: {sources}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def topological_sort(nodes: list[str], edges: list[OrderedEdge]) -> list[str]:
|
|
44
|
+
"""Return *nodes* in topological order (Kahn's algorithm).
|
|
45
|
+
|
|
46
|
+
Only edges where both endpoints are in *nodes* are considered; edges
|
|
47
|
+
referencing absent nodes are silently ignored.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
CycleError: If the constraints form a cycle, with attribution to
|
|
51
|
+
the edges among the cycle's nodes.
|
|
52
|
+
"""
|
|
53
|
+
node_set = set(nodes)
|
|
54
|
+
relevant = [e for e in edges if e.before in node_set and e.after in node_set]
|
|
55
|
+
|
|
56
|
+
in_degree: dict[str, int] = {n: 0 for n in nodes}
|
|
57
|
+
adj: dict[str, list[str]] = {n: [] for n in nodes}
|
|
58
|
+
for edge in relevant:
|
|
59
|
+
adj[edge.before].append(edge.after)
|
|
60
|
+
in_degree[edge.after] += 1
|
|
61
|
+
|
|
62
|
+
queue = [n for n in nodes if in_degree[n] == 0]
|
|
63
|
+
result: list[str] = []
|
|
64
|
+
while queue:
|
|
65
|
+
n = queue.pop(0)
|
|
66
|
+
result.append(n)
|
|
67
|
+
for m in adj[n]:
|
|
68
|
+
in_degree[m] -= 1
|
|
69
|
+
if in_degree[m] == 0:
|
|
70
|
+
queue.append(m)
|
|
71
|
+
|
|
72
|
+
if len(result) != len(nodes):
|
|
73
|
+
remaining = set(nodes) - set(result)
|
|
74
|
+
cycle_edges = [e for e in relevant if e.before in remaining and e.after in remaining]
|
|
75
|
+
raise CycleError(remaining, cycle_edges)
|
|
76
|
+
|
|
77
|
+
return result
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Workdir module — injects a temporary working directory into the pipeline.
|
|
3
|
+
|
|
4
|
+
Adds a ``workdir-setup`` task (creates a temp dir, exports its path to
|
|
5
|
+
``pipeline.globals.workdir``) at the start and a ``workdir-remove`` task
|
|
6
|
+
(cleans up the directory) at the end.
|
|
7
|
+
"""
|
|
8
|
+
from clankloop.loopfile.v2.module import Module, ModuleDependency
|
|
9
|
+
|
|
10
|
+
from clankloop.loopfile.v2.loopfile import Loopfile, BashTask, SetAction
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WorkdirModule(Module):
|
|
14
|
+
VERSION = "1.0.0"
|
|
15
|
+
DEPENDENCIES: tuple[ModuleDependency, ...] = ()
|
|
16
|
+
APPLY_BEFORE: tuple[str, ...] = ()
|
|
17
|
+
APPLY_AFTER: tuple[str, ...] = ()
|
|
18
|
+
|
|
19
|
+
def __init__(self, **module_args):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
def transform_loopfile(self, src: Loopfile) -> Loopfile:
|
|
23
|
+
new_locals = src.locals + ["workdir"]
|
|
24
|
+
new_tasks = [
|
|
25
|
+
BashTask(
|
|
26
|
+
name="workdir-setup",
|
|
27
|
+
cmds="mktemp -d | tr -d '\n'",
|
|
28
|
+
on_success=[SetAction(set="workdir", value="stdout")],
|
|
29
|
+
),
|
|
30
|
+
*src.tasks,
|
|
31
|
+
BashTask(name="workdir-remove", cmds="rm -rf ${pipeline.globals.workdir}"),
|
|
32
|
+
]
|
|
33
|
+
return Loopfile(
|
|
34
|
+
name=src.name,
|
|
35
|
+
constants=src.constants,
|
|
36
|
+
locals=new_locals,
|
|
37
|
+
tasks=new_tasks,
|
|
38
|
+
parameters=src.parameters,
|
|
39
|
+
modules=src.modules,
|
|
40
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Loopfile version constants."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
CURRENT_VERSION = "1"
|
|
7
|
+
SUPPORTED_VERSIONS: tuple[str, ...] = ("1", "2")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_version(version: object) -> str:
|
|
11
|
+
"""Coerce a YAML-parsed loopfile version to a canonical string.
|
|
12
|
+
|
|
13
|
+
YAML parses ``version: 1`` as int and ``version: 2.0`` as float; both map
|
|
14
|
+
to the same conceptual version. Integral floats are collapsed so ``2.0``
|
|
15
|
+
and ``2`` compare equal.
|
|
16
|
+
"""
|
|
17
|
+
if isinstance(version, float) and version.is_integer():
|
|
18
|
+
return str(int(version))
|
|
19
|
+
return str(version)
|
clankloop/plantuml.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PlantUML diagram generation — renders a labeled transition system as an activity diagram.
|
|
3
|
+
|
|
4
|
+
The renderer is a pure projection over the LTS: it knows nothing about
|
|
5
|
+
conditions, actions, or retry types. Nodes are projected by task (collapsing
|
|
6
|
+
the retry-budget dimension), and terminal outcomes (a task with no outgoing
|
|
7
|
+
edge for a given outcome) are emitted as edges to ``[*]``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from collections import deque
|
|
12
|
+
|
|
13
|
+
from clankloop.core.lts import LTSNode, LabeledTransitionSystem
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _task_id(task_name: str) -> str:
|
|
17
|
+
return task_name.replace(" ", "_").replace("-", "_")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_plantuml(lts: LabeledTransitionSystem) -> str:
|
|
21
|
+
"""Generate a PlantUML state diagram for a labeled transition system.
|
|
22
|
+
|
|
23
|
+
Projects LTS nodes by task (collapsing the retry-counter dimension) and
|
|
24
|
+
emits one ``state`` node per task, ``[*] --> entry``, labelled edges, and
|
|
25
|
+
``--> [*]`` for terminal outcomes (a task with no outgoing edge for that
|
|
26
|
+
outcome).
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
lts: The labeled transition system to visualise.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A complete PlantUML document string (``@startuml`` … ``@enduml``).
|
|
33
|
+
"""
|
|
34
|
+
# Tasks in LTS-discovery order (start first), for stable output.
|
|
35
|
+
seen = list(dict.fromkeys(node.task for node in _discovery_order(lts)))
|
|
36
|
+
|
|
37
|
+
# Edges collapsed to (source_task, target_task, label), deduped.
|
|
38
|
+
collapsed = {(e.source.task, e.target.task, e.label) for e in lts.edges}
|
|
39
|
+
|
|
40
|
+
state_lines = [f'state "{task}" as {_task_id(task)}' for task in seen]
|
|
41
|
+
|
|
42
|
+
# Group edges by source task in LTS-discovery order.
|
|
43
|
+
by_source: dict[str, list[tuple[str, str]]] = {}
|
|
44
|
+
for src, tgt, label in collapsed:
|
|
45
|
+
by_source.setdefault(src, []).append((tgt, label))
|
|
46
|
+
|
|
47
|
+
edge_lines: list[str] = []
|
|
48
|
+
for task in seen:
|
|
49
|
+
tid = _task_id(task)
|
|
50
|
+
outgoing = by_source.get(task, [])
|
|
51
|
+
labels_out = {label for _tgt, label in outgoing}
|
|
52
|
+
|
|
53
|
+
edge_lines.extend(
|
|
54
|
+
f"{tid} --> {_task_id(target)} : {label}"
|
|
55
|
+
for target, label in outgoing
|
|
56
|
+
)
|
|
57
|
+
# Terminal outcomes: outcomes no edge covers → [*].
|
|
58
|
+
if "On Success" not in labels_out and "Always" not in labels_out:
|
|
59
|
+
edge_lines.append(f"{tid} --> [*] : On Success")
|
|
60
|
+
if "On Failure" not in labels_out and "Always" not in labels_out:
|
|
61
|
+
edge_lines.append(f"{tid} --> [*] : On Failure")
|
|
62
|
+
|
|
63
|
+
lines = [
|
|
64
|
+
"@startuml",
|
|
65
|
+
*state_lines,
|
|
66
|
+
"",
|
|
67
|
+
f"[*] --> {_task_id(lts.start.task)}",
|
|
68
|
+
*edge_lines,
|
|
69
|
+
"@enduml",
|
|
70
|
+
]
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _discovery_order(lts: LabeledTransitionSystem) -> list[LTSNode]:
|
|
75
|
+
"""Return LTS nodes in BFS order from the start node."""
|
|
76
|
+
order: list[LTSNode] = []
|
|
77
|
+
visited: set[LTSNode] = {lts.start}
|
|
78
|
+
queue: deque[LTSNode] = deque([lts.start])
|
|
79
|
+
while queue:
|
|
80
|
+
node = queue.popleft()
|
|
81
|
+
order.append(node)
|
|
82
|
+
successors = [
|
|
83
|
+
e.target for e in lts.edges
|
|
84
|
+
if e.source == node and e.target not in visited
|
|
85
|
+
]
|
|
86
|
+
visited.update(successors)
|
|
87
|
+
queue.extend(successors)
|
|
88
|
+
return order
|
clankloop/runner.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pipeline runner — registers and executes compiled pipelines.
|
|
3
|
+
|
|
4
|
+
The runner is a thin registry. Execution is a two-step funnel: the caller
|
|
5
|
+
supplies a bare-name parameter dict, :meth:`Pipeline.bind` translates
|
|
6
|
+
and validates it against the compiler-built parameter model (writing resolved
|
|
7
|
+
values into the env), then :meth:`ExecutionGraph.execute` walks the graph
|
|
8
|
+
against the now-bound environment. The graph never sees bare parameter names.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Mapping
|
|
14
|
+
|
|
15
|
+
import clankloop.core.graph as graph
|
|
16
|
+
from clankloop.core.env import EnvPath, data_dependencies
|
|
17
|
+
from clankloop.core.errors import (
|
|
18
|
+
MissingParameterError,
|
|
19
|
+
UnknownParameterError,
|
|
20
|
+
)
|
|
21
|
+
from clankloop.core.graph import analyze
|
|
22
|
+
from clankloop.plantuml import generate_plantuml
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ParameterBinding:
|
|
27
|
+
"""A declared parameter and where it lives in the environment.
|
|
28
|
+
|
|
29
|
+
The compiler builds one binding per declared ``ParameterSpec`` using the
|
|
30
|
+
version-folder path builders, pre-resolving the env path so the
|
|
31
|
+
runtime never constructs namespace-aware paths. :meth:`Pipeline.bind`
|
|
32
|
+
consumes these to translate caller-facing bare names to env writes,
|
|
33
|
+
enforce ``required``, and inject ``default`` values.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
name: The bare parameter name (the caller-facing contract).
|
|
37
|
+
path: The pre-resolved env path where the parameter's ``Value`` lives.
|
|
38
|
+
required: If True, ``bind`` raises ``MissingParameterError`` when no
|
|
39
|
+
value is supplied.
|
|
40
|
+
default: If set and no value is supplied, injected by ``bind``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
path: EnvPath
|
|
45
|
+
required: bool = False
|
|
46
|
+
default: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Pipeline:
|
|
51
|
+
"""A compiled pipeline ready for execution.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
name: The pipeline name.
|
|
55
|
+
graph: The execution graph containing tasks, actions, and environment.
|
|
56
|
+
entry_task: The name of the first task to execute.
|
|
57
|
+
parameters: Compiler-built parameter model — one binding per declared
|
|
58
|
+
parameter, with pre-resolved env paths. Consumed by ``bind``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
graph: graph.ExecutionGraph
|
|
63
|
+
entry_task: str
|
|
64
|
+
parameters: tuple[ParameterBinding, ...] = field(default_factory=tuple)
|
|
65
|
+
|
|
66
|
+
def consumes(self) -> dict[str, frozenset[str]]:
|
|
67
|
+
"""Env paths each task transitively reads, keyed by task name.
|
|
68
|
+
|
|
69
|
+
A static data-flow summary: for every task, the env paths whose
|
|
70
|
+
evaluation is triggered when the task's command runs — first-order
|
|
71
|
+
template identifiers and export key-paths, closed through immutable
|
|
72
|
+
render values. Independent of branch, so it is a fact about the task,
|
|
73
|
+
not the path taken through the graph.
|
|
74
|
+
"""
|
|
75
|
+
return {
|
|
76
|
+
name: data_dependencies(self.graph.env, execution.consumes())
|
|
77
|
+
for name, execution in self.graph.execs.items()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def bind(self, supplied: dict[str, str]) -> None:
|
|
81
|
+
"""Bind caller-supplied parameter values into the graph's environment.
|
|
82
|
+
|
|
83
|
+
The single funnel where caller-facing bare names become resolved env
|
|
84
|
+
writes. For each declared parameter: if supplied, write the value at
|
|
85
|
+
its pre-resolved path; if not supplied and required, raise; if not
|
|
86
|
+
supplied and a default exists, inject the default; otherwise leave
|
|
87
|
+
the slot unassigned (the existing env state).
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
supplied: Mapping of bare parameter names to string values, as
|
|
91
|
+
produced by the CLI (``-p KEY=VALUE`` or taskcar stdin JSON).
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
UnknownParameterError: A supplied name is not a declared parameter.
|
|
95
|
+
MissingParameterError: A required parameter was not supplied.
|
|
96
|
+
"""
|
|
97
|
+
bindings = {b.name: b for b in self.parameters}
|
|
98
|
+
|
|
99
|
+
for name in supplied:
|
|
100
|
+
if name not in bindings:
|
|
101
|
+
raise UnknownParameterError(
|
|
102
|
+
f"Parameter {name!r} is not declared in pipeline {self.name!r}; "
|
|
103
|
+
f"declared: {sorted(bindings) or '(none)'}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
for binding in self.parameters:
|
|
107
|
+
if binding.name in supplied:
|
|
108
|
+
self.graph.env.set_value(binding.path, supplied[binding.name])
|
|
109
|
+
elif binding.required:
|
|
110
|
+
raise MissingParameterError(
|
|
111
|
+
f"Required parameter {binding.name!r} not supplied for "
|
|
112
|
+
f"pipeline {self.name!r}"
|
|
113
|
+
)
|
|
114
|
+
elif binding.default is not None:
|
|
115
|
+
self.graph.env.set_value(binding.path, binding.default)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Runner:
|
|
119
|
+
"""Registry and executor for compiled pipelines.
|
|
120
|
+
|
|
121
|
+
Pipelines are registered by name and can then be executed or inspected.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self) -> None:
|
|
125
|
+
self._pipelines: dict[str, Pipeline] = {}
|
|
126
|
+
|
|
127
|
+
def register(self, pipeline: Pipeline) -> None:
|
|
128
|
+
"""Register a compiled pipeline by its name."""
|
|
129
|
+
self._pipelines[pipeline.name] = pipeline
|
|
130
|
+
|
|
131
|
+
def run(
|
|
132
|
+
self,
|
|
133
|
+
name: str,
|
|
134
|
+
parameters: dict[str, str],
|
|
135
|
+
*,
|
|
136
|
+
environ: Mapping[str, str],
|
|
137
|
+
io: graph.IOChannels | None = None,
|
|
138
|
+
interactive: bool = False,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Execute a registered pipeline by name.
|
|
141
|
+
|
|
142
|
+
Funnels the caller-supplied bare-name parameter dict through
|
|
143
|
+
:meth:`Pipeline.bind` (translate, enforce required, inject
|
|
144
|
+
defaults), then walks the graph against the bound environment.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
name: The registered pipeline name.
|
|
148
|
+
parameters: Mapping of bare parameter names to string values.
|
|
149
|
+
environ: Base process environment forwarded to the graph; the
|
|
150
|
+
CLI supplies ``os.environ`` at the boundary.
|
|
151
|
+
io: CLI-owned streams the core tees subprocess output through.
|
|
152
|
+
interactive: When set, tasks that capture no stdio inherit the
|
|
153
|
+
tty.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
ValueError: If no pipeline with *name* has been registered.
|
|
157
|
+
UnknownParameterError: A supplied parameter is not declared.
|
|
158
|
+
MissingParameterError: A required parameter was not supplied.
|
|
159
|
+
"""
|
|
160
|
+
pipeline = self._pipelines.get(name)
|
|
161
|
+
if pipeline is None:
|
|
162
|
+
raise ValueError(f"Pipeline {name!r} has not been registered")
|
|
163
|
+
|
|
164
|
+
pipeline.bind(parameters)
|
|
165
|
+
pipeline.graph.execute(
|
|
166
|
+
pipeline.entry_task,
|
|
167
|
+
environ=environ,
|
|
168
|
+
io=io,
|
|
169
|
+
interactive=interactive,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def to_plantuml(self, name: str) -> str:
|
|
173
|
+
"""Render a registered pipeline's execution graph as PlantUML.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
name: The registered pipeline name.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
A PlantUML activity diagram string.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
ValueError: If no pipeline with *name* has been registered.
|
|
183
|
+
"""
|
|
184
|
+
pipeline = self._pipelines.get(name)
|
|
185
|
+
if pipeline is None:
|
|
186
|
+
raise ValueError(f"Pipeline {name!r} has not been registered")
|
|
187
|
+
|
|
188
|
+
return generate_plantuml(analyze(pipeline.graph, pipeline.entry_task))
|
clankloop/tracer.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tracing facade — proxies to OpenTelemetry when available, no-op otherwise.
|
|
3
|
+
|
|
4
|
+
This module provides a unified :class:`Tracer` that works whether or not
|
|
5
|
+
OpenTelemetry is installed. When OTel is present, spans are forwarded to the
|
|
6
|
+
real tracer; when it is absent, a silent :class:`DummySpan` is used instead.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from typing import Any, Iterator
|
|
12
|
+
|
|
13
|
+
# Try to parse OpenTelemetry core APIs at module level
|
|
14
|
+
try:
|
|
15
|
+
from opentelemetry import trace as otel_trace
|
|
16
|
+
HAS_OTEL_TRACE = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
HAS_OTEL_TRACE = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DummySpan:
|
|
22
|
+
"""A safe, silent surrogate for an OpenTelemetry Span."""
|
|
23
|
+
|
|
24
|
+
def set_attribute(self, key: str, value: Any) -> "DummySpan":
|
|
25
|
+
return self
|
|
26
|
+
|
|
27
|
+
def set_attributes(self, attributes: dict[str, Any]) -> "DummySpan":
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def record_exception(self, exception: Exception) -> "DummySpan":
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def set_status(self, status: Any) -> "DummySpan":
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Tracer:
|
|
38
|
+
"""A facade managing tracing.
|
|
39
|
+
|
|
40
|
+
If OpenTelemetry is installed, it proxies to the real OTel tracer.
|
|
41
|
+
If not, it acts as a silent, safe no-op.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, name: str):
|
|
45
|
+
self.name = name
|
|
46
|
+
self._otel_tracer = None
|
|
47
|
+
if HAS_OTEL_TRACE:
|
|
48
|
+
self._otel_tracer = otel_trace.get_tracer(name)
|
|
49
|
+
|
|
50
|
+
@contextmanager
|
|
51
|
+
def start_as_current_span(
|
|
52
|
+
self,
|
|
53
|
+
name: str,
|
|
54
|
+
attributes: dict[str, Any] | None = None,
|
|
55
|
+
) -> Iterator[Any]:
|
|
56
|
+
"""Context manager that starts a span and yields it.
|
|
57
|
+
|
|
58
|
+
When OpenTelemetry is active, a real span is created and made current.
|
|
59
|
+
Otherwise, a :class:`DummySpan` is yielded.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
name: The span name.
|
|
63
|
+
attributes: Optional attributes to attach to the span.
|
|
64
|
+
|
|
65
|
+
Yields:
|
|
66
|
+
The active span (real or dummy).
|
|
67
|
+
"""
|
|
68
|
+
if HAS_OTEL_TRACE and self._otel_tracer:
|
|
69
|
+
with self._otel_tracer.start_as_current_span(name) as span:
|
|
70
|
+
if attributes:
|
|
71
|
+
span.set_attributes(attributes)
|
|
72
|
+
yield span
|
|
73
|
+
else:
|
|
74
|
+
yield DummySpan()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: clankloop
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Build deterministic loops with clankers.
|
|
5
|
+
Author-email: Caj Larsson <polsent@caj.me>
|
|
6
|
+
License-Expression: GPL-3.0-only
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Requires-Dist: cattrs>=23.0.0
|
|
13
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# Clank Loop
|
|
17
|
+
|
|
18
|
+
# Ideas
|
|
19
|
+
|
|
20
|
+
|