ixt-cli 0.8.0__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 (84) hide show
  1. ixt/__init__.py +8 -0
  2. ixt/__main__.py +8 -0
  3. ixt/backends/__init__.py +1 -0
  4. ixt/backends/binary.py +935 -0
  5. ixt/backends/binary_resolver.py +307 -0
  6. ixt/backends/node.py +490 -0
  7. ixt/backends/python.py +234 -0
  8. ixt/cli/__init__.py +31 -0
  9. ixt/cli/argparse_completion.py +557 -0
  10. ixt/cli/cmd_apply.py +404 -0
  11. ixt/cli/cmd_cache.py +86 -0
  12. ixt/cli/cmd_config.py +295 -0
  13. ixt/cli/cmd_info.py +116 -0
  14. ixt/cli/cmd_install.py +508 -0
  15. ixt/cli/cmd_misc.py +261 -0
  16. ixt/cli/cmd_registry.py +35 -0
  17. ixt/cli/cmd_upgrade.py +336 -0
  18. ixt/cli/commands.py +70 -0
  19. ixt/cli/parser.py +555 -0
  20. ixt/cli/render.py +85 -0
  21. ixt/config/__init__.py +5 -0
  22. ixt/config/asset_index.py +305 -0
  23. ixt/config/asset_pattern_cache.py +87 -0
  24. ixt/config/env_policy.py +340 -0
  25. ixt/config/flags.py +29 -0
  26. ixt/config/fs_policy.py +17 -0
  27. ixt/config/heuristics.py +465 -0
  28. ixt/config/models.py +176 -0
  29. ixt/config/registry.py +145 -0
  30. ixt/config/settings.py +173 -0
  31. ixt/config/setup_toml.py +179 -0
  32. ixt/config/toml.py +416 -0
  33. ixt/core/__init__.py +16 -0
  34. ixt/core/apply.py +564 -0
  35. ixt/core/apply_actions.py +106 -0
  36. ixt/core/backend.py +187 -0
  37. ixt/core/bootstrap.py +410 -0
  38. ixt/core/cache.py +332 -0
  39. ixt/core/discover.py +150 -0
  40. ixt/core/doctor.py +591 -0
  41. ixt/core/export.py +419 -0
  42. ixt/core/expose.py +350 -0
  43. ixt/core/extract.py +261 -0
  44. ixt/core/hooks.py +182 -0
  45. ixt/core/identity.py +148 -0
  46. ixt/core/inject.py +143 -0
  47. ixt/core/install.py +509 -0
  48. ixt/core/install_local.py +229 -0
  49. ixt/core/locks.py +54 -0
  50. ixt/core/pathlink.py +86 -0
  51. ixt/core/resolution_stats.py +191 -0
  52. ixt/core/resolve.py +150 -0
  53. ixt/core/resolve_cache.py +185 -0
  54. ixt/core/runtimes.py +192 -0
  55. ixt/core/save.py +237 -0
  56. ixt/core/setup_completions.py +11 -0
  57. ixt/core/setup_path.py +368 -0
  58. ixt/core/upgrade.py +596 -0
  59. ixt/data/__init__.py +10 -0
  60. ixt/data/asset_index.json +574 -0
  61. ixt/data/heuristics.toml +98 -0
  62. ixt/data/registry.toml +71 -0
  63. ixt/libs/__init__.py +3 -0
  64. ixt/libs/constants.py +4 -0
  65. ixt/libs/fmt.py +108 -0
  66. ixt/libs/logger.py +109 -0
  67. ixt/libs/output.py +25 -0
  68. ixt/libs/req_spec.py +115 -0
  69. ixt/libs/semver.py +149 -0
  70. ixt/libs/shell.py +126 -0
  71. ixt/libs/style.py +238 -0
  72. ixt/net/__init__.py +1 -0
  73. ixt/net/github_api.py +158 -0
  74. ixt/net/gitlab_api.py +149 -0
  75. ixt/net/http.py +194 -0
  76. ixt/net/npm.py +24 -0
  77. ixt/net/pypi.py +26 -0
  78. ixt/net/source.py +163 -0
  79. ixt/platform/__init__.py +131 -0
  80. ixt/platform/win.py +68 -0
  81. ixt_cli-0.8.0.dist-info/METADATA +294 -0
  82. ixt_cli-0.8.0.dist-info/RECORD +84 -0
  83. ixt_cli-0.8.0.dist-info/WHEEL +4 -0
  84. ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
