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,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
+