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/resolve.py ADDED
@@ -0,0 +1,150 @@
1
+ """Version resolver — dispatches to backend-specific registry clients.
2
+
3
+ Consults the TTL cache first; on miss, performs the HTTP call and writes
4
+ back to the cache. Never raises on network errors: returns None so callers
5
+ (e.g. ``upgrade --dry-run``) can fail tool-by-tool.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from concurrent.futures import ThreadPoolExecutor, as_completed
11
+ from typing import TYPE_CHECKING, Final
12
+
13
+ from ixt.config.settings import Settings, get_settings
14
+ from ixt.core import resolve_cache
15
+ from ixt.core.backend import BackendType
16
+ from ixt.core.resolution_stats import (
17
+ record_github_latest_redirect,
18
+ resolution_phase,
19
+ )
20
+ from ixt.libs.logger import get_logger
21
+
22
+ if TYPE_CHECKING:
23
+ from ixt.net.source import Release
24
+
25
+ # Cap concurrent registry calls so `upgrade --all [--dry-run]` parallelizes
26
+ # without hammering PyPI / npm / GitHub.
27
+ _RESOLVE_MAX_WORKERS: Final = 3
28
+
29
+
30
+ def _resolve_python(name: str) -> tuple[str, str, list[dict] | None]:
31
+ from ixt.net import pypi
32
+
33
+ version = pypi.get_latest_version(name)
34
+ return version, pypi.latest_version_url(name), None
35
+
36
+
37
+ def _resolve_node(name: str) -> tuple[str, str, list[dict] | None]:
38
+ from ixt.net import npm
39
+
40
+ version = npm.get_latest_version(name)
41
+ return version, npm.latest_version_url(name), None
42
+
43
+
44
+ def _resolve_binary(spec: str) -> tuple[str, str, list[dict] | None]:
45
+ from ixt.backends.binary import make_source
46
+ from ixt.config.asset_index import is_github_api_allowed
47
+ from ixt.net.source import parse_spec
48
+
49
+ repo_spec = parse_spec(spec)
50
+ if repo_spec is None:
51
+ raise ValueError(f"Not a binary spec: {spec!r}")
52
+
53
+ if repo_spec.platform == "github":
54
+ from ixt.net.github_api import fetch_latest_tag
55
+
56
+ tag = fetch_latest_tag(repo_spec.owner, repo_spec.repo)
57
+ record_github_latest_redirect(repo_spec.owner, repo_spec.repo, hit=bool(tag))
58
+ if tag:
59
+ # Cheap HEAD-redirect path: tag only, no assets fetched. The
60
+ # install reconstructs the asset URL from the cached pattern.
61
+ src_url = f"{repo_spec.host}/{repo_spec.owner}/{repo_spec.repo}@{tag}"
62
+ return tag, src_url, None
63
+
64
+ if not is_github_api_allowed():
65
+ raise RuntimeError("IXT_GITHUB_API=never is set and latest redirect failed")
66
+
67
+ source = make_source(repo_spec)
68
+ release = source.get_latest_release(repo_spec.owner, repo_spec.repo)
69
+ src_url = f"{repo_spec.host}/{repo_spec.owner}/{repo_spec.repo}@{release.tag}"
70
+ return release.tag, src_url, _assets_to_dicts(release)
71
+
72
+
73
+ def _assets_to_dicts(release: Release) -> list[dict]:
74
+ """Serialize a release's assets for the resolve cache."""
75
+ return [{"name": a.name, "url": a.url, "size": a.size} for a in release.assets]
76
+
77
+
78
+ def resolve_latest(
79
+ backend: BackendType,
80
+ spec: str,
81
+ *,
82
+ use_cache: bool = True,
83
+ settings: Settings | None = None,
84
+ ) -> str | None:
85
+ """Return the latest upstream version for (backend, spec), or None on failure.
86
+
87
+ On success, writes the result to the TTL cache. On HTTP/parse failure,
88
+ returns None without raising.
89
+ """
90
+ settings = settings or get_settings()
91
+ key = resolve_cache.make_key(backend.value, spec)
92
+
93
+ if use_cache:
94
+ cached = resolve_cache.get(key, settings=settings)
95
+ if cached is not None:
96
+ return cached
97
+
98
+ with resolution_phase("version"):
99
+ try:
100
+ if backend == BackendType.PYTHON:
101
+ version, source, assets = _resolve_python(spec)
102
+ elif backend == BackendType.NODE:
103
+ version, source, assets = _resolve_node(spec)
104
+ elif backend == BackendType.BINARY:
105
+ version, source, assets = _resolve_binary(spec)
106
+ else:
107
+ return None
108
+ except Exception as exc:
109
+ # Intentionally broad: per-tool isolation (see module docstring).
110
+ get_logger("resolve").debug(
111
+ f"resolve_latest({backend.value}, {spec!r}) failed: {type(exc).__name__}: {exc}"
112
+ )
113
+ return None
114
+
115
+ resolve_cache.set_(key, version, source=source, assets=assets, settings=settings)
116
+ return version
117
+
118
+
119
+ def resolve_latest_many(
120
+ requests: list[tuple[str, BackendType, str]],
121
+ *,
122
+ use_cache: bool = True,
123
+ settings: Settings | None = None,
124
+ ) -> dict[str, str | None]:
125
+ """Resolve the latest version for many ``(key, backend, spec)`` in parallel.
126
+
127
+ Returns ``{key: version-or-None}``. Each resolution is independent and
128
+ never raises (``resolve_latest`` swallows network errors), so one slow or
129
+ failing tool does not block the rest. Honors the TTL cache per entry unless
130
+ *use_cache* is False (then every entry is re-resolved upstream).
131
+ """
132
+ settings = settings or get_settings()
133
+ if not requests:
134
+ return {}
135
+ results: dict[str, str | None] = {}
136
+ workers = min(_RESOLVE_MAX_WORKERS, len(requests))
137
+ with ThreadPoolExecutor(max_workers=workers) as executor:
138
+ futures = {
139
+ executor.submit(
140
+ resolve_latest, backend, spec, use_cache=use_cache, settings=settings
141
+ ): key
142
+ for key, backend, spec in requests
143
+ }
144
+ for future in as_completed(futures):
145
+ key = futures[future]
146
+ try:
147
+ results[key] = future.result()
148
+ except Exception:
149
+ results[key] = None
150
+ return results
@@ -0,0 +1,185 @@
1
+ """TTL cache for resolved tool versions (PyPI / npm / GH latest).
2
+
3
+ Populated by ``ixt tool upgrade --dry-run`` and consumed by the real ``upgrade``
4
+ so that a user who runs dry-run followed by the actual command within the
5
+ TTL window does not pay for two network resolutions.
6
+
7
+ Schema is forward-compatible with a future ``ixt.lock`` (phase 6): entries
8
+ already carry ``source``, leaving room for ``integrity`` / ``asset_url``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import tempfile
16
+ import threading
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from ixt.config.settings import Settings, get_settings
21
+
22
+ DEFAULT_TTL_SECONDS = 420 # 7 minutes
23
+ CACHE_FILENAME = "resolve.json"
24
+ _CACHE_LOCK = threading.Lock()
25
+
26
+
27
+ def _ttl_seconds() -> int:
28
+ override = os.environ.get("IXT_RESOLVE_TTL")
29
+ if override:
30
+ try:
31
+ return max(0, int(override))
32
+ except ValueError:
33
+ pass
34
+ return DEFAULT_TTL_SECONDS
35
+
36
+
37
+ def ttl_seconds() -> int:
38
+ """Public accessor for the active cache TTL (honors ``IXT_RESOLVE_TTL``)."""
39
+ return _ttl_seconds()
40
+
41
+
42
+ def min_remaining_ttl(settings: Settings | None = None) -> int | None:
43
+ """Seconds until the soonest-expiring fresh entry, or None.
44
+
45
+ Returns None when caching is disabled (TTL ≤ 0) or no fresh entry exists.
46
+ Lets the CLI tell the user *when* an instant (cached) run stops being
47
+ instant, rather than just the static TTL.
48
+ """
49
+ ttl = _ttl_seconds()
50
+ if ttl <= 0:
51
+ return None
52
+ settings = settings or get_settings()
53
+ now = datetime.now(timezone.utc)
54
+ remaining: list[int] = []
55
+ for entry in _load(settings)["entries"].values():
56
+ if not isinstance(entry, dict):
57
+ continue
58
+ resolved_at = _parse_iso(entry.get("resolved_at", ""))
59
+ if resolved_at is None:
60
+ continue
61
+ age = (now - resolved_at).total_seconds()
62
+ if age <= ttl:
63
+ remaining.append(int(ttl - age))
64
+ return min(remaining) if remaining else None
65
+
66
+
67
+ def _cache_path(settings: Settings) -> Path:
68
+ return settings.metadata_dir / CACHE_FILENAME
69
+
70
+
71
+ def _now_iso() -> str:
72
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
73
+
74
+
75
+ def _parse_iso(value: str) -> datetime | None:
76
+ try:
77
+ return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
78
+ except (ValueError, TypeError):
79
+ return None
80
+
81
+
82
+ def _load(settings: Settings) -> dict:
83
+ path = _cache_path(settings)
84
+ if not path.exists():
85
+ return {"entries": {}}
86
+ try:
87
+ with path.open(encoding="utf-8") as f:
88
+ data = json.load(f)
89
+ except (json.JSONDecodeError, OSError):
90
+ return {"entries": {}}
91
+ if not isinstance(data, dict) or "entries" not in data:
92
+ return {"entries": {}}
93
+ return data
94
+
95
+
96
+ def _atomic_write(path: Path, data: dict) -> None:
97
+ path.parent.mkdir(parents=True, exist_ok=True)
98
+ fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
99
+ try:
100
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
101
+ json.dump(data, f, indent=2, sort_keys=True)
102
+ os.replace(tmp, path)
103
+ except Exception:
104
+ Path(tmp).unlink(missing_ok=True)
105
+ raise
106
+
107
+
108
+ def _is_fresh(entry: object) -> bool:
109
+ """True if *entry* is a dict with a ``resolved_at`` still within TTL."""
110
+ if not isinstance(entry, dict) or "version" not in entry:
111
+ return False
112
+ resolved_at = _parse_iso(entry.get("resolved_at", ""))
113
+ if resolved_at is None:
114
+ return False
115
+ age = (datetime.now(timezone.utc) - resolved_at).total_seconds()
116
+ return age <= _ttl_seconds()
117
+
118
+
119
+ def get_entry(key: str, *, settings: Settings | None = None) -> dict | None:
120
+ """Return the full cache entry for *key* if within TTL, else None."""
121
+ settings = settings or get_settings()
122
+ entry = _load(settings)["entries"].get(key)
123
+ return entry if _is_fresh(entry) else None
124
+
125
+
126
+ def get(key: str, *, settings: Settings | None = None) -> str | None:
127
+ """Return the cached version for *key* if within TTL, else None."""
128
+ entry = get_entry(key, settings=settings)
129
+ return entry["version"] if entry else None
130
+
131
+
132
+ def set_(
133
+ key: str,
134
+ version: str,
135
+ *,
136
+ source: str | None = None,
137
+ assets: list[dict] | None = None,
138
+ settings: Settings | None = None,
139
+ ) -> None:
140
+ """Write *version* for *key* with current timestamp.
141
+
142
+ *assets* (when given) records the resolved release's downloadable assets
143
+ (``[{"name", "url", "size"}, …]``) so a later install can rebuild the
144
+ release without a second API call — used by binary backends whose asset
145
+ URLs can't be reconstructed from a pattern (GitLab).
146
+
147
+ Skips the disk write only when the existing entry is identical *and still
148
+ fresh* — a no-op refresh inside the TTL window isn't worth a rewrite. When
149
+ the entry is stale, we always rewrite so ``resolved_at`` is bumped and the
150
+ TTL window restarts; otherwise a stable-version tool would expire once and
151
+ never re-warm (every later resolve would re-hit the network).
152
+ """
153
+ settings = settings or get_settings()
154
+ with _CACHE_LOCK:
155
+ data = _load(settings)
156
+ existing = data["entries"].get(key)
157
+ if (
158
+ isinstance(existing, dict)
159
+ and existing.get("version") == version
160
+ and existing.get("source") == source
161
+ and existing.get("assets") == assets
162
+ and _is_fresh(existing)
163
+ ):
164
+ return
165
+ entry: dict = {"version": version, "resolved_at": _now_iso()}
166
+ if source:
167
+ entry["source"] = source
168
+ if assets:
169
+ entry["assets"] = assets
170
+ data["entries"][key] = entry
171
+ _atomic_write(_cache_path(settings), data)
172
+
173
+
174
+ def invalidate(key: str, *, settings: Settings | None = None) -> None:
175
+ """Remove *key* from the cache if present."""
176
+ settings = settings or get_settings()
177
+ with _CACHE_LOCK:
178
+ data = _load(settings)
179
+ if data["entries"].pop(key, None) is not None:
180
+ _atomic_write(_cache_path(settings), data)
181
+
182
+
183
+ def make_key(backend: str, spec: str) -> str:
184
+ """Compose a stable cache key."""
185
+ return f"{backend}:{spec}"
ixt/core/runtimes.py ADDED
@@ -0,0 +1,192 @@
1
+ """Inspect and prune ixt-managed runtime binaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from ixt.config.models import ToolRecord
12
+ from ixt.config.settings import Settings, get_settings
13
+ from ixt.core.cache import count_and_size
14
+ from ixt.libs.shell import ShellError, shell_run_output
15
+
16
+ RuntimeName = Literal["uv", "bun"]
17
+
18
+
19
+ @dataclass(slots=True, frozen=True)
20
+ class RuntimeStatus:
21
+ """Status of one ixt-managed runtime."""
22
+
23
+ name: RuntimeName
24
+ path: Path
25
+ present: bool
26
+ files: int
27
+ bytes: int
28
+ version: str | None
29
+ used_by: tuple[str, ...]
30
+ prune_reason: str
31
+
32
+ @property
33
+ def used(self) -> bool:
34
+ return bool(self.used_by)
35
+
36
+ @property
37
+ def prunable(self) -> bool:
38
+ return self.present and not self.used
39
+
40
+
41
+ @dataclass(slots=True, frozen=True)
42
+ class RuntimePruneResult:
43
+ """Outcome of a runtime prune action."""
44
+
45
+ name: RuntimeName
46
+ path: Path
47
+ removed: bool
48
+ files: int
49
+ bytes: int
50
+ reason: str
51
+
52
+
53
+ def runtime_statuses(*, settings: Settings | None = None) -> list[RuntimeStatus]:
54
+ """Return status for known ixt-managed runtimes.
55
+
56
+ Only runtimes under ``$IXT_HOME/installed/runtimes`` are considered
57
+ ixt-managed. System runtimes on ``PATH`` are intentionally out of scope.
58
+ """
59
+ settings = settings or get_settings()
60
+ bun_users = _bun_runtime_users(settings)
61
+ specs: list[tuple[RuntimeName, Path, tuple[str, ...], str]] = [
62
+ ("uv", settings.uv_runtime, (), "Python tools do not need managed uv at runtime"),
63
+ (
64
+ "bun",
65
+ settings.bun_runtime,
66
+ tuple(sorted(bun_users)),
67
+ "kept for node tools that may run through managed bun"
68
+ if bun_users
69
+ else "no installed node tool depends on managed bun",
70
+ ),
71
+ ]
72
+
73
+ statuses: list[RuntimeStatus] = []
74
+ for name, path, used_by, reason in specs:
75
+ files, bytes_ = count_and_size(path)
76
+ present = path.exists()
77
+ statuses.append(
78
+ RuntimeStatus(
79
+ name=name,
80
+ path=path,
81
+ present=present,
82
+ files=files,
83
+ bytes=bytes_,
84
+ version=_runtime_version(path) if present else None,
85
+ used_by=used_by,
86
+ prune_reason=reason,
87
+ )
88
+ )
89
+ return statuses
90
+
91
+
92
+ def prune_runtimes(
93
+ *,
94
+ settings: Settings | None = None,
95
+ all_: bool = False,
96
+ ) -> list[RuntimePruneResult]:
97
+ """Remove ixt-managed runtimes.
98
+
99
+ By default, removes only unused runtimes. With *all_* true, removes every
100
+ known ixt-managed runtime present under ``$IXT_HOME/installed/runtimes``.
101
+ Installed environments, shims, config, and downloaded archives are never
102
+ touched.
103
+ """
104
+ settings = settings or get_settings()
105
+ results: list[RuntimePruneResult] = []
106
+ for status in runtime_statuses(settings=settings):
107
+ should_remove = status.present and (all_ or status.prunable)
108
+ if not should_remove:
109
+ reason = "not installed" if not status.present else status.prune_reason
110
+ results.append(
111
+ RuntimePruneResult(
112
+ name=status.name,
113
+ path=status.path,
114
+ removed=False,
115
+ files=0,
116
+ bytes=0,
117
+ reason=reason,
118
+ )
119
+ )
120
+ continue
121
+
122
+ files, bytes_ = count_and_size(status.path)
123
+ _remove_runtime_path(status.path)
124
+ results.append(
125
+ RuntimePruneResult(
126
+ name=status.name,
127
+ path=status.path,
128
+ removed=True,
129
+ files=files,
130
+ bytes=bytes_,
131
+ reason="removed by --all" if all_ else status.prune_reason,
132
+ )
133
+ )
134
+ return results
135
+
136
+
137
+ def _remove_runtime_path(path: Path) -> None:
138
+ if path.is_dir() and not path.is_symlink():
139
+ shutil.rmtree(path)
140
+ else:
141
+ path.unlink(missing_ok=True)
142
+
143
+
144
+ def _runtime_version(path: Path) -> str | None:
145
+ if not path.is_file():
146
+ return None
147
+ try:
148
+ output = shell_run_output([str(path), "--version"])
149
+ except (OSError, ShellError):
150
+ return None
151
+ first = output.strip().splitlines()[0] if output.strip() else ""
152
+ return first or None
153
+
154
+
155
+ def _bun_runtime_users(settings: Settings) -> set[str]:
156
+ users: set[str] = set()
157
+ for meta_path in settings.iter_installed_metadata():
158
+ try:
159
+ record = ToolRecord.load_json(meta_path)
160
+ except (OSError, ValueError, KeyError, json.JSONDecodeError):
161
+ continue
162
+ if record.backend != "node":
163
+ continue
164
+ if _node_record_uses_managed_bun(record):
165
+ users.add(record.name)
166
+ return users
167
+
168
+
169
+ def _node_record_uses_managed_bun(record: ToolRecord) -> bool:
170
+ if record.node_shim is False:
171
+ return False
172
+
173
+ node_meta = _read_node_metadata(Path(record.env_dir))
174
+ if node_meta is None:
175
+ return True
176
+ if node_meta.get("runtime") == "node":
177
+ return False
178
+ if node_meta.get("used_npm_fallback") is True:
179
+ return False
180
+ return True
181
+
182
+
183
+ def _read_node_metadata(env_dir: Path) -> dict | None:
184
+ path = env_dir / "node_metadata.json"
185
+ if not path.exists():
186
+ return None
187
+ try:
188
+ with path.open(encoding="utf-8") as f:
189
+ data = json.load(f)
190
+ except (OSError, json.JSONDecodeError):
191
+ return None
192
+ return data if isinstance(data, dict) else None