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