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.
Files changed (48) hide show
  1. tangle_cli/__init__.py +19 -0
  2. tangle_cli/api_cli.py +787 -0
  3. tangle_cli/api_schema.py +633 -0
  4. tangle_cli/api_transport.py +461 -0
  5. tangle_cli/args_container.py +244 -0
  6. tangle_cli/artifacts.py +293 -0
  7. tangle_cli/artifacts_cli.py +108 -0
  8. tangle_cli/cli.py +57 -0
  9. tangle_cli/cli_helpers.py +116 -0
  10. tangle_cli/cli_options.py +52 -0
  11. tangle_cli/client.py +677 -0
  12. tangle_cli/component_from_func.py +1856 -0
  13. tangle_cli/component_generator.py +298 -0
  14. tangle_cli/component_inspector.py +494 -0
  15. tangle_cli/component_publisher.py +921 -0
  16. tangle_cli/components_cli.py +269 -0
  17. tangle_cli/dynamic_discovery_client.py +296 -0
  18. tangle_cli/generated_model_extensions.py +405 -0
  19. tangle_cli/generated_runtime.py +43 -0
  20. tangle_cli/handler.py +96 -0
  21. tangle_cli/hydration_trust.py +222 -0
  22. tangle_cli/logger.py +166 -0
  23. tangle_cli/models.py +407 -0
  24. tangle_cli/module_bundler.py +662 -0
  25. tangle_cli/openapi/__init__.py +0 -0
  26. tangle_cli/openapi/codegen.py +1090 -0
  27. tangle_cli/openapi/parser.py +77 -0
  28. tangle_cli/pipeline_dehydrator.py +720 -0
  29. tangle_cli/pipeline_hydrator.py +1785 -0
  30. tangle_cli/pipeline_run_annotations.py +41 -0
  31. tangle_cli/pipeline_run_details.py +203 -0
  32. tangle_cli/pipeline_run_manager.py +1994 -0
  33. tangle_cli/pipeline_run_search.py +712 -0
  34. tangle_cli/pipeline_runner.py +620 -0
  35. tangle_cli/pipeline_runs_cli.py +584 -0
  36. tangle_cli/pipelines.py +581 -0
  37. tangle_cli/pipelines_cli.py +271 -0
  38. tangle_cli/published_components_cli.py +373 -0
  39. tangle_cli/py.typed +0 -0
  40. tangle_cli/quickstart.py +110 -0
  41. tangle_cli/secrets.py +156 -0
  42. tangle_cli/secrets_cli.py +269 -0
  43. tangle_cli/utils.py +942 -0
  44. tangle_cli/version_manager.py +470 -0
  45. tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
  46. tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
  47. tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
  48. tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
@@ -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))