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,305 @@
1
+ """Asset-index loading for GitHub binary resolution.
2
+
3
+ ``asset_index.json`` is public resolution metadata. It maps canonical GitHub
4
+ specs (``@gh:owner/repo``) to platform-specific asset patterns or exact assets.
5
+ It is intentionally separate from ``registry.toml``: registry resolves names,
6
+ asset-index resolves release assets.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from ixt.config.settings import Settings, get_settings
18
+ from ixt.data import get_data_path
19
+ from ixt.net.source import RepoSpec, parse_spec
20
+ from ixt.platform import OS, Arch, get_arch, get_os
21
+
22
+ ASSET_INDEX_FILENAME = "asset_index.json"
23
+ GITHUB_API_ENV = "IXT_GITHUB_API"
24
+
25
+ _OS_KEY = {
26
+ OS.LINUX: "linux",
27
+ OS.MACOS: "macos",
28
+ OS.WINDOWS: "windows",
29
+ }
30
+ _ARCH_KEY = {
31
+ Arch.X86_64: "x86_64",
32
+ Arch.ARM64: "aarch64",
33
+ Arch.ARMV7: "armv7",
34
+ }
35
+
36
+
37
+ class GitHubApiDisabledError(RuntimeError):
38
+ """Raised when strict no-GitHub-API mode lacks local resolution metadata."""
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class AssetIndexAsset:
43
+ """An exact platform asset already known by an asset-index source."""
44
+
45
+ tag: str
46
+ asset: str
47
+ url: str | None = None
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class AssetIndexEntry:
52
+ """Resolution metadata for one canonical GitHub spec."""
53
+
54
+ canonical_spec: str
55
+ names: list[str] = field(default_factory=list)
56
+ patterns: dict[str, str] = field(default_factory=dict)
57
+ assets: dict[str, AssetIndexAsset] = field(default_factory=dict)
58
+ source: str = ""
59
+
60
+
61
+ def github_api_mode() -> str:
62
+ """Return the configured GitHub API mode (``auto`` or ``never``)."""
63
+ value = os.environ.get(GITHUB_API_ENV, "auto").strip().lower()
64
+ return value or "auto"
65
+
66
+
67
+ def is_github_api_allowed() -> bool:
68
+ """Whether calls to ``api.github.com`` are allowed."""
69
+ return github_api_mode() != "never"
70
+
71
+
72
+ def disabled_api_message(repo_spec: RepoSpec) -> str:
73
+ """Clear error text for strict mode when local metadata is insufficient."""
74
+ return (
75
+ f"Cannot resolve GitHub release for {repo_spec.owner}/{repo_spec.repo}: "
76
+ f"{GITHUB_API_ENV}=never is set and no matching asset_index.json entry, "
77
+ "learned asset pattern, exact cached asset, or downloaded cache metadata "
78
+ "could resolve the asset. Preload an asset_index.json with IXT_ASSET_INDEX "
79
+ "or unset IXT_GITHUB_API to allow the GitHub Releases API fallback."
80
+ )
81
+
82
+
83
+ def platform_key(
84
+ *,
85
+ os_override: OS | None = None,
86
+ arch_override: Arch | None = None,
87
+ ) -> str:
88
+ """Return the public ``os/arch`` key for the current or supplied platform."""
89
+ os_key = _OS_KEY[os_override or get_os()]
90
+ arch_key = _ARCH_KEY[arch_override or get_arch()]
91
+ return f"{os_key}/{arch_key}"
92
+
93
+
94
+ def canonical_github_spec(repo_spec: RepoSpec) -> str | None:
95
+ """Return ``@gh:owner/repo`` for GitHub specs; otherwise None."""
96
+ if repo_spec.platform != "github":
97
+ return None
98
+ return f"@gh:{repo_spec.owner}/{repo_spec.repo}"
99
+
100
+
101
+ def load_configured_asset_index(*, settings: Settings | None = None) -> dict[str, AssetIndexEntry]:
102
+ """Load built-in, user-config and env-supplied asset indexes.
103
+
104
+ Lower-priority sources are loaded first. Later sources replace an entire
105
+ canonical entry when the same top-level key is present.
106
+ """
107
+ settings = settings or get_settings()
108
+ merged: dict[str, AssetIndexEntry] = {}
109
+ for path, source in _configured_sources(settings):
110
+ merged.update(load_asset_index_file(path, source=source))
111
+ return merged
112
+
113
+
114
+ def load_learned_asset_index(*, settings: Settings | None = None) -> dict[str, AssetIndexEntry]:
115
+ """Expose learned cache metadata as low-priority asset-index entries."""
116
+ settings = settings or get_settings()
117
+ entries: dict[str, AssetIndexEntry] = {}
118
+ key = platform_key()
119
+
120
+ for owner_repo, pattern in _load_asset_patterns(settings).items():
121
+ owner, sep, repo = owner_repo.partition("/")
122
+ if not sep or not owner or not repo:
123
+ continue
124
+ canonical = f"@gh:{owner}/{repo}"
125
+ entry = entries.setdefault(
126
+ canonical,
127
+ AssetIndexEntry(canonical_spec=canonical, source="cache"),
128
+ )
129
+ entry.patterns[key] = pattern
130
+
131
+ for item in _load_download_assets(settings):
132
+ canonical = f"@gh:{item['owner']}/{item['repo']}"
133
+ entry = entries.setdefault(
134
+ canonical,
135
+ AssetIndexEntry(canonical_spec=canonical, source="cache"),
136
+ )
137
+ entry.assets[key] = AssetIndexAsset(
138
+ tag=item["tag"],
139
+ asset=item["asset"],
140
+ url=item["url"] or None,
141
+ )
142
+
143
+ return entries
144
+
145
+
146
+ def load_asset_index_file(path: Path, *, source: str | None = None) -> dict[str, AssetIndexEntry]:
147
+ """Parse one asset-index file. Missing or malformed files are ignored."""
148
+ if _is_remote_placeholder(path):
149
+ return {}
150
+ try:
151
+ with path.open(encoding="utf-8") as f:
152
+ data = json.load(f)
153
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
154
+ return {}
155
+ if not isinstance(data, dict):
156
+ return {}
157
+
158
+ source_label = source or str(path)
159
+ entries: dict[str, AssetIndexEntry] = {}
160
+ for raw_spec, raw_entry in data.items():
161
+ if not isinstance(raw_spec, str) or not isinstance(raw_entry, dict):
162
+ continue
163
+ canonical = _canonical_key(raw_spec)
164
+ if canonical is None:
165
+ continue
166
+ entry = _parse_entry(canonical, raw_entry, source=source_label)
167
+ if entry is not None:
168
+ entries[canonical] = entry
169
+ return entries
170
+
171
+
172
+ def _configured_sources(settings: Settings) -> list[tuple[Path, str]]:
173
+ sources = [
174
+ (get_data_path(ASSET_INDEX_FILENAME), "built-in"),
175
+ (settings.config_dir / ASSET_INDEX_FILENAME, "config"),
176
+ ]
177
+ for raw in _split_env_paths(os.environ.get("IXT_ASSET_INDEX", "")):
178
+ if raw.startswith(("http://", "https://")):
179
+ continue
180
+ sources.append((Path(raw).expanduser(), f"env:{raw}"))
181
+ return sources
182
+
183
+
184
+ def _split_env_paths(value: str) -> list[str]:
185
+ return [part for part in (item.strip() for item in value.split(";")) if part]
186
+
187
+
188
+ def _is_remote_placeholder(path: Path) -> bool:
189
+ raw = str(path)
190
+ return raw.startswith(("http://", "https://"))
191
+
192
+
193
+ def _canonical_key(raw_spec: str) -> str | None:
194
+ raw = raw_spec.strip()
195
+ if raw.startswith("@gh:"):
196
+ raw = raw[4:]
197
+ repo_spec = parse_spec(raw)
198
+ if repo_spec is None or repo_spec.platform != "github":
199
+ return None
200
+ return f"@gh:{repo_spec.owner}/{repo_spec.repo}"
201
+
202
+
203
+ def _parse_entry(
204
+ canonical: str,
205
+ data: dict[str, Any],
206
+ *,
207
+ source: str,
208
+ ) -> AssetIndexEntry | None:
209
+ names = _string_list(data.get("names"))
210
+ patterns = _string_map(data.get("patterns"))
211
+ assets = _asset_map(data.get("assets"))
212
+ if not patterns and not assets:
213
+ return None
214
+ return AssetIndexEntry(
215
+ canonical_spec=canonical,
216
+ names=names,
217
+ patterns=patterns,
218
+ assets=assets,
219
+ source=source,
220
+ )
221
+
222
+
223
+ def _string_list(value: object) -> list[str]:
224
+ if not isinstance(value, list):
225
+ return []
226
+ return [item for item in value if isinstance(item, str)]
227
+
228
+
229
+ def _string_map(value: object) -> dict[str, str]:
230
+ if not isinstance(value, dict):
231
+ return {}
232
+ return {str(k): v for k, v in value.items() if isinstance(k, str) and isinstance(v, str)}
233
+
234
+
235
+ def _asset_map(value: object) -> dict[str, AssetIndexAsset]:
236
+ if not isinstance(value, dict):
237
+ return {}
238
+ result: dict[str, AssetIndexAsset] = {}
239
+ for platform, raw_asset in value.items():
240
+ if not isinstance(platform, str) or not isinstance(raw_asset, dict):
241
+ continue
242
+ tag = raw_asset.get("tag")
243
+ asset = raw_asset.get("asset")
244
+ url = raw_asset.get("url")
245
+ if not isinstance(tag, str) or not isinstance(asset, str):
246
+ continue
247
+ result[platform] = AssetIndexAsset(
248
+ tag=tag,
249
+ asset=asset,
250
+ url=url if isinstance(url, str) else None,
251
+ )
252
+ return result
253
+
254
+
255
+ def _load_asset_patterns(settings: Settings) -> dict[str, str]:
256
+ path = settings.metadata_dir / "asset_patterns.json"
257
+ try:
258
+ with path.open(encoding="utf-8") as f:
259
+ data = json.load(f)
260
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
261
+ return {}
262
+ if not isinstance(data, dict):
263
+ return {}
264
+ return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
265
+
266
+
267
+ def _load_download_assets(settings: Settings) -> list[dict[str, str]]:
268
+ from ixt.core.cache import downloads_index_path
269
+
270
+ try:
271
+ with downloads_index_path(settings=settings).open(encoding="utf-8") as f:
272
+ data = json.load(f)
273
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
274
+ return []
275
+ if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
276
+ return []
277
+
278
+ items: list[dict[str, str]] = []
279
+ for entry in data["entries"]:
280
+ if not isinstance(entry, dict) or entry.get("kind") != "binary-asset":
281
+ continue
282
+ if entry.get("platform") != "github" or entry.get("host") != "github.com":
283
+ continue
284
+ owner = entry.get("owner")
285
+ repo = entry.get("repo")
286
+ tag = entry.get("tag")
287
+ asset = entry.get("asset")
288
+ url = entry.get("url")
289
+ if (
290
+ not isinstance(owner, str)
291
+ or not isinstance(repo, str)
292
+ or not isinstance(tag, str)
293
+ or not isinstance(asset, str)
294
+ ):
295
+ continue
296
+ items.append(
297
+ {
298
+ "owner": owner,
299
+ "repo": repo,
300
+ "tag": tag,
301
+ "asset": asset,
302
+ "url": url if isinstance(url, str) else "",
303
+ }
304
+ )
305
+ return items
@@ -0,0 +1,87 @@
1
+ """Cache of learned asset patterns for GitHub Releases.
2
+
3
+ Stored in ``$IXT_CACHE_HOME/metadata/asset_patterns.json``. When
4
+ ``IXT_CACHE_HOME`` is unset, ixt falls back to ``$XDG_CACHE_HOME/ixt`` or
5
+ ``~/.cache/ixt``.
6
+
7
+ Schema::
8
+
9
+ {
10
+ "sharkdp/bat": "bat-v{version}-x86_64-unknown-linux-gnu.tar.gz",
11
+ "BurntSushi/ripgrep": "ripgrep-{version}-x86_64-unknown-linux-musl.tar.gz"
12
+ }
13
+
14
+ No TTL. Patterns are stable across years — when one breaks (rare rename
15
+ by a maintainer), the caller invalidates the entry and relearns from a
16
+ fresh release API call.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import tempfile
24
+ from pathlib import Path
25
+
26
+ from ixt.config.settings import get_ixt_metadata_dir
27
+
28
+ CACHE_FILENAME = "asset_patterns.json"
29
+
30
+
31
+ def _cache_path() -> Path:
32
+ return get_ixt_metadata_dir() / CACHE_FILENAME
33
+
34
+
35
+ def _make_key(owner: str, repo: str) -> str:
36
+ return f"{owner}/{repo}"
37
+
38
+
39
+ def _load() -> dict[str, str]:
40
+ path = _cache_path()
41
+ if not path.exists():
42
+ return {}
43
+ try:
44
+ with path.open(encoding="utf-8") as f:
45
+ data = json.load(f)
46
+ except (json.JSONDecodeError, OSError):
47
+ return {}
48
+ if not isinstance(data, dict):
49
+ return {}
50
+ return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
51
+
52
+
53
+ def _atomic_write(data: dict[str, str]) -> None:
54
+ path = _cache_path()
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
57
+ try:
58
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
59
+ json.dump(data, f, indent=2, sort_keys=True)
60
+ f.write("\n")
61
+ os.replace(tmp, path)
62
+ except Exception:
63
+ Path(tmp).unlink(missing_ok=True)
64
+ raise
65
+
66
+
67
+ def get(owner: str, repo: str) -> str | None:
68
+ """Return the cached pattern for ``owner/repo``, or None if absent."""
69
+ return _load().get(_make_key(owner, repo))
70
+
71
+
72
+ def set_(owner: str, repo: str, pattern: str) -> None:
73
+ """Upsert a pattern for ``owner/repo``. Skips the write on no-op."""
74
+ data = _load()
75
+ key = _make_key(owner, repo)
76
+ if data.get(key) == pattern:
77
+ return
78
+ data[key] = pattern
79
+ _atomic_write(data)
80
+
81
+
82
+ def invalidate(owner: str, repo: str) -> None:
83
+ """Drop ``owner/repo`` from the cache if present."""
84
+ data = _load()
85
+ key = _make_key(owner, repo)
86
+ if data.pop(key, None) is not None:
87
+ _atomic_write(data)