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

1"""Tool configuration utilities for execution. 

2 

3This module provides functions for configuring tools before execution 

4and determining which tools to run. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass, field 

10from typing import TYPE_CHECKING 

11 

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 

18 

19if TYPE_CHECKING: 

20 from lintro.config.lintro_config import LintroConfig 

21 from lintro.plugins.base import BaseToolPlugin 

22 

23 

24@dataclass(frozen=True) 

25class SkippedTool: 

26 """A tool that was skipped during tool selection.""" 

27 

28 name: str 

29 reason: str 

30 

31 

32@dataclass 

33class ToolsToRunResult: 

34 """Result of get_tools_to_run() with both active and skipped tools.""" 

35 

36 to_run: list[str] = field(default_factory=list) 

37 skipped: list[SkippedTool] = field(default_factory=list) 

38 

39 

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. 

47 

48 Mutates *skipped* in place by appending tools removed during 

49 conflict resolution. 

50 

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. 

55 

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 

71 

72 

73def _get_disabled_reason(config: LintroConfig, tool_name: str) -> str: 

74 """Determine why a tool is disabled. 

75 

76 Args: 

77 config: Lintro configuration. 

78 tool_name: Name of the tool. 

79 

80 Returns: 

81 Human-readable reason string. 

82 """ 

83 tool_lower = tool_name.lower() 

84 

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" 

90 

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" 

95 

96 return "disabled" 

97 

98 

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. 

113 

114 Applies CLI overrides, unified config, and common options. 

115 This eliminates duplication between parallel and sequential execution paths. 

116 

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

132 

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) 

139 

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 ) 

145 

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) 

150 

151 tool.set_options(include_venv=include_venv) 

152 

153 # Set incremental mode if enabled 

154 if incremental: 

155 tool.set_options(incremental=True) 

156 

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 

164 

165 if effective_tool_auto_install: 

166 tool.set_options(auto_install=True) 

167 

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) 

183 

184 

185def get_tool_display_name(tool_name: str) -> str: 

186 """Get the canonical display name for a tool. 

187 

188 Args: 

189 tool_name: The tool name (case-insensitive). 

190 

191 Returns: 

192 The canonical display name for the tool. 

193 """ 

194 return tool_name.lower() 

195 

196 

197def get_tool_lookup_keys(tool_name: str) -> set[str]: 

198 """Get all possible lookup keys for a tool in tool_option_dict. 

199 

200 Args: 

201 tool_name: The canonical display name for the tool. 

202 

203 Returns: 

204 Set of lowercase keys to check in tool_option_dict. 

205 """ 

206 return {tool_name.lower()} 

207 

208 

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. 

216 

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. 

221 

222 Returns: 

223 ToolsToRunResult with tools to run and skipped tools with reasons. 

224 

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

250 

251 # Get lintro config for enabled/disabled tool checking 

252 lintro_config = get_config() 

253 

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

264 

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) 

275 

276 to_run = _apply_conflict_resolution( 

277 to_run, 

278 skipped, 

279 ignore_conflicts=ignore_conflicts, 

280 ) 

281 

282 return ToolsToRunResult(to_run=to_run, skipped=skipped) 

283 

284 # Parse specific tools 

285 tool_names: list[str] = [name.strip().lower() for name in tools.split(",")] 

286 to_run = [] 

287 skipped = [] 

288 

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) 

317 

318 to_run = _apply_conflict_resolution( 

319 to_run, 

320 skipped, 

321 ignore_conflicts=ignore_conflicts, 

322 ) 

323 

324 return ToolsToRunResult(to_run=to_run, skipped=skipped)