Coverage for lintro / utils / execution / tool_configuration.py: 94%
116 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 utilities for execution.
3This module provides functions for configuring tools before execution
4and determining which tools to run.
5"""
7from __future__ import annotations
9from dataclasses import dataclass, field
10from typing import TYPE_CHECKING
12from lintro.config.config_loader import get_config
13from lintro.enums.action import Action, normalize_action
14from lintro.enums.tool_name import ToolName
15from lintro.enums.tools_value import ToolsValue
16from lintro.tools import tool_manager
17from lintro.utils.unified_config import UnifiedConfigManager
19if TYPE_CHECKING:
20 from lintro.config.lintro_config import LintroConfig
21 from lintro.plugins.base import BaseToolPlugin
24@dataclass(frozen=True)
25class SkippedTool:
26 """A tool that was skipped during tool selection."""
28 name: str
29 reason: str
32@dataclass
33class ToolsToRunResult:
34 """Result of get_tools_to_run() with both active and skipped tools."""
36 to_run: list[str] = field(default_factory=list)
37 skipped: list[SkippedTool] = field(default_factory=list)
40def _apply_conflict_resolution(
41 to_run: list[str],
42 skipped: list[SkippedTool],
43 *,
44 ignore_conflicts: bool,
45) -> list[str]:
46 """Apply execution ordering and conflict resolution to a tool list.
48 Mutates *skipped* in place by appending tools removed during
49 conflict resolution.
51 Args:
52 to_run: Candidate tool names.
53 skipped: Accumulator for skipped tools (mutated in place).
54 ignore_conflicts: Whether to ignore tool conflicts.
56 Returns:
57 The ordered list of tools to run.
58 """
59 if not to_run:
60 return to_run
61 ordered = tool_manager.get_tool_execution_order(
62 to_run,
63 ignore_conflicts=ignore_conflicts,
64 )
65 removed = set(to_run) - set(ordered)
66 for name in sorted(removed):
67 skipped.append(
68 SkippedTool(name=name, reason="removed by conflict resolution"),
69 )
70 return ordered
73def _get_disabled_reason(config: LintroConfig, tool_name: str) -> str:
74 """Determine why a tool is disabled.
76 Args:
77 config: Lintro configuration.
78 tool_name: Name of the tool.
80 Returns:
81 Human-readable reason string.
82 """
83 tool_lower = tool_name.lower()
85 # Check if excluded by enabled_tools allowlist
86 if config.execution.enabled_tools:
87 enabled_lower = [t.lower() for t in config.execution.enabled_tools]
88 if tool_lower not in enabled_lower:
89 return "not in enabled_tools"
91 # Check tool-level enabled flag
92 tool_config = config.get_tool_config(tool_lower)
93 if not tool_config.enabled:
94 return "disabled in config"
96 return "disabled"
99def configure_tool_for_execution(
100 tool: BaseToolPlugin,
101 tool_name: str,
102 config_manager: UnifiedConfigManager,
103 tool_option_dict: dict[str, dict[str, object]],
104 exclude: str | None,
105 include_venv: bool,
106 incremental: bool,
107 action: Action,
108 post_tools: set[str],
109 auto_install: bool = False,
110 lintro_config: LintroConfig | None = None,
111) -> None:
112 """Configure a tool for execution.
114 Applies CLI overrides, unified config, and common options.
115 This eliminates duplication between parallel and sequential execution paths.
117 Args:
118 tool: The tool plugin instance to configure.
119 tool_name: Name of the tool.
120 config_manager: Unified config manager.
121 tool_option_dict: Parsed tool options from CLI.
122 exclude: Exclude patterns (comma-separated).
123 include_venv: Whether to include virtual environment directories.
124 incremental: Whether to only check changed files.
125 action: The action being performed (check/fix).
126 post_tools: Set of post-check tool names.
127 auto_install: Whether to auto-install Node.js deps if missing (global default).
128 lintro_config: Optional LintroConfig to reuse; fetched via get_config() if None.
129 """
130 # Reset accumulated state from prior runs (singleton instances)
131 tool.reset_options()
133 # Build CLI overrides from --tool-options
134 cli_overrides: dict[str, object] = {}
135 for option_key in get_tool_lookup_keys(tool_name):
136 overrides = tool_option_dict.get(option_key)
137 if overrides:
138 cli_overrides.update(overrides)
140 # Apply unified config with CLI overrides
141 config_manager.apply_config_to_tool(
142 tool=tool,
143 cli_overrides=cli_overrides if cli_overrides else None,
144 )
146 # Set common options
147 if exclude:
148 exclude_patterns = [p.strip() for p in exclude.split(",")]
149 tool.set_options(exclude_patterns=exclude_patterns)
151 tool.set_options(include_venv=include_venv)
153 # Set incremental mode if enabled
154 if incremental:
155 tool.set_options(incremental=True)
157 # Resolve per-tool auto_install: per-tool config > global effective > False
158 lintro_config = lintro_config or get_config()
159 tool_cfg = lintro_config.get_tool_config(tool_name)
160 if tool_cfg.auto_install is not None:
161 effective_tool_auto_install = tool_cfg.auto_install
162 else:
163 effective_tool_auto_install = auto_install
165 if effective_tool_auto_install:
166 tool.set_options(auto_install=True)
168 # Handle Black post-check coordination with Ruff
169 # If Black is configured as a post-check, avoid double formatting by
170 # disabling Ruff's formatting stages unless explicitly overridden.
171 if "black" in post_tools and tool_name == ToolName.RUFF.value:
172 tool_config = config_manager.get_tool_config(tool_name)
173 lintro_tool_cfg = tool_config.lintro_tool_config or {}
174 if action == Action.FIX:
175 if "format" not in cli_overrides and "format" not in lintro_tool_cfg:
176 tool.set_options(format=False)
177 else: # check
178 if (
179 "format_check" not in cli_overrides
180 and "format_check" not in lintro_tool_cfg
181 ):
182 tool.set_options(format_check=False)
185def get_tool_display_name(tool_name: str) -> str:
186 """Get the canonical display name for a tool.
188 Args:
189 tool_name: The tool name (case-insensitive).
191 Returns:
192 The canonical display name for the tool.
193 """
194 return tool_name.lower()
197def get_tool_lookup_keys(tool_name: str) -> set[str]:
198 """Get all possible lookup keys for a tool in tool_option_dict.
200 Args:
201 tool_name: The canonical display name for the tool.
203 Returns:
204 Set of lowercase keys to check in tool_option_dict.
205 """
206 return {tool_name.lower()}
209def get_tools_to_run(
210 tools: str | ToolsValue | None,
211 action: str | Action,
212 *,
213 ignore_conflicts: bool = False,
214) -> ToolsToRunResult:
215 """Get the list of tools to run based on the tools string and action.
217 Args:
218 tools: Comma-separated tool names, "all", or None.
219 action: "check", "fmt", or "test".
220 ignore_conflicts: If True, skip conflict checking between tools.
222 Returns:
223 ToolsToRunResult with tools to run and skipped tools with reasons.
225 Raises:
226 ValueError: If unknown tool names are provided.
227 """
228 action = normalize_action(action)
229 if action == Action.TEST:
230 # Test action only supports pytest
231 if tools and tools.lower() != "pytest":
232 raise ValueError(
233 (
234 "Only 'pytest' is supported for the test action; "
235 "run 'lintro test' without --tools or "
236 "use '--tools pytest'"
237 ),
238 )
239 # Use tool_manager to trigger discovery before checking registration
240 if not tool_manager.is_tool_registered("pytest"):
241 raise ValueError("pytest tool is not available")
242 # Respect enabled/disabled config for pytest
243 lintro_config = get_config()
244 if not lintro_config.is_tool_enabled("pytest"):
245 reason = _get_disabled_reason(lintro_config, "pytest")
246 return ToolsToRunResult(
247 skipped=[SkippedTool(name="pytest", reason=reason)],
248 )
249 return ToolsToRunResult(to_run=["pytest"])
251 # Get lintro config for enabled/disabled tool checking
252 lintro_config = get_config()
254 if (
255 tools is None
256 or tools == ToolsValue.ALL
257 or (isinstance(tools, str) and tools.lower() == "all")
258 ):
259 # Get all available tools for the action
260 if action == Action.FIX:
261 available_tools = tool_manager.get_fix_tools()
262 else: # check
263 available_tools = tool_manager.get_check_tools()
265 to_run: list[str] = []
266 skipped: list[SkippedTool] = []
267 for name in available_tools:
268 if name.lower() == "pytest":
269 continue
270 if not lintro_config.is_tool_enabled(name):
271 reason = _get_disabled_reason(lintro_config, name)
272 skipped.append(SkippedTool(name=name, reason=reason))
273 else:
274 to_run.append(name)
276 to_run = _apply_conflict_resolution(
277 to_run,
278 skipped,
279 ignore_conflicts=ignore_conflicts,
280 )
282 return ToolsToRunResult(to_run=to_run, skipped=skipped)
284 # Parse specific tools
285 tool_names: list[str] = [name.strip().lower() for name in tools.split(",")]
286 to_run = []
287 skipped = []
289 for name in tool_names:
290 # Reject pytest for check/fmt actions
291 if name == ToolName.PYTEST.value.lower():
292 raise ValueError(
293 "pytest tool is not available for check/fmt actions. "
294 "Use 'lintro test' instead.",
295 )
296 # Use tool_manager to trigger discovery before checking registration
297 if not tool_manager.is_tool_registered(name):
298 available_names = [
299 n for n in tool_manager.get_tool_names() if n.lower() != "pytest"
300 ]
301 raise ValueError(
302 f"Unknown tool '{name}'. Available tools: {available_names}",
303 )
304 # Track disabled tools with reason
305 if not lintro_config.is_tool_enabled(name):
306 reason = _get_disabled_reason(lintro_config, name)
307 skipped.append(SkippedTool(name=name, reason=reason))
308 continue
309 # Verify the tool supports the requested action
310 if action == Action.FIX:
311 tool_instance = tool_manager.get_tool(name)
312 if not tool_instance.definition.can_fix:
313 raise ValueError(
314 f"Tool '{name}' does not support formatting",
315 )
316 to_run.append(name)
318 to_run = _apply_conflict_resolution(
319 to_run,
320 skipped,
321 ignore_conflicts=ignore_conflicts,
322 )
324 return ToolsToRunResult(to_run=to_run, skipped=skipped)