Coverage for lintro / tools / core / version_parsing.py: 94%
126 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"""Version parsing utilities for tool version checking and validation."""
3import re
4import subprocess # nosec B404 - used safely with shell disabled
5from dataclasses import dataclass, field
6from functools import lru_cache
8from loguru import logger
9from packaging.version import InvalidVersion, Version
11from lintro.enums.tool_name import ToolName, normalize_tool_name
13# Import actual implementations from version_checking with aliases
14# to avoid name conflicts
15from lintro.tools.core.version_checking import (
16 VERSION_CHECK_TIMEOUT,
17)
18from lintro.tools.core.version_checking import (
19 get_install_hints as _get_install_hints_impl,
20)
21from lintro.tools.core.version_checking import (
22 get_minimum_versions as _get_minimum_versions_impl,
23)
24from lintro.utils.env import get_subprocess_env
26# Sentinel value for unknown/unspecified version requirements
27VERSION_UNKNOWN: str = "unknown"
29# Common regex pattern for tools that output simple version numbers
30# Matches version strings like "1.2.3", "0.14.0", "25.1", etc.
31VERSION_NUMBER_PATTERN: str = r"(\d+(?:\.\d+)*)"
33# Tools that use the simple version number pattern
34TOOLS_WITH_SIMPLE_VERSION_PATTERN: set[ToolName] = {
35 ToolName.ACTIONLINT,
36 ToolName.ASTRO_CHECK,
37 ToolName.BANDIT,
38 ToolName.CARGO_AUDIT,
39 ToolName.CARGO_DENY,
40 ToolName.GITLEAKS,
41 ToolName.HADOLINT,
42 ToolName.OSV_SCANNER,
43 ToolName.OXFMT,
44 ToolName.OXLINT,
45 ToolName.PRETTIER,
46 ToolName.PYDOCLINT,
47 ToolName.RUSTC,
48 ToolName.RUSTFMT,
49 ToolName.SEMGREP,
50 ToolName.SHELLCHECK,
51 ToolName.SHFMT,
52 ToolName.SQLFLUFF,
53 ToolName.SVELTE_CHECK,
54 ToolName.TAPLO,
55 ToolName.VUE_TSC,
56}
59@lru_cache(maxsize=1)
60def _get_minimum_versions_cached() -> dict[str, str]:
61 """Get minimum version requirements (cached).
63 Returns:
64 dict[str, str]: Dictionary mapping tool names to minimum version strings.
65 """
66 # Call the imported implementation directly to avoid recursion
67 return _get_minimum_versions_impl()
70@lru_cache(maxsize=1)
71def _get_install_hints_cached() -> dict[str, str]:
72 """Get installation hints (cached).
74 Returns:
75 dict[str, str]: Dictionary mapping tool names to installation hint strings.
76 """
77 # Call the imported implementation directly to avoid recursion
78 return _get_install_hints_impl()
81def get_minimum_versions() -> dict[str, str]:
82 """Get minimum version requirements for all tools.
84 Returns:
85 dict[str, str]: Dictionary mapping tool names to minimum version strings.
86 """
87 # Return a copy to avoid sharing mutable state
88 return dict(_get_minimum_versions_cached())
91def get_install_hints() -> dict[str, str]:
92 """Get installation hints for tools that don't meet requirements.
94 Returns:
95 dict[str, str]: Dictionary mapping tool names to installation hint strings.
96 """
97 # Return a copy to avoid sharing mutable state
98 return dict(_get_install_hints_cached())
101@dataclass
102class ToolVersionInfo:
103 """Information about a tool's version requirements."""
105 name: str = field(default="")
106 min_version: str = field(default="")
107 install_hint: str = field(default="")
108 current_version: str | None = field(default=None)
109 version_check_passed: bool = field(default=False)
110 error_message: str | None = field(default=None)
113def parse_version(version_str: str) -> Version:
114 """Parse a version string into a comparable Version object.
116 Uses the standard packaging library for robust version parsing that
117 handles PEP 440 compliant versions including pre-release, post-release,
118 and development versions.
120 Args:
121 version_str: Version string like "1.2.3", "0.14.0", or "v1.0.0-alpha"
123 Returns:
124 Version: Comparable Version object from packaging library.
126 Raises:
127 ValueError: If the version string cannot be parsed.
129 Examples:
130 >>> parse_version("1.2.3")
131 <Version('1.2.3')>
132 >>> parse_version("v0.14.0")
133 <Version('0.14.0')>
134 """
135 # Strip common prefixes and suffixes that packaging can't handle
136 cleaned = version_str.strip()
138 # Handle optional leading 'v' (e.g., "v1.2.3")
139 if cleaned.lower().startswith("v"):
140 cleaned = cleaned[1:]
142 # Handle pre-release suffixes with hyphens (convert to PEP 440 format)
143 # e.g., "1.0.0-alpha" -> "1.0.0a0", "1.0.0-beta.1" -> "1.0.0b1"
144 cleaned = cleaned.split("+")[0] # Remove build metadata
145 if "-" in cleaned:
146 base, suffix = cleaned.split("-", 1)
147 # Try to use just the base version for simpler comparison
148 cleaned = base
150 try:
151 return Version(cleaned)
152 except InvalidVersion as e:
153 raise ValueError(f"Unable to parse version string: {version_str!r}") from e
156def compare_versions(version1: str, version2: str) -> int:
157 """Compare two version strings.
159 Uses the packaging library for robust version comparison that properly
160 handles major/minor/patch versions, pre-release versions, and more.
162 Args:
163 version1: First version string
164 version2: Second version string
166 Returns:
167 int: -1 if version1 < version2, 0 if equal, 1 if version1 > version2
169 Examples:
170 >>> compare_versions("1.2.3", "1.2.3")
171 0
172 >>> compare_versions("1.2.3", "1.2.4")
173 -1
174 >>> compare_versions("2.0.0", "1.9.9")
175 1
176 """
177 v1 = parse_version(version1)
178 v2 = parse_version(version2)
179 return (v1 > v2) - (v1 < v2)
182def check_tool_version(
183 tool_name: str,
184 command: list[str],
185 *,
186 append_version: bool = True,
187) -> ToolVersionInfo:
188 """Check if a tool meets minimum version requirements.
190 Args:
191 tool_name: Name of the tool to check
192 command: Command list to run the tool (e.g., ["python", "-m", "ruff"])
193 append_version: Whether to append --version to command (default True).
194 Set to False when command already includes a version subcommand.
196 Returns:
197 ToolVersionInfo: Version check results
198 """
199 minimum_versions = get_minimum_versions()
200 install_hints = get_install_hints()
202 normalized_tool_name: ToolName | None = None
203 try:
204 normalized_tool_name = normalize_tool_name(tool_name)
205 except ValueError:
206 normalized_tool_name = None
208 lookup_names = [tool_name]
209 if normalized_tool_name is not None:
210 canonical_name = normalized_tool_name.value
211 if canonical_name not in lookup_names:
212 lookup_names.append(canonical_name)
214 min_version = VERSION_UNKNOWN
215 install_hint = f"Install {tool_name} and ensure it's in PATH"
216 has_requirements = False
217 for lookup_name in lookup_names:
218 if lookup_name in minimum_versions:
219 min_version = minimum_versions[lookup_name]
220 has_requirements = True
221 install_hint = install_hints.get(lookup_name, install_hint)
222 break
224 info = ToolVersionInfo(
225 name=tool_name,
226 min_version=min_version,
227 install_hint=install_hint,
228 # If no requirements, assume check passes
229 version_check_passed=not has_requirements,
230 )
232 try:
233 # Run the tool with --version flag (unless caller already included it)
234 version_cmd = command if not append_version else [*command, "--version"]
236 run_env = get_subprocess_env()
238 result = subprocess.run( # nosec B603 - args list, shell=False
239 version_cmd,
240 capture_output=True,
241 text=True,
242 timeout=VERSION_CHECK_TIMEOUT, # Configurable version check timeout
243 env=run_env,
244 )
246 if result.returncode != 0:
247 info.error_message = f"Command failed: {' '.join(version_cmd)}"
248 logger.debug(
249 f"[VersionCheck] Failed to get version for {tool_name}: "
250 f"{info.error_message}",
251 )
252 return info
254 # Extract version from output
255 output = result.stdout + result.stderr
256 parser_tool_name: str | ToolName = (
257 normalized_tool_name if normalized_tool_name is not None else tool_name
258 )
259 info.current_version = extract_version_from_output(output, parser_tool_name)
261 if not info.current_version:
262 info.error_message = (
263 f"Could not parse version from output: {output.strip()}"
264 )
265 logger.debug(
266 f"[VersionCheck] Failed to parse version for {tool_name}: "
267 f"{info.error_message}",
268 )
269 return info
271 # Compare versions
272 if min_version != VERSION_UNKNOWN:
273 comparison = compare_versions(info.current_version, min_version)
274 info.version_check_passed = comparison >= 0
275 else:
276 # If min_version is unknown, consider check passed since we got a version
277 info.version_check_passed = True
279 if not info.version_check_passed:
280 info.error_message = (
281 f"Version {info.current_version} is below minimum requirement "
282 f"{min_version}"
283 )
284 logger.debug(
285 f"[VersionCheck] Version check failed for {tool_name}: "
286 f"{info.error_message}",
287 )
289 except (subprocess.TimeoutExpired, OSError) as e:
290 info.error_message = f"Failed to run version check: {e}"
291 logger.debug(f"[VersionCheck] Exception checking version for {tool_name}: {e}")
293 return info
296def extract_version_from_output(output: str, tool_name: str | ToolName) -> str | None:
297 """Extract version string from tool --version output.
299 Args:
300 output: Raw output from tool --version
301 tool_name: Name of the tool (to handle tool-specific parsing)
303 Returns:
304 Optional[str]: Extracted version string, or None if not found
305 """
306 output = output.strip()
307 tool_name = normalize_tool_name(tool_name)
309 # Tool-specific patterns first (most reliable)
310 if tool_name == ToolName.BLACK:
311 # black: "black, 25.9.0 (compiled: yes)"
312 match = re.search(r"black,\s+(\d+(?:\.\d+)*)", output, re.IGNORECASE)
313 if match:
314 return match.group(1)
316 elif tool_name in TOOLS_WITH_SIMPLE_VERSION_PATTERN:
317 # Tools with simple version output (see TOOLS_WITH_SIMPLE_VERSION_PATTERN)
318 match = re.search(VERSION_NUMBER_PATTERN, output)
319 if match:
320 return match.group(1)
322 elif tool_name == ToolName.MARKDOWNLINT:
323 # markdownlint-cli2: "markdownlint-cli2 v0.19.1 (markdownlint v0.39.0)"
324 # Extract the cli2 version (first version number after "v")
325 match = re.search(
326 r"markdownlint-cli2\s+v(\d+(?:\.\d+)*)",
327 output,
328 re.IGNORECASE,
329 )
330 if match:
331 return match.group(1)
332 # Fallback: look for any version pattern
333 match = re.search(r"v(\d+(?:\.\d+)+)", output)
334 if match:
335 return match.group(1)
337 elif tool_name == ToolName.CLIPPY:
338 # For clippy, we check Rust version instead (clippy is tied to Rust)
339 # rustc --version outputs: "rustc 1.92.0 (ded5c06cf 2025-12-08)"
340 # cargo clippy --version outputs: "clippy 0.1.92 (ded5c06cf2 2025-12-08)"
341 # Extract Rust version from rustc output
342 match = re.search(r"rustc\s+(\d+(?:\.\d+)*)", output, re.IGNORECASE)
343 if match:
344 return match.group(1)
345 # Fallback: try clippy version format (0.1.X -> 1.X.0)
346 # Clippy uses 0.1.X where X is the Rust minor version
347 match = re.search(r"clippy\s+0\.1\.(\d+)", output, re.IGNORECASE)
348 if match:
349 return f"1.{match.group(1)}.0"
351 # Fallback: look for version-like pattern (more restrictive)
352 # Match version numbers that look reasonable: 1.2.3, 0.14, 25.1, etc.
353 match = re.search(r"\b(\d+(?:\.\d+){0,3})\b", output)
354 if match:
355 return match.group(1)
357 return None