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/models.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API-contract dataclasses for the Tangle Cloud Pipelines API.
|
|
3
|
+
|
|
4
|
+
These dataclasses model the shapes of HTTP request/response bodies on the
|
|
5
|
+
Tangle API — ``PipelineRun``, ``ComponentSpec``, container state, artifacts,
|
|
6
|
+
etc. They are used by wrapper packages and OpenAPI-backed client helpers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from tangle_api.generated.models import ComponentSpec, GetExecutionInfoResponse
|
|
15
|
+
|
|
16
|
+
from .artifacts import ArtifactComponentQuery, ArtifactInfo
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---- Execution / Run dataclasses -------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class GraphExecutionState:
|
|
24
|
+
"""Response from GET /api/executions/{id}/state.
|
|
25
|
+
|
|
26
|
+
Maps each child execution ID to a dict of status -> count.
|
|
27
|
+
Example::
|
|
28
|
+
|
|
29
|
+
GraphExecutionState(child_execution_status_stats={
|
|
30
|
+
"019c8b46508e751207fc": {"SUCCEEDED": 1},
|
|
31
|
+
"019c8b46508e76e607fd": {"RUNNING": 2, "SUCCEEDED": 3},
|
|
32
|
+
})
|
|
33
|
+
"""
|
|
34
|
+
child_execution_status_stats: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: dict[str, Any]) -> GraphExecutionState:
|
|
38
|
+
return cls(
|
|
39
|
+
child_execution_status_stats=data.get("child_execution_status_stats", {}),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def status_totals(self) -> dict[str, int]:
|
|
44
|
+
"""Aggregate counts across all child executions."""
|
|
45
|
+
totals: dict[str, int] = {}
|
|
46
|
+
for status_counts in self.child_execution_status_stats.values():
|
|
47
|
+
for status, count in status_counts.items():
|
|
48
|
+
totals[status] = totals.get(status, 0) + count
|
|
49
|
+
return totals
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def failed_execution_ids(self) -> list[str]:
|
|
53
|
+
"""Execution IDs that have at least one FAILED or SYSTEM_ERROR task."""
|
|
54
|
+
return [
|
|
55
|
+
exec_id
|
|
56
|
+
for exec_id, status_counts in self.child_execution_status_stats.items()
|
|
57
|
+
if status_counts.get("FAILED", 0) > 0
|
|
58
|
+
or status_counts.get("SYSTEM_ERROR", 0) > 0
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class PipelineRun:
|
|
64
|
+
"""Response from GET /api/pipeline_runs/{id}."""
|
|
65
|
+
id: str
|
|
66
|
+
root_execution_id: str | None
|
|
67
|
+
created_at: str | None = None
|
|
68
|
+
created_by: str | None = None
|
|
69
|
+
annotations: dict[str, str] | None = None
|
|
70
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_dict(cls, data: dict[str, Any]) -> PipelineRun:
|
|
74
|
+
return cls(
|
|
75
|
+
id=data["id"],
|
|
76
|
+
root_execution_id=data.get("root_execution_id"),
|
|
77
|
+
created_at=data.get("created_at"),
|
|
78
|
+
created_by=data.get("created_by"),
|
|
79
|
+
annotations=data.get("annotations"),
|
|
80
|
+
raw=data,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class TaskSpec:
|
|
86
|
+
"""A task within a pipeline execution graph.
|
|
87
|
+
|
|
88
|
+
Recursive: a graph task contains child TaskSpecs via ``graph_tasks``.
|
|
89
|
+
Leaf tasks have a container implementation instead.
|
|
90
|
+
"""
|
|
91
|
+
name: str | None = None
|
|
92
|
+
component_spec: ComponentSpec | None = None
|
|
93
|
+
arguments: dict[str, str] = field(default_factory=dict)
|
|
94
|
+
graph_tasks: dict[str, TaskSpec] = field(default_factory=dict)
|
|
95
|
+
annotations: dict[str, str] = field(default_factory=dict)
|
|
96
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_dict(cls, data: dict[str, Any]) -> TaskSpec:
|
|
100
|
+
"""Parse a task_spec dict (the shape returned by the API)."""
|
|
101
|
+
spec = data.get("componentRef", {}).get("spec", {})
|
|
102
|
+
graph = spec.get("implementation", {}).get("graph", {})
|
|
103
|
+
raw_tasks = graph.get("tasks", {})
|
|
104
|
+
|
|
105
|
+
graph_tasks: dict[str, TaskSpec] = {}
|
|
106
|
+
for task_name, task_data in raw_tasks.items():
|
|
107
|
+
graph_tasks[task_name] = TaskSpec.from_dict(task_data)
|
|
108
|
+
|
|
109
|
+
return cls(
|
|
110
|
+
name=spec.get("name"),
|
|
111
|
+
component_spec=ComponentSpec.from_spec(spec) if spec else None,
|
|
112
|
+
arguments=data.get("arguments", {}),
|
|
113
|
+
graph_tasks=graph_tasks,
|
|
114
|
+
annotations=data.get("annotations", {}),
|
|
115
|
+
raw=data,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def digest(self) -> str | None:
|
|
120
|
+
"""Component digest from componentRef."""
|
|
121
|
+
return self.raw.get("componentRef", {}).get("digest")
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def inputs(self) -> list[dict[str, Any]]:
|
|
125
|
+
"""Component inputs."""
|
|
126
|
+
return self.component_spec.inputs if self.component_spec else []
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def outputs(self) -> list[dict[str, Any]]:
|
|
130
|
+
"""Component outputs."""
|
|
131
|
+
return self.component_spec.outputs if self.component_spec else []
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def execution_id(self) -> str | None:
|
|
135
|
+
"""Execution ID injected by ``_enrich_execution_tree``."""
|
|
136
|
+
return self.raw.get("execution_id")
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def execution_input_artifacts(self) -> dict[str, str]:
|
|
140
|
+
"""Input artifact IDs injected by ``_enrich_execution_tree``."""
|
|
141
|
+
return self.raw.get("input_artifacts", {})
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def execution_output_artifacts(self) -> dict[str, str]:
|
|
145
|
+
"""Output artifact IDs injected by ``_enrich_execution_tree``."""
|
|
146
|
+
return self.raw.get("output_artifacts", {})
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def is_graph(self) -> bool:
|
|
150
|
+
"""True if this task is a subgraph (has child tasks)."""
|
|
151
|
+
return len(self.graph_tasks) > 0
|
|
152
|
+
|
|
153
|
+
def strip_implementations(self) -> None:
|
|
154
|
+
"""Remove container implementation blocks recursively.
|
|
155
|
+
|
|
156
|
+
Graph structure (tasks, arguments, connections) is preserved.
|
|
157
|
+
Only leaf container/code blocks are stripped. ``text`` fields
|
|
158
|
+
(raw YAML containing full implementations) are stripped at every
|
|
159
|
+
level to avoid leaking implementation details.
|
|
160
|
+
"""
|
|
161
|
+
if self.is_graph:
|
|
162
|
+
# Graph component: keep implementation dict but strip text fields
|
|
163
|
+
if self.component_spec:
|
|
164
|
+
self.component_spec.strip_implementation(keep_graph=True)
|
|
165
|
+
for child in self.graph_tasks.values():
|
|
166
|
+
child.strip_implementations()
|
|
167
|
+
else:
|
|
168
|
+
if self.component_spec:
|
|
169
|
+
self.component_spec.strip_implementation()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---- Container state -------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class KubernetesDebugInfo:
|
|
177
|
+
"""Kubernetes debug info from container state."""
|
|
178
|
+
pod_name: str | None = None
|
|
179
|
+
namespace: str | None = None
|
|
180
|
+
log_uri: str | None = None
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def from_dict(cls, data: dict[str, Any]) -> KubernetesDebugInfo:
|
|
184
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass
|
|
188
|
+
class KubernetesJobInfo:
|
|
189
|
+
"""Kubernetes job info from container state (debug_info.kubernetes_job)."""
|
|
190
|
+
job_name: str | None = None
|
|
191
|
+
namespace: str | None = None
|
|
192
|
+
|
|
193
|
+
@classmethod
|
|
194
|
+
def from_dict(cls, data: dict[str, Any]) -> KubernetesJobInfo:
|
|
195
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass
|
|
199
|
+
class DebugInfo:
|
|
200
|
+
"""Debug info from container state (mirrors debug_info in the API response)."""
|
|
201
|
+
kubernetes: KubernetesDebugInfo | None = None
|
|
202
|
+
kubernetes_job: KubernetesJobInfo | None = None
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_dict(cls, data: dict[str, Any]) -> DebugInfo:
|
|
206
|
+
k8s_data = data.get("kubernetes", {})
|
|
207
|
+
job_data = data.get("kubernetes_job", {})
|
|
208
|
+
return cls(
|
|
209
|
+
kubernetes=KubernetesDebugInfo.from_dict(k8s_data) if k8s_data else None,
|
|
210
|
+
kubernetes_job=KubernetesJobInfo.from_dict(job_data) if job_data else None,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class ContainerState:
|
|
216
|
+
"""Response from GET /api/executions/{id}/container_state.
|
|
217
|
+
|
|
218
|
+
Extracts key fields for debugging; the full Kubernetes debug info
|
|
219
|
+
(pod spec, status, etc.) is available via ``debug_info.kubernetes`` and ``raw``.
|
|
220
|
+
"""
|
|
221
|
+
status: str = "UNKNOWN"
|
|
222
|
+
exit_code: int | None = None
|
|
223
|
+
started_at: str | None = None
|
|
224
|
+
ended_at: str | None = None
|
|
225
|
+
pod_name: str | None = None
|
|
226
|
+
namespace: str | None = None
|
|
227
|
+
debug_info: DebugInfo | None = None
|
|
228
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def from_dict(cls, data: dict[str, Any]) -> ContainerState:
|
|
232
|
+
debug_data = data.get("debug_info", {})
|
|
233
|
+
fields = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
|
|
234
|
+
if debug_data:
|
|
235
|
+
fields["debug_info"] = DebugInfo.from_dict(debug_data)
|
|
236
|
+
fields["raw"] = data
|
|
237
|
+
|
|
238
|
+
# Resolve pod_name: debug_info.kubernetes.pod_name > debug_info.kubernetes_job.job_name
|
|
239
|
+
if not fields.get("pod_name"):
|
|
240
|
+
di = fields.get("debug_info")
|
|
241
|
+
k8s = di.kubernetes if di else None
|
|
242
|
+
if k8s and k8s.pod_name:
|
|
243
|
+
fields["pod_name"] = k8s.pod_name
|
|
244
|
+
if not fields.get("namespace"):
|
|
245
|
+
fields["namespace"] = k8s.namespace
|
|
246
|
+
else:
|
|
247
|
+
job = di.kubernetes_job if di else None
|
|
248
|
+
if job and job.job_name:
|
|
249
|
+
fields["pod_name"] = job.job_name
|
|
250
|
+
|
|
251
|
+
return cls(**fields)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---- Composite -------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class RunDetails:
|
|
259
|
+
"""Combined pipeline run + execution details from get_run_details."""
|
|
260
|
+
run: PipelineRun
|
|
261
|
+
execution: GetExecutionInfoResponse | None = None
|
|
262
|
+
annotations: dict[str, str | None] | None = None
|
|
263
|
+
execution_state: GraphExecutionState | None = None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---- Users / secrets -------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class UserInfo:
|
|
271
|
+
"""Current authenticated user from /api/users/me."""
|
|
272
|
+
id: str
|
|
273
|
+
permissions: list[str]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass
|
|
277
|
+
class SecretInfo:
|
|
278
|
+
"""Secret metadata from /api/secrets/ endpoints."""
|
|
279
|
+
secret_name: str
|
|
280
|
+
created_at: str
|
|
281
|
+
updated_at: str
|
|
282
|
+
expires_at: str | None = None
|
|
283
|
+
description: str | None = None
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def from_dict(cls, data: dict[str, Any]) -> SecretInfo:
|
|
287
|
+
return cls(
|
|
288
|
+
secret_name=data["secret_name"],
|
|
289
|
+
created_at=data["created_at"],
|
|
290
|
+
updated_at=data["updated_at"],
|
|
291
|
+
expires_at=data.get("expires_at"),
|
|
292
|
+
description=data.get("description"),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---- Components ------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ``ComponentSpec`` is generated from OpenAPI and extended in
|
|
300
|
+
# ``tangle_cli.generated_model_extensions.ComponentSpecExtensions``. Re-export
|
|
301
|
+
# it from this module for compatibility with callers that import domain models
|
|
302
|
+
# from ``tangle_cli.models``.
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass
|
|
306
|
+
class ComponentInfo:
|
|
307
|
+
"""Merged view of a published component: spec + publication metadata."""
|
|
308
|
+
|
|
309
|
+
name: str = ""
|
|
310
|
+
digest: str | None = None
|
|
311
|
+
version: str | None = None
|
|
312
|
+
published_by: str | None = None
|
|
313
|
+
deprecated: bool = False
|
|
314
|
+
superseded_by: str | None = None
|
|
315
|
+
description: str = ""
|
|
316
|
+
component_spec: ComponentSpec | None = None
|
|
317
|
+
spec_error: str | None = None
|
|
318
|
+
|
|
319
|
+
@classmethod
|
|
320
|
+
def from_dict(cls, pub: dict[str, Any]) -> ComponentInfo:
|
|
321
|
+
"""Create from a published_components API response entry."""
|
|
322
|
+
return cls(
|
|
323
|
+
name=pub.get("name", ""),
|
|
324
|
+
digest=pub.get("digest"),
|
|
325
|
+
version=pub.get("version"),
|
|
326
|
+
published_by=pub.get("published_by"),
|
|
327
|
+
deprecated=pub.get("deprecated", False),
|
|
328
|
+
superseded_by=pub.get("superseded_by"),
|
|
329
|
+
description=pub.get("description", ""),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
def to_dict(self, strip_spec: bool = True) -> dict[str, Any]:
|
|
333
|
+
"""Serialize to a dict, omitting None/empty optional fields.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
strip_spec: If True (default), strip bulky annotations and
|
|
337
|
+
implementation blocks from the component spec.
|
|
338
|
+
"""
|
|
339
|
+
d: dict[str, Any] = {"digest": self.digest, "version": self.version}
|
|
340
|
+
if self.published_by is not None:
|
|
341
|
+
d["published_by"] = self.published_by
|
|
342
|
+
d["deprecated"] = self.deprecated
|
|
343
|
+
if self.superseded_by is not None:
|
|
344
|
+
d["superseded_by"] = self.superseded_by
|
|
345
|
+
if self.description:
|
|
346
|
+
d["description"] = self.description
|
|
347
|
+
if self.component_spec is not None:
|
|
348
|
+
spec = self.component_spec.stripped_spec if strip_spec else self.component_spec.data
|
|
349
|
+
if spec is not None:
|
|
350
|
+
d["spec"] = spec
|
|
351
|
+
if self.spec_error is not None:
|
|
352
|
+
d["spec_error"] = self.spec_error
|
|
353
|
+
return d
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---- Pagination ------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@dataclass
|
|
360
|
+
class PageChunk:
|
|
361
|
+
"""Metadata for a single page of search results."""
|
|
362
|
+
|
|
363
|
+
rows: list[dict[str, Any]]
|
|
364
|
+
page_token: str | None
|
|
365
|
+
next_page_token: str | None
|
|
366
|
+
ui_filter_url: str
|
|
367
|
+
next_ui_filter_url: str | None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---- Dict-like compatibility ----------------------------------------------
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _dataclass_to_dict(self) -> dict[str, Any]:
|
|
374
|
+
return asdict(self)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _dataclass_get(self, key: str, default: Any = None) -> Any:
|
|
378
|
+
return _dataclass_to_dict(self).get(key, default)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _dataclass_getitem(self, key: str) -> Any:
|
|
382
|
+
return _dataclass_to_dict(self)[key]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
for _dict_like_cls in (
|
|
386
|
+
GraphExecutionState,
|
|
387
|
+
PipelineRun,
|
|
388
|
+
TaskSpec,
|
|
389
|
+
KubernetesDebugInfo,
|
|
390
|
+
KubernetesJobInfo,
|
|
391
|
+
DebugInfo,
|
|
392
|
+
ContainerState,
|
|
393
|
+
RunDetails,
|
|
394
|
+
ArtifactComponentQuery,
|
|
395
|
+
ArtifactInfo,
|
|
396
|
+
UserInfo,
|
|
397
|
+
SecretInfo,
|
|
398
|
+
ComponentInfo,
|
|
399
|
+
PageChunk,
|
|
400
|
+
):
|
|
401
|
+
if not hasattr(_dict_like_cls, "to_dict"):
|
|
402
|
+
setattr(_dict_like_cls, "to_dict", _dataclass_to_dict)
|
|
403
|
+
setattr(_dict_like_cls, "get", _dataclass_get)
|
|
404
|
+
setattr(_dict_like_cls, "__getitem__", _dataclass_getitem)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
del _dict_like_cls
|