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

1"""Project configuration helpers for Lintro. 

2 

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. 

6 

7Reads configuration from `pyproject.toml` under the `[tool.lintro]` table. 

8Allows tool-specific defaults via `[tool.lintro.<tool>]` (e.g., `[tool.lintro.ruff]`). 

9""" 

10 

11from __future__ import annotations 

12 

13import configparser 

14import tomllib 

15from pathlib import Path 

16from typing import Any 

17 

18from loguru import logger 

19 

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] 

41 

42 

43# ============================================================================= 

44# Core pyproject.toml Loading 

45# ============================================================================= 

46 

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]] = {} 

52 

53 

54def clear_pyproject_cache() -> None: 

55 """Clear both pyproject path and data caches. 

56 

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() 

62 

63 

64def _find_pyproject(start_path: Path | None = None) -> Path | None: 

65 """Search for pyproject.toml up the directory tree. 

66 

67 Results are cached by resolved start path so that different working 

68 directories return the correct pyproject.toml. 

69 

70 Args: 

71 start_path: Optional starting path for search. 

72 Defaults to current working directory. 

73 

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 

89 

90 

91def load_pyproject() -> dict[str, Any]: 

92 """Load the full pyproject.toml with caching. 

93 

94 Results are cached by resolved pyproject path so that different 

95 working directories correctly load their own pyproject.toml. 

96 

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 {} 

118 

119 

120def load_pyproject_config() -> dict[str, Any]: 

121 """Load the entire pyproject.toml configuration. 

122 

123 Alias for load_pyproject() for backward compatibility. 

124 

125 Returns: 

126 dict[str, Any]: Complete pyproject.toml configuration, or empty dict if 

127 not found. 

128 """ 

129 return load_pyproject() 

130 

131 

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

133 """Extract the [tool.lintro] section from pyproject.toml. 

134 

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 {} 

143 

144 

145# ============================================================================= 

146# Lintro Configuration Loading 

147# ============================================================================= 

148 

149 

150def load_lintro_global_config() -> dict[str, Any]: 

151 """Load global Lintro configuration from [tool.lintro]. 

152 

153 Returns: 

154 Global configuration dictionary (excludes tool-specific sections) 

155 """ 

156 lintro_config = _get_lintro_section() 

157 

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 } 

176 

177 return {k: v for k, v in lintro_config.items() if k not in tool_sections} 

178 

179 

180def load_lintro_tool_config(tool_name: str) -> dict[str, Any]: 

181 """Load tool-specific Lintro config from [tool.lintro.<tool>]. 

182 

183 Args: 

184 tool_name: Name of the tool 

185 

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 {} 

192 

193 

194def get_tool_order_config() -> dict[str, Any]: 

195 """Get tool ordering configuration from [tool.lintro]. 

196 

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() 

204 

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 } 

210 

211 

212def load_post_checks_config() -> dict[str, Any]: 

213 """Load post-checks configuration from pyproject. 

214 

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 {} 

226 

227 

228# ============================================================================= 

229# Tool Configuration Loading (from pyproject.toml [tool.<tool>]) 

230# ============================================================================= 

231 

232 

233def load_tool_config_from_pyproject(tool_name: str) -> dict[str, Any]: 

234 """Load tool-specific configuration from pyproject.toml [tool.<tool_name>]. 

235 

236 Args: 

237 tool_name: Name of the tool to load config for. 

238 

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", {}) 

244 

245 if tool_name in tool_section: 

246 config = tool_section[tool_name] 

247 if isinstance(config, dict): 

248 return config 

249 

250 return {} 

251 

252 

253def load_ruff_config() -> dict[str, Any]: 

254 """Load ruff configuration from pyproject.toml with flattened lint settings. 

255 

256 Returns: 

257 dict[str, Any]: Ruff configuration dictionary with flattened lint settings. 

258 """ 

259 config = load_tool_config_from_pyproject("ruff") 

260 

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"] 

273 

274 return config 

275 

276 

277def load_bandit_config() -> dict[str, Any]: 

278 """Load bandit configuration from pyproject.toml. 

279 

280 Returns: 

281 dict[str, Any]: Bandit configuration dictionary. 

282 """ 

283 return load_tool_config_from_pyproject("bandit") 

284 

285 

286def load_pydoclint_config() -> dict[str, Any]: 

287 """Load pydoclint configuration from pyproject.toml. 

288 

289 Returns: 

290 dict[str, Any]: Pydoclint configuration dictionary. 

291 """ 

292 return load_tool_config_from_pyproject("pydoclint") 

293 

294 

295def load_black_config() -> dict[str, Any]: 

296 """Load black configuration from pyproject.toml. 

297 

298 Returns: 

299 dict[str, Any]: Black configuration dictionary. 

300 """ 

301 return load_tool_config_from_pyproject("black") 

302 

303 

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. 

308 

309 Args: 

310 base_dir: Directory to search for mypy configuration files. 

311 Defaults to the current working directory. 

312 

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() 

318 

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}") 

330 

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}") 

343 

344 return {}, None 

345 

346 

347# ============================================================================= 

348# Backward Compatibility Functions 

349# ============================================================================= 

350 

351 

352def get_central_line_length() -> int | None: 

353 """Get the central line length configuration. 

354 

355 Backward-compatible wrapper that returns the effective line length 

356 for Ruff (which serves as the source of truth). 

357 

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 

363 

364 return get_effective_line_length("ruff") 

365 

366 

367def validate_line_length_consistency() -> list[str]: 

368 """Validate line length consistency across tools. 

369 

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 

375 

376 return validate_config_consistency()