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.
- ixt/__init__.py +8 -0
- ixt/__main__.py +8 -0
- ixt/backends/__init__.py +1 -0
- ixt/backends/binary.py +935 -0
- ixt/backends/binary_resolver.py +307 -0
- ixt/backends/node.py +490 -0
- ixt/backends/python.py +234 -0
- ixt/cli/__init__.py +31 -0
- ixt/cli/argparse_completion.py +557 -0
- ixt/cli/cmd_apply.py +404 -0
- ixt/cli/cmd_cache.py +86 -0
- ixt/cli/cmd_config.py +295 -0
- ixt/cli/cmd_info.py +116 -0
- ixt/cli/cmd_install.py +508 -0
- ixt/cli/cmd_misc.py +261 -0
- ixt/cli/cmd_registry.py +35 -0
- ixt/cli/cmd_upgrade.py +336 -0
- ixt/cli/commands.py +70 -0
- ixt/cli/parser.py +555 -0
- ixt/cli/render.py +85 -0
- ixt/config/__init__.py +5 -0
- ixt/config/asset_index.py +305 -0
- ixt/config/asset_pattern_cache.py +87 -0
- ixt/config/env_policy.py +340 -0
- ixt/config/flags.py +29 -0
- ixt/config/fs_policy.py +17 -0
- ixt/config/heuristics.py +465 -0
- ixt/config/models.py +176 -0
- ixt/config/registry.py +145 -0
- ixt/config/settings.py +173 -0
- ixt/config/setup_toml.py +179 -0
- ixt/config/toml.py +416 -0
- ixt/core/__init__.py +16 -0
- ixt/core/apply.py +564 -0
- ixt/core/apply_actions.py +106 -0
- ixt/core/backend.py +187 -0
- ixt/core/bootstrap.py +410 -0
- ixt/core/cache.py +332 -0
- ixt/core/discover.py +150 -0
- ixt/core/doctor.py +591 -0
- ixt/core/export.py +419 -0
- ixt/core/expose.py +350 -0
- ixt/core/extract.py +261 -0
- ixt/core/hooks.py +182 -0
- ixt/core/identity.py +148 -0
- ixt/core/inject.py +143 -0
- ixt/core/install.py +509 -0
- ixt/core/install_local.py +229 -0
- ixt/core/locks.py +54 -0
- ixt/core/pathlink.py +86 -0
- ixt/core/resolution_stats.py +191 -0
- ixt/core/resolve.py +150 -0
- ixt/core/resolve_cache.py +185 -0
- ixt/core/runtimes.py +192 -0
- ixt/core/save.py +237 -0
- ixt/core/setup_completions.py +11 -0
- ixt/core/setup_path.py +368 -0
- ixt/core/upgrade.py +596 -0
- ixt/data/__init__.py +10 -0
- ixt/data/asset_index.json +574 -0
- ixt/data/heuristics.toml +98 -0
- ixt/data/registry.toml +71 -0
- ixt/libs/__init__.py +3 -0
- ixt/libs/constants.py +4 -0
- ixt/libs/fmt.py +108 -0
- ixt/libs/logger.py +109 -0
- ixt/libs/output.py +25 -0
- ixt/libs/req_spec.py +115 -0
- ixt/libs/semver.py +149 -0
- ixt/libs/shell.py +126 -0
- ixt/libs/style.py +238 -0
- ixt/net/__init__.py +1 -0
- ixt/net/github_api.py +158 -0
- ixt/net/gitlab_api.py +149 -0
- ixt/net/http.py +194 -0
- ixt/net/npm.py +24 -0
- ixt/net/pypi.py +26 -0
- ixt/net/source.py +163 -0
- ixt/platform/__init__.py +131 -0
- ixt/platform/win.py +68 -0
- ixt_cli-0.8.0.dist-info/METADATA +294 -0
- ixt_cli-0.8.0.dist-info/RECORD +84 -0
- ixt_cli-0.8.0.dist-info/WHEEL +4 -0
- 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
|