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/logger.py ADDED
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Structured logging — JSON, text, scoped buffers, and optional OTel export.
3
+
4
+ This module configures the standard Python logging system to route logs to
5
+ stderr (as structured text or JSON), to scoped memory buffers for capture
6
+ within a concurrency scope, and optionally to an OpenTelemetry exporter.
7
+ Console logging defaults to stderr so pipeline stdout is never polluted.
8
+ """
9
+
10
+
11
+ import contextvars
12
+ import json
13
+ import logging
14
+ import sys
15
+ from contextlib import contextmanager
16
+ from typing import Any, Iterator
17
+
18
+ logger = logging.getLogger("clankloop")
19
+
20
+ # OTel is an optional dependency. The exporter lives in a separate package
21
+ # (opentelemetry-exporter-otlp-proto-http) from the SDK, so both must import.
22
+ try:
23
+ from opentelemetry._logs import set_logger_provider
24
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
25
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
26
+ from opentelemetry.exporter.otlp.proto.http.log_exporter import OTLPLogExporter
27
+
28
+ HAS_OTEL = True
29
+ except ImportError:
30
+ HAS_OTEL = False
31
+
32
+
33
+ # Derive the standard LogRecord attributes once at class-load time
34
+ # by instantiating a dummy LogRecord and extracting its dict keys.
35
+ _STANDARD_LOGRECORD_KEYS: set[str] = set(
36
+ logging.LogRecord("dummy", 0, "", 0, "", (), None).__dict__.keys()
37
+ ) | {"asctime", "message"}
38
+
39
+
40
+ # ContextVar to hold an active list of LogRecords for the current concurrency scope
41
+ _active_buffer: contextvars.ContextVar[list[logging.LogRecord] | None] = (
42
+ contextvars.ContextVar("_active_buffer", default=None)
43
+ )
44
+
45
+
46
+ class _ClankloopHandler(logging.Handler):
47
+ """Marker base for handlers :func:`setup_logging` owns and manages.
48
+
49
+ Idempotent re-configuration checks ``isinstance`` against this base rather
50
+ than monkey-patching attributes onto stdlib handler instances.
51
+ """
52
+
53
+
54
+ class ScopedBufferHandler(_ClankloopHandler):
55
+ """Intercepts records and appends them to a context-local memory buffer
56
+ if one is currently active.
57
+ """
58
+
59
+ def emit(self, record: logging.LogRecord) -> None:
60
+ buf = _active_buffer.get()
61
+ if buf is not None:
62
+ buf.append(record)
63
+
64
+
65
+ class _ConsoleHandler(_ClankloopHandler, logging.StreamHandler):
66
+ """Console stream handler owned by setup_logging."""
67
+
68
+
69
+ class JsonFormatter(logging.Formatter):
70
+ """Formats log records as structured single-line JSON."""
71
+
72
+ def format(self, record: logging.LogRecord) -> str:
73
+ log_data: dict[str, Any] = {
74
+ "timestamp": self.formatTime(record, self.datefmt),
75
+ "level": record.levelname,
76
+ "logger": record.name,
77
+ "message": record.getMessage(),
78
+ }
79
+ if record.exc_info:
80
+ log_data["exception"] = self.formatException(record.exc_info)
81
+ if record.stack_info:
82
+ log_data["stack"] = self.formatStack(record.stack_info)
83
+
84
+ # Extract extra attributes for structured logging
85
+ for key, value in record.__dict__.items():
86
+ if key not in _STANDARD_LOGRECORD_KEYS:
87
+ log_data[key] = value
88
+
89
+ return json.dumps(log_data)
90
+
91
+
92
+ class StructuredFormatter(logging.Formatter):
93
+ """Formats log records as structured multi-line text with key=value pairs."""
94
+
95
+ def format(self, record: logging.LogRecord) -> str:
96
+ base = f"[{self.formatTime(record, self.datefmt)}] {record.levelname} in {record.name}: {record.getMessage()}"
97
+ extra_keys = record.__dict__.keys() - _STANDARD_LOGRECORD_KEYS
98
+
99
+ for key in sorted(extra_keys):
100
+ value = record.__dict__[key]
101
+ base += f" {key}={value!r}"
102
+ return base
103
+
104
+
105
+ @contextmanager
106
+ def scoped_buffer() -> Iterator[list[logging.LogRecord]]:
107
+ """Context manager to intercept and capture all logging records emitted
108
+ within the current thread/task scope in a mutable list.
109
+ """
110
+ buf: list[logging.LogRecord] = []
111
+ token = _active_buffer.set(buf)
112
+ try:
113
+ yield buf
114
+ finally:
115
+ _active_buffer.reset(token)
116
+
117
+
118
+ def setup_logging(
119
+ level: int = logging.INFO,
120
+ console: bool = True,
121
+ json_format: bool = False,
122
+ otel: bool = False,
123
+ stream=None,
124
+ ) -> None:
125
+ """Configure the standard Python logging system.
126
+
127
+ Sets up a :class:`ScopedBufferHandler` (always), a console handler
128
+ (optional, text or JSON), and an OTel logging handler (optional).
129
+
130
+ Args:
131
+ level: Root logger level (defaults to ``INFO``).
132
+ console: Whether to add a console/stream handler.
133
+ json_format: If True, console output uses :class:`JsonFormatter`;
134
+ otherwise :class:`StructuredFormatter`.
135
+ otel: If True, attempt to export logs via OpenTelemetry.
136
+ stream: Optional stream for the console handler (defaults to stderr).
137
+ """
138
+ root = logging.getLogger()
139
+ root.setLevel(level)
140
+
141
+ logging.getLogger("clankloop").setLevel(level)
142
+
143
+ for h in list(root.handlers):
144
+ if isinstance(h, _ClankloopHandler):
145
+ root.removeHandler(h)
146
+
147
+ buffer_handler = ScopedBufferHandler()
148
+ root.addHandler(buffer_handler)
149
+
150
+ if console:
151
+ console_handler = _ConsoleHandler(
152
+ stream if stream is not None else sys.stderr
153
+ )
154
+ if json_format:
155
+ console_handler.setFormatter(JsonFormatter())
156
+ else:
157
+ console_handler.setFormatter(
158
+ StructuredFormatter(
159
+ fmt="[{asctime}] {levelname} in {name}: {message}",
160
+ datefmt="%Y-%m-%d %H:%M:%S",
161
+ style="{",
162
+ )
163
+ )
164
+ root.addHandler(console_handler)
165
+
166
+ if otel:
167
+ if HAS_OTEL:
168
+ provider = LoggerProvider()
169
+ processor = BatchLogRecordProcessor(OTLPLogExporter())
170
+ provider.add_log_record_processor(processor)
171
+ set_logger_provider(provider)
172
+
173
+ otel_handler = LoggingHandler(logger_provider=provider)
174
+ root.addHandler(otel_handler)
175
+ else:
176
+ logger.warning(
177
+ "OTel logging requested but OpenTelemetry SDK or exporter is "
178
+ "not installed. Falling back without OTel agent export."
179
+ )
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ """Loopfile parsing and compilation.
3
+
4
+ The top-level :func:`compile_pipeline` function dispatches to the correct
5
+ version-specific compiler based on the ``version`` key in the raw YAML data.
6
+ Supported versions are listed in :data:`~clankloop.loopfile.versions.SUPPORTED_VERSIONS`.
7
+ """
8
+
9
+ from clankloop.loopfile.versions import SUPPORTED_VERSIONS, CURRENT_VERSION, normalize_version
10
+ from clankloop.loopfile.v1.compiler import compile_pipeline as compile_v1
11
+ from clankloop.loopfile.v2.compiler import compile_pipeline as compile_v2
12
+
13
+ from clankloop.runner import Pipeline
14
+ from typing import Any
15
+
16
+
17
+ def compile_pipeline(data: dict[str, Any], pipeline_name: str | None = None) -> Pipeline:
18
+ """Compile raw loopfile data into a :class:`~clankloop.runner.Pipeline`.
19
+
20
+ Args:
21
+ data: Parsed YAML dictionary (as returned by ``yaml.safe_load``).
22
+ pipeline_name: Name of the pipeline to compile. Required for v1
23
+ loopfiles (which can contain multiple pipelines); ignored by v2
24
+ (one loopfile = one pipeline).
25
+
26
+ Returns:
27
+ A pipeline object ready for registration with a
28
+ :class:`~clankloop.runner.Runner`.
29
+
30
+ Raises:
31
+ ValueError: If the loopfile version is not supported.
32
+ RuntimeError: If no compiler matched the version (should not happen).
33
+ """
34
+ version = data.get("version")
35
+
36
+ if version is None:
37
+ version = CURRENT_VERSION
38
+ else:
39
+ version = normalize_version(version)
40
+
41
+ if version not in SUPPORTED_VERSIONS:
42
+ raise ValueError(
43
+ f"Unsupported Loopfile version {version!r}. "
44
+ f"Supported versions: {SUPPORTED_VERSIONS}"
45
+ )
46
+
47
+ if version == "1":
48
+ return compile_v1(data, pipeline_name)
49
+
50
+ if version == "2":
51
+ return compile_v2(data, pipeline_name)
52
+
53
+ raise RuntimeError(f"Unexpectedly did not find loopfile compiler {version!r}")
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Deprecated, no active development. Use :mod:`clankloop.loopfile.v2` instead.
4
+ Loopfile schema v1 — multi-pipeline definitions with flat task lists."""
@@ -0,0 +1,247 @@
1
+ #!/usr/bin/env python3
2
+ """Loopfile v1 compiler — transforms a v1 loopfile spec into a Pipeline.
3
+
4
+ Compiles pipelines, tasks, and actions into an :class:`~clankloop.core.graph.ExecutionGraph`
5
+ with a shared environment of parameters, constants, and locals.
6
+ """
7
+
8
+
9
+ from typing import Sequence, Any
10
+ from itertools import takewhile
11
+
12
+ import clankloop.core.graph as graph
13
+ import clankloop.loopfile.v1.loopfile as loopfile
14
+ from clankloop.runner import ParameterBinding, Pipeline
15
+ from clankloop.core.env import Object, Value, EnvPath
16
+ from clankloop.loopfile.v1 import paths as v1_paths
17
+
18
+
19
+ def compile_pipeline(data: dict[str, Any], pipeline_name: str | None) -> Pipeline:
20
+ """Compile a v1 loopfile into a :class:`~clankloop.runner.Pipeline`.
21
+
22
+ Parses the raw YAML data, finds the named pipeline, validates it, and
23
+ builds an execution graph with the pipeline's parameters, constants,
24
+ locals, and task-local constants wired into the environment.
25
+
26
+ Args:
27
+ data: Parsed YAML dictionary.
28
+ pipeline_name: Name of the pipeline to compile. Required — a v1
29
+ loopfile can contain multiple pipelines.
30
+
31
+ Returns:
32
+ A compiled pipeline ready for execution.
33
+
34
+ Raises:
35
+ ValueError: If *pipeline_name* is not provided.
36
+ RuntimeError: If the pipeline is not found in the loopfile.
37
+ ValueError: If the pipeline has no tasks, duplicate task names,
38
+ or invalid action references.
39
+ """
40
+ if pipeline_name is None:
41
+ raise ValueError(
42
+ "pipeline_name is required for v1 loopfiles (which can contain "
43
+ "multiple pipelines)"
44
+ )
45
+ loopfile_data = loopfile.parse(data)
46
+
47
+ pipeline = next(
48
+ (p for p in loopfile_data.pipelines if p.name == pipeline_name), None
49
+ )
50
+ if pipeline is None:
51
+ available = ", ".join(repr(p.name) for p in loopfile_data.pipelines)
52
+ raise RuntimeError(
53
+ f"Pipeline {pipeline_name!r} not found in Loopfile. "
54
+ f"Available pipelines: {available}"
55
+ )
56
+
57
+ if not pipeline.tasks:
58
+ raise ValueError(f"Pipeline {pipeline.name!r} has no tasks")
59
+
60
+ _validate_pipeline(pipeline)
61
+
62
+ execs: dict[str, graph.Execution] = {}
63
+
64
+ pipeline_exports: dict[str, EnvPath] = {}
65
+
66
+ pipeline_parameters = {
67
+ param.name: Value("", immutable=True, assigned=False)
68
+ for param in loopfile_data.parameters
69
+ }
70
+
71
+ pipeline_locals = {}
72
+ pipeline_constants = {}
73
+
74
+ for pipeline_local_name in pipeline.locals:
75
+ pipeline_exports[pipeline_local_name] = v1_paths.glob(pipeline_local_name)
76
+ pipeline_locals[pipeline_local_name] = Value(
77
+ "", immutable=False, assigned=False
78
+ )
79
+
80
+ for pc_name, pc_value in pipeline.constants.items():
81
+ pipeline_exports[pc_name] = v1_paths.constant(pc_name)
82
+ pipeline_constants[pc_name] = Value(pc_value, immutable=True, assigned=True)
83
+
84
+ for param in loopfile_data.parameters:
85
+ pipeline_exports[param.name] = v1_paths.parameter(param.name)
86
+
87
+ execution_constants: dict[str, Value] = {}
88
+
89
+ for task in pipeline.tasks:
90
+ # Task local constants need to exported with a local name
91
+ task_exports: dict[str, EnvPath] = {}
92
+
93
+ # v1 has no first-class task-local namespace, so task constants live
94
+ # in the flat env under a synthetic `task-<task>-<name>` key (see the
95
+ # `pipeline.current_action` follow-on). The builder owns that shape.
96
+ for constant_name, contant_value in task.constants.items():
97
+ contant_key = f"task-{task.name}-{constant_name}"
98
+ task_exports[contant_key] = v1_paths.task_constant(task.name, constant_name)
99
+ execution_constants[contant_key] = Value(
100
+ value=contant_value, immutable=True, assigned=True
101
+ )
102
+
103
+ execs[task.name] = graph.BashExecution(
104
+ name=task.name, cmds=task.cmds, exports={**pipeline_exports, **task_exports}
105
+ )
106
+
107
+ exec_actions: dict[
108
+ str, Sequence[tuple[graph.Condition, Sequence[graph.Action]]]
109
+ ] = {
110
+ task.name: _compile_task_actions(
111
+ task, _next_task_name(pipeline, i), pipeline.retries
112
+ )
113
+ for i, task in enumerate(pipeline.tasks)
114
+ }
115
+
116
+ parameter_bindings = tuple(
117
+ ParameterBinding(
118
+ name=p.name,
119
+ path=v1_paths.parameter(p.name),
120
+ required=p.required,
121
+ )
122
+ for p in loopfile_data.parameters
123
+ )
124
+
125
+ execution_graph = graph.ExecutionGraph(
126
+ name=pipeline.name,
127
+ execs=execs,
128
+ exec_actions=exec_actions,
129
+ env=Object(
130
+ {
131
+ **pipeline_parameters,
132
+ **pipeline_constants,
133
+ **pipeline_locals,
134
+ **execution_constants,
135
+ }
136
+ ),
137
+ )
138
+ execution_graph.validate(
139
+ pipeline.tasks[0].name,
140
+ parameter_paths=frozenset(str(b.path) for b in parameter_bindings),
141
+ )
142
+
143
+ return Pipeline(
144
+ name=pipeline.name,
145
+ graph=execution_graph,
146
+ entry_task=pipeline.tasks[0].name,
147
+ parameters=parameter_bindings,
148
+ )
149
+
150
+
151
+ def _validate_pipeline(pipeline: loopfile.PipelineSpec) -> None:
152
+ task_names = [task.name for task in pipeline.tasks]
153
+ unique_task_names = set(task_names)
154
+
155
+ if len(task_names) != len(unique_task_names):
156
+ raise ValueError(f"Pipeline {pipeline.name!r} contains duplicate task names")
157
+
158
+ local_names = set(pipeline.locals)
159
+ for task in pipeline.tasks:
160
+ for action in (task.on_success or []) + (task.on_failure or []):
161
+ if isinstance(action, loopfile.RetryFromAction):
162
+ if action.retry_from not in unique_task_names:
163
+ raise ValueError(
164
+ f"Task {task.name!r} retries from unknown task "
165
+ f"{action.retry_from!r}"
166
+ )
167
+ elif isinstance(action, loopfile.SetAction):
168
+ if action.set not in local_names:
169
+ raise ValueError(
170
+ f"Task {task.name!r} sets unknown local {action.set!r}"
171
+ )
172
+
173
+
174
+ def _next_task_name(pipeline: loopfile.PipelineSpec, index: int) -> str | None:
175
+ if index + 1 >= len(pipeline.tasks):
176
+ return None
177
+ return pipeline.tasks[index + 1].name
178
+
179
+
180
+ def _compile_task_actions(
181
+ task: loopfile.Task,
182
+ next_task: str | None,
183
+ pipeline_retries: int = 0,
184
+ ) -> list[tuple[graph.Condition, list[graph.Action]]]:
185
+ branches: list[tuple[graph.Condition, list[graph.Action]]] = []
186
+
187
+ # 1. Compile OnSuccess branch
188
+ success_actions = _compile_actions_list(task.on_success or [], is_success=True)
189
+
190
+ # If no flow action is present and next task exists, append default NextAction
191
+ has_flow_action = any(isinstance(act, graph.NextAction) for act in success_actions)
192
+ if not has_flow_action and next_task is not None:
193
+ # Check if setup or any set action terminated early via an Abort check
194
+ aborted = any(
195
+ isinstance(act, loopfile.AbortAction) for act in (task.on_success or [])
196
+ )
197
+ if not aborted:
198
+ success_actions.append(graph.NextAction(next_task=next_task))
199
+
200
+ if success_actions:
201
+ branches.append((graph.OnSuccessCondition(), success_actions))
202
+
203
+ # 2. Compile OnFailure/Retry branches
204
+ failure_actions = _compile_actions_list(task.on_failure or [], is_success=False)
205
+
206
+ # Check for RetryFromAction setup
207
+ retry_targets = [
208
+ act.retry_from
209
+ for act in (task.on_failure or [])
210
+ if isinstance(act, loopfile.RetryFromAction)
211
+ ]
212
+
213
+ if retry_targets:
214
+ # If retry condition is hit and we have retries remaining, loop back
215
+ branches.append(
216
+ (graph.OnRetryCondition(retries=pipeline_retries), [graph.NextAction(next_task=retry_targets[0])])
217
+ )
218
+
219
+ if failure_actions:
220
+ branches.append((graph.OnFailureCondition(), failure_actions))
221
+
222
+ return branches
223
+
224
+
225
+ def _compile_actions_list(
226
+ actions: list[loopfile.Action],
227
+ is_success: bool,
228
+ ) -> list[graph.Action]:
229
+ return [
230
+ _compile_action(action)
231
+ for action in takewhile(lambda a: not isinstance(a, loopfile.AbortAction), actions)
232
+ if not isinstance(action, loopfile.RetryFromAction)
233
+ ]
234
+
235
+
236
+ def _compile_action(action: loopfile.Action) -> graph.Action:
237
+ if isinstance(action, loopfile.SetAction):
238
+ stdout = action.value == "stdout"
239
+ stderr = action.value == "stderr"
240
+ return graph.SetAction(
241
+ var_name=v1_paths.glob(action.set),
242
+ value=None if stdout or stderr else action.value,
243
+ stdout=stdout,
244
+ stderr=stderr,
245
+ )
246
+ assert isinstance(action, loopfile.NextTaskAction)
247
+ return graph.NextAction(next_task=action.goto)
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """Loopfile v1 schema — dataclasses and YAML parsing for v1 loopfiles.
3
+
4
+ A v1 loopfile defines parameters, constants, and multiple pipelines, each with
5
+ flat task lists. Tasks have bash commands, task-local constants, and
6
+ ``on_success`` / ``on_failure`` action lists.
7
+ """
8
+
9
+
10
+ from typing import Any
11
+
12
+ import attrs
13
+ from attrs import frozen
14
+ import cattrs
15
+ from cattrs import Converter
16
+
17
+
18
+ @frozen
19
+ class ParameterSpec:
20
+ name: str
21
+ required: bool = False
22
+
23
+
24
+ @frozen
25
+ class SetAction:
26
+ set: str
27
+ value: str
28
+
29
+
30
+ @frozen
31
+ class RetryFromAction:
32
+ retry_from: str
33
+
34
+
35
+ @frozen
36
+ class NextTaskAction:
37
+ goto: str
38
+
39
+
40
+ @frozen
41
+ class AbortAction:
42
+ pass
43
+
44
+
45
+ Action = SetAction | RetryFromAction | NextTaskAction | AbortAction
46
+
47
+
48
+ @frozen
49
+ class Task:
50
+ name: str
51
+ cmds: str
52
+ constants: dict[str, Any] = attrs.Factory(dict)
53
+ on_success: list[Action] | None = None
54
+ on_failure: list[Action] | None = None
55
+
56
+
57
+ @frozen
58
+ class PipelineSpec:
59
+ name: str
60
+ retries: int = 0
61
+ constants: dict[str, Any] = attrs.Factory(dict)
62
+ locals: list[str] = attrs.Factory(list)
63
+ tasks: list[Task] = attrs.Factory(list)
64
+
65
+
66
+ @frozen
67
+ class Loopfile:
68
+ parameters: list[ParameterSpec] = attrs.Factory(list)
69
+ constants: dict[str, Any] = attrs.Factory(dict)
70
+ pipelines: list[PipelineSpec] = attrs.Factory(list)
71
+
72
+
73
+ def _structure_action(data: Any, _type: Any, converter: Converter) -> Action:
74
+ if not isinstance(data, dict):
75
+ raise ValueError(f"Expected dict for Action, got {type(data).__name__}")
76
+ if "abort" in data:
77
+ return AbortAction()
78
+ if "retry_from" in data:
79
+ return converter.structure(data, RetryFromAction)
80
+ if "set" in data:
81
+ return converter.structure(data, SetAction)
82
+ if "goto" in data:
83
+ return converter.structure(data, NextTaskAction)
84
+
85
+ raise ValueError(f"Unrecognised action keys: {set(data.keys())}")
86
+
87
+
88
+ def make_converter() -> Converter:
89
+ converter = cattrs.Converter()
90
+ converter.register_structure_hook_func(
91
+ lambda t: t is Action,
92
+ lambda data, t: _structure_action(data, t, converter),
93
+ )
94
+ return converter
95
+
96
+
97
+ def parse(data: dict[str, Any]) -> Loopfile:
98
+ """Parse raw YAML data into a v1 :class:`Loopfile` spec.
99
+
100
+ Args:
101
+ data: Parsed YAML dictionary.
102
+
103
+ Returns:
104
+ A structured :class:`Loopfile` instance.
105
+ """
106
+ converter = make_converter()
107
+ return converter.structure(data, Loopfile)
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env python3
2
+ """Env-path builders for the v1 loopfile layout.
3
+
4
+ v1 uses a *flat* environment: parameters, constants, and locals (globals)
5
+ all live as single-segment paths at the root of the env. The builders here
6
+ are the single source of truth for that layout — callers supply the leaf
7
+ name, the builder supplies the (trivial, flat) resolution.
8
+
9
+ These builders are the place to review the v1 path schema. Future layout
10
+ features (a =pipeline.current_action= alias, array/stack segments) extend
11
+ this module.
12
+ """
13
+
14
+
15
+ from clankloop.core.env import EnvPath
16
+
17
+
18
+ def pipeline_name() -> EnvPath:
19
+ """The pipeline identifier path (flat in v1: =pipeline=)."""
20
+ return EnvPath.of("pipeline")
21
+
22
+
23
+ def constant(name: str) -> EnvPath:
24
+ """A declared constant's path (flat in v1: =<name>=)."""
25
+ return EnvPath.of(name)
26
+
27
+
28
+ def parameter(name: str) -> EnvPath:
29
+ """A declared parameter's path (flat in v1: =<name>=)."""
30
+ return EnvPath.of(name)
31
+
32
+
33
+ def glob(name: str) -> EnvPath:
34
+ """A pipeline global (local) path (flat in v1: =<name>=)."""
35
+ return EnvPath.of(name)
36
+
37
+
38
+ def task_constant(task_name: str, name: str) -> EnvPath:
39
+ """A task-local constant's path.
40
+
41
+ v1 has no first-class task-local namespace, so task constants live in the
42
+ flat env under a =task-<task>-<name>= synthetic key. This is a known
43
+ placeholder pending the =pipeline.current_action= alias follow-on; it is
44
+ kept here rather than inlined at the compiler so the layout is reviewable
45
+ in one place.
46
+ """
47
+ return EnvPath.of(f"task-{task_name}-{name}")
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env python3
2
+ """Loopfile schema v2 — module system with transforms.
3
+
4
+ v2 introduces a single-pipeline loopfile with a *module* system. Modules
5
+ (:mod:`~clankloop.loopfile.v2.module`) transform the loopfile before compilation,
6
+ injecting setup/teardown tasks (e.g. working directories, git clones).
7
+ """