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/cli/cmd_apply.py ADDED
@@ -0,0 +1,404 @@
1
+ """``ixt tool apply``, ``ixt tool export``, and ``ixt asset-index export`` handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import time
7
+ from collections.abc import Callable
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, cast
10
+
11
+ from ixt.cli.render import (
12
+ format_exposed_bins,
13
+ format_source_tag,
14
+ source_label_for_spec,
15
+ )
16
+ from ixt.config.models import ToolRecord
17
+ from ixt.config.settings import get_settings
18
+ from ixt.libs.logger import get_logger
19
+ from ixt.libs.output import data
20
+ from ixt.libs.style import bold, cyan, dim, green, red, yellow
21
+
22
+ if TYPE_CHECKING:
23
+ from ixt.core.apply import (
24
+ ApplyAction,
25
+ ApplyPlan,
26
+ ApplyResult,
27
+ InjectAction,
28
+ InstallAction,
29
+ ReexposeAction,
30
+ ReinstallAction,
31
+ UninjectAction,
32
+ )
33
+
34
+
35
+ def cmd_apply(args: argparse.Namespace) -> int:
36
+ from ixt.core.apply import RemovalsRefused, apply_config
37
+
38
+ logger = get_logger("apply")
39
+ dry_run = getattr(args, "dry_run", False)
40
+ yes = getattr(args, "yes", False)
41
+
42
+ config_path = Path(args.config_file) if args.config_file else None
43
+
44
+ if dry_run:
45
+ return _cmd_apply_dry_run(config_path, args.remove, logger)
46
+
47
+ confirm: Callable[[list[str]], bool] | None = None
48
+ if args.remove and not yes:
49
+ confirm = _make_remove_confirmer(logger)
50
+
51
+ try:
52
+ start = time.monotonic()
53
+ result = apply_config(
54
+ config_path=config_path,
55
+ remove_unlisted=args.remove,
56
+ confirm_removals=confirm,
57
+ progress=_make_apply_progress(logger),
58
+ )
59
+ except FileNotFoundError:
60
+ logger.error("No ixt.toml found")
61
+ logger.error(f" {dim('Hint: create one with ixt tool export > ixt.toml')}")
62
+ return 1
63
+ except RemovalsRefused:
64
+ logger.info("aborted by user")
65
+ return 0
66
+
67
+ total = len(result.installed) + len(result.updated) + len(result.removed)
68
+ if not total and not result.errors:
69
+ logger.info("Already in sync")
70
+ else:
71
+ logger.info(_apply_footer(result, time.monotonic() - start))
72
+
73
+ return 1 if result.errors else 0
74
+
75
+
76
+ def _make_apply_progress(logger):
77
+ """Create a progress callback for apply_config."""
78
+ settings = get_settings()
79
+ started = False
80
+
81
+ def progress(
82
+ phase: str,
83
+ kind: str,
84
+ action: ApplyAction,
85
+ index: int,
86
+ total: int,
87
+ _result: ApplyResult,
88
+ error: Exception | None,
89
+ ) -> None:
90
+ nonlocal started
91
+ prefix = dim(f"[{index}/{total}]")
92
+ if phase == "start":
93
+ if not started:
94
+ logger.info(f"applying {total} change(s)")
95
+ started = True
96
+ logger.info(_apply_progress_line(prefix, kind, action))
97
+ return
98
+ if phase == "done" and error is not None:
99
+ logger.error(f"{prefix} {red('x')} {_apply_action_name(action)}: {error}")
100
+ return
101
+ if phase == "done":
102
+ line = _apply_done_line(kind, action, settings)
103
+ if line is not None:
104
+ logger.info(line)
105
+
106
+ return progress
107
+
108
+
109
+ def _apply_action_name(action: ApplyAction) -> str:
110
+ if isinstance(action, str):
111
+ return action
112
+ return action.name
113
+
114
+
115
+ def _apply_progress_line(prefix: str, kind: str, action: ApplyAction) -> str:
116
+ name = _apply_action_name(action)
117
+ if kind == "install":
118
+ install = cast("InstallAction", action)
119
+ source = format_source_tag(
120
+ source_label_for_spec(
121
+ install.install_spec,
122
+ backend_hint=install.backend,
123
+ tool_id=install.name,
124
+ )
125
+ )
126
+ source_suffix = f" {source}" if source else ""
127
+ display = bold(install.install_spec)
128
+ return f"{prefix} {dim('installing')} {display}{source_suffix}"
129
+ if kind == "reinstall":
130
+ reinstall = cast("ReinstallAction", action)
131
+ reasons = "+".join(reinstall.reasons)
132
+ source = format_source_tag(
133
+ source_label_for_spec(
134
+ reinstall.install_spec,
135
+ backend_hint=reinstall.backend,
136
+ tool_id=reinstall.name,
137
+ )
138
+ )
139
+ source_suffix = f" {source}" if source else ""
140
+ return (
141
+ f"{prefix} {dim('reinstalling')} "
142
+ f"{bold(reinstall.install_spec)} {dim(reasons)}{source_suffix}"
143
+ )
144
+ if kind == "inject":
145
+ inject = cast("InjectAction", action)
146
+ packages = ", ".join(inject.packages)
147
+ return f"{prefix} {green('+')} {dim('injecting')} {bold(name)} {dim(packages)}"
148
+ if kind == "uninject":
149
+ uninject = cast("UninjectAction", action)
150
+ packages = ", ".join(uninject.packages)
151
+ return f"{prefix} {red('-')} {dim('uninjecting')} {bold(name)} {dim(packages)}"
152
+ if kind == "re-expose":
153
+ reexpose = cast("ReexposeAction", action)
154
+ old = ",".join(reexpose.old_rules) or "(none)"
155
+ new = ",".join(reexpose.new_rules) or "(none)"
156
+ return f"{prefix} {cyan('~')} {dim('re-exposing')} {bold(name)} {dim(old)} -> {dim(new)}"
157
+ if kind == "policy":
158
+ return f"{prefix} {cyan('~')} {dim('applying policy')} {bold(name)}"
159
+ return f"{prefix} {red('-')} {dim('removing')} {bold(name)}"
160
+
161
+
162
+ def _load_action_record(action: ApplyAction, settings) -> ToolRecord | None:
163
+ if isinstance(action, str):
164
+ return None
165
+ meta_file = settings.get_tool_metadata_file(action.name)
166
+ try:
167
+ return ToolRecord.load_json(meta_file)
168
+ except (FileNotFoundError, ValueError):
169
+ return None
170
+
171
+
172
+ def _apply_record_done_line(status: str, record: ToolRecord) -> str:
173
+ version = dim(record.version or "unknown")
174
+ exposes = format_exposed_bins(record.exposed_bins)
175
+ marker = green("+") if status == "installed" else yellow("*")
176
+ return f" {marker} {status} {version} {dim('·')} exposes: {exposes}"
177
+
178
+
179
+ def _apply_done_line(kind: str, action: ApplyAction, settings) -> str | None:
180
+ if kind not in {"install", "reinstall", "re-expose"}:
181
+ return None
182
+ record = _load_action_record(action, settings)
183
+ if record is None:
184
+ return None
185
+ if kind == "install":
186
+ return _apply_record_done_line("installed", record)
187
+ if kind == "reinstall":
188
+ return _apply_record_done_line("reinstalled", record)
189
+ return (
190
+ f" {cyan('re-exposed')} {dim('·')} "
191
+ f"exposes: {format_exposed_bins(record.exposed_bins)}"
192
+ )
193
+
194
+
195
+ def _apply_footer(result: ApplyResult, elapsed: float) -> str:
196
+ labels = [
197
+ (len(result.installed), "installed"),
198
+ (len(result.updated), "updated"),
199
+ (len(result.removed), "removed"),
200
+ (len(result.errors), "failed"),
201
+ ]
202
+ parts = [f"{count} {label}" for count, label in labels if count]
203
+ summary = ", ".join(parts) if parts else "nothing to do"
204
+ return f"{summary} {dim(f'in {elapsed:.1f}s')}"
205
+
206
+
207
+ def _make_remove_confirmer(logger) -> Callable[[list[str]], bool]:
208
+ """Build a confirmer that prompts y/N on TTY, refuses in non-interactive."""
209
+ import sys
210
+
211
+ def confirm(names: list[str]) -> bool:
212
+ summary = f"About to remove {len(names)} tool(s): {', '.join(sorted(names))}"
213
+ if not sys.stdin.isatty():
214
+ logger.error(summary)
215
+ logger.error(f" {dim('non-interactive session; pass --yes to confirm')}")
216
+ return False
217
+ print(summary, file=sys.stderr)
218
+ try:
219
+ answer = input("Proceed? [y/N] ").strip().lower()
220
+ except (EOFError, KeyboardInterrupt):
221
+ print("", file=sys.stderr)
222
+ return False
223
+ return answer in ("y", "yes")
224
+
225
+ return confirm
226
+
227
+
228
+ def _load_apply_plan(
229
+ config_path: Path | None, remove_unlisted: bool, logger
230
+ ) -> tuple[ApplyPlan, Path] | int:
231
+ """Load the ixt.toml and build an ApplyPlan, or return an exit code on error."""
232
+ from ixt.config.toml import find_config_file, load_config
233
+ from ixt.core.apply import _get_installed_tools, _normalize_config_for_apply, plan_apply
234
+
235
+ path = config_path or find_config_file()
236
+ if path is None:
237
+ logger.error("No ixt.toml found")
238
+ logger.error(f" {dim('Hint: create one with ixt tool export > ixt.toml')}")
239
+ return 1
240
+
241
+ try:
242
+ config = load_config(path)
243
+ except Exception as exc:
244
+ logger.error(f"Failed to load {path}: {exc}")
245
+ return 1
246
+
247
+ settings = get_settings()
248
+ installed = _get_installed_tools(settings)
249
+ config = _normalize_config_for_apply(config, settings)
250
+ plan = plan_apply(config, installed, remove_unlisted=remove_unlisted)
251
+ return plan, path
252
+
253
+
254
+ def _print_apply_installs(actions, logger) -> None:
255
+ for action in actions:
256
+ source = format_source_tag(
257
+ source_label_for_spec(
258
+ action.install_spec,
259
+ backend_hint=action.backend,
260
+ tool_id=action.name,
261
+ )
262
+ )
263
+ source_suffix = f" {source}" if source else ""
264
+ display = bold(action.install_spec)
265
+ logger.info(f" {green('+')} {dim('would install')} {display}{source_suffix}")
266
+
267
+
268
+ def _print_apply_reinstalls(actions, logger) -> None:
269
+ for action in actions:
270
+ reason_str = "+".join(action.reasons)
271
+ source = format_source_tag(
272
+ source_label_for_spec(
273
+ action.install_spec,
274
+ backend_hint=action.backend,
275
+ tool_id=action.name,
276
+ )
277
+ )
278
+ source_suffix = f" {source}" if source else ""
279
+ logger.info(
280
+ f" {yellow('*')} {dim('would reinstall')} {bold(action.install_spec)} "
281
+ f"{dim(f'(reinstall: {reason_str})')}{source_suffix}"
282
+ )
283
+ if "version" in action.reasons:
284
+ old = action.old_version or "?"
285
+ new = action.pinned_version or "?"
286
+ logger.info(f" {dim('version:')} {old} {dim('->')} {new}")
287
+ if "inject" in action.reasons:
288
+ added = sorted(set(action.new_inject) - set(action.old_inject))
289
+ removed = sorted(set(action.old_inject) - set(action.new_inject))
290
+ if added:
291
+ logger.info(f" {dim('inject +:')} {', '.join(added)}")
292
+ if removed:
293
+ logger.info(f" {dim('inject -:')} {', '.join(removed)}")
294
+
295
+
296
+ def _print_apply_injects(actions, logger) -> None:
297
+ for action in actions:
298
+ logger.info(
299
+ f" {green('+')} {dim('would inject')} {bold(action.name)} {dim('(inject:')} "
300
+ f"{', '.join(action.packages)}{dim(')')}"
301
+ )
302
+
303
+
304
+ def _print_apply_uninjects(actions, logger) -> None:
305
+ for action in actions:
306
+ logger.info(
307
+ f" {red('-')} {dim('would uninject')} {bold(action.name)} {dim('(uninject:')} "
308
+ f"{', '.join(action.packages)}{dim(')')}"
309
+ )
310
+
311
+
312
+ def _print_apply_reexposes(actions, logger) -> None:
313
+ for action in actions:
314
+ old = ",".join(action.old_rules) or "(none)"
315
+ new = ",".join(action.new_rules) or "(none)"
316
+ logger.info(
317
+ f" {cyan('~')} {dim('would re-expose')} {bold(action.name)} "
318
+ f"{dim('(re-expose:')} {old} "
319
+ f"{dim('->')} {new}{dim(')')}"
320
+ )
321
+
322
+
323
+ def _print_apply_repolicies(actions, logger) -> None:
324
+ for action in actions:
325
+ logger.info(f" {cyan('~')} {dim('would apply policy')} {bold(action.name)}")
326
+
327
+
328
+ def _print_apply_removes(names, logger) -> None:
329
+ for name in names:
330
+ logger.info(f" {red('-')} {dim('would remove')} {bold(name)}")
331
+
332
+
333
+ def _print_apply_summary(plan, logger) -> None:
334
+ """Print the ``total: N install, M reinstall, ...`` summary line."""
335
+ sections: list[tuple[list, str]] = [
336
+ (plan.to_install, "install"),
337
+ (plan.to_reinstall, "reinstall"),
338
+ (plan.to_inject, "inject"),
339
+ (plan.to_uninject, "uninject"),
340
+ (plan.to_reexpose, "re-expose"),
341
+ (plan.to_repolicy, "policy"),
342
+ (plan.to_remove, "remove"),
343
+ ]
344
+ parts = [f"{len(items)} {label}" for items, label in sections if items]
345
+ if parts:
346
+ logger.info(f" {dim('total: ' + ', '.join(parts))}")
347
+
348
+
349
+ def _cmd_apply_dry_run(config_path: Path | None, remove_unlisted: bool, logger) -> int:
350
+ result = _load_apply_plan(config_path, remove_unlisted, logger)
351
+ if isinstance(result, int):
352
+ return result
353
+ plan, path = result
354
+
355
+ logger.info(f"{dim('dry-run: no changes will be applied')}")
356
+ logger.info(f"{dim('config: ')}{path}")
357
+
358
+ if plan.is_empty:
359
+ logger.info("Already in sync")
360
+ return 0
361
+
362
+ _print_apply_installs(plan.to_install, logger)
363
+ _print_apply_reinstalls(plan.to_reinstall, logger)
364
+ _print_apply_injects(plan.to_inject, logger)
365
+ _print_apply_uninjects(plan.to_uninject, logger)
366
+ _print_apply_reexposes(plan.to_reexpose, logger)
367
+ _print_apply_repolicies(plan.to_repolicy, logger)
368
+ _print_apply_removes(plan.to_remove, logger)
369
+ _print_apply_summary(plan, logger)
370
+ return 0
371
+
372
+
373
+ def cmd_export(args: argparse.Namespace) -> int:
374
+ action = getattr(args, "export_action", None)
375
+ if action == "asset-index":
376
+ import sys
377
+
378
+ from ixt.core.export import export_asset_index_json
379
+
380
+ from_registry = getattr(args, "from_registry", None)
381
+ json_text = export_asset_index_json(
382
+ from_registry=Path(from_registry) if from_registry else None,
383
+ all_platforms=bool(getattr(args, "all_platforms", False)),
384
+ warn=lambda message: print(message, file=sys.stderr),
385
+ )
386
+ data(json_text.rstrip("\n"))
387
+ return 0
388
+ if action != "tools":
389
+ get_logger("export").error("Specify what to export: tools or asset-index")
390
+ return 1
391
+
392
+ # lazy: keeps `ixt.core.export` (and its TOML serializer chain)
393
+ # off the import path of every other CLI command.
394
+ from ixt.core.export import ToolNotInstalledError, export_toml
395
+
396
+ names = list(getattr(args, "names", []) or [])
397
+ try:
398
+ toml_text = export_toml(names or None)
399
+ except (ToolNotInstalledError, ValueError) as exc:
400
+ get_logger("export").error(str(exc))
401
+ return 1
402
+
403
+ data(toml_text.rstrip("\n"))
404
+ return 0
ixt/cli/cmd_cache.py ADDED
@@ -0,0 +1,86 @@
1
+ """Cache management subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from ixt.config.settings import get_settings
8
+ from ixt.core.cache import cache_sizes, clear_caches, humanize_size, prune_downloads
9
+ from ixt.libs.logger import get_logger
10
+ from ixt.libs.style import bold, cyan, dim, green
11
+
12
+
13
+ def cmd_cache(args: argparse.Namespace) -> int:
14
+ """Dispatch ``ixt cache <subcommand>``."""
15
+ action = getattr(args, "cache_action", None)
16
+ if action == "info":
17
+ return _cmd_cache_info(args)
18
+ if action == "clear":
19
+ return _cmd_cache_clear(args)
20
+ if action == "prune":
21
+ return _cmd_cache_prune(args)
22
+
23
+ logger = get_logger("cache")
24
+ logger.error("Specify a cache subcommand (info, prune, clear)")
25
+ return 1
26
+
27
+
28
+ def _cmd_cache_info(args: argparse.Namespace) -> int:
29
+ _ = args
30
+ logger = get_logger("cache")
31
+ settings = get_settings()
32
+ entries = cache_sizes(settings=settings)
33
+ total = sum(entry.bytes for entry in entries)
34
+
35
+ logger.info(f" {bold('ixt cache')}")
36
+ logger.info(f" Root: {cyan(str(settings.cache_home))}")
37
+ logger.info("")
38
+ for entry in entries:
39
+ files = _files(entry.files)
40
+ logger.info(
41
+ f" {entry.name:10s} {humanize_size(entry.bytes):>8s} "
42
+ f"{entry.files} {files} {dim(str(entry.path))}"
43
+ )
44
+ logger.info("")
45
+ logger.info(f" Total: {bold(humanize_size(total))}")
46
+ return 0
47
+
48
+
49
+ def _cmd_cache_clear(args: argparse.Namespace) -> int:
50
+ logger = get_logger("cache")
51
+ for result in clear_caches(args.target):
52
+ files = _files(result.removed_count)
53
+ logger.info(
54
+ f" {green('ok')} cleared {result.target}: "
55
+ f"{humanize_size(result.removed_bytes)}, "
56
+ f"{result.removed_count} {files} ({dim(str(result.path))})"
57
+ )
58
+ return 0
59
+
60
+
61
+ def _cmd_cache_prune(args: argparse.Namespace) -> int:
62
+ logger = get_logger("cache")
63
+ try:
64
+ result = prune_downloads(keep=args.keep)
65
+ except ValueError as exc:
66
+ logger.error(str(exc))
67
+ return 1
68
+
69
+ files = _files(result.removed_count)
70
+ stale = (
71
+ f", dropped {result.stale_entries} stale metadata "
72
+ f"{'entry' if result.stale_entries == 1 else 'entries'}"
73
+ if result.stale_entries
74
+ else ""
75
+ )
76
+ logger.info(
77
+ f" {green('ok')} pruned downloads: "
78
+ f"removed {humanize_size(result.removed_bytes)}, "
79
+ f"{result.removed_count} {files}; kept {result.kept_count} indexed artifacts"
80
+ f"{stale}"
81
+ )
82
+ return 0
83
+
84
+
85
+ def _files(count: int) -> str:
86
+ return "file" if count == 1 else "files"