ixt-cli 0.9.1__tar.gz → 0.10.1__tar.gz

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 (163) hide show
  1. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/PKG-INFO +4 -3
  2. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/README.md +2 -1
  3. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/pyproject.toml +1 -1
  4. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/__init__.py +1 -3
  5. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/backends/binary.py +407 -51
  6. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/backends/node.py +49 -14
  7. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/__init__.py +12 -2
  8. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/argparse_completion.py +10 -1
  9. ixt_cli-0.10.1/src/ixt/cli/cmd_exec.py +71 -0
  10. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/cmd_info.py +5 -1
  11. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/cmd_misc.py +24 -1
  12. ixt_cli-0.10.1/src/ixt/cli/commands.py +89 -0
  13. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/parser.py +39 -9
  14. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/heuristics.py +143 -16
  15. ixt_cli-0.10.1/src/ixt/core/__init__.py +51 -0
  16. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/discover.py +27 -4
  17. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/doctor.py +56 -0
  18. ixt_cli-0.10.1/src/ixt/core/exec_tool.py +165 -0
  19. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/upgrade.py +19 -8
  20. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/data/heuristics.toml +37 -0
  21. ixt_cli-0.10.1/src/ixt/libs/elf.py +174 -0
  22. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/net/http.py +38 -0
  23. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_argparse_completion.py +16 -1
  24. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_binary_backend.py +466 -0
  25. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_cli.py +125 -0
  26. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_discover.py +63 -0
  27. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_doctor.py +94 -0
  28. ixt_cli-0.10.1/tests/unit/test_elf.py +230 -0
  29. ixt_cli-0.10.1/tests/unit/test_exec.py +289 -0
  30. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_heuristics.py +125 -0
  31. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_http.py +49 -0
  32. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_node_backend.py +111 -9
  33. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_upgrade.py +74 -28
  34. ixt_cli-0.9.1/src/ixt/cli/commands.py +0 -73
  35. ixt_cli-0.9.1/src/ixt/core/__init__.py +0 -16
  36. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/.gitignore +0 -0
  37. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/LICENSE +0 -0
  38. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/__main__.py +0 -0
  39. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/backends/__init__.py +0 -0
  40. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/backends/binary_resolver.py +0 -0
  41. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/backends/python.py +0 -0
  42. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/cmd_apply.py +0 -0
  43. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/cmd_cache.py +0 -0
  44. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/cmd_config.py +0 -0
  45. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/cmd_install.py +0 -0
  46. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/cmd_lock.py +0 -0
  47. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/cmd_registry.py +0 -0
  48. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/cmd_upgrade.py +0 -0
  49. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/cli/render.py +0 -0
  50. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/__init__.py +0 -0
  51. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/asset_index.py +0 -0
  52. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/asset_pattern_cache.py +0 -0
  53. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/env_policy.py +0 -0
  54. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/flags.py +0 -0
  55. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/fs_policy.py +0 -0
  56. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/lockfile.py +0 -0
  57. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/models.py +0 -0
  58. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/registry.py +0 -0
  59. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/settings.py +0 -0
  60. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/setup_toml.py +0 -0
  61. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/config/toml.py +0 -0
  62. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/apply.py +0 -0
  63. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/apply_actions.py +0 -0
  64. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/backend.py +0 -0
  65. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/bootstrap.py +0 -0
  66. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/cache.py +0 -0
  67. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/export.py +0 -0
  68. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/expose.py +0 -0
  69. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/extract.py +0 -0
  70. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/hooks.py +0 -0
  71. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/identity.py +0 -0
  72. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/inject.py +0 -0
  73. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/install.py +0 -0
  74. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/install_local.py +0 -0
  75. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/lockfile.py +0 -0
  76. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/locks.py +0 -0
  77. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/pathlink.py +0 -0
  78. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/resolution_stats.py +0 -0
  79. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/resolve.py +0 -0
  80. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/resolve_cache.py +0 -0
  81. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/runtimes.py +0 -0
  82. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/save.py +0 -0
  83. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/setup_completions.py +0 -0
  84. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/setup_path.py +0 -0
  85. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/core/transactions.py +0 -0
  86. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/data/__init__.py +0 -0
  87. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/data/asset_index.json +0 -0
  88. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/data/registry.toml +0 -0
  89. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/libs/__init__.py +0 -0
  90. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/libs/atomic.py +0 -0
  91. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/libs/constants.py +0 -0
  92. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/libs/fmt.py +0 -0
  93. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/libs/logger.py +0 -0
  94. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/libs/output.py +0 -0
  95. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/libs/req_spec.py +0 -0
  96. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/libs/semver.py +0 -0
  97. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/libs/shell.py +0 -0
  98. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/libs/style.py +0 -0
  99. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/net/__init__.py +0 -0
  100. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/net/github_api.py +0 -0
  101. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/net/gitlab_api.py +0 -0
  102. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/net/npm.py +0 -0
  103. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/net/pypi.py +0 -0
  104. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/net/source.py +0 -0
  105. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/platform/__init__.py +0 -0
  106. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/src/ixt/platform/win.py +0 -0
  107. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/__init__.py +0 -0
  108. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/conftest.py +0 -0
  109. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/e2e/README.md +0 -0
  110. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/e2e/conftest.py +0 -0
  111. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/e2e/docker/Dockerfile.alpine +0 -0
  112. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/e2e/docker/Dockerfile.fedora +0 -0
  113. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/e2e/docker/Dockerfile.ubuntu +0 -0
  114. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/e2e/playground_incus.sh +0 -0
  115. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/e2e/run_e2e_docker.sh +0 -0
  116. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/e2e/run_e2e_incus.sh +0 -0
  117. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/e2e/runtime_matrix.sh +0 -0
  118. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/e2e/test_e2e.py +0 -0
  119. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/integration/__init__.py +0 -0
  120. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/integration/test_lifecycle.py +0 -0
  121. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/integration/test_policy_runtime.py +0 -0
  122. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/__init__.py +0 -0
  123. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_apply.py +0 -0
  124. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_asset_index.py +0 -0
  125. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_asset_pattern_cache.py +0 -0
  126. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_atomic.py +0 -0
  127. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_backend.py +0 -0
  128. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_bootstrap.py +0 -0
  129. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_cache.py +0 -0
  130. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_env_policy.py +0 -0
  131. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_export.py +0 -0
  132. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_expose.py +0 -0
  133. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_extract.py +0 -0
  134. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_flags.py +0 -0
  135. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_fs_policy.py +0 -0
  136. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_github_api.py +0 -0
  137. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_gitlab_api.py +0 -0
  138. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_hooks.py +0 -0
  139. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_inject.py +0 -0
  140. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_install.py +0 -0
  141. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_install_local.py +0 -0
  142. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_lock_write.py +0 -0
  143. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_lockfile.py +0 -0
  144. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_models.py +0 -0
  145. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_namespacing.py +0 -0
  146. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_output.py +0 -0
  147. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_pathlink.py +0 -0
  148. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_platform.py +0 -0
  149. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_python_backend.py +0 -0
  150. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_registry.py +0 -0
  151. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_req_spec.py +0 -0
  152. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_resolve.py +0 -0
  153. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_resolve_cache.py +0 -0
  154. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_runtimes.py +0 -0
  155. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_save.py +0 -0
  156. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_semver.py +0 -0
  157. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_settings.py +0 -0
  158. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_setup_path.py +0 -0
  159. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_setup_toml.py +0 -0
  160. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_source.py +0 -0
  161. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_style.py +0 -0
  162. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_toml.py +0 -0
  163. {ixt_cli-0.9.1 → ixt_cli-0.10.1}/tests/unit/test_transactions.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: ixt-cli
