ixt-cli 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. ixt/__init__.py +8 -0
  2. ixt/__main__.py +8 -0
  3. ixt/backends/__init__.py +1 -0
  4. ixt/backends/binary.py +935 -0
  5. ixt/backends/binary_resolver.py +307 -0
  6. ixt/backends/node.py +490 -0
  7. ixt/backends/python.py +234 -0
  8. ixt/cli/__init__.py +31 -0
  9. ixt/cli/argparse_completion.py +557 -0
  10. ixt/cli/cmd_apply.py +404 -0
  11. ixt/cli/cmd_cache.py +86 -0
  12. ixt/cli/cmd_config.py +295 -0
  13. ixt/cli/cmd_info.py +116 -0
  14. ixt/cli/cmd_install.py +508 -0
  15. ixt/cli/cmd_misc.py +261 -0
  16. ixt/cli/cmd_registry.py +35 -0
  17. ixt/cli/cmd_upgrade.py +336 -0
  18. ixt/cli/commands.py +70 -0
  19. ixt/cli/parser.py +555 -0
  20. ixt/cli/render.py +85 -0
  21. ixt/config/__init__.py +5 -0
  22. ixt/config/asset_index.py +305 -0
  23. ixt/config/asset_pattern_cache.py +87 -0
  24. ixt/config/env_policy.py +340 -0
  25. ixt/config/flags.py +29 -0
  26. ixt/config/fs_policy.py +17 -0
  27. ixt/config/heuristics.py +465 -0
  28. ixt/config/models.py +176 -0
  29. ixt/config/registry.py +145 -0
  30. ixt/config/settings.py +173 -0
  31. ixt/config/setup_toml.py +179 -0
  32. ixt/config/toml.py +416 -0
  33. ixt/core/__init__.py +16 -0
  34. ixt/core/apply.py +564 -0
  35. ixt/core/apply_actions.py +106 -0
  36. ixt/core/backend.py +187 -0
  37. ixt/core/bootstrap.py +410 -0
  38. ixt/core/cache.py +332 -0
  39. ixt/core/discover.py +150 -0
  40. ixt/core/doctor.py +591 -0
  41. ixt/core/export.py +419 -0
  42. ixt/core/expose.py +350 -0
  43. ixt/core/extract.py +261 -0
  44. ixt/core/hooks.py +182 -0
  45. ixt/core/identity.py +148 -0
  46. ixt/core/inject.py +143 -0
  47. ixt/core/install.py +509 -0
  48. ixt/core/install_local.py +229 -0
  49. ixt/core/locks.py +54 -0
  50. ixt/core/pathlink.py +86 -0
  51. ixt/core/resolution_stats.py +191 -0
  52. ixt/core/resolve.py +150 -0
  53. ixt/core/resolve_cache.py +185 -0
  54. ixt/core/runtimes.py +192 -0
  55. ixt/core/save.py +237 -0
  56. ixt/core/setup_completions.py +11 -0
  57. ixt/core/setup_path.py +368 -0
  58. ixt/core/upgrade.py +596 -0
  59. ixt/data/__init__.py +10 -0
  60. ixt/data/asset_index.json +574 -0
  61. ixt/data/heuristics.toml +98 -0
  62. ixt/data/registry.toml +71 -0
  63. ixt/libs/__init__.py +3 -0
  64. ixt/libs/constants.py +4 -0
  65. ixt/libs/fmt.py +108 -0
  66. ixt/libs/logger.py +109 -0
  67. ixt/libs/output.py +25 -0
  68. ixt/libs/req_spec.py +115 -0
  69. ixt/libs/semver.py +149 -0
  70. ixt/libs/shell.py +126 -0
  71. ixt/libs/style.py +238 -0
  72. ixt/net/__init__.py +1 -0
  73. ixt/net/github_api.py +158 -0
  74. ixt/net/gitlab_api.py +149 -0
  75. ixt/net/http.py +194 -0
  76. ixt/net/npm.py +24 -0
  77. ixt/net/pypi.py +26 -0
  78. ixt/net/source.py +163 -0
  79. ixt/platform/__init__.py +131 -0
  80. ixt/platform/win.py +68 -0
  81. ixt_cli-0.8.0.dist-info/METADATA +294 -0
  82. ixt_cli-0.8.0.dist-info/RECORD +84 -0
  83. ixt_cli-0.8.0.dist-info/WHEEL +4 -0
  84. ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
@@ -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}"