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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Configuration loader for Lintro.
3Loads configuration from .lintro-config.yaml with fallback to
4[tool.lintro] in pyproject.toml for backward compatibility.
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"""
13from __future__ import annotations
15import tomllib
16from pathlib import Path
17from typing import Any
19from loguru import logger
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
30try:
31 import yaml
32except ImportError:
33 yaml = None # type: ignore[assignment]
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]
45def _find_config_file(start_dir: Path | None = None) -> Path | None:
46 """Find .lintro-config.yaml by searching upward from start_dir.
48 Args:
49 start_dir: Directory to start searching from. Defaults to cwd.
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()
57 while True:
58 for filename in LINTRO_CONFIG_FILENAMES:
59 config_path = current / filename
60 if config_path.exists():
61 return config_path
63 # Move up one directory
64 parent = current.parent
65 if parent == current:
66 # Reached filesystem root
67 break
68 current = parent
70 return None
73def _load_yaml_file(path: Path) -> dict[str, Any]:
74 """Load a YAML file.
76 Args:
77 path: Path to YAML file.
79 Returns:
80 dict[str, Any]: Parsed YAML content.
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 )
91 with path.open(encoding="utf-8") as f:
92 content = yaml.safe_load(f)
94 return content if isinstance(content, dict) else {}
97def _load_pyproject_fallback() -> tuple[dict[str, Any], Path | None]:
98 """Load [tool.lintro] from pyproject.toml as fallback.
100 Searches upward from current directory for pyproject.toml, consistent
101 with _find_config_file's search behavior.
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()
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
125 # Move up one directory
126 parent = current.parent
127 if parent == current:
128 # Reached filesystem root
129 break
130 current = parent
132 return {}, None
135def _parse_enforce_config(data: dict[str, Any]) -> EnforceConfig:
136 """Parse enforce configuration section.
138 Args:
139 data: Raw 'enforce' or 'global' section from config.
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 )
150def _parse_execution_config(data: dict[str, Any]) -> ExecutionConfig:
151 """Parse execution configuration section.
153 Args:
154 data: Raw 'execution' section from config.
156 Returns:
157 ExecutionConfig: Parsed execution configuration.
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]
166 tool_order = data.get("tool_order", "priority")
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 )
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 )
207def _parse_tool_config(data: dict[str, Any]) -> LintroToolConfig:
208 """Parse a single tool configuration.
210 In the tiered model, tools only have enabled and optional config_source.
212 Args:
213 data: Raw tool configuration dict.
215 Returns:
216 LintroToolConfig: Parsed tool configuration.
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 )
233 return LintroToolConfig(
234 enabled=enabled,
235 config_source=config_source,
236 auto_install=auto_install,
237 )
240def _parse_tools_config(data: dict[str, Any]) -> dict[str, LintroToolConfig]:
241 """Parse all tool configurations.
243 Args:
244 data: Raw 'tools' section from config.
246 Returns:
247 dict[str, LintroToolConfig]: Tool configurations keyed by tool name.
248 """
249 tools: dict[str, LintroToolConfig] = {}
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)
258 return tools
261def _parse_defaults(data: dict[str, Any]) -> dict[str, dict[str, Any]]:
262 """Parse defaults configuration section.
264 Args:
265 data: Raw 'defaults' section from config.
267 Returns:
268 dict[str, dict[str, Any]]: Defaults configurations keyed by tool name.
269 """
270 defaults: dict[str, dict[str, Any]] = {}
272 for tool_name, tool_defaults in data.items():
273 if isinstance(tool_defaults, dict):
274 defaults[tool_name.lower()] = tool_defaults
276 return defaults
279def _parse_ai_config(data: dict[str, Any]) -> AIConfig:
280 """Parse AI configuration section.
282 Passes only recognized keys through to AIConfig so the model's
283 own defaults apply for any omitted fields.
285 Args:
286 data: Raw 'ai' section from config.
288 Returns:
289 AIConfig: Parsed AI configuration.
290 """
291 if not data:
292 return AIConfig()
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)
305def _convert_pyproject_to_config(data: dict[str, Any]) -> dict[str, Any]:
306 """Convert pyproject.toml [tool.lintro] format to .lintro-config.yaml format.
308 The pyproject format uses flat tool sections like [tool.lintro.ruff],
309 while .lintro-config.yaml uses nested tools: section.
311 Args:
312 data: Raw [tool.lintro] section from pyproject.toml.
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 }
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
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())
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 }
347 # Known enforce settings (formerly global)
348 enforce_keys = {"line_length", "target_python"}
350 for key, value in data.items():
351 key_lower = key.lower()
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
376 return result
379def load_config(
380 config_path: Path | str | None = None,
381 allow_pyproject_fallback: bool = True,
382) -> LintroConfig:
383 """Load Lintro configuration.
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
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.
397 Returns:
398 LintroConfig: Loaded configuration.
399 """
400 data: dict[str, Any] = {}
401 resolved_path: str | None = None
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}")
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}")
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 )
432 # Parse enforce config
433 enforce_data = data.get("enforce", {})
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", {}))
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 )
451def get_default_config() -> LintroConfig:
452 """Get a default configuration with sensible defaults.
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 )
468# Global singleton for loaded config
469_loaded_config: LintroConfig | None = None
472def get_config(reload: bool = False) -> LintroConfig:
473 """Get the loaded configuration singleton.
475 Args:
476 reload: Force reload from disk.
478 Returns:
479 LintroConfig: Loaded configuration.
480 """
481 global _loaded_config
483 if _loaded_config is None or reload:
484 _loaded_config = load_config()
486 return _loaded_config
489def clear_config_cache() -> None:
490 """Clear the configuration cache.
492 Useful for testing or when config file has changed.
493 """
494 global _loaded_config
495 _loaded_config = None