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/artifacts.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Read-only artifact lookup helpers for Tangle pipeline runs.
|
|
2
|
+
|
|
3
|
+
This module intentionally resolves artifact metadata only. It does not fetch
|
|
4
|
+
signed URLs, download remote objects, write local files, or mutate artifacts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import asdict, dataclass, field, is_dataclass
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
from .handler import TangleCliHandler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ArtifactClient(Protocol):
|
|
16
|
+
"""Subset of the static API client used for read-only artifact lookup."""
|
|
17
|
+
|
|
18
|
+
def get_run_details(self, run_id: str) -> Any: ...
|
|
19
|
+
|
|
20
|
+
def get_execution_details(self, execution_id: str) -> Any: ...
|
|
21
|
+
|
|
22
|
+
def artifacts_get(self, artifact_id: str) -> Any: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ArtifactComponentQuery:
|
|
27
|
+
"""Filter for selecting artifacts by component name or digest."""
|
|
28
|
+
|
|
29
|
+
name: str | None = None
|
|
30
|
+
digest: str | None = None
|
|
31
|
+
outputs: list[str] = field(default_factory=list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ArtifactInfo:
|
|
36
|
+
"""Resolved artifact metadata from GET /api/artifacts/{id}.
|
|
37
|
+
|
|
38
|
+
This dataclass intentionally lives in this native-free module. Generated
|
|
39
|
+
response objects are accepted structurally via ``from_response`` so no
|
|
40
|
+
``tangle_api`` import is required.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
id: str
|
|
44
|
+
uri: str
|
|
45
|
+
key: str = ""
|
|
46
|
+
total_size: int = 0
|
|
47
|
+
is_dir: bool = False
|
|
48
|
+
hash: str | None = None
|
|
49
|
+
created_at: str | None = None
|
|
50
|
+
error: str | None = None
|
|
51
|
+
local_path: str | None = None
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_dict(cls, data: dict[str, Any], key: str = "") -> ArtifactInfo:
|
|
55
|
+
ad = data.get("artifact_data", {})
|
|
56
|
+
return cls(
|
|
57
|
+
id=data.get("id", ""),
|
|
58
|
+
uri=_mapping_or_attr(ad, "uri", ""),
|
|
59
|
+
key=key,
|
|
60
|
+
total_size=_mapping_or_attr(ad, "total_size", 0),
|
|
61
|
+
is_dir=_mapping_or_attr(ad, "is_dir", False),
|
|
62
|
+
hash=_optional_str(_mapping_or_attr(ad, "hash")),
|
|
63
|
+
created_at=_optional_str(_mapping_or_attr(ad, "created_at")),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_response(cls, response: Any, *, key: str = "") -> ArtifactInfo:
|
|
68
|
+
"""Create a flattened artifact DTO from a generated or duck-typed response."""
|
|
69
|
+
|
|
70
|
+
artifact_data = getattr(response, "artifact_data", None)
|
|
71
|
+
total_size = _mapping_or_attr(artifact_data, "total_size", 0)
|
|
72
|
+
is_dir = _mapping_or_attr(artifact_data, "is_dir", False)
|
|
73
|
+
return cls(
|
|
74
|
+
id=str(getattr(response, "id", "") or ""),
|
|
75
|
+
uri=str(_mapping_or_attr(artifact_data, "uri", "") or ""),
|
|
76
|
+
key=key,
|
|
77
|
+
total_size=total_size if isinstance(total_size, int) else 0,
|
|
78
|
+
is_dir=is_dir if isinstance(is_dir, bool) else False,
|
|
79
|
+
hash=_optional_str(_mapping_or_attr(artifact_data, "hash")),
|
|
80
|
+
created_at=_optional_str(_mapping_or_attr(artifact_data, "created_at")),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class ArtifactManager(TangleCliHandler):
|
|
85
|
+
"""Read-only artifact metadata manager.
|
|
86
|
+
|
|
87
|
+
Downstream packages can inject an already-authenticated client or a lazy
|
|
88
|
+
``client_factory`` (for example, one that applies provider auth). The manager
|
|
89
|
+
keeps the same read-only constraints as the module-level helpers: it never
|
|
90
|
+
downloads artifact contents, signs URLs, writes files, or mutates artifacts.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
client: ArtifactClient | None = None,
|
|
96
|
+
*,
|
|
97
|
+
client_factory: Any | None = None,
|
|
98
|
+
logger: Any | None = None,
|
|
99
|
+
**kwargs: Any,
|
|
100
|
+
) -> None:
|
|
101
|
+
super().__init__(client=client, client_factory=client_factory, logger=logger, **kwargs)
|
|
102
|
+
|
|
103
|
+
def collect_artifacts(
|
|
104
|
+
self,
|
|
105
|
+
execution: Any,
|
|
106
|
+
tasks_query: dict[str, list[str]],
|
|
107
|
+
components_query: list[ArtifactComponentQuery],
|
|
108
|
+
prefix: str = "",
|
|
109
|
+
) -> dict[str, str]:
|
|
110
|
+
"""Collect artifact IDs by walking an enriched execution tree."""
|
|
111
|
+
|
|
112
|
+
artifact_ids: dict[str, str] = {}
|
|
113
|
+
task_spec = _mapping_or_attr(execution, "task_spec")
|
|
114
|
+
graph_tasks = _mapping_or_attr(task_spec, "graph_tasks", {})
|
|
115
|
+
if not isinstance(graph_tasks, dict):
|
|
116
|
+
return artifact_ids
|
|
117
|
+
|
|
118
|
+
for task_name, child_task in graph_tasks.items():
|
|
119
|
+
task_name = str(task_name)
|
|
120
|
+
key_prefix = f"{prefix}{task_name}" if prefix else task_name
|
|
121
|
+
output_filters: list[list[str]] = []
|
|
122
|
+
|
|
123
|
+
for query_name in (task_name, key_prefix):
|
|
124
|
+
if query_name in tasks_query:
|
|
125
|
+
output_filters.append(tasks_query[query_name])
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
child_digest = _mapping_or_attr(child_task, "digest")
|
|
129
|
+
child_name = _mapping_or_attr(child_task, "name")
|
|
130
|
+
for component in components_query:
|
|
131
|
+
if (component.digest and child_digest == component.digest) or (
|
|
132
|
+
component.name and child_name == component.name
|
|
133
|
+
):
|
|
134
|
+
output_filters.append(component.outputs)
|
|
135
|
+
|
|
136
|
+
out_artifacts = _artifact_id_map(_mapping_or_attr(child_task, "execution_output_artifacts", {}))
|
|
137
|
+
if output_filters and out_artifacts:
|
|
138
|
+
include_all = any(not output_filter for output_filter in output_filters)
|
|
139
|
+
requested_outputs = {
|
|
140
|
+
output_name
|
|
141
|
+
for output_filter in output_filters
|
|
142
|
+
for output_name in output_filter
|
|
143
|
+
}
|
|
144
|
+
for output_name, artifact_id in out_artifacts.items():
|
|
145
|
+
if include_all or output_name in requested_outputs:
|
|
146
|
+
artifact_ids[f"{key_prefix}/{output_name}"] = artifact_id
|
|
147
|
+
|
|
148
|
+
if _mapping_or_attr(child_task, "is_graph", False):
|
|
149
|
+
child_executions = _mapping_or_attr(execution, "child_executions", {})
|
|
150
|
+
child_execution = child_executions.get(task_name) if isinstance(child_executions, dict) else None
|
|
151
|
+
if child_execution:
|
|
152
|
+
artifact_ids.update(
|
|
153
|
+
self.collect_artifacts(
|
|
154
|
+
child_execution,
|
|
155
|
+
tasks_query,
|
|
156
|
+
components_query,
|
|
157
|
+
prefix=f"{key_prefix}/",
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return artifact_ids
|
|
162
|
+
|
|
163
|
+
def collect_execution_artifacts(
|
|
164
|
+
self,
|
|
165
|
+
execution_ids: dict[str, list[str]],
|
|
166
|
+
) -> dict[str, str]:
|
|
167
|
+
"""Collect artifact IDs directly from execution IDs."""
|
|
168
|
+
|
|
169
|
+
artifact_ids: dict[str, str] = {}
|
|
170
|
+
client = self._require_client()
|
|
171
|
+
for execution_id, output_filter in execution_ids.items():
|
|
172
|
+
execution = client.get_execution_details(execution_id)
|
|
173
|
+
output_artifacts = _artifact_id_map(_mapping_or_attr(execution, "output_artifacts", {}))
|
|
174
|
+
for output_name, artifact_id in output_artifacts.items():
|
|
175
|
+
if not output_filter or output_name in output_filter:
|
|
176
|
+
artifact_ids[f"{execution_id}/{output_name}"] = artifact_id
|
|
177
|
+
return artifact_ids
|
|
178
|
+
|
|
179
|
+
def get_artifacts(
|
|
180
|
+
self,
|
|
181
|
+
run_id: str,
|
|
182
|
+
query: dict[str, Any],
|
|
183
|
+
) -> dict[str, ArtifactInfo]:
|
|
184
|
+
"""Get artifact metadata for tasks/components in a pipeline run.
|
|
185
|
+
|
|
186
|
+
Query keys:
|
|
187
|
+
- ``tasks``: ``{<task_name>: [<output_names>]}``
|
|
188
|
+
- ``components``: ``[{"name"|"digest": ..., "outputs": [...]}]``
|
|
189
|
+
- ``executions``: ``{<execution_id>: [<output_names>]}``
|
|
190
|
+
- ``artifact_ids``: ``[<artifact_id>, ...]``
|
|
191
|
+
|
|
192
|
+
Empty output lists mean all outputs. Per-artifact lookup failures are
|
|
193
|
+
returned as ``ArtifactInfo(error=...)`` entries instead of failing the
|
|
194
|
+
whole command.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
artifact_ids: dict[str, str] = {}
|
|
198
|
+
|
|
199
|
+
for artifact_id in query.get("artifact_ids", []) or []:
|
|
200
|
+
artifact_ids[str(artifact_id)] = str(artifact_id)
|
|
201
|
+
|
|
202
|
+
executions_query = query.get("executions", {}) or {}
|
|
203
|
+
if executions_query:
|
|
204
|
+
artifact_ids.update(self.collect_execution_artifacts(executions_query))
|
|
205
|
+
|
|
206
|
+
tasks_query = query.get("tasks", {}) or {}
|
|
207
|
+
components_query_raw = query.get("components", []) or []
|
|
208
|
+
if tasks_query or components_query_raw:
|
|
209
|
+
details = self._require_client().get_run_details(run_id)
|
|
210
|
+
execution = _mapping_or_attr(details, "execution")
|
|
211
|
+
if not execution:
|
|
212
|
+
raise RuntimeError("No execution details found for run")
|
|
213
|
+
artifact_ids.update(
|
|
214
|
+
self.collect_artifacts(
|
|
215
|
+
execution,
|
|
216
|
+
tasks_query,
|
|
217
|
+
_component_queries(components_query_raw),
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
artifacts: dict[str, ArtifactInfo] = {}
|
|
222
|
+
for key, artifact_id in artifact_ids.items():
|
|
223
|
+
try:
|
|
224
|
+
response = self._require_client().artifacts_get(artifact_id)
|
|
225
|
+
artifacts[key] = _artifact_info_from_response(response, artifact_id=artifact_id, key=key)
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
artifacts[key] = ArtifactInfo(id=artifact_id, uri="", key=key, error=str(exc))
|
|
228
|
+
|
|
229
|
+
return artifacts
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def serialize_artifacts(artifacts: dict[str, ArtifactInfo]) -> list[dict[str, Any]]:
|
|
233
|
+
"""Serialize artifact dict to a JSON-friendly list, dropping ``None`` fields."""
|
|
234
|
+
|
|
235
|
+
result: list[dict[str, Any]] = []
|
|
236
|
+
for artifact in artifacts.values():
|
|
237
|
+
data = asdict(artifact) if is_dataclass(artifact) else dict(artifact)
|
|
238
|
+
result.append({key: value for key, value in data.items() if value is not None})
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _mapping_or_attr(value: Any, key: str, default: Any = None) -> Any:
|
|
243
|
+
if isinstance(value, dict):
|
|
244
|
+
return value.get(key, default)
|
|
245
|
+
return getattr(value, key, default)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _optional_str(value: Any) -> str | None:
|
|
249
|
+
if value is None:
|
|
250
|
+
return None
|
|
251
|
+
return str(value)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _artifact_id_map(raw_artifacts: Any) -> dict[str, str]:
|
|
255
|
+
"""Normalize API artifact maps to ``{output_name: artifact_id}``."""
|
|
256
|
+
|
|
257
|
+
if not isinstance(raw_artifacts, dict):
|
|
258
|
+
return {}
|
|
259
|
+
|
|
260
|
+
artifact_ids: dict[str, str] = {}
|
|
261
|
+
for output_name, value in raw_artifacts.items():
|
|
262
|
+
if isinstance(value, str):
|
|
263
|
+
artifact_ids[str(output_name)] = value
|
|
264
|
+
elif isinstance(value, dict) and value.get("id"):
|
|
265
|
+
artifact_ids[str(output_name)] = str(value["id"])
|
|
266
|
+
elif getattr(value, "id", None):
|
|
267
|
+
artifact_ids[str(output_name)] = str(value.id)
|
|
268
|
+
return artifact_ids
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _component_queries(raw_components: list[dict[str, Any]]) -> list[ArtifactComponentQuery]:
|
|
272
|
+
return [
|
|
273
|
+
ArtifactComponentQuery(
|
|
274
|
+
name=component.get("name"),
|
|
275
|
+
digest=component.get("digest"),
|
|
276
|
+
outputs=component.get("outputs") or [],
|
|
277
|
+
)
|
|
278
|
+
for component in raw_components
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _artifact_info_from_response(response: Any, *, artifact_id: str, key: str) -> ArtifactInfo:
|
|
283
|
+
if isinstance(response, dict):
|
|
284
|
+
return ArtifactInfo.from_dict(response, key=key)
|
|
285
|
+
return ArtifactInfo.from_response(response, key=key)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
__all__ = [
|
|
289
|
+
"ArtifactClient",
|
|
290
|
+
"ArtifactComponentQuery",
|
|
291
|
+
"ArtifactInfo",
|
|
292
|
+
"ArtifactManager",
|
|
293
|
+
]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""`tangle sdk artifacts` read-only artifact commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from cyclopts import App, Parameter
|
|
8
|
+
|
|
9
|
+
from .cli_helpers import (
|
|
10
|
+
LazyTangleApiClient,
|
|
11
|
+
api_arg_specs,
|
|
12
|
+
include_env_credentials_for_args,
|
|
13
|
+
load_args_or_exit,
|
|
14
|
+
print_json,
|
|
15
|
+
)
|
|
16
|
+
from .cli_options import (
|
|
17
|
+
AuthHeaderOption,
|
|
18
|
+
BaseUrlOption,
|
|
19
|
+
ConfigOption,
|
|
20
|
+
HeaderOption,
|
|
21
|
+
LogTypeOption,
|
|
22
|
+
TokenOption,
|
|
23
|
+
)
|
|
24
|
+
from .logger import logger_for_log_type
|
|
25
|
+
|
|
26
|
+
QueryOption = Annotated[
|
|
27
|
+
str | None,
|
|
28
|
+
Parameter(
|
|
29
|
+
name="--query",
|
|
30
|
+
alias="-q",
|
|
31
|
+
help=(
|
|
32
|
+
"JSON query with optional keys: "
|
|
33
|
+
"'tasks', 'components', 'executions', and 'artifact_ids'. "
|
|
34
|
+
"Empty output lists mean all outputs."
|
|
35
|
+
),
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
app = App(
|
|
40
|
+
name="artifacts",
|
|
41
|
+
help="Read artifact metadata for Tangle pipeline runs.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command(name="get")
|
|
46
|
+
def artifacts_get(
|
|
47
|
+
run_id: str | None = None,
|
|
48
|
+
*,
|
|
49
|
+
query: QueryOption = None,
|
|
50
|
+
base_url: BaseUrlOption = None,
|
|
51
|
+
token: TokenOption = None,
|
|
52
|
+
auth_header: AuthHeaderOption = None,
|
|
53
|
+
header: HeaderOption = None,
|
|
54
|
+
config: ConfigOption = None,
|
|
55
|
+
log_type: LogTypeOption = "console",
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Get artifact metadata for tasks/components in a pipeline run."""
|
|
58
|
+
|
|
59
|
+
all_args = load_args_or_exit(
|
|
60
|
+
config,
|
|
61
|
+
run_id=("run_id", run_id, None, False, True),
|
|
62
|
+
query=("query", query, None, True, True),
|
|
63
|
+
log_type=(log_type, "console"),
|
|
64
|
+
**api_arg_specs(
|
|
65
|
+
base_url=base_url,
|
|
66
|
+
token=token,
|
|
67
|
+
auth_header=auth_header,
|
|
68
|
+
header=header,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
results: list[dict[str, Any]] = []
|
|
73
|
+
for args in all_args:
|
|
74
|
+
logger, finalize_logs = logger_for_log_type(args.log_type)
|
|
75
|
+
try:
|
|
76
|
+
client = LazyTangleApiClient(
|
|
77
|
+
base_url=args.base_url,
|
|
78
|
+
token=args.token,
|
|
79
|
+
auth_header=args.auth_header,
|
|
80
|
+
header=args.header,
|
|
81
|
+
include_env_credentials=include_env_credentials_for_args(args, base_url),
|
|
82
|
+
command_name="artifact commands",
|
|
83
|
+
)
|
|
84
|
+
if require_available := getattr(client, "require_available", None):
|
|
85
|
+
require_available()
|
|
86
|
+
from .artifacts import ArtifactManager
|
|
87
|
+
|
|
88
|
+
manager = ArtifactManager(client=client)
|
|
89
|
+
try:
|
|
90
|
+
artifacts = manager.get_artifacts(args.run_id, args.query)
|
|
91
|
+
except RuntimeError as exc:
|
|
92
|
+
print_json({"status": "error", "error": str(exc)})
|
|
93
|
+
raise SystemExit(1) from exc
|
|
94
|
+
|
|
95
|
+
results.append(
|
|
96
|
+
{
|
|
97
|
+
"status": "success",
|
|
98
|
+
"run_id": args.run_id,
|
|
99
|
+
"count": len(artifacts),
|
|
100
|
+
"artifacts": ArtifactManager.serialize_artifacts(artifacts),
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
finally:
|
|
104
|
+
finalize_logs()
|
|
105
|
+
|
|
106
|
+
print_json(
|
|
107
|
+
results[0] if len(results) == 1 else {"status": "success", "results": results}
|
|
108
|
+
)
|
tangle_cli/cli.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from cyclopts import App
|
|
2
|
+
|
|
3
|
+
from . import (
|
|
4
|
+
__version__,
|
|
5
|
+
api_cli,
|
|
6
|
+
artifacts_cli,
|
|
7
|
+
components_cli,
|
|
8
|
+
pipeline_runs_cli,
|
|
9
|
+
pipelines_cli,
|
|
10
|
+
published_components_cli,
|
|
11
|
+
quickstart,
|
|
12
|
+
secrets_cli,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def version() -> None:
|
|
17
|
+
"""Print the installed tangle-cli package version."""
|
|
18
|
+
|
|
19
|
+
print(__version__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_sdk_app() -> App:
|
|
23
|
+
"""Build the SDK command group."""
|
|
24
|
+
|
|
25
|
+
sdk_app = App(
|
|
26
|
+
name="sdk",
|
|
27
|
+
help="Work with local Tangle SDK resources and scaffolding.",
|
|
28
|
+
)
|
|
29
|
+
sdk_app.command(artifacts_cli.app)
|
|
30
|
+
sdk_app.command(components_cli.app)
|
|
31
|
+
sdk_app.command(pipelines_cli.app)
|
|
32
|
+
sdk_app.command(pipeline_runs_cli.app)
|
|
33
|
+
sdk_app.command(published_components_cli.app)
|
|
34
|
+
sdk_app.command(secrets_cli.app)
|
|
35
|
+
return sdk_app
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_app() -> App:
|
|
39
|
+
"""Build the root CLI app lazily for the current invocation."""
|
|
40
|
+
|
|
41
|
+
app = App(
|
|
42
|
+
help="CLI for Tangle, the open-source ML pipeline orchestration platform.",
|
|
43
|
+
version=__version__,
|
|
44
|
+
)
|
|
45
|
+
app.command(name="version")(version)
|
|
46
|
+
app.command(quickstart.app)
|
|
47
|
+
app.command(api_cli.build_app())
|
|
48
|
+
app.command(build_sdk_app())
|
|
49
|
+
return app
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main() -> None:
|
|
53
|
+
build_app()()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Shared helpers for Tangle CLI command modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import pathlib
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .args_container import ArgsContainer, ConfigFileError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_args_or_exit(config: str | None, **kwargs: Any) -> list[ArgsContainer]:
|
|
13
|
+
"""Load ArgsContainer values from CLI/config specs, exiting with CLI errors."""
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
return ArgsContainer.load(config, **kwargs)
|
|
17
|
+
except ConfigFileError as exc:
|
|
18
|
+
raise SystemExit(f"Config error: {exc}") from exc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def print_json(payload: object) -> None:
|
|
22
|
+
"""Print a stable pretty JSON payload for CLI output."""
|
|
23
|
+
|
|
24
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config_or_exit(config: str | None) -> dict[str, object]:
|
|
28
|
+
"""Load the first YAML/JSON config mapping for commands with custom merging."""
|
|
29
|
+
|
|
30
|
+
if config is None:
|
|
31
|
+
return {}
|
|
32
|
+
try:
|
|
33
|
+
configs = ArgsContainer._load_config_file(config)
|
|
34
|
+
except ConfigFileError as exc:
|
|
35
|
+
raise SystemExit(f"Config error: {exc}") from exc
|
|
36
|
+
return configs[0] if configs else {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def optional_path(value: str | pathlib.Path | object | None) -> pathlib.Path | None:
|
|
40
|
+
"""Convert a CLI/config path value to Path when present."""
|
|
41
|
+
|
|
42
|
+
if isinstance(value, pathlib.Path):
|
|
43
|
+
return value
|
|
44
|
+
if isinstance(value, str):
|
|
45
|
+
return pathlib.Path(value)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def api_arg_specs(
|
|
50
|
+
*,
|
|
51
|
+
base_url: str | None = None,
|
|
52
|
+
token: str | None = None,
|
|
53
|
+
auth_header: str | None = None,
|
|
54
|
+
header: list[str] | None = None,
|
|
55
|
+
) -> dict[str, tuple[Any, ...]]:
|
|
56
|
+
"""Build ArgsContainer specs for common API connection options."""
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"base_url": (base_url, None),
|
|
60
|
+
"token": (token, None),
|
|
61
|
+
"auth_header": (auth_header, None),
|
|
62
|
+
"header": (header, None),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class LazyTangleApiClient:
|
|
67
|
+
"""Instantiate the generated API client only when a command uses it.
|
|
68
|
+
|
|
69
|
+
Importing CLI modules must stay native-free so local-only commands can run
|
|
70
|
+
without the generated ``tangle_api`` package. This proxy delays importing and
|
|
71
|
+
constructing ``TangleApiClient`` until an API method is actually accessed,
|
|
72
|
+
while keeping CLI-friendly error wording in the CLI helper layer.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, *, command_name: str, **client_kwargs: Any) -> None:
|
|
76
|
+
self.command_name = command_name
|
|
77
|
+
self.client_kwargs = client_kwargs
|
|
78
|
+
self._client: Any | None = None
|
|
79
|
+
|
|
80
|
+
def _get_client(self) -> Any:
|
|
81
|
+
if self._client is None:
|
|
82
|
+
try:
|
|
83
|
+
from .api_transport import DEFAULT_TIMEOUT_SECONDS
|
|
84
|
+
from .client import TangleApiClient
|
|
85
|
+
except ModuleNotFoundError as exc:
|
|
86
|
+
if exc.name == "tangle_api":
|
|
87
|
+
raise SystemExit(
|
|
88
|
+
"Native generated Tangle API bindings are required for "
|
|
89
|
+
f"{self.command_name}. Install tangle-cli[native] or provide "
|
|
90
|
+
"a local tangle_api.generated package."
|
|
91
|
+
) from exc
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
kwargs = dict(self.client_kwargs)
|
|
95
|
+
kwargs.setdefault("timeout", DEFAULT_TIMEOUT_SECONDS)
|
|
96
|
+
self._client = TangleApiClient(**kwargs)
|
|
97
|
+
return self._client
|
|
98
|
+
|
|
99
|
+
def require_available(self) -> None:
|
|
100
|
+
"""Materialize the client so CLI commands fail before native helper imports."""
|
|
101
|
+
|
|
102
|
+
self._get_client()
|
|
103
|
+
|
|
104
|
+
def __getattr__(self, name: str) -> Any:
|
|
105
|
+
return getattr(self._get_client(), name)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def include_env_credentials_for_args(args: ArgsContainer, cli_base_url: str | None) -> bool:
|
|
109
|
+
"""Suppress ambient credentials when base_url came from config, not CLI.
|
|
110
|
+
|
|
111
|
+
Explicit config/CLI token/auth/header values remain present on *args* and are
|
|
112
|
+
passed through by callers. This helper only controls environment fallback.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
config_base_url = getattr(args, "_config", {}).get("base_url")
|
|
116
|
+
return not (cli_base_url is None and config_base_url is not None)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Shared Cyclopts option annotations for Tangle CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from cyclopts import Parameter
|
|
8
|
+
|
|
9
|
+
from .api_transport import DEFAULT_API_URL
|
|
10
|
+
|
|
11
|
+
BaseUrlOption = Annotated[
|
|
12
|
+
str | None,
|
|
13
|
+
Parameter(
|
|
14
|
+
help=(
|
|
15
|
+
"Tangle API base URL. Defaults to TANGLE_API_URL, then "
|
|
16
|
+
f"{DEFAULT_API_URL}."
|
|
17
|
+
)
|
|
18
|
+
),
|
|
19
|
+
]
|
|
20
|
+
TokenOption = Annotated[
|
|
21
|
+
str | None,
|
|
22
|
+
Parameter(help="Bearer token. Defaults to TANGLE_API_TOKEN."),
|
|
23
|
+
]
|
|
24
|
+
AuthHeaderOption = Annotated[
|
|
25
|
+
str | None,
|
|
26
|
+
Parameter(
|
|
27
|
+
help=(
|
|
28
|
+
"Authorization header value, e.g. 'Bearer TOKEN' or 'Basic BASE64'. "
|
|
29
|
+
"Defaults to TANGLE_API_AUTH_HEADER or TANGLE_AUTH_HEADER."
|
|
30
|
+
)
|
|
31
|
+
),
|
|
32
|
+
]
|
|
33
|
+
HeaderOption = Annotated[
|
|
34
|
+
list[str] | None,
|
|
35
|
+
Parameter(
|
|
36
|
+
name="--header",
|
|
37
|
+
alias="-H",
|
|
38
|
+
help=(
|
|
39
|
+
"Custom request header as 'Name: value'. Repeat for multiple. "
|
|
40
|
+
"Applied after TANGLE_API_HEADERS."
|
|
41
|
+
),
|
|
42
|
+
negative_iterable=(),
|
|
43
|
+
),
|
|
44
|
+
]
|
|
45
|
+
ConfigOption = Annotated[
|
|
46
|
+
str | None,
|
|
47
|
+
Parameter(help="YAML/JSON config file providing command defaults."),
|
|
48
|
+
]
|
|
49
|
+
LogTypeOption = Annotated[
|
|
50
|
+
str,
|
|
51
|
+
Parameter(help="Log output: console, none, file."),
|
|
52
|
+
]
|