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/core/cache.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Cache storage helpers for ixt.
|
|
2
|
+
|
|
3
|
+
This module owns operational cache actions. ``doctor`` can report cache
|
|
4
|
+
health, but cache mutation lives here and behind ``ixt cache``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ixt.config.settings import Settings, get_settings
|
|
19
|
+
|
|
20
|
+
DOWNLOADS_INDEX_FILENAME = "downloads.json"
|
|
21
|
+
DOWNLOADS_INDEX_SCHEMA = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class SizeEntry:
|
|
26
|
+
"""Size and file count for one storage location."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
path: Path
|
|
30
|
+
files: int
|
|
31
|
+
bytes: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class ClearResult:
|
|
36
|
+
"""Outcome of a cache-clear action."""
|
|
37
|
+
|
|
38
|
+
target: str
|
|
39
|
+
path: Path
|
|
40
|
+
removed_count: int
|
|
41
|
+
removed_bytes: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class PruneResult:
|
|
46
|
+
"""Outcome of pruning indexed download artifacts."""
|
|
47
|
+
|
|
48
|
+
kept_count: int
|
|
49
|
+
removed_count: int
|
|
50
|
+
removed_bytes: int
|
|
51
|
+
stale_entries: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def humanize_size(size: int) -> str:
|
|
55
|
+
"""Return a compact human-readable size."""
|
|
56
|
+
value: float = float(size)
|
|
57
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
58
|
+
if value < 1024 or unit == "GB":
|
|
59
|
+
return f"{int(value)} B" if unit == "B" else f"{value:.1f} {unit}"
|
|
60
|
+
value /= 1024
|
|
61
|
+
return f"{int(value)} B"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def count_and_size(path: Path) -> tuple[int, int]:
|
|
65
|
+
"""Return ``(file_count, total_bytes)`` for *path*.
|
|
66
|
+
|
|
67
|
+
Missing paths count as empty. A direct file path counts as one file.
|
|
68
|
+
"""
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return 0, 0
|
|
71
|
+
try:
|
|
72
|
+
if path.is_file() or path.is_symlink():
|
|
73
|
+
return 1, path.stat().st_size
|
|
74
|
+
except OSError:
|
|
75
|
+
return 0, 0
|
|
76
|
+
|
|
77
|
+
count = 0
|
|
78
|
+
total = 0
|
|
79
|
+
for entry in path.rglob("*"):
|
|
80
|
+
try:
|
|
81
|
+
if entry.is_file():
|
|
82
|
+
count += 1
|
|
83
|
+
total += entry.stat().st_size
|
|
84
|
+
except OSError:
|
|
85
|
+
continue
|
|
86
|
+
return count, total
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def storage_sizes(*, settings: Settings | None = None) -> list[SizeEntry]:
|
|
90
|
+
"""Return the main ixt storage locations with sizes."""
|
|
91
|
+
settings = settings or get_settings()
|
|
92
|
+
paths = [
|
|
93
|
+
("Home", settings.home),
|
|
94
|
+
("Config", settings.config_dir),
|
|
95
|
+
("Installed", settings.installed_dir),
|
|
96
|
+
("Bin", settings.bin_dir),
|
|
97
|
+
("Envs", settings.envs_dir),
|
|
98
|
+
("Runtimes", settings.runtimes_dir),
|
|
99
|
+
("Cache", settings.cache_home),
|
|
100
|
+
("Downloads", settings.downloads_dir),
|
|
101
|
+
("Metadata", settings.metadata_dir),
|
|
102
|
+
("Tmp", settings.tmp_dir),
|
|
103
|
+
]
|
|
104
|
+
return [_size_entry(name, path) for name, path in paths]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cache_sizes(*, settings: Settings | None = None) -> list[SizeEntry]:
|
|
108
|
+
"""Return cache-only locations with sizes."""
|
|
109
|
+
settings = settings or get_settings()
|
|
110
|
+
return [
|
|
111
|
+
_size_entry("Downloads", settings.downloads_dir),
|
|
112
|
+
_size_entry("Metadata", settings.metadata_dir),
|
|
113
|
+
_size_entry("Tmp", settings.tmp_dir),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _size_entry(name: str, path: Path) -> SizeEntry:
|
|
118
|
+
files, bytes_ = count_and_size(path)
|
|
119
|
+
return SizeEntry(name=name, path=path, files=files, bytes=bytes_)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def clear_caches(target: str, *, settings: Settings | None = None) -> list[ClearResult]:
|
|
123
|
+
"""Clear the requested cache(s).
|
|
124
|
+
|
|
125
|
+
*target* is one of ``"downloads"``, ``"metadata"``, ``"tmp"``, or
|
|
126
|
+
``"all"``. This never touches installed tools or user config.
|
|
127
|
+
"""
|
|
128
|
+
settings = settings or get_settings()
|
|
129
|
+
targets: dict[str, Path] = {
|
|
130
|
+
"downloads": settings.downloads_dir,
|
|
131
|
+
"metadata": settings.metadata_dir,
|
|
132
|
+
"tmp": settings.tmp_dir,
|
|
133
|
+
}
|
|
134
|
+
if target != "all" and target not in targets:
|
|
135
|
+
raise ValueError(f"Unknown cache target: {target}")
|
|
136
|
+
|
|
137
|
+
selected = targets.items() if target == "all" else [(target, targets[target])]
|
|
138
|
+
results: list[ClearResult] = []
|
|
139
|
+
for name, path in selected:
|
|
140
|
+
count, size = count_and_size(path)
|
|
141
|
+
if path.exists():
|
|
142
|
+
shutil.rmtree(path)
|
|
143
|
+
results.append(ClearResult(name, path, count, size))
|
|
144
|
+
return results
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def downloads_index_path(*, settings: Settings | None = None) -> Path:
|
|
148
|
+
"""Return the metadata file that indexes downloaded artifacts."""
|
|
149
|
+
settings = settings or get_settings()
|
|
150
|
+
return settings.metadata_dir / DOWNLOADS_INDEX_FILENAME
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def record_download(
|
|
154
|
+
*,
|
|
155
|
+
settings: Settings | None = None,
|
|
156
|
+
kind: str,
|
|
157
|
+
platform: str,
|
|
158
|
+
host: str,
|
|
159
|
+
owner: str,
|
|
160
|
+
repo: str,
|
|
161
|
+
tag: str,
|
|
162
|
+
asset_name: str,
|
|
163
|
+
asset_url: str,
|
|
164
|
+
asset_size: int,
|
|
165
|
+
path: Path,
|
|
166
|
+
) -> None:
|
|
167
|
+
"""Record a downloaded artifact in the programmatic cache index."""
|
|
168
|
+
settings = settings or get_settings()
|
|
169
|
+
index = _load_downloads_index(settings=settings)
|
|
170
|
+
relative_path = _safe_relative(path, settings.cache_home)
|
|
171
|
+
entry = {
|
|
172
|
+
"kind": kind,
|
|
173
|
+
"platform": platform,
|
|
174
|
+
"host": host,
|
|
175
|
+
"owner": owner,
|
|
176
|
+
"repo": repo,
|
|
177
|
+
"tag": tag,
|
|
178
|
+
"version": _version_from_tag(tag),
|
|
179
|
+
"asset": asset_name,
|
|
180
|
+
"asset_size": int(asset_size),
|
|
181
|
+
"path": relative_path,
|
|
182
|
+
"url": asset_url,
|
|
183
|
+
"downloaded_at": _now_utc(),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
entries = index["entries"]
|
|
187
|
+
entries[:] = [
|
|
188
|
+
existing
|
|
189
|
+
for existing in entries
|
|
190
|
+
if not (
|
|
191
|
+
existing.get("kind") == kind
|
|
192
|
+
and existing.get("platform") == platform
|
|
193
|
+
and existing.get("host") == host
|
|
194
|
+
and existing.get("owner") == owner
|
|
195
|
+
and existing.get("repo") == repo
|
|
196
|
+
and existing.get("tag") == tag
|
|
197
|
+
and existing.get("asset") == asset_name
|
|
198
|
+
)
|
|
199
|
+
]
|
|
200
|
+
entries.append(entry)
|
|
201
|
+
_write_downloads_index(index, settings=settings)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def prune_downloads(*, settings: Settings | None = None, keep: int = 2) -> PruneResult:
|
|
205
|
+
"""Prune indexed download artifacts, keeping newest *keep* per repo.
|
|
206
|
+
|
|
207
|
+
Only files present in ``downloads.json`` are candidates. Unindexed files
|
|
208
|
+
are left alone, which keeps the operation conservative.
|
|
209
|
+
"""
|
|
210
|
+
if keep < 1:
|
|
211
|
+
raise ValueError("keep must be >= 1")
|
|
212
|
+
|
|
213
|
+
settings = settings or get_settings()
|
|
214
|
+
index = _load_downloads_index(settings=settings)
|
|
215
|
+
groups: dict[tuple[str, str, str, str, str], list[tuple[dict[str, Any], Path]]] = {}
|
|
216
|
+
stale_entries = 0
|
|
217
|
+
|
|
218
|
+
for entry in index["entries"]:
|
|
219
|
+
path = _entry_path(entry, settings=settings)
|
|
220
|
+
if path is None or not path.exists():
|
|
221
|
+
stale_entries += 1
|
|
222
|
+
continue
|
|
223
|
+
groups.setdefault(_group_key(entry), []).append((entry, path))
|
|
224
|
+
|
|
225
|
+
kept_entries: list[dict[str, Any]] = []
|
|
226
|
+
removed_count = 0
|
|
227
|
+
removed_bytes = 0
|
|
228
|
+
|
|
229
|
+
for items in groups.values():
|
|
230
|
+
sortable = [item for item in items if isinstance(item[0].get("downloaded_at"), str)]
|
|
231
|
+
unsortable = [item for item in items if not isinstance(item[0].get("downloaded_at"), str)]
|
|
232
|
+
sortable.sort(key=lambda item: item[0]["downloaded_at"], reverse=True)
|
|
233
|
+
|
|
234
|
+
kept = sortable[:keep] + unsortable
|
|
235
|
+
to_remove = sortable[keep:]
|
|
236
|
+
kept_entries.extend(entry for entry, _path in kept)
|
|
237
|
+
|
|
238
|
+
for entry, path in to_remove:
|
|
239
|
+
count, size = count_and_size(path)
|
|
240
|
+
try:
|
|
241
|
+
if path.is_dir() and not path.is_symlink():
|
|
242
|
+
shutil.rmtree(path)
|
|
243
|
+
else:
|
|
244
|
+
path.unlink(missing_ok=True)
|
|
245
|
+
except OSError:
|
|
246
|
+
kept_entries.append(entry)
|
|
247
|
+
continue
|
|
248
|
+
removed_count += count
|
|
249
|
+
removed_bytes += size
|
|
250
|
+
|
|
251
|
+
_write_downloads_index(
|
|
252
|
+
{"schema": DOWNLOADS_INDEX_SCHEMA, "entries": kept_entries},
|
|
253
|
+
settings=settings,
|
|
254
|
+
)
|
|
255
|
+
return PruneResult(
|
|
256
|
+
kept_count=len(kept_entries),
|
|
257
|
+
removed_count=removed_count,
|
|
258
|
+
removed_bytes=removed_bytes,
|
|
259
|
+
stale_entries=stale_entries,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _load_downloads_index(*, settings: Settings) -> dict[str, Any]:
|
|
264
|
+
path = downloads_index_path(settings=settings)
|
|
265
|
+
try:
|
|
266
|
+
with path.open(encoding="utf-8") as f:
|
|
267
|
+
data = json.load(f)
|
|
268
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
269
|
+
return {"schema": DOWNLOADS_INDEX_SCHEMA, "entries": []}
|
|
270
|
+
if not isinstance(data, dict):
|
|
271
|
+
return {"schema": DOWNLOADS_INDEX_SCHEMA, "entries": []}
|
|
272
|
+
entries = data.get("entries", [])
|
|
273
|
+
if not isinstance(entries, list):
|
|
274
|
+
entries = []
|
|
275
|
+
data["schema"] = DOWNLOADS_INDEX_SCHEMA
|
|
276
|
+
data["entries"] = [entry for entry in entries if isinstance(entry, dict)]
|
|
277
|
+
return data
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _write_downloads_index(data: dict[str, Any], *, settings: Settings) -> None:
|
|
281
|
+
path = downloads_index_path(settings=settings)
|
|
282
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
|
|
284
|
+
try:
|
|
285
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
286
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
287
|
+
f.write("\n")
|
|
288
|
+
os.replace(tmp, path)
|
|
289
|
+
except Exception:
|
|
290
|
+
Path(tmp).unlink(missing_ok=True)
|
|
291
|
+
raise
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _entry_path(entry: dict[str, Any], *, settings: Settings) -> Path | None:
|
|
295
|
+
raw = entry.get("path")
|
|
296
|
+
if not isinstance(raw, str) or not raw:
|
|
297
|
+
return None
|
|
298
|
+
path = Path(raw)
|
|
299
|
+
if not path.is_absolute():
|
|
300
|
+
path = settings.cache_home / path
|
|
301
|
+
try:
|
|
302
|
+
path.resolve().relative_to(settings.cache_home.resolve())
|
|
303
|
+
except ValueError:
|
|
304
|
+
return None
|
|
305
|
+
return path
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _group_key(entry: dict[str, Any]) -> tuple[str, str, str, str, str]:
|
|
309
|
+
return (
|
|
310
|
+
str(entry.get("kind", "")),
|
|
311
|
+
str(entry.get("platform", "")),
|
|
312
|
+
str(entry.get("host", "")),
|
|
313
|
+
str(entry.get("owner", "")),
|
|
314
|
+
str(entry.get("repo", "")),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _safe_relative(path: Path, base: Path) -> str:
|
|
319
|
+
try:
|
|
320
|
+
return str(path.resolve().relative_to(base.resolve()))
|
|
321
|
+
except ValueError:
|
|
322
|
+
return str(path)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _version_from_tag(tag: str) -> str:
|
|
326
|
+
if tag[:1].lower() == "v":
|
|
327
|
+
return tag[1:]
|
|
328
|
+
return tag
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _now_utc() -> str:
|
|
332
|
+
return datetime.now(timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
|
ixt/core/discover.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Executable discovery in extracted binary environments.
|
|
2
|
+
|
|
3
|
+
Scans extracted directories for executable files, filtering out
|
|
4
|
+
non-executable content using heuristics.toml expose rules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import stat
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from ixt.config.heuristics import ExposeConfig, load_heuristics
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ixt.libs.logger import Logger
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_PATH_SEPARATOR_RE = re.compile(r"[\\/]+")
|
|
23
|
+
_TOKEN_SEPARATOR_RE = re.compile(r"[\\/._-]+")
|
|
24
|
+
_MAN_PATH_COMPONENT_RE = re.compile(r"man(?:[0-9][a-z0-9]*|[nl])?$", re.IGNORECASE)
|
|
25
|
+
_DOC_COMPONENT_KEYWORDS = frozenset(
|
|
26
|
+
{"license", "readme", "changelog", "notice", "copying", "dist-manifest"}
|
|
27
|
+
)
|
|
28
|
+
_TOKEN_KEYWORDS = frozenset({"completion", "completions", "autocomplete"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_executable(path: Path) -> bool:
|
|
32
|
+
"""Return True if *path* is a regular file with an executable bit set."""
|
|
33
|
+
try:
|
|
34
|
+
st = path.stat()
|
|
35
|
+
except OSError:
|
|
36
|
+
return False
|
|
37
|
+
if not stat.S_ISREG(st.st_mode):
|
|
38
|
+
return False
|
|
39
|
+
return bool(st.st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _should_skip(name: str, expose: ExposeConfig, rel_path: str = "") -> bool:
|
|
43
|
+
"""Return True if *name* (or its relative path) should be filtered out by expose rules."""
|
|
44
|
+
lower = name.lower()
|
|
45
|
+
lower_path = rel_path.lower() if rel_path else lower
|
|
46
|
+
|
|
47
|
+
# Skip known runtime names
|
|
48
|
+
if lower in (n.lower() for n in expose.skip_names):
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
# Skip by glob pattern (*.md, *.txt, etc.) — matched against filename only
|
|
52
|
+
for pat in expose.skip_patterns:
|
|
53
|
+
if fnmatch.fnmatch(lower, pat.lower()):
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
# Skip by keyword, using tighter rules for the built-in archive metadata names.
|
|
57
|
+
for kw in expose.skip_keywords:
|
|
58
|
+
if _keyword_matches_path(kw, lower_path):
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _keyword_matches_path(keyword: str, lower_path: str) -> bool:
|
|
65
|
+
"""Return True if an expose skip keyword matches *lower_path*."""
|
|
66
|
+
lower_keyword = keyword.lower()
|
|
67
|
+
components = _path_components(lower_path)
|
|
68
|
+
|
|
69
|
+
# "man" is meant to catch man-page directories such as man/ or man1/,
|
|
70
|
+
# not binaries whose names merely contain those letters.
|
|
71
|
+
if lower_keyword == "man":
|
|
72
|
+
return any(_MAN_PATH_COMPONENT_RE.fullmatch(component) for component in components[:-1])
|
|
73
|
+
|
|
74
|
+
if lower_keyword in _DOC_COMPONENT_KEYWORDS:
|
|
75
|
+
return any(
|
|
76
|
+
component == lower_keyword or _component_stem(component) == lower_keyword
|
|
77
|
+
for component in components
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if lower_keyword in _TOKEN_KEYWORDS:
|
|
81
|
+
return lower_keyword in _path_tokens(lower_path)
|
|
82
|
+
|
|
83
|
+
return lower_keyword in lower_path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _path_components(lower_path: str) -> list[str]:
|
|
87
|
+
"""Split a lowercase relative path into non-empty directory/file components."""
|
|
88
|
+
return [component for component in _PATH_SEPARATOR_RE.split(lower_path) if component]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _path_tokens(lower_path: str) -> list[str]:
|
|
92
|
+
"""Split a lowercase relative path into non-empty separator-delimited tokens."""
|
|
93
|
+
return [token for token in _TOKEN_SEPARATOR_RE.split(lower_path) if token]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _component_stem(component: str) -> str:
|
|
97
|
+
"""Return the filename stem using the first dot as metadata extension boundary."""
|
|
98
|
+
return component.split(".", 1)[0]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def find_executables(
|
|
102
|
+
root: Path,
|
|
103
|
+
*,
|
|
104
|
+
expose: ExposeConfig | None = None,
|
|
105
|
+
max_depth: int = 3,
|
|
106
|
+
log: Logger | None = None,
|
|
107
|
+
) -> list[Path]:
|
|
108
|
+
"""Find executable files in *root*, filtered by expose rules.
|
|
109
|
+
|
|
110
|
+
Walks the directory tree up to *max_depth* levels, looking for
|
|
111
|
+
executable files that pass the heuristics.toml expose filters.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
root: Directory to scan (typically the extraction root).
|
|
115
|
+
expose: Expose config to use. Loads defaults if None.
|
|
116
|
+
max_depth: Maximum directory depth to scan (0 = root only).
|
|
117
|
+
log: Optional logger; emits debug lines for skipped files.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
List of absolute paths to discovered executables, sorted by name.
|
|
121
|
+
"""
|
|
122
|
+
if expose is None:
|
|
123
|
+
expose = load_heuristics().expose
|
|
124
|
+
|
|
125
|
+
executables: list[Path] = []
|
|
126
|
+
root_depth = len(root.parts)
|
|
127
|
+
|
|
128
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
129
|
+
current = Path(dirpath)
|
|
130
|
+
depth = len(current.parts) - root_depth
|
|
131
|
+
|
|
132
|
+
if depth >= max_depth:
|
|
133
|
+
dirnames.clear() # stop descending
|
|
134
|
+
|
|
135
|
+
for fname in filenames:
|
|
136
|
+
fpath = current / fname
|
|
137
|
+
|
|
138
|
+
if not _is_executable(fpath):
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
rel = str(fpath.relative_to(root))
|
|
142
|
+
if _should_skip(fname, expose, rel_path=rel):
|
|
143
|
+
if log:
|
|
144
|
+
log.debug(f"expose skip: {rel}")
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
executables.append(fpath)
|
|
148
|
+
|
|
149
|
+
executables.sort(key=lambda p: p.name)
|
|
150
|
+
return executables
|