3
- Version: 0.9.1
3
+ Version: 0.10.1
4
4
  Summary: Universal CLI tool isolator - manage Python, Node, and binary tools with per-tool isolation
5
5
  Project-URL: Homepage, https://gitlab.com/pytgaen-group/ixt
6
6
  Project-URL: Repository, https://gitlab.com/pytgaen-group/ixt.git
@@ -223,7 +223,7 @@ resolve upstream again and refresh the lock.
223
223
  | `ixt tool apply --locked --strict` | Sync using exact versions and assets from ixt.lock |
224
224
  | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
225
225
  | `ixt lock write` | Resolve ixt.toml and write ixt.lock |
226
- | `ixt lock check` | Validate ixt.lock against ixt.toml locally |
226
+ | `ixt lock check` | Validate ixt.lock against ixt.toml locally |
227
227
  | `ixt lock diff` | Show semantic differences between ixt.toml and ixt.lock |
228
228
  | `ixt asset-index export` | Export portable binary-resolution metadata as JSON |
229
229
  | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
@@ -232,6 +232,7 @@ resolve upstream again and refresh the lock.
232
232
  | `ixt tool config __all__ expose <rules...>` | Re-expose every installed tool |
233
233
  | `ixt tool shell <pkg>` | Open a subshell in tool's env |
