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.
- ixt/__init__.py +8 -0
- ixt/__main__.py +8 -0
- ixt/backends/__init__.py +1 -0
- ixt/backends/binary.py +935 -0
- ixt/backends/binary_resolver.py +307 -0
- ixt/backends/node.py +490 -0
- ixt/backends/python.py +234 -0
- ixt/cli/__init__.py +31 -0
- ixt/cli/argparse_completion.py +557 -0
- ixt/cli/cmd_apply.py +404 -0
- ixt/cli/cmd_cache.py +86 -0
- ixt/cli/cmd_config.py +295 -0
- ixt/cli/cmd_info.py +116 -0
- ixt/cli/cmd_install.py +508 -0
- ixt/cli/cmd_misc.py +261 -0
- ixt/cli/cmd_registry.py +35 -0
- ixt/cli/cmd_upgrade.py +336 -0
- ixt/cli/commands.py +70 -0
- ixt/cli/parser.py +555 -0
- ixt/cli/render.py +85 -0
- ixt/config/__init__.py +5 -0
- ixt/config/asset_index.py +305 -0
- ixt/config/asset_pattern_cache.py +87 -0
- ixt/config/env_policy.py +340 -0
- ixt/config/flags.py +29 -0
- ixt/config/fs_policy.py +17 -0
- ixt/config/heuristics.py +465 -0
- ixt/config/models.py +176 -0
- ixt/config/registry.py +145 -0
- ixt/config/settings.py +173 -0
- ixt/config/setup_toml.py +179 -0
- ixt/config/toml.py +416 -0
- ixt/core/__init__.py +16 -0
- ixt/core/apply.py +564 -0
- ixt/core/apply_actions.py +106 -0
- ixt/core/backend.py +187 -0
- ixt/core/bootstrap.py +410 -0
- ixt/core/cache.py +332 -0
- ixt/core/discover.py +150 -0
- ixt/core/doctor.py +591 -0
- ixt/core/export.py +419 -0
- ixt/core/expose.py +350 -0
- ixt/core/extract.py +261 -0
- ixt/core/hooks.py +182 -0
- ixt/core/identity.py +148 -0
- ixt/core/inject.py +143 -0
- ixt/core/install.py +509 -0
- ixt/core/install_local.py +229 -0
- ixt/core/locks.py +54 -0
- ixt/core/pathlink.py +86 -0
- ixt/core/resolution_stats.py +191 -0
- ixt/core/resolve.py +150 -0
- ixt/core/resolve_cache.py +185 -0
- ixt/core/runtimes.py +192 -0
- ixt/core/save.py +237 -0
- ixt/core/setup_completions.py +11 -0
- ixt/core/setup_path.py +368 -0
- ixt/core/upgrade.py +596 -0
- ixt/data/__init__.py +10 -0
- ixt/data/asset_index.json +574 -0
- ixt/data/heuristics.toml +98 -0
- ixt/data/registry.toml +71 -0
- ixt/libs/__init__.py +3 -0
- ixt/libs/constants.py +4 -0
- ixt/libs/fmt.py +108 -0
- ixt/libs/logger.py +109 -0
- ixt/libs/output.py +25 -0
- ixt/libs/req_spec.py +115 -0
- ixt/libs/semver.py +149 -0
- ixt/libs/shell.py +126 -0
- ixt/libs/style.py +238 -0
- ixt/net/__init__.py +1 -0
- ixt/net/github_api.py +158 -0
- ixt/net/gitlab_api.py +149 -0
- ixt/net/http.py +194 -0
- ixt/net/npm.py +24 -0
- ixt/net/pypi.py +26 -0
- ixt/net/source.py +163 -0
- ixt/platform/__init__.py +131 -0
- ixt/platform/win.py +68 -0
- ixt_cli-0.8.0.dist-info/METADATA +294 -0
- ixt_cli-0.8.0.dist-info/RECORD +84 -0
- ixt_cli-0.8.0.dist-info/WHEEL +4 -0
- ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
ixt/config/heuristics.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Heuristic asset selection engine for binary backend.
|
|
2
|
+
|
|
3
|
+
Loads heuristics.toml (embedded defaults + optional user overrides)
|
|
4
|
+
and implements the 3-level asset selection algorithm:
|
|
5
|
+
Level 1 — Exact pattern (from ixt.setup.toml)
|
|
6
|
+
Level 2 — Generic patterns (from heuristics.toml)
|
|
7
|
+
Level 3 — Scoring fallback
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fnmatch
|
|
13
|
+
import functools
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ixt.config.toml import _load_toml
|
|
19
|
+
from ixt.data import get_data_path
|
|
20
|
+
from ixt.net.source import Asset
|
|
21
|
+
from ixt.platform import OS, Arch, get_arch, get_os
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Config models
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class ScoringConfig:
|
|
30
|
+
prefer: list[str] = field(default_factory=list)
|
|
31
|
+
avoid: list[str] = field(default_factory=list)
|
|
32
|
+
skip_ext: list[str] = field(default_factory=list)
|
|
33
|
+
archive_ext: list[str] = field(default_factory=list)
|
|
34
|
+
archive_priority: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class ExposeConfig:
|
|
39
|
+
skip_names: list[str] = field(default_factory=list)
|
|
40
|
+
skip_patterns: list[str] = field(default_factory=list)
|
|
41
|
+
skip_keywords: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class NamesConfig:
|
|
46
|
+
strip_suffix_tokens: list[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(slots=True)
|
|
50
|
+
class HeuristicsConfig:
|
|
51
|
+
os_aliases: dict[str, list[str]] = field(default_factory=dict)
|
|
52
|
+
arch_aliases: dict[str, list[str]] = field(default_factory=dict)
|
|
53
|
+
patterns: list[str] = field(default_factory=list)
|
|
54
|
+
scoring: ScoringConfig = field(default_factory=ScoringConfig)
|
|
55
|
+
expose: ExposeConfig = field(default_factory=ExposeConfig)
|
|
56
|
+
names: NamesConfig = field(default_factory=NamesConfig)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Loading
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
_OS_MAP: dict[OS, str] = {
|
|
64
|
+
OS.LINUX: "linux",
|
|
65
|
+
OS.MACOS: "macos",
|
|
66
|
+
OS.WINDOWS: "windows",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_ARCH_MAP: dict[Arch, str] = {
|
|
70
|
+
Arch.X86_64: "x86_64",
|
|
71
|
+
Arch.ARM64: "aarch64",
|
|
72
|
+
Arch.ARMV7: "armv7",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_heuristics(data: dict[str, Any]) -> HeuristicsConfig:
|
|
77
|
+
"""Build a HeuristicsConfig from parsed TOML dict."""
|
|
78
|
+
scoring_data = data.get("scoring", {})
|
|
79
|
+
expose_data = data.get("expose", {})
|
|
80
|
+
names_data = data.get("names", {})
|
|
81
|
+
|
|
82
|
+
return HeuristicsConfig(
|
|
83
|
+
os_aliases=data.get("os", {}),
|
|
84
|
+
arch_aliases=data.get("arch", {}),
|
|
85
|
+
patterns=data.get("patterns", {}).get("templates", []),
|
|
86
|
+
scoring=ScoringConfig(
|
|
87
|
+
prefer=scoring_data.get("prefer", []),
|
|
88
|
+
avoid=scoring_data.get("avoid", []),
|
|
89
|
+
skip_ext=scoring_data.get("skip_ext", []),
|
|
90
|
+
archive_ext=scoring_data.get("archive_ext", []),
|
|
91
|
+
archive_priority=scoring_data.get("archive_priority", []),
|
|
92
|
+
),
|
|
93
|
+
expose=ExposeConfig(
|
|
94
|
+
skip_names=expose_data.get("skip_names", []),
|
|
95
|
+
skip_patterns=expose_data.get("skip_patterns", []),
|
|
96
|
+
skip_keywords=expose_data.get("skip_keywords", []),
|
|
97
|
+
),
|
|
98
|
+
names=NamesConfig(
|
|
99
|
+
strip_suffix_tokens=names_data.get("strip_suffix_tokens", []),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _merge_heuristics(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
"""Shallow merge: override replaces entire top-level sections."""
|
|
106
|
+
merged = dict(base)
|
|
107
|
+
for key, val in override.items():
|
|
108
|
+
if isinstance(val, dict) and isinstance(merged.get(key), dict):
|
|
109
|
+
merged[key] = {**merged[key], **val}
|
|
110
|
+
else:
|
|
111
|
+
merged[key] = val
|
|
112
|
+
return merged
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@functools.lru_cache(maxsize=1)
|
|
116
|
+
def load_heuristics(user_path: Path | None = None) -> HeuristicsConfig:
|
|
117
|
+
"""Load heuristics config (embedded defaults + optional user overrides).
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
user_path: Explicit path to user override file.
|
|
121
|
+
If None, checks $IXT_HOME/config/heuristics.toml.
|
|
122
|
+
"""
|
|
123
|
+
# Embedded defaults
|
|
124
|
+
data = _load_toml(get_data_path("heuristics.toml"))
|
|
125
|
+
|
|
126
|
+
# User overrides
|
|
127
|
+
if user_path is None:
|
|
128
|
+
from ixt.config.settings import get_ixt_config_dir
|
|
129
|
+
|
|
130
|
+
user_path = get_ixt_config_dir() / "heuristics.toml"
|
|
131
|
+
|
|
132
|
+
if user_path.is_file():
|
|
133
|
+
user_data = _load_toml(user_path)
|
|
134
|
+
data = _merge_heuristics(data, user_data)
|
|
135
|
+
|
|
136
|
+
return _parse_heuristics(data)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Verbose trace
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class _Trace:
|
|
145
|
+
"""Collects selection trace lines. No-op when level=0.
|
|
146
|
+
|
|
147
|
+
level 1 (-v): key decisions — pattern matches, winner, scoring
|
|
148
|
+
level 2 (-vv): adds filtering — skipped assets, all patterns tried
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, level: int = 0):
|
|
152
|
+
self.level = level
|
|
153
|
+
self.lines: list[str] = []
|
|
154
|
+
|
|
155
|
+
def __call__(self, msg: str, *, min_level: int = 1) -> None:
|
|
156
|
+
if self.level >= min_level:
|
|
157
|
+
self.lines.append(msg)
|
|
158
|
+
|
|
159
|
+
def flush(self) -> None:
|
|
160
|
+
if not self.level or not self.lines:
|
|
161
|
+
return
|
|
162
|
+
from ixt.libs.logger import Level, get_logger
|
|
163
|
+
|
|
164
|
+
log = get_logger("heuristics", Level.DEBUG)
|
|
165
|
+
for line in self.lines:
|
|
166
|
+
log.debug(line)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# Pattern helpers
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _resolve_pattern(pattern: str, name: str, os_str: str, arch_str: str, version: str) -> str:
|
|
175
|
+
"""Replace {name}, {os}, {arch}, {version} in a pattern template."""
|
|
176
|
+
result = pattern.replace("{name}", name)
|
|
177
|
+
result = result.replace("{os}", os_str)
|
|
178
|
+
result = result.replace("{arch}", arch_str)
|
|
179
|
+
result = result.replace("{version}", version)
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _get_os_aliases(cfg: HeuristicsConfig, os_key: str) -> list[str]:
|
|
184
|
+
"""Return all aliases for the given OS key (includes the key itself)."""
|
|
185
|
+
aliases = cfg.os_aliases.get(os_key, [])
|
|
186
|
+
if os_key not in aliases:
|
|
187
|
+
return [os_key, *aliases]
|
|
188
|
+
return aliases
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _get_arch_aliases(cfg: HeuristicsConfig, arch_key: str) -> list[str]:
|
|
192
|
+
"""Return all aliases for the given arch key (includes the key itself)."""
|
|
193
|
+
aliases = cfg.arch_aliases.get(arch_key, [])
|
|
194
|
+
if arch_key not in aliases:
|
|
195
|
+
return [arch_key, *aliases]
|
|
196
|
+
return aliases
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def get_platform_aliases(
|
|
200
|
+
cfg: HeuristicsConfig | None = None,
|
|
201
|
+
*,
|
|
202
|
+
os_override: OS | None = None,
|
|
203
|
+
arch_override: Arch | None = None,
|
|
204
|
+
) -> tuple[list[str], list[str]]:
|
|
205
|
+
"""Return OS and architecture aliases for the current platform."""
|
|
206
|
+
cfg = cfg or load_heuristics()
|
|
207
|
+
current_os = _OS_MAP[os_override or get_os()]
|
|
208
|
+
current_arch = _ARCH_MAP[arch_override or get_arch()]
|
|
209
|
+
return _get_os_aliases(cfg, current_os), _get_arch_aliases(cfg, current_arch)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_name_suffix_tokens(cfg: HeuristicsConfig | None = None) -> list[str]:
|
|
213
|
+
"""Return extra suffix tokens stripped from generated executable names."""
|
|
214
|
+
cfg = cfg or load_heuristics()
|
|
215
|
+
return list(cfg.names.strip_suffix_tokens)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _has_skip_ext(name_lower: str, skip_ext: list[str]) -> bool:
|
|
219
|
+
"""Check if asset name ends with a skippable extension."""
|
|
220
|
+
return any(name_lower.endswith(ext) for ext in skip_ext)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _has_avoid_keyword(name_lower: str, avoid: list[str]) -> bool:
|
|
224
|
+
"""Check if asset name contains an avoid keyword."""
|
|
225
|
+
return any(kw in name_lower for kw in avoid)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _get_archive_priority_score(name_lower: str, priority: list[str]) -> int:
|
|
229
|
+
"""Return archive priority score (higher index = lower priority)."""
|
|
230
|
+
for i, ext in enumerate(priority):
|
|
231
|
+
if name_lower.endswith(ext):
|
|
232
|
+
return len(priority) - i # first in list = highest score
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# Asset selection — 3-level algorithm
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def select_asset(
|
|
242
|
+
assets: list[Asset],
|
|
243
|
+
name: str,
|
|
244
|
+
version: str = "",
|
|
245
|
+
*,
|
|
246
|
+
heuristics: HeuristicsConfig | None = None,
|
|
247
|
+
asset_pattern: str | None = None,
|
|
248
|
+
os_override: OS | None = None,
|
|
249
|
+
arch_override: Arch | None = None,
|
|
250
|
+
verbose: bool | int = False,
|
|
251
|
+
) -> Asset | None:
|
|
252
|
+
"""Select the best matching asset from a release.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
assets: List of release assets.
|
|
256
|
+
name: Tool name (e.g. "ripgrep", "fd").
|
|
257
|
+
version: Release version tag (e.g. "14.1.0"), stripped of "v" prefix.
|
|
258
|
+
heuristics: Config to use. Loads defaults if None.
|
|
259
|
+
asset_pattern: Exact pattern from ixt.setup.toml (Level 1).
|
|
260
|
+
os_override: Override detected OS (for testing).
|
|
261
|
+
arch_override: Override detected arch (for testing).
|
|
262
|
+
verbose: Verbosity level (0=off, 1=decisions, 2=filtering details).
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Best matching Asset, or None if no match found.
|
|
266
|
+
"""
|
|
267
|
+
if not assets:
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
cfg = heuristics or load_heuristics()
|
|
271
|
+
current_os = _OS_MAP[os_override or get_os()]
|
|
272
|
+
current_arch = _ARCH_MAP[arch_override or get_arch()]
|
|
273
|
+
t = _Trace(int(verbose))
|
|
274
|
+
|
|
275
|
+
# Strip v prefix from version for pattern matching
|
|
276
|
+
ver = version.lstrip("v") if version else ""
|
|
277
|
+
|
|
278
|
+
os_aliases = _get_os_aliases(cfg, current_os)
|
|
279
|
+
arch_aliases = _get_arch_aliases(cfg, current_arch)
|
|
280
|
+
|
|
281
|
+
t(f"select: name={name!r} version={ver!r} os={current_os} arch={current_arch}")
|
|
282
|
+
t(f" os_aliases: {os_aliases}")
|
|
283
|
+
t(f" arch_aliases: {arch_aliases}")
|
|
284
|
+
t(f" assets ({len(assets)}): {[a.name for a in assets]}")
|
|
285
|
+
|
|
286
|
+
# Level 1 — Exact pattern
|
|
287
|
+
if asset_pattern:
|
|
288
|
+
t(f" level 1: asset_pattern={asset_pattern!r}")
|
|
289
|
+
matches = _match_pattern(assets, asset_pattern, name, current_os, current_arch, ver, t)
|
|
290
|
+
if len(matches) == 1:
|
|
291
|
+
t(f" level 1 -> exact match: {matches[0].name}")
|
|
292
|
+
t.flush()
|
|
293
|
+
return matches[0]
|
|
294
|
+
if matches:
|
|
295
|
+
result = _score_best(matches, os_aliases, arch_aliases, cfg.scoring, t)
|
|
296
|
+
t(f" level 1 -> scored winner: {result.name if result else 'none'}")
|
|
297
|
+
t.flush()
|
|
298
|
+
return result
|
|
299
|
+
t(" level 1 -> no match")
|
|
300
|
+
|
|
301
|
+
# Level 2 — Generic patterns
|
|
302
|
+
t(" level 2: trying generic patterns")
|
|
303
|
+
for pattern in cfg.patterns:
|
|
304
|
+
if "{version}" in pattern and not ver:
|
|
305
|
+
t(f" pattern {pattern!r} -> skip (no version)", min_level=2)
|
|
306
|
+
continue
|
|
307
|
+
matches = _match_pattern(assets, pattern, name, current_os, current_arch, ver, t)
|
|
308
|
+
if matches:
|
|
309
|
+
t(f" pattern {pattern!r} -> {len(matches)} match(es): {[a.name for a in matches]}")
|
|
310
|
+
if len(matches) == 1:
|
|
311
|
+
t(f" level 2 -> single match: {matches[0].name}")
|
|
312
|
+
t.flush()
|
|
313
|
+
return matches[0]
|
|
314
|
+
result = _score_best(matches, os_aliases, arch_aliases, cfg.scoring, t)
|
|
315
|
+
t(f" level 2 -> scored winner: {result.name if result else 'none'}")
|
|
316
|
+
t.flush()
|
|
317
|
+
return result
|
|
318
|
+
t(f" pattern {pattern!r} -> no match", min_level=2)
|
|
319
|
+
|
|
320
|
+
# Level 3 — Scoring fallback
|
|
321
|
+
t(" level 3: scoring all assets")
|
|
322
|
+
result = _score_assets(assets, os_aliases, arch_aliases, cfg.scoring, t)
|
|
323
|
+
t(f" level 3 -> winner: {result.name if result else 'none'}")
|
|
324
|
+
t.flush()
|
|
325
|
+
return result
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _match_pattern(
|
|
329
|
+
assets: list[Asset],
|
|
330
|
+
pattern: str,
|
|
331
|
+
name: str,
|
|
332
|
+
os_str: str,
|
|
333
|
+
arch_str: str,
|
|
334
|
+
version: str,
|
|
335
|
+
trace: _Trace | None = None,
|
|
336
|
+
) -> list[Asset]:
|
|
337
|
+
"""Try to match assets against a pattern template.
|
|
338
|
+
|
|
339
|
+
For each OS alias x arch alias combination, resolve the pattern
|
|
340
|
+
and fnmatch against all assets. Assets with skip_ext are excluded.
|
|
341
|
+
Return list of matches (may be empty).
|
|
342
|
+
"""
|
|
343
|
+
t = trace or _Trace(0)
|
|
344
|
+
cfg = load_heuristics()
|
|
345
|
+
os_aliases = _get_os_aliases(cfg, os_str)
|
|
346
|
+
arch_aliases = _get_arch_aliases(cfg, arch_str)
|
|
347
|
+
|
|
348
|
+
matches: list[Asset] = []
|
|
349
|
+
seen: set[str] = set()
|
|
350
|
+
skipped: list[str] = []
|
|
351
|
+
|
|
352
|
+
for os_alias in os_aliases:
|
|
353
|
+
for arch_alias in arch_aliases:
|
|
354
|
+
resolved = _resolve_pattern(pattern, name, os_alias, arch_alias, version)
|
|
355
|
+
for asset in assets:
|
|
356
|
+
if asset.name in seen:
|
|
357
|
+
continue
|
|
358
|
+
lower = asset.name.lower()
|
|
359
|
+
if _has_skip_ext(lower, cfg.scoring.skip_ext):
|
|
360
|
+
seen.add(asset.name)
|
|
361
|
+
skipped.append(asset.name)
|
|
362
|
+
continue
|
|
363
|
+
if fnmatch.fnmatch(lower, resolved.lower()):
|
|
364
|
+
matches.append(asset)
|
|
365
|
+
seen.add(asset.name)
|
|
366
|
+
|
|
367
|
+
if skipped:
|
|
368
|
+
t(f" skip_ext: {skipped}", min_level=2)
|
|
369
|
+
|
|
370
|
+
return matches
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _score_best(
|
|
374
|
+
assets: list[Asset],
|
|
375
|
+
os_aliases: list[str],
|
|
376
|
+
arch_aliases: list[str],
|
|
377
|
+
scoring: ScoringConfig,
|
|
378
|
+
trace: _Trace | None = None,
|
|
379
|
+
) -> Asset | None:
|
|
380
|
+
"""Score a pre-filtered list and return the best."""
|
|
381
|
+
scored = _compute_scores(assets, os_aliases, arch_aliases, scoring, trace)
|
|
382
|
+
if not scored:
|
|
383
|
+
return None
|
|
384
|
+
scored.sort(key=lambda x: (-x[1], len(x[0].name)))
|
|
385
|
+
return scored[0][0]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _score_assets(
|
|
389
|
+
assets: list[Asset],
|
|
390
|
+
os_aliases: list[str],
|
|
391
|
+
arch_aliases: list[str],
|
|
392
|
+
scoring: ScoringConfig,
|
|
393
|
+
trace: _Trace | None = None,
|
|
394
|
+
) -> Asset | None:
|
|
395
|
+
"""Level 3: score all assets and return the best match."""
|
|
396
|
+
t = trace or _Trace(0)
|
|
397
|
+
|
|
398
|
+
# Pre-filter
|
|
399
|
+
candidates = []
|
|
400
|
+
for asset in assets:
|
|
401
|
+
lower = asset.name.lower()
|
|
402
|
+
if _has_skip_ext(lower, scoring.skip_ext):
|
|
403
|
+
t(f" skip (ext): {asset.name}", min_level=2)
|
|
404
|
+
continue
|
|
405
|
+
if _has_avoid_keyword(lower, scoring.avoid):
|
|
406
|
+
t(f" skip (avoid): {asset.name}", min_level=2)
|
|
407
|
+
continue
|
|
408
|
+
candidates.append(asset)
|
|
409
|
+
|
|
410
|
+
scored = _compute_scores(candidates, os_aliases, arch_aliases, scoring, trace)
|
|
411
|
+
if not scored:
|
|
412
|
+
return None
|
|
413
|
+
|
|
414
|
+
# Must have at least OS or arch match
|
|
415
|
+
scored = [(a, s) for a, s in scored if s > 0]
|
|
416
|
+
if not scored:
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
scored.sort(key=lambda x: (-x[1], len(x[0].name)))
|
|
420
|
+
return scored[0][0]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _compute_scores(
|
|
424
|
+
assets: list[Asset],
|
|
425
|
+
os_aliases: list[str],
|
|
426
|
+
arch_aliases: list[str],
|
|
427
|
+
scoring: ScoringConfig,
|
|
428
|
+
trace: _Trace | None = None,
|
|
429
|
+
) -> list[tuple[Asset, int]]:
|
|
430
|
+
"""Compute score for each asset."""
|
|
431
|
+
t = trace or _Trace(False)
|
|
432
|
+
results: list[tuple[Asset, int]] = []
|
|
433
|
+
|
|
434
|
+
for asset in assets:
|
|
435
|
+
lower = asset.name.lower()
|
|
436
|
+
score = 0
|
|
437
|
+
reasons: list[str] = []
|
|
438
|
+
|
|
439
|
+
# OS match (+10)
|
|
440
|
+
if any(alias.lower() in lower for alias in os_aliases):
|
|
441
|
+
score += 10
|
|
442
|
+
reasons.append("os+10")
|
|
443
|
+
|
|
444
|
+
# Arch match (+10)
|
|
445
|
+
if any(alias.lower() in lower for alias in arch_aliases):
|
|
446
|
+
score += 10
|
|
447
|
+
reasons.append("arch+10")
|
|
448
|
+
|
|
449
|
+
# Prefer keywords (earlier in list = higher bonus)
|
|
450
|
+
for i, kw in enumerate(scoring.prefer):
|
|
451
|
+
if kw.lower() in lower:
|
|
452
|
+
bonus = len(scoring.prefer) - i + 1
|
|
453
|
+
score += bonus
|
|
454
|
+
reasons.append(f"{kw}+{bonus}")
|
|
455
|
+
|
|
456
|
+
# Archive priority (+1..N)
|
|
457
|
+
ap = _get_archive_priority_score(lower, scoring.archive_priority)
|
|
458
|
+
if ap:
|
|
459
|
+
score += ap
|
|
460
|
+
reasons.append(f"archive+{ap}")
|
|
461
|
+
|
|
462
|
+
t(f" score {score:3d} ({', '.join(reasons) or '-':30s}) {asset.name}")
|
|
463
|
+
results.append((asset, score))
|
|
464
|
+
|
|
465
|
+
return results
|
ixt/config/models.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Backend-agnostic metadata persisted per installed tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ixt.libs.constants import EXPOSE_MAIN
|
|
13
|
+
|
|
14
|
+
_METADATA_FILENAME = "ixt.json"
|
|
15
|
+
_MAX_METADATA_BYTES = 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _metadata_path(file_path: str | Path) -> Path:
|
|
19
|
+
"""Return a ToolRecord metadata path after cheap path-safety checks."""
|
|
20
|
+
path = Path(file_path).expanduser()
|
|
21
|
+
if path.name != _METADATA_FILENAME:
|
|
22
|
+
raise ValueError(f"ToolRecord metadata path must end with {_METADATA_FILENAME!r}: {path}")
|
|
23
|
+
if ".." in path.parts:
|
|
24
|
+
raise ValueError(f"Path traversal detected in ToolRecord metadata path: {path}")
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _absolute_path(path: str | Path) -> Path:
|
|
29
|
+
return Path(os.path.abspath(Path(path).expanduser()))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _encode_under_base(value: str, base: Path) -> str:
|
|
33
|
+
"""Persist *value* relative to *base* when it is inside that tree."""
|
|
34
|
+
path = Path(value).expanduser()
|
|
35
|
+
if not path.is_absolute():
|
|
36
|
+
return value
|
|
37
|
+
try:
|
|
38
|
+
rel = _absolute_path(path).relative_to(_absolute_path(base))
|
|
39
|
+
except ValueError:
|
|
40
|
+
return value
|
|
41
|
+
return "." if rel == Path(".") else str(rel)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _metadata_env_dir(data: dict[str, Any], path: Path) -> tuple[Path, Path | None]:
|
|
45
|
+
"""Return current env dir and legacy absolute env dir, if one was stored."""
|
|
46
|
+
raw = data.get("env_dir", ".")
|
|
47
|
+
raw_path = Path(raw)
|
|
48
|
+
if not raw_path.is_absolute():
|
|
49
|
+
return _absolute_path(path.parent / raw_path), None
|
|
50
|
+
|
|
51
|
+
current = _absolute_path(raw_path)
|
|
52
|
+
metadata_parent = _absolute_path(path.parent)
|
|
53
|
+
# Legacy records stored absolute env_dir values. If the metadata file is
|
|
54
|
+
# now inside a same-named env dir elsewhere, trust the file location so a
|
|
55
|
+
# copied installed layer can rebase itself on load.
|
|
56
|
+
if current != metadata_parent and current.name == metadata_parent.name:
|
|
57
|
+
return metadata_parent, current
|
|
58
|
+
return current, current
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _decode_exposed_bin(value: str, current_env_dir: Path, legacy_env_dir: Path | None) -> str:
|
|
62
|
+
path = Path(value)
|
|
63
|
+
if path.is_absolute():
|
|
64
|
+
if legacy_env_dir is not None:
|
|
65
|
+
try:
|
|
66
|
+
return str(current_env_dir / path.relative_to(legacy_env_dir))
|
|
67
|
+
except ValueError:
|
|
68
|
+
pass
|
|
69
|
+
return value
|
|
70
|
+
return str(current_env_dir / path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class ToolRecord:
|
|
75
|
+
"""Persisted state for one installed tool (``ixt.json``).
|
|
76
|
+
|
|
77
|
+
This model is intentionally flat and backend-agnostic.
|
|
78
|
+
Backend-specific details (venv layout, node_modules structure)
|
|
79
|
+
stay in the backend implementations.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
name: str # installed tool id (equals env_dir basename; may include a --slot prefix)
|
|
83
|
+
backend: str # "python" | "node" | "binary"
|
|
84
|
+
spec: str # original install spec as given by user
|
|
85
|
+
env_dir: str # in-memory absolute path; persisted relative when possible
|
|
86
|
+
version: str | None = None
|
|
87
|
+
# Underlying package name for resolve/expose. None on legacy records →
|
|
88
|
+
# pkg() falls back to ``name`` for records without explicit package metadata.
|
|
89
|
+
pkg_name: str | None = None
|
|
90
|
+
injected: list[str] = field(default_factory=list)
|
|
91
|
+
expose_rules: list[str] = field(default_factory=lambda: [EXPOSE_MAIN])
|
|
92
|
+
exposed_bins: dict[str, str] = field(default_factory=dict)
|
|
93
|
+
node_shim: bool | None = None
|
|
94
|
+
asset_pattern: str | None = None
|
|
95
|
+
asset_pattern_forced: bool = False
|
|
96
|
+
env_base: str = "all"
|
|
97
|
+
env_allow: list[str] = field(default_factory=list)
|
|
98
|
+
env_deny: dict[str, dict[str, list[str]]] = field(default_factory=dict)
|
|
99
|
+
fs_base: str = "all"
|
|
100
|
+
fs_ro: list[str] = field(default_factory=list)
|
|
101
|
+
fs_rw: list[str] = field(default_factory=list)
|
|
102
|
+
fs_scratch: list[str] = field(default_factory=list)
|
|
103
|
+
source: str = "registry" # "registry" | "local" (extensible: "git", "url", ...)
|
|
104
|
+
config_version: str = "1"
|
|
105
|
+
|
|
106
|
+
def pkg(self) -> str:
|
|
107
|
+
"""Underlying package name (for PyPI/npm resolve, backend bin lookup)."""
|
|
108
|
+
return self.pkg_name or self.name
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_dict(cls, data: dict[str, Any]) -> ToolRecord:
|
|
112
|
+
return cls(
|
|
113
|
+
name=data["name"],
|
|
114
|
+
backend=data["backend"],
|
|
115
|
+
spec=data["spec"],
|
|
116
|
+
env_dir=data.get("env_dir", "."),
|
|
117
|
+
version=data.get("version"),
|
|
118
|
+
pkg_name=data.get("pkg_name"),
|
|
119
|
+
injected=list(data.get("injected", [])),
|
|
120
|
+
expose_rules=list(data.get("expose_rules", [EXPOSE_MAIN])),
|
|
121
|
+
exposed_bins=dict(data.get("exposed_bins", {})),
|
|
122
|
+
node_shim=data.get("node_shim"),
|
|
123
|
+
asset_pattern=data.get("asset_pattern"),
|
|
124
|
+
asset_pattern_forced=bool(data.get("asset_pattern_forced", False)),
|
|
125
|
+
env_base=data.get("env_base", "all"),
|
|
126
|
+
env_allow=list(data.get("env_allow", [])),
|
|
127
|
+
env_deny=dict(data.get("env_deny", {})),
|
|
128
|
+
fs_base=data.get("fs_base", "all"),
|
|
129
|
+
fs_ro=list(data.get("fs_ro", [])),
|
|
130
|
+
fs_rw=list(data.get("fs_rw", [])),
|
|
131
|
+
fs_scratch=list(data.get("fs_scratch", [])),
|
|
132
|
+
source=data.get("source", "registry"),
|
|
133
|
+
config_version=data.get("config_version", "1"),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def load_json(cls, file_path: str | Path) -> ToolRecord:
|
|
138
|
+
path = _metadata_path(file_path)
|
|
139
|
+
if not path.exists():
|
|
140
|
+
raise FileNotFoundError(path)
|
|
141
|
+
if path.is_symlink() or not path.is_file():
|
|
142
|
+
raise ValueError(f"ToolRecord metadata must be a regular file: {path}")
|
|
143
|
+
if path.stat().st_size > _MAX_METADATA_BYTES:
|
|
144
|
+
raise ValueError(f"ToolRecord metadata is too large: {path}")
|
|
145
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
146
|
+
current_env_dir, legacy_env_dir = _metadata_env_dir(data, path)
|
|
147
|
+
record = cls.from_dict(data)
|
|
148
|
+
record.env_dir = str(current_env_dir)
|
|
149
|
+
record.exposed_bins = {
|
|
150
|
+
name: _decode_exposed_bin(source, current_env_dir, legacy_env_dir)
|
|
151
|
+
for name, source in record.exposed_bins.items()
|
|
152
|
+
}
|
|
153
|
+
return record
|
|
154
|
+
|
|
155
|
+
def save_json(self, file_path: str | Path) -> Path:
|
|
156
|
+
path = _metadata_path(file_path)
|
|
157
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
if path.exists() and (path.is_symlink() or not path.is_file()):
|
|
159
|
+
raise ValueError(f"ToolRecord metadata destination must be a regular file: {path}")
|
|
160
|
+
env_dir = _absolute_path(self.env_dir)
|
|
161
|
+
data = asdict(self)
|
|
162
|
+
data["env_dir"] = _encode_under_base(str(env_dir), path.parent)
|
|
163
|
+
data["exposed_bins"] = {
|
|
164
|
+
name: _encode_under_base(source, env_dir)
|
|
165
|
+
for name, source in self.exposed_bins.items()
|
|
166
|
+
}
|
|
167
|
+
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
|
|
168
|
+
try:
|
|
169
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
170
|
+
json.dump(data, f, indent=2)
|
|
171
|
+
f.write("\n")
|
|
172
|
+
os.replace(tmp, path)
|
|
173
|
+
except Exception:
|
|
174
|
+
Path(tmp).unlink(missing_ok=True)
|
|
175
|
+
raise
|
|
176
|
+
return path
|