ixt/core/cache.py ADDED
@@ -0,0 +1,332 @@
1
+ """Cache storage helpers for ixt.
2
+
3
+ This module owns operational cache actions. ``doctor`` can report cache
4
+ health, but cache mutation lives here and behind ``ixt cache``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import shutil
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ixt.config.settings import Settings, get_settings
19
+
20
+ DOWNLOADS_INDEX_FILENAME = "downloads.json"
21
+ DOWNLOADS_INDEX_SCHEMA = 1
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class SizeEntry:
26
+ """Size and file count for one storage location."""
27
+
28
+ name: str
29
+ path: Path
30
+ files: int
31
+ bytes: int
32
+
33
+
34
+ @dataclass(slots=True)
35
+ class ClearResult:
36
+ """Outcome of a cache-clear action."""
37
+
38
+ target: str
39
+ path: Path
40
+ removed_count: int
41
+ removed_bytes: int
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class PruneResult:
46
+ """Outcome of pruning indexed download artifacts."""
47
+
48
+ kept_count: int
49
+ removed_count: int
50
+ removed_bytes: int
51
+ stale_entries: int
52
+
53
+
54
+ def humanize_size(size: int) -> str:
55
+ """Return a compact human-readable size."""
56
+ value: float = float(size)
57
+ for unit in ("B", "KB", "MB", "GB"):
58
+ if value < 1024 or unit == "GB":
59
+ return f"{int(value)} B" if unit == "B" else f"{value:.1f} {unit}"
60
+ value /= 1024
61
+ return f"{int(value)} B"
62
+
63
+
64
+ def count_and_size(path: Path) -> tuple[int, int]:
65
+ """Return ``(file_count, total_bytes)`` for *path*.
66
+
67
+ Missing paths count as empty. A direct file path counts as one file.
68
+ """
69
+ if not path.exists():
70
+ return 0, 0
71
+ try:
72
+ if path.is_file() or path.is_symlink():
73
+ return 1, path.stat().st_size
74
+ except OSError:
75
+ return 0, 0
76
+
77
+ count = 0
78
+ total = 0
79
+ for entry in path.rglob("*"):
80
+ try:
81
+ if entry.is_file():
82
+ count += 1
83
+ total += entry.stat().st_size
84
+ except OSError:
85
+ continue
86
+ return count, total
87
+
88
+
89
+ def storage_sizes(*, settings: Settings | None = None) -> list[SizeEntry]:
90
+ """Return the main ixt storage locations with sizes."""
91
+ settings = settings or get_settings()
92
+ paths = [
93
+ ("Home", settings.home),
94
+ ("Config", settings.config_dir),
95
+ ("Installed", settings.installed_dir),
96
+ ("Bin", settings.bin_dir),
97
+ ("Envs", settings.envs_dir),
98
+ ("Runtimes", settings.runtimes_dir),
99
+ ("Cache", settings.cache_home),
100
+ ("Downloads", settings.downloads_dir),
101
+ ("Metadata", settings.metadata_dir),
102
+ ("Tmp", settings.tmp_dir),
103
+ ]
104
+ return [_size_entry(name, path) for name, path in paths]
105
+
106
+
107
+ def cache_sizes(*, settings: Settings | None = None) -> list[SizeEntry]:
108
+ """Return cache-only locations with sizes."""
109
+ settings = settings or get_settings()
110
+ return [
111
+ _size_entry("Downloads", settings.downloads_dir),
112
+ _size_entry("Metadata", settings.metadata_dir),
113
+ _size_entry("Tmp", settings.tmp_dir),
114
+ ]
115
+
116
+
117
+ def _size_entry(name: str, path: Path) -> SizeEntry:
118
+ files, bytes_ = count_and_size(path)
119
+ return SizeEntry(name=name, path=path, files=files, bytes=bytes_)
120
+
121
+
122
+ def clear_caches(target: str, *, settings: Settings | None = None) -> list[ClearResult]:
123
+ """Clear the requested cache(s).
124
+
125
+ *target* is one of ``"downloads"``, ``"metadata"``, ``"tmp"``, or
126
+ ``"all"``. This never touches installed tools or user config.
127
+ """
128
+ settings = settings or get_settings()
129
+ targets: dict[str, Path] = {
130
+ "downloads": settings.downloads_dir,
131
+ "metadata": settings.metadata_dir,
132
+ "tmp": settings.tmp_dir,
133
+ }
134
+ if target != "all" and target not in targets:
135
+ raise ValueError(f"Unknown cache target: {target}")
136
+
137
+ selected = targets.items() if target == "all" else [(target, targets[target])]
138
+ results: list[ClearResult] = []
139
+ for name, path in selected:
140
+ count, size = count_and_size(path)
141
+ if path.exists():
142
+ shutil.rmtree(path)
143
+ results.append(ClearResult(name, path, count, size))
144
+ return results
145
+
146
+
147
+ def downloads_index_path(*, settings: Settings | None = None) -> Path:
148
+ """Return the metadata file that indexes downloaded artifacts."""
149
+ settings = settings or get_settings()
150
+ return settings.metadata_dir / DOWNLOADS_INDEX_FILENAME
151
+
152
+
153
+ def record_download(
154
+ *,
155
+ settings: Settings | None = None,
156
+ kind: str,
157
+ platform: str,
158
+ host: str,
159
+ owner: str,
160
+ repo: str,
161
+ tag: str,
162
+ asset_name: str,
163
+ asset_url: str,
164
+ asset_size: int,
165
+ path: Path,
166
+ ) -> None:
167
+ """Record a downloaded artifact in the programmatic cache index."""
168
+ settings = settings or get_settings()
169
+ index = _load_downloads_index(settings=settings)
170
+ relative_path = _safe_relative(path, settings.cache_home)
171
+ entry = {
172
+ "kind": kind,
173
+ "platform": platform,
174
+ "host": host,
175
+ "owner": owner,
176
+ "repo": repo,
177
+ "tag": tag,
178
+ "version": _version_from_tag(tag),
179
+ "asset": asset_name,
180
+ "asset_size": int(asset_size),
181
+ "path": relative_path,
182
+ "url": asset_url,
183
+ "downloaded_at": _now_utc(),
184
+ }
185
+
186
+ entries = index["entries"]
187
+ entries[:] = [
188
+ existing
189
+ for existing in entries
190
+ if not (
191
+ existing.get("kind") == kind
192
+ and existing.get("platform") == platform
193
+ and existing.get("host") == host
194
+ and existing.get("owner") == owner
195
+ and existing.get("repo") == repo
196
+ and existing.get("tag") == tag
197
+ and existing.get("asset") == asset_name
198
+ )
199
+ ]
200
+ entries.append(entry)
201
+ _write_downloads_index(index, settings=settings)
202
+
203
+
204
+ def prune_downloads(*, settings: Settings | None = None, keep: int = 2) -> PruneResult:
205
+ """Prune indexed download artifacts, keeping newest *keep* per repo.
206
+
207
+ Only files present in ``downloads.json`` are candidates. Unindexed files
208
+ are left alone, which keeps the operation conservative.
209
+ """
210
+ if keep < 1:
211
+ raise ValueError("keep must be >= 1")
212
+
213
+ settings = settings or get_settings()
214
+ index = _load_downloads_index(settings=settings)
215
+ groups: dict[tuple[str, str, str, str, str], list[tuple[dict[str, Any], Path]]] = {}
216
+ stale_entries = 0
217
+
218
+ for entry in index["entries"]:
219
+ path = _entry_path(entry, settings=settings)
220
+ if path is None or not path.exists():
221
+ stale_entries += 1
222
+ continue
223
+ groups.setdefault(_group_key(entry), []).append((entry, path))
224
+
225
+ kept_entries: list[dict[str, Any]] = []
226
+ removed_count = 0
227
+ removed_bytes = 0
228
+
229
+ for items in groups.values():
230
+ sortable = [item for item in items if isinstance(item[0].get("downloaded_at"), str)]
231
+ unsortable = [item for item in items if not isinstance(item[0].get("downloaded_at"), str)]
232
+ sortable.sort(key=lambda item: item[0]["downloaded_at"], reverse=True)
233
+
234
+ kept = sortable[:keep] + unsortable
235
+ to_remove = sortable[keep:]
236
+ kept_entries.extend(entry for entry, _path in kept)
237
+
238
+ for entry, path in to_remove:
239
+ count, size = count_and_size(path)
240
+ try:
241
+ if path.is_dir() and not path.is_symlink():
242
+ shutil.rmtree(path)
243
+ else:
244
+ path.unlink(missing_ok=True)
245
+ except OSError:
246
+ kept_entries.append(entry)
247
+ continue
248
+ removed_count += count
249
+ removed_bytes += size
250
+
251
+ _write_downloads_index(
252
+ {"schema": DOWNLOADS_INDEX_SCHEMA, "entries": kept_entries},
253
+ settings=settings,
254
+ )
255
+ return PruneResult(
256
+ kept_count=len(kept_entries),
257
+ removed_count=removed_count,
258
+ removed_bytes=removed_bytes,
259
+ stale_entries=stale_entries,
260
+ )
261
+
262
+
263
+ def _load_downloads_index(*, settings: Settings) -> dict[str, Any]:
264
+ path = downloads_index_path(settings=settings)
265
+ try:
266
+ with path.open(encoding="utf-8") as f:
267
+ data = json.load(f)
268
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
269
+ return {"schema": DOWNLOADS_INDEX_SCHEMA, "entries": []}
270
+ if not isinstance(data, dict):
271
+ return {"schema": DOWNLOADS_INDEX_SCHEMA, "entries": []}
272
+ entries = data.get("entries", [])
273
+ if not isinstance(entries, list):
274
+ entries = []
275
+ data["schema"] = DOWNLOADS_INDEX_SCHEMA
276
+ data["entries"] = [entry for entry in entries if isinstance(entry, dict)]
277
+ return data
278
+
279
+
280
+ def _write_downloads_index(data: dict[str, Any], *, settings: Settings) -> None:
281
+ path = downloads_index_path(settings=settings)
282
+ path.parent.mkdir(parents=True, exist_ok=True)
283
+ fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
284
+ try:
285
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
286
+ json.dump(data, f, indent=2, sort_keys=True)
287
+ f.write("\n")
288
+ os.replace(tmp, path)
289
+ except Exception:
290
+ Path(tmp).unlink(missing_ok=True)
291
+ raise
292
+
293
+
294
+ def _entry_path(entry: dict[str, Any], *, settings: Settings) -> Path | None:
295
+ raw = entry.get("path")
296
+ if not isinstance(raw, str) or not raw:
297
+ return None
298
+ path = Path(raw)
299
+ if not path.is_absolute():
300
+ path = settings.cache_home / path
301
+ try:
302
+ path.resolve().relative_to(settings.cache_home.resolve())
303
+ except ValueError:
304
+ return None
305
+ return path
306
+
307
+
308
+ def _group_key(entry: dict[str, Any]) -> tuple[str, str, str, str, str]:
309
+ return (
310
+ str(entry.get("kind", "")),
311
+ str(entry.get("platform", "")),
312
+ str(entry.get("host", "")),
313
+ str(entry.get("owner", "")),
314
+ str(entry.get("repo", "")),
315
+ )
316
+
317
+
318
+ def _safe_relative(path: Path, base: Path) -> str:
319
+ try:
320
+ return str(path.resolve().relative_to(base.resolve()))
321
+ except ValueError:
322
+ return str(path)
323
+
324
+
325
+ def _version_from_tag(tag: str) -> str:
326
+ if tag[:1].lower() == "v":
327
+ return tag[1:]
328
+ return tag
329
+
330
+
331
+ def _now_utc() -> str:
332
+ return datetime.now(timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
ixt/core/discover.py ADDED
@@ -0,0 +1,150 @@
1
+ """Executable discovery in extracted binary environments.
2
+
3
+ Scans extracted directories for executable files, filtering out
4
+ non-executable content using heuristics.toml expose rules.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fnmatch
10
+ import os
11
+ import re
12
+ import stat
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from ixt.config.heuristics import ExposeConfig, load_heuristics
17
+
18
+ if TYPE_CHECKING:
19
+ from ixt.libs.logger import Logger
20
+
21
+
22
+ _PATH_SEPARATOR_RE = re.compile(r"[\\/]+")
23
+ _TOKEN_SEPARATOR_RE = re.compile(r"[\\/._-]+")
24
+ _MAN_PATH_COMPONENT_RE = re.compile(r"man(?:[0-9][a-z0-9]*|[nl])?$", re.IGNORECASE)
25
+ _DOC_COMPONENT_KEYWORDS = frozenset(
26
+ {"license", "readme", "changelog", "notice", "copying", "dist-manifest"}
27
+ )
28
+ _TOKEN_KEYWORDS = frozenset({"completion", "completions", "autocomplete"})
29
+
30
+
31
+ def _is_executable(path: Path) -> bool:
32
+ """Return True if *path* is a regular file with an executable bit set."""
33
+ try:
34
+ st = path.stat()
35
+ except OSError:
36
+ return False
37
+ if not stat.S_ISREG(st.st_mode):
38
+ return False
39
+ return bool(st.st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH))
40
+
41
+
42
+ def _should_skip(name: str, expose: ExposeConfig, rel_path: str = "") -> bool:
43
+ """Return True if *name* (or its relative path) should be filtered out by expose rules."""
44
+ lower = name.lower()
45
+ lower_path = rel_path.lower() if rel_path else lower
46
+
47
+ # Skip known runtime names
48
+ if lower in (n.lower() for n in expose.skip_names):
49
+ return True
50
+
51
+ # Skip by glob pattern (*.md, *.txt, etc.) — matched against filename only
52
+ for pat in expose.skip_patterns:
53
+ if fnmatch.fnmatch(lower, pat.lower()):
54
+ return True
55
+
56
+ # Skip by keyword, using tighter rules for the built-in archive metadata names.
57
+ for kw in expose.skip_keywords:
58
+ if _keyword_matches_path(kw, lower_path):
59
+ return True
60
+
61
+ return False
62
+
63
+
64
+ def _keyword_matches_path(keyword: str, lower_path: str) -> bool:
65
+ """Return True if an expose skip keyword matches *lower_path*."""
66
+ lower_keyword = keyword.lower()
67
+ components = _path_components(lower_path)
68
+
69
+ # "man" is meant to catch man-page directories such as man/ or man1/,
70
+ # not binaries whose names merely contain those letters.
71
+ if lower_keyword == "man":
72
+ return any(_MAN_PATH_COMPONENT_RE.fullmatch(component) for component in components[:-1])
73
+
74
+ if lower_keyword in _DOC_COMPONENT_KEYWORDS:
75
+ return any(
76
+ component == lower_keyword or _component_stem(component) == lower_keyword
77
+ for component in components
78
+ )
79
+
80
+ if lower_keyword in _TOKEN_KEYWORDS:
81
+ return lower_keyword in _path_tokens(lower_path)
82
+
83
+ return lower_keyword in lower_path
84
+
85
+
86
+ def _path_components(lower_path: str) -> list[str]:
87
+ """Split a lowercase relative path into non-empty directory/file components."""
88
+ return [component for component in _PATH_SEPARATOR_RE.split(lower_path) if component]
89
+
90
+
91
+ def _path_tokens(lower_path: str) -> list[str]:
92
+ """Split a lowercase relative path into non-empty separator-delimited tokens."""
93
+ return [token for token in _TOKEN_SEPARATOR_RE.split(lower_path) if token]
94
+
95
+
96
+ def _component_stem(component: str) -> str:
97
+ """Return the filename stem using the first dot as metadata extension boundary."""
98
+ return component.split(".", 1)[0]
99
+
100
+
101
+ def find_executables(
102
+ root: Path,
103
+ *,
104
+ expose: ExposeConfig | None = None,
105
+ max_depth: int = 3,
106
+ log: Logger | None = None,
107
+ ) -> list[Path]:
108
+ """Find executable files in *root*, filtered by expose rules.
109
+
110
+ Walks the directory tree up to *max_depth* levels, looking for
111
+ executable files that pass the heuristics.toml expose filters.
112
+
113
+ Args:
114
+ root: Directory to scan (typically the extraction root).
115
+ expose: Expose config to use. Loads defaults if None.
116
+ max_depth: Maximum directory depth to scan (0 = root only).
117
+ log: Optional logger; emits debug lines for skipped files.
118
+
119
+ Returns:
120
+ List of absolute paths to discovered executables, sorted by name.
121
+ """
122
+ if expose is None:
123
+ expose = load_heuristics().expose
124
+
125
+ executables: list[Path] = []
126
+ root_depth = len(root.parts)
127
+
128
+ for dirpath, dirnames, filenames in os.walk(root):
129
+ current = Path(dirpath)
130
+ depth = len(current.parts) - root_depth
131
+
132
+ if depth >= max_depth:
133
+ dirnames.clear() # stop descending
134
+
135
+ for fname in filenames:
136
+ fpath = current / fname
137
+
138
+ if not _is_executable(fpath):
139
+ continue
140
+
141
+ rel = str(fpath.relative_to(root))
142
+ if _should_skip(fname, expose, rel_path=rel):
143
+ if log:
144
+ log.debug(f"expose skip: {rel}")
145
+ continue
146
+
147
+ executables.append(fpath)
148
+
149
+ executables.sort(key=lambda p: p.name)
150
+ return executables