Coverage for lintro / config / tool_config_generator.py: 80%

140 statements  

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

1"""Tool configuration generator for Lintro. 

2 

3This module provides CLI argument injection for enforced settings and 

4default config generation for tools without native configs. 

5 

6The tiered configuration model: 

71. EXECUTION: What tools run and how 

82. ENFORCE: Cross-cutting settings injected via CLI flags 

93. DEFAULTS: Fallback config when no native config exists 

104. TOOLS: Per-tool enable/disable and config source 

11""" 

12 

13from __future__ import annotations 

14 

15import atexit 

16import json 

17import os 

18import tempfile 

19from pathlib import Path 

20from typing import Any 

21 

22from loguru import logger 

23 

24from lintro.config.lintro_config import LintroConfig 

25from lintro.enums.config_format import ConfigFormat 

26from lintro.enums.tool_name import ToolName 

27 

28try: 

29 import yaml 

30except ImportError: 

31 yaml = None # type: ignore[assignment] 

32 

33 

34# CLI flags for enforced settings: setting -> {tool: flag} 

35ENFORCE_CLI_FLAGS: dict[str, dict[str, str]] = { 

36 "line_length": { 

37 "ruff": "--line-length", 

38 "black": "--line-length", 

39 }, 

40 "target_python": { 

41 "ruff": "--target-version", 

42 "black": "--target-version", 

43 "mypy": "--python-version", 

44 }, 

45} 

46 

47 

48def _convert_python_version_for_mypy(version: str) -> str: 

49 """Convert ``py313`` style strings to ``3.13`` for mypy. 

50 

51 Args: 

52 version: Python version string, often ``py313`` format. 

53 

54 Returns: 

55 str: Version string formatted for mypy (for example, ``3.13``). 

56 """ 

57 if version.startswith("py") and len(version) >= 4: 

58 major = version[2] 

59 minor = version[3:] 

60 return f"{major}.{minor}" 

61 return version 

62 

63 

64# Tool config format for defaults generation 

65TOOL_CONFIG_FORMATS: dict[str, ConfigFormat] = { 

66 "bandit": ConfigFormat.YAML, 

67 "hadolint": ConfigFormat.YAML, 

68 "markdownlint": ConfigFormat.JSON, 

69 "oxfmt": ConfigFormat.JSON, 

70 "oxlint": ConfigFormat.JSON, 

71 "yamllint": ConfigFormat.YAML, 

72 "prettier": ConfigFormat.JSON, 

73} 

74 

75# Key mappings for tools that use different naming conventions in their native configs. 

76# Maps lintro config keys (snake_case) to native tool keys (often camelCase). 

77NATIVE_KEY_MAPPINGS: dict[str, dict[str, str]] = { 

78 "hadolint": { 

79 "trusted_registries": "trustedRegistries", 

80 "require_labels": "requireLabels", 

81 "strict_labels": "strictLabels", 

82 # "ignored" stays as "ignored" (same in both formats) 

83 }, 

84} 

85 

86# Built-in defaults that Lintro provides for tools even without user config. 

87# User defaults always take precedence (merged on top of builtins). 

88TOOL_BUILTIN_DEFAULTS: dict[str, dict[str, Any]] = { 

89 "prettier": { 

90 "proseWrap": "always", 

91 }, 

92} 

93 

94# Native config file patterns for checking if tool has native config 

