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,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)