234
234
  | `ixt tool where <pkg>` | Print the tool env path |
235
+ | `ixt exec [--sudo] <tool> -- <args...>` | Run an exposed tool by name, without relying on PATH |
235
236
  | `ixt environment [--sizes]` | Show ixt configuration, paths, and optional storage sizes |
236
237
  | `ixt doctor` | Check ixt health (runtimes, PATH, caches) |
237
238
  | `ixt cache info` | Show cache paths and sizes |
@@ -194,7 +194,7 @@ resolve upstream again and refresh the lock.
194
194
  | `ixt tool apply --locked --strict` | Sync using exact versions and assets from ixt.lock |
195
195
  | `ixt tool export` | Export installed tools as ixt.toml (stdout), preserves install intent |
196
196
  | `ixt lock write` | Resolve ixt.toml and write ixt.lock |
197
- | `ixt lock check` | Validate ixt.lock against ixt.toml locally |
197
+ | `ixt lock check` | Validate ixt.lock against ixt.toml locally |
198
198
  | `ixt lock diff` | Show semantic differences between ixt.toml and ixt.lock |
199
199
  | `ixt asset-index export` | Export portable binary-resolution metadata as JSON |
200
200
  | `ixt tool config <pkg> expose <rules...>` | Change exposed binaries |
@@ -203,6 +203,7 @@ resolve upstream again and refresh the lock.
203
203
  | `ixt tool config __all__ expose <rules...>` | Re-expose every installed tool |
204
204
  | `ixt tool shell <pkg>` | Open a subshell in tool's env |
205
205
  | `ixt tool where <pkg>` | Print the tool env path |
206
+ | `ixt exec [--sudo] <tool> -- <args...>` | Run an exposed tool by name, without relying on PATH |
206
207
  | `ixt environment [--sizes]` | Show ixt configuration, paths, and optional storage sizes |
207
208
  | `ixt doctor` | Check ixt health (runtimes, PATH, caches) |
208
209
  | `ixt cache info` | Show cache paths and sizes |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ixt-cli"
7
- version = "0.9.1" # to bump
7
+ version = "0.10.1" # to bump
8
8
  description = "Universal CLI tool isolator - manage Python, Node, and binary tools with per-tool isolation"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,8 +1,6 @@
1
1
  """ixt — Universal CLI Tool Isolator"""
2
2
 
3
- from importlib.metadata import version
4
-
5
- __version__ = version("ixt-cli")
3
+ __version__ = "0.10.1" # to bump
6
4
  __author__ = "Gaetan"
7
5
 
8
6
  __all__ = ["__author__", "__version__"]
@@ -4,7 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import hashlib
6
6
  import json
7
+ import os
7
8
  import re
9
+ import shutil
8
10
  from dataclasses import dataclass, field
9
11
  from pathlib import Path
10
12
  from typing import TYPE_CHECKING, TypedDict
@@ -19,7 +21,9 @@ from ixt.config.flags import is_setup_toml_enabled
19
21
  from ixt.config.heuristics import (
20
22
  get_name_suffix_tokens,
21
23
  get_platform_aliases,
24
+ has_any_token,
22
25
  is_asset_arch_compatible,
26
+ libc_tokens,
23
27
  load_heuristics,
24
28
  select_asset,
25
29
  )
@@ -31,14 +35,17 @@ from ixt.config.settings import Settings, get_settings
31
35
  from ixt.core.backend import BackendType
