Coverage for lintro / tools / core / version_checking.py: 88%

50 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Tool version requirements and checking utilities. 

2 

3This module centralizes version management for external lintro tools. 

4 

5## Version Sources 

6 

7External tool versions come from two sources: 

81. npm tools (prettier, oxlint, etc.): Read from package.json at runtime 

92. Non-npm tools (hadolint, shellcheck, etc.): Defined in _tool_versions.py 

10 

11Both are accessed via the get_tool_version() and get_all_expected_versions() 

12functions in lintro/_tool_versions.py. 

13 

14Bundled Python tools (ruff, black, bandit, mypy, yamllint) are managed 

15via pyproject.toml dependencies and don't need tracking in _tool_versions.py. 

16 

17## Adding a New Tool 

18 

19All tool types must be added to manifest.json with the correct install type. 

20CI runs verify-manifest-sync.py which validates every manifest entry against 

21its authoritative source (pyproject.toml for pip, package.json for npm, 

22TOOL_VERSIONS for binary/cargo/rustup). PRs will fail if they drift. 

23 

24### For npm Tools: 

251. Add to package.json devDependencies 

262. Add mapping in _NPM_PACKAGE_TO_TOOL in _tool_versions.py 

273. Add entry to manifest.json with install.type = "npm" 

284. Renovate updates package.json automatically 

29 

30### For Non-npm External Tools (binary, cargo, rustup): 

311. Add to TOOL_VERSIONS in _tool_versions.py 

322. Add entry to manifest.json (version must match TOOL_VERSIONS) 

333. Add Renovate regex manager in renovate.json for both files 

344. If installable via Homebrew: verify the Homebrew formula provides a 

35 version compatible with the entry in TOOL_VERSIONS and manifest.json 

36 before adding depends_on to lintro.rb.template. Homebrew's depends_on 

37 cannot pin versions, so only add it when the Homebrew package version 

38 matches. If versions diverge, omit the depends_on line and document 

39 alternative install instructions (or add a note in renovate.json). 

40 

41### For Bundled Python Tools: 

421. Add as dependency in pyproject.toml 

432. Add entry to manifest.json with install.type = "pip" 

443. Renovate tracks it automatically 

454. Note: Homebrew formula excludes bundled Python tools from the venv 

46 (via generate_resources.py --exclude). They are installed as separate 

47 Homebrew formulae and discovered via PATH, not python -m. 

48""" 

49 

50import os 

51import threading 

52 

53from loguru import logger 

54 

55from lintro._tool_versions import ( 

56 _NPM_PACKAGE_TO_TOOL, 

57 get_all_expected_versions, 

58) 

59from lintro.enums.tool_name import ToolName 

60 

61# Module-level set to track logged warnings and prevent duplicates 

62# during parallel execution 

63_logged_warnings: set[str] = set() 

64_logged_warnings_lock: threading.Lock = threading.Lock() 

65 

66 

67def _get_version_timeout() -> int: 

68 """Return the validated version check timeout. 

69 

70 Returns: 

71 int: Timeout in seconds; falls back to default when the env var is invalid. 

72 """ 

73 default_timeout = 30 

74 env_value = os.getenv("LINTRO_VERSION_TIMEOUT") 

75 if env_value is None: 

76 return default_timeout 

77 

78 try: 

79 timeout = int(env_value) 

80 except (TypeError, ValueError): 

81 logger.warning( 

82 f"Invalid LINTRO_VERSION_TIMEOUT '{env_value}'; " 

83 f"using default {default_timeout}", 

84 ) 

85 return default_timeout 

86 

87 if timeout < 1: 

88 logger.warning( 

89 f"LINTRO_VERSION_TIMEOUT must be >= 1; using default {default_timeout}", 

90 ) 

91 return default_timeout 

92 

93 return timeout 

94 

95 

96VERSION_CHECK_TIMEOUT: int = _get_version_timeout() 

97 

98 

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

100 """Get minimum version requirements for external tools. 

101 

102 Returns versions from _tool_versions module for tools that users 

103 must install separately. Includes both npm-managed tools (from package.json) 

104 and non-npm tools (from TOOL_VERSIONS). 

105 

106 Returns: 

107 dict[str, str]: Dictionary mapping tool names (as strings) to minimum 

108 version strings. Includes string equivalents of ToolName enums 

109 (e.g., "pytest") and package aliases (e.g., "typescript" for TSC). 

110 """ 

111 result: dict[str, str] = {} 

112 

113 # Get all versions (both npm and non-npm tools) 

114 all_versions = get_all_expected_versions() 

115 

116 # Convert ToolName keys to their string values 

117 for tool_name, version in all_versions.items(): 

118 if isinstance(tool_name, ToolName): 

119 result[tool_name.value] = version 

120 else: 

121 result[tool_name] = version 

122 

123 # Add npm package aliases (e.g., "typescript" -> tsc version) 

124 for npm_pkg, tool_name in _NPM_PACKAGE_TO_TOOL.items(): 

125 npm_version = all_versions.get(tool_name) 

126 if npm_version is not None: 

127 result[npm_pkg] = npm_version 

128 

129 return result 

130 

131 

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

133 """Generate installation hints for external tools. 

134 

135 Returns: 

