Coverage for tests / unit / tools / executor / test_tool_executor_more.py: 97%
146 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"""Additional tests for `lintro.utils.tool_executor` coverage.
3These tests focus on unhit branches in the simple executor:
4- `_get_tools_to_run` edge cases and validation
5- Main-loop error handling when resolving tools
6- Early post-checks filtering removing tools from the main phase
7- Post-checks behavior for unknown tool names
8- Output persistence error handling
9"""
11from __future__ import annotations
13import json
14from collections.abc import Callable
15from dataclasses import dataclass, field
16from typing import TYPE_CHECKING, Any, Never
18import pytest
19from assertpy import assert_that
21if TYPE_CHECKING:
22 pass
24import lintro.utils.tool_executor as te
25from lintro.models.core.tool_result import ToolResult
26from lintro.tools import tool_manager
27from lintro.utils.execution.tool_configuration import ToolsToRunResult
28from lintro.utils.output import OutputManager
29from lintro.utils.tool_executor import run_lint_tools_simple
32@dataclass
33class FakeToolDefinition:
34 """Fake ToolDefinition for testing."""
36 name: str
37 can_fix: bool = False
38 description: str = ""
39 file_patterns: list[str] = field(default_factory=list)
40 native_configs: list[str] = field(default_factory=list)
43def _stub_logger(monkeypatch: pytest.MonkeyPatch) -> None:
44 import lintro.utils.console as cl
46 class SilentLogger:
47 def __getattr__(
48 self,
49 name: str,
50 ) -> Callable[..., None]: # noqa: D401 - test stub
51 def _(*a: Any, **k: Any) -> None:
52 return None
54 return _
56 monkeypatch.setattr(cl, "create_logger", lambda *_a, **_k: SilentLogger())
59def test_get_tools_to_run_unknown_tool_raises(monkeypatch: pytest.MonkeyPatch) -> None:
60 """Unknown tool name should raise ValueError.
62 Args:
63 monkeypatch: Pytest fixture to modify objects during the test.
65 Raises:
66 AssertionError: If the expected ValueError is not raised.
67 """
68 from lintro.utils.execution import tool_configuration as tc
70 _stub_logger(monkeypatch)
72 # Use real function; only patch manager lookups to be harmless if called
73 monkeypatch.setattr(
74 tool_manager,
75 "get_check_tools",
76 lambda: {},
77 raising=True,
78 )
80 try:
81 _ = tc.get_tools_to_run(tools="notatool", action="check")
82 raise AssertionError("Expected ValueError for unknown tool")
83 except ValueError as e: # noqa: PT017
84 assert_that(str(e)).contains("Unknown tool")
87def test_get_tools_to_run_fmt_with_cannot_fix_raises(
88 monkeypatch: pytest.MonkeyPatch,
89) -> None:
90 """Selecting a non-fix tool for fmt should raise a validation error.
92 Args:
93 monkeypatch: Pytest fixture to modify objects during the test.
95 Raises:
96 AssertionError: If the expected ValueError is not raised.
97 """
98 from lintro.utils.execution import tool_configuration as tc
100 _stub_logger(monkeypatch)
102 class NoFixTool:
103 def __init__(self) -> None:
104 self._definition = FakeToolDefinition(name="bandit", can_fix=False)
106 @property
107 def definition(self) -> FakeToolDefinition:
108 return self._definition
110 @property
111 def can_fix(self) -> bool:
112 return self._definition.can_fix
114 def set_options(self, **kwargs: Any) -> None: # noqa: D401
115 return None
117 # Ensure we resolve a tool instance with can_fix False
118 monkeypatch.setattr(
119 tool_manager,
120 "get_tool",
121 lambda name: NoFixTool(),
122 raising=True,
123 )
125 # Directly call the helper
126 try:
127 _ = tc.get_tools_to_run(tools="bandit", action="fmt")
128 raise AssertionError("Expected ValueError for non-fix tool in fmt")
129 except ValueError as e: # noqa: PT017
130 assert_that(str(e)).contains("does not support formatting")
133def test_main_loop_get_tool_raises_appends_failure(
134 monkeypatch: pytest.MonkeyPatch,
135 capsys: pytest.CaptureFixture[str],
136) -> None:
137 """If a tool cannot be resolved, a failure result is appended and run continues.
139 Args:
140 monkeypatch: Pytest fixture to modify objects during the test.
141 capsys: Pytest fixture to capture stdout/stderr for assertions.
142 """
143 _stub_logger(monkeypatch)
145 ok = ToolResult(name="black", success=True, output="", issues_count=0)
147 def fake_get_tools(
148 _tools: str | None,
149 _action: str,
150 *,
151 ignore_conflicts: bool = False, # noqa: ARG001 — must match caller kwarg name
152 ) -> ToolsToRunResult:
153 return ToolsToRunResult(to_run=["ruff", "black"])
155 def fake_get_tool(name: str) -> object:
156 if name == "ruff":
157 raise RuntimeError("ruff not available")
158 return type(
159 "_T",
160 (),
161 { # simple stub
162 "name": "black",
163 "definition": FakeToolDefinition(name="black", can_fix=True),
164 "can_fix": True,
165 "set_options": lambda _self, **k: None,
166 "reset_options": lambda _self: None,
167 "check": lambda _self, paths, options=None: ok,
168 "fix": lambda _self, paths, options=None: ok,
169 "options": {},
170 },
171 )()
173 monkeypatch.setattr(te, "get_tools_to_run", fake_get_tools, raising=True)
174 monkeypatch.setattr(tool_manager, "get_tool", fake_get_tool, raising=True)
175 monkeypatch.setattr(
176 OutputManager,
177 "write_reports_from_results",
178 lambda self, results: None,
179 raising=True,
180 )
182 code = run_lint_tools_simple(
183 action="check",
184 paths=["."],
185 tools="all",
186 tool_options=None,
187 exclude=None,
188 include_venv=False,
189 group_by="auto",
190 output_format="json",
191 verbose=False,
192 raw_output=False,
193 )
194 out = capsys.readouterr().out
195 data = json.loads(out)
196 tool_names = [r.get("tool") for r in data.get("results", [])]
197 assert_that("ruff" in tool_names).is_true()
198 # Exit should be failure due to appended failure result
199 assert_that(code).is_equal_to(1)
202def test_write_reports_errors_are_swallowed(monkeypatch: pytest.MonkeyPatch) -> None:
203 """Errors while saving outputs should not crash or change exit semantics.
205 Args:
206 monkeypatch: Pytest fixture to modify objects during the test.
207 """
208 _stub_logger(monkeypatch)
210 ok = ToolResult(name="ruff", success=True, output="", issues_count=0)
212 def fake_get_tools(
213 _tools: str | None,
214 _action: str,
215 *,
216 ignore_conflicts: bool = False, # noqa: ARG001 — must match caller kwarg name
217 ) -> ToolsToRunResult:
218 return ToolsToRunResult(to_run=["ruff"])
220 ruff_tool = type(
221 "_T",
222 (),
223 {
224 "name": "ruff",
225 "definition": FakeToolDefinition(name="ruff", can_fix=True),
226 "can_fix": True,
227 "set_options": lambda _self, **k: None,
228 "reset_options": lambda _self: None,
229 "check": lambda _self, paths, options=None: ok,
230 "fix": lambda _self, paths, options=None: ok,
231 "options": {},
232 },
233 )()
235 monkeypatch.setattr(te, "get_tools_to_run", fake_get_tools, raising=True)
236 monkeypatch.setattr(tool_manager, "get_tool", lambda name: ruff_tool)
238 def boom(self: object, results: list[ToolResult]) -> Never:
239 raise OSError("disk full")
241 monkeypatch.setattr(
242 OutputManager,
243 "write_reports_from_results",
244 boom,
245 raising=True,
246 )
248 code = run_lint_tools_simple(
249 action="check",
250 paths=["."],
251 tools="all",
252 tool_options=None,
253 exclude=None,
254 include_venv=False,
255 group_by="auto",
256 output_format="grid",
257 verbose=False,
258 raw_output=False,
259 )
260 assert_that(code).is_equal_to(0)
263def test_unknown_post_check_tool_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
264 """Unknown post-check tool names should be warned and skipped gracefully.
266 Args:
267 monkeypatch: Pytest fixture to modify objects during the test.
268 """
269 _stub_logger(monkeypatch)
271 import lintro.utils.tool_executor as te
273 ok = ToolResult(name="ruff", success=True, output="", issues_count=0)
274 ruff_tool = type(
275 "_T",
276 (),
277 {
278 "name": "ruff",
279 "definition": FakeToolDefinition(name="ruff", can_fix=True),
280 "can_fix": True,
281 "set_options": lambda _self, **k: None,
282 "reset_options": lambda _self: None,
283 "check": lambda _self, paths, options=None: ok,
284 "fix": lambda _self, paths, options=None: ok,
285 "options": {},
286 },
287 )()
289 monkeypatch.setattr(
290 te,
291 "get_tools_to_run",
292 lambda _tools, _action, *, ignore_conflicts=False: ToolsToRunResult(
293 to_run=["ruff"],
294 ),
295 raising=True,
296 )
297 monkeypatch.setattr(tool_manager, "get_tool", lambda name: ruff_tool)
298 monkeypatch.setattr(
299 te,
300 "load_post_checks_config",
301 lambda: {"enabled": True, "tools": ["notatool"], "enforce_failure": False},
302 raising=True,
303 )
305 code = run_lint_tools_simple(
306 action="check",
307 paths=["."],
308 tools="all",
309 tool_options=None,
310 exclude=None,
311 include_venv=False,
312 group_by="auto",
313 output_format="grid",
314 verbose=False,
315 raw_output=False,
316 )
317 assert_that(code).is_equal_to(0)
320def test_post_checks_early_filter_removes_black_from_main(
321 monkeypatch: pytest.MonkeyPatch,
322) -> None:
323 """Black should be excluded from main phase when configured as post-check.
325 Args:
326 monkeypatch: Pytest fixture to modify objects during the test.
327 """
328 import lintro.utils.config as cfg
329 import lintro.utils.post_checks as pc
330 import lintro.utils.tool_executor as te
332 class LoggerCapture:
333 def __init__(self) -> None:
334 self.tools_list: list[str] | None = None
335 self.run_dir: str | None = None
337 def __getattr__(self, name: str) -> Callable[..., None]: # default no-ops
338 def _(*a: Any, **k: Any) -> None:
339 return None
341 return _
343 def print_lintro_header(self) -> None:
344 return None
346 def print_tool_header(self, tool_name: str, action: str) -> None:
347 # Capture tool names that get executed
348 if self.tools_list is None:
349 self.tools_list = []
350 self.tools_list.append(tool_name)
351 return None
353 logger = LoggerCapture()
354 from lintro.utils import console
356 monkeypatch.setattr(
357 console,
358 "create_logger",
359 lambda **k: logger,
360 raising=True,
361 )
363 # Tools initially include ruff and black
364 monkeypatch.setattr(
365 te,
366 "get_tools_to_run",
367 lambda _tools, _action, *, ignore_conflicts=False: ToolsToRunResult(
368 to_run=["ruff", "black"],
369 ),
370 raising=True,
371 )
373 def post_check_config():
374 return {"enabled": True, "tools": ["black"], "enforce_failure": True}
376 # Early config marks black as post-check
377 # Must patch in all modules that import load_post_checks_config
378 monkeypatch.setattr(cfg, "load_post_checks_config", post_check_config, raising=True)
379 monkeypatch.setattr(te, "load_post_checks_config", post_check_config, raising=True)
380 monkeypatch.setattr(pc, "load_post_checks_config", post_check_config, raising=True)
382 # Provide a no-op ruff tool
383 ok = ToolResult(name="ruff", success=True, output="", issues_count=0)
384 ruff_tool = type(
385 "_T",
386 (),
387 {
388 "name": "ruff",
389 "definition": FakeToolDefinition(name="ruff", can_fix=True),
390 "can_fix": True,
391 "set_options": lambda _self, **k: None,
392 "reset_options": lambda _self: None,
393 "check": lambda _self, paths, options=None: ok,
394 "fix": lambda _self, paths, options=None: ok,
395 "options": {},
396 },
397 )()
398 monkeypatch.setattr(
399 tool_manager,
400 "get_tool",
401 lambda name: ruff_tool,
402 raising=True,
403 )
404 monkeypatch.setattr(
405 OutputManager,
406 "write_reports_from_results",
407 lambda self, results: None,
408 raising=True,
409 )
411 # Mock execute_post_checks to not run any post-checks
412 # (we're only testing that black is filtered from the main phase)
413 def mock_execute_post_checks(**kwargs: Any) -> tuple[int, int, int]:
414 return (
415 kwargs.get("total_issues", 0),
416 kwargs.get("total_fixed", 0),
417 kwargs.get("total_remaining", 0),
418 )
420 monkeypatch.setattr(
421 te,
422 "execute_post_checks",
423 mock_execute_post_checks,
424 raising=True,
425 )
427 code = run_lint_tools_simple(
428 action="check",
429 paths=["."],
430 tools="all",
431 tool_options=None,
432 exclude=None,
433 include_venv=False,
434 group_by="auto",
435 output_format="grid",
436 verbose=False,
437 raw_output=False,
438 )
439 assert_that(code).is_equal_to(0)
440 # Ensure black is not in main-phase tool headers (only ruff should run)
441 assert_that(logger.tools_list).is_not_none()
442 assert_that(logger.tools_list).is_equal_to(["ruff"])
445def test_all_filtered_results_in_no_tools_warning(
446 monkeypatch: pytest.MonkeyPatch,
447) -> None:
448 """If filtering removes all tools, executor should return failure gracefully.
450 When all selected tools are configured as post-checks (filtered from main phase)
451 but post-checks don't actually produce results, the executor should return 1.
453 Args:
454 monkeypatch: Pytest fixture to modify objects during the test.
455 """
456 _stub_logger(monkeypatch)
458 import lintro.utils.tool_executor as te
460 # Mock config that filters out all tools to post-checks
461 mock_config = {"enabled": True, "tools": ["black"], "enforce_failure": True}
463 # Start with only black
464 monkeypatch.setattr(
465 te,
466 "get_tools_to_run",
467 lambda _tools, _action, *, ignore_conflicts=False: ToolsToRunResult(
468 to_run=["black"],
469 ),
470 raising=True,
471 )
472 # Early config filters out black
473 monkeypatch.setattr(te, "load_post_checks_config", lambda: mock_config)
474 # Mock execute_post_checks to do nothing (simulates post-checks not running)
475 # Returns (total_issues, total_fixed, total_remaining)
476 monkeypatch.setattr(
477 te,
478 "execute_post_checks",
479 lambda **kwargs: (0, 0, 0),
480 )
482 code = run_lint_tools_simple(
483 action="check",
484 paths=["."],
485 tools="all",
486 tool_options=None,
487 exclude=None,
488 include_venv=False,
489 group_by="auto",
490 output_format="grid",
491 verbose=False,
492 raw_output=False,
493 )
494 assert_that(code).is_equal_to(1)