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/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