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
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""High-level OSS pipeline-run orchestration.
|
|
2
|
+
|
|
3
|
+
This module owns the generic path-based run flow that downstream CLIs can share:
|
|
4
|
+
load/hydrate a pipeline, perform generic pre-submit preparation, optionally
|
|
5
|
+
layout/validate, then submit/wait/retry through :mod:`tangle_cli.pipeline_run_manager`.
|
|
6
|
+
Downstream-specific behavior (provider auth, cloud-object I/O, hosted logs,
|
|
7
|
+
notifications, mutexes, schedulers, service-account annotations, and legacy
|
|
8
|
+
result shapes) is exposed as hooks rather than imported here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import inspect
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Mapping
|
|
18
|
+
|
|
19
|
+
from .pipeline_run_manager import (
|
|
20
|
+
PipelineRunContext,
|
|
21
|
+
PipelineRunError,
|
|
22
|
+
PipelineRunHooks,
|
|
23
|
+
PipelineRunManager,
|
|
24
|
+
PipelineSubmitPayload,
|
|
25
|
+
PipelineWaitOutcome,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class PipelinePreparationResult:
|
|
31
|
+
"""Prepared pipeline state before submit/wait orchestration."""
|
|
32
|
+
|
|
33
|
+
pipeline_spec: dict[str, Any]
|
|
34
|
+
pipeline_name: str
|
|
35
|
+
effective_path: str | Path | None
|
|
36
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PipelineRunnerHooks(PipelineRunHooks):
|
|
41
|
+
"""Extension seams for high-level pipeline-run orchestration.
|
|
42
|
+
|
|
43
|
+
``PipelineRunHooks`` already covers submit/wait/retry lifecycle behavior.
|
|
44
|
+
This subclass adds path/spec preparation seams so downstreams can keep their
|
|
45
|
+
platform-specific behavior outside the generic OSS runner.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def initial_pipeline_name(self, pipeline_path: str | Path) -> str:
|
|
49
|
+
"""Return the fallback display/run name before the spec is loaded."""
|
|
50
|
+
|
|
51
|
+
return Path(str(pipeline_path)).stem
|
|
52
|
+
|
|
53
|
+
def load_pipeline(self, pipeline_path: str | Path) -> dict[str, Any]:
|
|
54
|
+
"""Load an unhydrated pipeline spec.
|
|
55
|
+
|
|
56
|
+
The default delegates to ``read_pipeline_yaml`` from ``PipelineRunHooks``.
|
|
57
|
+
Downstreams can override this for alternate URI schemes such as gs://.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
return self.read_pipeline_yaml(pipeline_path)
|
|
61
|
+
|
|
62
|
+
def hydrate_pipeline_for_run(
|
|
63
|
+
self,
|
|
64
|
+
pipeline_path: str | Path,
|
|
65
|
+
*,
|
|
66
|
+
client: Any | None = None,
|
|
67
|
+
resolution_overrides: dict[str, Any] | None = None,
|
|
68
|
+
) -> tuple[dict[str, Any], str | Path | None]:
|
|
69
|
+
"""Hydrate a pipeline path for a run.
|
|
70
|
+
|
|
71
|
+
Returns the hydrated spec and an optional effective path. The effective
|
|
72
|
+
path is the location layout/validation should use when hydration writes
|
|
73
|
+
to a temporary file. OSS hydration is in-memory by default.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
hydrate_kwargs: dict[str, Any] = {"resolution_overrides": resolution_overrides}
|
|
77
|
+
try:
|
|
78
|
+
parameters = inspect.signature(self.hydrate_pipeline).parameters
|
|
79
|
+
except (TypeError, ValueError):
|
|
80
|
+
parameters = {}
|
|
81
|
+
if client is not None and (
|
|
82
|
+
"client" in parameters
|
|
83
|
+
or any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values())
|
|
84
|
+
):
|
|
85
|
+
hydrate_kwargs["client"] = client
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
self.hydrate_pipeline(
|
|
89
|
+
pipeline_path,
|
|
90
|
+
**hydrate_kwargs,
|
|
91
|
+
),
|
|
92
|
+
None,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def prepare_loaded_pipeline_spec(
|
|
96
|
+
self,
|
|
97
|
+
pipeline_spec: dict[str, Any],
|
|
98
|
+
*,
|
|
99
|
+
pipeline_path: str | Path,
|
|
100
|
+
effective_path: str | Path | None,
|
|
101
|
+
hydrate: bool,
|
|
102
|
+
run_args: dict[str, Any] | None,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Transform a loaded/hydrated spec before validation/layout.
|
|
105
|
+
|
|
106
|
+
Use this for downstream template post-processing that is not specific to
|
|
107
|
+
submit payload construction.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
return pipeline_spec
|
|
111
|
+
|
|
112
|
+
def validate_pipeline_for_run(
|
|
113
|
+
self,
|
|
114
|
+
pipeline_spec: dict[str, Any],
|
|
115
|
+
*,
|
|
116
|
+
pipeline_path: str | Path,
|
|
117
|
+
effective_path: str | Path | None,
|
|
118
|
+
skip_validation: bool,
|
|
119
|
+
) -> list[str]:
|
|
120
|
+
"""Return validation errors for a prepared pipeline spec.
|
|
121
|
+
|
|
122
|
+
The OSS default intentionally does not enforce the local authoring
|
|
123
|
+
validator here: submit-time API validation remains the source of truth,
|
|
124
|
+
while downstreams can plug in stricter schema/input validators.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
del pipeline_spec, pipeline_path, effective_path, skip_validation
|
|
128
|
+
return []
|
|
129
|
+
|
|
130
|
+
def has_layout(self, pipeline_spec: Mapping[str, Any]) -> bool:
|
|
131
|
+
"""Return True when a pipeline graph already has non-zero coordinates."""
|
|
132
|
+
|
|
133
|
+
tasks = (
|
|
134
|
+
pipeline_spec.get("implementation", {})
|
|
135
|
+
.get("graph", {})
|
|
136
|
+
.get("tasks", {})
|
|
137
|
+
)
|
|
138
|
+
if not tasks:
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
for task in tasks.values() if isinstance(tasks, Mapping) else []:
|
|
142
|
+
if not isinstance(task, Mapping):
|
|
143
|
+
continue
|
|
144
|
+
annotations = task.get("annotations", {})
|
|
145
|
+
position = annotations.get("editor.position") if isinstance(annotations, Mapping) else None
|
|
146
|
+
if isinstance(position, str):
|
|
147
|
+
try:
|
|
148
|
+
import json
|
|
149
|
+
|
|
150
|
+
parsed = json.loads(position)
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
parsed = None
|
|
153
|
+
if isinstance(parsed, Mapping) and (parsed.get("x", 0) != 0 or parsed.get("y", 0) != 0):
|
|
154
|
+
return True
|
|
155
|
+
component_ref = task.get("componentRef", {})
|
|
156
|
+
nested_spec = component_ref.get("spec") if isinstance(component_ref, Mapping) else None
|
|
157
|
+
if isinstance(nested_spec, Mapping) and not self.has_layout(nested_spec):
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
def should_apply_layout(
|
|
163
|
+
self,
|
|
164
|
+
pipeline_spec: dict[str, Any],
|
|
165
|
+
*,
|
|
166
|
+
pipeline_path: str | Path,
|
|
167
|
+
effective_path: str | Path | None,
|
|
168
|
+
skip_layout: bool,
|
|
169
|
+
force_layout: bool,
|
|
170
|
+
layout_algorithm: str | None,
|
|
171
|
+
) -> bool:
|
|
172
|
+
"""Return True when the runner should layout before submit."""
|
|
173
|
+
|
|
174
|
+
del pipeline_path, effective_path, layout_algorithm
|
|
175
|
+
return not skip_layout and (force_layout or not self.has_layout(pipeline_spec))
|
|
176
|
+
|
|
177
|
+
def apply_layout(
|
|
178
|
+
self,
|
|
179
|
+
pipeline_spec: dict[str, Any],
|
|
180
|
+
*,
|
|
181
|
+
pipeline_path: str | Path,
|
|
182
|
+
effective_path: str | Path | None,
|
|
183
|
+
force_layout: bool,
|
|
184
|
+
layout_algorithm: str | None,
|
|
185
|
+
) -> dict[str, Any]:
|
|
186
|
+
"""Apply the OSS deterministic layout to an in-memory pipeline spec."""
|
|
187
|
+
|
|
188
|
+
del pipeline_path, effective_path, force_layout, layout_algorithm
|
|
189
|
+
from .pipelines import layout_pipeline_spec
|
|
190
|
+
|
|
191
|
+
laid_out = copy.deepcopy(pipeline_spec)
|
|
192
|
+
layout_pipeline_spec(laid_out, recursive=True)
|
|
193
|
+
return laid_out
|
|
194
|
+
|
|
195
|
+
def before_submit_pipeline_spec(
|
|
196
|
+
self,
|
|
197
|
+
pipeline_spec: dict[str, Any],
|
|
198
|
+
*,
|
|
199
|
+
pipeline_path: str | Path,
|
|
200
|
+
effective_path: str | Path | None,
|
|
201
|
+
run_args: dict[str, Any] | None,
|
|
202
|
+
) -> dict[str, Any]:
|
|
203
|
+
"""Final pre-submit transform after validation/layout."""
|
|
204
|
+
|
|
205
|
+
del pipeline_path, effective_path, run_args
|
|
206
|
+
return pipeline_spec
|
|
207
|
+
|
|
208
|
+
def metadata_for_run(
|
|
209
|
+
self,
|
|
210
|
+
*,
|
|
211
|
+
pipeline_name: str,
|
|
212
|
+
pipeline_path: str | Path,
|
|
213
|
+
effective_path: str | Path | None,
|
|
214
|
+
wait: bool,
|
|
215
|
+
open_browser: bool,
|
|
216
|
+
include_next_steps: bool,
|
|
217
|
+
retry: int,
|
|
218
|
+
max_wait: float | None,
|
|
219
|
+
poll_interval: float,
|
|
220
|
+
extra_metadata: dict[str, Any] | None = None,
|
|
221
|
+
) -> dict[str, Any]:
|
|
222
|
+
"""Build metadata passed to submit/wait/retry lifecycle hooks."""
|
|
223
|
+
|
|
224
|
+
metadata: dict[str, Any] = {
|
|
225
|
+
"pipeline_name": pipeline_name,
|
|
226
|
+
"pipeline_path": str(pipeline_path),
|
|
227
|
+
"wait": wait,
|
|
228
|
+
"open_browser": open_browser,
|
|
229
|
+
"include_next_steps": include_next_steps,
|
|
230
|
+
"retry": retry,
|
|
231
|
+
"max_attempts": retry + 1 if wait else 1,
|
|
232
|
+
"poll_interval": poll_interval,
|
|
233
|
+
"max_wait_time": max_wait,
|
|
234
|
+
}
|
|
235
|
+
if effective_path is not None:
|
|
236
|
+
metadata["effective_path"] = str(effective_path)
|
|
237
|
+
if extra_metadata:
|
|
238
|
+
metadata.update(extra_metadata)
|
|
239
|
+
return metadata
|
|
240
|
+
|
|
241
|
+
def cleanup_prepared_pipeline(
|
|
242
|
+
self,
|
|
243
|
+
preparation: PipelinePreparationResult,
|
|
244
|
+
*,
|
|
245
|
+
error: Exception | None = None,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Clean up resources associated with a prepared pipeline.
|
|
248
|
+
|
|
249
|
+
Downstreams that hydrate into temporary files can override this to
|
|
250
|
+
remove ``preparation.effective_path`` on success, validation failure,
|
|
251
|
+
submit failure, wait failure, or retry failure.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
del preparation, error
|
|
255
|
+
|
|
256
|
+
def format_run_result(
|
|
257
|
+
self,
|
|
258
|
+
result: dict[str, Any],
|
|
259
|
+
*,
|
|
260
|
+
preparation: PipelinePreparationResult,
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
"""Return the normalized OSS orchestration result.
|
|
263
|
+
|
|
264
|
+
Downstreams can override this to preserve legacy CLI/MCP return shapes.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
context = result.get("context")
|
|
268
|
+
response = result.get("response") if isinstance(result.get("response"), Mapping) else {}
|
|
269
|
+
wait_result = result.get("wait") if isinstance(result.get("wait"), Mapping) else None
|
|
270
|
+
run_id = getattr(context, "run_id", None) if isinstance(context, PipelineRunContext) else response.get("id")
|
|
271
|
+
root_execution_id = (
|
|
272
|
+
getattr(context, "root_execution_id", None)
|
|
273
|
+
if isinstance(context, PipelineRunContext)
|
|
274
|
+
else response.get("root_execution_id")
|
|
275
|
+
)
|
|
276
|
+
status = "submitted"
|
|
277
|
+
success: bool | None = True
|
|
278
|
+
if wait_result is not None:
|
|
279
|
+
status = str(wait_result.get("status") or "unknown")
|
|
280
|
+
outcome = (
|
|
281
|
+
context.wait_outcome
|
|
282
|
+
if isinstance(context, PipelineRunContext) and context.wait_outcome is not None
|
|
283
|
+
else PipelineWaitOutcome.from_wait_result(wait_result)
|
|
284
|
+
)
|
|
285
|
+
success = outcome.success
|
|
286
|
+
result_pipeline_name = (
|
|
287
|
+
str(context.run_name)
|
|
288
|
+
if isinstance(context, PipelineRunContext) and context.run_name
|
|
289
|
+
else preparation.pipeline_name
|
|
290
|
+
)
|
|
291
|
+
return {
|
|
292
|
+
**result,
|
|
293
|
+
"success": success,
|
|
294
|
+
"status": status,
|
|
295
|
+
"pipeline_name": result_pipeline_name,
|
|
296
|
+
"run_id": run_id,
|
|
297
|
+
"root_execution_id": root_execution_id,
|
|
298
|
+
"preparation": preparation,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass
|
|
303
|
+
class PipelineRunner(PipelineRunnerHooks, PipelineRunManager):
|
|
304
|
+
"""Generic high-level pipeline runner orchestration."""
|
|
305
|
+
|
|
306
|
+
hooks: PipelineRunnerHooks = field(default_factory=PipelineRunnerHooks)
|
|
307
|
+
|
|
308
|
+
def __post_init__(self) -> None:
|
|
309
|
+
super().__post_init__()
|
|
310
|
+
if self.hooks is not self:
|
|
311
|
+
setattr(self.hooks, "client", self.client)
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _ensure_mapping(value: Any) -> dict[str, Any]:
|
|
315
|
+
if not isinstance(value, dict):
|
|
316
|
+
raise PipelineRunError("pipeline spec must be a mapping")
|
|
317
|
+
return value
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _accepts_client_keyword(method: Any) -> bool:
|
|
321
|
+
try:
|
|
322
|
+
parameters = inspect.signature(method).parameters
|
|
323
|
+
except (TypeError, ValueError):
|
|
324
|
+
return False
|
|
325
|
+
return "client" in parameters or any(
|
|
326
|
+
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
327
|
+
for parameter in parameters.values()
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def _high_level_hooks(self) -> PipelineRunnerHooks:
|
|
331
|
+
"""Return the object that owns high-level path/spec hooks.
|
|
332
|
+
|
|
333
|
+
Subclasses override methods on ``self``. For direct OSS composition,
|
|
334
|
+
preserve the existing ``PipelineRunner(client, hooks=...)`` API.
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
if type(self) is PipelineRunner and self.hooks is not self:
|
|
338
|
+
return self.hooks
|
|
339
|
+
return self
|
|
340
|
+
|
|
341
|
+
def prepare_pipeline_for_run(
|
|
342
|
+
self,
|
|
343
|
+
pipeline_path: str | Path,
|
|
344
|
+
*,
|
|
345
|
+
run_args: dict[str, Any] | None = None,
|
|
346
|
+
hydrate: bool = True,
|
|
347
|
+
resolution_overrides: dict[str, Any] | None = None,
|
|
348
|
+
skip_validation: bool = False,
|
|
349
|
+
skip_layout: bool = True,
|
|
350
|
+
force_layout: bool = False,
|
|
351
|
+
layout_algorithm: str | None = None,
|
|
352
|
+
) -> PipelinePreparationResult:
|
|
353
|
+
"""Load/hydrate/validate/layout a pipeline before submission."""
|
|
354
|
+
|
|
355
|
+
hooks = self._high_level_hooks()
|
|
356
|
+
pipeline_name = hooks.initial_pipeline_name(pipeline_path)
|
|
357
|
+
effective_path: str | Path | None = pipeline_path
|
|
358
|
+
pipeline_spec: Any = {}
|
|
359
|
+
preparation: PipelinePreparationResult | None = None
|
|
360
|
+
try:
|
|
361
|
+
if hydrate:
|
|
362
|
+
hydrate_pipeline_for_run = hooks.hydrate_pipeline_for_run
|
|
363
|
+
hydrate_kwargs: dict[str, Any] = {"resolution_overrides": resolution_overrides}
|
|
364
|
+
if self._accepts_client_keyword(hydrate_pipeline_for_run):
|
|
365
|
+
hydrate_kwargs["client"] = self._get_client()
|
|
366
|
+
pipeline_spec, hydrated_effective_path = hydrate_pipeline_for_run(
|
|
367
|
+
pipeline_path,
|
|
368
|
+
**hydrate_kwargs,
|
|
369
|
+
)
|
|
370
|
+
if hydrated_effective_path is not None:
|
|
371
|
+
effective_path = hydrated_effective_path
|
|
372
|
+
else:
|
|
373
|
+
pipeline_spec = hooks.load_pipeline(pipeline_path)
|
|
374
|
+
|
|
375
|
+
pipeline_spec = self._ensure_mapping(pipeline_spec)
|
|
376
|
+
spec_name = pipeline_spec.get("name")
|
|
377
|
+
if isinstance(spec_name, str) and spec_name:
|
|
378
|
+
pipeline_name = spec_name
|
|
379
|
+
|
|
380
|
+
pipeline_spec = hooks.prepare_loaded_pipeline_spec(
|
|
381
|
+
pipeline_spec,
|
|
382
|
+
pipeline_path=pipeline_path,
|
|
383
|
+
effective_path=effective_path,
|
|
384
|
+
hydrate=hydrate,
|
|
385
|
+
run_args=run_args,
|
|
386
|
+
)
|
|
387
|
+
pipeline_spec = self._ensure_mapping(pipeline_spec)
|
|
388
|
+
spec_name = pipeline_spec.get("name")
|
|
389
|
+
if isinstance(spec_name, str) and spec_name:
|
|
390
|
+
pipeline_name = spec_name
|
|
391
|
+
|
|
392
|
+
if hooks.should_apply_layout(
|
|
393
|
+
pipeline_spec,
|
|
394
|
+
pipeline_path=pipeline_path,
|
|
395
|
+
effective_path=effective_path,
|
|
396
|
+
skip_layout=skip_layout,
|
|
397
|
+
force_layout=force_layout,
|
|
398
|
+
layout_algorithm=layout_algorithm,
|
|
399
|
+
):
|
|
400
|
+
pipeline_spec = hooks.apply_layout(
|
|
401
|
+
pipeline_spec,
|
|
402
|
+
pipeline_path=pipeline_path,
|
|
403
|
+
effective_path=effective_path,
|
|
404
|
+
force_layout=force_layout,
|
|
405
|
+
layout_algorithm=layout_algorithm,
|
|
406
|
+
)
|
|
407
|
+
pipeline_spec = self._ensure_mapping(pipeline_spec)
|
|
408
|
+
spec_name = pipeline_spec.get("name")
|
|
409
|
+
if isinstance(spec_name, str) and spec_name:
|
|
410
|
+
pipeline_name = spec_name
|
|
411
|
+
|
|
412
|
+
validation_errors = hooks.validate_pipeline_for_run(
|
|
413
|
+
pipeline_spec,
|
|
414
|
+
pipeline_path=pipeline_path,
|
|
415
|
+
effective_path=effective_path,
|
|
416
|
+
skip_validation=skip_validation,
|
|
417
|
+
)
|
|
418
|
+
if validation_errors and not skip_validation:
|
|
419
|
+
raise PipelineRunError("Pipeline validation failed:\n - " + "\n - ".join(validation_errors))
|
|
420
|
+
|
|
421
|
+
pipeline_spec = hooks.before_submit_pipeline_spec(
|
|
422
|
+
pipeline_spec,
|
|
423
|
+
pipeline_path=pipeline_path,
|
|
424
|
+
effective_path=effective_path,
|
|
425
|
+
run_args=run_args,
|
|
426
|
+
)
|
|
427
|
+
pipeline_spec = self._ensure_mapping(pipeline_spec)
|
|
428
|
+
spec_name = pipeline_spec.get("name")
|
|
429
|
+
if isinstance(spec_name, str) and spec_name:
|
|
430
|
+
pipeline_name = spec_name
|
|
431
|
+
|
|
432
|
+
preparation = PipelinePreparationResult(
|
|
433
|
+
pipeline_spec=pipeline_spec,
|
|
434
|
+
pipeline_name=pipeline_name,
|
|
435
|
+
effective_path=effective_path,
|
|
436
|
+
)
|
|
437
|
+
return preparation
|
|
438
|
+
except Exception as exc:
|
|
439
|
+
cleanup_spec = pipeline_spec if isinstance(pipeline_spec, dict) else {}
|
|
440
|
+
hooks.cleanup_prepared_pipeline(
|
|
441
|
+
preparation
|
|
442
|
+
or PipelinePreparationResult(
|
|
443
|
+
pipeline_spec=cleanup_spec,
|
|
444
|
+
pipeline_name=pipeline_name,
|
|
445
|
+
effective_path=effective_path,
|
|
446
|
+
),
|
|
447
|
+
error=exc,
|
|
448
|
+
)
|
|
449
|
+
raise
|
|
450
|
+
|
|
451
|
+
def submit_pipeline_spec_result(
|
|
452
|
+
self,
|
|
453
|
+
pipeline_name: str,
|
|
454
|
+
pipeline_spec: dict[str, Any],
|
|
455
|
+
*,
|
|
456
|
+
run_args: dict[str, Any] | None = None,
|
|
457
|
+
annotations: dict[str, str] | None = None,
|
|
458
|
+
run_as: str | None = None,
|
|
459
|
+
pipeline_path: str | Path | None = None,
|
|
460
|
+
) -> dict[str, Any]:
|
|
461
|
+
"""Submit an already prepared spec and return a normalized summary."""
|
|
462
|
+
|
|
463
|
+
submit_payload = self.prepare_submit_payload_from_spec(
|
|
464
|
+
copy.deepcopy(pipeline_spec),
|
|
465
|
+
run_args=run_args,
|
|
466
|
+
annotations=annotations,
|
|
467
|
+
pipeline_path=pipeline_path,
|
|
468
|
+
run_as=run_as,
|
|
469
|
+
hydrate=False,
|
|
470
|
+
)
|
|
471
|
+
response = self.submit_prepared_payload(submit_payload, pipeline_path=pipeline_path)
|
|
472
|
+
run_id = str(response.get("id")) if response.get("id") is not None else None
|
|
473
|
+
root_execution_id = (
|
|
474
|
+
str(response.get("root_execution_id")) if response.get("root_execution_id") is not None else None
|
|
475
|
+
)
|
|
476
|
+
return {
|
|
477
|
+
"success": True,
|
|
478
|
+
"status": "submitted",
|
|
479
|
+
"pipeline_name": submit_payload.run_name or pipeline_name,
|
|
480
|
+
"run_id": run_id,
|
|
481
|
+
"root_execution_id": root_execution_id,
|
|
482
|
+
"response": response,
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
def run_pipeline(
|
|
486
|
+
self,
|
|
487
|
+
pipeline_path: str | Path,
|
|
488
|
+
*,
|
|
489
|
+
run_args: dict[str, Any] | None = None,
|
|
490
|
+
annotations: dict[str, str] | None = None,
|
|
491
|
+
hydrate: bool = True,
|
|
492
|
+
run_as: str | None = None,
|
|
493
|
+
resolution_overrides: dict[str, Any] | None = None,
|
|
494
|
+
wait: bool = False,
|
|
495
|
+
max_wait: float | None = 600.0,
|
|
496
|
+
poll_interval: float = 10.0,
|
|
497
|
+
use_graph_state: bool = False,
|
|
498
|
+
retry: int = 0,
|
|
499
|
+
max_attempts: int | None = None,
|
|
500
|
+
allow_zero_poll_interval: bool = False,
|
|
501
|
+
timeout_clock: str = "monotonic",
|
|
502
|
+
exit_on_first_failure: bool = False,
|
|
503
|
+
skip_validation: bool = False,
|
|
504
|
+
skip_layout: bool = True,
|
|
505
|
+
force_layout: bool = False,
|
|
506
|
+
layout_algorithm: str | None = None,
|
|
507
|
+
open_browser: bool = False,
|
|
508
|
+
include_next_steps: bool = False,
|
|
509
|
+
metadata: dict[str, Any] | None = None,
|
|
510
|
+
) -> dict[str, Any]:
|
|
511
|
+
"""Run a pipeline path through generic preparation + lifecycle hooks.
|
|
512
|
+
|
|
513
|
+
Path-based runs prepare inside the retry body factory so every retry
|
|
514
|
+
re-runs load/hydrate/validation/layout/pre-submit hooks.
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
attempts = max_attempts if max_attempts is not None else (retry + 1 if wait else 1)
|
|
518
|
+
hooks = self._high_level_hooks()
|
|
519
|
+
preparations: dict[int, PipelinePreparationResult] = {}
|
|
520
|
+
submit_payloads: dict[int, PipelineSubmitPayload] = {}
|
|
521
|
+
|
|
522
|
+
def prepare_attempt(attempt: int) -> PipelinePreparationResult:
|
|
523
|
+
preparation = self.prepare_pipeline_for_run(
|
|
524
|
+
pipeline_path,
|
|
525
|
+
run_args=run_args,
|
|
526
|
+
hydrate=hydrate,
|
|
527
|
+
resolution_overrides=resolution_overrides,
|
|
528
|
+
skip_validation=skip_validation,
|
|
529
|
+
skip_layout=skip_layout,
|
|
530
|
+
force_layout=force_layout,
|
|
531
|
+
layout_algorithm=layout_algorithm,
|
|
532
|
+
)
|
|
533
|
+
preparations[attempt] = preparation
|
|
534
|
+
return preparation
|
|
535
|
+
|
|
536
|
+
def body_factory(
|
|
537
|
+
attempt: int,
|
|
538
|
+
_previous_context: PipelineRunContext | None,
|
|
539
|
+
_error: Exception | None,
|
|
540
|
+
) -> dict[str, Any]:
|
|
541
|
+
preparation = prepare_attempt(attempt)
|
|
542
|
+
submit_payload = self.prepare_submit_payload_from_spec(
|
|
543
|
+
copy.deepcopy(preparation.pipeline_spec),
|
|
544
|
+
run_args=run_args,
|
|
545
|
+
annotations=annotations,
|
|
546
|
+
pipeline_path=pipeline_path,
|
|
547
|
+
run_as=run_as,
|
|
548
|
+
hydrate=False,
|
|
549
|
+
)
|
|
550
|
+
submit_payloads[attempt] = submit_payload
|
|
551
|
+
return submit_payload.to_body()
|
|
552
|
+
|
|
553
|
+
def metadata_factory(
|
|
554
|
+
attempt: int,
|
|
555
|
+
previous_context: PipelineRunContext | None,
|
|
556
|
+
_error: Exception | None,
|
|
557
|
+
) -> dict[str, Any]:
|
|
558
|
+
preparation = preparations.get(attempt)
|
|
559
|
+
submit_payload = submit_payloads.get(attempt)
|
|
560
|
+
if (
|
|
561
|
+
preparation is None
|
|
562
|
+
and previous_context is not None
|
|
563
|
+
and previous_context.run_id is None
|
|
564
|
+
and previous_context.submit_body is not None
|
|
565
|
+
):
|
|
566
|
+
# ``PipelineRunManager`` reuses the previous submit body after
|
|
567
|
+
# submit-time exceptions. Mirror the previous preparation
|
|
568
|
+
# bookkeeping so metadata/result formatting still point at the
|
|
569
|
+
# logical pipeline being retried without re-running dynamic
|
|
570
|
+
# body preparation hooks.
|
|
571
|
+
preparation = preparations.get(previous_context.attempt)
|
|
572
|
+
if preparation is not None:
|
|
573
|
+
preparations[attempt] = preparation
|
|
574
|
+
submit_payload = submit_payloads.get(previous_context.attempt)
|
|
575
|
+
if submit_payload is not None:
|
|
576
|
+
submit_payloads[attempt] = submit_payload
|
|
577
|
+
if preparation is None:
|
|
578
|
+
raise PipelineRunError("Pipeline retry metadata requested before preparation")
|
|
579
|
+
return hooks.metadata_for_run(
|
|
580
|
+
pipeline_name=(submit_payload.run_name if submit_payload else None) or preparation.pipeline_name,
|
|
581
|
+
pipeline_path=pipeline_path,
|
|
582
|
+
effective_path=preparation.effective_path,
|
|
583
|
+
wait=wait,
|
|
584
|
+
open_browser=open_browser,
|
|
585
|
+
include_next_steps=include_next_steps,
|
|
586
|
+
retry=retry,
|
|
587
|
+
max_wait=max_wait,
|
|
588
|
+
poll_interval=poll_interval,
|
|
589
|
+
extra_metadata=metadata,
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
error: Exception | None = None
|
|
593
|
+
try:
|
|
594
|
+
result = self._run_body_factory(
|
|
595
|
+
body_factory,
|
|
596
|
+
pipeline_path=pipeline_path,
|
|
597
|
+
wait=wait,
|
|
598
|
+
max_wait=max_wait,
|
|
599
|
+
poll_interval=poll_interval,
|
|
600
|
+
use_graph_state=use_graph_state,
|
|
601
|
+
max_attempts=attempts,
|
|
602
|
+
allow_zero_poll_interval=allow_zero_poll_interval,
|
|
603
|
+
timeout_clock=timeout_clock,
|
|
604
|
+
exit_on_first_failure=exit_on_first_failure,
|
|
605
|
+
metadata_factory=metadata_factory,
|
|
606
|
+
)
|
|
607
|
+
context = result.get("context")
|
|
608
|
+
attempt = context.attempt if isinstance(context, PipelineRunContext) else max(preparations)
|
|
609
|
+
return hooks.format_run_result(result, preparation=preparations[attempt])
|
|
610
|
+
except Exception as exc:
|
|
611
|
+
error = exc
|
|
612
|
+
raise
|
|
613
|
+
finally:
|
|
614
|
+
cleaned_preparation_ids: set[int] = set()
|
|
615
|
+
for preparation in preparations.values():
|
|
616
|
+
preparation_id = id(preparation)
|
|
617
|
+
if preparation_id in cleaned_preparation_ids:
|
|
618
|
+
continue
|
|
619
|
+
cleaned_preparation_ids.add(preparation_id)
|
|
620
|
+
hooks.cleanup_prepared_pipeline(preparation, error=error)
|