Coverage for lintro / config / config_loader.py: 78%

174 statements  

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

1"""Configuration loader for Lintro. 

2 

3Loads configuration from .lintro-config.yaml with fallback to 

4[tool.lintro] in pyproject.toml for backward compatibility. 

5 

6Supports the new tiered configuration model: 

71. execution: What tools run and how 

82. enforce: Cross-cutting settings (replaces 'global') 

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 tomllib 

16from pathlib import Path 

17from typing import Any 

18 

19from loguru import logger 

20 

21from lintro.ai.config import AIConfig 

22from lintro.config.lintro_config import ( 

23 EnforceConfig, 

24 ExecutionConfig, 

25 LintroConfig, 

26 LintroToolConfig, 

27) 

28from lintro.enums.config_key import ConfigKey 

29 

30try: 

31 import yaml 

32except ImportError: 

33 yaml = None # type: ignore[assignment] 

34 

35# Default config file name 

36LINTRO_CONFIG_FILENAME = ".lintro-config.yaml" 

37LINTRO_CONFIG_FILENAMES = [ 

38 ".lintro-config.yaml", 

39 ".lintro-config.yml", 

40 "lintro-config.yaml", 

41 "lintro-config.yml", 

42] 

43 

44 

45def _find_config_file(start_dir: Path | None = None) -> Path | None: 

46 """Find .lintro-config.yaml by searching upward from start_dir. 

47 

48 Args: 

49 start_dir: Directory to start searching from. Defaults to cwd. 

50 

51 Returns: 

52 Path | None: Path to config file if found. 

53 """ 

54 current = Path(start_dir) if start_dir else Path.cwd() 

55 current = current.resolve() 

56 

57 while True: 

58 for filename in LINTRO_CONFIG_FILENAMES: 

59 config_path = current / filename 

60 if config_path.exists(): 

61 return config_path 

62 

63 # Move up one directory 

64 parent = current.parent 

65 if parent == current: 

66 # Reached filesystem root 

67 break 

68 current = parent 

69 

70 return None 

71 

72 

73def _load_yaml_file(path: Path) -> dict[str, Any]: 

74 """Load a YAML file. 

75 

76 Args: 

77 path: Path to YAML file. 

78 

79 Returns: 

80 dict[str, Any]: Parsed YAML content. 

81 

82 Raises: 

83 ImportError: If PyYAML is not installed. 

84 """ 

85 if yaml is None: 

86 raise ImportError( 

87 "PyYAML is required to load .lintro-config.yaml. " 

88 "Install it with: pip install pyyaml", 

89 ) 

90 

91 with path.open(encoding="utf-8") as f: 

92 content = yaml.safe_load(f) 

93 

94 return content if isinstance(content, dict) else {} 

95 

96 

97def _load_pyproject_fallback() -> tuple[dict[str, Any], Path | None]: 

98 """Load [tool.lintro] from pyproject.toml as fallback. 

99 

100 Searches upward from current directory for pyproject.toml, consistent 

101 with _find_config_file's search behavior. 

102 

103 Returns: 

104 tuple[dict[str, Any], Path | None]: Tuple of (config data, path to 

105 pyproject.toml). Path is None if no pyproject.toml was found. 

106 """ 

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

108 

109 while True: 

110 pyproject_path = current / "pyproject.toml" 

111 if pyproject_path.exists(): 

112 try: 

113 with pyproject_path.open("rb") as f: 

114 data = tomllib.load(f) 

115 return data.get("tool", {}).get("lintro", {}), pyproject_path 

116 except tomllib.TOMLDecodeError as e: 

117 logger.warning( 

118 f"Failed to parse pyproject.toml at {pyproject_path}: {e}", 

119 ) 

120 return {}, None 

121 except OSError as e: 

122 logger.debug(f"Could not read pyproject.toml at {pyproject_path}: {e}") 

123 return {}, None 

124 

125 # Move up one directory 

126 parent = current.parent 

127 if parent == current: 

128 # Reached filesystem root 

129 break 

130 current = parent 

131 

132 return {}, None 

133 

134 

135def _parse_enforce_config(data: dict[str, Any]) -> EnforceConfig: 

136 """Parse enforce configuration section. 

137 

