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