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,494 @@
|
|
|
1
|
+
"""Component inspection helpers for OpenAPI-backed Tangle clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
from urllib.parse import urljoin, urlparse
|
|
8
|
+
from weakref import WeakKeyDictionary
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from tangle_cli.handler import TangleCliHandler
|
|
13
|
+
from tangle_cli.models import ComponentInfo, ComponentSpec
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from tangle_cli.client import TangleApiClient
|
|
17
|
+
|
|
18
|
+
# ============================================================================
|
|
19
|
+
# Client helpers
|
|
20
|
+
# ============================================================================
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _request_path(client: TangleApiClient, path: str) -> Any:
|
|
24
|
+
"""Fetch an API-origin path using the client's auth settings.
|
|
25
|
+
|
|
26
|
+
``component_library.yaml`` is not guaranteed to be represented as an
|
|
27
|
+
OpenAPI operation, but it is served from the same origin as the API. This
|
|
28
|
+
helper preserves the static client's base URL, session, and auth/header
|
|
29
|
+
precedence even though the library YAML is outside the OpenAPI schema.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
custom_request_path = getattr(client, "request_path", None)
|
|
33
|
+
if callable(custom_request_path):
|
|
34
|
+
response = custom_request_path(path)
|
|
35
|
+
else:
|
|
36
|
+
response = client._make_request("GET", path)
|
|
37
|
+
response.raise_for_status()
|
|
38
|
+
return response
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _published_components(
|
|
42
|
+
client: TangleApiClient,
|
|
43
|
+
*,
|
|
44
|
+
include_deprecated: bool = False,
|
|
45
|
+
name_substring: str | None = None,
|
|
46
|
+
published_by_substring: str | None = None,
|
|
47
|
+
digest: str | None = None,
|
|
48
|
+
) -> list[ComponentInfo]:
|
|
49
|
+
return client.list_published_component_infos(
|
|
50
|
+
include_deprecated=include_deprecated,
|
|
51
|
+
name_substring=name_substring,
|
|
52
|
+
published_by_substring=published_by_substring,
|
|
53
|
+
digest=digest,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_digest(client: TangleApiClient, digest: str) -> str:
|
|
58
|
+
current = digest
|
|
59
|
+
seen: set[str] = set()
|
|
60
|
+
|
|
61
|
+
while True:
|
|
62
|
+
if current in seen:
|
|
63
|
+
break
|
|
64
|
+
seen.add(current)
|
|
65
|
+
|
|
66
|
+
published = _published_components(
|
|
67
|
+
client, digest=current, include_deprecated=True
|
|
68
|
+
)
|
|
69
|
+
if not published:
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
meta = published[0]
|
|
73
|
+
if not meta.deprecated:
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
superseded_by = meta.superseded_by
|
|
77
|
+
if not superseded_by:
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
current = superseded_by
|
|
81
|
+
|
|
82
|
+
return current
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ============================================================================
|
|
86
|
+
# Standard component library cache
|
|
87
|
+
# ============================================================================
|
|
88
|
+
|
|
89
|
+
_COMPONENT_LIBRARY_PATH = "/component_library.yaml"
|
|
90
|
+
|
|
91
|
+
# Component libraries are fetched through authenticated clients and may differ by
|
|
92
|
+
# base URL or caller. Cache by client identity so long-lived processes can safely
|
|
93
|
+
# use multiple Tangle sessions without leaking library entries across them.
|
|
94
|
+
_LibraryState = tuple[dict[str, Any], dict[str, dict[str, Any]]]
|
|
95
|
+
_component_libraries_by_client: WeakKeyDictionary[Any, _LibraryState] = WeakKeyDictionary()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _library_fetch_path(client: TangleApiClient, url: str) -> str | None:
|
|
99
|
+
"""Return a same-origin path for a component-library URL, or None.
|
|
100
|
+
|
|
101
|
+
The component library is supplied by the Tangle API origin. Treat URLs
|
|
102
|
+
inside it as untrusted input: relative and same-origin URLs are okay, but
|
|
103
|
+
cross-origin URLs would make the CLI issue arbitrary outbound requests from
|
|
104
|
+
the operator's workstation/CI runner.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
base = client.base_url.rstrip("/") + "/"
|
|
108
|
+
base_parts = urlparse(base)
|
|
109
|
+
target_parts = urlparse(urljoin(base, url))
|
|
110
|
+
if target_parts.scheme != base_parts.scheme or target_parts.netloc != base_parts.netloc:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
path = target_parts.path or "/"
|
|
114
|
+
if target_parts.query:
|
|
115
|
+
path = f"{path}?{target_parts.query}"
|
|
116
|
+
return path
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _fetch_library_component(client: TangleApiClient, url: str) -> ComponentSpec:
|
|
120
|
+
path = _library_fetch_path(client, url)
|
|
121
|
+
if path is None:
|
|
122
|
+
return ComponentSpec()
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
response = _request_path(client, path)
|
|
126
|
+
return ComponentSpec.from_yaml(response.text)
|
|
127
|
+
except Exception:
|
|
128
|
+
return ComponentSpec()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_component_library(raw: dict[str, Any], client: TangleApiClient) -> dict[str, Any]:
|
|
132
|
+
"""Parse the component library YAML into entries with full specs.
|
|
133
|
+
|
|
134
|
+
Each entry has ``url``, ``digest``, and ``spec`` (full, unstripped). Use
|
|
135
|
+
:func:`_strip_entry` to produce a lightweight view on demand.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
result: dict[str, Any] = {"folders": []}
|
|
139
|
+
for folder in raw.get("folders", []):
|
|
140
|
+
parsed_components = []
|
|
141
|
+
for comp in folder.get("components", []):
|
|
142
|
+
entry: dict[str, Any] = {}
|
|
143
|
+
url = comp.get("url")
|
|
144
|
+
if url:
|
|
145
|
+
entry["url"] = url
|
|
146
|
+
|
|
147
|
+
component = ComponentSpec.from_dict(comp)
|
|
148
|
+
|
|
149
|
+
# Fall back to URL fetch if no spec resolved. URLs come from the
|
|
150
|
+
# server-supplied component_library.yaml, so only fetch relative or
|
|
151
|
+
# same-origin URLs through the configured API client.
|
|
152
|
+
if not component.data and url:
|
|
153
|
+
component = _fetch_library_component(client, url)
|
|
154
|
+
|
|
155
|
+
if component.data:
|
|
156
|
+
component.ensure_digest()
|
|
157
|
+
entry["spec"] = component.data
|
|
158
|
+
if component.digest:
|
|
159
|
+
entry["digest"] = component.digest
|
|
160
|
+
parsed_components.append(entry)
|
|
161
|
+
result["folders"].append({"name": folder.get("name"), "components": parsed_components})
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _strip_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
|
166
|
+
"""Return a copy of a library entry with the spec stripped of bulky fields."""
|
|
167
|
+
|
|
168
|
+
spec = ComponentSpec(data=entry.get("spec") or {})
|
|
169
|
+
result = {k: v for k, v in entry.items() if k != "spec"}
|
|
170
|
+
result["spec"] = spec.stripped_spec
|
|
171
|
+
return result
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _ensure_library_loaded(client: TangleApiClient) -> _LibraryState:
|
|
175
|
+
"""Fetch and cache the component library for this client if needed."""
|
|
176
|
+
|
|
177
|
+
cached = _component_libraries_by_client.get(client)
|
|
178
|
+
if cached is not None:
|
|
179
|
+
return cached
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
response = _request_path(client, _COMPONENT_LIBRARY_PATH)
|
|
183
|
+
raw = yaml.safe_load(response.text)
|
|
184
|
+
parsed = _parse_component_library(raw, client)
|
|
185
|
+
cache: dict[str, dict[str, Any]] = {}
|
|
186
|
+
for folder in parsed.get("folders", []):
|
|
187
|
+
for comp in folder.get("components", []):
|
|
188
|
+
name = (comp.get("spec") or {}).get("name", "")
|
|
189
|
+
if name:
|
|
190
|
+
cache[name.lower()] = comp
|
|
191
|
+
state = (parsed, cache)
|
|
192
|
+
except Exception:
|
|
193
|
+
state = ({"folders": []}, {})
|
|
194
|
+
|
|
195
|
+
_component_libraries_by_client[client] = state
|
|
196
|
+
return state
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ComponentInspector(TangleCliHandler):
|
|
200
|
+
"""Inspector for published component metadata and specs.
|
|
201
|
+
|
|
202
|
+
Generic inspection and search behavior lives here. Downstreams provide an
|
|
203
|
+
authenticated client by subclassing the shared handler or by passing
|
|
204
|
+
``client=`` explicitly in tests/callers.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def get_standard_library(self) -> dict[str, Any]:
|
|
208
|
+
"""Return the standard component library organised by folders.
|
|
209
|
+
|
|
210
|
+
Each component entry has a stripped spec (no implementation blocks), an
|
|
211
|
+
optional ``url``, and a ``digest``.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
library_full, _ = _ensure_library_loaded(self._require_client())
|
|
215
|
+
return {
|
|
216
|
+
"folders": [
|
|
217
|
+
{
|
|
218
|
+
"name": folder.get("name"),
|
|
219
|
+
"components": [_strip_entry(comp) for comp in folder.get("components", [])],
|
|
220
|
+
}
|
|
221
|
+
for folder in library_full.get("folders", [])
|
|
222
|
+
],
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
def inspect_by_digest(
|
|
226
|
+
self,
|
|
227
|
+
digest: str,
|
|
228
|
+
full_spec: bool = False,
|
|
229
|
+
follow_deprecated: bool = False,
|
|
230
|
+
) -> dict[str, Any]:
|
|
231
|
+
"""Inspect a single component by digest.
|
|
232
|
+
|
|
233
|
+
Fetches the full spec and publication metadata via static client helpers.
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
client = self._require_client()
|
|
237
|
+
if follow_deprecated:
|
|
238
|
+
resolved = _resolve_digest(client, digest)
|
|
239
|
+
if resolved != digest:
|
|
240
|
+
digest = resolved
|
|
241
|
+
|
|
242
|
+
comp = _get_component_spec(client, digest)
|
|
243
|
+
if comp is None:
|
|
244
|
+
_, library_cache = _ensure_library_loaded(client)
|
|
245
|
+
for entry in library_cache.values():
|
|
246
|
+
if entry.get("digest") == digest:
|
|
247
|
+
out = _strip_entry(entry) if not full_spec else dict(entry)
|
|
248
|
+
return {
|
|
249
|
+
"status": "success",
|
|
250
|
+
"source": "component_library",
|
|
251
|
+
"transparent": True,
|
|
252
|
+
"transparency_reason": "curated standard component from the component library",
|
|
253
|
+
"name": (entry.get("spec") or {}).get("name", ""),
|
|
254
|
+
**out,
|
|
255
|
+
}
|
|
256
|
+
return {"status": "not_found", "digest": digest, "error": f"Component not found: {digest}"}
|
|
257
|
+
|
|
258
|
+
published = _published_components(client, digest=digest, include_deprecated=True)
|
|
259
|
+
pub_info = published[0] if published else None
|
|
260
|
+
|
|
261
|
+
if pub_info:
|
|
262
|
+
info = pub_info
|
|
263
|
+
else:
|
|
264
|
+
info = ComponentInfo(digest=digest)
|
|
265
|
+
info.version = comp.version if comp else None
|
|
266
|
+
|
|
267
|
+
info.component_spec = comp
|
|
268
|
+
_backfill_version_from_spec(info)
|
|
269
|
+
|
|
270
|
+
result: dict[str, Any] = {"status": "success"}
|
|
271
|
+
if not pub_info:
|
|
272
|
+
result["published"] = False
|
|
273
|
+
if comp:
|
|
274
|
+
result["name"] = comp.name
|
|
275
|
+
transparent, transparency_reason = self.transparency_check(comp)
|
|
276
|
+
result["transparent"] = transparent
|
|
277
|
+
result["transparency_reason"] = transparency_reason
|
|
278
|
+
result.update(info.to_dict(strip_spec=not full_spec))
|
|
279
|
+
if comp:
|
|
280
|
+
git_source = _resolve_git_source(comp)
|
|
281
|
+
if git_source:
|
|
282
|
+
result["source"] = git_source
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
def inspect_by_name(
|
|
286
|
+
self,
|
|
287
|
+
name: str,
|
|
288
|
+
include_all_versions: bool = False,
|
|
289
|
+
include_deprecated: bool = False,
|
|
290
|
+
full_spec: bool = False,
|
|
291
|
+
published_by: str | None = None,
|
|
292
|
+
) -> dict[str, Any]:
|
|
293
|
+
"""Inspect component(s) by name."""
|
|
294
|
+
|
|
295
|
+
client = self._require_client()
|
|
296
|
+
published = _published_components(
|
|
297
|
+
client,
|
|
298
|
+
name_substring=name,
|
|
299
|
+
include_deprecated=include_deprecated,
|
|
300
|
+
published_by_substring=published_by,
|
|
301
|
+
)
|
|
302
|
+
published = [c for c in published if str(c.name or "").lower() == name.lower()]
|
|
303
|
+
|
|
304
|
+
if not published:
|
|
305
|
+
_, library_cache = _ensure_library_loaded(client)
|
|
306
|
+
entry = library_cache.get(name.lower())
|
|
307
|
+
if entry:
|
|
308
|
+
out = _strip_entry(entry) if not full_spec else dict(entry)
|
|
309
|
+
return {
|
|
310
|
+
"status": "success",
|
|
311
|
+
"source": "component_library",
|
|
312
|
+
"transparent": True,
|
|
313
|
+
"transparency_reason": "curated standard component from the component library",
|
|
314
|
+
"name": name,
|
|
315
|
+
**out,
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
"status": "not_found",
|
|
319
|
+
"query": name,
|
|
320
|
+
"message": f"No published component found with name: {name}",
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
def _version_key(component: ComponentInfo) -> tuple[int, ...]:
|
|
324
|
+
"""Parse version string into numeric tuple for proper sorting."""
|
|
325
|
+
|
|
326
|
+
v = str(component.version or "0.0.1")
|
|
327
|
+
try:
|
|
328
|
+
return tuple(int(p) for p in v.split("."))
|
|
329
|
+
except ValueError:
|
|
330
|
+
return (0, 0, 1)
|
|
331
|
+
|
|
332
|
+
published.sort(key=_version_key, reverse=True)
|
|
333
|
+
|
|
334
|
+
if not include_all_versions:
|
|
335
|
+
published = published[:1]
|
|
336
|
+
|
|
337
|
+
versions: list[dict[str, Any]] = []
|
|
338
|
+
for info in published:
|
|
339
|
+
if info.digest:
|
|
340
|
+
try:
|
|
341
|
+
info.component_spec = _get_component_spec(client, info.digest)
|
|
342
|
+
_backfill_version_from_spec(info)
|
|
343
|
+
except Exception as e:
|
|
344
|
+
info.spec_error = str(e)
|
|
345
|
+
entry = info.to_dict(strip_spec=not full_spec)
|
|
346
|
+
if info.component_spec:
|
|
347
|
+
transparent, transparency_reason = self.transparency_check(info.component_spec)
|
|
348
|
+
entry["transparent"] = transparent
|
|
349
|
+
entry["transparency_reason"] = transparency_reason
|
|
350
|
+
git_source = _resolve_git_source(info.component_spec)
|
|
351
|
+
if git_source:
|
|
352
|
+
entry["source"] = git_source
|
|
353
|
+
versions.append(entry)
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
"status": "success",
|
|
357
|
+
"name": name,
|
|
358
|
+
"version_count": len(versions),
|
|
359
|
+
"versions": versions,
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
def search_components(
|
|
363
|
+
self,
|
|
364
|
+
name: str | None = None,
|
|
365
|
+
include_deprecated: bool = False,
|
|
366
|
+
published_by: str | None = None,
|
|
367
|
+
digest: str | None = None,
|
|
368
|
+
) -> dict[str, Any]:
|
|
369
|
+
"""Search for published components."""
|
|
370
|
+
|
|
371
|
+
components = _published_components(
|
|
372
|
+
self._require_client(),
|
|
373
|
+
name_substring=name,
|
|
374
|
+
include_deprecated=include_deprecated,
|
|
375
|
+
published_by_substring=published_by,
|
|
376
|
+
digest=digest,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
results = [
|
|
380
|
+
{
|
|
381
|
+
"name": comp.name,
|
|
382
|
+
"digest": comp.digest,
|
|
383
|
+
"version": comp.version,
|
|
384
|
+
"deprecated": comp.deprecated,
|
|
385
|
+
"description": (comp.description or "")[:200],
|
|
386
|
+
}
|
|
387
|
+
for comp in components
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
"status": "success",
|
|
392
|
+
"query": name,
|
|
393
|
+
"count": len(results),
|
|
394
|
+
"components": results,
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
@staticmethod
|
|
398
|
+
def transparency_check(spec: ComponentSpec) -> tuple[bool, str]:
|
|
399
|
+
"""Check if a component's definition is transparent (source-inspectable).
|
|
400
|
+
|
|
401
|
+
Returns a ``(transparent, reason)`` tuple. The *reason* is a short
|
|
402
|
+
human-readable explanation of **why** the component was classified as
|
|
403
|
+
transparent or opaque so that consuming agents can understand the decision
|
|
404
|
+
before applying their own judgment.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
ann = spec.annotations
|
|
408
|
+
|
|
409
|
+
if ann.get("python_original_code"):
|
|
410
|
+
return True, "inline Python source code embedded in annotations"
|
|
411
|
+
|
|
412
|
+
canonical = ann.get("canonical_location")
|
|
413
|
+
if isinstance(canonical, str) and canonical.startswith(("https://", "http://")):
|
|
414
|
+
return True, f"canonical_location annotation points to {canonical}"
|
|
415
|
+
|
|
416
|
+
if ann.get("git_remote_url") and (
|
|
417
|
+
ann.get("component_yaml_path") or ann.get("git_relative_dir")
|
|
418
|
+
):
|
|
419
|
+
return True, f"git source metadata links to {ann['git_remote_url']}"
|
|
420
|
+
|
|
421
|
+
impl = spec.implementation or {}
|
|
422
|
+
container = impl.get("container", {})
|
|
423
|
+
image = container.get("image", "")
|
|
424
|
+
if any(image.startswith(prefix) for prefix in _TRANSPARENT_IMAGE_PREFIXES):
|
|
425
|
+
return (
|
|
426
|
+
True,
|
|
427
|
+
f"uses standard public base image ({image})"
|
|
428
|
+
" — code logic is in the component definition, not hidden in the container",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return False, "no inline source, canonical location, git metadata, or standard public image found"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# ============================================================================
|
|
435
|
+
# Private function-level implementation helpers
|
|
436
|
+
# ============================================================================
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
_TRANSPARENT_IMAGE_PREFIXES = ("python:", "ubuntu:", "debian:", "alpine:")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _resolve_git_source(spec: ComponentSpec) -> dict[str, Any] | None:
|
|
443
|
+
"""Extract git annotations and resolve to GitHub URLs and local paths."""
|
|
444
|
+
|
|
445
|
+
annotations = spec.annotations
|
|
446
|
+
git_url = annotations.get("git_remote_url")
|
|
447
|
+
if not git_url:
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
sha = annotations.get("git_remote_sha", "")
|
|
451
|
+
branch = annotations.get("git_remote_branch", "main")
|
|
452
|
+
component_yaml = annotations.get("component_yaml_path")
|
|
453
|
+
docs_path = annotations.get("documentation_path")
|
|
454
|
+
dockerfile = annotations.get("dockerfile_path")
|
|
455
|
+
relative_dir = annotations.get("git_relative_dir")
|
|
456
|
+
|
|
457
|
+
repo_base = git_url.removesuffix(".git")
|
|
458
|
+
ref = sha or branch
|
|
459
|
+
|
|
460
|
+
def _full_path(rel_path: str) -> str:
|
|
461
|
+
"""Resolve a path relative to git_relative_dir into a git-root-relative path."""
|
|
462
|
+
|
|
463
|
+
if relative_dir:
|
|
464
|
+
return str(PurePosixPath(relative_dir, rel_path))
|
|
465
|
+
return str(PurePosixPath(rel_path))
|
|
466
|
+
|
|
467
|
+
source: dict[str, Any] = {}
|
|
468
|
+
if component_yaml:
|
|
469
|
+
source["component_yaml"] = f"{repo_base}/blob/{ref}/{_full_path(component_yaml)}"
|
|
470
|
+
if docs_path:
|
|
471
|
+
source["docs"] = f"{repo_base}/blob/{ref}/{_full_path(docs_path)}"
|
|
472
|
+
if dockerfile:
|
|
473
|
+
source["dockerfile"] = f"{repo_base}/blob/{ref}/{_full_path(dockerfile)}"
|
|
474
|
+
if relative_dir:
|
|
475
|
+
source["source_dir"] = f"{repo_base}/tree/{ref}/{relative_dir}"
|
|
476
|
+
|
|
477
|
+
return source if source else None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _backfill_version_from_spec(info: ComponentInfo) -> None:
|
|
481
|
+
"""Use attached component spec metadata when registry metadata omits version."""
|
|
482
|
+
|
|
483
|
+
if info.version is None and info.component_spec is not None:
|
|
484
|
+
info.version = info.component_spec.version
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _get_component_spec(client: TangleApiClient, digest: str) -> ComponentSpec | None:
|
|
488
|
+
try:
|
|
489
|
+
return client.get_component_spec(digest)
|
|
490
|
+
except Exception as exc:
|
|
491
|
+
response = getattr(exc, "response", None)
|
|
492
|
+
if getattr(response, "status_code", None) == 404:
|
|
493
|
+
return None
|
|
494
|
+
raise
|