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
tangle_cli/client.py ADDED
@@ -0,0 +1,677 @@
1
+ """Static public Tangle API client.
2
+
3
+ ``TangleApiClient`` is the stable wrapper class consumed by downstream tools.
4
+ Endpoint methods are generated offline into :mod:`tangle_api.generated.operations`
5
+ from the checked-in OpenAPI snapshot; handwritten methods in this file keep the
6
+ higher-level semantic helpers that downstream callers use.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from collections.abc import Iterable, Mapping
13
+ from dataclasses import asdict, is_dataclass
14
+ from email.utils import parsedate_to_datetime
15
+ from typing import Any
16
+ from urllib.parse import quote, urljoin, urlparse
17
+
18
+ import requests
19
+
20
+ from .api_transport import (
21
+ DEFAULT_TIMEOUT_SECONDS,
22
+ _join_operation_url,
23
+ _normalize_base_url,
24
+ _request_headers,
25
+ default_base_url,
26
+ log_http_exchange,
27
+ tangle_verbose_enabled,
28
+ )
29
+ from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse
30
+ from tangle_api.generated.operations import GeneratedTangleApiOperations
31
+ from .logger import Logger, _null_logger, get_default_logger
32
+ from .models import (
33
+ ComponentInfo,
34
+ GraphExecutionState,
35
+ PipelineRun,
36
+ RunDetails,
37
+ TaskSpec,
38
+ )
39
+
40
+
41
+ class TangleApiClient(GeneratedTangleApiOperations):
42
+ """Single public API wrapper for Tangle backends.
43
+
44
+ The constructor keeps the historical ``tangle-deploy`` shape while also
45
+ accepting the auth/header knobs used by the dynamic-discovery client. No
46
+ OpenAPI schema is loaded at runtime; all endpoint wrappers are checked in.
47
+ """
48
+
49
+ _REDIRECT_STATUSES = {301, 302, 303, 307, 308}
50
+ _MAX_REDIRECTS = 5
51
+ _MAX_RATE_LIMIT_RETRIES = 3
52
+ _RATE_LIMIT_BACKOFF_SECONDS = 1.0
53
+ _MAX_RETRY_AFTER_SECONDS = 60.0
54
+
55
+ def __init__(
56
+ self,
57
+ base_url: str | None = None,
58
+ *,
59
+ logger: Logger | None = None,
60
+ verbose: bool = False,
61
+ headers: Mapping[str, str] | None = None,
62
+ token: str | None = None,
63
+ auth_header: str | None = None,
64
+ header: list[str] | str | None = None,
65
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
66
+ session: requests.Session | None = None,
67
+ include_env_credentials: bool = True,
68
+ ) -> None:
69
+ self.base_url = _normalize_base_url(base_url or default_base_url())
70
+ env_verbose = tangle_verbose_enabled()
71
+ self.verbose = verbose or env_verbose
72
+ self.logger = logger or (get_default_logger() if self.verbose else _null_logger)
73
+ self.headers = dict(headers or {})
74
+ self.token = token
75
+ self.auth_header = auth_header
76
+ self.header = header
77
+ self.timeout = timeout
78
+ self.session = session or requests.Session()
79
+ self.include_env_credentials = include_env_credentials
80
+
81
+ def set_verbose(self, enabled: bool) -> None:
82
+ """Enable or disable request logging."""
83
+
84
+ self.verbose = enabled
85
+
86
+ def _refresh_auth(self) -> None:
87
+ """Hook for subclasses to refresh auth before/retry after a request.
88
+
89
+ Subclasses commonly mutate ``self.headers`` or session state here. The
90
+ base implementation intentionally does nothing.
91
+ """
92
+
93
+ def _make_request(
94
+ self,
95
+ method: str,
96
+ path: str,
97
+ params: Mapping[str, Any] | None = None,
98
+ json_data: Any = None,
99
+ **kwargs: Any,
100
+ ) -> requests.Response:
101
+ """Issue an HTTP request and return the raw ``requests.Response``.
102
+
103
+ This method preserves the subclass extension point used by
104
+ ``tangle-deploy``: auth can be refreshed by overriding
105
+ :meth:`_refresh_auth`, and callers that need streaming can pass standard
106
+ ``requests`` keyword arguments such as ``stream=True``.
107
+ """
108
+
109
+ if "json" in kwargs and json_data is None:
110
+ json_data = kwargs.pop("json")
111
+ timeout = kwargs.pop("timeout", self.timeout)
112
+ extra_headers = kwargs.pop("headers", None)
113
+ url = self._url(path)
114
+ clean_params = self._clean_mapping(params)
115
+ request_method = method.upper()
116
+
117
+ self._refresh_auth()
118
+ response = self._request_with_rate_limit_retries(
119
+ request_method,
120
+ url,
121
+ params=clean_params,
122
+ json_data=json_data,
123
+ extra_headers=extra_headers,
124
+ timeout=timeout,
125
+ request_kwargs=kwargs,
126
+ )
127
+ if response.status_code == 401:
128
+ self._refresh_auth()
129
+ response = self._request_with_rate_limit_retries(
130
+ request_method,
131
+ url,
132
+ params=clean_params,
133
+ json_data=json_data,
134
+ extra_headers=extra_headers,
135
+ timeout=timeout,
136
+ request_kwargs=kwargs,
137
+ )
138
+ return response
139
+
140
+ def _request_with_rate_limit_retries(
141
+ self,
142
+ method: str,
143
+ url: str,
144
+ *,
145
+ params: Mapping[str, Any] | None,
146
+ json_data: Any,
147
+ extra_headers: Mapping[str, str] | None,
148
+ timeout: float,
149
+ request_kwargs: Mapping[str, Any],
150
+ ) -> requests.Response:
151
+ response: requests.Response | None = None
152
+ for attempt in range(self._MAX_RATE_LIMIT_RETRIES + 1):
153
+ response = self._request_with_same_origin_redirects(
154
+ method,
155
+ url,
156
+ params=params,
157
+ json_data=json_data,
158
+ extra_headers=extra_headers,
159
+ timeout=timeout,
160
+ request_kwargs=request_kwargs,
161
+ )
162
+ if response.status_code != 429 or attempt == self._MAX_RATE_LIMIT_RETRIES:
163
+ return response
164
+ self._sleep_for_rate_limit(response, attempt)
165
+ return response
166
+
167
+ def _sleep_for_rate_limit(self, response: requests.Response, attempt: int) -> None:
168
+ retry_after = response.headers.get("Retry-After")
169
+ delay = self._retry_after_delay(retry_after)
170
+ if delay is None:
171
+ delay = self._RATE_LIMIT_BACKOFF_SECONDS * (2 ** attempt)
172
+ delay = min(delay, self._MAX_RETRY_AFTER_SECONDS)
173
+ if self.verbose:
174
+ self.logger.info(f"429 rate limited; retrying in {delay:.1f}s")
175
+ time.sleep(delay)
176
+
177
+ @staticmethod
178
+ def _retry_after_delay(value: str | None) -> float | None:
179
+ if not value:
180
+ return None
181
+ try:
182
+ return max(0.0, float(value))
183
+ except ValueError:
184
+ pass
185
+ try:
186
+ retry_at = parsedate_to_datetime(value)
187
+ except (TypeError, ValueError):
188
+ return None
189
+ if retry_at.tzinfo is None:
190
+ return None
191
+ return max(0.0, retry_at.timestamp() - time.time())
192
+
193
+ def _request_with_same_origin_redirects(
194
+ self,
195
+ method: str,
196
+ url: str,
197
+ *,
198
+ params: Mapping[str, Any] | None,
199
+ json_data: Any,
200
+ extra_headers: Mapping[str, str] | None,
201
+ timeout: float,
202
+ request_kwargs: Mapping[str, Any],
203
+ ) -> requests.Response:
204
+ """Send one request, following only same-origin redirects.
205
+
206
+ The client may carry custom auth headers/cookies in ``session.headers``.
207
+ ``requests`` does not strip those custom credentials on cross-origin
208
+ redirects, so redirects are handled manually and constrained to the
209
+ original origin.
210
+ """
211
+
212
+ current_method = method
213
+ current_url = url
214
+ current_params = params
215
+ current_json = json_data
216
+ response: requests.Response | None = None
217
+
218
+ for _ in range(self._MAX_REDIRECTS + 1):
219
+ request_headers = self._headers(extra_headers)
220
+ response = self.session.request(
221
+ current_method,
222
+ current_url,
223
+ params=current_params,
224
+ json=current_json,
225
+ headers=request_headers,
226
+ timeout=timeout,
227
+ allow_redirects=False,
228
+ **request_kwargs,
229
+ )
230
+ if self.verbose:
231
+ log_http_exchange(
232
+ self.logger,
233
+ method=current_method,
234
+ url=current_url,
235
+ request_headers=request_headers,
236
+ request_body=current_json,
237
+ response_status=response.status_code,
238
+ response_headers=dict(response.headers),
239
+ response_body=response.text,
240
+ )
241
+ if response.status_code not in self._REDIRECT_STATUSES:
242
+ return response
243
+
244
+ location = response.headers.get("Location")
245
+ if not location:
246
+ return response
247
+
248
+ next_url = urljoin(response.url, location)
249
+ if not self._same_origin(response.url, next_url):
250
+ raise requests.HTTPError(
251
+ f"Refusing to follow cross-origin redirect from {response.url} to {next_url}",
252
+ response=response,
253
+ )
254
+
255
+ try:
256
+ response.close()
257
+ except Exception:
258
+ pass
259
+ if response.status_code == 303 or (
260
+ response.status_code in {301, 302} and current_method not in {"GET", "HEAD"}
261
+ ):
262
+ current_method = "GET"
263
+ current_json = None
264
+ current_url = next_url
265
+ current_params = None
266
+
267
+ raise requests.TooManyRedirects(
268
+ f"Exceeded {self._MAX_REDIRECTS} redirects for {url}",
269
+ response=response,
270
+ )
271
+
272
+ @staticmethod
273
+ def _same_origin(left: str, right: str) -> bool:
274
+ left_parts = urlparse(left)
275
+ right_parts = urlparse(right)
276
+ return (
277
+ left_parts.scheme.lower() == right_parts.scheme.lower()
278
+ and left_parts.netloc.lower() == right_parts.netloc.lower()
279
+ )
280
+
281
+ def _request_json(
282
+ self,
283
+ method: str,
284
+ path: str,
285
+ *,
286
+ path_params: Mapping[str, Any] | None = None,
287
+ params: Mapping[str, Any] | None = None,
288
+ json_data: Any = None,
289
+ response_model: Any = None,
290
+ ) -> Any:
291
+ formatted_path = self._format_path(path, path_params)
292
+ response = self._make_request(method, formatted_path, params=params, json_data=json_data)
293
+ response.raise_for_status()
294
+ data = self._decode_response(response)
295
+ if response_model is not None and isinstance(data, dict):
296
+ return response_model.from_dict(data)
297
+ if response_model is not None and isinstance(data, list):
298
+ return [
299
+ response_model.from_dict(item) if isinstance(item, dict) else item
300
+ for item in data
301
+ ]
302
+ return data
303
+
304
+ def _headers(self, extra_headers: Mapping[str, str] | None = None) -> dict[str, str]:
305
+ headers = dict(self.headers)
306
+ if extra_headers:
307
+ headers.update({name: str(value) for name, value in extra_headers.items()})
308
+ return _request_headers(
309
+ self.token,
310
+ self.header,
311
+ self.auth_header,
312
+ headers,
313
+ include_env_credentials=self.include_env_credentials,
314
+ )
315
+
316
+ def _url(self, path: str) -> str:
317
+ return _join_operation_url(self.base_url, path)
318
+
319
+ @staticmethod
320
+ def _format_path(path: str, path_params: Mapping[str, Any] | None = None) -> str:
321
+ if not path_params:
322
+ return path
323
+ for name, value in path_params.items():
324
+ path = path.replace("{" + name + "}", quote(str(value), safe=""))
325
+ return path
326
+
327
+ @staticmethod
328
+ def _clean_mapping(values: Mapping[str, Any] | None) -> dict[str, Any] | None:
329
+ if not values:
330
+ return None
331
+ cleaned = {key: value for key, value in values.items() if value is not None}
332
+ return cleaned or None
333
+
334
+ @staticmethod
335
+ def _decode_response(response: requests.Response) -> Any:
336
+ if response.status_code == 204 or not response.content:
337
+ return None
338
+ content_type = response.headers.get("Content-Type", "")
339
+ if "json" in content_type.lower():
340
+ return response.json()
341
+ try:
342
+ return response.json()
343
+ except ValueError:
344
+ return response.text
345
+
346
+ # ---- Handwritten semantic helpers consumed by tangle-deploy ----------
347
+
348
+ def get_execution_details(self, execution_id: str) -> GetExecutionInfoResponse:
349
+ details = self.executions_details(execution_id)
350
+ self._enrich_execution_tree(details)
351
+ return details
352
+
353
+ def stream_execution_container_log(self, execution_id: str) -> requests.Response:
354
+ response = self._make_request(
355
+ "GET",
356
+ self._format_path(
357
+ "/api/executions/{id}/stream_container_log",
358
+ {"id": execution_id},
359
+ ),
360
+ stream=True,
361
+ )
362
+ response.raise_for_status()
363
+ return response
364
+
365
+ def get_component_spec(self, digest: str) -> ComponentSpec:
366
+ """Return a parsed domain component spec from the generated component endpoint."""
367
+
368
+ return ComponentSpec.from_dict(_to_plain(self.components_get(digest)))
369
+
370
+ def resolve_digest(self, digest: str) -> str:
371
+ """Resolve a component digest/name, following deprecation successors."""
372
+
373
+ current = digest
374
+ seen: set[str] = set()
375
+
376
+ while current not in seen:
377
+ seen.add(current)
378
+ matches = self._published_component_rows(include_deprecated=True, digest=current)
379
+ if not matches:
380
+ matches = self._published_component_rows(
381
+ include_deprecated=True,
382
+ name_substring=current,
383
+ )
384
+ if len(matches) != 1:
385
+ return current
386
+
387
+ component = matches[0]
388
+ resolved = str(component.get("digest") or current)
389
+ successor = component.get("superseded_by")
390
+ if component.get("deprecated") and successor:
391
+ current = str(successor)
392
+ continue
393
+ return resolved
394
+
395
+ return current
396
+
397
+ def _published_component_rows(
398
+ self,
399
+ include_deprecated: bool = False,
400
+ name_substring: str | None = None,
401
+ published_by_substring: str | None = None,
402
+ digest: str | None = None,
403
+ ) -> list[dict[str, Any]]:
404
+ data = _to_plain(
405
+ self.published_components_list(
406
+ include_deprecated=include_deprecated,
407
+ name_substring=name_substring,
408
+ published_by_substring=published_by_substring,
409
+ digest=digest,
410
+ )
411
+ )
412
+ if isinstance(data, dict):
413
+ return list(data.get("published_components") or [])
414
+ return list(data or [])
415
+
416
+ def list_published_component_infos(
417
+ self,
418
+ include_deprecated: bool = False,
419
+ name_substring: str | None = None,
420
+ published_by_substring: str | None = None,
421
+ digest: str | None = None,
422
+ *,
423
+ fetch_specs: bool = False,
424
+ ) -> list[ComponentInfo]:
425
+ infos = [
426
+ ComponentInfo.from_dict(component)
427
+ for component in self._published_component_rows(
428
+ include_deprecated=include_deprecated,
429
+ name_substring=name_substring,
430
+ published_by_substring=published_by_substring,
431
+ digest=digest,
432
+ )
433
+ ]
434
+ if fetch_specs:
435
+ for info in infos:
436
+ if not info.digest:
437
+ continue
438
+ try:
439
+ info.component_spec = self.get_component_spec(info.digest)
440
+ except Exception as exc: # pragma: no cover - best-effort enrichment
441
+ info.spec_error = str(exc)
442
+ return infos
443
+
444
+ def find_existing_components(
445
+ self,
446
+ components: Iterable[ComponentSpec | Mapping[str, Any] | str] | None = None,
447
+ *,
448
+ names: Iterable[str] | None = None,
449
+ digests: Iterable[str] | None = None,
450
+ include_deprecated: bool = False,
451
+ published_by: str | None = None,
452
+ published_by_substring: str | None = None,
453
+ verbose: bool = False,
454
+ ) -> list[ComponentInfo]:
455
+ """Find published components matching component specs, names, or digests.
456
+
457
+ ``components`` may contain domain component specs, mapping-like component
458
+ references, or plain component names. Results are de-duplicated by digest
459
+ when available, falling back to name.
460
+ """
461
+
462
+ search_names = set(names or [])
463
+ search_digests = set(digests or [])
464
+ for component in components or []:
465
+ data = _to_plain(component)
466
+ if isinstance(component, str):
467
+ search_names.add(component)
468
+ elif isinstance(component, ComponentSpec):
469
+ search_names.update(name for name in component.search_names if name)
470
+ if component.digest:
471
+ search_digests.add(component.digest)
472
+ elif isinstance(data, Mapping):
473
+ if data.get("name"):
474
+ search_names.add(str(data["name"]))
475
+ if data.get("digest"):
476
+ search_digests.add(str(data["digest"]))
477
+
478
+ publisher_filter = published_by_substring or published_by
479
+ found: dict[str, ComponentInfo] = {}
480
+
481
+ def add(info: ComponentInfo) -> None:
482
+ key = info.digest or info.name
483
+ if not key:
484
+ return
485
+ found[key] = info
486
+ if verbose:
487
+ self.logger.info(f" Found existing component: {info.name} ({key[:16]}...)")
488
+
489
+ for digest in search_digests:
490
+ for info in self.list_published_component_infos(
491
+ include_deprecated=include_deprecated,
492
+ published_by_substring=publisher_filter,
493
+ digest=digest,
494
+ ):
495
+ add(info)
496
+ for name in search_names:
497
+ for info in self.list_published_component_infos(
498
+ include_deprecated=include_deprecated,
499
+ published_by_substring=publisher_filter,
500
+ name_substring=name,
501
+ ):
502
+ if info.name.lower() == name.lower():
503
+ add(info)
504
+ return list(found.values())
505
+
506
+ def get_run_details(
507
+ self,
508
+ run_id: str,
509
+ include_implementations: bool = False,
510
+ include_annotations: bool = False,
511
+ include_execution_state: bool = False,
512
+ execution_id: str | None = None,
513
+ ) -> RunDetails:
514
+ annotations_run_id: str | None = run_id
515
+ try:
516
+ run = PipelineRun.from_dict(_to_plain(self.pipeline_runs_get(run_id)))
517
+ root_execution_id = execution_id or run.root_execution_id
518
+ except requests.HTTPError as exc:
519
+ if exc.response is None or exc.response.status_code != 404 or execution_id is not None:
520
+ raise
521
+ root_execution_id = run_id
522
+ annotations_run_id = None
523
+ run = PipelineRun(
524
+ id=run_id,
525
+ root_execution_id=root_execution_id,
526
+ raw={"id": run_id, "root_execution_id": root_execution_id},
527
+ )
528
+
529
+ execution = self.get_execution_details(root_execution_id) if root_execution_id else None
530
+ if execution and not include_implementations:
531
+ self._strip_execution_raw_tasks_for_run_details(execution)
532
+ execution.strip_implementations()
533
+ raw_annotations = (
534
+ self.pipeline_runs_annotations(annotations_run_id)
535
+ if include_annotations and annotations_run_id
536
+ else None
537
+ )
538
+ annotations = raw_annotations if isinstance(raw_annotations, dict) else None
539
+ execution_state = (
540
+ GraphExecutionState.from_dict(
541
+ _to_plain(self.executions_graph_execution_state(root_execution_id))
542
+ )
543
+ if include_execution_state and root_execution_id
544
+ else None
545
+ )
546
+ return RunDetails(
547
+ run=run,
548
+ execution=execution,
549
+ annotations=annotations,
550
+ execution_state=execution_state,
551
+ )
552
+
553
+ def get_run_pipeline_spec(self, run_id: str) -> TaskSpec | None:
554
+ try:
555
+ run = self.pipeline_runs_get(run_id)
556
+ root_execution_id = getattr(run, "root_execution_id", None)
557
+ if root_execution_id is None and isinstance(run, dict):
558
+ root_execution_id = run.get("root_execution_id")
559
+ except requests.HTTPError as exc:
560
+ if exc.response is None or exc.response.status_code != 404:
561
+ raise
562
+ root_execution_id = run_id
563
+
564
+ if not root_execution_id:
565
+ return None
566
+ execution = self.executions_details(root_execution_id)
567
+ return execution.task_spec
568
+
569
+ def _enrich_execution_tree(self, execution: GetExecutionInfoResponse) -> None:
570
+ child_ids = execution.raw.get("child_task_execution_ids") or {}
571
+ if not isinstance(child_ids, dict):
572
+ return
573
+
574
+ raw_tasks = self._execution_graph_tasks(execution)
575
+ for task_name, child_execution_id in child_ids.items():
576
+ if not child_execution_id:
577
+ continue
578
+ child = self.executions_details(child_execution_id)
579
+ self._enrich_execution_tree(child)
580
+ execution.child_executions[task_name] = child
581
+
582
+ task = execution.task_spec.graph_tasks.get(task_name)
583
+ raw_task = raw_tasks.get(task_name) if isinstance(raw_tasks, dict) else None
584
+ if raw_task is None and task is not None:
585
+ raw_task = task.raw
586
+
587
+ context = {
588
+ "execution_id": child.id,
589
+ "input_artifacts": child.input_artifacts,
590
+ "output_artifacts": child.output_artifacts,
591
+ }
592
+ if child.raw.get("state") is not None:
593
+ context["state"] = child.raw["state"]
594
+
595
+ if task is not None:
596
+ task.raw.update(context)
597
+ if isinstance(raw_task, dict):
598
+ raw_task.update(context)
599
+ child_impl = (
600
+ child.task_spec.component_spec.implementation
601
+ if child.task_spec.component_spec
602
+ else None
603
+ )
604
+ raw_spec = raw_task.get("componentRef", {}).get("spec")
605
+ if isinstance(raw_spec, dict) and child_impl:
606
+ raw_spec["implementation"] = child_impl
607
+
608
+ @staticmethod
609
+ def _execution_graph_tasks(execution: GetExecutionInfoResponse) -> dict[str, Any]:
610
+ implementation = (
611
+ execution.task_spec.component_spec.implementation
612
+ if execution.task_spec.component_spec
613
+ else None
614
+ )
615
+ if not isinstance(implementation, dict):
616
+ return {}
617
+ graph = implementation.get("graph")
618
+ if not isinstance(graph, dict):
619
+ return {}
620
+ tasks = graph.get("tasks")
621
+ return tasks if isinstance(tasks, dict) else {}
622
+
623
+ def _strip_execution_raw_tasks_for_run_details(
624
+ self,
625
+ execution: GetExecutionInfoResponse,
626
+ ) -> None:
627
+ for raw_task in self._execution_graph_tasks(execution).values():
628
+ if isinstance(raw_task, dict):
629
+ self._strip_raw_task_for_run_details(raw_task)
630
+ for child in execution.child_executions.values():
631
+ self._strip_execution_raw_tasks_for_run_details(child)
632
+
633
+ def _strip_raw_task_for_run_details(self, task: dict[str, Any]) -> None:
634
+ component_ref = task.get("componentRef")
635
+ if not isinstance(component_ref, dict):
636
+ return
637
+ component_ref.pop("text", None)
638
+ spec = component_ref.get("spec")
639
+ if not isinstance(spec, dict):
640
+ return
641
+
642
+ annotations = spec.get("metadata", {}).get("annotations")
643
+ if isinstance(annotations, dict):
644
+ for key in ComponentSpec._STRIP_ANNOTATION_KEYS:
645
+ annotations.pop(key, None)
646
+
647
+ implementation = spec.get("implementation")
648
+ if not isinstance(implementation, dict):
649
+ return
650
+ graph = implementation.get("graph")
651
+ if isinstance(graph, dict) and isinstance(graph.get("tasks"), dict):
652
+ for child_task in graph["tasks"].values():
653
+ if isinstance(child_task, dict):
654
+ self._strip_raw_task_for_run_details(child_task)
655
+ else:
656
+ spec.pop("implementation", None)
657
+
658
+
659
+ def _to_plain(value: Any) -> Any:
660
+ if value is None:
661
+ return None
662
+ if hasattr(value, "to_dict") and callable(value.to_dict):
663
+ return value.to_dict()
664
+ if hasattr(value, "model_dump") and callable(value.model_dump):
665
+ return value.model_dump(by_alias=True)
666
+ if is_dataclass(value):
667
+ return asdict(value)
668
+ if isinstance(value, list):
669
+ return [_to_plain(item) for item in value]
670
+ if isinstance(value, tuple):
671
+ return tuple(_to_plain(item) for item in value)
672
+ if isinstance(value, dict):
673
+ return {key: _to_plain(item) for key, item in value.items()}
674
+ return value
675
+
676
+
677
+ __all__ = ["TangleApiClient"]