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