138 Args: 

139 data: Raw 'enforce' or 'global' section from config. 

140 

141 Returns: 

142 EnforceConfig: Parsed enforce configuration. 

143 """ 

144 return EnforceConfig( 

145 line_length=data.get("line_length"), 

146 target_python=data.get("target_python"), 

147 ) 

148 

149 

150def _parse_execution_config(data: dict[str, Any]) -> ExecutionConfig: 

151 """Parse execution configuration section. 

152 

153 Args: 

154 data: Raw 'execution' section from config. 

155 

156 Returns: 

157 ExecutionConfig: Parsed execution configuration. 

158 

159 Raises: 

160 ValueError: If max_fix_retries is not a valid positive integer. 

161 """ 

162 enabled_tools = data.get("enabled_tools", []) 

163 if isinstance(enabled_tools, str): 

164 enabled_tools = [enabled_tools] 

165 

166 tool_order = data.get("tool_order", "priority") 

167 

168 # Validate max_fix_retries 

169 raw_retries = data.get("max_fix_retries") 

170 if raw_retries is None: 

171 max_fix_retries = 3 

172 elif isinstance(raw_retries, bool): 

173 raise ValueError( 

174 "execution.max_fix_retries must be an integer, got bool", 

175 ) 

176 elif isinstance(raw_retries, int): 

177 max_fix_retries = raw_retries 

178 elif isinstance(raw_retries, str): 

179 try: 

180 max_fix_retries = int(raw_retries.strip()) 

181 except ValueError: 

182 raise ValueError( 

183 f"execution.max_fix_retries must be an integer, " 

184 f"got {type(raw_retries).__name__}: {raw_retries!r}", 

185 ) from None 

186 else: 

187 raise ValueError( 

188 f"execution.max_fix_retries must be an integer, " 

189 f"got {type(raw_retries).__name__}: {raw_retries!r}", 

190 ) 

191 if not 1 <= max_fix_retries <= 10: 

192 raise ValueError( 

193 f"execution.max_fix_retries must be between 1 and 10, " 

194 f"got {max_fix_retries}", 

195 ) 

196 

197 return ExecutionConfig( 

198 enabled_tools=enabled_tools, 

199 tool_order=tool_order, 

200 fail_fast=data.get("fail_fast", False), 

201 parallel=data.get("parallel", True), 

202 auto_install_deps=data.get("auto_install_deps"), 

203 max_fix_retries=max_fix_retries, 

204 ) 

205 

206 

207def _parse_tool_config(data: dict[str, Any]) -> LintroToolConfig: 

208 """Parse a single tool configuration. 

209 

210 In the tiered model, tools only have enabled and optional config_source. 

211 

212 Args: 

213 data: Raw tool configuration dict. 

214 

215 Returns: 

216 LintroToolConfig: Parsed tool configuration. 

217 

218 Raises: 

219 ValueError: If auto_install is not a boolean. 

220 """ 

221 enabled = data.get("enabled", True) 

222 config_source = data.get("config_source") 

223 auto_install_raw = data.get("auto_install") 

224 auto_install: bool | None = None 

225 if isinstance(auto_install_raw, bool): 

226 auto_install = auto_install_raw 

227 elif auto_install_raw is not None: 

228 type_name = type(auto_install_raw).__name__ 

229 raise ValueError( 

230 f"tools.<name>.auto_install must be a boolean, got {type_name}", 

231 ) 

232 

233 return LintroToolConfig( 

234 enabled=enabled, 

235 config_source=config_source, 

236 auto_install=auto_install, 

237 ) 

238 

239 

240def _parse_tools_config(data: dict[str, Any]) -> dict[str, LintroToolConfig]: 

241 """Parse all tool configurations. 

242 

243 Args: 

244 data: Raw 'tools' section from config. 

245 

246 Returns: 

247 dict[str, LintroToolConfig]: Tool configurations keyed by tool name. 

