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

1"""Tool version requirements for lintro. 

2 

3This module loads external tool versions from a manifest when available, with 

4fallbacks to package.json and legacy in-module constants. 

5 

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. 

10 

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) 

17 

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. 

21 

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

26 

27from __future__ import annotations 

28 

29import json 

30import logging 

31from functools import lru_cache 

32from pathlib import Path 

33from typing import TYPE_CHECKING 

34 

35from lintro.enums.tool_name import ToolName, normalize_tool_name 

36 

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

40 

41if TYPE_CHECKING: 

42 pass 

43 

44# Manifest path (preferred source of truth for tool versions) 

45_MANIFEST_PATH = Path(__file__).parent / "tools" / "manifest.json" 

46 

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} 

66 

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} 

79 

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} 

84 

85 

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} 

98 

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} 

105 

106 

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. 

110 

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 {}, {} 

118 

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 {}, {} 

124 

125 tools = data.get("tools", []) 

126 if not isinstance(tools, list): 

127 return {}, {} 

128 

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 

148 

149 return versions, npm_map 

150 

151 

152@lru_cache(maxsize=1) 

153def _load_package_json() -> dict[str, str]: 

154 """Load all npm package versions from package.json. 

155 

156 Tries multiple paths to find package.json. Returns all dependencies 

157 with versions stripped of ^ or ~ prefixes. 

158 

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 ] 

168 

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} 

176 

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 

181 

182 return {} 

183 

184 

185@lru_cache(maxsize=1) 

186def _load_npm_versions() -> dict[ToolName, str]: 

187 """Load npm tool versions from package.json with fallback. 

188 

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. 

192 

193 This is cached to avoid repeated file reads. 

194 

195 Returns: 

196 Dictionary mapping ToolName to version string for npm-managed tools. 

197 """ 

198 all_deps = _load_package_json() 

199 

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] 

206 

207 _logger.debug("Loaded npm versions (package.json + fallbacks)") 

208 return versions 

209 

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) 

213 

214 

215def get_tool_version(tool_name: ToolName | str) -> str | None: 

216 """Get the expected version for an external tool. 

217 

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

222 

223 Returns: 

224 Version string if found, None otherwise. 

225 """ 

226 manifest_versions, manifest_npm_map = _load_manifest_versions() 

227 

228 # Store original string for fallback npm package lookup 

229 original_name = tool_name if isinstance(tool_name, str) else None 

230 

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 

245 

246 # Check manifest first (single source of truth when available) 

247 if tool_name in manifest_versions: 

248 return manifest_versions[tool_name] 

249 

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] 

254 

255 # Check non-npm tools 

256 return TOOL_VERSIONS.get(tool_name) 

257 

258 

259def _get_npm_package_version(package_name: str) -> str | None: 

260 """Get version for a raw npm package from package.json. 

261 

262 This is used for companion packages (like @astrojs/check) that are needed 

263 for installation but aren't mapped to a ToolName. 

264 

265 Args: 

266 package_name: The npm package name (e.g., "@astrojs/check"). 

267 

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] 

275 

276 # Fallback to companion packages dict 

277 return _COMPANION_NPM_PACKAGES.get(package_name) 

278 

279 

280def get_min_version(tool_name: ToolName) -> str: 

281 """Get the minimum required version for an external tool. 

282 

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. 

286 

287 Args: 

288 tool_name: ToolName enum member. 

289 

290 Returns: 

291 Version string. 

292 

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 

304 

305 

306def get_all_expected_versions() -> dict[ToolName | str, str]: 

307 """Get all expected external tool versions. 

308 

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) 

315 

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 

320 

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 

326 

327 return all_versions 

328 

329 

330def is_npm_managed(tool_name: ToolName) -> bool: 

331 """Check if a tool's version is managed via npm/package.json. 

332 

333 Args: 

334 tool_name: ToolName enum member. 

335 

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