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