Coverage for lintro / _tool_versions.py: 79%
117 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Tool version requirements for lintro.
3This module loads external tool versions from a manifest when available, with
4fallbacks to package.json and legacy in-module constants.
6External tools are those that users must install separately (not bundled with
7lintro). Bundled Python tools (ruff, black, bandit, etc.) are managed via
8pyproject.toml dependencies but can be pinned in the manifest for deterministic
9Docker builds.
11Version sources (in priority order):
12- Manifest (lintro/tools/manifest.json) — authoritative when present
13- npm tools (prettier, oxlint, etc.): Read from package.json
14 (Renovate updates it natively)
15- Non-npm tools (hadolint, shellcheck, etc.): Defined in TOOL_VERSIONS below
16 (Renovate updates via custom regex managers)
18IMPORTANT: manifest.json and TOOL_VERSIONS must stay in sync for
19binary/cargo/rustup tools. CI enforces this via verify-manifest-sync.py.
20When adding or updating a tool version, update BOTH files.
22For shell scripts that need these versions, use:
23 python3 -c "from lintro._tool_versions import get_tool_version; \
24print(get_tool_version('toolname'))"
25"""
27from __future__ import annotations
29import json
30import logging
31from functools import lru_cache
32from pathlib import Path
33from typing import TYPE_CHECKING
35from lintro.enums.tool_name import ToolName, normalize_tool_name
37# Use stdlib logging to avoid external dependencies (this module must be
38# importable in Docker build context before lintro dependencies are installed)
39_logger = logging.getLogger(__name__)
41if TYPE_CHECKING:
42 pass
44# Manifest path (preferred source of truth for tool versions)
45_MANIFEST_PATH = Path(__file__).parent / "tools" / "manifest.json"
47# Non-npm external tools - updated by Renovate via custom regex managers
48# Keys use ToolName enum values for type safety
49TOOL_VERSIONS: dict[ToolName | str, str] = {
50 ToolName.ACTIONLINT: "1.7.10",
51 ToolName.CARGO_AUDIT: "0.21.0",
52 ToolName.CARGO_DENY: "0.19.0",
53 ToolName.CLIPPY: "1.92.0",
54 ToolName.GITLEAKS: "8.30.0",
55 ToolName.HADOLINT: "2.14.0",
56 ToolName.OSV_SCANNER: "2.3.5",
57 ToolName.PYTEST: "9.0.2",
58 ToolName.RUSTC: "1.92.0",
59 ToolName.RUSTFMT: "1.8.0",
60 ToolName.SEMGREP: "1.151.0",
61 ToolName.SHELLCHECK: "0.11.0",
62 ToolName.SHFMT: "3.12.0",
63 ToolName.SQLFLUFF: "4.0.0",
64 ToolName.TAPLO: "0.10.0",
65}
67# Mapping from npm package names to ToolName for npm-managed tools
68# These versions are read from package.json at runtime
69_NPM_PACKAGE_TO_TOOL: dict[str, ToolName] = {
70 "astro": ToolName.ASTRO_CHECK,
71 "svelte-check": ToolName.SVELTE_CHECK,
72 "typescript": ToolName.TSC,
73 "vue-tsc": ToolName.VUE_TSC,
74 "prettier": ToolName.PRETTIER,
75 "markdownlint-cli2": ToolName.MARKDOWNLINT,
76 "oxlint": ToolName.OXLINT,
77 "oxfmt": ToolName.OXFMT,
78}
80# Reverse mapping for lookups
81_TOOL_TO_NPM_PACKAGE: dict[ToolName, str] = {
82 v: k for k, v in _NPM_PACKAGE_TO_TOOL.items()
83}
86# Fallback npm tool versions - used when package.json is not found.
87# CI should verify these match package.json to prevent drift.
88_FALLBACK_NPM_VERSIONS: dict[ToolName, str] = {
89 ToolName.ASTRO_CHECK: "6.0.8",
90 ToolName.SVELTE_CHECK: "4.4.5",
91 ToolName.TSC: "5.9.3",
92 ToolName.VUE_TSC: "3.2.6",
93 ToolName.PRETTIER: "3.8.1",
94 ToolName.MARKDOWNLINT: "0.22.0",
95 ToolName.OXLINT: "1.57.0",
96 ToolName.OXFMT: "0.42.0",
97}
99# Companion npm packages - packages that support a tool but have separate versions
100# These are NOT mapped to ToolName (they're companion packages, not tools themselves)
101# Used by install scripts to get the correct version for related packages
102_COMPANION_NPM_PACKAGES: dict[str, str] = {
103 "@astrojs/check": "0.9.8", # Type checking companion for astro
104}
107@lru_cache(maxsize=1)
108def _load_manifest_versions() -> tuple[dict[ToolName, str], dict[str, ToolName]]:
109 """Load tool versions from the manifest, if present.
111 Returns:
112 Tuple of:
113 - versions: mapping of ToolName -> version string
114 - npm_map: mapping of npm package name -> ToolName
115 """
116 if not _MANIFEST_PATH.exists():
117 return {}, {}
119 try:
120 data = json.loads(_MANIFEST_PATH.read_text())
121 except (json.JSONDecodeError, OSError) as exc:
122 _logger.debug("Failed to read manifest: %s", exc)
123 return {}, {}
125 tools = data.get("tools", [])
126 if not isinstance(tools, list):
127 return {}, {}
129 versions: dict[ToolName, str] = {}
130 npm_map: dict[str, ToolName] = {}
131 for entry in tools:
132 if not isinstance(entry, dict):
133 continue
134 name = entry.get("name")
135 version = entry.get("version")
136 if not name or not version:
137 continue
138 try:
139 tool_name = normalize_tool_name(str(name))
140 except ValueError:
141 continue
142 versions[tool_name] = str(version)
143 install = entry.get("install", {})
144 if isinstance(install, dict) and install.get("type") == "npm":
145 package = install.get("package")
146 if package:
147 npm_map[str(package)] = tool_name
149 return versions, npm_map
152@lru_cache(maxsize=1)
153def _load_package_json() -> dict[str, str]:
154 """Load all npm package versions from package.json.
156 Tries multiple paths to find package.json. Returns all dependencies
157 with versions stripped of ^ or ~ prefixes.
159 Returns:
160 Dictionary mapping npm package names to version strings.
161 """
162 possible_paths = [
163 # Development: py-lintro/package.json
164 Path(__file__).parent.parent / "package.json",
165 # If bundled alongside module
166 Path(__file__).parent / "package.json",
167 ]
169 for package_json_path in possible_paths:
170 if package_json_path.exists():
171 try:
172 data = json.loads(package_json_path.read_text())
173 dev_deps = data.get("devDependencies", {})
174 deps = data.get("dependencies", {})
175 all_deps = {**deps, **dev_deps}
177 # Strip ^ or ~ prefix from all versions
178 return {pkg: ver.lstrip("^~") for pkg, ver in all_deps.items()}
179 except (json.JSONDecodeError, OSError):
180 continue
182 return {}
185@lru_cache(maxsize=1)
186def _load_npm_versions() -> dict[ToolName, str]:
187 """Load npm tool versions from package.json with fallback.
189 Tries multiple paths to find package.json, falling back to hardcoded
190 versions if not found. This handles both development environments and
191 pip-installed packages.
193 This is cached to avoid repeated file reads.
195 Returns:
196 Dictionary mapping ToolName to version string for npm-managed tools.
197 """
198 all_deps = _load_package_json()
200 if all_deps:
201 # Start with fallback versions, then overlay package.json values
202 versions: dict[ToolName, str] = dict(_FALLBACK_NPM_VERSIONS)
203 for npm_pkg, tool_name in _NPM_PACKAGE_TO_TOOL.items():
204 if npm_pkg in all_deps:
205 versions[tool_name] = all_deps[npm_pkg]
207 _logger.debug("Loaded npm versions (package.json + fallbacks)")
208 return versions
210 # Fallback: use hardcoded minimum versions when package.json not found
211 _logger.debug("package.json not found, using fallback npm versions")
212 return dict(_FALLBACK_NPM_VERSIONS)
215def get_tool_version(tool_name: ToolName | str) -> str | None:
216 """Get the expected version for an external tool.
218 Args:
219 tool_name: Name of the tool (ToolName enum or string).
220 Also accepts npm package names like "typescript" for "tsc",
221 or companion npm packages like "@astrojs/check".
223 Returns:
224 Version string if found, None otherwise.
225 """
226 manifest_versions, manifest_npm_map = _load_manifest_versions()
228 # Store original string for fallback npm package lookup
229 original_name = tool_name if isinstance(tool_name, str) else None
231 # Convert string to ToolName if it's an npm package alias
232 if isinstance(tool_name, str):
233 if tool_name in manifest_npm_map:
234 tool_name = manifest_npm_map[tool_name]
235 elif tool_name in _NPM_PACKAGE_TO_TOOL:
236 tool_name = _NPM_PACKAGE_TO_TOOL[tool_name]
237 else:
238 try:
239 tool_name = normalize_tool_name(tool_name)
240 except ValueError:
241 # Not a known tool - try looking up as raw npm package
242 if original_name:
243 return _get_npm_package_version(original_name)
244 return None
246 # Check manifest first (single source of truth when available)
247 if tool_name in manifest_versions:
248 return manifest_versions[tool_name]
250 # Check npm-managed tools first
251 npm_versions = _load_npm_versions()
252 if tool_name in npm_versions:
253 return npm_versions[tool_name]
255 # Check non-npm tools
256 return TOOL_VERSIONS.get(tool_name)
259def _get_npm_package_version(package_name: str) -> str | None:
260 """Get version for a raw npm package from package.json.
262 This is used for companion packages (like @astrojs/check) that are needed
263 for installation but aren't mapped to a ToolName.
265 Args:
266 package_name: The npm package name (e.g., "@astrojs/check").
268 Returns:
269 Version string if found in package.json or fallback, None otherwise.
270 """
271 # Try package.json first
272 all_deps = _load_package_json()
273 if package_name in all_deps:
274 return all_deps[package_name]
276 # Fallback to companion packages dict
277 return _COMPANION_NPM_PACKAGES.get(package_name)
280def get_min_version(tool_name: ToolName) -> str:
281 """Get the minimum required version for an external tool.
283 Use this in tool definitions for the min_version field. Unlike get_tool_version,
284 this raises an error if the tool isn't registered, ensuring all external tools
285 are tracked.
287 Args:
288 tool_name: ToolName enum member.
290 Returns:
291 Version string.
293 Raises:
294 KeyError: If tool_name is not found in either TOOL_VERSIONS or package.json.
295 """
296 version = get_tool_version(tool_name)
297 if version is None:
298 raise KeyError(
299 f"Tool '{tool_name}' not found. "
300 f"Add it to TOOL_VERSIONS in lintro/_tool_versions.py "
301 f"or package.json (for npm tools).",
302 )
303 return version
306def get_all_expected_versions() -> dict[ToolName | str, str]:
307 """Get all expected external tool versions.
309 Returns:
310 Dictionary mapping tool names to version strings.
311 Includes both npm-managed and non-npm tools.
312 """
313 # Start with non-npm tools (fallback)
314 all_versions: dict[ToolName | str, str] = dict(TOOL_VERSIONS)
316 # Add npm-managed tools (fallback)
317 npm_versions = _load_npm_versions()
318 for tool_name, version in npm_versions.items():
319 all_versions[tool_name] = version
321 # Load manifest versions and override fallbacks (manifest is authoritative)
322 manifest_versions, _ = _load_manifest_versions()
323 if manifest_versions:
324 for k, v in manifest_versions.items():
325 all_versions[k] = v
327 return all_versions
330def is_npm_managed(tool_name: ToolName) -> bool:
331 """Check if a tool's version is managed via npm/package.json.
333 Args:
334 tool_name: ToolName enum member.
336 Returns:
337 True if the tool version comes from package.json, False otherwise.
338 """
339 _, manifest_npm_map = _load_manifest_versions()
340 if manifest_npm_map:
341 return tool_name in manifest_npm_map.values()
342 return tool_name in _TOOL_TO_NPM_PACKAGE