95NATIVE_CONFIG_PATTERNS: dict[str, list[str]] = { 

96 "markdownlint": [ 

97 ".markdownlint-cli2.jsonc", 

98 ".markdownlint-cli2.yaml", 

99 ".markdownlint-cli2.cjs", 

100 ".markdownlint.jsonc", 

101 ".markdownlint.json", 

102 ".markdownlint.yaml", 

103 ".markdownlint.yml", 

104 ".markdownlint.cjs", 

105 ], 

106 "yamllint": [ 

107 ".yamllint", 

108 ".yamllint.yaml", 

109 ".yamllint.yml", 

110 ], 

111 "hadolint": [ 

112 ".hadolint.yaml", 

113 ".hadolint.yml", 

114 ], 

115 "bandit": [ 

116 ".bandit", 

117 ".bandit.yaml", 

118 ".bandit.yml", 

119 "bandit.yaml", 

120 "bandit.yml", 

121 ], 

122 "oxlint": [ 

123 ".oxlintrc.json", 

124 ], 

125 "oxfmt": [ 

126 ".oxfmtrc.json", 

127 ".oxfmtrc.jsonc", 

128 ], 

129 "prettier": [ 

130 ".prettierrc", 

131 ".prettierrc.json", 

132 ".prettierrc.json5", 

133 ".prettierrc.yaml", 

134 ".prettierrc.yml", 

135 ".prettierrc.js", 

136 ".prettierrc.cjs", 

137 ".prettierrc.mjs", 

138 ".prettierrc.toml", 

139 "prettier.config.js", 

140 "prettier.config.cjs", 

141 "prettier.config.mjs", 

142 "prettier.config.ts", 

143 "prettier.config.cts", 

144 "prettier.config.mts", 

145 "package.json", 

146 ], 

147} 

148 

149# Track temporary files for cleanup 

150_temp_files: list[Path] = [] 

151 

152 

153def _cleanup_temp_files() -> None: 

154 """Clean up temporary config files on exit.""" 

155 for temp_file in _temp_files: 

156 try: 

157 if temp_file.exists(): 

158 temp_file.unlink() 

159 logger.debug(f"Cleaned up temp config: {temp_file}") 

160 except OSError as e: 

161 logger.debug(f"Failed to clean up {temp_file}: {e}") 

162 

163 

164# Register cleanup on exit 

165atexit.register(_cleanup_temp_files) 

166 

167 

168def get_enforce_cli_args( 

169 tool_name: str, 

170 lintro_config: LintroConfig, 

171) -> list[str]: 

172 """Get CLI arguments for enforced settings. 

173 

174 These settings override native tool configs to ensure consistency 

175 across different tools for shared concerns like line length. 

176 

177 Args: 

178 tool_name: Name of the tool (e.g., "ruff", "black"). 

179 lintro_config: Lintro configuration. 

180 

181 Returns: 

182 list[str]: CLI arguments to inject (e.g., ["--line-length", "88"]). 

183 """ 

184 args: list[str] = [] 

185 tool_lower = tool_name.lower() 

186 enforce = lintro_config.enforce 

187 

188 # Inject line_length if set 

189 if enforce.line_length is not None: 

190 flag = ENFORCE_CLI_FLAGS.get("line_length", {}).get(tool_lower) 

191 if flag: 

192 args.extend([flag, str(enforce.line_length)]) 

193 logger.debug( 

194 f"Injecting enforce.line_length={enforce.line_length} " 

195 f"to {tool_name} as {flag}", 

196 ) 

197 

198 # Inject target_python if set 

199 if enforce.target_python is not None: 

200 flag = ENFORCE_CLI_FLAGS.get("target_python", {}).get(tool_lower) 

201 if flag: 

202 target_value = ( 

203 _convert_python_version_for_mypy(enforce.target_python) 

204 if tool_lower == ToolName.MYPY.value 

205 else enforce.target_python 

206 ) 

207 args.extend([flag, target_value]) 

208 logger.debug( 

209 f"Injecting enforce.target_python={target_value} " 

210 f"to {tool_name} as {flag}", 

211 ) 

212 

213 return args 

214 

215 

216def has_native_config(tool_name: str) -> bool: 

217 """Check if a tool has a native config file in the project. 

218 

219 Searches for known native config file patterns starting from the 

220 current working directory and moving upward to find the project root. 

221 

222 Args: 

223 tool_name: Name of the tool (e.g., "markdownlint"). 

224 

225 Returns: 

226 bool: True if a native config file exists. 

227 """ 

