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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Install tools from a local directory via ``ixt tool install --from <path>``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ixt.config.models import ToolRecord
|
|
12
|
+
from ixt.config.settings import Settings, get_settings
|
|
13
|
+
from ixt.config.setup_toml import _loads as _toml_loads
|
|
14
|
+
from ixt.core.backend import BackendType, get_backend
|
|
15
|
+
from ixt.core.expose import expose_tool, unexpose_tool
|
|
16
|
+
from ixt.core.identity import apply_slot, validate_slot
|
|
17
|
+
from ixt.core.install import ToolAlreadyInstalledError, _remove_env_dir, _validate_env_dir
|
|
18
|
+
from ixt.libs.constants import EXPOSE_MAIN
|
|
19
|
+
from ixt.libs.shell import shell_run
|
|
20
|
+
|
|
21
|
+
_LOCAL_VERSION = "local"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class LocalInstallPlan:
|
|
26
|
+
"""What ``install_from_local`` would do, without mutating disk."""
|
|
27
|
+
|
|
28
|
+
source_path: Path
|
|
29
|
+
backend: BackendType
|
|
30
|
+
tool_name: str
|
|
31
|
+
pkg_name: str
|
|
32
|
+
env_dir: Path
|
|
33
|
+
expose_rules: list[str]
|
|
34
|
+
already_installed: bool
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_backend_from_dir(path: Path) -> BackendType:
|
|
38
|
+
"""Pick the backend that fits the directory contents.
|
|
39
|
+
|
|
40
|
+
Cascade:
|
|
41
|
+
1. ``pyproject.toml`` or ``setup.py`` → PYTHON
|
|
42
|
+
2. ``package.json`` → NODE
|
|
43
|
+
3. otherwise → BINARY (copy + auto-expose)
|
|
44
|
+
"""
|
|
45
|
+
if (path / "pyproject.toml").exists() or (path / "setup.py").exists():
|
|
46
|
+
return BackendType.PYTHON
|
|
47
|
+
if (path / "package.json").exists():
|
|
48
|
+
return BackendType.NODE
|
|
49
|
+
return BackendType.BINARY
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_python_pkg_name(source_path: Path, fallback: str) -> str:
|
|
53
|
+
"""Read [project].name from pyproject.toml; fall back when missing."""
|
|
54
|
+
pyproject = source_path / "pyproject.toml"
|
|
55
|
+
if pyproject.exists():
|
|
56
|
+
try:
|
|
57
|
+
data = _toml_loads(pyproject.read_text(encoding="utf-8"))
|
|
58
|
+
name = data.get("project", {}).get("name")
|
|
59
|
+
if isinstance(name, str) and name:
|
|
60
|
+
return name
|
|
61
|
+
except (OSError, ValueError):
|
|
62
|
+
pass
|
|
63
|
+
return fallback
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _read_node_pkg_name(source_path: Path, fallback: str) -> str:
|
|
67
|
+
"""Read .name from package.json; fall back when missing."""
|
|
68
|
+
pkg_json = source_path / "package.json"
|
|
69
|
+
if pkg_json.exists():
|
|
70
|
+
try:
|
|
71
|
+
with pkg_json.open(encoding="utf-8") as f:
|
|
72
|
+
data = json.load(f)
|
|
73
|
+
name = data.get("name")
|
|
74
|
+
if isinstance(name, str) and name:
|
|
75
|
+
return name
|
|
76
|
+
except (OSError, json.JSONDecodeError):
|
|
77
|
+
pass
|
|
78
|
+
return fallback
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def plan_install_from_local(
|
|
82
|
+
source_path: Path,
|
|
83
|
+
*,
|
|
84
|
+
slot: str | None = None,
|
|
85
|
+
settings: Settings | None = None,
|
|
86
|
+
) -> LocalInstallPlan:
|
|
87
|
+
"""Resolve a local install plan without copying or installing anything."""
|
|
88
|
+
settings = settings or get_settings()
|
|
89
|
+
validate_slot(slot)
|
|
90
|
+
source_path = source_path.expanduser().resolve()
|
|
91
|
+
if not source_path.exists():
|
|
92
|
+
raise ValueError(f"--from path does not exist: {source_path}")
|
|
93
|
+
if not source_path.is_dir():
|
|
94
|
+
raise ValueError(f"--from path is not a directory: {source_path}")
|
|
95
|
+
|
|
96
|
+
bt = detect_backend_from_dir(source_path)
|
|
97
|
+
base_name = source_path.name
|
|
98
|
+
tool_name = apply_slot(base_name, slot)
|
|
99
|
+
if bt == BackendType.PYTHON:
|
|
100
|
+
pkg_name = _read_python_pkg_name(source_path, fallback=base_name)
|
|
101
|
+
elif bt == BackendType.NODE:
|
|
102
|
+
pkg_name = _read_node_pkg_name(source_path, fallback=base_name)
|
|
103
|
+
else:
|
|
104
|
+
pkg_name = base_name
|
|
105
|
+
env_dir = settings.get_tool_env_dir(tool_name)
|
|
106
|
+
env_dir = _validate_env_dir(env_dir, settings.envs_dir)
|
|
107
|
+
|
|
108
|
+
return LocalInstallPlan(
|
|
109
|
+
source_path=source_path,
|
|
110
|
+
backend=bt,
|
|
111
|
+
tool_name=tool_name,
|
|
112
|
+
pkg_name=pkg_name,
|
|
113
|
+
env_dir=env_dir,
|
|
114
|
+
expose_rules=[EXPOSE_MAIN],
|
|
115
|
+
already_installed=env_dir.exists(),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _copy_local_tree(source_path: Path, env_dir: Path, settings: Settings) -> None:
|
|
120
|
+
"""Copy a local binary tree into an env without following symlinks."""
|
|
121
|
+
source_root = source_path.expanduser().resolve()
|
|
122
|
+
safe_env_dir = _validate_env_dir(env_dir, settings.envs_dir)
|
|
123
|
+
for root, dirs, files in os.walk(source_root, followlinks=False):
|
|
124
|
+
root_path = Path(root)
|
|
125
|
+
rel = root_path.relative_to(source_root)
|
|
126
|
+
dest_root = _validate_env_dir(safe_env_dir / rel, settings.envs_dir)
|
|
127
|
+
dest_root.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
|
|
129
|
+
# Do not traverse or copy symlinked directories: a local install should
|
|
130
|
+
# not silently pull content from outside the declared source tree.
|
|
131
|
+
dirs[:] = [name for name in dirs if not (root_path / name).is_symlink()]
|
|
132
|
+
|
|
133
|
+
for filename in files:
|
|
134
|
+
src = root_path / filename
|
|
135
|
+
if src.is_symlink() or not src.is_file():
|
|
136
|
+
continue
|
|
137
|
+
dest = _validate_env_dir(dest_root / filename, settings.envs_dir)
|
|
138
|
+
with src.open("rb") as source, dest.open("wb") as out:
|
|
139
|
+
shutil.copyfileobj(source, out)
|
|
140
|
+
shutil.copymode(src, dest)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def install_from_local(
|
|
144
|
+
source_path: Path,
|
|
145
|
+
*,
|
|
146
|
+
slot: str | None = None,
|
|
147
|
+
force: bool = False,
|
|
148
|
+
settings: Settings | None = None,
|
|
149
|
+
) -> ToolRecord:
|
|
150
|
+
"""Install a tool from a local directory.
|
|
151
|
+
|
|
152
|
+
Backend is auto-detected from directory contents. The install is a copy
|
|
153
|
+
(not editable): the env_dir is autonomous and the source path can be
|
|
154
|
+
moved or deleted afterwards without breaking the install.
|
|
155
|
+
"""
|
|
156
|
+
settings = settings or get_settings()
|
|
157
|
+
plan = plan_install_from_local(source_path, slot=slot, settings=settings)
|
|
158
|
+
source_path = plan.source_path
|
|
159
|
+
bt = plan.backend
|
|
160
|
+
tool_name = plan.tool_name
|
|
161
|
+
env_dir = plan.env_dir
|
|
162
|
+
|
|
163
|
+
if env_dir.exists():
|
|
164
|
+
if not force:
|
|
165
|
+
raise ToolAlreadyInstalledError(tool_name, env_dir)
|
|
166
|
+
meta_file = settings.get_tool_metadata_file(tool_name)
|
|
167
|
+
if meta_file.exists():
|
|
168
|
+
old = ToolRecord.load_json(meta_file)
|
|
169
|
+
unexpose_tool(old.exposed_bins, settings.bin_dir)
|
|
170
|
+
_remove_env_dir(env_dir, settings)
|
|
171
|
+
|
|
172
|
+
backend = get_backend(bt, settings=settings)
|
|
173
|
+
created = False
|
|
174
|
+
try:
|
|
175
|
+
if bt == BackendType.PYTHON:
|
|
176
|
+
created = backend.create_env(env_dir)
|
|
177
|
+
backend.install_packages(env_dir, [str(source_path)])
|
|
178
|
+
pkg_name = _read_python_pkg_name(source_path, fallback=tool_name)
|
|
179
|
+
version = backend.installed_version(env_dir, pkg_name) or _LOCAL_VERSION
|
|
180
|
+
elif bt == BackendType.NODE:
|
|
181
|
+
from ixt.backends.node import NodeBackend
|
|
182
|
+
|
|
183
|
+
if not isinstance(backend, NodeBackend):
|
|
184
|
+
raise RuntimeError(f"Expected NodeBackend, got {type(backend).__name__}")
|
|
185
|
+
created = backend.create_env(env_dir)
|
|
186
|
+
pkg_name = _read_node_pkg_name(source_path, fallback=tool_name)
|
|
187
|
+
shell_run(
|
|
188
|
+
[backend._bun(), "add", str(source_path)],
|
|
189
|
+
cwd=env_dir,
|
|
190
|
+
check=True,
|
|
191
|
+
capture_output=True,
|
|
192
|
+
)
|
|
193
|
+
version = backend.installed_version(env_dir, pkg_name) or _LOCAL_VERSION
|
|
194
|
+
else: # BINARY
|
|
195
|
+
env_dir = _validate_env_dir(env_dir, settings.envs_dir)
|
|
196
|
+
env_dir.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
created = True
|
|
198
|
+
_copy_local_tree(source_path, env_dir, settings)
|
|
199
|
+
pkg_name = plan.pkg_name
|
|
200
|
+
version = _LOCAL_VERSION
|
|
201
|
+
|
|
202
|
+
rules = [EXPOSE_MAIN]
|
|
203
|
+
result = expose_tool(
|
|
204
|
+
pkg_name,
|
|
205
|
+
backend,
|
|
206
|
+
env_dir,
|
|
207
|
+
settings.bin_dir,
|
|
208
|
+
rules,
|
|
209
|
+
overwrite=force,
|
|
210
|
+
)
|
|
211
|
+
linked_bins = result.linked
|
|
212
|
+
|
|
213
|
+
record = ToolRecord(
|
|
214
|
+
name=tool_name,
|
|
215
|
+
pkg_name=pkg_name,
|
|
216
|
+
backend=bt.value,
|
|
217
|
+
spec=str(source_path),
|
|
218
|
+
env_dir=str(env_dir),
|
|
219
|
+
version=version,
|
|
220
|
+
expose_rules=rules,
|
|
221
|
+
exposed_bins=linked_bins,
|
|
222
|
+
source="local",
|
|
223
|
+
)
|
|
224
|
+
record.save_json(settings.get_tool_metadata_file(tool_name))
|
|
225
|
+
return record
|
|
226
|
+
except Exception:
|
|
227
|
+
if created:
|
|
228
|
+
_remove_env_dir(env_dir, settings, ignore_errors=True)
|
|
229
|
+
raise
|
ixt/core/locks.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Small file locks for mutating installed tool state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import BinaryIO
|
|
9
|
+
|
|
10
|
+
from ixt.config.settings import Settings, get_settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@contextmanager
|
|
14
|
+
def tool_lock(tool_name: str, *, settings: Settings | None = None) -> Iterator[Path]:
|
|
15
|
+
"""Hold an exclusive lock for operations mutating one installed tool."""
|
|
16
|
+
settings = settings or get_settings()
|
|
17
|
+
lock_dir = settings.envs_dir / ".locks"
|
|
18
|
+
lock_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
lock_path = lock_dir / f"{settings.get_tool_env_dir(tool_name).name}.lock"
|
|
20
|
+
with lock_path.open("a+b") as handle:
|
|
21
|
+
_lock_file(handle)
|
|
22
|
+
try:
|
|
23
|
+
yield lock_path
|
|
24
|
+
finally:
|
|
25
|
+
_unlock_file(handle)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _lock_file(handle: BinaryIO) -> None:
|
|
29
|
+
try:
|
|
30
|
+
import fcntl
|
|
31
|
+
except ImportError:
|
|
32
|
+
import msvcrt
|
|
33
|
+
|
|
34
|
+
handle.seek(0)
|
|
35
|
+
handle.write(b"\0")
|
|
36
|
+
handle.flush()
|
|
37
|
+
handle.seek(0)
|
|
38
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1) # pyright: ignore[reportAttributeAccessIssue]
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _unlock_file(handle: BinaryIO) -> None:
|
|
45
|
+
try:
|
|
46
|
+
import fcntl
|
|
47
|
+
except ImportError:
|
|
48
|
+
import msvcrt
|
|
49
|
+
|
|
50
|
+
handle.seek(0)
|
|
51
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1) # pyright: ignore[reportAttributeAccessIssue]
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
ixt/core/pathlink.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Cross-platform linking helpers for exposed binaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ixt.platform import is_windows
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _file_hash(path: Path) -> str:
|
|
15
|
+
digest = hashlib.sha256()
|
|
16
|
+
with path.open("rb") as handle:
|
|
17
|
+
for chunk in iter(lambda: handle.read(8192), b""):
|
|
18
|
+
digest.update(chunk)
|
|
19
|
+
return digest.hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class PathLink:
|
|
24
|
+
"""Represents one exposed binary link or copy."""
|
|
25
|
+
|
|
26
|
+
source_path: Path
|
|
27
|
+
target_path: Path
|
|
28
|
+
|
|
29
|
+
def source_exists(self) -> bool:
|
|
30
|
+
return self.source_path.exists()
|
|
31
|
+
|
|
32
|
+
def target_exists(self) -> bool:
|
|
33
|
+
return self.target_path.exists() or self.target_path.is_symlink()
|
|
34
|
+
|
|
35
|
+
def is_valid(self) -> bool:
|
|
36
|
+
if not self.target_exists():
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
if is_windows():
|
|
40
|
+
return (
|
|
41
|
+
self.source_exists()
|
|
42
|
+
and self.target_path.exists()
|
|
43
|
+
and _file_hash(self.source_path) == _file_hash(self.target_path)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
self.target_path.is_symlink()
|
|
48
|
+
and self.target_path.resolve() == self.source_path.resolve()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def create(self, overwrite: bool = False) -> None:
|
|
52
|
+
if not self.source_exists():
|
|
53
|
+
raise FileNotFoundError(self.source_path)
|
|
54
|
+
|
|
55
|
+
if self.target_exists():
|
|
56
|
+
if not overwrite:
|
|
57
|
+
raise FileExistsError(self.target_path)
|
|
58
|
+
self.remove(force=True)
|
|
59
|
+
|
|
60
|
+
self.target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
if is_windows():
|
|
62
|
+
shutil.copy2(self.source_path, self.target_path)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
link_source = os.path.relpath(self.source_path, self.target_path.parent)
|
|
66
|
+
os.symlink(link_source, self.target_path)
|
|
67
|
+
|
|
68
|
+
def remove(self, force: bool = False) -> bool:
|
|
69
|
+
if not self.target_exists():
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
if not force and not self.is_valid():
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
self.target_path.unlink()
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def display_name(self) -> str:
|
|
79
|
+
if self.target_path.name == self.source_path.name:
|
|
80
|
+
return self.target_path.name
|
|
81
|
+
return f"{self.source_path.name} -> {self.target_path.name}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def create_path_link(app_bin: Path, bin_dir: Path, rename_to: str | None = None) -> PathLink:
|
|
85
|
+
"""Create a PathLink object for one exposed binary."""
|
|
86
|
+
return PathLink(source_path=app_bin, target_path=bin_dir / (rename_to or app_bin.name))
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Runtime counters for version-resolution diagnostics.
|
|
2
|
+
|
|
3
|
+
The collector is intentionally process-local: the CLI enables it around one
|
|
4
|
+
command, resolver worker threads record into it, then the CLI renders a compact
|
|
5
|
+
summary. Outside that command scope every record call is a no-op.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
_CURRENT_LOCK = threading.Lock()
|
|
16
|
+
_CURRENT: ResolutionStats | None = None
|
|
17
|
+
_LOCAL = threading.local()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class VersionResolutionStats:
|
|
22
|
+
github_latest_redirect_attempts: int = 0
|
|
23
|
+
github_latest_redirect_hits: int = 0
|
|
24
|
+
github_api_calls: int = 0
|
|
25
|
+
gitlab_api_calls: int = 0
|
|
26
|
+
pypi_registry_calls: int = 0
|
|
27
|
+
npm_registry_calls: int = 0
|
|
28
|
+
|
|
29
|
+
def has_activity(self) -> bool:
|
|
30
|
+
return any(
|
|
31
|
+
(
|
|
32
|
+
self.github_latest_redirect_attempts,
|
|
33
|
+
self.github_api_calls,
|
|
34
|
+
self.gitlab_api_calls,
|
|
35
|
+
self.pypi_registry_calls,
|
|
36
|
+
self.npm_registry_calls,
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class ResolutionStats:
|
|
43
|
+
version: VersionResolutionStats = field(default_factory=VersionResolutionStats)
|
|
44
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
45
|
+
|
|
46
|
+
def has_activity(self) -> bool:
|
|
47
|
+
return self.version.has_activity()
|
|
48
|
+
|
|
49
|
+
def record_github_latest_redirect(self, owner: str, repo: str, *, hit: bool) -> None:
|
|
50
|
+
if _current_phase() != "version":
|
|
51
|
+
return
|
|
52
|
+
with self._lock:
|
|
53
|
+
self.version.github_latest_redirect_attempts += 1
|
|
54
|
+
if hit:
|
|
55
|
+
self.version.github_latest_redirect_hits += 1
|
|
56
|
+
_debug_trace(f"{owner}/{repo}: GitHub latest redirect {'hit' if hit else 'miss'}")
|
|
57
|
+
|
|
58
|
+
def record_github_api(self, owner: str, repo: str, endpoint: str) -> None:
|
|
59
|
+
if _current_phase() != "version":
|
|
60
|
+
return
|
|
61
|
+
with self._lock:
|
|
62
|
+
self.version.github_api_calls += 1
|
|
63
|
+
_debug_trace(f"{owner}/{repo}: GitHub API {endpoint}")
|
|
64
|
+
|
|
65
|
+
def record_gitlab_api(self, owner: str, repo: str, endpoint: str) -> None:
|
|
66
|
+
if _current_phase() != "version":
|
|
67
|
+
return
|
|
68
|
+
with self._lock:
|
|
69
|
+
self.version.gitlab_api_calls += 1
|
|
70
|
+
_debug_trace(f"{owner}/{repo}: GitLab API {endpoint}")
|
|
71
|
+
|
|
72
|
+
def record_pypi_registry(self, name: str) -> None:
|
|
73
|
+
if _current_phase() != "version":
|
|
74
|
+
return
|
|
75
|
+
with self._lock:
|
|
76
|
+
self.version.pypi_registry_calls += 1
|
|
77
|
+
_debug_trace(f"{name}: PyPI registry")
|
|
78
|
+
|
|
79
|
+
def record_npm_registry(self, name: str) -> None:
|
|
80
|
+
if _current_phase() != "version":
|
|
81
|
+
return
|
|
82
|
+
with self._lock:
|
|
83
|
+
self.version.npm_registry_calls += 1
|
|
84
|
+
_debug_trace(f"{name}: npm registry")
|
|
85
|
+
|
|
86
|
+
def format_summary(self) -> str:
|
|
87
|
+
parts = _version_parts(self.version)
|
|
88
|
+
return f"version: {', '.join(parts)}" if parts else ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@contextmanager
|
|
92
|
+
def collect_resolution_stats() -> Iterator[ResolutionStats]:
|
|
93
|
+
"""Collect resolver counters inside the context."""
|
|
94
|
+
global _CURRENT
|
|
95
|
+
stats = ResolutionStats()
|
|
96
|
+
with _CURRENT_LOCK:
|
|
97
|
+
previous = _CURRENT
|
|
98
|
+
_CURRENT = stats
|
|
99
|
+
try:
|
|
100
|
+
yield stats
|
|
101
|
+
finally:
|
|
102
|
+
with _CURRENT_LOCK:
|
|
103
|
+
_CURRENT = previous
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@contextmanager
|
|
107
|
+
def resolution_phase(name: str) -> Iterator[None]:
|
|
108
|
+
"""Mark the kind of resolution currently running in this thread."""
|
|
109
|
+
previous = getattr(_LOCAL, "phase", None)
|
|
110
|
+
_LOCAL.phase = name
|
|
111
|
+
try:
|
|
112
|
+
yield
|
|
113
|
+
finally:
|
|
114
|
+
if previous is None:
|
|
115
|
+
try:
|
|
116
|
+
delattr(_LOCAL, "phase")
|
|
117
|
+
except AttributeError:
|
|
118
|
+
pass
|
|
119
|
+
else:
|
|
120
|
+
_LOCAL.phase = previous
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def record_github_latest_redirect(owner: str, repo: str, *, hit: bool) -> None:
|
|
124
|
+
stats = _current()
|
|
125
|
+
if stats is not None:
|
|
126
|
+
stats.record_github_latest_redirect(owner, repo, hit=hit)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def record_github_api(owner: str, repo: str, endpoint: str) -> None:
|
|
130
|
+
stats = _current()
|
|
131
|
+
if stats is not None:
|
|
132
|
+
stats.record_github_api(owner, repo, endpoint)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def record_gitlab_api(owner: str, repo: str, endpoint: str) -> None:
|
|
136
|
+
stats = _current()
|
|
137
|
+
if stats is not None:
|
|
138
|
+
stats.record_gitlab_api(owner, repo, endpoint)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def record_pypi_registry(name: str) -> None:
|
|
142
|
+
stats = _current()
|
|
143
|
+
if stats is not None:
|
|
144
|
+
stats.record_pypi_registry(name)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def record_npm_registry(name: str) -> None:
|
|
148
|
+
stats = _current()
|
|
149
|
+
if stats is not None:
|
|
150
|
+
stats.record_npm_registry(name)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _current() -> ResolutionStats | None:
|
|
154
|
+
with _CURRENT_LOCK:
|
|
155
|
+
return _CURRENT
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _current_phase() -> str | None:
|
|
159
|
+
value = getattr(_LOCAL, "phase", None)
|
|
160
|
+
return value if isinstance(value, str) else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _debug_trace(message: str) -> None:
|
|
164
|
+
from ixt.libs.logger import get_logger, get_verbosity
|
|
165
|
+
|
|
166
|
+
if get_verbosity() >= 2:
|
|
167
|
+
get_logger("resolution").debug(f"resolution: version: {message}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _version_parts(stats: VersionResolutionStats) -> list[str]:
|
|
171
|
+
parts: list[str] = []
|
|
172
|
+
if stats.github_latest_redirect_attempts:
|
|
173
|
+
parts.append(
|
|
174
|
+
"GitHub latest redirect "
|
|
175
|
+
f"{stats.github_latest_redirect_hits}/{stats.github_latest_redirect_attempts} hit"
|
|
176
|
+
)
|
|
177
|
+
parts.append(_count_phrase(stats.github_api_calls, "GitHub API call"))
|
|
178
|
+
elif stats.github_api_calls:
|
|
179
|
+
parts.append(_count_phrase(stats.github_api_calls, "GitHub API call"))
|
|
180
|
+
if stats.gitlab_api_calls:
|
|
181
|
+
parts.append(_count_phrase(stats.gitlab_api_calls, "GitLab API call"))
|
|
182
|
+
if stats.pypi_registry_calls:
|
|
183
|
+
parts.append(_count_phrase(stats.pypi_registry_calls, "PyPI registry call"))
|
|
184
|
+
if stats.npm_registry_calls:
|
|
185
|
+
parts.append(_count_phrase(stats.npm_registry_calls, "npm registry call"))
|
|
186
|
+
return parts
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _count_phrase(count: int, singular: str) -> str:
|
|
190
|
+
suffix = "" if count == 1 else "s"
|
|
191
|
+
return f"{count} {singular}{suffix}"
|