Coverage for tests / unit / tools / executor / test_tool_executor.py: 99%
107 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"""Unit tests for main tool executor: success/failure and JSON outputs."""
3from __future__ import annotations
5import json
6from dataclasses import dataclass, field
7from pathlib import Path
8from typing import Any, Never
10import pytest
11from assertpy import assert_that
13import lintro.utils.tool_executor as te
14from lintro.models.core.tool_result import ToolResult
15from lintro.tools import tool_manager
16from lintro.utils.execution.tool_configuration import ToolsToRunResult
17from lintro.utils.output import OutputManager
18from lintro.utils.tool_executor import run_lint_tools_simple
21@dataclass
22class FakeToolDefinition:
23 """Fake ToolDefinition for testing."""
25 name: str
26 can_fix: bool = False
27 description: str = ""
28 file_patterns: list[str] = field(default_factory=list)
29 native_configs: list[str] = field(default_factory=list)
32class FakeTool:
33 """Simple tool stub returning a pre-baked ToolResult."""
35 def __init__(self, name: str, can_fix: bool, result: ToolResult) -> None:
36 """Initialize the fake tool.
38 Args:
39 name: Tool name.
40 can_fix: Whether fixes are supported.
41 result: Result object to return from check/fix.
42 """
43 self.name = name
44 self._definition = FakeToolDefinition(name=name, can_fix=can_fix)
45 self._result = result
46 self.options: dict[str, Any] = {}
48 @property
49 def definition(self) -> FakeToolDefinition:
50 """Return the tool definition.
52 Returns:
53 FakeToolDefinition containing tool metadata.
54 """
55 return self._definition
57 @property
58 def can_fix(self) -> bool:
59 """Return whether the tool can fix issues.
61 Returns:
62 True if the tool can fix issues.
63 """
64 return self._definition.can_fix
66 def reset_options(self) -> None:
67 """Reset options to defaults (stub for testing)."""
68 self.options = {}
70 def set_options(self, **kwargs: Any) -> None:
71 """Record option values provided to the tool stub.
73 Args:
74 **kwargs: Arbitrary options to store for assertions.
75 """
76 self.options.update(kwargs)
78 def check(
79 self,
80 paths: list[str],
81 options: dict[str, Any] | None = None,
82 ) -> ToolResult:
83 """Return the stored result for a check invocation.
85 Args:
86 paths: Target paths (ignored in stub).
87 options: Optional tool options.
89 Returns:
90 ToolResult: Pre-baked result instance.
91 """
92 return self._result
94 def fix(
95 self,
96 paths: list[str],
97 options: dict[str, Any] | None = None,
98 ) -> ToolResult:
99 """Return the stored result for a fix invocation.
101 Args:
102 paths: Target paths (ignored in stub).
103 options: Optional tool options.
105 Returns:
106 ToolResult: Pre-baked result instance.
107 """
108 return self._result
111def _setup_tool_manager(
112 monkeypatch: pytest.MonkeyPatch,
113 tools: dict[str, FakeTool],
114) -> None:
115 """Configure tool manager stubs to return provided tools.
117 Args:
118 monkeypatch: Pytest monkeypatch fixture.
119 tools: Mapping of tool name to FakeTool instance.
120 """
121 tools_dict = tools
123 def fake_get_tools(
124 tools: str | None,
125 action: str,
126 **_kwargs: object,
127 ) -> ToolsToRunResult:
128 return ToolsToRunResult(to_run=list(tools_dict.keys()))
130 monkeypatch.setattr(te, "get_tools_to_run", fake_get_tools, raising=True)
132 def fake_get_tool(name: str) -> FakeTool:
133 return tools_dict[name.lower()]
135 monkeypatch.setattr(tool_manager, "get_tool", fake_get_tool, raising=True)
137 def noop_write_reports_from_results(
138 self: Any,
139 results: list[ToolResult],
140 ) -> None:
141 return None
143 monkeypatch.setattr(
144 OutputManager,
145 "write_reports_from_results",
146 noop_write_reports_from_results,
147 raising=True,
148 )
151def _stub_logger(monkeypatch: pytest.MonkeyPatch, fake_logger: Any) -> None:
152 """Patch create_logger to return a FakeLogger instance.
154 Args:
155 monkeypatch: Pytest monkeypatch fixture.
156 fake_logger: FakeLogger fixture instance.
157 """
158 import lintro.utils.console as cl
160 monkeypatch.setattr(cl, "create_logger", lambda **k: fake_logger, raising=True)
163def test_executor_check_success(
164 monkeypatch: pytest.MonkeyPatch,
165 fake_logger: Any,
166) -> None:
167 """Exit with 0 when check succeeds and has zero issues.
169 Args:
170 monkeypatch: Pytest monkeypatch fixture.
171 fake_logger: FakeLogger fixture.
172 """
173 _stub_logger(monkeypatch, fake_logger)
174 result = ToolResult(name="ruff", success=True, output="", issues_count=0)
175 _setup_tool_manager(
176 monkeypatch,
177 {"ruff": FakeTool("ruff", can_fix=True, result=result)},
178 )
179 code = run_lint_tools_simple(
180 action="check",
181 paths=["."],
182 tools="all",
183 tool_options=None,
184 exclude=None,
185 include_venv=False,
186 group_by="auto",
187 output_format="grid",
188 verbose=False,
189 raw_output=False,
190 )
191 assert_that(code).is_equal_to(0)
194def test_executor_check_failure(
195 monkeypatch: pytest.MonkeyPatch,
196 fake_logger: Any,
197) -> None:
198 """Exit with 1 when check succeeds but issues are reported.
200 Args:
201 monkeypatch: Pytest monkeypatch fixture.
202 fake_logger: FakeLogger fixture.
203 """
204 _stub_logger(monkeypatch, fake_logger)
205 result = ToolResult(name="ruff", success=True, output="something", issues_count=2)
206 _setup_tool_manager(
207 monkeypatch,
208 {"ruff": FakeTool("ruff", can_fix=True, result=result)},
209 )
210 code = run_lint_tools_simple(
211 action="check",
212 paths=["."],
213 tools="all",
214 tool_options=None,
215 exclude=None,
216 include_venv=False,
217 group_by="file",
218 output_format="grid",
219 verbose=True,
220 raw_output=False,
221 )
222 assert_that(code).is_equal_to(1)
225def test_executor_fmt_success_with_counts(
226 monkeypatch: pytest.MonkeyPatch,
227 fake_logger: Any,
228) -> None:
229 """Exit with 0 when format succeeds and counts are populated.
231 Args:
232 monkeypatch: Pytest monkeypatch fixture.
233 fake_logger: Fake logger fixture.
234 """
235 _stub_logger(monkeypatch, fake_logger)
236 result = ToolResult(
237 name="prettier",
238 success=True,
239 output="Fixed 2 issue(s)\nFound 0 issue(s) that cannot be auto-fixed",
240 issues_count=0,
241 fixed_issues_count=2,
242 remaining_issues_count=0,
243 )
244 _setup_tool_manager(
245 monkeypatch,
246 {"prettier": FakeTool("prettier", can_fix=True, result=result)},
247 )
248 code = run_lint_tools_simple(
249 action="fmt",
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_executor_json_output(
264 monkeypatch: pytest.MonkeyPatch,
265 fake_logger: Any,
266 capsys: pytest.CaptureFixture[str],
267) -> None:
268 """Emit JSON output containing action and results when requested.
270 Args:
271 monkeypatch: Pytest monkeypatch fixture.
272 fake_logger: Fake logger fixture.
273 capsys: Pytest capture fixture for stdout.
274 """
275 _stub_logger(monkeypatch, fake_logger)
276 t1 = ToolResult(name="ruff", success=True, output="", issues_count=1)
277 t2 = ToolResult(
278 name="prettier",
279 success=True,
280 output="",
281 issues_count=0,
282 fixed_issues_count=1,
283 remaining_issues_count=0,
284 )
285 _setup_tool_manager(
286 monkeypatch,
287 {
288 "ruff": FakeTool("ruff", can_fix=True, result=t1),
289 "prettier": FakeTool("prettier", can_fix=True, result=t2),
290 },
291 )
292 code = run_lint_tools_simple(
293 action="check",
294 paths=["."],
295 tools="all",
296 tool_options=None,
297 exclude=None,
298 include_venv=False,
299 group_by="auto",
300 output_format="json",
301 verbose=False,
302 raw_output=False,
303 )
304 assert_that(code).is_equal_to(1)
305 out = capsys.readouterr().out
306 data = json.loads(out)
307 # JSON output includes results and summary sections
308 assert_that("results" in data and len(data["results"]) >= 2).is_true()
309 assert_that("summary" in data).is_true()
312def test_executor_handles_tool_failure_with_output(
313 monkeypatch: pytest.MonkeyPatch,
314 fake_logger: Any,
315 tmp_path: Path,
316) -> None:
317 """Return non-zero when a tool fails but emits output (coverage branch).
319 Args:
320 monkeypatch: pytest monkeypatch fixture
321 fake_logger: Fake logger fixture.
322 tmp_path: pytest tmp_path fixture
323 """
324 _stub_logger(monkeypatch, fake_logger)
325 failing = ToolResult(name="bandit", success=False, output="oops", issues_count=0)
326 _setup_tool_manager(
327 monkeypatch,
328 {"bandit": FakeTool("bandit", can_fix=False, result=failing)},
329 )
330 code = run_lint_tools_simple(
331 action="check",
332 paths=[str(tmp_path)],
333 tools="all",
334 tool_options=None,
335 exclude=None,
336 include_venv=False,
337 group_by="auto",
338 output_format="plain",
339 verbose=False,
340 raw_output=False,
341 )
342 assert_that(code).is_equal_to(1)
345def test_parse_tool_options_typed_values() -> None:
346 """Ensure --tool-options parsing coerces values into proper types.
348 Supported coercions:
349 - booleans (True/False)
350 - None/null
351 - integers/floats
352 - lists via pipe separation (E|F|W)
353 """
354 opt_str = (
355 "ruff:unsafe_fixes=True,ruff:line_length=88,ruff:target_version=py313,"
356 "ruff:select=E|F,prettier:verbose_fix_output=false,yamllint:strict=None,"
357 "ruff:ratio=0.5"
358 )
359 from lintro.utils.tool_options import parse_tool_options
361 parsed = parse_tool_options(opt_str)
362 assert_that(
363 isinstance(parsed["ruff"]["unsafe_fixes"], bool)
364 and parsed["ruff"]["unsafe_fixes"],
365 ).is_true()
366 assert_that(
367 isinstance(parsed["ruff"]["line_length"], int)
368 and parsed["ruff"]["line_length"] == 88,
369 ).is_true()
370 assert_that(parsed["ruff"]["target_version"]).is_equal_to("py313")
371 assert_that(parsed["ruff"]["select"]).is_equal_to(["E", "F"])
372 assert_that(parsed["prettier"]["verbose_fix_output"] is False).is_true()
373 assert_that(parsed["yamllint"]["strict"]).is_none()
374 assert_that(
375 isinstance(parsed["ruff"]["ratio"], float) and parsed["ruff"]["ratio"] == 0.5,
376 ).is_true()
379def test_executor_unknown_tool(
380 monkeypatch: pytest.MonkeyPatch,
381 fake_logger: Any,
382) -> None:
383 """Exit with 1 when an unknown tool is requested.
385 Args:
386 monkeypatch: Pytest monkeypatch fixture.
387 fake_logger: Fake logger fixture.
388 """
389 _stub_logger(monkeypatch, fake_logger)
391 def raise_value_error(_tools: str | None, _action: str, **_kwargs: object) -> Never:
392 raise ValueError("unknown tool")
394 monkeypatch.setattr(te, "get_tools_to_run", raise_value_error, raising=True)
395 code = run_lint_tools_simple(
396 action="check",
397 paths=["."],
398 tools="unknown",
399 tool_options=None,
400 exclude=None,
401 include_venv=False,
402 group_by="auto",
403 output_format="grid",
404 verbose=False,
405 raw_output=False,
406 )
407 assert_that(code).is_equal_to(1)