228 tool_lower = tool_name.lower() 

229 patterns = NATIVE_CONFIG_PATTERNS.get(tool_lower, []) 

230 

231 if not patterns: 

232 return False 

233 

234 # Search from current directory upward 

235 current = Path.cwd().resolve() 

236 

237 while True: 

238 for pattern in patterns: 

239 config_path = current / pattern 

240 if config_path.exists(): 

241 # package.json only counts if it contains a "prettier" key 

242 if tool_lower == "prettier" and pattern == "package.json": 

243 try: 

244 pkg_data = json.loads( 

245 config_path.read_text(encoding="utf-8"), 

246 ) 

247 if "prettier" not in pkg_data: 

248 continue 

249 except (json.JSONDecodeError, OSError): 

250 logger.debug( 

251 f"Skipping unreadable package.json: {config_path}", 

252 ) 

253 continue 

254 logger.debug( 

255 f"Found native config for {tool_name}: {config_path}", 

256 ) 

257 return True 

258 

259 # Move up one directory 

260 parent = current.parent 

261 if parent == current: 

262 # Reached filesystem root 

263 break 

264 current = parent 

265 

266 return False 

267 

268 

269def generate_defaults_config( 

270 tool_name: str, 

271 lintro_config: LintroConfig, 

272) -> Path | None: 

273 """Generate a temporary config file from defaults. 

274 

275 Only used when a tool has no native config file and defaults 

276 are specified in the Lintro config. 

277 

278 Args: 

279 tool_name: Name of the tool. 

280 lintro_config: Lintro configuration. 

281 

282 Returns: 

283 Path | None: Path to generated config file, or None if not needed. 

284 """ 

285 tool_lower = tool_name.lower() 

286 

287 # Check if tool has native config - if so, don't generate defaults 

288 if has_native_config(tool_lower): 

289 logger.debug( 

290 f"Tool {tool_name} has native config, skipping defaults generation", 

291 ) 

292 return None 

293 

294 # Get defaults for this tool (user defaults override builtin defaults) 

295 user_defaults = lintro_config.get_tool_defaults(tool_lower) 

296 builtin_defaults = TOOL_BUILTIN_DEFAULTS.get(tool_lower, {}) 

297 if not user_defaults and not builtin_defaults: 

298 return None 

299 defaults = {**builtin_defaults, **user_defaults} 

300 

301 # Get config format for this tool 

302 config_format = TOOL_CONFIG_FORMATS.get(tool_lower, ConfigFormat.JSON) 

303 

304 try: 

305 return _write_defaults_config( 

306 defaults=defaults, 

307 tool_name=tool_lower, 

308 config_format=config_format, 

309 ) 

310 except (OSError, ValueError, TypeError, ImportError) as e: 

311 logger.error( 

312 f"Failed to generate defaults config for {tool_name}: " 

313 f"{type(e).__name__}: {e}", 

314 ) 

315 return None 

316 

317 

318def _transform_keys_for_native_config( 

319 defaults: dict[str, Any], 

320 tool_name: str, 

321) -> dict[str, Any]: 

322 """Transform lintro config keys to native tool key format. 

323 

324 Some tools (like hadolint) use camelCase keys in their native config files, 

325 while lintro uses snake_case for consistency. This function transforms keys 

326 to match the native tool's expected format. 

327 

328 Args: 

329 defaults: Default configuration dictionary with lintro keys. 

330 tool_name: Name of the tool. 

331 

332 Returns: 

333 dict[str, Any]: Configuration with keys transformed to native format. 

334 """ 

335 key_mapping = NATIVE_KEY_MAPPINGS.get(tool_name.lower(), {}) 

336 if not key_mapping: 

337 return defaults 

338 

339 transformed: dict[str, Any] = {} 

340 for key, value in defaults.items(): 

341 native_key = key_mapping.get(key, key) 

342 transformed[native_key] = value 

343 

344 if transformed != defaults: 