32
36
  from ixt.core.discover import find_executables
33
37
  from ixt.core.extract import extract, is_archive
38
+ from ixt.libs.elf import PREFIX_SIZE as ELF_PREFIX_SIZE
39
+ from ixt.libs.elf import parse_elf, read_elf
34
40
  from ixt.libs.logger import get_logger, get_verbosity
35
- from ixt.net.http import MAX_DOWNLOAD_SIZE, DownloadTooLargeError
41
+ from ixt.net.http import MAX_DOWNLOAD_SIZE, DownloadTooLargeError, get_range
36
42
  from ixt.net.source import Asset, Release, ReleaseSource, RepoSpec, parse_spec
37
43
  from ixt.platform import get_arch, get_os
38
44
 
39
45
  __all__ = [
40
46
  "AssetSelectionError",
41
47
  "BinaryBackend",
48
+ "BinaryNoExecutableError",
42
49
  "BinaryPlatformError",
43
50
  "BinarySpecError",
44
51
  "HookEntry",
@@ -88,14 +95,27 @@ class SetupMetadata(_SetupMetadataRequired, total=False):
88
95
  class AssetSelectionError(RuntimeError):
89
96
  """Raised when no suitable asset could be selected for the current platform."""
90
97
 
91
- def __init__(self, spec: str, release_tag: str, asset_names: list[str]):
98
+ def __init__(
99
+ self,
100
+ spec: str,
101
+ release_tag: str,
102
+ asset_names: list[str],
103
+ unusable: list[tuple[str, str]] | None = None,
104
+ ):
92
105
  self.spec = spec
93
106
  self.release_tag = release_tag
94
107
  self.asset_names = asset_names
108
+ self.unusable = unusable or []
95
109
  names = ", ".join(asset_names[:10]) or "<none>"
96
- super().__init__(
110
+ message = (
97
111
  f"No suitable asset found for '{spec}' (release {release_tag}). Available: {names}"
98
112
  )
113
+ if self.unusable:
114
+ skipped = ", ".join(
115
+ f"{name} (needs missing loader {loader})" for name, loader in self.unusable
116
+ )
117
+ message += f". Skipped as unrunnable here: {skipped}"
118
+ super().__init__(message)
99
119
 
100
120
 
101
121
  class BinaryPlatformError(RuntimeError):
@@ -123,6 +143,78 @@ class BinaryPlatformError(RuntimeError):
123
143
  )
124
144
 
125
145
 
146
+ def _empty_dir(path: Path) -> None:
147
+ """Remove everything inside *path*, keeping the directory itself."""
148
+ if not path.is_dir():
149
+ return
150
+ for entry in path.iterdir():
151
+ if entry.is_dir() and not entry.is_symlink():
152
+ shutil.rmtree(entry, ignore_errors=True)
153
+ else:
154
+ entry.unlink(missing_ok=True)
155
+
156
+
157
+ def _doomed_by_libc(release: Release, rejected: list[tuple[Path, str]], log) -> set[str]:
158
+ """Return the sibling assets built against a libc this host cannot load.
159
+
160
+ A candidate turned down for a missing loader condemns every asset built
161
+ against the same libc: downloading them to reach the very same verdict is
162
+ pure waste. Must be called while the rejected files are still on disk.
163
+ """
164
+ loaders = set()
165
+ for path, _label in rejected:
166
+ info = read_elf(path)
167
+ if info is not None and info.interpreter and info.loader_missing():
168
+ loaders.add(info.interpreter)
169
+ if not loaders:
170
+ return set()
171
+
172
+ cfg = load_heuristics()
173
+ tokens = sorted({token for loader in loaders for token in libc_tokens(loader, cfg)})
174
+ if not tokens:
175
+ return set()
176
+
177
+ doomed = {asset.name for asset in release.assets if has_any_token(asset.name, tokens)}
178
+ if doomed:
179
+ log.debug(f"skip (needs {'/'.join(tokens)} loader): {sorted(doomed)}")
180
+ return doomed
181
+
182
+
183
+ def _rejection_reason(rejected: list[tuple[Path, str]]) -> str:
184
+ """Describe why an unpacked asset yielded nothing runnable."""
185
+ if not rejected:
186
+ return "no executable inside"
187
+ return ", ".join(
188
+ f"{path.name} ({label})" for path, label in sorted(rejected, key=lambda item: str(item[0]))
189
+ )
190
+
191
+
192
+ class BinaryNoExecutableError(RuntimeError):
193
+ """Raised when the selected asset extracts without a single runnable file."""
194
+
195
+ def __init__(
196
+ self,
197
+ spec: str,
198
+ release_tag: str,
199
+ asset_name: str,
200
+ alternatives: list[str],
201
+ ):
202
+ self.spec = spec
203
+ self.release_tag = release_tag
204
+ self.asset_name = asset_name
205
+ self.alternatives = alternatives
206
+ message = (
207
+ f"No executable found in '{asset_name}' for '{spec}' (release {release_tag}). "
208
+ "The asset carries no runnable file — a shared library, headers or a "
209
+ "source archive rather than the tool itself."
210
+ )
211
+ if alternatives:
212
+ others = ", ".join(alternatives[:10])
213
+ message += f" Other assets in this release: {others}."
214
+ message += " Point --asset-pattern at another asset."
215
+ super().__init__(message)
216
+
217
+
126
218
  class BinarySpecError(ValueError):
127
219
  """Raised when a spec cannot be parsed as a binary backend spec."""
128
220
 
@@ -578,10 +670,7 @@ class BinaryBackend:
578
670
  )
