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
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Asset URL resolution helpers for the binary backend.
|
|
2
|
+
|
|
3
|
+
Pure resolution logic kept orthogonal to ``BinaryBackend``: turning a
|
|
4
|
+
release tag + asset name into a reusable ``{tag}``/``{version}`` pattern,
|
|
5
|
+
and a fast-path that synthesizes a ``Release`` directly from a cached
|
|
6
|
+
pattern plus either a known tag or a ``releases/latest`` redirect —
|
|
7
|
+
bypassing the forge API.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from ixt.config.asset_index import (
|
|
16
|
+
AssetIndexEntry,
|
|
17
|
+
canonical_github_spec,
|
|
18
|
+
load_configured_asset_index,
|
|
19
|
+
load_learned_asset_index,
|
|
20
|
+
platform_key,
|
|
21
|
+
)
|
|
22
|
+
from ixt.config.settings import Settings
|
|
23
|
+
from ixt.net.source import Asset, Release, RepoSpec
|
|
24
|
+
|
|
25
|
+
_METADATA_FILE = "binary_metadata.json"
|
|
26
|
+
|
|
27
|
+
# Direct-download fast path: probe at most this many candidate asset names
|
|
28
|
+
# (canonical platform aliases first) before deferring to the release API.
|
|
29
|
+
_FAST_PATH_MAX_PROBES = 3
|
|
30
|
+
_FAST_PATH_HEAD_TIMEOUT = 30
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def derive_asset_pattern(asset_name: str, tag: str) -> str | None:
|
|
34
|
+
"""Turn a resolved asset name into a ``{tag}``/``{version}`` template.
|
|
35
|
+
|
|
36
|
+
Replaces the tag (longest) first, then the stripped version on what
|
|
37
|
+
remains. If the asset name does not embed the version, returns the
|
|
38
|
+
constant asset name as the pattern. The fast-path later validates the
|
|
39
|
+
interpolated URL with HEAD and falls back to the release API on 404.
|
|
40
|
+
"""
|
|
41
|
+
if not asset_name or not tag:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
version = tag.removeprefix("v")
|
|
45
|
+
pattern = asset_name
|
|
46
|
+
if tag and tag in pattern:
|
|
47
|
+
pattern = pattern.replace(tag, "{tag}")
|
|
48
|
+
if version and version != tag and version in pattern:
|
|
49
|
+
pattern = pattern.replace(version, "{version}")
|
|
50
|
+
return pattern
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read_pattern_from_env(env_dir: Path) -> str | None:
|
|
54
|
+
"""Return a learned/backfilled asset pattern from an installed env."""
|
|
55
|
+
ixt_json = env_dir / "ixt.json"
|
|
56
|
+
if ixt_json.exists():
|
|
57
|
+
try:
|
|
58
|
+
from ixt.config.models import ToolRecord
|
|
59
|
+
|
|
60
|
+
pattern = ToolRecord.load_json(ixt_json).asset_pattern
|
|
61
|
+
except Exception:
|
|
62
|
+
pattern = None
|
|
63
|
+
if pattern:
|
|
64
|
+
return pattern
|
|
65
|
+
|
|
66
|
+
metadata = env_dir / _METADATA_FILE
|
|
67
|
+
if not metadata.exists():
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
with metadata.open(encoding="utf-8") as f:
|
|
71
|
+
data = json.load(f)
|
|
72
|
+
except (json.JSONDecodeError, OSError):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
asset_name = data.get("asset_name")
|
|
76
|
+
tag = data.get("tag")
|
|
77
|
+
if not isinstance(asset_name, str) or not isinstance(tag, str):
|
|
78
|
+
return None
|
|
79
|
+
return derive_asset_pattern(asset_name, tag)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _fast_path_release(
|
|
83
|
+
repo_spec: RepoSpec,
|
|
84
|
+
env_dir: Path | None,
|
|
85
|
+
*,
|
|
86
|
+
known_tag: str | None = None,
|
|
87
|
+
asset_pattern: str | None = None,
|
|
88
|
+
pattern_name: str | None = None,
|
|
89
|
+
invalidate_cache_on_error: bool = True,
|
|
90
|
+
allow_partial_version: bool = False,
|
|
91
|
+
settings: Settings | None = None,
|
|
92
|
+
) -> Release | None:
|
|
93
|
+
"""Synthesize a Release without calling the forge API.
|
|
94
|
+
|
|
95
|
+
Works on fresh install *and* upgrade: looks for a learned pattern in
|
|
96
|
+
an explicit pattern first, then the global metadata cache, then the per-tool
|
|
97
|
+
metadata (if the tool is already installed). Resolves the latest tag via
|
|
98
|
+
the ``releases/latest`` redirect unless *known_tag* is supplied,
|
|
99
|
+
interpolates the pattern, and verifies the most common candidate URLs
|
|
100
|
+
via concurrent HEAD requests — rarer alias spellings defer to the
|
|
101
|
+
release API. Only GitHub is supported; GitLab releases still go
|
|
102
|
+
through the API.
|
|
103
|
+
|
|
104
|
+
*known_tag* (when supplied by an upgrade that already resolved the
|
|
105
|
+
latest tag) is reused verbatim, skipping the ``releases/latest`` HEAD.
|
|
106
|
+
"""
|
|
107
|
+
if repo_spec.platform != "github":
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
index_entry = _index_entry(repo_spec, settings=settings)
|
|
111
|
+
if asset_pattern is None and index_entry is not None:
|
|
112
|
+
indexed = _indexed_asset_release(repo_spec, index_entry, known_tag=known_tag)
|
|
113
|
+
if indexed is not None:
|
|
114
|
+
return indexed
|
|
115
|
+
if allow_partial_version:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
from ixt.config import asset_pattern_cache
|
|
119
|
+
|
|
120
|
+
pattern_source = "explicit" if asset_pattern else "cache"
|
|
121
|
+
learned_cache = False
|
|
122
|
+
pattern = asset_pattern
|
|
123
|
+
if pattern is None and index_entry is not None:
|
|
124
|
+
platform_pattern = index_entry.patterns.get(platform_key())
|
|
125
|
+
if platform_pattern:
|
|
126
|
+
pattern = platform_pattern
|
|
127
|
+
pattern_source = f"asset-index:{index_entry.source}"
|
|
128
|
+
if pattern is None:
|
|
129
|
+
pattern = asset_pattern_cache.get(repo_spec.owner, repo_spec.repo)
|
|
130
|
+
learned_cache = pattern is not None
|
|
131
|
+
if pattern is None and env_dir is not None:
|
|
132
|
+
pattern = _read_pattern_from_env(env_dir)
|
|
133
|
+
if pattern:
|
|
134
|
+
pattern_source = "env"
|
|
135
|
+
asset_pattern_cache.set_(repo_spec.owner, repo_spec.repo, pattern)
|
|
136
|
+
if not pattern:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
if known_tag:
|
|
140
|
+
tag: str | None = known_tag
|
|
141
|
+
else:
|
|
142
|
+
from ixt.net.github_api import fetch_latest_tag
|
|
143
|
+
|
|
144
|
+
tag = fetch_latest_tag(
|
|
145
|
+
repo_spec.owner,
|
|
146
|
+
repo_spec.repo,
|
|
147
|
+
timeout=_FAST_PATH_HEAD_TIMEOUT,
|
|
148
|
+
)
|
|
149
|
+
if not tag:
|
|
150
|
+
return None
|
|
151
|
+
candidates = _asset_name_candidates(repo_spec, pattern, tag, pattern_name=pattern_name)
|
|
152
|
+
asset_name = _probe_candidates(repo_spec, tag, candidates)
|
|
153
|
+
if asset_name is not None:
|
|
154
|
+
url = _download_url(repo_spec.owner, repo_spec.repo, tag, asset_name)
|
|
155
|
+
return Release(
|
|
156
|
+
tag=tag,
|
|
157
|
+
assets=[Asset(name=asset_name, url=url)],
|
|
158
|
+
resolution_source=("cache:asset_patterns.json" if learned_cache else pattern_source),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Learned pattern no longer matches — drop it so we relearn on fallback.
|
|
162
|
+
# Explicit patterns should not mutate the user's learned cache.
|
|
163
|
+
from ixt.libs.logger import get_logger
|
|
164
|
+
|
|
165
|
+
get_logger("binary").debug(
|
|
166
|
+
f"asset pattern stale for {repo_spec.owner}/{repo_spec.repo} "
|
|
167
|
+
"— invalidating cache, falling back to release API"
|
|
168
|
+
)
|
|
169
|
+
if pattern_source in {"cache", "env"} and invalidate_cache_on_error:
|
|
170
|
+
asset_pattern_cache.invalidate(repo_spec.owner, repo_spec.repo)
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _index_entry(
|
|
175
|
+
repo_spec: RepoSpec,
|
|
176
|
+
*,
|
|
177
|
+
settings: Settings | None,
|
|
178
|
+
) -> AssetIndexEntry | None:
|
|
179
|
+
canonical = canonical_github_spec(repo_spec)
|
|
180
|
+
if canonical is None:
|
|
181
|
+
return None
|
|
182
|
+
configured = load_configured_asset_index(settings=settings)
|
|
183
|
+
if canonical in configured:
|
|
184
|
+
return configured[canonical]
|
|
185
|
+
learned = load_learned_asset_index(settings=settings)
|
|
186
|
+
return learned.get(canonical)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _indexed_asset_release(
|
|
190
|
+
repo_spec: RepoSpec,
|
|
191
|
+
entry: AssetIndexEntry,
|
|
192
|
+
*,
|
|
193
|
+
known_tag: str | None,
|
|
194
|
+
) -> Release | None:
|
|
195
|
+
indexed = entry.assets.get(platform_key())
|
|
196
|
+
if indexed is None:
|
|
197
|
+
return None
|
|
198
|
+
if known_tag is not None and not _tag_matches(known_tag, indexed.tag):
|
|
199
|
+
return None
|
|
200
|
+
if known_tag is None and repo_spec.version:
|
|
201
|
+
if _is_partial_version(repo_spec.version):
|
|
202
|
+
if not _matches_partial(indexed.tag, repo_spec.version):
|
|
203
|
+
return None
|
|
204
|
+
elif not _tag_matches(repo_spec.version, indexed.tag):
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
url = indexed.url or _download_url(repo_spec.owner, repo_spec.repo, indexed.tag, indexed.asset)
|
|
208
|
+
return Release(
|
|
209
|
+
tag=indexed.tag,
|
|
210
|
+
assets=[Asset(name=indexed.asset, url=url)],
|
|
211
|
+
resolution_source=f"asset-index:{entry.source}",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _tag_matches(requested: str, actual: str) -> bool:
|
|
216
|
+
if requested == actual:
|
|
217
|
+
return True
|
|
218
|
+
return requested.removeprefix("v") == actual.removeprefix("v")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _is_partial_version(version: str) -> bool:
|
|
222
|
+
from ixt.libs.semver import is_partial_version
|
|
223
|
+
|
|
224
|
+
return is_partial_version(version)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _matches_partial(tag: str, partial: str) -> bool:
|
|
228
|
+
from ixt.libs.semver import matches_partial
|
|
229
|
+
|
|
230
|
+
return matches_partial(tag, partial)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _asset_name_candidates(
|
|
234
|
+
repo_spec: RepoSpec,
|
|
235
|
+
pattern: str,
|
|
236
|
+
tag: str,
|
|
237
|
+
*,
|
|
238
|
+
pattern_name: str | None = None,
|
|
239
|
+
) -> list[str]:
|
|
240
|
+
"""Interpolate direct-download asset names for all platform aliases."""
|
|
241
|
+
from ixt.config.heuristics import get_platform_aliases
|
|
242
|
+
|
|
243
|
+
version = tag.removeprefix("v")
|
|
244
|
+
name = pattern_name or repo_spec.repo
|
|
245
|
+
os_values, arch_values = get_platform_aliases()
|
|
246
|
+
if "{os}" not in pattern:
|
|
247
|
+
os_values = [""]
|
|
248
|
+
if "{arch}" not in pattern:
|
|
249
|
+
arch_values = [""]
|
|
250
|
+
|
|
251
|
+
candidates: list[str] = []
|
|
252
|
+
seen: set[str] = set()
|
|
253
|
+
for os_value in os_values:
|
|
254
|
+
for arch_value in arch_values:
|
|
255
|
+
asset_name = (
|
|
256
|
+
pattern.replace("{name}", name)
|
|
257
|
+
.replace("{tag}", tag)
|
|
258
|
+
.replace("{version}", version)
|
|
259
|
+
.replace("{os}", os_value)
|
|
260
|
+
.replace("{arch}", arch_value)
|
|
261
|
+
)
|
|
262
|
+
if asset_name not in seen:
|
|
263
|
+
candidates.append(asset_name)
|
|
264
|
+
seen.add(asset_name)
|
|
265
|
+
return candidates
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _download_url(owner: str, repo: str, tag: str, asset_name: str) -> str:
|
|
269
|
+
return f"https://github.com/{owner}/{repo}/releases/download/{tag}/{asset_name}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _probe_candidates(repo_spec: RepoSpec, tag: str, candidates: list[str]) -> str | None:
|
|
273
|
+
"""Return the first existing asset among the most common candidates.
|
|
274
|
+
|
|
275
|
+
Only the top :data:`_FAST_PATH_MAX_PROBES` names (canonical platform
|
|
276
|
+
aliases first) are probed, concurrently, via HEAD on the CDN. Rarer
|
|
277
|
+
alias spellings are left to the release API rather than fanning out a
|
|
278
|
+
long tail of requests. The usual single-candidate case (a learned
|
|
279
|
+
pattern with literal ``os``/``arch``) is probed directly, no threads.
|
|
280
|
+
"""
|
|
281
|
+
from ixt.net.http import HttpError, get_final_url
|
|
282
|
+
|
|
283
|
+
probed = candidates[:_FAST_PATH_MAX_PROBES]
|
|
284
|
+
if not probed:
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
def _exists(asset_name: str) -> bool:
|
|
288
|
+
try:
|
|
289
|
+
get_final_url(
|
|
290
|
+
_download_url(repo_spec.owner, repo_spec.repo, tag, asset_name),
|
|
291
|
+
timeout=_FAST_PATH_HEAD_TIMEOUT,
|
|
292
|
+
)
|
|
293
|
+
except HttpError:
|
|
294
|
+
return False
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
if len(probed) == 1:
|
|
298
|
+
return probed[0] if _exists(probed[0]) else None
|
|
299
|
+
|
|
300
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
301
|
+
|
|
302
|
+
with ThreadPoolExecutor(max_workers=len(probed)) as pool:
|
|
303
|
+
hits = list(pool.map(_exists, probed))
|
|
304
|
+
for asset_name, ok in zip(probed, hits, strict=True):
|
|
305
|
+
if ok:
|
|
306
|
+
return asset_name
|
|
307
|
+
return None
|