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