tangle-cli 0.0.1a1__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.
- tangle_cli/__init__.py +19 -0
- tangle_cli/api_cli.py +787 -0
- tangle_cli/api_schema.py +633 -0
- tangle_cli/api_transport.py +461 -0
- tangle_cli/args_container.py +244 -0
- tangle_cli/artifacts.py +293 -0
- tangle_cli/artifacts_cli.py +108 -0
- tangle_cli/cli.py +57 -0
- tangle_cli/cli_helpers.py +116 -0
- tangle_cli/cli_options.py +52 -0
- tangle_cli/client.py +677 -0
- tangle_cli/component_from_func.py +1856 -0
- tangle_cli/component_generator.py +298 -0
- tangle_cli/component_inspector.py +494 -0
- tangle_cli/component_publisher.py +921 -0
- tangle_cli/components_cli.py +269 -0
- tangle_cli/dynamic_discovery_client.py +296 -0
- tangle_cli/generated_model_extensions.py +405 -0
- tangle_cli/generated_runtime.py +43 -0
- tangle_cli/handler.py +96 -0
- tangle_cli/hydration_trust.py +222 -0
- tangle_cli/logger.py +166 -0
- tangle_cli/models.py +407 -0
- tangle_cli/module_bundler.py +662 -0
- tangle_cli/openapi/__init__.py +0 -0
- tangle_cli/openapi/codegen.py +1090 -0
- tangle_cli/openapi/parser.py +77 -0
- tangle_cli/pipeline_dehydrator.py +720 -0
- tangle_cli/pipeline_hydrator.py +1785 -0
- tangle_cli/pipeline_run_annotations.py +41 -0
- tangle_cli/pipeline_run_details.py +203 -0
- tangle_cli/pipeline_run_manager.py +1994 -0
- tangle_cli/pipeline_run_search.py +712 -0
- tangle_cli/pipeline_runner.py +620 -0
- tangle_cli/pipeline_runs_cli.py +584 -0
- tangle_cli/pipelines.py +581 -0
- tangle_cli/pipelines_cli.py +271 -0
- tangle_cli/published_components_cli.py +373 -0
- tangle_cli/py.typed +0 -0
- tangle_cli/quickstart.py +110 -0
- tangle_cli/secrets.py +156 -0
- tangle_cli/secrets_cli.py +269 -0
- tangle_cli/utils.py +942 -0
- tangle_cli/version_manager.py +470 -0
- tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
- tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
- tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
- tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
tangle_cli/pipelines.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""Local helpers for working with Tangle pipeline component specs.
|
|
2
|
+
|
|
3
|
+
This module intentionally stays API-free: it validates, diagrams, and lays out
|
|
4
|
+
pipeline YAML files that are already present on disk.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from collections import deque
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterable, Mapping
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from .utils import dump_yaml
|
|
19
|
+
|
|
20
|
+
PIPELINE_GRAPH_PATH = "implementation.graph"
|
|
21
|
+
TASKS_PATH = f"{PIPELINE_GRAPH_PATH}.tasks"
|
|
22
|
+
POSITION_ANNOTATION = "editor.position"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PipelineValidationError(ValueError):
|
|
26
|
+
"""Raised when a local pipeline spec cannot be parsed or validated."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class LayoutResult:
|
|
31
|
+
"""Summary of a layout operation."""
|
|
32
|
+
|
|
33
|
+
output_path: Path
|
|
34
|
+
tasks_positioned: int
|
|
35
|
+
graphs_positioned: int
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class HydrateResult:
|
|
40
|
+
"""Summary of a hydrate operation."""
|
|
41
|
+
|
|
42
|
+
content: str
|
|
43
|
+
output_path: Path | None
|
|
44
|
+
resolved_components: int
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class _LoadedComponent:
|
|
49
|
+
digest: str
|
|
50
|
+
spec: dict[str, Any]
|
|
51
|
+
base_dir: Path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# YAML loading / validation
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_pipeline_file(path: str | Path) -> dict[str, Any]:
|
|
60
|
+
"""Load a pipeline YAML file and return its top-level mapping.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
PipelineValidationError: If the file cannot be read, parsed, or does
|
|
64
|
+
not contain a top-level mapping.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
pipeline_path = Path(path)
|
|
68
|
+
try:
|
|
69
|
+
text = pipeline_path.read_text(encoding="utf-8")
|
|
70
|
+
except OSError as exc:
|
|
71
|
+
raise PipelineValidationError(f"Unable to read {pipeline_path}: {exc}") from exc
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
loaded = yaml.safe_load(text)
|
|
75
|
+
except yaml.YAMLError as exc:
|
|
76
|
+
raise PipelineValidationError(f"Invalid YAML in {pipeline_path}: {exc}") from exc
|
|
77
|
+
|
|
78
|
+
if not isinstance(loaded, dict):
|
|
79
|
+
raise PipelineValidationError("Pipeline YAML must contain a top-level mapping")
|
|
80
|
+
|
|
81
|
+
return loaded
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_pipeline_file(path: str | Path) -> dict[str, Any]:
|
|
85
|
+
"""Load and validate a pipeline YAML file, returning the parsed spec."""
|
|
86
|
+
|
|
87
|
+
pipeline = load_pipeline_file(path)
|
|
88
|
+
validate_pipeline_spec(pipeline)
|
|
89
|
+
return pipeline
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_pipeline_spec(pipeline: Mapping[str, Any]) -> None:
|
|
93
|
+
"""Validate the OSS-compatible local pipeline shape.
|
|
94
|
+
|
|
95
|
+
This is a pragmatic validator for local authoring workflows. It focuses on
|
|
96
|
+
the graph structure that the CLI commands consume rather than provider-specific
|
|
97
|
+
deployment extensions or remote API fields.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
errors: list[str] = []
|
|
101
|
+
_validate_root_pipeline(pipeline, errors)
|
|
102
|
+
if errors:
|
|
103
|
+
details = "\n".join(f"- {error}" for error in errors)
|
|
104
|
+
raise PipelineValidationError(f"Pipeline validation failed:\n{details}")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _validate_root_pipeline(pipeline: Mapping[str, Any], errors: list[str]) -> None:
|
|
108
|
+
name = pipeline.get("name")
|
|
109
|
+
if not isinstance(name, str) or not name.strip():
|
|
110
|
+
errors.append("name must be a non-empty string")
|
|
111
|
+
|
|
112
|
+
implementation = pipeline.get("implementation")
|
|
113
|
+
if not isinstance(implementation, Mapping):
|
|
114
|
+
errors.append("implementation must be an object")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
graph = implementation.get("graph")
|
|
118
|
+
if not isinstance(graph, Mapping):
|
|
119
|
+
errors.append(f"{PIPELINE_GRAPH_PATH} must be an object")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
_validate_graph_spec(pipeline, "pipeline", errors, require_tasks=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _validate_graph_spec(
|
|
126
|
+
spec: Mapping[str, Any],
|
|
127
|
+
path: str,
|
|
128
|
+
errors: list[str],
|
|
129
|
+
*,
|
|
130
|
+
require_tasks: bool,
|
|
131
|
+
) -> None:
|
|
132
|
+
implementation = spec.get("implementation")
|
|
133
|
+
if not isinstance(implementation, Mapping):
|
|
134
|
+
if require_tasks:
|
|
135
|
+
errors.append(f"{path}.implementation must be an object")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
graph = implementation.get("graph")
|
|
139
|
+
if not isinstance(graph, Mapping):
|
|
140
|
+
if require_tasks:
|
|
141
|
+
errors.append(f"{path}.{PIPELINE_GRAPH_PATH} must be an object")
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
tasks = graph.get("tasks")
|
|
145
|
+
if tasks is None and not require_tasks:
|
|
146
|
+
return
|
|
147
|
+
if not isinstance(tasks, Mapping):
|
|
148
|
+
errors.append(f"{path}.{TASKS_PATH} must be an object")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
task_names: set[str] = set()
|
|
152
|
+
for name in tasks.keys():
|
|
153
|
+
if not isinstance(name, str):
|
|
154
|
+
errors.append(f"{path}.{TASKS_PATH} task ids must be strings")
|
|
155
|
+
continue
|
|
156
|
+
task_names.add(name)
|
|
157
|
+
edges: set[tuple[str, str]] = set()
|
|
158
|
+
|
|
159
|
+
for task_name, raw_task in tasks.items():
|
|
160
|
+
task_path = f"{path}.{TASKS_PATH}.{task_name}"
|
|
161
|
+
if not isinstance(task_name, str):
|
|
162
|
+
continue
|
|
163
|
+
if not isinstance(raw_task, Mapping):
|
|
164
|
+
errors.append(f"{task_path} must be an object")
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
component_ref = raw_task.get("componentRef")
|
|
168
|
+
if not isinstance(component_ref, Mapping):
|
|
169
|
+
errors.append(f"{task_path}.componentRef must be an object")
|
|
170
|
+
else:
|
|
171
|
+
_validate_component_ref(component_ref, f"{task_path}.componentRef", errors)
|
|
172
|
+
|
|
173
|
+
dependencies = raw_task.get("dependencies", [])
|
|
174
|
+
if dependencies is None:
|
|
175
|
+
dependencies = []
|
|
176
|
+
if not isinstance(dependencies, list):
|
|
177
|
+
errors.append(f"{task_path}.dependencies must be a list of task ids")
|
|
178
|
+
else:
|
|
179
|
+
for dep in dependencies:
|
|
180
|
+
if not isinstance(dep, str):
|
|
181
|
+
errors.append(f"{task_path}.dependencies entries must be strings")
|
|
182
|
+
continue
|
|
183
|
+
if dep not in task_names:
|
|
184
|
+
errors.append(f"{task_path}.dependencies references unknown task {dep!r}")
|
|
185
|
+
else:
|
|
186
|
+
edges.add((dep, str(task_name)))
|
|
187
|
+
|
|
188
|
+
arguments = raw_task.get("arguments", {})
|
|
189
|
+
if arguments is not None and not isinstance(arguments, Mapping):
|
|
190
|
+
errors.append(f"{task_path}.arguments must be an object")
|
|
191
|
+
else:
|
|
192
|
+
for referenced_task in _extract_task_output_refs(arguments or {}):
|
|
193
|
+
if referenced_task not in task_names:
|
|
194
|
+
errors.append(
|
|
195
|
+
f"{task_path}.arguments references unknown task {referenced_task!r}"
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
edges.add((referenced_task, str(task_name)))
|
|
199
|
+
|
|
200
|
+
if isinstance(component_ref, Mapping):
|
|
201
|
+
nested_spec = component_ref.get("spec")
|
|
202
|
+
if isinstance(nested_spec, Mapping):
|
|
203
|
+
_validate_graph_spec(
|
|
204
|
+
nested_spec,
|
|
205
|
+
f"{task_path}.componentRef.spec",
|
|
206
|
+
errors,
|
|
207
|
+
require_tasks=False,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
output_values = graph.get("outputValues", {})
|
|
211
|
+
if output_values is not None and not isinstance(output_values, Mapping):
|
|
212
|
+
errors.append(f"{path}.{PIPELINE_GRAPH_PATH}.outputValues must be an object")
|
|
213
|
+
else:
|
|
214
|
+
for referenced_task in _extract_task_output_refs(output_values or {}):
|
|
215
|
+
if referenced_task not in task_names:
|
|
216
|
+
errors.append(
|
|
217
|
+
f"{path}.{PIPELINE_GRAPH_PATH}.outputValues references unknown task "
|
|
218
|
+
f"{referenced_task!r}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
cycle = _find_cycle(task_names, edges)
|
|
222
|
+
if cycle:
|
|
223
|
+
errors.append(f"{path}.{TASKS_PATH} contains a dependency cycle: {' -> '.join(cycle)}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _validate_component_ref(ref: Mapping[str, Any], path: str, errors: list[str]) -> None:
|
|
227
|
+
has_selector = any(ref.get(key) for key in ("name", "digest", "tag", "url", "text"))
|
|
228
|
+
nested_spec = ref.get("spec")
|
|
229
|
+
if nested_spec is not None and not isinstance(nested_spec, Mapping):
|
|
230
|
+
errors.append(f"{path}.spec must be an object when provided")
|
|
231
|
+
if isinstance(nested_spec, Mapping):
|
|
232
|
+
has_selector = True
|
|
233
|
+
if not has_selector:
|
|
234
|
+
errors.append(
|
|
235
|
+
f"{path} must include at least one of name, digest, tag, url, text, or spec"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _extract_task_output_refs(value: Any) -> set[str]:
|
|
240
|
+
refs: set[str] = set()
|
|
241
|
+
if isinstance(value, Mapping):
|
|
242
|
+
task_output = value.get("taskOutput")
|
|
243
|
+
if isinstance(task_output, Mapping) and isinstance(task_output.get("taskId"), str):
|
|
244
|
+
refs.add(task_output["taskId"])
|
|
245
|
+
for nested in value.values():
|
|
246
|
+
refs.update(_extract_task_output_refs(nested))
|
|
247
|
+
elif isinstance(value, list):
|
|
248
|
+
for item in value:
|
|
249
|
+
refs.update(_extract_task_output_refs(item))
|
|
250
|
+
return refs
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _find_cycle(nodes: Iterable[str], edges: Iterable[tuple[str, str]]) -> list[str]:
|
|
254
|
+
adjacency: dict[str, list[str]] = {node: [] for node in nodes}
|
|
255
|
+
for source, target in edges:
|
|
256
|
+
adjacency.setdefault(source, []).append(target)
|
|
257
|
+
|
|
258
|
+
visiting: set[str] = set()
|
|
259
|
+
visited: set[str] = set()
|
|
260
|
+
stack: list[str] = []
|
|
261
|
+
|
|
262
|
+
def visit(node: str) -> list[str] | None:
|
|
263
|
+
if node in visited:
|
|
264
|
+
return None
|
|
265
|
+
if node in visiting:
|
|
266
|
+
try:
|
|
267
|
+
start = stack.index(node)
|
|
268
|
+
except ValueError:
|
|
269
|
+
return [node, node]
|
|
270
|
+
return stack[start:] + [node]
|
|
271
|
+
|
|
272
|
+
visiting.add(node)
|
|
273
|
+
stack.append(node)
|
|
274
|
+
for neighbor in sorted(adjacency.get(node, [])):
|
|
275
|
+
cycle = visit(neighbor)
|
|
276
|
+
if cycle:
|
|
277
|
+
return cycle
|
|
278
|
+
stack.pop()
|
|
279
|
+
visiting.remove(node)
|
|
280
|
+
visited.add(node)
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
for node in sorted(adjacency):
|
|
284
|
+
cycle = visit(node)
|
|
285
|
+
if cycle:
|
|
286
|
+
return cycle
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Mermaid diagrams
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def generate_mermaid(pipeline_spec: Mapping[str, Any], name: str | None = None) -> str:
|
|
296
|
+
"""Generate GitHub-compatible Mermaid diagrams for a pipeline spec."""
|
|
297
|
+
|
|
298
|
+
display_name = name or str(pipeline_spec.get("name") or "Pipeline")
|
|
299
|
+
tasks = _tasks_for_spec(pipeline_spec)
|
|
300
|
+
if not tasks:
|
|
301
|
+
return f"No tasks found in pipeline `{display_name}`."
|
|
302
|
+
|
|
303
|
+
sections = [f"### {display_name}\n", _render_mermaid_graph(tasks)]
|
|
304
|
+
for heading_level, task_id, nested_spec in _iter_nested_graph_specs(tasks, level=3):
|
|
305
|
+
nested_name = str(nested_spec.get("name") or task_id)
|
|
306
|
+
sections.append(f"\n{'#' * heading_level} Subgraph: {nested_name} (`{task_id}`)\n")
|
|
307
|
+
sections.append(_render_mermaid_graph(_tasks_for_spec(nested_spec)))
|
|
308
|
+
return "\n".join(sections)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _render_mermaid_graph(tasks: Mapping[str, Any]) -> str:
|
|
312
|
+
lines = ["```mermaid", "flowchart LR"]
|
|
313
|
+
|
|
314
|
+
for task_id, task_spec in tasks.items():
|
|
315
|
+
label = _task_label(str(task_id), task_spec)
|
|
316
|
+
lines.append(f" {_safe_mermaid_id(str(task_id))}[\"{_escape_mermaid_label(label)}\"]")
|
|
317
|
+
|
|
318
|
+
edges = _dependency_edges(tasks)
|
|
319
|
+
if edges:
|
|
320
|
+
lines.append("")
|
|
321
|
+
for source, target in sorted(edges):
|
|
322
|
+
lines.append(f" {_safe_mermaid_id(source)} --> {_safe_mermaid_id(target)}")
|
|
323
|
+
|
|
324
|
+
lines.append("```")
|
|
325
|
+
return "\n".join(lines)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _iter_nested_graph_specs(
|
|
329
|
+
tasks: Mapping[str, Any],
|
|
330
|
+
*,
|
|
331
|
+
level: int,
|
|
332
|
+
) -> Iterable[tuple[int, str, Mapping[str, Any]]]:
|
|
333
|
+
for task_id, task_spec in tasks.items():
|
|
334
|
+
if not isinstance(task_spec, Mapping):
|
|
335
|
+
continue
|
|
336
|
+
spec = _component_ref_spec(task_spec)
|
|
337
|
+
if spec is None or not _tasks_for_spec(spec):
|
|
338
|
+
continue
|
|
339
|
+
yield level, str(task_id), spec
|
|
340
|
+
yield from _iter_nested_graph_specs(_tasks_for_spec(spec), level=level + 1)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _task_label(task_id: str, task_spec: Any) -> str:
|
|
344
|
+
if not isinstance(task_spec, Mapping):
|
|
345
|
+
return task_id
|
|
346
|
+
|
|
347
|
+
component_ref = task_spec.get("componentRef")
|
|
348
|
+
ref_name = component_ref.get("name") if isinstance(component_ref, Mapping) else None
|
|
349
|
+
spec = _component_ref_spec(task_spec)
|
|
350
|
+
spec_name = spec.get("name") if spec is not None else None
|
|
351
|
+
label = str(ref_name or spec_name or task_id)
|
|
352
|
+
if spec is not None and _tasks_for_spec(spec):
|
|
353
|
+
return f"{label} [subgraph]"
|
|
354
|
+
return label
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _safe_mermaid_id(task_id: str) -> str:
|
|
358
|
+
safe_id = re.sub(r"\W+", "_", task_id).strip("_") or "task"
|
|
359
|
+
if safe_id[0].isdigit():
|
|
360
|
+
safe_id = f"task_{safe_id}"
|
|
361
|
+
return safe_id
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _escape_mermaid_label(label: str) -> str:
|
|
365
|
+
return label.replace("\\", "\\\\").replace('"', "\\\"")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Hydration
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def hydrate_pipeline_file(
|
|
374
|
+
pipeline_path: str | Path,
|
|
375
|
+
*,
|
|
376
|
+
output: str | Path | None = None,
|
|
377
|
+
overrides: Mapping[str, Any] | None = None,
|
|
378
|
+
base_url: str | None = None,
|
|
379
|
+
token: str | None = None,
|
|
380
|
+
auth_header: str | None = None,
|
|
381
|
+
header: list[str] | None = None,
|
|
382
|
+
include_env_credentials: bool = True,
|
|
383
|
+
client: Any | None = None,
|
|
384
|
+
logger: Any | None = None,
|
|
385
|
+
trusted_python_sources: list[str] | None = None,
|
|
386
|
+
allow_all_hydration: bool = False,
|
|
387
|
+
) -> HydrateResult:
|
|
388
|
+
"""Hydrate a local pipeline YAML file using the ported TD hydrator."""
|
|
389
|
+
|
|
390
|
+
from .pipeline_hydrator import HydrationError, PipelineHydrator
|
|
391
|
+
|
|
392
|
+
output_path = Path(output) if output is not None else None
|
|
393
|
+
try:
|
|
394
|
+
hydrator = PipelineHydrator(
|
|
395
|
+
client=client,
|
|
396
|
+
base_url=base_url,
|
|
397
|
+
token=token,
|
|
398
|
+
auth_header=auth_header,
|
|
399
|
+
header=header,
|
|
400
|
+
include_env_credentials=include_env_credentials,
|
|
401
|
+
logger=logger,
|
|
402
|
+
resolution_overrides=dict(overrides or {}),
|
|
403
|
+
trusted_python_sources=trusted_python_sources,
|
|
404
|
+
allow_all_hydration=allow_all_hydration,
|
|
405
|
+
)
|
|
406
|
+
hydrated = hydrator.hydrate_file(
|
|
407
|
+
pipeline_path,
|
|
408
|
+
output_file=output_path,
|
|
409
|
+
overrides={str(key): str(value) for key, value in (overrides or {}).items()},
|
|
410
|
+
)
|
|
411
|
+
validate_pipeline_spec(hydrated.data)
|
|
412
|
+
except HydrationError as exc:
|
|
413
|
+
raise PipelineValidationError(str(exc)) from exc
|
|
414
|
+
|
|
415
|
+
return HydrateResult(
|
|
416
|
+
content=hydrated.content,
|
|
417
|
+
output_path=output_path,
|
|
418
|
+
resolved_components=hydrated.resolved_count,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ---------------------------------------------------------------------------
|
|
423
|
+
# Layout
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def layout_pipeline_file(
|
|
428
|
+
pipeline_path: str | Path,
|
|
429
|
+
*,
|
|
430
|
+
output: str | Path | None = None,
|
|
431
|
+
recursive: bool = False,
|
|
432
|
+
x_spacing: int = 300,
|
|
433
|
+
y_spacing: int = 120,
|
|
434
|
+
) -> LayoutResult:
|
|
435
|
+
"""Apply a deterministic left-to-right layout to a pipeline YAML file."""
|
|
436
|
+
|
|
437
|
+
source_path = Path(pipeline_path)
|
|
438
|
+
pipeline = validate_pipeline_file(source_path)
|
|
439
|
+
tasks_positioned, graphs_positioned = layout_pipeline_spec(
|
|
440
|
+
pipeline,
|
|
441
|
+
recursive=recursive,
|
|
442
|
+
x_spacing=x_spacing,
|
|
443
|
+
y_spacing=y_spacing,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
output_path = Path(output) if output is not None else source_path
|
|
447
|
+
output_path.write_text(dump_yaml(pipeline), encoding="utf-8")
|
|
448
|
+
return LayoutResult(
|
|
449
|
+
output_path=output_path,
|
|
450
|
+
tasks_positioned=tasks_positioned,
|
|
451
|
+
graphs_positioned=graphs_positioned,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def layout_pipeline_spec(
|
|
456
|
+
pipeline: Mapping[str, Any],
|
|
457
|
+
*,
|
|
458
|
+
recursive: bool = False,
|
|
459
|
+
x_spacing: int = 300,
|
|
460
|
+
y_spacing: int = 120,
|
|
461
|
+
) -> tuple[int, int]:
|
|
462
|
+
"""Mutate a parsed pipeline spec with deterministic task coordinates."""
|
|
463
|
+
|
|
464
|
+
tasks_positioned = _layout_graph_spec(pipeline, x_spacing=x_spacing, y_spacing=y_spacing)
|
|
465
|
+
graphs_positioned = 1 if tasks_positioned else 0
|
|
466
|
+
|
|
467
|
+
if recursive:
|
|
468
|
+
for _task_id, nested_spec in _iter_mutable_nested_specs(_tasks_for_spec(pipeline)):
|
|
469
|
+
nested_count = _layout_graph_spec(nested_spec, x_spacing=x_spacing, y_spacing=y_spacing)
|
|
470
|
+
if nested_count:
|
|
471
|
+
tasks_positioned += nested_count
|
|
472
|
+
graphs_positioned += 1
|
|
473
|
+
|
|
474
|
+
return tasks_positioned, graphs_positioned
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _layout_graph_spec(spec: Mapping[str, Any], *, x_spacing: int, y_spacing: int) -> int:
|
|
478
|
+
tasks = _tasks_for_spec(spec)
|
|
479
|
+
if not tasks:
|
|
480
|
+
return 0
|
|
481
|
+
|
|
482
|
+
layers = _task_layers(tasks)
|
|
483
|
+
for layer_index, layer in enumerate(layers):
|
|
484
|
+
for row_index, task_name in enumerate(layer):
|
|
485
|
+
raw_task = tasks[task_name]
|
|
486
|
+
if not isinstance(raw_task, dict):
|
|
487
|
+
continue
|
|
488
|
+
annotations = raw_task.setdefault("annotations", {})
|
|
489
|
+
if not isinstance(annotations, dict):
|
|
490
|
+
annotations = {}
|
|
491
|
+
raw_task["annotations"] = annotations
|
|
492
|
+
annotations[POSITION_ANNOTATION] = json.dumps(
|
|
493
|
+
{"x": layer_index * x_spacing, "y": row_index * y_spacing}
|
|
494
|
+
)
|
|
495
|
+
return len(tasks)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _task_layers(tasks: Mapping[str, Any]) -> list[list[str]]:
|
|
499
|
+
task_names = [name for name in tasks.keys() if isinstance(name, str)]
|
|
500
|
+
task_name_set = set(task_names)
|
|
501
|
+
outgoing: dict[str, set[str]] = {name: set() for name in task_names}
|
|
502
|
+
incoming_count: dict[str, int] = {name: 0 for name in task_names}
|
|
503
|
+
|
|
504
|
+
for source, target in _dependency_edges(tasks):
|
|
505
|
+
if source not in task_name_set or target not in task_name_set:
|
|
506
|
+
continue
|
|
507
|
+
if target not in outgoing[source]:
|
|
508
|
+
outgoing[source].add(target)
|
|
509
|
+
incoming_count[target] += 1
|
|
510
|
+
|
|
511
|
+
ready = deque(name for name in task_names if incoming_count[name] == 0)
|
|
512
|
+
layer_by_task: dict[str, int] = {name: 0 for name in ready}
|
|
513
|
+
|
|
514
|
+
while ready:
|
|
515
|
+
current = ready.popleft()
|
|
516
|
+
for target in sorted(outgoing[current], key=task_names.index):
|
|
517
|
+
layer_by_task[target] = max(layer_by_task.get(target, 0), layer_by_task[current] + 1)
|
|
518
|
+
incoming_count[target] -= 1
|
|
519
|
+
if incoming_count[target] == 0:
|
|
520
|
+
ready.append(target)
|
|
521
|
+
|
|
522
|
+
# Validation rejects cycles, but keep layout deterministic if called directly.
|
|
523
|
+
for name in task_names:
|
|
524
|
+
if name not in layer_by_task:
|
|
525
|
+
layer_by_task[name] = 0
|
|
526
|
+
|
|
527
|
+
max_layer = max(layer_by_task.values(), default=0)
|
|
528
|
+
layers: list[list[str]] = [[] for _ in range(max_layer + 1)]
|
|
529
|
+
for name in task_names:
|
|
530
|
+
layers[layer_by_task[name]].append(name)
|
|
531
|
+
return layers
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _dependency_edges(tasks: Mapping[str, Any]) -> set[tuple[str, str]]:
|
|
535
|
+
edges: set[tuple[str, str]] = set()
|
|
536
|
+
task_names = {name for name in tasks.keys() if isinstance(name, str)}
|
|
537
|
+
|
|
538
|
+
for task_id, task_spec in tasks.items():
|
|
539
|
+
if not isinstance(task_id, str) or not isinstance(task_spec, Mapping):
|
|
540
|
+
continue
|
|
541
|
+
target = task_id
|
|
542
|
+
dependencies = task_spec.get("dependencies", [])
|
|
543
|
+
if isinstance(dependencies, list):
|
|
544
|
+
for dependency in dependencies:
|
|
545
|
+
if isinstance(dependency, str) and dependency in task_names:
|
|
546
|
+
edges.add((dependency, target))
|
|
547
|
+
for referenced_task in _extract_task_output_refs(task_spec.get("arguments", {})):
|
|
548
|
+
if referenced_task in task_names:
|
|
549
|
+
edges.add((referenced_task, target))
|
|
550
|
+
|
|
551
|
+
return edges
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _tasks_for_spec(spec: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
555
|
+
implementation = spec.get("implementation")
|
|
556
|
+
if not isinstance(implementation, Mapping):
|
|
557
|
+
return {}
|
|
558
|
+
graph = implementation.get("graph")
|
|
559
|
+
if not isinstance(graph, Mapping):
|
|
560
|
+
return {}
|
|
561
|
+
tasks = graph.get("tasks")
|
|
562
|
+
return tasks if isinstance(tasks, Mapping) else {}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _component_ref_spec(task_spec: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
|
566
|
+
component_ref = task_spec.get("componentRef")
|
|
567
|
+
if not isinstance(component_ref, Mapping):
|
|
568
|
+
return None
|
|
569
|
+
spec = component_ref.get("spec")
|
|
570
|
+
return spec if isinstance(spec, Mapping) else None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _iter_mutable_nested_specs(tasks: Mapping[str, Any]) -> Iterable[tuple[str, Mapping[str, Any]]]:
|
|
574
|
+
for task_id, task_spec in tasks.items():
|
|
575
|
+
if not isinstance(task_spec, Mapping):
|
|
576
|
+
continue
|
|
577
|
+
spec = _component_ref_spec(task_spec)
|
|
578
|
+
if spec is None or not _tasks_for_spec(spec):
|
|
579
|
+
continue
|
|
580
|
+
yield str(task_id), spec
|
|
581
|
+
yield from _iter_mutable_nested_specs(_tasks_for_spec(spec))
|