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/upgrade.py
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""Upgrade an installed tool to the latest version."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Iterable, Iterator
|
|
8
|
+
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
from ixt.config.models import ToolRecord
|
|
14
|
+
from ixt.config.settings import Settings, get_settings
|
|
15
|
+
from ixt.config.toml import IxtConfig, ToolSpec
|
|
16
|
+
from ixt.core import resolve
|
|
17
|
+
from ixt.core.backend import BackendType, get_backend
|
|
18
|
+
from ixt.core.expose import expose_tool, unexpose_tool
|
|
19
|
+
from ixt.core.install import ToolNotInstalledError, _remove_env_dir
|
|
20
|
+
from ixt.core.locks import tool_lock
|
|
21
|
+
from ixt.libs.logger import get_logger
|
|
22
|
+
from ixt.libs.semver import compare_versions, parse_version
|
|
23
|
+
|
|
24
|
+
_UPGRADE_ALL_LATEST_WORKERS: Final = 3
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ToolLocalInstallError",
|
|
28
|
+
"ToolNotInstalledError",
|
|
29
|
+
"ToolPinnedError",
|
|
30
|
+
"load_config_pins",
|
|
31
|
+
"upgrade_all",
|
|
32
|
+
"upgrade_tool",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ToolLocalInstallError(Exception):
|
|
37
|
+
"""Raised when trying to upgrade a tool installed via ``ixt tool install --from``."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, tool_name: str, source_path: str):
|
|
40
|
+
self.tool_name = tool_name
|
|
41
|
+
self.source_path = source_path
|
|
42
|
+
super().__init__(
|
|
43
|
+
f"'{tool_name}' is a local install — cannot upgrade.\n"
|
|
44
|
+
f" Re-run: ixt tool install --from {source_path} --reinstall"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ToolPinnedError(Exception):
|
|
49
|
+
"""Raised when ``upgrade`` is called on a tool pinned with ``==`` in ixt.toml.
|
|
50
|
+
|
|
51
|
+
Carries the pinned version and (if resolvable) the latest available version
|
|
52
|
+
so the CLI can show an informational skip rather than treating it as a
|
|
53
|
+
failure.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
tool_name: str,
|
|
59
|
+
pinned_version: str,
|
|
60
|
+
*,
|
|
61
|
+
latest_available: str | None = None,
|
|
62
|
+
):
|
|
63
|
+
self.tool_name = tool_name
|
|
64
|
+
self.pinned_version = pinned_version
|
|
65
|
+
self.latest_available = latest_available
|
|
66
|
+
super().__init__(f"Tool '{tool_name}' is pinned to {pinned_version} in ixt.toml")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class _PrefetchJob:
|
|
71
|
+
name: str
|
|
72
|
+
record: ToolRecord
|
|
73
|
+
bt: BackendType
|
|
74
|
+
main_spec: str
|
|
75
|
+
config_spec: ToolSpec | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class _PendingUpgrade:
|
|
80
|
+
"""A tool queued for the real upgrade, carrying any pre-resolved data.
|
|
81
|
+
|
|
82
|
+
``config_checked`` mirrors the ``latest_checked`` convention: when True,
|
|
83
|
+
``upgrade_tool`` trusts ``config_spec`` instead of reloading ixt.toml.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
name: str
|
|
87
|
+
latest_checked: bool = False
|
|
88
|
+
latest_version: str | None = None
|
|
89
|
+
config_checked: bool = False
|
|
90
|
+
config_spec: ToolSpec | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_discovered_config() -> IxtConfig | None:
|
|
94
|
+
"""Load the nearest ixt.toml (cwd upward, then global), or None.
|
|
95
|
+
|
|
96
|
+
Separated from the per-tool lookup so a batch caller can parse the file
|
|
97
|
+
once and reuse the result across every installed tool.
|
|
98
|
+
"""
|
|
99
|
+
from ixt.config.toml import find_config_file, load_config
|
|
100
|
+
|
|
101
|
+
path = find_config_file()
|
|
102
|
+
if path is None:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
return load_config(path)
|
|
106
|
+
except (OSError, ValueError) as exc:
|
|
107
|
+
get_logger("upgrade").debug(f"Could not load config at {path}: {exc}")
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _spec_in_config(config: IxtConfig | None, tool_name: str) -> ToolSpec | None:
|
|
112
|
+
"""Return the ToolSpec for *tool_name* within an already-loaded *config*."""
|
|
113
|
+
if config is None:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
spec = config.tools.get(tool_name)
|
|
117
|
+
if spec is not None:
|
|
118
|
+
return spec
|
|
119
|
+
try:
|
|
120
|
+
from ixt.core.apply import _normalize_config_for_apply
|
|
121
|
+
|
|
122
|
+
normalized = _normalize_config_for_apply(config, get_settings())
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
get_logger("upgrade").debug(f"Could not normalize config: {exc}")
|
|
125
|
+
else:
|
|
126
|
+
spec = normalized.tools.get(tool_name)
|
|
127
|
+
if spec is not None:
|
|
128
|
+
return spec
|
|
129
|
+
for key, candidate in config.tools.items():
|
|
130
|
+
if "/" in key and key.rsplit("/", 1)[1] == tool_name:
|
|
131
|
+
return candidate
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _lookup_spec_in_config(tool_name: str) -> ToolSpec | None:
|
|
136
|
+
"""Return the ToolSpec for *tool_name* from the discovered ixt.toml, or None."""
|
|
137
|
+
return _spec_in_config(_load_discovered_config(), tool_name)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def load_config_pins(tool_names: Iterable[str]) -> dict[str, str | None]:
|
|
141
|
+
"""Return ``{name: exact-pin-or-None}`` resolved from a single config load.
|
|
142
|
+
|
|
143
|
+
A batch command (``upgrade --all --dry-run``) parses ``ixt.toml`` once
|
|
144
|
+
instead of once per tool.
|
|
145
|
+
"""
|
|
146
|
+
config = _load_discovered_config()
|
|
147
|
+
return {name: _pin_from_spec(_spec_in_config(config, name)) for name in tool_names}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _pin_from_spec(spec: ToolSpec | None) -> str | None:
|
|
151
|
+
"""Return the exact pin (``==X.Y.Z``) declared by *spec*, or None."""
|
|
152
|
+
if spec is None or not spec.version:
|
|
153
|
+
return None
|
|
154
|
+
version = spec.version.strip()
|
|
155
|
+
return version if version.startswith("==") else None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _constraint_from_spec(spec: ToolSpec | None) -> str | None:
|
|
159
|
+
"""Return the raw version constraint declared by *spec*, or None."""
|
|
160
|
+
if spec is None or not spec.version:
|
|
161
|
+
return None
|
|
162
|
+
return spec.version.strip() or None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _cached_resolution(
|
|
166
|
+
bt: BackendType, resolve_spec: str, tool_name: str, *, settings: Settings
|
|
167
|
+
) -> str | None:
|
|
168
|
+
"""Return a cached resolved version for this tool, or None if stale/missing.
|
|
169
|
+
|
|
170
|
+
For Python/Node, the resolver indexes by package name; for Binary, by
|
|
171
|
+
``owner/repo`` (no tag).
|
|
172
|
+
"""
|
|
173
|
+
from ixt.core import resolve_cache
|
|
174
|
+
|
|
175
|
+
key_spec = tool_name if bt != BackendType.BINARY else resolve_spec
|
|
176
|
+
return resolve_cache.get(resolve_cache.make_key(bt.value, key_spec), settings=settings)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _pin_spec(bt: BackendType, main_spec: str, tool_name: str, version: str) -> str:
|
|
180
|
+
"""Build a backend-specific install spec pinned to *version*."""
|
|
181
|
+
if bt == BackendType.PYTHON:
|
|
182
|
+
return f"{tool_name}=={version}"
|
|
183
|
+
if bt == BackendType.NODE:
|
|
184
|
+
return f"{tool_name}@{version}"
|
|
185
|
+
# Binary: owner/repo@tag (keep the 'v' prefix if upstream uses it).
|
|
186
|
+
return f"{main_spec}@{version}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _strip_binary_pin(bt: BackendType, spec: str) -> str:
|
|
190
|
+
"""For binary backend, drop the ``@tag`` suffix so we resolve latest."""
|
|
191
|
+
if bt == BackendType.BINARY:
|
|
192
|
+
from ixt.net.source import strip_version_suffix
|
|
193
|
+
|
|
194
|
+
return strip_version_suffix(spec)
|
|
195
|
+
return spec
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _raise_if_pinned(
|
|
199
|
+
record: ToolRecord,
|
|
200
|
+
bt: BackendType,
|
|
201
|
+
main_spec: str,
|
|
202
|
+
settings: Settings,
|
|
203
|
+
*,
|
|
204
|
+
config_spec: ToolSpec | None,
|
|
205
|
+
latest_checked: bool = False,
|
|
206
|
+
latest_version: str | None = None,
|
|
207
|
+
use_cache: bool = True,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Raise ToolPinnedError if the tool is pinned with ``==`` in ixt.toml.
|
|
210
|
+
|
|
211
|
+
The error surfaces ``record.name`` (user-facing identifier) but the
|
|
212
|
+
resolve cache is keyed by the underlying package name.
|
|
213
|
+
"""
|
|
214
|
+
pinned = _pin_from_spec(config_spec)
|
|
215
|
+
if pinned is None:
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
latest = (
|
|
219
|
+
latest_version
|
|
220
|
+
if latest_checked
|
|
221
|
+
else _resolve_latest_for_upgrade(
|
|
222
|
+
record, bt, main_spec, settings=settings, use_cache=use_cache
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
raise ToolPinnedError(record.name, pinned, latest_available=latest)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _resolve_install_spec(
|
|
229
|
+
record: ToolRecord,
|
|
230
|
+
bt: BackendType,
|
|
231
|
+
main_spec: str,
|
|
232
|
+
settings: Settings,
|
|
233
|
+
*,
|
|
234
|
+
config_spec: ToolSpec | None,
|
|
235
|
+
use_cache: bool = True,
|
|
236
|
+
) -> str:
|
|
237
|
+
"""Apply cached resolution or a declared range from ixt.toml to *main_spec*."""
|
|
238
|
+
# Honor a declared range from ixt.toml (e.g. ``>=1.0,<2.0``) so the
|
|
239
|
+
# backend resolves within bounds. Binary backend only understands
|
|
240
|
+
# exact tags, so skip it there.
|
|
241
|
+
constraint = _constraint_from_spec(config_spec)
|
|
242
|
+
if constraint and not constraint.startswith("==") and bt != BackendType.BINARY:
|
|
243
|
+
return f"{record.pkg()}{constraint}"
|
|
244
|
+
|
|
245
|
+
if use_cache:
|
|
246
|
+
cached_version = _cached_resolution(bt, main_spec, record.pkg(), settings=settings)
|
|
247
|
+
if cached_version is not None and bt != BackendType.BINARY:
|
|
248
|
+
return _pin_spec(bt, main_spec, record.pkg(), cached_version)
|
|
249
|
+
return main_spec
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _has_range_constraint(bt: BackendType, config_spec: ToolSpec | None) -> bool:
|
|
253
|
+
constraint = _constraint_from_spec(config_spec)
|
|
254
|
+
return bool(constraint and not constraint.startswith("==") and bt != BackendType.BINARY)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _versions_match_current(bt: BackendType, installed: str | None, latest: str | None) -> bool:
|
|
258
|
+
"""Return True when *latest* is the already-installed version.
|
|
259
|
+
|
|
260
|
+
Python and Node versions are compared as exact registry strings. Binary
|
|
261
|
+
release tags may differ only by a leading ``v`` or semver padding.
|
|
262
|
+
"""
|
|
263
|
+
if not installed or not latest:
|
|
264
|
+
return False
|
|
265
|
+
installed_norm = installed.removeprefix("v")
|
|
266
|
+
latest_norm = latest.removeprefix("v")
|
|
267
|
+
if installed_norm == latest_norm:
|
|
268
|
+
return True
|
|
269
|
+
if bt != BackendType.BINARY:
|
|
270
|
+
return False
|
|
271
|
+
return (
|
|
272
|
+
parse_version(installed) is not None
|
|
273
|
+
and parse_version(latest) is not None
|
|
274
|
+
and compare_versions(installed, latest) == 0
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _resolve_latest_for_upgrade(
|
|
279
|
+
record: ToolRecord,
|
|
280
|
+
bt: BackendType,
|
|
281
|
+
main_spec: str,
|
|
282
|
+
*,
|
|
283
|
+
settings: Settings,
|
|
284
|
+
use_cache: bool = True,
|
|
285
|
+
) -> str | None:
|
|
286
|
+
if use_cache:
|
|
287
|
+
latest = _cached_resolution(bt, main_spec, record.pkg(), settings=settings)
|
|
288
|
+
if latest is not None:
|
|
289
|
+
return latest
|
|
290
|
+
return resolve.resolve_latest(bt, main_spec, use_cache=use_cache, settings=settings)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _can_prefetch_latest(record: ToolRecord, bt: BackendType, config_spec: ToolSpec | None) -> bool:
|
|
294
|
+
if record.source == "local":
|
|
295
|
+
return False
|
|
296
|
+
if record.injected:
|
|
297
|
+
return False
|
|
298
|
+
return not _has_range_constraint(bt, config_spec)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _node_runtime_kwargs(bt: BackendType, env_dir: Path) -> dict[str, str]:
|
|
302
|
+
"""Return ``{'runtime': ...}`` for node envs that stored a runtime hint."""
|
|
303
|
+
if bt != BackendType.NODE:
|
|
304
|
+
return {}
|
|
305
|
+
from ixt.backends.node import read_metadata as read_node_meta
|
|
306
|
+
|
|
307
|
+
node_meta = read_node_meta(env_dir)
|
|
308
|
+
if node_meta and node_meta.get("runtime"):
|
|
309
|
+
return {"runtime": node_meta["runtime"]}
|
|
310
|
+
return {}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _backup_env_dir(env_dir: Path, settings: Settings) -> Path:
|
|
314
|
+
"""Copy *env_dir* to a per-transaction backup directory."""
|
|
315
|
+
backup_root = settings.envs_dir / ".ixt-backups"
|
|
316
|
+
backup_root.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
txn_dir = Path(tempfile.mkdtemp(prefix=f"{env_dir.name}-", dir=backup_root))
|
|
318
|
+
backup_env = txn_dir / "env"
|
|
319
|
+
shutil.copytree(env_dir, backup_env, symlinks=True)
|
|
320
|
+
return backup_env
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _cleanup_backup(backup_env: Path, settings: Settings) -> None:
|
|
324
|
+
_remove_env_dir(backup_env.parent, settings, ignore_errors=True)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _restore_upgrade_backup(
|
|
328
|
+
record: ToolRecord,
|
|
329
|
+
backup_env: Path,
|
|
330
|
+
settings: Settings,
|
|
331
|
+
*,
|
|
332
|
+
failed_stage: str,
|
|
333
|
+
old_version: str | None,
|
|
334
|
+
new_version: str | None,
|
|
335
|
+
original_error: Exception,
|
|
336
|
+
) -> Exception | None:
|
|
337
|
+
"""Restore the previous env and shims. Return rollback error, if any."""
|
|
338
|
+
log = get_logger("upgrade")
|
|
339
|
+
env_dir = Path(record.env_dir)
|
|
340
|
+
log.warn(
|
|
341
|
+
"upgrade failed during "
|
|
342
|
+
f"{failed_stage}; restoring {record.name} "
|
|
343
|
+
f"(old={old_version or '?'}, new={new_version or '?'}, backup={backup_env})"
|
|
344
|
+
)
|
|
345
|
+
try:
|
|
346
|
+
_remove_env_dir(env_dir, settings, ignore_errors=True)
|
|
347
|
+
shutil.copytree(backup_env, env_dir, symlinks=True)
|
|
348
|
+
restored = ToolRecord.load_json(env_dir / "ixt.json")
|
|
349
|
+
restored.exposed_bins = dict(record.exposed_bins)
|
|
350
|
+
unexpose_tool(record.exposed_bins, settings.bin_dir)
|
|
351
|
+
result = expose_tool(
|
|
352
|
+
restored.pkg(),
|
|
353
|
+
get_backend(BackendType(restored.backend), settings=settings),
|
|
354
|
+
env_dir,
|
|
355
|
+
settings.bin_dir,
|
|
356
|
+
restored.expose_rules,
|
|
357
|
+
overwrite=True,
|
|
358
|
+
)
|
|
359
|
+
restored.exposed_bins = result.linked or dict(record.exposed_bins)
|
|
360
|
+
restored.save_json(settings.get_tool_metadata_file(record.name))
|
|
361
|
+
log.warn(f"rollback restored {record.name} after {type(original_error).__name__}")
|
|
362
|
+
return None
|
|
363
|
+
except Exception as rollback_error:
|
|
364
|
+
log.error(
|
|
365
|
+
f"rollback failed for {record.name}: {rollback_error}; "
|
|
366
|
+
f"manual backup remains at {backup_env}"
|
|
367
|
+
)
|
|
368
|
+
return rollback_error
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def upgrade_tool(
|
|
372
|
+
tool_name: str,
|
|
373
|
+
*,
|
|
374
|
+
settings: Settings | None = None,
|
|
375
|
+
ignore_pin: bool = False,
|
|
376
|
+
use_cache: bool = True,
|
|
377
|
+
_latest_checked: bool = False,
|
|
378
|
+
_latest_version: str | None = None,
|
|
379
|
+
_config_checked: bool = False,
|
|
380
|
+
_config_spec: ToolSpec | None = None,
|
|
381
|
+
) -> tuple[ToolRecord, str | None]:
|
|
382
|
+
"""Upgrade an installed tool and re-expose its binaries.
|
|
383
|
+
|
|
384
|
+
Returns ``(updated_record, old_version)``.
|
|
385
|
+
|
|
386
|
+
Raises ``ToolPinnedError`` when the tool is pinned with ``==`` in ixt.toml
|
|
387
|
+
and *ignore_pin* is False. The error carries the pinned version and, if
|
|
388
|
+
it could be resolved, the latest upstream version.
|
|
389
|
+
"""
|
|
390
|
+
settings = settings or get_settings()
|
|
391
|
+
with tool_lock(tool_name, settings=settings):
|
|
392
|
+
meta_file = settings.get_tool_metadata_file(tool_name)
|
|
393
|
+
|
|
394
|
+
if not meta_file.exists():
|
|
395
|
+
raise ToolNotInstalledError(tool_name)
|
|
396
|
+
|
|
397
|
+
record = ToolRecord.load_json(meta_file)
|
|
398
|
+
|
|
399
|
+
if record.source == "local":
|
|
400
|
+
raise ToolLocalInstallError(record.name, record.spec)
|
|
401
|
+
|
|
402
|
+
old_version = record.version
|
|
403
|
+
env_dir = Path(record.env_dir)
|
|
404
|
+
bt = BackendType(record.backend)
|
|
405
|
+
if bt == BackendType.BINARY and record.injected:
|
|
406
|
+
raise ValueError("Binary backend does not support injected packages")
|
|
407
|
+
backend = get_backend(bt, settings=settings)
|
|
408
|
+
|
|
409
|
+
main_spec = _strip_binary_pin(bt, record.spec)
|
|
410
|
+
config_spec = _config_spec if _config_checked else _lookup_spec_in_config(record.name)
|
|
411
|
+
|
|
412
|
+
if not ignore_pin:
|
|
413
|
+
_raise_if_pinned(
|
|
414
|
+
record,
|
|
415
|
+
bt,
|
|
416
|
+
main_spec,
|
|
417
|
+
settings,
|
|
418
|
+
config_spec=config_spec,
|
|
419
|
+
latest_checked=_latest_checked,
|
|
420
|
+
latest_version=_latest_version,
|
|
421
|
+
use_cache=use_cache,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
latest: str | None = None
|
|
425
|
+
if not record.injected and not _has_range_constraint(bt, config_spec):
|
|
426
|
+
latest = (
|
|
427
|
+
_latest_version
|
|
428
|
+
if _latest_checked
|
|
429
|
+
else _resolve_latest_for_upgrade(
|
|
430
|
+
record, bt, main_spec, settings=settings, use_cache=use_cache
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
if _versions_match_current(bt, old_version, latest):
|
|
434
|
+
return record, old_version
|
|
435
|
+
|
|
436
|
+
main_spec = _resolve_install_spec(
|
|
437
|
+
record, bt, main_spec, settings, config_spec=config_spec, use_cache=use_cache
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
rollback_record = ToolRecord.load_json(meta_file)
|
|
441
|
+
backup_env = _backup_env_dir(env_dir, settings)
|
|
442
|
+
stage = "install"
|
|
443
|
+
new_version: str | None = None
|
|
444
|
+
try:
|
|
445
|
+
# Reinstall main + injected with --upgrade.
|
|
446
|
+
all_specs = [main_spec] if bt == BackendType.BINARY else [main_spec, *record.injected]
|
|
447
|
+
upgrade_kwargs: dict = {"upgrade": True, **_node_runtime_kwargs(bt, env_dir)}
|
|
448
|
+
# The latest tag was just resolved above; hand it to the binary fast-path
|
|
449
|
+
# so it doesn't re-issue the ``releases/latest`` HEAD it already paid for.
|
|
450
|
+
if bt == BackendType.BINARY and latest:
|
|
451
|
+
upgrade_kwargs["known_tag"] = latest
|
|
452
|
+
if bt == BackendType.BINARY:
|
|
453
|
+
upgrade_kwargs["use_cache"] = use_cache
|
|
454
|
+
backend.install_packages(env_dir, all_specs, **upgrade_kwargs)
|
|
455
|
+
|
|
456
|
+
stage = "version-read"
|
|
457
|
+
new_version = backend.installed_version(env_dir, record.pkg())
|
|
458
|
+
|
|
459
|
+
stage = "unexpose"
|
|
460
|
+
unexpose_tool(record.exposed_bins, settings.bin_dir)
|
|
461
|
+
stage = "expose"
|
|
462
|
+
result = expose_tool(
|
|
463
|
+
record.pkg(),
|
|
464
|
+
backend,
|
|
465
|
+
env_dir,
|
|
466
|
+
settings.bin_dir,
|
|
467
|
+
record.expose_rules,
|
|
468
|
+
overwrite=True,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
stage = "metadata"
|
|
472
|
+
record.version = new_version
|
|
473
|
+
record.exposed_bins = result.linked
|
|
474
|
+
record.save_json(meta_file)
|
|
475
|
+
except Exception as exc:
|
|
476
|
+
rollback_error = _restore_upgrade_backup(
|
|
477
|
+
rollback_record,
|
|
478
|
+
backup_env,
|
|
479
|
+
settings,
|
|
480
|
+
failed_stage=stage,
|
|
481
|
+
old_version=old_version,
|
|
482
|
+
new_version=new_version,
|
|
483
|
+
original_error=exc,
|
|
484
|
+
)
|
|
485
|
+
if rollback_error is not None:
|
|
486
|
+
raise RuntimeError(
|
|
487
|
+
f"upgrade failed during {stage}; rollback failed: {rollback_error}"
|
|
488
|
+
) from exc
|
|
489
|
+
raise
|
|
490
|
+
_cleanup_backup(backup_env, settings)
|
|
491
|
+
return record, old_version
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def upgrade_all(
|
|
495
|
+
*,
|
|
496
|
+
settings: Settings | None = None,
|
|
497
|
+
use_cache: bool = True,
|
|
498
|
+
) -> Iterator[tuple[str, tuple[ToolRecord, str | None] | Exception]]:
|
|
499
|
+
"""Upgrade every installed tool. Yields ``(name, result)`` as each completes.
|
|
500
|
+
|
|
501
|
+
*result* is either ``(updated_record, old_version)`` on success, or an
|
|
502
|
+
Exception (``ToolPinnedError``, ``ToolLocalInstallError``, or any other
|
|
503
|
+
failure surfaced by ``upgrade_tool``).
|
|
504
|
+
|
|
505
|
+
The generator drives one upgrade per ``next()`` call so callers can stream
|
|
506
|
+
progress lines as they happen. Materialise via ``dict(upgrade_all())`` if
|
|
507
|
+
you want the legacy map shape.
|
|
508
|
+
"""
|
|
509
|
+
settings = settings or get_settings()
|
|
510
|
+
meta_files = settings.iter_installed_metadata()
|
|
511
|
+
config = _load_discovered_config()
|
|
512
|
+
prefetches: dict[Future[str | None], _PrefetchJob] = {}
|
|
513
|
+
queued: list[_PendingUpgrade] = []
|
|
514
|
+
with ThreadPoolExecutor(max_workers=_UPGRADE_ALL_LATEST_WORKERS) as executor:
|
|
515
|
+
for meta_file in meta_files:
|
|
516
|
+
name = meta_file.parent.name
|
|
517
|
+
try:
|
|
518
|
+
record = ToolRecord.load_json(meta_file)
|
|
519
|
+
bt = BackendType(record.backend)
|
|
520
|
+
main_spec = _strip_binary_pin(bt, record.spec)
|
|
521
|
+
config_spec = _spec_in_config(config, record.name)
|
|
522
|
+
except Exception:
|
|
523
|
+
queued.append(_PendingUpgrade(name))
|
|
524
|
+
continue
|
|
525
|
+
if record.source == "local":
|
|
526
|
+
yield name, ToolLocalInstallError(record.name, record.spec)
|
|
527
|
+
continue
|
|
528
|
+
if _can_prefetch_latest(record, bt, config_spec):
|
|
529
|
+
prefetches[
|
|
530
|
+
executor.submit(
|
|
531
|
+
_resolve_latest_for_upgrade,
|
|
532
|
+
record,
|
|
533
|
+
bt,
|
|
534
|
+
main_spec,
|
|
535
|
+
settings=settings,
|
|
536
|
+
use_cache=use_cache,
|
|
537
|
+
)
|
|
538
|
+
] = _PrefetchJob(
|
|
539
|
+
name=name,
|
|
540
|
+
record=record,
|
|
541
|
+
bt=bt,
|
|
542
|
+
main_spec=main_spec,
|
|
543
|
+
config_spec=config_spec,
|
|
544
|
+
)
|
|
545
|
+
else:
|
|
546
|
+
queued.append(_PendingUpgrade(name, config_checked=True, config_spec=config_spec))
|
|
547
|
+
|
|
548
|
+
for future in as_completed(prefetches):
|
|
549
|
+
job = prefetches[future]
|
|
550
|
+
latest_checked = True
|
|
551
|
+
latest_version = None
|
|
552
|
+
try:
|
|
553
|
+
latest_version = future.result()
|
|
554
|
+
except Exception:
|
|
555
|
+
latest_checked = False
|
|
556
|
+
|
|
557
|
+
pinned = _pin_from_spec(job.config_spec)
|
|
558
|
+
if pinned is not None:
|
|
559
|
+
yield (
|
|
560
|
+
job.name,
|
|
561
|
+
ToolPinnedError(
|
|
562
|
+
job.record.name,
|
|
563
|
+
pinned,
|
|
564
|
+
latest_available=latest_version,
|
|
565
|
+
),
|
|
566
|
+
)
|
|
567
|
+
continue
|
|
568
|
+
if _versions_match_current(job.bt, job.record.version, latest_version):
|
|
569
|
+
yield job.name, (job.record, job.record.version)
|
|
570
|
+
continue
|
|
571
|
+
queued.append(
|
|
572
|
+
_PendingUpgrade(
|
|
573
|
+
job.name,
|
|
574
|
+
latest_checked=latest_checked,
|
|
575
|
+
latest_version=latest_version,
|
|
576
|
+
config_checked=True,
|
|
577
|
+
config_spec=job.config_spec,
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
for item in queued:
|
|
582
|
+
try:
|
|
583
|
+
yield (
|
|
584
|
+
item.name,
|
|
585
|
+
upgrade_tool(
|
|
586
|
+
item.name,
|
|
587
|
+
settings=settings,
|
|
588
|
+
use_cache=use_cache,
|
|
589
|
+
_latest_checked=item.latest_checked,
|
|
590
|
+
_latest_version=item.latest_version,
|
|
591
|
+
_config_checked=item.config_checked,
|
|
592
|
+
_config_spec=item.config_spec,
|
|
593
|
+
),
|
|
594
|
+
)
|
|
595
|
+
except Exception as exc:
|
|
596
|
+
yield item.name, exc
|
ixt/data/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Embedded data files (heuristics.toml, registry.toml)."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
DATA_DIR = Path(__file__).parent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_data_path(filename: str) -> Path:
|
|
9
|
+
"""Return the absolute path to an embedded data file."""
|
|
10
|
+
return DATA_DIR / filename
|