248 """ 

249 tools: dict[str, LintroToolConfig] = {} 

250 

251 for tool_name, tool_data in data.items(): 

252 if isinstance(tool_data, dict): 

253 tools[tool_name.lower()] = _parse_tool_config(tool_data) 

254 elif isinstance(tool_data, bool): 

255 # Simple enabled/disabled flag 

256 tools[tool_name.lower()] = LintroToolConfig(enabled=tool_data) 

257 

258 return tools 

259 

260 

261def _parse_defaults(data: dict[str, Any]) -> dict[str, dict[str, Any]]: 

262 """Parse defaults configuration section. 

263 

264 Args: 

265 data: Raw 'defaults' section from config. 

266 

267 Returns: 

268 dict[str, dict[str, Any]]: Defaults configurations keyed by tool name. 

269 """ 

270 defaults: dict[str, dict[str, Any]] = {} 

271 

272 for tool_name, tool_defaults in data.items(): 

273 if isinstance(tool_defaults, dict): 

274 defaults[tool_name.lower()] = tool_defaults 

275 

276 return defaults 

277 

278 

279def _parse_ai_config(data: dict[str, Any]) -> AIConfig: 

280 """Parse AI configuration section. 

281 

282 Passes only recognized keys through to AIConfig so the model's 

283 own defaults apply for any omitted fields. 

284 

285 Args: 

286 data: Raw 'ai' section from config. 

287 

288 Returns: 

289 AIConfig: Parsed AI configuration. 

290 """ 

291 if not data: 

292 return AIConfig() 

293 

294 known_fields = set(AIConfig.model_fields) 

295 unknown = set(data) - known_fields 

296 if unknown: 

297 logger.warning( 

298 "Unknown AI config keys ignored: {}", 

299 ", ".join(sorted(unknown)), 

300 ) 

301 filtered = {k: v for k, v in data.items() if k in known_fields} 

302 return AIConfig(**filtered) 

303 

304 

305def _convert_pyproject_to_config(data: dict[str, Any]) -> dict[str, Any]: 

306 """Convert pyproject.toml [tool.lintro] format to .lintro-config.yaml format. 

307 

308 The pyproject format uses flat tool sections like [tool.lintro.ruff], 

309 while .lintro-config.yaml uses nested tools: section. 

310 

311 Args: 

312 data: Raw [tool.lintro] section from pyproject.toml. 

313 

314 Returns: 

315 dict[str, Any]: Converted configuration in .lintro-config.yaml format. 

316 """ 

317 result: dict[str, Any] = { 

318 "enforce": {}, 

319 "execution": {}, 

320 "defaults": {}, 

321 "tools": {}, 

322 "ai": {}, 

323 } 

324 

325 # Inline import: ToolName is a static StrEnum that does not trigger 

326 # the plugin registry. Imported here to avoid a circular dependency 

327 # between config_loader and the tool subsystem. 

328 from lintro.enums.tool_name import ToolName 

329 

330 known_tools = {t.value for t in ToolName} | { 

331 t.value.replace("_", "-") for t in ToolName 

332 } 

333 # Add common aliases for tools 

334 tool_aliases = {"markdownlint-cli2": "markdownlint"} 

335 known_tools.update(tool_aliases.keys()) 

336 

337 # Known execution settings 

338 execution_keys = { 

339 "enabled_tools", 

340 "tool_order", 

341 "fail_fast", 

342 "parallel", 

343 "auto_install_deps", 

344 "max_fix_retries", 

345 } 

346 

347 # Known enforce settings (formerly global) 

348 enforce_keys = {"line_length", "target_python"} 

349 

350 for key, value in data.items(): 

351 key_lower = key.lower() 

352 

353 if key_lower in known_tools: 

354 # Tool-specific config - normalize aliases to canonical names 

355 canonical_name = tool_aliases.get(key_lower, key_lower) 

356 result["tools"][canonical_name] = value 

357 elif key in execution_keys or key.replace("-", "_") in execution_keys: 

358 # Execution config 

359 result["execution"][key.replace("-", "_")] = value 

360 elif key in enforce_keys or key.replace("-", "_") in enforce_keys: 

361 # Enforce config 

362 result["enforce"][key.replace("-", "_")] = value 

363 elif key_lower == ConfigKey.POST_CHECKS.value.lower(): 

364 # Skip post_checks (handled separately) 

365 pass 

366 elif key_lower == ConfigKey.VERSIONS.value.lower(): 

367 # Skip versions (handled separately) 

368 pass 

369 elif key_lower == ConfigKey.DEFAULTS.value.lower() and isinstance(value, dict): 

370 # Defaults section 

371 result["defaults"] = value 

372 elif key_lower == "ai" and isinstance(value, dict): 

373 # AI configuration section 

374 result["ai"] = value 

375 

376 return result 

377 

378 

379def load_config( 

380 config_path: Path | str | None = None, 

381 allow_pyproject_fallback: bool = True, 

382) -> LintroConfig: 

383 """Load Lintro configuration. 