136 dict[str, str]: Dictionary mapping tool names to installation hint strings. 

137 """ 

138 # Static templates mapping tool -> install hint template with {version} placeholder 

139 templates: dict[str, str] = { 

140 "bandit": ( 

141 "Install via: pip install bandit>={version} or uv add bandit>={version}" 

142 ), 

143 "black": ( 

144 "Install via: pip install black>={version} or uv add black>={version}" 

145 ), 

146 "mypy": ("Install via: pip install mypy>={version} or uv add mypy>={version}"), 

147 "pydoclint": ( 

148 "Install via: pip install pydoclint>={version} " 

149 "or uv add pydoclint>={version}" 

150 ), 

151 "ruff": ("Install via: pip install ruff>={version} or uv add ruff>={version}"), 

152 "yamllint": ( 

153 "Install via: pip install yamllint>={version} or uv add yamllint>={version}" 

154 ), 

155 "pytest": ( 

156 "Install via: pip install pytest>={version} or uv add pytest>={version}" 

157 ), 

158 "markdownlint": "Install via: bun add -d markdownlint-cli2@>={version}", 

159 "markdownlint-cli2": "Install via: bun add -d markdownlint-cli2@>={version}", 

160 "oxfmt": "Install via: bun add -d oxfmt@>={version}", 

161 "oxlint": "Install via: bun add -d oxlint@>={version}", 

162 "prettier": "Install via: bun add -d prettier@>={version}", 

163 "tsc": ( 

164 "Install via: bun add -g typescript@{version}, " 

165 "npm install -g typescript@{version}, or brew install typescript" 

166 ), 

167 "typescript": ( 

168 "Install via: bun add -g typescript@{version}, " 

169 "npm install -g typescript@{version}, or brew install typescript" 

170 ), 

171 "hadolint": ( 

172 "Install via: https://github.com/hadolint/hadolint/releases (v{version}+)" 

173 ), 

174 "actionlint": ( 

175 "Install via: https://github.com/rhysd/actionlint/releases (v{version}+)" 

176 ), 

177 "clippy": "Install via: rustup component add clippy (requires Rust {version}+)", 

178 "rustc": ( 

179 "Install via: rustup toolchain install {version} " 

180 "&& rustup default {version}" 

181 ), 

182 "rustfmt": "Install via: rustup component add rustfmt (v{version}+)", 

183 "cargo_audit": "Install via: cargo install cargo-audit (v{version}+)", 

184 "cargo_deny": "Install via: cargo install cargo-deny (v{version}+)", 

185 "biome": "Install via: bun add -d @biomejs/biome@>={version}", 

186 "semgrep": ( 

187 "Install via: pip install semgrep>={version} or brew install semgrep" 

188 ), 

189 "gitleaks": ( 

190 "Install via: https://github.com/gitleaks/gitleaks/releases (v{version}+)" 

191 ), 

192 "osv_scanner": ( 

193 "Install via: https://github.com/google/osv-scanner/releases (v{version}+)" 

194 ), 

195 "shellcheck": ( 

196 "Install via: https://github.com/koalaman/shellcheck/releases (v{version}+)" 

197 ), 

198 "shfmt": "Install via: https://github.com/mvdan/sh/releases (v{version}+)", 

199 "sqlfluff": ( 

200 "Install via: pip install sqlfluff>={version} or uv add sqlfluff>={version}" 

201 ), 

202 "taplo": ( 

203 "Install via: cargo install taplo-cli " 

204 "or download from https://github.com/tamasfe/taplo/releases (v{version}+)" 

205 ), 

206 "astro_check": ( 

207 "Install via: bun add astro@>={version} or npm install astro@>={version}" 

208 ), 

209 "astro": ( 

210 "Install via: bun add astro@>={version} or npm install astro@>={version}" 

211 ), 

212 "svelte_check": ( 

213 "Install via: bun add -D svelte-check@>={version} " 

214 "or npm install -D svelte-check@>={version}" 

215 ), 

216 "svelte-check": ( 

217 "Install via: bun add -D svelte-check@>={version} " 

218 "or npm install -D svelte-check@>={version}" 

219 ), 

220 "vue_tsc": ( 

221 "Install via: bun add -D vue-tsc@>={version} " 

222 "or npm install -D vue-tsc@>={version}" 

223 ), 

224 "vue-tsc": ( 

225 "Install via: bun add -D vue-tsc@>={version} " 

226 "or npm install -D vue-tsc@>={version}" 

227 ), 

228 } 

229 

230 versions = get_minimum_versions() 

231 hints: dict[str, str] = {} 

232 

233 # Build hints only for tools that exist in versions 

234 for tool, template in templates.items(): 

235 version = versions.get(tool) 

236 if version is not None: 

237 hints[tool] = template.format(version=version) 

238 

239 # Warn about tools in versions that don't have templates (only once) 

240 missing = set(versions) - set(templates) 

241 if missing: 

242 warning_key = f"missing_hints:{','.join(sorted(missing))}" 

243 with _logged_warnings_lock: 

244 if warning_key not in _logged_warnings: 

245 _logged_warnings.add(warning_key) 

246 logger.warning( 

247 f"Missing install hints for tools: {', '.join(sorted(missing))}", 

248 ) 

249 

250 return hints