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

1"""Version parsing utilities for tool version checking and validation.""" 

2 

3import re 

4import subprocess # nosec B404 - used safely with shell disabled 

5from dataclasses import dataclass, field 

6from functools import lru_cache 

7 

8from loguru import logger 

9from packaging.version import InvalidVersion, Version 

10 

11from lintro.enums.tool_name import ToolName, normalize_tool_name 

12 

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 

25 

26# Sentinel value for unknown/unspecified version requirements 

27VERSION_UNKNOWN: str = "unknown" 

28 

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+)*)" 

32 

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} 

57 

58 

59@lru_cache(maxsize=1) 

60def _get_minimum_versions_cached() -> dict[str, str]: 

61 """Get minimum version requirements (cached). 

62 

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() 

68 

69 

70@lru_cache(maxsize=1) 

71def _get_install_hints_cached() -> dict[str, str]: 

72 """Get installation hints (cached). 

73 

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() 

79 

80 

81def get_minimum_versions() -> dict[str, str]: 

82 """Get minimum version requirements for all tools. 

83 

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()) 

89 

90 

91def get_install_hints() -> dict[str, str]: 

92 """Get installation hints for tools that don't meet requirements. 

93 

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()) 

99 

100 

101@dataclass 

102class ToolVersionInfo: 

103 """Information about a tool's version requirements.""" 

104 

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) 

111 

112 

113def parse_version(version_str: str) -> Version: 

114 """Parse a version string into a comparable Version object. 

115 

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. 

119 

120 Args: 

121 version_str: Version string like "1.2.3", "0.14.0", or "v1.0.0-alpha" 

122 

123 Returns: 

124 Version: Comparable Version object from packaging library. 

125 

126 Raises: 

127 ValueError: If the version string cannot be parsed. 

128 

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() 

137 

138 # Handle optional leading 'v' (e.g., "v1.2.3") 

139 if cleaned.lower().startswith("v"): 

140 cleaned = cleaned[1:] 

141 

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 

149 

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 

154 

155 

156def compare_versions(version1: str, version2: str) -> int: 

157 """Compare two version strings. 

158 

159 Uses the packaging library for robust version comparison that properly 

160 handles major/minor/patch versions, pre-release versions, and more. 

161 

162 Args: 

163 version1: First version string 

164 version2: Second version string 

165 

166 Returns: 

167 int: -1 if version1 < version2, 0 if equal, 1 if version1 > version2 

168 

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) 

180 

181 

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. 

189 

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. 

195 

196 Returns: 

197 ToolVersionInfo: Version check results 

198 """ 

199 minimum_versions = get_minimum_versions() 

200 install_hints = get_install_hints() 

201 

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 

207 

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) 

213 

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 

223 

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 ) 

231 

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"] 

235 

236 run_env = get_subprocess_env() 

237 

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 ) 

245 

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 

253 

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) 

260 

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 

270 

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 

278 

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 ) 

288 

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}") 

292 

293 return info 

294 

295 

296def extract_version_from_output(output: str, tool_name: str | ToolName) -> str | None: 

297 """Extract version string from tool --version output. 

298 

299 Args: 

300 output: Raw output from tool --version 

301 tool_name: Name of the tool (to handle tool-specific parsing) 

302 

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) 

308 

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) 

315 

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) 

321 

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) 

336 

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" 

350 

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) 

356 

357 return None