Coverage for lintro / utils / config.py: 91%
127 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"""Project configuration helpers for Lintro.
3This module provides centralized access to configuration from pyproject.toml
4and other config sources. It consolidates functionality from config_loaders
5and config_utils into a single module.
7Reads configuration from `pyproject.toml` under the `[tool.lintro]` table.
8Allows tool-specific defaults via `[tool.lintro.<tool>]` (e.g., `[tool.lintro.ruff]`).
9"""
11from __future__ import annotations
13import configparser
14import tomllib
15from pathlib import Path
16from typing import Any
18from loguru import logger
20__all__ = [
21 # Core pyproject loading
22 "load_pyproject",
23 "load_pyproject_config",
24 "load_tool_config_from_pyproject",
25 "clear_pyproject_cache",
26 # Lintro config loading
27 "load_lintro_global_config",
28 "load_lintro_tool_config",
29 "get_tool_order_config",
30 "load_post_checks_config",
31 # Tool-specific loaders
32 "load_ruff_config",
33 "load_bandit_config",
34 "load_black_config",
35 "load_mypy_config",
36 "load_pydoclint_config",
37 # Backward compatibility
38 "get_central_line_length",
39 "validate_line_length_consistency",
40]
43# =============================================================================
44# Core pyproject.toml Loading
45# =============================================================================
47# Module-level caches keyed on resolved paths so that different working
48# directories get independent cache entries (unlike functools.lru_cache
49# which has no awareness of cwd).
50_pyproject_path_cache: dict[Path, Path | None] = {}
51_pyproject_data_cache: dict[Path, dict[str, Any]] = {}
54def clear_pyproject_cache() -> None:
55 """Clear both pyproject path and data caches.
57 Call this when the working directory may have changed or when
58 pyproject.toml contents should be re-read from disk.
59 """
60 _pyproject_path_cache.clear()
61 _pyproject_data_cache.clear()
64def _find_pyproject(start_path: Path | None = None) -> Path | None:
65 """Search for pyproject.toml up the directory tree.
67 Results are cached by resolved start path so that different working
68 directories return the correct pyproject.toml.
70 Args:
71 start_path: Optional starting path for search.
72 Defaults to current working directory.
74 Returns:
75 Path to pyproject.toml if found, None otherwise.
76 """
77 if start_path is None:
78 start_path = Path.cwd()
79 key = start_path.resolve()
80 if key in _pyproject_path_cache:
81 return _pyproject_path_cache[key]
82 for parent in [start_path, *start_path.parents]:
83 candidate = parent / "pyproject.toml"
84 if candidate.exists():
85 _pyproject_path_cache[key] = candidate
86 return candidate
87 _pyproject_path_cache[key] = None
88 return None
91def load_pyproject() -> dict[str, Any]:
92 """Load the full pyproject.toml with caching.
94 Results are cached by resolved pyproject path so that different
95 working directories correctly load their own pyproject.toml.
97 Returns:
98 Full pyproject.toml contents as dict
99 """
100 pyproject_path = _find_pyproject()
101 if not pyproject_path:
102 logger.debug("No pyproject.toml found in current directory or parents")
103 return {}
104 key = pyproject_path.resolve()
105 if key in _pyproject_data_cache:
106 return _pyproject_data_cache[key]
107 try:
108 with pyproject_path.open("rb") as f:
109 data = tomllib.load(f)
110 _pyproject_data_cache[key] = data
111 return data
112 except OSError as e:
113 logger.warning(f"Failed to read pyproject.toml at {pyproject_path}: {e}")
114 return {}
115 except tomllib.TOMLDecodeError as e:
116 logger.warning(f"Failed to parse pyproject.toml at {pyproject_path}: {e}")
117 return {}
120def load_pyproject_config() -> dict[str, Any]:
121 """Load the entire pyproject.toml configuration.
123 Alias for load_pyproject() for backward compatibility.
125 Returns:
126 dict[str, Any]: Complete pyproject.toml configuration, or empty dict if
127 not found.
128 """
129 return load_pyproject()
132def _get_lintro_section() -> dict[str, Any]:
133 """Extract the [tool.lintro] section from pyproject.toml.
135 Returns:
136 The tool.lintro section as a dict, or {} if not found or invalid.
137 """
138 pyproject = load_pyproject()
139 tool_section_raw = pyproject.get("tool", {})
140 tool_section = tool_section_raw if isinstance(tool_section_raw, dict) else {}
141 lintro_config_raw = tool_section.get("lintro", {})
142 return lintro_config_raw if isinstance(lintro_config_raw, dict) else {}
145# =============================================================================
146# Lintro Configuration Loading
147# =============================================================================
150def load_lintro_global_config() -> dict[str, Any]:
151 """Load global Lintro configuration from [tool.lintro].
153 Returns:
154 Global configuration dictionary (excludes tool-specific sections)
155 """
156 lintro_config = _get_lintro_section()
158 # Filter out known tool-specific sections
159 tool_sections = {
160 "ruff",
161 "black",
162 "yamllint",
163 "markdownlint",
164 "markdownlint-cli2",
165 "bandit",
166 "hadolint",
167 "actionlint",
168 "pytest",
169 "mypy",
170 "clippy",
171 "pydoclint",
172 "tsc",
173 "post_checks",
174 "versions",
175 }
177 return {k: v for k, v in lintro_config.items() if k not in tool_sections}
180def load_lintro_tool_config(tool_name: str) -> dict[str, Any]:
181 """Load tool-specific Lintro config from [tool.lintro.<tool>].
183 Args:
184 tool_name: Name of the tool
186 Returns:
187 Tool-specific Lintro configuration
188 """
189 lintro_config = _get_lintro_section()
190 tool_config = lintro_config.get(tool_name, {})
191 return tool_config if isinstance(tool_config, dict) else {}
194def get_tool_order_config() -> dict[str, Any]:
195 """Get tool ordering configuration from [tool.lintro].
197 Returns:
198 Tool ordering configuration with keys:
199 - strategy: "priority", "alphabetical", or "custom"
200 - custom_order: list of tool names (for custom strategy)
201 - priority_overrides: dict of tool -> priority (for priority strategy)
202 """
203 global_config = load_lintro_global_config()
205 return {
206 "strategy": global_config.get("tool_order", "priority"),
207 "custom_order": global_config.get("tool_order_custom", []),
208 "priority_overrides": global_config.get("tool_priorities", {}),
209 }
212def load_post_checks_config() -> dict[str, Any]:
213 """Load post-checks configuration from pyproject.
215 Returns:
216 Dict with keys like:
217 - enabled: bool
218 - tools: list[str]
219 - enforce_failure: bool
220 """
221 cfg = _get_lintro_section()
222 section = cfg.get("post_checks", {})
223 if isinstance(section, dict):
224 return section
225 return {}
228# =============================================================================
229# Tool Configuration Loading (from pyproject.toml [tool.<tool>])
230# =============================================================================
233def load_tool_config_from_pyproject(tool_name: str) -> dict[str, Any]:
234 """Load tool-specific configuration from pyproject.toml [tool.<tool_name>].
236 Args:
237 tool_name: Name of the tool to load config for.
239 Returns:
240 dict[str, Any]: Tool configuration dictionary, or empty dict if not found.
241 """
242 pyproject_data = load_pyproject()
243 tool_section = pyproject_data.get("tool", {})
245 if tool_name in tool_section:
246 config = tool_section[tool_name]
247 if isinstance(config, dict):
248 return config
250 return {}
253def load_ruff_config() -> dict[str, Any]:
254 """Load ruff configuration from pyproject.toml with flattened lint settings.
256 Returns:
257 dict[str, Any]: Ruff configuration dictionary with flattened lint settings.
258 """
259 config = load_tool_config_from_pyproject("ruff")
261 # Flatten nested lint section to top level for easy access
262 if "lint" in config:
263 lint_config = config["lint"]
264 if isinstance(lint_config, dict):
265 if "select" in lint_config:
266 config["select"] = lint_config["select"]
267 if "ignore" in lint_config:
268 config["ignore"] = lint_config["ignore"]
269 if "extend-select" in lint_config:
270 config["extend_select"] = lint_config["extend-select"]
271 if "extend-ignore" in lint_config:
272 config["extend_ignore"] = lint_config["extend-ignore"]
274 return config
277def load_bandit_config() -> dict[str, Any]:
278 """Load bandit configuration from pyproject.toml.
280 Returns:
281 dict[str, Any]: Bandit configuration dictionary.
282 """
283 return load_tool_config_from_pyproject("bandit")
286def load_pydoclint_config() -> dict[str, Any]:
287 """Load pydoclint configuration from pyproject.toml.
289 Returns:
290 dict[str, Any]: Pydoclint configuration dictionary.
291 """
292 return load_tool_config_from_pyproject("pydoclint")
295def load_black_config() -> dict[str, Any]:
296 """Load black configuration from pyproject.toml.
298 Returns:
299 dict[str, Any]: Black configuration dictionary.
300 """
301 return load_tool_config_from_pyproject("black")
304def load_mypy_config(
305 base_dir: Path | None = None,
306) -> tuple[dict[str, Any], Path | None]:
307 """Load mypy configuration from pyproject.toml or mypy.ini files.
309 Args:
310 base_dir: Directory to search for mypy configuration files.
311 Defaults to the current working directory.
313 Returns:
314 tuple[dict[str, Any], Path | None]: Parsed configuration data and the
315 path to the config file if found.
316 """
317 root = base_dir or Path.cwd()
319 # Try pyproject.toml first
320 pyproject = root / "pyproject.toml"
321 if pyproject.exists():
322 try:
323 with pyproject.open("rb") as handle:
324 data = tomllib.load(handle)
325 pyproject_config = data.get("tool", {}).get("mypy", {}) or {}
326 if pyproject_config:
327 return pyproject_config, pyproject
328 except (OSError, tomllib.TOMLDecodeError, KeyError, TypeError) as e:
329 logger.warning(f"Failed to load mypy config from pyproject.toml: {e}")
331 # Fallback to mypy.ini or .mypy.ini
332 for config_file in ["mypy.ini", ".mypy.ini"]:
333 config_path = root / config_file
334 if config_path.exists():
335 try:
336 parser = configparser.ConfigParser()
337 parser.read(config_path)
338 if "mypy" in parser:
339 config_dict = dict(parser["mypy"])
340 return config_dict, config_path
341 except (OSError, configparser.Error) as e:
342 logger.warning(f"Failed to load mypy config from {config_file}: {e}")
344 return {}, None
347# =============================================================================
348# Backward Compatibility Functions
349# =============================================================================
352def get_central_line_length() -> int | None:
353 """Get the central line length configuration.
355 Backward-compatible wrapper that returns the effective line length
356 for Ruff (which serves as the source of truth).
358 Returns:
359 Line length value if configured, None otherwise.
360 """
361 # Import here to avoid circular import
362 from lintro.utils.unified_config import get_effective_line_length
364 return get_effective_line_length("ruff")
367def validate_line_length_consistency() -> list[str]:
368 """Validate line length consistency across tools.
370 Returns:
371 List of warning messages about inconsistencies.
372 """
373 # Import here to avoid circular import
374 from lintro.utils.unified_config import validate_config_consistency
376 return validate_config_consistency()