384 

385 Priority: 

386 1. Explicit config_path if provided 

387 2. .lintro-config.yaml found by searching upward 

388 3. [tool.lintro] in pyproject.toml fallback 

389 4. Default empty configuration 

390 

391 Args: 

392 config_path: Explicit path to config file. If None, searches for 

393 .lintro-config.yaml. 

394 allow_pyproject_fallback: Whether to fall back to pyproject.toml 

395 if no .lintro-config.yaml is found. 

396 

397 Returns: 

398 LintroConfig: Loaded configuration. 

399 """ 

400 data: dict[str, Any] = {} 

401 resolved_path: str | None = None 

402 

403 # Try explicit path first 

404 if config_path: 

405 path = Path(config_path) 

406 if path.exists(): 

407 data = _load_yaml_file(path) 

408 resolved_path = str(path.resolve()) 

409 logger.debug(f"Loaded config from explicit path: {resolved_path}") 

410 else: 

411 logger.warning(f"Config file not found: {config_path}") 

412 

413 # Try searching for .lintro-config.yaml 

414 if not data: 

415 found_path = _find_config_file() 

416 if found_path: 

417 data = _load_yaml_file(found_path) 

418 resolved_path = str(found_path.resolve()) 

419 logger.debug(f"Loaded config from: {resolved_path}") 

420 

421 # Fall back to pyproject.toml 

422 if not data and allow_pyproject_fallback: 

423 pyproject_data, pyproject_path = _load_pyproject_fallback() 

424 if pyproject_data: 

425 data = _convert_pyproject_to_config(pyproject_data) 

426 resolved_path = str(pyproject_path.resolve()) if pyproject_path else None 

427 logger.debug( 

428 "Using [tool.lintro] from pyproject.toml. " 

429 "Consider migrating to .lintro-config.yaml", 

430 ) 

431 

432 # Parse enforce config 

433 enforce_data = data.get("enforce", {}) 

434 

435 enforce_config = _parse_enforce_config(enforce_data) 

436 execution_config = _parse_execution_config(data.get("execution", {})) 

437 defaults = _parse_defaults(data.get("defaults", {})) 

438 tools_config = _parse_tools_config(data.get("tools", {})) 

439 ai_config = _parse_ai_config(data.get("ai", {})) 

440 

441 return LintroConfig( 

442 execution=execution_config, 

443 enforce=enforce_config, 

444 defaults=defaults, 

445 tools=tools_config, 

446 ai=ai_config, 

447 config_path=resolved_path, 

448 ) 

449 

450 

451def get_default_config() -> LintroConfig: 

452 """Get a default configuration with sensible defaults. 

453 

454 Returns: 

455 LintroConfig: Default configuration. 

456 """ 

457 return LintroConfig( 

458 enforce=EnforceConfig( 

459 line_length=88, 

460 target_python=None, 

461 ), 

462 execution=ExecutionConfig( 

463 tool_order="priority", 

464 ), 

465 ) 

466 

467 

468# Global singleton for loaded config 

469_loaded_config: LintroConfig | None = None 

470 

471 

472def get_config(reload: bool = False) -> LintroConfig: 

473 """Get the loaded configuration singleton. 

474 

475 Args: 

476 reload: Force reload from disk. 

477 

478 Returns: 

479 LintroConfig: Loaded configuration. 

480 """ 

481 global _loaded_config 

482 

483 if _loaded_config is None or reload: 

484 _loaded_config = load_config() 

485 

486 return _loaded_config 

487 

488 

489def clear_config_cache() -> None: 

490 """Clear the configuration cache. 

491 

492 Useful for testing or when config file has changed. 

493 """ 

494 global _loaded_config 

495 _loaded_config = None