345 logger.debug( 

346 f"Transformed config keys for {tool_name}: " 

347 f"{list(defaults.keys())} -> {list(transformed.keys())}", 

348 ) 

349 

350 return transformed 

351 

352 

353def _write_defaults_config( 

354 defaults: dict[str, Any], 

355 tool_name: str, 

356 config_format: ConfigFormat, 

357) -> Path: 

358 """Write defaults configuration to a temporary file. 

359 

360 Args: 

361 defaults: Default configuration dictionary. 

362 tool_name: Name of the tool. 

363 config_format: Output format (json, yaml). 

364 

365 Returns: 

366 Path: Path to temporary config file. 

367 

368 Raises: 

369 ImportError: If PyYAML is not installed and YAML format is requested. 

370 """ 

371 # Tool-specific suffixes required by some tools (e.g., markdownlint-cli2 v0.17+ 

372 # enforces strict config file naming conventions) 

373 tool_suffix_overrides: dict[str, str] = { 

374 "markdownlint": ".markdownlint-cli2.jsonc", 

375 } 

376 

377 tool_lower = tool_name.lower() 

378 if tool_lower in tool_suffix_overrides: 

379 suffix = tool_suffix_overrides[tool_lower] 

380 else: 

381 suffix_map = {ConfigFormat.JSON: ".json", ConfigFormat.YAML: ".yaml"} 

382 suffix = suffix_map.get(config_format, ".json") 

383 

384 temp_fd, temp_path_str = tempfile.mkstemp( 

385 prefix=f"lintro-{tool_name}-defaults-", 

386 suffix=suffix, 

387 ) 

388 os.close(temp_fd) 

389 temp_path = Path(temp_path_str) 

390 _temp_files.append(temp_path) 

391 

392 # Transform keys to native format before writing 

393 native_defaults = _transform_keys_for_native_config(defaults, tool_lower) 

394 

395 if config_format == ConfigFormat.YAML: 

396 if yaml is None: 

397 raise ImportError("PyYAML required for YAML output") 

398 content = yaml.dump(native_defaults, default_flow_style=False) 

399 else: 

400 content = json.dumps(native_defaults, indent=2) 

401 

402 temp_path.write_text(content, encoding="utf-8") 

403 logger.debug(f"Generated defaults config for {tool_name}: {temp_path}") 

404 

405 return temp_path 

406 

407 

408def get_defaults_injection_args( 

409 tool_name: str, 

410 config_path: Path | None, 

411) -> list[str]: 

412 """Get CLI arguments to inject defaults config file into a tool. 

413 

414 Args: 

415 tool_name: Name of the tool. 

416 config_path: Path to defaults config file (or None). 

417 

418 Returns: 

419 list[str]: CLI arguments to pass to the tool. 

420 """ 

421 if config_path is None: 

422 return [] 

423 

424 tool_lower = tool_name.lower() 

425 config_str = str(config_path) 

426 

427 # Tool-specific config flags 

428 config_flags: dict[str, list[str]] = { 

429 "yamllint": ["-c", config_str], 

430 "markdownlint": ["--config", config_str], 

431 "hadolint": ["--config", config_str], 

432 "bandit": ["-c", config_str], 

433 "oxlint": ["--config", config_str], 

434 "oxfmt": ["--config", config_str], 

435 "prettier": ["--no-config", "--config", config_str], 

436 } 

437 

438 return config_flags.get(tool_lower, []) 

439 

440 

441def cleanup_temp_config(config_path: Path) -> None: 

442 """Explicitly clean up a temporary config file. 

443 

444 Args: 

445 config_path: Path to temporary config file. 

446 """ 

447 try: 

448 if config_path in _temp_files: 

449 _temp_files.remove(config_path) 

450 if config_path.exists(): 

451 config_path.unlink() 

452 logger.debug(f"Cleaned up temp config: {config_path}") 

453 except OSError as e: 

454 logger.debug(f"Failed to clean up {config_path}: {e}") 

455 

456 

457# =============================================================================