579
671
  if asset_pattern:
580
672
  log.debug(f"user-forced asset_pattern: {asset_pattern!r}")
581
- selection_pattern = _selection_pattern(effective_pattern, release.tag)
582
- if locked_binary is None:
583
- asset = self._select_asset(release, selection_pattern, tool_name, spec)
584
- else:
673
+ if locked_binary is not None:
585
674
  asset = release.assets[0]
586
675
  if not is_asset_arch_compatible(
587
676
  asset.name,
@@ -589,36 +678,33 @@ class BinaryBackend:
589
678
  declared_platform=locked_binary.platform,
590
679
  ):
591
680
  raise AssetSelectionError(spec, release.tag, [asset.name])
592
- log.debug(f"selected asset: {asset.name}")
593
-
594
- archive_path = self._download_asset(
595
- source,
596
- repo_spec,
597
- asset,
598
- cache_dir,
599
- tag=release.tag,
600
- upgrade=upgrade,
601
- )
602
- log.debug(f"archive: {archive_path}")
603
-
604
- extract_tool_name = tool_name
605
- if not is_archive(asset.name) and not (setup and setup.name):
606
- extract_tool_name = _derive_bare_binary_name(asset.name, tool_name, release.tag)
607
-
608
- result = extract(archive_path, env_dir, tool_name=extract_tool_name)
609
- log.debug(f"extracted to: {result.root}")
610
-
611
- heuristics = load_heuristics()
612
- rejected_executables: list[tuple[Path, str]] = []
613
- executables = find_executables(
614
- result.root,
615
- expose=heuristics.expose,
616
- log=log,
617
- rejected=rejected_executables,
618
- )
619
- log.debug(f"executables: {[e.name for e in executables]}")
620
- if not executables and rejected_executables:
621
- raise BinaryPlatformError(spec, release.tag, asset.name, rejected_executables)
681
+ executables = self._unpack(
682
+ source,
683
+ repo_spec,
684
+ release,
685
+ asset,
686
+ env_dir,
687
+ cache_dir,
688
+ spec=spec,
689
+ tool_name=tool_name,
690
+ setup=setup,
691
+ upgrade=upgrade,
692
+ log=log,
693
+ )
694
+ else:
695
+ asset, release, executables = self._unpack_first_runnable(
696
+ source,
697
+ repo_spec,
698
+ release,
699
+ env_dir,
700
+ cache_dir,
701
+ spec=spec,
702
+ tool_name=tool_name,
703
+ setup=setup,
704
+ effective_pattern=effective_pattern,
705
+ upgrade=upgrade,
706
+ log=log,
707
+ )
622
708
 
