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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Tool configuration generator for Lintro.
3This module provides CLI argument injection for enforced settings and
4default config generation for tools without native configs.
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"""
13from __future__ import annotations
15import atexit
16import json
17import os
18import tempfile
19from pathlib import Path
20from typing import Any
22from loguru import logger
24from lintro.config.lintro_config import LintroConfig
25from lintro.enums.config_format import ConfigFormat
26from lintro.enums.tool_name import ToolName
28try:
29 import yaml
30except ImportError:
31 yaml = None # type: ignore[assignment]
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}
48def _convert_python_version_for_mypy(version: str) -> str:
49 """Convert ``py313`` style strings to ``3.13`` for mypy.
51 Args:
52 version: Python version string, often ``py313`` format.
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
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}
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}
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}
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}
149# Track temporary files for cleanup
150_temp_files: list[Path] = []
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}")
164# Register cleanup on exit
165atexit.register(_cleanup_temp_files)
168def get_enforce_cli_args(
169 tool_name: str,
170 lintro_config: LintroConfig,
171) -> list[str]:
172 """Get CLI arguments for enforced settings.
174 These settings override native tool configs to ensure consistency
175 across different tools for shared concerns like line length.
177 Args:
178 tool_name: Name of the tool (e.g., "ruff", "black").
179 lintro_config: Lintro configuration.
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
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 )
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 )
213 return args
216def has_native_config(tool_name: str) -> bool:
217 """Check if a tool has a native config file in the project.
219 Searches for known native config file patterns starting from the
220 current working directory and moving upward to find the project root.
222 Args:
223 tool_name: Name of the tool (e.g., "markdownlint").
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, [])
231 if not patterns:
232 return False
234 # Search from current directory upward
235 current = Path.cwd().resolve()
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
259 # Move up one directory
260 parent = current.parent
261 if parent == current:
262 # Reached filesystem root
263 break
264 current = parent
266 return False
269def generate_defaults_config(
270 tool_name: str,
271 lintro_config: LintroConfig,
272) -> Path | None:
273 """Generate a temporary config file from defaults.
275 Only used when a tool has no native config file and defaults
276 are specified in the Lintro config.
278 Args:
279 tool_name: Name of the tool.
280 lintro_config: Lintro configuration.
282 Returns:
283 Path | None: Path to generated config file, or None if not needed.
284 """
285 tool_lower = tool_name.lower()
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
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}
301 # Get config format for this tool
302 config_format = TOOL_CONFIG_FORMATS.get(tool_lower, ConfigFormat.JSON)
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
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.
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.
328 Args:
329 defaults: Default configuration dictionary with lintro keys.
330 tool_name: Name of the tool.
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
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
344 if transformed != defaults:
345 logger.debug(
346 f"Transformed config keys for {tool_name}: "
347 f"{list(defaults.keys())} -> {list(transformed.keys())}",
348 )
350 return transformed
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.
360 Args:
361 defaults: Default configuration dictionary.
362 tool_name: Name of the tool.
363 config_format: Output format (json, yaml).
365 Returns:
366 Path: Path to temporary config file.
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 }
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")
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)
392 # Transform keys to native format before writing
393 native_defaults = _transform_keys_for_native_config(defaults, tool_lower)
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)
402 temp_path.write_text(content, encoding="utf-8")
403 logger.debug(f"Generated defaults config for {tool_name}: {temp_path}")
405 return temp_path
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.
414 Args:
415 tool_name: Name of the tool.
416 config_path: Path to defaults config file (or None).
418 Returns:
419 list[str]: CLI arguments to pass to the tool.
420 """
421 if config_path is None:
422 return []
424 tool_lower = tool_name.lower()
425 config_str = str(config_path)
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 }
438 return config_flags.get(tool_lower, [])
441def cleanup_temp_config(config_path: Path) -> None:
442 """Explicitly clean up a temporary config file.
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}")
457# =============================================================================