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/export.py ADDED
@@ -0,0 +1,419 @@
1
+ """Generate ixt.toml from installed tools, preserving user install intent.
2
+
3
+ Unlike the removed ``freeze`` command (which always force-pinned ``==X.Y.Z``),
4
+ ``export`` rebuilds each tool entry from ``ixt.json``, keeping the version
5
+ constraint as the user originally wrote it at install time (range, pin, or no
6
+ constraint). Extra fields (``asset_pattern`` for binary, ``node_shim`` and
7
+ ``runtime`` for node) are also preserved.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from ixt.config.asset_index import canonical_github_spec, platform_key
18
+ from ixt.config.models import ToolRecord
19
+ from ixt.config.settings import Settings, get_settings
20
+ from ixt.config.toml import IxtConfig, ToolSpec, serialize_config
21
+ from ixt.core.backend import BackendType
22
+ from ixt.core.install import ToolNotInstalledError
23
+ from ixt.core.save import tool_entry_key, tool_install_spec
24
+ from ixt.net.source import parse_spec
25
+ from ixt.platform import OS, Arch
26
+
27
+ __all__ = [
28
+ "ToolNotInstalledError",
29
+ "export_asset_index",
30
+ "export_asset_index_json",
31
+ "export_config",
32
+ "export_toml",
33
+ ]
34
+
35
+
36
+ def export_toml(
37
+ names: list[str] | None = None,
38
+ *,
39
+ settings: Settings | None = None,
40
+ ) -> str:
41
+ """Return a TOML string for the requested tools (all if *names* is None)."""
42
+ config = export_config(names, settings=settings)
43
+ return serialize_config(config)
44
+
45
+
46
+ def export_config(
47
+ names: list[str] | None = None,
48
+ *,
49
+ settings: Settings | None = None,
50
+ ) -> IxtConfig:
51
+ """Build an IxtConfig from installed tools' metadata.
52
+
53
+ When *names* is given, only those tools are exported. Unknown names raise
54
+ ToolNotInstalledError. When *names* is None or empty, every installed tool
55
+ is exported.
56
+ """
57
+ settings = settings or get_settings()
58
+
59
+ records_by_name: dict[str, ToolRecord] = {}
60
+ for meta_file in settings.iter_installed_metadata():
61
+ record = ToolRecord.load_json(meta_file)
62
+ records_by_name[record.name] = record
63
+
64
+ if names:
65
+ selected: dict[str, ToolRecord] = {}
66
+ for name in names:
67
+ from ixt.core.install import resolve_tool_arg
68
+
69
+ tool_id = resolve_tool_arg(name, settings=settings)
70
+ if tool_id not in records_by_name:
71
+ raise ToolNotInstalledError(name)
72
+ selected[tool_id] = records_by_name[tool_id]
73
+ else:
74
+ selected = records_by_name
75
+
76
+ tools: dict[str, ToolSpec] = {}
77
+ for record in selected.values():
78
+ # Local installs (--from <path>) are not reproducible cross-machine.
79
+ if record.source == "local":
80
+ continue
81
+ spec = _record_to_spec(record, settings)
82
+ tools[spec.name] = spec
83
+
84
+ return IxtConfig(tools=tools)
85
+
86
+
87
+ def export_asset_index(
88
+ *,
89
+ settings: Settings | None = None,
90
+ from_registry: Path | None = None,
91
+ all_platforms: bool = False,
92
+ warn: Callable[[str], None] | None = None,
93
+ ) -> dict[str, Any]:
94
+ """Return portable binary-resolution metadata from the local cache.
95
+
96
+ This exports metadata, not installed environments and not the user's
97
+ ``ixt.toml`` intent. It intentionally omits local cache paths.
98
+ """
99
+ settings = settings or get_settings()
100
+ if from_registry is not None:
101
+ return _asset_index_from_registry(
102
+ from_registry,
103
+ all_platforms=all_platforms,
104
+ warn=warn,
105
+ )
106
+ if all_platforms:
107
+ _warn(
108
+ warn,
109
+ "--all-platforms is ignored without --from-registry; "
110
+ "cache-based asset-index export only knows the current platform",
111
+ )
112
+ return _asset_index_from_cache(settings)
113
+
114
+
115
+ def export_asset_index_json(
116
+ *,
117
+ settings: Settings | None = None,
118
+ from_registry: Path | None = None,
119
+ all_platforms: bool = False,
120
+ warn: Callable[[str], None] | None = None,
121
+ ) -> str:
122
+ """Return ``asset_index.json`` content for the local metadata cache."""
123
+ return (
124
+ json.dumps(
125
+ export_asset_index(
126
+ settings=settings,
127
+ from_registry=from_registry,
128
+ all_platforms=all_platforms,
129
+ warn=warn,
130
+ ),
131
+ indent=2,
132
+ sort_keys=True,
133
+ )
134
+ + "\n"
135
+ )
136
+
137
+
138
+ # -- Internal helpers --------------------------------------------------------
139
+
140
+
141
+ def _record_to_spec(record: ToolRecord, settings: Settings | None = None) -> ToolSpec:
142
+ """Convert a ToolRecord into a ToolSpec faithful to user install intent."""
143
+ settings = settings or get_settings()
144
+ name = _config_key_for_record(record)
145
+ version = _version_constraint(record)
146
+ asset_pattern = _read_asset_pattern(record) if record.backend == "binary" else None
147
+ runtime = _read_node_runtime(record) if record.backend == "node" else None
148
+ install = None
149
+ slot = _slot_for_record(record, name, settings)
150
+ if slot is not None:
151
+ name = slot
152
+ install = tool_install_spec(record.spec, BackendType(record.backend))
153
+
154
+ return ToolSpec(
155
+ name=name,
156
+ install=install,
157
+ version=version,
158
+ expose=list(record.expose_rules),
159
+ inject=list(record.injected),
160
+ node_shim=record.node_shim,
161
+ runtime=runtime,
162
+ asset_pattern=asset_pattern,
163
+ env_base=record.env_base,
164
+ env_allow=list(record.env_allow),
165
+ env_deny=dict(record.env_deny),
166
+ fs_base=record.fs_base,
167
+ fs_ro=list(record.fs_ro),
168
+ fs_rw=list(record.fs_rw),
169
+ fs_scratch=list(record.fs_scratch),
170
+ )
171
+
172
+
173
+ def _config_key_for_record(record: ToolRecord) -> str:
174
+ """Return the canonical install-spec key to write in ``ixt.toml``."""
175
+ return tool_entry_key(record.name, record.spec, BackendType(record.backend))
176
+
177
+
178
+ def _slot_for_record(record: ToolRecord, install_spec: str, settings: Settings) -> str | None:
179
+ from ixt.core.identity import slot_from_id
180
+ from ixt.core.install import plan_install
181
+
182
+ try:
183
+ base_id = plan_install(install_spec, settings=settings).tool_name
184
+ except Exception:
185
+ return None
186
+ return slot_from_id(record.name, base_id)
187
+
188
+
189
+ def _version_constraint(record: ToolRecord) -> str | None:
190
+ """Extract the version constraint the user wrote at install time.
191
+
192
+ Returns None when the user didn't pin anything. The shape depends on the
193
+ backend: PEP 508 for python/node (``==0.5.0``, ``>=1.0``), plain-tag-with-``==``
194
+ for binary (``==14.1.0``).
195
+ """
196
+ spec = record.spec
197
+ if record.backend == "python":
198
+ from ixt.libs.req_spec import parse_requirement
199
+
200
+ try:
201
+ return parse_requirement(spec).version_constraint
202
+ except ValueError:
203
+ return None
204
+
205
+ if record.backend == "node":
206
+ from ixt.backends.node import parse_npm_spec
207
+
208
+ _name, version = parse_npm_spec(spec)
209
+ if not version:
210
+ return None
211
+ # npm version can already carry an operator (``^5.0``, ``~1.2``). Keep
212
+ # ixt.toml's PEP-508-ish convention: bare semver → prefix ``==``.
213
+ if version[0] in "^~><=*":
214
+ return version
215
+ return f"=={version}"
216
+
217
+ if record.backend == "binary":
218
+ from ixt.net.source import parse_spec as parse_repo_spec
219
+
220
+ repo = parse_repo_spec(spec)
221
+ if repo is None or not repo.version:
222
+ return None
223
+ return f"=={repo.version}"
224
+
225
+ return None
226
+
227
+
228
+ def _read_asset_pattern(record: ToolRecord) -> str | None:
229
+ """Return the ``asset_pattern`` to export, if any.
230
+
231
+ Only forced patterns (``--asset-pattern`` CLI or ``ixt.setup.toml`` author)
232
+ are exported — patterns derived from the resolved asset name bake in
233
+ OS/arch and would break cross-machine ``apply``.
234
+ """
235
+ if not record.asset_pattern_forced:
236
+ return None
237
+ return record.asset_pattern if record.asset_pattern else None
238
+
239
+
240
+ def _read_node_runtime(record: ToolRecord) -> str | None:
241
+ """Return the non-default node runtime to export, if any."""
242
+ from ixt.backends.node import read_metadata
243
+
244
+ data = read_metadata(Path(record.env_dir))
245
+ if data and data.get("runtime") == "node":
246
+ return "node"
247
+ return None
248
+
249
+
250
+ def _load_asset_patterns(settings: Settings) -> dict[str, str]:
251
+ path = settings.metadata_dir / "asset_patterns.json"
252
+ try:
253
+ with path.open(encoding="utf-8") as f:
254
+ data = json.load(f)
255
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
256
+ return {}
257
+ if not isinstance(data, dict):
258
+ return {}
259
+ return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
260
+
261
+
262
+ def _load_downloads(settings: Settings) -> list[dict[str, Any]]:
263
+ from ixt.core.cache import downloads_index_path
264
+
265
+ try:
266
+ with downloads_index_path(settings=settings).open(encoding="utf-8") as f:
267
+ data = json.load(f)
268
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
269
+ return []
270
+ if not isinstance(data, dict):
271
+ return []
272
+ entries = data.get("entries", [])
273
+ if not isinstance(entries, list):
274
+ return []
275
+ return [
276
+ entry
277
+ for entry in entries
278
+ if isinstance(entry, dict) and entry.get("kind") == "binary-asset"
279
+ ]
280
+
281
+
282
+ def _asset_index_from_cache(settings: Settings) -> dict[str, Any]:
283
+ result: dict[str, dict[str, Any]] = {}
284
+ key = platform_key()
285
+ patterns = _load_asset_patterns(settings)
286
+ for owner_repo, pattern in sorted(patterns.items()):
287
+ owner, sep, repo = owner_repo.partition("/")
288
+ if not sep or not owner or not repo:
289
+ continue
290
+ entry = _asset_index_entry(result, owner=owner, repo=repo)
291
+ entry.setdefault("patterns", {})[key] = pattern
292
+
293
+ downloads = sorted(
294
+ _load_downloads(settings),
295
+ key=lambda item: _str(item.get("downloaded_at")),
296
+ )
297
+ for download in downloads:
298
+ if download.get("platform") != "github" or download.get("host") != "github.com":
299
+ continue
300
+ owner = _str(download.get("owner"))
301
+ repo = _str(download.get("repo"))
302
+ tag = _str(download.get("tag"))
303
+ asset = _str(download.get("asset"))
304
+ if not owner or not repo or not tag or not asset:
305
+ continue
306
+ entry = _asset_index_entry(result, owner=owner, repo=repo)
307
+ entry.setdefault("assets", {})[key] = {
308
+ "tag": tag,
309
+ "asset": asset,
310
+ "url": _str(download.get("url")),
311
+ }
312
+ return _prune_empty_asset_index(result)
313
+
314
+
315
+ def _asset_index_from_registry(
316
+ path: Path,
317
+ *,
318
+ all_platforms: bool,
319
+ warn: Callable[[str], None] | None,
320
+ ) -> dict[str, Any]:
321
+ from ixt.backends.binary import derive_asset_pattern, make_source
322
+ from ixt.config.heuristics import select_asset
323
+ from ixt.config.toml import _load_toml
324
+ from ixt.core.backend import strip_protocol
325
+
326
+ data = _load_toml(path)
327
+ tools = data.get("tools", {})
328
+ if not isinstance(tools, dict):
329
+ return {}
330
+
331
+ result: dict[str, dict[str, Any]] = {}
332
+ platforms = _export_platforms(all_platforms=all_platforms)
333
+ for name, raw_spec in sorted(tools.items()):
334
+ if not isinstance(name, str) or not isinstance(raw_spec, str):
335
+ continue
336
+ try:
337
+ _forced, clean_spec = strip_protocol(raw_spec)
338
+ repo_spec = parse_spec(clean_spec)
339
+ if repo_spec is None or repo_spec.platform != "github":
340
+ continue
341
+ canonical = canonical_github_spec(repo_spec)
342
+ if canonical is None:
343
+ continue
344
+ release = make_source(repo_spec).get_latest_release(repo_spec.owner, repo_spec.repo)
345
+ entry = result.setdefault(canonical, {"names": [], "patterns": {}})
346
+ if name not in entry["names"]:
347
+ entry["names"].append(name)
348
+ for os_value, arch_value in platforms:
349
+ key = platform_key(os_override=os_value, arch_override=arch_value)
350
+ asset = select_asset(
351
+ release.assets,
352
+ repo_spec.repo,
353
+ release.tag.removeprefix("v"),
354
+ os_override=os_value,
355
+ arch_override=arch_value,
356
+ )
357
+ if asset is None:
358
+ _warn(warn, f"{name}: no asset for {key} in {release.tag}")
359
+ continue
360
+ pattern = derive_asset_pattern(asset.name, release.tag)
361
+ if pattern is None:
362
+ _warn(warn, f"{name}: cannot derive pattern for {asset.name}")
363
+ continue
364
+ entry["patterns"][key] = pattern
365
+ except Exception as exc:
366
+ _warn(warn, f"{name}: {type(exc).__name__}: {exc}")
367
+ continue
368
+
369
+ return _prune_empty_asset_index(result)
370
+
371
+
372
+ def _asset_index_entry(
373
+ result: dict[str, dict[str, Any]],
374
+ *,
375
+ owner: str,
376
+ repo: str,
377
+ ) -> dict[str, Any]:
378
+ canonical = f"@gh:{owner}/{repo}"
379
+ return result.setdefault(canonical, {})
380
+
381
+
382
+ def _prune_empty_asset_index(data: dict[str, dict[str, Any]]) -> dict[str, Any]:
383
+ result: dict[str, Any] = {}
384
+ for canonical, entry in sorted(data.items()):
385
+ cleaned: dict[str, Any] = {}
386
+ names = entry.get("names")
387
+ patterns = entry.get("patterns")
388
+ assets = entry.get("assets")
389
+ if isinstance(names, list) and names:
390
+ cleaned["names"] = sorted({name for name in names if isinstance(name, str)})
391
+ if isinstance(patterns, dict) and patterns:
392
+ cleaned["patterns"] = dict(sorted(patterns.items()))
393
+ if isinstance(assets, dict) and assets:
394
+ cleaned["assets"] = dict(sorted(assets.items()))
395
+ if "patterns" in cleaned or "assets" in cleaned:
396
+ result[canonical] = cleaned
397
+ return result
398
+
399
+
400
+ def _export_platforms(*, all_platforms: bool) -> list[tuple[OS, Arch]]:
401
+ if not all_platforms:
402
+ from ixt.platform import get_arch, get_os
403
+
404
+ return [(get_os(), get_arch())]
405
+ return [
406
+ (OS.LINUX, Arch.X86_64),
407
+ (OS.LINUX, Arch.ARM64),
408
+ (OS.MACOS, Arch.X86_64),
409
+ (OS.MACOS, Arch.ARM64),
410
+ ]
411
+
412
+
413
+ def _warn(warn: Callable[[str], None] | None, message: str) -> None:
414
+ if warn is not None:
415
+ warn(message)
416
+
417
+
418
+ def _str(value: object) -> str:
419
+ return value if isinstance(value, str) else ""