tangle-cli 0.0.1a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tangle_cli/__init__.py +19 -0
- tangle_cli/api_cli.py +787 -0
- tangle_cli/api_schema.py +633 -0
- tangle_cli/api_transport.py +461 -0
- tangle_cli/args_container.py +244 -0
- tangle_cli/artifacts.py +293 -0
- tangle_cli/artifacts_cli.py +108 -0
- tangle_cli/cli.py +57 -0
- tangle_cli/cli_helpers.py +116 -0
- tangle_cli/cli_options.py +52 -0
- tangle_cli/client.py +677 -0
- tangle_cli/component_from_func.py +1856 -0
- tangle_cli/component_generator.py +298 -0
- tangle_cli/component_inspector.py +494 -0
- tangle_cli/component_publisher.py +921 -0
- tangle_cli/components_cli.py +269 -0
- tangle_cli/dynamic_discovery_client.py +296 -0
- tangle_cli/generated_model_extensions.py +405 -0
- tangle_cli/generated_runtime.py +43 -0
- tangle_cli/handler.py +96 -0
- tangle_cli/hydration_trust.py +222 -0
- tangle_cli/logger.py +166 -0
- tangle_cli/models.py +407 -0
- tangle_cli/module_bundler.py +662 -0
- tangle_cli/openapi/__init__.py +0 -0
- tangle_cli/openapi/codegen.py +1090 -0
- tangle_cli/openapi/parser.py +77 -0
- tangle_cli/pipeline_dehydrator.py +720 -0
- tangle_cli/pipeline_hydrator.py +1785 -0
- tangle_cli/pipeline_run_annotations.py +41 -0
- tangle_cli/pipeline_run_details.py +203 -0
- tangle_cli/pipeline_run_manager.py +1994 -0
- tangle_cli/pipeline_run_search.py +712 -0
- tangle_cli/pipeline_runner.py +620 -0
- tangle_cli/pipeline_runs_cli.py +584 -0
- tangle_cli/pipelines.py +581 -0
- tangle_cli/pipelines_cli.py +271 -0
- tangle_cli/published_components_cli.py +373 -0
- tangle_cli/py.typed +0 -0
- tangle_cli/quickstart.py +110 -0
- tangle_cli/secrets.py +156 -0
- tangle_cli/secrets_cli.py +269 -0
- tangle_cli/utils.py +942 -0
- tangle_cli/version_manager.py +470 -0
- tangle_cli-0.0.1a1.dist-info/METADATA +561 -0
- tangle_cli-0.0.1a1.dist-info/RECORD +48 -0
- tangle_cli-0.0.1a1.dist-info/WHEEL +4 -0
- tangle_cli-0.0.1a1.dist-info/entry_points.txt +3 -0
tangle_cli/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"]
|