623
709
  binaries = {exe.name: str(exe) for exe in executables}
624
710
 
@@ -727,29 +813,299 @@ class BinaryBackend:
727
813
  )
728
814
  return setup
729
815
 
816
+ def _unpack_candidate(
817
+ self,
818
+ source: ReleaseSource,
819
+ repo_spec: RepoSpec,
820
+ release: Release,
821
+ asset: Asset,
822
+ env_dir: Path,
823
+ cache_dir: Path,
824
+ *,
825
+ tool_name: str,
826
+ setup: SetupConfig | None,
827
+ upgrade: bool,
828
+ log,
829
+ ) -> tuple[list[Path], list[tuple[Path, str]]]:
830
+ """Download *asset* into *env_dir* and report what can run on this host.
831
+
832
+ Returns the runnable executables and, when there are none, the files
833
+ that were found but rejected as incompatible.
834
+ """
835
+ log.debug(f"selected asset: {asset.name}")
836
+ archive_path = self._download_asset(
837
+ source,
838
+ repo_spec,
839
+ asset,
840
+ cache_dir,
841
+ tag=release.tag,
842
+ upgrade=upgrade,
843
+ )
844
+ log.debug(f"archive: {archive_path}")
845
+
846
+ extract_tool_name = tool_name
847
+ if not is_archive(asset.name) and not (setup and setup.name):
848
+ extract_tool_name = _derive_bare_binary_name(asset.name, tool_name, release.tag)
849
+
850
+ result = extract(archive_path, env_dir, tool_name=extract_tool_name)
851
+ log.debug(f"extracted to: {result.root}")
852
+
853
+ rejected: list[tuple[Path, str]] = []
854
+ executables = find_executables(
855
+ result.root,
856
+ expose=load_heuristics().expose,
857
+ log=log,
858
+ rejected=rejected,
859
+ )
860
+ log.debug(f"executables: {[e.name for e in executables]}")
861
+ return executables, rejected
862
+
863
+ def _unpack(
864
+ self,
865
+ source: ReleaseSource,
866
+ repo_spec: RepoSpec,
867
+ release: Release,
868
+ asset: Asset,
869
+ env_dir: Path,
870
+ cache_dir: Path,
871
+ *,
872
+ spec: str,
873
+ tool_name: str,
874
+ setup: SetupConfig | None,
875
+ upgrade: bool,
876
+ log,
877
+ ) -> list[Path]:
878
+ """Unpack the one asset a lockfile names, which has no fallback."""
879
+ executables, rejected = self._unpack_candidate(
880
+ source,
881
+ repo_spec,
882
+ release,
883
+ asset,
884
+ env_dir,
885
+ cache_dir,
886
+ tool_name=tool_name,
887
+ setup=setup,
888
+ upgrade=upgrade,
889
+ log=log,
890
+ )
891
+ if executables:
892
+ return executables
893
+ if rejected:
894
+ raise BinaryPlatformError(spec, release.tag, asset.name, rejected)
895
+ raise BinaryNoExecutableError(spec, release.tag, asset.name, [])
896
+
897
+ def _unpack_first_runnable(
898
+ self,
899
+ source: ReleaseSource,
900
+ repo_spec: RepoSpec,
901
+ release: Release,
902
+ env_dir: Path,
903
+ cache_dir: Path,
904
+ *,
905
+ spec: str,
906
+ tool_name: str,
907
+ setup: SetupConfig | None,
908
+ effective_pattern: str | None,
909
+ upgrade: bool,
910
+ log,
911
+ ) -> tuple[Asset, Release, list[Path]]:
912
+ """Try candidates in scoring order until one unpacks to something runnable.
913
+
914
+ An archive only says what it holds once unpacked: the tool, a shared
915
+ library shipped under the same os/arch tokens, or a build this host has
916
+ no loader for. Rather than fail on an unlucky first pick, a candidate
917
+ that unpacks to nothing usable is dropped and its siblings get their
918
+ turn — releases that ship a library beside the binary, or a musl build
919
+ beside the glibc one, resolve on the second pass.
920
+ """
921
+ excluded: set[str] = set()
922
+ tried: list[str] = []
923
+ first_rejection: tuple[str, list[tuple[Path, str]]] | None = None
924
+ relearned = False
925
+
926
+ while True:
927
+ selection_pattern = _selection_pattern(effective_pattern, release.tag)
928
+ try:
929
+ asset = self._select_asset(
930
+ release,
931
+ selection_pattern,
932
+ tool_name,
933
+ spec,
934
+ repo_spec=repo_spec,
935
+ exclude=excluded,
936
+ )
937
+ except AssetSelectionError:
938
+ # A synthesized release carries only the asset its pattern named.
939
+ # If that asset cannot run here, the pattern is wrong for this
940
+ # host, not merely stale — relearn it against the full release.
941
+ wider = None if relearned else self._relearn_release(source, repo_spec, release)
942
+ if wider is not None:
943
+ relearned = True
944
+ release = wider
945
+ continue
946
+ if first_rejection is not None:
947
+ name, rejected = first_rejection
948
+ raise BinaryPlatformError(spec, release.tag, name, rejected) from None
949
+ if tried:
950
+ # Nothing in the release unpacked to a runnable file. Name the
951
+ # best candidate and let the user point at another asset: an
952
+ # install that exposes nothing must never look successful.
953
+ raise BinaryNoExecutableError(
954
+ spec,
955
+ release.tag,
956
+ tried[0],
957
+ [a.name for a in release.assets if a.name not in tried],
958
+ ) from None
959
+ raise
960
+
961
+ executables, rejected = self._unpack_candidate(
962
+ source,
963
+ repo_spec,
964
+ release,
965
+ asset,
966
+ env_dir,
967
+ cache_dir,
968
+ tool_name=tool_name,
969
+ setup=setup,
970
+ upgrade=upgrade,
971
+ log=log,
972
+ )
973
+ if executables:
974
+ return asset, release, executables
975
+
976
+ log.debug(f"asset skip: {asset.name} — {_rejection_reason(rejected)}")
977
+ doomed = _doomed_by_libc(release, rejected, log)
978
+ _empty_dir(env_dir)
979
+ if effective_pattern is not None:
980
+ # The asset was named, by the user or by the tool's setup file.
981
+ # Quietly installing a different one would answer a question
982
+ # nobody asked: report what the named asset turned out to be.
983
+ if rejected:
984
+ raise BinaryPlatformError(spec, release.tag, asset.name, rejected)
985
+ raise BinaryNoExecutableError(
986
+ spec,
987
+ release.tag,
988
+ asset.name,
989
+ [a.name for a in release.assets if a.name != asset.name],
990
+ )
991
+ tried.append(asset.name)
992
+ if rejected and first_rejection is None:
993
+ first_rejection = (asset.name, rejected)
994
+ excluded.add(asset.name)
995
+ excluded |= doomed
996
+
730
997
  def _select_asset(
731
998
  self,
732
999
  release: Release,
733
1000
  asset_pattern: str | None,
734
1001
  tool_name: str,
735
1002
  main_spec: str,
1003
+ *,
1004
+ repo_spec: RepoSpec | None = None,
1005
+ exclude: set[str] | None = None,
736
1006
  ) -> Asset:
