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,405 @@
1
+ """Handwritten extensions mixed into generated Tangle API models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import datetime, timezone
7
+ from typing import Any, cast
8
+
9
+ import yaml
10
+
11
+ import tangle_cli.utils as utils
12
+
13
+
14
+ def _strip_text_from_graph(implementation: dict[str, Any]) -> None:
15
+ """Recursively remove raw component text from graph component references."""
16
+
17
+ graph = implementation.get("graph", {})
18
+ for task_data in graph.get("tasks", {}).values():
19
+ ref = task_data.get("componentRef")
20
+ if not ref:
21
+ continue
22
+ ref.pop("text", None)
23
+ spec = ref.get("spec", {})
24
+ nested_impl = spec.get("implementation")
25
+ if nested_impl and "graph" in nested_impl:
26
+ _strip_text_from_graph(nested_impl)
27
+
28
+
29
+ def _add_official_prefix(name: str) -> str:
30
+ """Return the official component name variant used by registry searches."""
31
+
32
+ if name and not name.startswith("[Official]"):
33
+ return f"[Official] {name}"
34
+ return name
35
+
36
+
37
+ class ComponentSpecExtensions:
38
+ """Legacy YAML-domain conveniences for the generated ComponentSpec model."""
39
+
40
+ _STRIP_ANNOTATION_KEYS = {"python_original_code", "python_dependencies"}
41
+
42
+ def _extra_get(self, key: str, default: Any = None) -> Any:
43
+ extra = getattr(self, "__pydantic_extra__", None)
44
+ if isinstance(extra, dict) and key in extra:
45
+ return extra[key]
46
+ return getattr(self, "__dict__", {}).get(key, default)
47
+
48
+ def _extra_set(self, key: str, value: Any) -> None:
49
+ extra = getattr(self, "__pydantic_extra__", None)
50
+ if isinstance(extra, dict):
51
+ extra[key] = value
52
+ else: # pragma: no cover - pydantic v1 fallback
53
+ self.__dict__[key] = value
54
+
55
+ @property
56
+ def data(self) -> dict[str, Any]:
57
+ data = self._extra_get("data")
58
+ if isinstance(data, dict):
59
+ return data
60
+ result: dict[str, Any] = {}
61
+ if self.name:
62
+ result["name"] = self.name
63
+ if self.description is not None:
64
+ result["description"] = self.description
65
+ if self.metadata:
66
+ result["metadata"] = self.metadata
67
+ if self.inputs:
68
+ result["inputs"] = self.inputs
69
+ if self.outputs:
70
+ result["outputs"] = self.outputs
71
+ if self.implementation:
72
+ result["implementation"] = self.implementation
73
+ return result
74
+
75
+ @data.setter
76
+ def data(self, value: dict[str, Any]) -> None:
77
+ self._extra_set("data", value)
78
+
79
+ @property
80
+ def digest(self) -> str:
81
+ return str(self._extra_get("digest", "") or "")
82
+
83
+ @digest.setter
84
+ def digest(self, value: str) -> None:
85
+ self._extra_set("digest", value)
86
+
87
+ @property
88
+ def text(self) -> str | None:
89
+ return self._extra_get("text")
90
+
91
+ @text.setter
92
+ def text(self, value: str | None) -> None:
93
+ self._extra_set("text", value)
94
+
95
+ @property
96
+ def version(self) -> str | None:
97
+ return self._extra_get("version")
98
+
99
+ @version.setter
100
+ def version(self, value: str | None) -> None:
101
+ self._extra_set("version", value)
102
+
103
+ @property
104
+ def annotations(self) -> dict[str, str]:
105
+ annotations = self._extra_get("annotations")
106
+ if isinstance(annotations, dict):
107
+ return annotations
108
+ return (self.metadata or {}).get("annotations", {})
109
+
110
+ @annotations.setter
111
+ def annotations(self, value: dict[str, str]) -> None:
112
+ self._extra_set("annotations", value)
113
+
114
+ @classmethod
115
+ def from_dict(cls, data: dict[str, Any]) -> Any:
116
+ """Create from a raw component API response.
117
+
118
+ ``/api/components/{digest}`` responses carry raw YAML in ``text`` and
119
+ may carry the parsed YAML in ``spec``. The generated model stores the
120
+ schema fields while extra fields preserve legacy helpers such as
121
+ ``data``, ``digest``, ``text``, ``version``, and ``annotations``.
122
+ """
123
+
124
+ spec = data.get("spec")
125
+ text = data.get("text")
126
+ if spec is None and text:
127
+ spec = yaml.safe_load(text)
128
+ if spec is None and any(
129
+ key in data
130
+ for key in ("name", "description", "metadata", "inputs", "outputs", "implementation")
131
+ ):
132
+ spec = data
133
+ spec = spec or {}
134
+ annotations = spec.get("metadata", {}).get("annotations", {})
135
+ return cls(
136
+ digest=data.get("digest", ""),
137
+ data=spec,
138
+ text=text,
139
+ name=spec.get("name", ""),
140
+ version=annotations.get("version"),
141
+ description=spec.get("description"),
142
+ annotations=annotations,
143
+ inputs=spec.get("inputs", []),
144
+ outputs=spec.get("outputs", []),
145
+ implementation=spec.get("implementation"),
146
+ metadata=spec.get("metadata"),
147
+ )
148
+
149
+ @classmethod
150
+ def from_yaml_file(cls, yaml_path: str) -> Any:
151
+ """Load and parse a component YAML file."""
152
+
153
+ with open(yaml_path) as f:
154
+ yaml_content = f.read()
155
+ return cls.from_yaml(yaml_content)
156
+
157
+ @classmethod
158
+ def from_yaml(
159
+ cls,
160
+ yaml_content: str,
161
+ annotations: dict[str, str] | None = None,
162
+ ) -> Any:
163
+ """Create from YAML text, optionally merging annotations first."""
164
+
165
+ data = utils.parse_yaml_string(yaml_content)
166
+ if not data:
167
+ raise ValueError("Unable to parse YAML content")
168
+
169
+ if annotations:
170
+ data.setdefault("metadata", {}).setdefault("annotations", {}).update(annotations)
171
+
172
+ name = data.get("name")
173
+ if not name:
174
+ raise ValueError("Component name is required but not found in YAML")
175
+
176
+ version = utils.get_version_from_data(data) or None
177
+ return cls(
178
+ data=data,
179
+ version=version,
180
+ name=name,
181
+ description=data.get("description"),
182
+ text=yaml_content,
183
+ annotations=data.get("metadata", {}).get("annotations", {}),
184
+ inputs=data.get("inputs", []),
185
+ outputs=data.get("outputs", []),
186
+ implementation=data.get("implementation"),
187
+ metadata=data.get("metadata"),
188
+ )
189
+
190
+ @classmethod
191
+ def from_spec(cls, spec: dict[str, Any]) -> Any:
192
+ """Create from an inline component spec dict."""
193
+
194
+ annotations = spec.get("metadata", {}).get("annotations", {})
195
+ return cls(
196
+ data=spec,
197
+ name=spec.get("name", ""),
198
+ description=spec.get("description"),
199
+ annotations=annotations,
200
+ inputs=spec.get("inputs", []),
201
+ outputs=spec.get("outputs", []),
202
+ implementation=spec.get("implementation"),
203
+ metadata=spec.get("metadata"),
204
+ )
205
+
206
+ def __bool__(self) -> bool:
207
+ return bool(getattr(self, "data", None))
208
+
209
+ @property
210
+ def search_names(self) -> list[str]:
211
+ """Names to use for searching, including the official-name variant."""
212
+
213
+ name = getattr(self, "name", "") or ""
214
+ return [name, _add_official_prefix(name)]
215
+
216
+ @property
217
+ def stripped_spec(self) -> dict[str, Any] | None:
218
+ """Component data with bulky annotations and implementation removed."""
219
+
220
+ data = getattr(self, "data", None)
221
+ if not data:
222
+ return None
223
+ result = dict(data)
224
+ result.pop("implementation", None)
225
+ annotations = result.get("metadata", {}).get("annotations", {})
226
+ if annotations:
227
+ result["metadata"] = dict(result["metadata"])
228
+ result["metadata"]["annotations"] = {
229
+ key: value
230
+ for key, value in annotations.items()
231
+ if key not in self._STRIP_ANNOTATION_KEYS
232
+ }
233
+ return result
234
+
235
+ def strip_implementation(self, *, keep_graph: bool = False) -> None:
236
+ """Remove implementation details in-place."""
237
+
238
+ self.text = None
239
+ if keep_graph:
240
+ if self.implementation:
241
+ _strip_text_from_graph(self.implementation)
242
+ else:
243
+ self.implementation = None
244
+ self.data.pop("implementation", None)
245
+
246
+ def to_yaml(self) -> str:
247
+ """Convert component data back to YAML."""
248
+
249
+ return utils.dump_yaml(self.data)
250
+
251
+ def save_to_file(self, file_path: str) -> None:
252
+ """Write component data to a YAML file."""
253
+
254
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
255
+ with open(file_path, "w") as f:
256
+ f.write(self.to_yaml())
257
+
258
+ def update_fields(
259
+ self,
260
+ git_remote_sha: str | None = None,
261
+ git_remote_branch: str | None = None,
262
+ git_remote_url: str | None = None,
263
+ image: str | None = None,
264
+ component_yaml_path: str | None = None,
265
+ ) -> Any:
266
+ """Update publishing metadata in-place and return ``self``."""
267
+
268
+ self.data.setdefault("metadata", {}).setdefault("annotations", {})
269
+ annotations = self.data["metadata"]["annotations"]
270
+ annotations["published_at"] = datetime.now(timezone.utc).isoformat()
271
+
272
+ if git_remote_sha:
273
+ annotations.setdefault("git_remote_sha", git_remote_sha)
274
+ if git_remote_branch:
275
+ annotations.setdefault("git_remote_branch", git_remote_branch)
276
+ if git_remote_url:
277
+ annotations.setdefault("git_remote_url", git_remote_url)
278
+ if component_yaml_path:
279
+ utils.set_component_yaml_path(component_yaml_path, annotations, overwrite=False)
280
+
281
+ if "version" in self.data:
282
+ annotations["version"] = str(self.data.pop("version"))
283
+ if "updated_at" in self.data:
284
+ annotations["updated_at"] = str(self.data.pop("updated_at"))
285
+
286
+ if image:
287
+ self.data.setdefault("implementation", {}).setdefault("container", {})["image"] = image
288
+ self.implementation = self.data["implementation"]
289
+ self.metadata = self.data.get("metadata")
290
+ self.annotations = annotations
291
+ return self
292
+
293
+ def fetch_from_url(self, url: str, timeout: int = 10) -> bool:
294
+ """Fetch and parse component YAML from a URL into this model."""
295
+
296
+ import httpx
297
+
298
+ try:
299
+ response = httpx.get(url, timeout=timeout)
300
+ response.raise_for_status()
301
+ self.text = response.text
302
+ self.data = yaml.safe_load(response.text)
303
+ self.name = self.data.get("name", "")
304
+ self.description = self.data.get("description")
305
+ self.metadata = self.data.get("metadata")
306
+ self.annotations = self.data.get("metadata", {}).get("annotations", {})
307
+ self.inputs = self.data.get("inputs", [])
308
+ self.outputs = self.data.get("outputs", [])
309
+ self.implementation = self.data.get("implementation")
310
+ self.version = self.annotations.get("version")
311
+ return True
312
+ except Exception:
313
+ return False
314
+
315
+ def ensure_digest(self) -> str | None:
316
+ """Compute and store a digest if one is not already present."""
317
+
318
+ if getattr(self, "digest", None):
319
+ return self.digest
320
+ from tangle_cli.utils import compute_spec_digest, compute_text_digest
321
+
322
+ if getattr(self, "text", None):
323
+ self.digest = compute_text_digest(self.text)
324
+ elif getattr(self, "data", None):
325
+ self.digest = compute_spec_digest(self.data)
326
+ return self.digest or None
327
+
328
+
329
+
330
+ class GetExecutionInfoResponseExtensions:
331
+ """Legacy execution-detail conveniences for generated execution responses."""
332
+
333
+ @classmethod
334
+ def from_dict(cls, data: dict[str, Any]) -> Any:
335
+ """Create a normalized execution details model from API response data."""
336
+
337
+ from tangle_cli.models import TaskSpec
338
+
339
+ return cls(
340
+ id=data.get("id", ""),
341
+ task_spec=TaskSpec.from_dict(data.get("task_spec", {})),
342
+ pipeline_run_id=data.get("pipeline_run_id"),
343
+ parent_execution_id=data.get("parent_execution_id"),
344
+ child_task_execution_ids=data.get("child_task_execution_ids"),
345
+ input_artifacts={
346
+ key: value["id"]
347
+ for key, value in data.get("input_artifacts", {}).items()
348
+ if "id" in value
349
+ },
350
+ output_artifacts={
351
+ key: value["id"]
352
+ for key, value in data.get("output_artifacts", {}).items()
353
+ if "id" in value
354
+ },
355
+ child_executions={},
356
+ raw=data,
357
+ )
358
+
359
+ def strip_implementations(self) -> None:
360
+ """Remove implementation blocks from this execution tree in-place."""
361
+
362
+ self.task_spec.strip_implementations()
363
+ for child in self.child_executions.values():
364
+ child.strip_implementations()
365
+
366
+ @property
367
+ def tasks(self) -> dict[str, Any]:
368
+ """Shortcut to the root task spec's graph tasks."""
369
+
370
+ return self.task_spec.graph_tasks
371
+
372
+
373
+ class GetGraphExecutionStateResponseExtensions:
374
+ """Convenience properties for graph execution state responses."""
375
+
376
+ @property
377
+ def per_execution(self) -> dict[str, dict[str, int]]:
378
+ return cast(
379
+ dict[str, dict[str, int]],
380
+ getattr(self, "child_execution_status_stats", None) or {},
381
+ )
382
+
383
+ @property
384
+ def status_totals(self) -> dict[str, int]:
385
+ totals: dict[str, int] = {}
386
+ for status_counts in self.per_execution.values():
387
+ for status, count in status_counts.items():
388
+ totals[status] = totals.get(status, 0) + count
389
+ return totals
390
+
391
+ @property
392
+ def failed_execution_ids(self) -> list[str]:
393
+ return [
394
+ execution_id
395
+ for execution_id, status_counts in self.per_execution.items()
396
+ if status_counts.get("FAILED", 0) > 0
397
+ or status_counts.get("SYSTEM_ERROR", 0) > 0
398
+ ]
399
+
400
+
401
+ MODEL_EXTENSIONS = {
402
+ "ComponentSpec": "ComponentSpecExtensions",
403
+ "GetExecutionInfoResponse": "GetExecutionInfoResponseExtensions",
404
+ "GetGraphExecutionStateResponse": "GetGraphExecutionStateResponseExtensions",
405
+ }
@@ -0,0 +1,43 @@
1
+ """Runtime helpers shared by generated Tangle API model packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+ try:
10
+ from pydantic import ConfigDict
11
+ except ImportError: # pragma: no cover - pydantic v1 fallback
12
+ ConfigDict = None # type: ignore[assignment]
13
+
14
+
15
+ class TangleGeneratedModel(BaseModel):
16
+ """Base for generated response models with dict-like conveniences."""
17
+
18
+ if ConfigDict is not None:
19
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
20
+ else: # pragma: no cover - pydantic v1 fallback
21
+ class Config:
22
+ extra = "allow"
23
+ allow_population_by_field_name = True
24
+
25
+ def get(self, key: str, default: Any = None) -> Any:
26
+ return self.to_dict().get(key, default)
27
+
28
+ def __getitem__(self, key: str) -> Any:
29
+ return self.to_dict()[key]
30
+
31
+ def to_dict(self) -> dict[str, Any]:
32
+ if hasattr(self, "model_dump"):
33
+ return self.model_dump(by_alias=True)
34
+ return self.dict(by_alias=True)
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: dict[str, Any]) -> Any:
38
+ if hasattr(cls, "model_validate"):
39
+ return cls.model_validate(data)
40
+ return cls.parse_obj(data)
41
+
42
+
43
+ __all__ = ["TangleGeneratedModel"]
tangle_cli/handler.py ADDED
@@ -0,0 +1,96 @@
1
+ """Shared base classes for Tangle CLI service handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from typing import Any
7
+
8
+ from .api_transport import default_base_url
9
+ from .logger import Logger, get_default_logger
10
+
11
+
12
+ def _client_base_url_without_materializing(client: Any | None) -> Any | None:
13
+ """Return a client's configured base URL without triggering lazy proxies."""
14
+
15
+ if client is None:
16
+ return None
17
+
18
+ try:
19
+ client_vars = object.__getattribute__(client, "__dict__")
20
+ except AttributeError:
21
+ client_vars = {}
22
+ if isinstance(client_vars, Mapping):
23
+ if client_vars.get("base_url"):
24
+ return client_vars["base_url"]
25
+ client_kwargs = client_vars.get("client_kwargs")
26
+ if isinstance(client_kwargs, Mapping):
27
+ return client_kwargs.get("base_url")
28
+
29
+ try:
30
+ return object.__getattribute__(client, "base_url")
31
+ except AttributeError:
32
+ return None
33
+
34
+
35
+ class TangleCliHandler:
36
+ """Base class for CLI/services that use logging and lazy Tangle API clients."""
37
+
38
+ _required_client_error_type: type[Exception] = RuntimeError
39
+ _required_client_error_message = "Failed to create TangleApiClient"
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ dry_run: bool = False,
45
+ client: Any = None,
46
+ client_factory: Callable[[], Any] | None = None,
47
+ logger: Logger | None = None,
48
+ base_url: str | None = None,
49
+ ) -> None:
50
+ self.dry_run = dry_run
51
+ client_base_url = _client_base_url_without_materializing(client)
52
+ self.base_url = str(base_url or client_base_url or default_base_url())
53
+ self.client = client
54
+ self._client = client
55
+ self._client_factory = client_factory
56
+ self.log = logger or get_default_logger()
57
+
58
+ def _create_client(self) -> Any | None:
59
+ """Create the default OSS Tangle API client."""
60
+
61
+ try:
62
+ from .client import TangleApiClient
63
+ except ModuleNotFoundError as exc:
64
+ if exc.name == "tangle_api":
65
+ self.log.error(
66
+ "❌ Native generated Tangle API bindings are required for Tangle API operations. "
67
+ "Install tangle-cli[native] or provide a local tangle_api.generated package."
68
+ )
69
+ return None
70
+ raise
71
+ return TangleApiClient(logger=self.log)
72
+
73
+ def _set_client(self, client: Any | None) -> Any | None:
74
+ self.client = client
75
+ self._client = client
76
+ client_base_url = _client_base_url_without_materializing(client)
77
+ if client_base_url:
78
+ self.base_url = str(client_base_url)
79
+ return client
80
+
81
+ def _get_client(self) -> Any | None:
82
+ """Get or lazily create a Tangle API client instance."""
83
+
84
+ if self._client is None and not self.dry_run:
85
+ if self._client_factory is not None:
86
+ return self._set_client(self._client_factory())
87
+ return self._set_client(self._create_client())
88
+ return self._client
89
+
90
+ def _require_client(self) -> Any:
91
+ """Return a Tangle API client, raising if one cannot be created."""
92
+
93
+ client = self._get_client()
94
+ if client is None:
95
+ raise self._required_client_error_type(self._required_client_error_message)
96
+ return client