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/backends/binary.py
ADDED
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
"""Binary backend — GitHub/GitLab Releases via heuristic asset selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, TypedDict
|
|
10
|
+
|
|
11
|
+
from ixt.backends.binary_resolver import _fast_path_release, derive_asset_pattern
|
|
12
|
+
from ixt.config.asset_index import (
|
|
13
|
+
GitHubApiDisabledError,
|
|
14
|
+
disabled_api_message,
|
|
15
|
+
is_github_api_allowed,
|
|
16
|
+
)
|
|
17
|
+
from ixt.config.flags import is_setup_toml_enabled
|
|
18
|
+
from ixt.config.heuristics import (
|
|
19
|
+
get_name_suffix_tokens,
|
|
20
|
+
get_platform_aliases,
|
|
21
|
+
load_heuristics,
|
|
22
|
+
select_asset,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from ixt.config.setup_toml import SetupConfig
|
|
27
|
+
from ixt.config.settings import Settings, get_settings
|
|
28
|
+
from ixt.core.backend import BackendType
|
|
29
|
+
from ixt.core.discover import find_executables
|
|
30
|
+
from ixt.core.extract import extract, is_archive
|
|
31
|
+
from ixt.libs.logger import get_logger, get_verbosity
|
|
32
|
+
from ixt.net.source import Asset, Release, ReleaseSource, RepoSpec, parse_spec
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"AssetSelectionError",
|
|
36
|
+
"BinaryBackend",
|
|
37
|
+
"BinarySpecError",
|
|
38
|
+
"HookEntry",
|
|
39
|
+
"SetupMetadata",
|
|
40
|
+
"_fast_path_release",
|
|
41
|
+
"derive_asset_pattern",
|
|
42
|
+
"make_source",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# 3.10 lacks typing.NotRequired (added in 3.11) and the project bans
|
|
47
|
+
# third-party deps (typing_extensions). The required-base + total=False
|
|
48
|
+
# subclass pattern is the canonical 3.10 stdlib alternative.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _HookEntryRequired(TypedDict):
|
|
52
|
+
run: list[str] | str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class HookEntry(_HookEntryRequired, total=False):
|
|
56
|
+
"""Persisted lifecycle hook entry inside ``binary_metadata.json``."""
|
|
57
|
+
|
|
58
|
+
check: list[str] | str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _SetupMetadataRequired(TypedDict):
|
|
62
|
+
name: str | None
|
|
63
|
+
expose: list[str]
|
|
64
|
+
description: str | None
|
|
65
|
+
asset_pattern: str | None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SetupMetadata(_SetupMetadataRequired, total=False):
|
|
69
|
+
"""``setup`` sub-document stored in ``binary_metadata.json``.
|
|
70
|
+
|
|
71
|
+
Mirrors the relevant subset of ``SetupConfig`` — the bits the tool
|
|
72
|
+
runtime needs after install (expose filter, hook commands).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
post_install: HookEntry
|
|
76
|
+
pre_uninstall: HookEntry
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AssetSelectionError(RuntimeError):
|
|
80
|
+
"""Raised when no suitable asset could be selected for the current platform."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, spec: str, release_tag: str, asset_names: list[str]):
|
|
83
|
+
self.spec = spec
|
|
84
|
+
self.release_tag = release_tag
|
|
85
|
+
self.asset_names = asset_names
|
|
86
|
+
names = ", ".join(asset_names[:10]) or "<none>"
|
|
87
|
+
super().__init__(
|
|
88
|
+
f"No suitable asset found for '{spec}' (release {release_tag}). Available: {names}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class BinarySpecError(ValueError):
|
|
93
|
+
"""Raised when a spec cannot be parsed as a binary backend spec."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, spec: str):
|
|
96
|
+
self.spec = spec
|
|
97
|
+
super().__init__(f"Invalid binary spec: {spec!r} (expected owner/repo[@version])")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# Metadata stored alongside the extracted environment
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
_META_FILE = "binary_metadata.json"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _encode_path_under_env(path: str, env_dir: Path) -> str:
|
|
108
|
+
candidate = Path(path)
|
|
109
|
+
if not candidate.is_absolute():
|
|
110
|
+
return path
|
|
111
|
+
try:
|
|
112
|
+
return str(candidate.absolute().relative_to(env_dir.absolute()))
|
|
113
|
+
except ValueError:
|
|
114
|
+
return path
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _decode_path_from_env(path: str, env_dir: Path) -> str:
|
|
118
|
+
candidate = Path(path)
|
|
119
|
+
if candidate.is_absolute():
|
|
120
|
+
return path
|
|
121
|
+
return str(env_dir / candidate)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _existing_metadata_binaries(meta: dict, env_dir: Path) -> dict[str, str]:
|
|
125
|
+
binaries = meta.get("binaries", {})
|
|
126
|
+
if not isinstance(binaries, dict):
|
|
127
|
+
return {}
|
|
128
|
+
existing: dict[str, str] = {}
|
|
129
|
+
for name, bin_path in binaries.items():
|
|
130
|
+
path = Path(bin_path)
|
|
131
|
+
if path.exists():
|
|
132
|
+
existing[str(name)] = str(path)
|
|
133
|
+
return existing
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _write_metadata(
|
|
137
|
+
env_dir: Path,
|
|
138
|
+
*,
|
|
139
|
+
spec: str,
|
|
140
|
+
tag: str,
|
|
141
|
+
asset_name: str,
|
|
142
|
+
binaries: dict[str, str],
|
|
143
|
+
setup: SetupMetadata | None = None,
|
|
144
|
+
resolution_source: str | None = None,
|
|
145
|
+
) -> Path:
|
|
146
|
+
"""Write binary backend metadata to env_dir."""
|
|
147
|
+
path = env_dir / _META_FILE
|
|
148
|
+
data: dict = {
|
|
149
|
+
"spec": spec,
|
|
150
|
+
"tag": tag,
|
|
151
|
+
"asset_name": asset_name,
|
|
152
|
+
"binaries": {
|
|
153
|
+
name: _encode_path_under_env(path, env_dir) for name, path in binaries.items()
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
if setup is not None:
|
|
157
|
+
data["setup"] = setup
|
|
158
|
+
if resolution_source:
|
|
159
|
+
data["resolution_source"] = resolution_source
|
|
160
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
with path.open("w", encoding="utf-8") as f:
|
|
162
|
+
json.dump(data, f, indent=2)
|
|
163
|
+
f.write("\n")
|
|
164
|
+
return path
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read_metadata(env_dir: Path) -> dict | None:
|
|
168
|
+
"""Read binary backend metadata, or None if absent."""
|
|
169
|
+
path = env_dir / _META_FILE
|
|
170
|
+
if not path.exists():
|
|
171
|
+
return None
|
|
172
|
+
with path.open(encoding="utf-8") as f:
|
|
173
|
+
data = json.load(f)
|
|
174
|
+
binaries = data.get("binaries")
|
|
175
|
+
if isinstance(binaries, dict):
|
|
176
|
+
data["binaries"] = {
|
|
177
|
+
name: _decode_path_from_env(path, env_dir) for name, path in binaries.items()
|
|
178
|
+
}
|
|
179
|
+
return data
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _build_setup_metadata(setup: SetupConfig | None) -> SetupMetadata | None:
|
|
183
|
+
"""Serialize a SetupConfig into the JSON-ready metadata dict."""
|
|
184
|
+
if setup is None:
|
|
185
|
+
return None
|
|
186
|
+
data: SetupMetadata = {
|
|
187
|
+
"name": setup.name,
|
|
188
|
+
"expose": setup.expose,
|
|
189
|
+
"description": setup.description,
|
|
190
|
+
"asset_pattern": setup.asset_pattern,
|
|
191
|
+
}
|
|
192
|
+
if setup.post_install.run:
|
|
193
|
+
post: HookEntry = {"run": setup.post_install.run}
|
|
194
|
+
if setup.post_install.check:
|
|
195
|
+
post["check"] = setup.post_install.check
|
|
196
|
+
data["post_install"] = post
|
|
197
|
+
if setup.pre_uninstall.run:
|
|
198
|
+
pre: HookEntry = {"run": setup.pre_uninstall.run}
|
|
199
|
+
if setup.pre_uninstall.check:
|
|
200
|
+
pre["check"] = setup.pre_uninstall.check
|
|
201
|
+
data["pre_uninstall"] = pre
|
|
202
|
+
return data
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
# Release source factory
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def make_source(repo_spec: RepoSpec) -> ReleaseSource:
|
|
211
|
+
"""Create the appropriate ReleaseSource for a parsed spec.
|
|
212
|
+
|
|
213
|
+
For GitLab, ``base_url`` is derived from ``repo_spec.host`` so
|
|
214
|
+
self-hosted instances (e.g. ``gitlab.company.com``) are reached
|
|
215
|
+
instead of ``gitlab.com``.
|
|
216
|
+
"""
|
|
217
|
+
if repo_spec.platform == "github":
|
|
218
|
+
from ixt.net.github_api import GitHubSource
|
|
219
|
+
|
|
220
|
+
return GitHubSource()
|
|
221
|
+
|
|
222
|
+
from ixt.net.gitlab_api import GitLabSource
|
|
223
|
+
|
|
224
|
+
base_url = f"https://{repo_spec.host}"
|
|
225
|
+
return GitLabSource(base_url=base_url)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Cache helpers
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _cache_dir_for(settings: Settings, repo_spec: RepoSpec) -> Path:
|
|
234
|
+
"""Return the cache directory for a given repo spec.
|
|
235
|
+
|
|
236
|
+
Layout: ``$IXT_CACHE_HOME/downloads/{host}/{owner}-{repo}/``
|
|
237
|
+
"""
|
|
238
|
+
host = repo_spec.host.replace(":", "_")
|
|
239
|
+
return settings.cache_dir / host / f"{repo_spec.owner}-{repo_spec.repo}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _cached_asset(cache_dir: Path, asset_name: str) -> Path | None:
|
|
243
|
+
"""Return the cached asset path if it exists."""
|
|
244
|
+
path = cache_dir / asset_name
|
|
245
|
+
if path.exists() and path.stat().st_size > 0:
|
|
246
|
+
return path
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# Version extraction
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
_BARE_NAME_SEPARATORS = "._-"
|
|
255
|
+
_VERSION_SUFFIX_RE = re.compile(
|
|
256
|
+
rf"(^|[{re.escape(_BARE_NAME_SEPARATORS)}])"
|
|
257
|
+
rf"v?\d+(?:\.\d+){{1,}}(?:[-+][0-9A-Za-z.]+)?$",
|
|
258
|
+
re.IGNORECASE,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _strip_v_prefix(tag: str) -> str:
|
|
263
|
+
"""Strip leading 'v' from a version tag: 'v14.1.0' -> '14.1.0'."""
|
|
264
|
+
if tag.startswith("v") and len(tag) > 1 and tag[1:2].isdigit():
|
|
265
|
+
return tag[1:]
|
|
266
|
+
return tag
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _tag_candidates(tag: str) -> list[str]:
|
|
270
|
+
"""Return direct-download tag candidates, preserving exact-tag priority."""
|
|
271
|
+
if tag.startswith("v"):
|
|
272
|
+
return [tag]
|
|
273
|
+
return [tag, f"v{tag}"]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _selection_pattern(pattern: str | None, tag: str) -> str | None:
|
|
277
|
+
"""Return a pattern suitable for the existing selector.
|
|
278
|
+
|
|
279
|
+
The selection layer knows ``{version}``, ``{os}``, ``{arch}``, and
|
|
280
|
+
``{name}``. Direct-download patterns may also use ``{tag}``, so replace
|
|
281
|
+
that placeholder before matching the synthesized one-asset release.
|
|
282
|
+
"""
|
|
283
|
+
if pattern is None:
|
|
284
|
+
return None
|
|
285
|
+
return pattern.replace("{tag}", tag)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _strip_exe_suffix(name: str) -> str:
|
|
289
|
+
"""Return *name* without a Windows executable suffix, if present."""
|
|
290
|
+
return name[:-4] if name.lower().endswith(".exe") else name
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _strip_trailing_token(name: str, token: str) -> str:
|
|
294
|
+
"""Strip *token* when it is the final separator-delimited name token."""
|
|
295
|
+
cleaned = token.strip(_BARE_NAME_SEPARATORS)
|
|
296
|
+
if not cleaned:
|
|
297
|
+
return name
|
|
298
|
+
|
|
299
|
+
lower = name.lower()
|
|
300
|
+
token_lower = cleaned.lower()
|
|
301
|
+
if lower == token_lower:
|
|
302
|
+
return ""
|
|
303
|
+
|
|
304
|
+
for sep in _BARE_NAME_SEPARATORS:
|
|
305
|
+
suffix = f"{sep}{token_lower}"
|
|
306
|
+
if lower.endswith(suffix):
|
|
307
|
+
return name[: -len(suffix)].rstrip(_BARE_NAME_SEPARATORS)
|
|
308
|
+
return name
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _strip_trailing_tokens(name: str, tokens: list[str]) -> str:
|
|
312
|
+
"""Repeatedly strip known final tokens, longest aliases first."""
|
|
313
|
+
current = name
|
|
314
|
+
aliases = sorted({token for token in tokens if token}, key=len, reverse=True)
|
|
315
|
+
changed = True
|
|
316
|
+
while current and changed:
|
|
317
|
+
changed = False
|
|
318
|
+
for token in aliases:
|
|
319
|
+
stripped = _strip_trailing_token(current, token)
|
|
320
|
+
if stripped != current:
|
|
321
|
+
current = stripped
|
|
322
|
+
changed = True
|
|
323
|
+
break
|
|
324
|
+
return current
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _strip_trailing_version(name: str, tag: str) -> str:
|
|
328
|
+
"""Strip a final release-version token from *name*."""
|
|
329
|
+
version = _strip_v_prefix(tag)
|
|
330
|
+
candidates = [tag, version]
|
|
331
|
+
if version:
|
|
332
|
+
candidates.append(f"v{version}")
|
|
333
|
+
|
|
334
|
+
stripped = _strip_trailing_tokens(name, candidates)
|
|
335
|
+
if stripped != name:
|
|
336
|
+
return stripped
|
|
337
|
+
|
|
338
|
+
return _VERSION_SUFFIX_RE.sub("", name).strip(_BARE_NAME_SEPARATORS)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _derive_bare_binary_name(asset_name: str, fallback_name: str, tag: str) -> str:
|
|
342
|
+
"""Infer the executable name for a bare binary asset.
|
|
343
|
+
|
|
344
|
+
Bare release assets are the executable itself. Keep that source of truth,
|
|
345
|
+
but remove common version/platform suffixes such as
|
|
346
|
+
``shfmt_v3.13.1_linux_amd64`` -> ``shfmt``.
|
|
347
|
+
"""
|
|
348
|
+
current = _strip_exe_suffix(asset_name).strip(_BARE_NAME_SEPARATORS)
|
|
349
|
+
os_aliases, arch_aliases = get_platform_aliases()
|
|
350
|
+
suffix_tokens = [*os_aliases, *arch_aliases, *get_name_suffix_tokens()]
|
|
351
|
+
|
|
352
|
+
changed = True
|
|
353
|
+
while current and changed:
|
|
354
|
+
changed = False
|
|
355
|
+
stripped = _strip_trailing_tokens(current, suffix_tokens)
|
|
356
|
+
stripped = _strip_trailing_version(stripped, tag)
|
|
357
|
+
if stripped != current:
|
|
358
|
+
current = stripped
|
|
359
|
+
changed = True
|
|
360
|
+
|
|
361
|
+
return current or _strip_exe_suffix(fallback_name)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# BinaryBackend
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass
|
|
370
|
+
class BinaryBackend:
|
|
371
|
+
"""Install binary tools from GitHub/GitLab Releases."""
|
|
372
|
+
|
|
373
|
+
settings: Settings = field(default_factory=get_settings)
|
|
374
|
+
backend_type: BackendType = field(default=BackendType.BINARY, init=False)
|
|
375
|
+
|
|
376
|
+
# -- Backend protocol ---------------------------------------------------
|
|
377
|
+
|
|
378
|
+
def env_exists(self, env_dir: Path) -> bool:
|
|
379
|
+
"""Check whether extracted binary content exists."""
|
|
380
|
+
meta = env_dir / _META_FILE
|
|
381
|
+
return meta.exists()
|
|
382
|
+
|
|
383
|
+
def create_env(self, env_dir: Path) -> bool:
|
|
384
|
+
"""Create the environment directory. Return True if newly created."""
|
|
385
|
+
if env_dir.exists() and self.env_exists(env_dir):
|
|
386
|
+
return False
|
|
387
|
+
env_dir.mkdir(parents=True, exist_ok=True)
|
|
388
|
+
return True
|
|
389
|
+
|
|
390
|
+
def install_packages(
|
|
391
|
+
self,
|
|
392
|
+
env_dir: Path,
|
|
393
|
+
specs: list[str],
|
|
394
|
+
*,
|
|
395
|
+
upgrade: bool = False,
|
|
396
|
+
setup_path: Path | None = None,
|
|
397
|
+
asset_pattern: str | None = None,
|
|
398
|
+
known_tag: str | None = None,
|
|
399
|
+
use_cache: bool = True,
|
|
400
|
+
) -> dict | None:
|
|
401
|
+
"""Compatibility adapter for the shared backend protocol."""
|
|
402
|
+
if not specs:
|
|
403
|
+
return None
|
|
404
|
+
if len(specs) > 1:
|
|
405
|
+
raise ValueError("Binary backend does not support injected packages")
|
|
406
|
+
return self.install_package(
|
|
407
|
+
env_dir,
|
|
408
|
+
specs[0],
|
|
409
|
+
upgrade=upgrade,
|
|
410
|
+
setup_path=setup_path,
|
|
411
|
+
asset_pattern=asset_pattern,
|
|
412
|
+
known_tag=known_tag,
|
|
413
|
+
use_cache=use_cache,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
def install_package(
|
|
417
|
+
self,
|
|
418
|
+
env_dir: Path,
|
|
419
|
+
spec: str,
|
|
420
|
+
*,
|
|
421
|
+
upgrade: bool = False,
|
|
422
|
+
setup_path: Path | None = None,
|
|
423
|
+
asset_pattern: str | None = None,
|
|
424
|
+
known_tag: str | None = None,
|
|
425
|
+
use_cache: bool = True,
|
|
426
|
+
) -> dict | None:
|
|
427
|
+
"""Resolve, download, extract, and discover one atomic binary tool.
|
|
428
|
+
|
|
429
|
+
*asset_pattern* (when provided) forces the asset selection pattern
|
|
430
|
+
and takes precedence over any value declared in ixt.setup.toml.
|
|
431
|
+
|
|
432
|
+
*known_tag* (when provided) is the latest tag a caller already
|
|
433
|
+
resolved, reused by the fast-path to skip a redundant network HEAD.
|
|
434
|
+
|
|
435
|
+
*use_cache=False* disables reuse of the resolve TTL cache entry. It
|
|
436
|
+
does not disable asset-pattern reuse because that cache stores URL
|
|
437
|
+
shape, not a latest-version decision.
|
|
438
|
+
"""
|
|
439
|
+
|
|
440
|
+
log = get_logger("binary")
|
|
441
|
+
repo_spec = parse_spec(spec)
|
|
442
|
+
if repo_spec is None:
|
|
443
|
+
raise BinarySpecError(spec)
|
|
444
|
+
|
|
445
|
+
source = make_source(repo_spec)
|
|
446
|
+
log.debug(f"spec: {repo_spec.owner}/{repo_spec.repo} platform={repo_spec.platform}")
|
|
447
|
+
|
|
448
|
+
cache_dir = _cache_dir_for(self.settings, repo_spec)
|
|
449
|
+
|
|
450
|
+
setup, setup_resolved = self._resolve_setup_before_release(
|
|
451
|
+
source,
|
|
452
|
+
repo_spec,
|
|
453
|
+
cache_dir,
|
|
454
|
+
setup_path,
|
|
455
|
+
upgrade=upgrade,
|
|
456
|
+
asset_pattern=asset_pattern,
|
|
457
|
+
)
|
|
458
|
+
release_pattern = asset_pattern or (
|
|
459
|
+
setup.asset_pattern if setup and setup.asset_pattern else None
|
|
460
|
+
)
|
|
461
|
+
release_pattern_name = setup.name if setup and setup.name else None
|
|
462
|
+
|
|
463
|
+
# Reuse the release already fetched during version resolution (e.g. an
|
|
464
|
+
# upgrade or a dry-run within the TTL window). This skips a second API
|
|
465
|
+
# call — the only option for GitLab, whose asset URLs can't be rebuilt
|
|
466
|
+
# from a pattern like GitHub's.
|
|
467
|
+
release = self._cached_release(spec) if use_cache and not repo_spec.version else None
|
|
468
|
+
if release is not None:
|
|
469
|
+
log.debug(f"release (resolve cache): {release.tag} ({len(release.assets)} assets)")
|
|
470
|
+
else:
|
|
471
|
+
release = self._fetch_release(
|
|
472
|
+
source,
|
|
473
|
+
repo_spec,
|
|
474
|
+
env_dir=env_dir,
|
|
475
|
+
upgrade=upgrade,
|
|
476
|
+
known_tag=known_tag,
|
|
477
|
+
asset_pattern=release_pattern,
|
|
478
|
+
pattern_name=release_pattern_name,
|
|
479
|
+
)
|
|
480
|
+
log.debug(f"release: {release.tag} ({len(release.assets)} assets)")
|
|
481
|
+
|
|
482
|
+
if setup is None and not setup_resolved:
|
|
483
|
+
setup = self._resolve_setup(
|
|
484
|
+
source, repo_spec, cache_dir, setup_path, upgrade=upgrade, ref=release.tag
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
tool_name = setup.name if setup and setup.name else repo_spec.repo
|
|
488
|
+
effective_pattern = asset_pattern or (
|
|
489
|
+
setup.asset_pattern if setup and setup.asset_pattern else None
|
|
490
|
+
)
|
|
491
|
+
if asset_pattern:
|
|
492
|
+
log.debug(f"user-forced asset_pattern: {asset_pattern!r}")
|
|
493
|
+
selection_pattern = _selection_pattern(effective_pattern, release.tag)
|
|
494
|
+
asset = self._select_asset(release, selection_pattern, tool_name, spec)
|
|
495
|
+
log.debug(f"selected asset: {asset.name}")
|
|
496
|
+
|
|
497
|
+
archive_path = self._download_asset(
|
|
498
|
+
source,
|
|
499
|
+
repo_spec,
|
|
500
|
+
asset,
|
|
501
|
+
cache_dir,
|
|
502
|
+
tag=release.tag,
|
|
503
|
+
upgrade=upgrade,
|
|
504
|
+
)
|
|
505
|
+
log.debug(f"archive: {archive_path}")
|
|
506
|
+
|
|
507
|
+
extract_tool_name = tool_name
|
|
508
|
+
if not is_archive(asset.name) and not (setup and setup.name):
|
|
509
|
+
extract_tool_name = _derive_bare_binary_name(asset.name, tool_name, release.tag)
|
|
510
|
+
|
|
511
|
+
result = extract(archive_path, env_dir, tool_name=extract_tool_name)
|
|
512
|
+
log.debug(f"extracted to: {result.root}")
|
|
513
|
+
|
|
514
|
+
heuristics = load_heuristics()
|
|
515
|
+
executables = find_executables(result.root, expose=heuristics.expose, log=log)
|
|
516
|
+
log.debug(f"executables: {[e.name for e in executables]}")
|
|
517
|
+
|
|
518
|
+
binaries = {exe.name: str(exe) for exe in executables}
|
|
519
|
+
|
|
520
|
+
# Learn the asset URL pattern so future upgrades can skip the API.
|
|
521
|
+
# Priority: user CLI override > ixt.setup.toml (author) > derived
|
|
522
|
+
# from the resolved asset name. Only the first two are "forced" —
|
|
523
|
+
# the derived one bakes in OS/arch and is unsafe for cross-machine
|
|
524
|
+
# replay via ixt tool export.
|
|
525
|
+
pattern_forced = effective_pattern is not None
|
|
526
|
+
learned_pattern = effective_pattern or derive_asset_pattern(asset.name, release.tag)
|
|
527
|
+
|
|
528
|
+
_write_metadata(
|
|
529
|
+
env_dir,
|
|
530
|
+
spec=spec,
|
|
531
|
+
tag=release.tag,
|
|
532
|
+
asset_name=asset.name,
|
|
533
|
+
binaries=binaries,
|
|
534
|
+
setup=_build_setup_metadata(setup),
|
|
535
|
+
resolution_source=release.resolution_source,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Persist the pattern globally (IXT_CACHE_HOME metadata) so reinstalls of the
|
|
539
|
+
# same tool — including after an uninstall — skip the API too.
|
|
540
|
+
if learned_pattern and repo_spec.platform == "github":
|
|
541
|
+
from ixt.config import asset_pattern_cache
|
|
542
|
+
|
|
543
|
+
asset_pattern_cache.set_(repo_spec.owner, repo_spec.repo, learned_pattern)
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
"asset_pattern": learned_pattern,
|
|
547
|
+
"asset_pattern_forced": pattern_forced,
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
def _resolve_setup_before_release(
|
|
551
|
+
self,
|
|
552
|
+
source: ReleaseSource,
|
|
553
|
+
repo_spec: RepoSpec,
|
|
554
|
+
cache_dir: Path,
|
|
555
|
+
setup_path: Path | None,
|
|
556
|
+
*,
|
|
557
|
+
upgrade: bool,
|
|
558
|
+
asset_pattern: str | None,
|
|
559
|
+
) -> tuple[SetupConfig | None, bool]:
|
|
560
|
+
"""Resolve setup early when it can feed a direct release URL.
|
|
561
|
+
|
|
562
|
+
Remote setup files are only fetched early for exact pinned binary tags:
|
|
563
|
+
the raw-file fetch can be pinned to the same tag without consulting the
|
|
564
|
+
release API. Unpinned and partial-version installs keep the normal
|
|
565
|
+
release-first order so setup stays tied to the resolved release tag.
|
|
566
|
+
|
|
567
|
+
Returns ``(setup, resolved)`` where ``resolved`` means the normal
|
|
568
|
+
post-release setup lookup can be skipped, even when no file exists.
|
|
569
|
+
"""
|
|
570
|
+
if asset_pattern:
|
|
571
|
+
return None, False
|
|
572
|
+
if setup_path is not None:
|
|
573
|
+
setup = self._resolve_setup(
|
|
574
|
+
source, repo_spec, cache_dir, setup_path, upgrade=upgrade, ref=repo_spec.version
|
|
575
|
+
)
|
|
576
|
+
return setup, True
|
|
577
|
+
if not is_setup_toml_enabled() or not repo_spec.version:
|
|
578
|
+
return None, False
|
|
579
|
+
|
|
580
|
+
from ixt.libs.semver import is_partial_version
|
|
581
|
+
|
|
582
|
+
if is_partial_version(repo_spec.version):
|
|
583
|
+
return None, False
|
|
584
|
+
|
|
585
|
+
for ref in _tag_candidates(repo_spec.version):
|
|
586
|
+
setup = self._resolve_setup(
|
|
587
|
+
source, repo_spec, cache_dir, None, upgrade=upgrade, ref=ref
|
|
588
|
+
)
|
|
589
|
+
if setup is not None:
|
|
590
|
+
return setup, True
|
|
591
|
+
return None, True
|
|
592
|
+
|
|
593
|
+
def _resolve_setup(
|
|
594
|
+
self,
|
|
595
|
+
source: ReleaseSource,
|
|
596
|
+
repo_spec: RepoSpec,
|
|
597
|
+
cache_dir: Path,
|
|
598
|
+
setup_path: Path | None,
|
|
599
|
+
*,
|
|
600
|
+
upgrade: bool,
|
|
601
|
+
ref: str | None = None,
|
|
602
|
+
) -> SetupConfig | None:
|
|
603
|
+
"""Resolve ixt.setup.toml: local override > remote (gated by flag).
|
|
604
|
+
|
|
605
|
+
*ref* pins the remote fetch to a specific revision (tag) so the
|
|
606
|
+
config matches the release being installed.
|
|
607
|
+
"""
|
|
608
|
+
log = get_logger("binary")
|
|
609
|
+
if setup_path is not None:
|
|
610
|
+
from ixt.config.setup_toml import load_local_setup_toml
|
|
611
|
+
|
|
612
|
+
setup = load_local_setup_toml(setup_path)
|
|
613
|
+
log.debug(f"using local setup.toml: {setup_path}")
|
|
614
|
+
elif is_setup_toml_enabled():
|
|
615
|
+
setup = self._fetch_setup_toml(source, repo_spec, cache_dir, upgrade=upgrade, ref=ref)
|
|
616
|
+
else:
|
|
617
|
+
setup = None
|
|
618
|
+
if setup:
|
|
619
|
+
log.debug(
|
|
620
|
+
f"ixt.setup.toml: name={setup.name} expose={setup.expose}"
|
|
621
|
+
f" pattern={setup.asset_pattern}"
|
|
622
|
+
)
|
|
623
|
+
return setup
|
|
624
|
+
|
|
625
|
+
def _select_asset(
|
|
626
|
+
self,
|
|
627
|
+
release: Release,
|
|
628
|
+
asset_pattern: str | None,
|
|
629
|
+
tool_name: str,
|
|
630
|
+
main_spec: str,
|
|
631
|
+
) -> Asset:
|
|
632
|
+
heuristics = load_heuristics()
|
|
633
|
+
asset = select_asset(
|
|
634
|
+
release.assets,
|
|
635
|
+
tool_name,
|
|
636
|
+
_strip_v_prefix(release.tag),
|
|
637
|
+
heuristics=heuristics,
|
|
638
|
+
asset_pattern=asset_pattern,
|
|
639
|
+
verbose=get_verbosity(),
|
|
640
|
+
)
|
|
641
|
+
if asset is None:
|
|
642
|
+
raise AssetSelectionError(
|
|
643
|
+
main_spec,
|
|
644
|
+
release.tag,
|
|
645
|
+
[a.name for a in release.assets],
|
|
646
|
+
)
|
|
647
|
+
return asset
|
|
648
|
+
|
|
649
|
+
def uninstall_package(self, env_dir: Path, package: str) -> None:
|
|
650
|
+
"""No-op — binary tools are atomic (uninstall removes the whole env)."""
|
|
651
|
+
|
|
652
|
+
def find_binaries(self, env_dir: Path) -> list[Path]:
|
|
653
|
+
"""Discover executables in the extracted environment."""
|
|
654
|
+
meta = _read_metadata(env_dir)
|
|
655
|
+
if meta and meta.get("binaries"):
|
|
656
|
+
# Fast path: use cached binary list from metadata
|
|
657
|
+
existing = _existing_metadata_binaries(meta, env_dir)
|
|
658
|
+
if existing:
|
|
659
|
+
return sorted((Path(p) for p in existing.values()), key=lambda p: p.name)
|
|
660
|
+
|
|
661
|
+
# Slow path: re-scan the environment
|
|
662
|
+
heuristics = load_heuristics()
|
|
663
|
+
return find_executables(env_dir, expose=heuristics.expose, log=get_logger("binary"))
|
|
664
|
+
|
|
665
|
+
def get_package_binaries(self, env_dir: Path, package_name: str) -> dict[str, str]:
|
|
666
|
+
"""Return discovered binaries as {name: path}.
|
|
667
|
+
|
|
668
|
+
Priority: setup.toml expose > all discovered executables.
|
|
669
|
+
When setup.toml defines an expose list, only those names are returned.
|
|
670
|
+
"""
|
|
671
|
+
meta = _read_metadata(env_dir)
|
|
672
|
+
if not meta or not meta.get("binaries"):
|
|
673
|
+
# Fallback: live scan
|
|
674
|
+
bins = self.find_binaries(env_dir)
|
|
675
|
+
all_bins = {p.name: str(p) for p in bins}
|
|
676
|
+
else:
|
|
677
|
+
all_bins = _existing_metadata_binaries(meta, env_dir)
|
|
678
|
+
if not all_bins:
|
|
679
|
+
bins = self.find_binaries(env_dir)
|
|
680
|
+
all_bins = {p.name: str(p) for p in bins}
|
|
681
|
+
|
|
682
|
+
# Apply setup.toml expose filter if present
|
|
683
|
+
setup = meta.get("setup") if meta else None
|
|
684
|
+
if setup and setup.get("expose"):
|
|
685
|
+
expose_names = set(setup["expose"])
|
|
686
|
+
filtered = {k: v for k, v in all_bins.items() if k in expose_names}
|
|
687
|
+
if filtered:
|
|
688
|
+
return filtered
|
|
689
|
+
from ixt.libs.logger import get_logger
|
|
690
|
+
|
|
691
|
+
get_logger("binary").warn(
|
|
692
|
+
f"setup.toml expose {setup['expose']} matched no binaries "
|
|
693
|
+
f"(available: {list(all_bins)}), falling back to all"
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
return all_bins
|
|
697
|
+
|
|
698
|
+
def installed_version(self, env_dir: Path, package_name: str) -> str | None:
|
|
699
|
+
"""Return the installed version from the release tag."""
|
|
700
|
+
meta = _read_metadata(env_dir)
|
|
701
|
+
if meta is None:
|
|
702
|
+
return None
|
|
703
|
+
tag = meta.get("tag", "")
|
|
704
|
+
return _strip_v_prefix(tag) if tag else None
|
|
705
|
+
|
|
706
|
+
def export(self, env_dir: Path) -> list[str]:
|
|
707
|
+
"""Return installed spec with pinned version."""
|
|
708
|
+
meta = _read_metadata(env_dir)
|
|
709
|
+
if meta is None:
|
|
710
|
+
return []
|
|
711
|
+
spec = meta.get("spec", "")
|
|
712
|
+
tag = meta.get("tag", "")
|
|
713
|
+
if spec and tag:
|
|
714
|
+
repo_spec = parse_spec(spec)
|
|
715
|
+
if repo_spec:
|
|
716
|
+
return [f"{repo_spec.owner}/{repo_spec.repo}@{tag}"]
|
|
717
|
+
return []
|
|
718
|
+
|
|
719
|
+
# -- Internal helpers ---------------------------------------------------
|
|
720
|
+
|
|
721
|
+
def _cached_release(self, main_spec: str) -> Release | None:
|
|
722
|
+
"""Rebuild a Release from a fresh resolve-cache entry that carries assets.
|
|
723
|
+
|
|
724
|
+
Returns None when there is no fresh entry or it has no assets (e.g. the
|
|
725
|
+
GitHub HEAD-redirect path, which caches only the tag). Keyed identically
|
|
726
|
+
to ``resolve.resolve_latest`` so the resolution and the install agree.
|
|
727
|
+
"""
|
|
728
|
+
from ixt.core import resolve_cache
|
|
729
|
+
|
|
730
|
+
entry = resolve_cache.get_entry(
|
|
731
|
+
resolve_cache.make_key("binary", main_spec), settings=self.settings
|
|
732
|
+
)
|
|
733
|
+
if not entry:
|
|
734
|
+
return None
|
|
735
|
+
assets = entry.get("assets")
|
|
736
|
+
if not assets:
|
|
737
|
+
return None
|
|
738
|
+
rebuilt = [
|
|
739
|
+
Asset(name=a["name"], url=a["url"], size=a.get("size", 0))
|
|
740
|
+
for a in assets
|
|
741
|
+
if isinstance(a, dict) and a.get("name") and a.get("url")
|
|
742
|
+
]
|
|
743
|
+
if not rebuilt:
|
|
744
|
+
# Entry present but every asset is malformed/schema-drifted: fall
|
|
745
|
+
# back to the API rather than fail asset selection on an empty list.
|
|
746
|
+
return None
|
|
747
|
+
return Release(tag=entry["version"], assets=rebuilt, resolution_source="resolve-cache")
|
|
748
|
+
|
|
749
|
+
def _fetch_release(
|
|
750
|
+
self,
|
|
751
|
+
source: ReleaseSource,
|
|
752
|
+
repo_spec: RepoSpec,
|
|
753
|
+
*,
|
|
754
|
+
env_dir: Path | None = None,
|
|
755
|
+
upgrade: bool = False,
|
|
756
|
+
known_tag: str | None = None,
|
|
757
|
+
asset_pattern: str | None = None,
|
|
758
|
+
pattern_name: str | None = None,
|
|
759
|
+
) -> Release:
|
|
760
|
+
"""Fetch the target release (by tag, partial semver, or latest).
|
|
761
|
+
|
|
762
|
+
Fast-path: a GitHub repo that has an explicit or cached
|
|
763
|
+
``asset_pattern`` can synthesize the Release without touching the
|
|
764
|
+
API. Unpinned installs resolve the tag via a plain 302 on
|
|
765
|
+
``github.com/.../releases/latest``; pinned installs reuse their tag
|
|
766
|
+
directly. Falls back silently on any failure. *known_tag* lets an
|
|
767
|
+
upgrade reuse the tag it already resolved, skipping even that 302.
|
|
768
|
+
"""
|
|
769
|
+
if not repo_spec.version:
|
|
770
|
+
# Pattern-based fast-path: consults the global metadata cache and
|
|
771
|
+
# falls back to per-tool metadata. Works on fresh install and
|
|
772
|
+
# upgrade alike — upgrade kept in the signature for callers
|
|
773
|
+
# that still want to force-refresh the cached setup.toml.
|
|
774
|
+
fast = _fast_path_release(
|
|
775
|
+
repo_spec,
|
|
776
|
+
env_dir,
|
|
777
|
+
known_tag=known_tag,
|
|
778
|
+
asset_pattern=asset_pattern,
|
|
779
|
+
pattern_name=pattern_name,
|
|
780
|
+
settings=self.settings,
|
|
781
|
+
)
|
|
782
|
+
if fast is not None:
|
|
783
|
+
return fast
|
|
784
|
+
|
|
785
|
+
if repo_spec.version:
|
|
786
|
+
from ixt.libs.semver import is_partial_version
|
|
787
|
+
|
|
788
|
+
if is_partial_version(repo_spec.version):
|
|
789
|
+
fast = _fast_path_release(
|
|
790
|
+
repo_spec,
|
|
791
|
+
env_dir,
|
|
792
|
+
known_tag=None,
|
|
793
|
+
asset_pattern=asset_pattern,
|
|
794
|
+
pattern_name=pattern_name,
|
|
795
|
+
allow_partial_version=True,
|
|
796
|
+
settings=self.settings,
|
|
797
|
+
)
|
|
798
|
+
if fast is not None:
|
|
799
|
+
return fast
|
|
800
|
+
self._require_github_api(repo_spec)
|
|
801
|
+
return self._fetch_release_partial(source, repo_spec)
|
|
802
|
+
|
|
803
|
+
tag = repo_spec.version
|
|
804
|
+
for direct_tag in _tag_candidates(tag):
|
|
805
|
+
fast = _fast_path_release(
|
|
806
|
+
repo_spec,
|
|
807
|
+
env_dir,
|
|
808
|
+
known_tag=direct_tag,
|
|
809
|
+
asset_pattern=asset_pattern,
|
|
810
|
+
pattern_name=pattern_name,
|
|
811
|
+
invalidate_cache_on_error=False,
|
|
812
|
+
settings=self.settings,
|
|
813
|
+
)
|
|
814
|
+
if fast is not None:
|
|
815
|
+
return fast
|
|
816
|
+
|
|
817
|
+
self._require_github_api(repo_spec)
|
|
818
|
+
# Try exact tag first, then with 'v' prefix.
|
|
819
|
+
try:
|
|
820
|
+
release = source.get_release_by_tag(repo_spec.owner, repo_spec.repo, tag)
|
|
821
|
+
except Exception:
|
|
822
|
+
if not tag.startswith("v"):
|
|
823
|
+
release = source.get_release_by_tag(repo_spec.owner, repo_spec.repo, f"v{tag}")
|
|
824
|
+
release.resolution_source = f"{repo_spec.platform}-api"
|
|
825
|
+
return release
|
|
826
|
+
raise
|
|
827
|
+
release.resolution_source = f"{repo_spec.platform}-api"
|
|
828
|
+
return release
|
|
829
|
+
|
|
830
|
+
self._require_github_api(repo_spec)
|
|
831
|
+
release = source.get_latest_release(repo_spec.owner, repo_spec.repo)
|
|
832
|
+
release.resolution_source = f"{repo_spec.platform}-api"
|
|
833
|
+
return release
|
|
834
|
+
|
|
835
|
+
def _require_github_api(self, repo_spec: RepoSpec) -> None:
|
|
836
|
+
"""Fail before touching ``api.github.com`` in strict mode."""
|
|
837
|
+
if repo_spec.platform == "github" and not is_github_api_allowed():
|
|
838
|
+
raise GitHubApiDisabledError(disabled_api_message(repo_spec))
|
|
839
|
+
|
|
840
|
+
def _fetch_release_partial(
|
|
841
|
+
self,
|
|
842
|
+
source: ReleaseSource,
|
|
843
|
+
repo_spec: RepoSpec,
|
|
844
|
+
) -> Release:
|
|
845
|
+
"""Fetch the latest release matching a partial semver (e.g. '14' or '14.1')."""
|
|
846
|
+
from ixt.libs.semver import matches_partial, parse_version
|
|
847
|
+
|
|
848
|
+
releases = source.list_releases(repo_spec.owner, repo_spec.repo)
|
|
849
|
+
partial = repo_spec.version or ""
|
|
850
|
+
candidates = [
|
|
851
|
+
r
|
|
852
|
+
for r in releases
|
|
853
|
+
if not r.prerelease and not r.draft and matches_partial(r.tag, partial)
|
|
854
|
+
]
|
|
855
|
+
if not candidates:
|
|
856
|
+
raise RuntimeError(
|
|
857
|
+
f"No release matching '{partial}' for {repo_spec.owner}/{repo_spec.repo}"
|
|
858
|
+
)
|
|
859
|
+
# Sort by parsed version descending, pick newest
|
|
860
|
+
candidates.sort(key=lambda r: parse_version(r.tag) or (), reverse=True)
|
|
861
|
+
release = candidates[0]
|
|
862
|
+
release.resolution_source = f"{repo_spec.platform}-api"
|
|
863
|
+
return release
|
|
864
|
+
|
|
865
|
+
def _fetch_setup_toml(
|
|
866
|
+
self,
|
|
867
|
+
source: ReleaseSource,
|
|
868
|
+
repo_spec: RepoSpec,
|
|
869
|
+
cache_dir: Path,
|
|
870
|
+
*,
|
|
871
|
+
upgrade: bool = False,
|
|
872
|
+
ref: str | None = None,
|
|
873
|
+
) -> SetupConfig | None:
|
|
874
|
+
"""Fetch ixt.setup.toml from the repo (cached, silent on 404)."""
|
|
875
|
+
from ixt.config.setup_toml import fetch_setup_toml
|
|
876
|
+
|
|
877
|
+
# On upgrade, invalidate cached setup.toml so we fetch fresh
|
|
878
|
+
if upgrade:
|
|
879
|
+
cached = cache_dir / "setup.toml"
|
|
880
|
+
cached.unlink(missing_ok=True)
|
|
881
|
+
|
|
882
|
+
return fetch_setup_toml(
|
|
883
|
+
source,
|
|
884
|
+
repo_spec.owner,
|
|
885
|
+
repo_spec.repo,
|
|
886
|
+
cache_dir=cache_dir,
|
|
887
|
+
ref=ref,
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
def _download_asset(
|
|
891
|
+
self,
|
|
892
|
+
source: ReleaseSource,
|
|
893
|
+
repo_spec: RepoSpec,
|
|
894
|
+
asset: Asset,
|
|
895
|
+
cache_dir: Path,
|
|
896
|
+
*,
|
|
897
|
+
tag: str,
|
|
898
|
+
upgrade: bool = False,
|
|
899
|
+
) -> Path:
|
|
900
|
+
"""Download an asset, using cache when available."""
|
|
901
|
+
cached = _cached_asset(cache_dir, asset.name)
|
|
902
|
+
if cached is not None and not upgrade:
|
|
903
|
+
self._record_download(repo_spec, asset, cached, tag=tag)
|
|
904
|
+
return cached
|
|
905
|
+
|
|
906
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
907
|
+
dest = cache_dir / asset.name
|
|
908
|
+
source.download_asset(asset.url, dest)
|
|
909
|
+
self._record_download(repo_spec, asset, dest, tag=tag)
|
|
910
|
+
return dest
|
|
911
|
+
|
|
912
|
+
def _record_download(
|
|
913
|
+
self,
|
|
914
|
+
repo_spec: RepoSpec,
|
|
915
|
+
asset: Asset,
|
|
916
|
+
path: Path,
|
|
917
|
+
*,
|
|
918
|
+
tag: str,
|
|
919
|
+
) -> None:
|
|
920
|
+
"""Index the downloaded binary asset for later cache pruning."""
|
|
921
|
+
from ixt.core.cache import record_download
|
|
922
|
+
|
|
923
|
+
record_download(
|
|
924
|
+
settings=self.settings,
|
|
925
|
+
kind="binary-asset",
|
|
926
|
+
platform=repo_spec.platform,
|
|
927
|
+
host=repo_spec.host,
|
|
928
|
+
owner=repo_spec.owner,
|
|
929
|
+
repo=repo_spec.repo,
|
|
930
|
+
tag=tag,
|
|
931
|
+
asset_name=asset.name,
|
|
932
|
+
asset_url=asset.url,
|
|
933
|
+
asset_size=asset.size,
|
|
934
|
+
path=path,
|
|
935
|
+
)
|