1007
+ log = get_logger("binary")
737
1008
  heuristics = load_heuristics()
738
- asset = select_asset(
739
- release.assets,
740
- tool_name,
741
- _strip_v_prefix(release.tag),
742
- heuristics=heuristics,
743
- asset_pattern=asset_pattern,
744
- verbose=get_verbosity(),
745
- )
746
- if asset is None:
747
- raise AssetSelectionError(
748
- main_spec,
749
- release.tag,
750
- [a.name for a in release.assets],
1009
+ excluded = exclude or set()
1010
+ candidates = [a for a in release.assets if a.name not in excluded]
1011
+ unusable: list[tuple[str, str]] = []
1012
+
1013
+ while candidates:
1014
+ asset = select_asset(
1015
+ candidates,
1016
+ tool_name,
1017
+ _strip_v_prefix(release.tag),
1018
+ heuristics=heuristics,
1019
+ asset_pattern=asset_pattern,
1020
+ verbose=get_verbosity(),
751
1021
  )
752
- return asset
1022
+ if asset is None:
1023
+ break
1024
+
1025
+ loader = self._unrunnable_loader(asset, repo_spec=repo_spec, tag=release.tag)
1026
+ if loader is None:
1027
+ return asset
1028
+
1029
+ log.debug(f"asset skip: {asset.name} needs missing loader {loader}")
1030
+ unusable.append((asset.name, loader))
1031
+ candidates = [a for a in candidates if a.name != asset.name]
1032
+
1033
+ raise AssetSelectionError(
1034
+ main_spec,
1035
+ release.tag,
1036
+ [a.name for a in release.assets],
1037
+ unusable,
1038
+ )
1039
+
1040
+ def _relearn_release(
1041
+ self,
1042
+ source: ReleaseSource,
1043
+ repo_spec: RepoSpec,
1044
+ release: Release,
1045
+ ) -> Release | None:
1046
+ """Re-fetch *release* from the API after its learned pattern proved wrong.
1047
+
1048
+ The fast path synthesizes a release holding only the asset its pattern
1049
+ names, so a pattern pointing at an unrunnable build leaves selection
1050
+ with nothing to fall back on. The pattern is dropped and the real asset
1051
+ list fetched, which lets scoring reach the runnable siblings.
1052
+
1053
+ Returns None when the release already came from the API: there is then
1054
+ no wider list to fall back to, and the caller should surface the
1055
+ original selection error instead.
1056
+ """
1057
+ if (release.resolution_source or "").endswith("-api"):
1058
+ return None
1059
+
1060
+ log = get_logger("binary")
1061
+ log.debug(
1062
+ f"asset pattern for {repo_spec.owner}/{repo_spec.repo} names an unrunnable "
1063
+ "asset — invalidating and refetching from the release API"
1064
+ )
1065
+ from ixt.config import asset_pattern_cache
1066
+
1067
+ asset_pattern_cache.invalidate(repo_spec.owner, repo_spec.repo)
1068
+ self._require_github_api(repo_spec)
1069
+ refetched = source.get_release_by_tag(repo_spec.owner, repo_spec.repo, release.tag)
1070
+ refetched.resolution_source = f"{repo_spec.platform}-api"
1071
+ log.debug(f"release (relearn): {refetched.tag} ({len(refetched.assets)} assets)")
1072
+ return refetched
1073
+
1074
+ def _unrunnable_loader(
1075
+ self,
1076
+ asset: Asset,
1077
+ *,
1078
+ repo_spec: RepoSpec | None,
1079
+ tag: str,
1080
+ ) -> str | None:
1081
+ """Return the loader *asset* needs but this host lacks, else None.
1082
+
1083
+ A cached asset is read from disk; otherwise only the head of the asset
1084
+ is fetched over HTTP Range, which keeps a doomed candidate from costing
1085
+ a full download. Only bare binaries can be checked this way — an
1086
+ archive reveals nothing at offset zero, so those are validated after
1087
+ extraction by ``find_executables`` instead.
1088
+
1089
+ Any doubt resolves to None: a server without Range support, a network
1090
+ hiccup, or a truncated prefix all fall through to the normal path.
1091
+ """
1092
+ if os.name == "nt" or is_archive(asset.name):
1093
+ return None
1094
+ if repo_spec is not None:
1095
+ cache_path = _asset_cache_path(self.settings.downloads_dir, repo_spec, asset, tag=tag)
1096
+ cached = _cached_asset(cache_path, asset.size)
1097
+ if cached is not None:
1098
+ # Already on disk: judge the real file, no request needed.
1099
+ info = read_elf(cached)
1100
+ return info.interpreter if info is not None and info.loader_missing() else None
1101
+
1102
+ head = get_range(asset.url, ELF_PREFIX_SIZE)
1103
+ if head is None:
1104
+ return None
1105
+ info = parse_elf(head)
1106
+ if info is None or not info.loader_missing():
1107
+ return None
1108
+ return info.interpreter
753
1109
 
754
1110
  def uninstall_package(self, env_dir: Path, package: str) -> None:
755
1111
  """No-op — binary tools are atomic (uninstall removes the whole env)."""