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

1"""Additional tests for `lintro.utils.tool_executor` coverage. 

2 

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

10 

11from __future__ import annotations 

12 

13import json 

14from collections.abc import Callable 

15from dataclasses import dataclass, field 

16from typing import TYPE_CHECKING, Any, Never 

17 

18import pytest 

19from assertpy import assert_that 

20 

21if TYPE_CHECKING: 

22 pass 

23 

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 

30 

31 

32@dataclass 

33class FakeToolDefinition: 

34 """Fake ToolDefinition for testing.""" 

35 

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) 

41 

42 

43def _stub_logger(monkeypatch: pytest.MonkeyPatch) -> None: 

44 import lintro.utils.console as cl 

45 

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 

53 

54 return _ 

55 

56 monkeypatch.setattr(cl, "create_logger", lambda *_a, **_k: SilentLogger()) 

57 

58 

59def test_get_tools_to_run_unknown_tool_raises(monkeypatch: pytest.MonkeyPatch) -> None: 

60 """Unknown tool name should raise ValueError. 

61 

62 Args: 

63 monkeypatch: Pytest fixture to modify objects during the test. 

64 

65 Raises: 

66 AssertionError: If the expected ValueError is not raised. 

67 """ 

68 from lintro.utils.execution import tool_configuration as tc 

69 

70 _stub_logger(monkeypatch) 

71 

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 ) 

79 

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

85 

86 

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. 

91 

92 Args: 

93 monkeypatch: Pytest fixture to modify objects during the test. 

94 

95 Raises: 

96 AssertionError: If the expected ValueError is not raised. 

97 """ 

98 from lintro.utils.execution import tool_configuration as tc 

99 

100 _stub_logger(monkeypatch) 

101 

102 class NoFixTool: 

103 def __init__(self) -> None: 

104 self._definition = FakeToolDefinition(name="bandit", can_fix=False) 

105 

106 @property 

107 def definition(self) -> FakeToolDefinition: 

108 return self._definition 

109 

110 @property 

111 def can_fix(self) -> bool: 

112 return self._definition.can_fix 

113 

114 def set_options(self, **kwargs: Any) -> None: # noqa: D401 

115 return None 

116 

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 ) 

124 

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

131 

132 

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. 

138 

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) 

144 

145 ok = ToolResult(name="black", success=True, output="", issues_count=0) 

146 

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

154 

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

172 

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 ) 

181 

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) 

200 

201 

202def test_write_reports_errors_are_swallowed(monkeypatch: pytest.MonkeyPatch) -> None: 

203 """Errors while saving outputs should not crash or change exit semantics. 

204 

205 Args: 

206 monkeypatch: Pytest fixture to modify objects during the test. 

207 """ 

208 _stub_logger(monkeypatch) 

209 

210 ok = ToolResult(name="ruff", success=True, output="", issues_count=0) 

211 

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

219 

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

234 

235 monkeypatch.setattr(te, "get_tools_to_run", fake_get_tools, raising=True) 

236 monkeypatch.setattr(tool_manager, "get_tool", lambda name: ruff_tool) 

237 

238 def boom(self: object, results: list[ToolResult]) -> Never: 

239 raise OSError("disk full") 

240 

241 monkeypatch.setattr( 

242 OutputManager, 

243 "write_reports_from_results", 

244 boom, 

245 raising=True, 

246 ) 

247 

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) 

261 

262 

263def test_unknown_post_check_tool_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None: 

264 """Unknown post-check tool names should be warned and skipped gracefully. 

265 

266 Args: 

267 monkeypatch: Pytest fixture to modify objects during the test. 

268 """ 

269 _stub_logger(monkeypatch) 

270 

271 import lintro.utils.tool_executor as te 

272 

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

288 

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 ) 

304 

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) 

318 

319 

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. 

324 

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 

331 

332 class LoggerCapture: 

333 def __init__(self) -> None: 

334 self.tools_list: list[str] | None = None 

335 self.run_dir: str | None = None 

336 

337 def __getattr__(self, name: str) -> Callable[..., None]: # default no-ops 

338 def _(*a: Any, **k: Any) -> None: 

339 return None 

340 

341 return _ 

342 

343 def print_lintro_header(self) -> None: 

344 return None 

345 

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 

352 

353 logger = LoggerCapture() 

354 from lintro.utils import console 

355 

356 monkeypatch.setattr( 

357 console, 

358 "create_logger", 

359 lambda **k: logger, 

360 raising=True, 

361 ) 

362 

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 ) 

372 

373 def post_check_config(): 

374 return {"enabled": True, "tools": ["black"], "enforce_failure": True} 

375 

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) 

381 

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 ) 

410 

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 ) 

419 

420 monkeypatch.setattr( 

421 te, 

422 "execute_post_checks", 

423 mock_execute_post_checks, 

424 raising=True, 

425 ) 

426 

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

443 

444 

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. 

449 

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. 

452 

453 Args: 

454 monkeypatch: Pytest fixture to modify objects during the test. 

455 """ 

456 _stub_logger(monkeypatch) 

457 

458 import lintro.utils.tool_executor as te 

459 

460 # Mock config that filters out all tools to post-checks 

461 mock_config = {"enabled": True, "tools": ["black"], "enforce_failure": True} 

462 

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 ) 

481 

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)