Coverage for tests / unit / tools / core / test_tool_installer.py: 100%

249 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Unit tests for the ToolInstaller class.""" 

2 

3from __future__ import annotations 

4 

5import subprocess 

6from collections.abc import Callable 

7from unittest.mock import MagicMock, patch 

8 

9import pytest 

10from assertpy import assert_that 

11 

12from lintro.enums.install_context import InstallContext, PackageManager 

13from lintro.tools.core.install_context import RuntimeContext 

14from lintro.tools.core.tool_installer import InstallPlan, InstallResult, ToolInstaller 

15from lintro.tools.core.tool_registry import ManifestTool 

16 

17# --------------------------------------------------------------------------- 

18# Fixtures 

19# --------------------------------------------------------------------------- 

20 

21 

22@pytest.fixture() 

23def make_tool() -> Callable[..., ManifestTool]: 

24 """Return a factory that builds ManifestTool instances with sensible defaults. 

25 

26 Returns: 

27 A callable that accepts keyword overrides and produces ManifestTool. 

28 """ 

29 

30 def _factory(**overrides: object) -> ManifestTool: 

31 """Build a ManifestTool with defaults. 

32 

33 Args: 

34 **overrides: Fields to override on the dataclass. 

35 

36 Returns: 

37 A ManifestTool instance. 

38 """ 

39 defaults = { 

40 "name": "faketool", 

41 "version": "1.2.0", 

42 "install_type": "pip", 

43 "install_package": "faketool-pkg", 

44 "version_command": ("faketool", "--version"), 

45 } 

46 defaults.update(overrides) # type: ignore[arg-type] # test factory merges heterogeneous overrides 

47 return ManifestTool(**defaults) # type: ignore[arg-type] # test factory with dynamic kwargs 

48 

49 return _factory 

50 

51 

52@pytest.fixture() 

53def context() -> RuntimeContext: 

54 """Return a default RuntimeContext for testing. 

55 

56 Returns: 

57 A RuntimeContext with common defaults. 

58 """ 

59 from lintro.tools.core.install_strategies.environment import InstallEnvironment 

60 

61 return RuntimeContext( 

62 install_context=InstallContext.PIP, 

63 platform_label="Linux x86_64", 

64 environment=InstallEnvironment( 

65 install_context=InstallContext.PIP, 

66 available_managers=frozenset( 

67 { 

68 PackageManager.UV, 

69 PackageManager.PIP, 

70 PackageManager.NPM, 

71 PackageManager.CARGO, 

72 PackageManager.RUSTUP, 

73 }, 

74 ), 

75 ), 

76 is_ci=False, 

77 ) 

78 

79 

80@pytest.fixture() 

81def registry() -> MagicMock: 

82 """Return a mock ToolRegistry. 

83 

84 Returns: 

85 A MagicMock standing in for ToolRegistry. 

86 """ 

87 return MagicMock() 

88 

89 

90@pytest.fixture() 

91def installer(registry: MagicMock, context: RuntimeContext) -> ToolInstaller: 

92 """Return a ToolInstaller wired to mock registry and real context. 

93 

94 Args: 

95 registry: Mock ToolRegistry. 

96 context: RuntimeContext fixture. 

97 

98 Returns: 

99 A ToolInstaller instance. 

100 """ 

101 return ToolInstaller(registry, context) 

102 

103 

104# --------------------------------------------------------------------------- 

105# _is_manual_hint (static) 

106# --------------------------------------------------------------------------- 

107 

108 

109@pytest.mark.parametrize( 

110 ("hint", "expected"), 

111 [ 

112 ("See https://foo", True), 

113 ("Install hadolint manually", True), 

114 ("Upgrade hadolint manually", True), 

115 ("brew install foo", False), 

116 ("pip install foo>=1.0", False), 

117 ("pip install 'httpie>=3.0'", False), 

118 ("Download from http://example.com", True), 

119 ], 

120 ids=[ 

121 "see_url", 

122 "install_manually", 

123 "upgrade_manually", 

124 "brew_command", 

125 "pip_command", 

126 "pip_http_package", 

127 "download_url", 

128 ], 

129) 

130def test_is_manual_hint(hint: str, expected: bool) -> None: 

131 """Classify install hints as manual or executable. 

132 

133 Args: 

134 hint: The hint string to classify. 

135 expected: Whether it should be treated as manual. 

136 """ 

137 assert_that(ToolInstaller._is_manual_hint(hint)).is_equal_to(expected) 

138 

139 

140# --------------------------------------------------------------------------- 

141# _version_meets_minimum (static) 

142# --------------------------------------------------------------------------- 

143 

144 

145@pytest.mark.parametrize( 

146 ("installed", "minimum", "expected"), 

147 [ 

148 ("1.2.3", "1.0.0", True), 

149 ("1.0.0", "1.2.0", False), 

150 ("1.2.3", "1.2.3", True), 

151 ("invalid", "1.0.0", False), 

152 ], 

153 ids=[ 

154 "newer_than_minimum", 

155 "older_than_minimum", 

156 "equal_to_minimum", 

157 "invalid_version", 

158 ], 

159) 

160def test_version_meets_minimum( 

161 installed: str, 

162 minimum: str, 

163 expected: bool, 

164) -> None: 

165 """Compare installed version against a minimum requirement. 

166 

167 Args: 

168 installed: The installed version string. 

169 minimum: The required minimum version string. 

170 expected: Whether the installed version should meet the minimum. 

171 """ 

172 assert_that( 

173 ToolInstaller._version_meets_minimum(installed, minimum), 

174 ).is_equal_to(expected) 

175 

176 

177# --------------------------------------------------------------------------- 

178# _plan_tool 

179# --------------------------------------------------------------------------- 

180 

181 

182def test_plan_tool_already_ok( 

183 installer: ToolInstaller, 

184 make_tool: Callable[..., ManifestTool], 

185) -> None: 

186 """Place tool in already_ok when installed at the current version. 

187 

188 Args: 

189 installer: ToolInstaller fixture. 

190 make_tool: ManifestTool factory. 

191 """ 

192 tool = make_tool(version="1.2.0") 

193 plan = InstallPlan() 

194 

195 with patch.object(installer, "_get_installed_version", return_value="1.2.0"): 

196 installer._plan_tool(plan, tool, upgrade=False) 

197 

198 assert_that(plan.already_ok).contains(tool) 

199 assert_that(plan.to_install).is_empty() 

200 assert_that(plan.to_upgrade).is_empty() 

201 assert_that(plan.outdated).is_empty() 

202 assert_that(plan.skipped).is_empty() 

203 

204 

205def test_plan_tool_outdated_no_upgrade( 

206 installer: ToolInstaller, 

207 make_tool: Callable[..., ManifestTool], 

208) -> None: 

209 """Place tool in outdated when version is old and upgrade is False. 

210 

211 Args: 

212 installer: ToolInstaller fixture. 

213 make_tool: ManifestTool factory. 

214 """ 

215 tool = make_tool(version="2.0.0") 

216 plan = InstallPlan() 

217 

218 with patch.object(installer, "_get_installed_version", return_value="1.0.0"): 

219 installer._plan_tool(plan, tool, upgrade=False) 

220 

221 assert_that(plan.outdated).is_length(1) 

222 assert_that(plan.outdated[0][0]).is_equal_to(tool) 

223 assert_that(plan.outdated[0][1]).is_equal_to("1.0.0") 

224 assert_that(plan.to_upgrade).is_empty() 

225 

226 

227def test_plan_tool_outdated_with_upgrade( 

228 installer: ToolInstaller, 

229 make_tool: Callable[..., ManifestTool], 

230) -> None: 

231 """Place tool in to_upgrade when version is old and upgrade is True. 

232 

233 Args: 

234 installer: ToolInstaller fixture. 

235 make_tool: ManifestTool factory. 

236 """ 

237 tool = make_tool(version="2.0.0") 

238 plan = InstallPlan() 

239 

240 with ( 

241 patch.object(installer, "_get_installed_version", return_value="1.0.0"), 

242 patch.object( 

243 installer, 

244 "_get_install_command", 

245 return_value="pip install --upgrade faketool-pkg>=2.0.0", 

246 ), 

247 ): 

248 installer._plan_tool(plan, tool, upgrade=True) 

249 

250 assert_that(plan.to_upgrade).is_length(1) 

251 assert_that(plan.to_upgrade[0][0]).is_equal_to(tool) 

252 assert_that(plan.to_upgrade[0][1]).is_equal_to("1.0.0") 

253 assert_that(plan.outdated).is_empty() 

254 

255 

256def test_plan_tool_missing_installable( 

257 installer: ToolInstaller, 

258 make_tool: Callable[..., ManifestTool], 

259) -> None: 

260 """Place tool in to_install when not installed and hint is executable. 

261 

262 Args: 

263 installer: ToolInstaller fixture. 

264 make_tool: ManifestTool factory. 

265 """ 

266 tool = make_tool() 

267 plan = InstallPlan() 

268 

269 with ( 

270 patch.object(installer, "_get_installed_version", return_value=None), 

271 patch.object(installer, "_check_prerequisites", return_value=None), 

272 patch.object( 

273 installer, 

274 "_get_install_command", 

275 return_value="pip install faketool-pkg>=1.2.0", 

276 ), 

277 ): 

278 installer._plan_tool(plan, tool, upgrade=False) 

279 

280 assert_that(plan.to_install).is_length(1) 

281 assert_that(plan.to_install[0][0]).is_equal_to(tool) 

282 assert_that(plan.skipped).is_empty() 

283 

284 

285def test_plan_tool_missing_manual_hint( 

286 installer: ToolInstaller, 

287 make_tool: Callable[..., ManifestTool], 

288) -> None: 

289 """Place tool in skipped when not installed and hint is manual. 

290 

291 Args: 

292 installer: ToolInstaller fixture. 

293 make_tool: ManifestTool factory. 

294 """ 

295 tool = make_tool(install_type="binary") 

296 plan = InstallPlan() 

297 

298 with ( 

299 patch.object(installer, "_get_installed_version", return_value=None), 

300 patch.object(installer, "_check_prerequisites", return_value=None), 

301 patch.object( 

302 installer, 

303 "_get_install_command", 

304 return_value="See https://github.com/example/releases", 

305 ), 

306 patch.object(installer, "_has_install_script", return_value=False), 

307 ): 

308 installer._plan_tool(plan, tool, upgrade=False) 

309 

310 assert_that(plan.skipped).is_length(1) 

311 assert_that(plan.skipped[0][0]).is_equal_to(tool) 

312 assert_that(plan.to_install).is_empty() 

313 

314 

315def test_plan_tool_missing_with_install_script( 

316 installer: ToolInstaller, 

317 make_tool: Callable[..., ManifestTool], 

318) -> None: 

319 """Place binary tool in to_install with script hint when script exists. 

320 

321 Args: 

322 installer: ToolInstaller fixture. 

323 make_tool: ManifestTool factory. 

324 """ 

325 tool = make_tool(install_type="binary") 

326 plan = InstallPlan() 

327 

328 with ( 

329 patch.object(installer, "_get_installed_version", return_value=None), 

330 patch.object(installer, "_check_prerequisites", return_value=None), 

331 patch.object( 

332 installer, 

333 "_get_install_command", 

334 return_value="See https://github.com/example/releases", 

335 ), 

336 patch.object(installer, "_has_install_script", return_value=True), 

337 ): 

338 installer._plan_tool(plan, tool, upgrade=False) 

339 

340 assert_that(plan.to_install).is_length(1) 

341 assert_that(plan.to_install[0][0]).is_equal_to(tool) 

342 assert_that(plan.to_install[0][1]).contains("install-tools.sh") 

343 assert_that(plan.skipped).is_empty() 

344 

345 

346def test_plan_tool_upgrade_manual_hint( 

347 installer: ToolInstaller, 

348 make_tool: Callable[..., ManifestTool], 

349) -> None: 

350 """Place tool in skipped when upgrade hint is manual. 

351 

352 Args: 

353 installer: ToolInstaller fixture. 

354 make_tool: ManifestTool factory. 

355 """ 

356 tool = make_tool(version="2.0.0", install_type="binary") 

357 plan = InstallPlan() 

358 

359 with ( 

360 patch.object(installer, "_get_installed_version", return_value="1.0.0"), 

361 patch.object( 

362 installer, 

363 "_get_install_command", 

364 return_value="See https://github.com/example/releases", 

365 ), 

366 patch.object(installer, "_has_install_script", return_value=False), 

367 ): 

368 installer._plan_tool(plan, tool, upgrade=True) 

369 

370 assert_that(plan.skipped).is_length(1) 

371 assert_that(plan.skipped[0][0]).is_equal_to(tool) 

372 assert_that(plan.to_upgrade).is_empty() 

373 

374 

375def test_plan_tool_skipped_no_cargo( 

376 registry: MagicMock, 

377 make_tool: Callable[..., ManifestTool], 

378) -> None: 

379 """Skip cargo tool when cargo is not available. 

380 

381 Args: 

382 registry: Mock ToolRegistry. 

383 make_tool: ManifestTool factory. 

384 """ 

385 from lintro.tools.core.install_strategies.environment import InstallEnvironment 

386 

387 ctx = RuntimeContext( 

388 install_context=InstallContext.PIP, 

389 platform_label="Linux x86_64", 

390 environment=InstallEnvironment( 

391 install_context=InstallContext.PIP, 

392 available_managers=frozenset( 

393 { 

394 PackageManager.UV, 

395 PackageManager.PIP, 

396 PackageManager.NPM, 

397 PackageManager.RUSTUP, 

398 }, 

399 ), 

400 ), 

401 is_ci=False, 

402 ) 

403 inst = ToolInstaller(registry, ctx) 

404 tool = make_tool(install_type="cargo") 

405 plan = InstallPlan() 

406 

407 with patch.object(inst, "_get_installed_version", return_value=None): 

408 inst._plan_tool(plan, tool, upgrade=False) 

409 

410 assert_that(plan.skipped).is_length(1) 

411 assert_that(plan.skipped[0][1]).contains("cargo") 

412 

413 

414def test_plan_tool_skipped_no_npm( 

415 registry: MagicMock, 

416 make_tool: Callable[..., ManifestTool], 

417) -> None: 

418 """Skip npm tool when bun and npm are both unavailable. 

419 

420 Args: 

421 registry: Mock ToolRegistry. 

422 make_tool: ManifestTool factory. 

423 """ 

424 from lintro.tools.core.install_strategies.environment import InstallEnvironment 

425 

426 ctx = RuntimeContext( 

427 install_context=InstallContext.PIP, 

428 platform_label="Linux x86_64", 

429 environment=InstallEnvironment( 

430 install_context=InstallContext.PIP, 

431 available_managers=frozenset( 

432 { 

433 PackageManager.UV, 

434 PackageManager.PIP, 

435 PackageManager.CARGO, 

436 PackageManager.RUSTUP, 

437 }, 

438 ), 

439 ), 

440 is_ci=False, 

441 ) 

442 inst = ToolInstaller(registry, ctx) 

443 tool = make_tool(install_type="npm", name="eslint") 

444 plan = InstallPlan() 

445 

446 with patch.object(inst, "_get_installed_version", return_value=None): 

447 inst._plan_tool(plan, tool, upgrade=False) 

448 

449 assert_that(plan.skipped).is_length(1) 

450 assert_that(plan.skipped[0][1]).contains("npm") 

451 

452 

453# --------------------------------------------------------------------------- 

454# plan() 

455# --------------------------------------------------------------------------- 

456 

457 

458def test_plan_with_tools_list( 

459 installer: ToolInstaller, 

460 registry: MagicMock, 

461 make_tool: Callable[..., ManifestTool], 

462) -> None: 

463 """Plan resolves specific tool names via the registry. 

464 

465 Args: 

466 installer: ToolInstaller fixture. 

467 registry: Mock ToolRegistry. 

468 make_tool: ManifestTool factory. 

469 """ 

470 tool_a = make_tool(name="tool_a") 

471 tool_b = make_tool(name="tool_b") 

472 registry.__contains__ = MagicMock(side_effect=lambda n: n in {"tool_a", "tool_b"}) 

473 registry.get = MagicMock( 

474 side_effect=lambda n: {"tool_a": tool_a, "tool_b": tool_b}[n], 

475 ) 

476 

477 with patch.object(installer, "_plan_tool") as mock_plan_tool: 

478 installer.plan(tools=["tool_a", "tool_b"]) 

479 

480 assert_that(mock_plan_tool.call_count).is_equal_to(2) 

481 

482 

483def test_plan_deduplicates_tools( 

484 installer: ToolInstaller, 

485 registry: MagicMock, 

486 make_tool: Callable[..., ManifestTool], 

487) -> None: 

488 """Plan deduplicates tool names while preserving order. 

489 

490 Args: 

491 installer: ToolInstaller fixture. 

492 registry: Mock ToolRegistry. 

493 make_tool: ManifestTool factory. 

494 """ 

495 tool_a = make_tool(name="tool_a") 

496 registry.__contains__ = MagicMock(return_value=True) 

497 registry.get = MagicMock(return_value=tool_a) 

498 

499 with patch.object(installer, "_plan_tool") as mock_plan_tool: 

500 installer.plan(tools=["tool_a", "tool_a", "tool_a"]) 

501 

502 assert_that(mock_plan_tool.call_count).is_equal_to(1) 

503 

504 

505def test_plan_with_profile( 

506 installer: ToolInstaller, 

507 registry: MagicMock, 

508 make_tool: Callable[..., ManifestTool], 

509) -> None: 

510 """Plan resolves tools from a profile via registry.tools_for_profile. 

511 

512 Args: 

513 installer: ToolInstaller fixture. 

514 registry: Mock ToolRegistry. 

515 make_tool: ManifestTool factory. 

516 """ 

517 tool_a = make_tool(name="profiled_tool") 

518 registry.tools_for_profile = MagicMock(return_value=[tool_a]) 

519 

520 with patch.object(installer, "_plan_tool") as mock_plan_tool: 

521 installer.plan(profile="recommended") 

522 

523 registry.tools_for_profile.assert_called_once_with("recommended", None) 

524 assert_that(mock_plan_tool.call_count).is_equal_to(1) 

525 

526 

527def test_plan_with_upgrade_flag( 

528 installer: ToolInstaller, 

529 registry: MagicMock, 

530 make_tool: Callable[..., ManifestTool], 

531) -> None: 

532 """Plan passes the upgrade flag through to _plan_tool. 

533 

534 Args: 

535 installer: ToolInstaller fixture. 

536 registry: Mock ToolRegistry. 

537 make_tool: ManifestTool factory. 

538 """ 

539 tool_a = make_tool(name="upgradable") 

540 registry.__contains__ = MagicMock(return_value=True) 

541 registry.get = MagicMock(return_value=tool_a) 

542 

543 with patch.object(installer, "_plan_tool") as mock_plan_tool: 

544 installer.plan(tools=["upgradable"], upgrade=True) 

545 

546 call_kwargs = mock_plan_tool.call_args 

547 assert_that(call_kwargs.kwargs["upgrade"]).is_true() 

548 

549 

550# --------------------------------------------------------------------------- 

551# _check_prerequisites 

552# --------------------------------------------------------------------------- 

553 

554 

555def test_check_prerequisites_cargo_missing( 

556 registry: MagicMock, 

557 make_tool: Callable[..., ManifestTool], 

558) -> None: 

559 """Return skip reason when cargo is not available for a cargo tool. 

560 

561 Args: 

562 registry: Mock ToolRegistry. 

563 make_tool: ManifestTool factory. 

564 """ 

565 from lintro.tools.core.install_strategies.environment import InstallEnvironment 

566 

567 ctx = RuntimeContext( 

568 install_context=InstallContext.PIP, 

569 platform_label="Linux x86_64", 

570 environment=InstallEnvironment( 

571 install_context=InstallContext.PIP, 

572 available_managers=frozenset( 

573 { 

574 PackageManager.UV, 

575 PackageManager.PIP, 

576 PackageManager.NPM, 

577 PackageManager.RUSTUP, 

578 }, 

579 ), 

580 ), 

581 is_ci=False, 

582 ) 

583 inst = ToolInstaller(registry, ctx) 

584 tool = make_tool(install_type="cargo") 

585 

586 result = inst._check_prerequisites(tool) 

587 

588 assert_that(result).is_not_none() 

589 assert_that(result).contains("cargo") 

590 

591 

592def test_check_prerequisites_npm_missing( 

593 registry: MagicMock, 

594 make_tool: Callable[..., ManifestTool], 

595) -> None: 

596 """Return skip reason when npm and bun are both unavailable for npm tool. 

597 

598 Args: 

599 registry: Mock ToolRegistry. 

600 make_tool: ManifestTool factory. 

601 """ 

602 from lintro.tools.core.install_strategies.environment import InstallEnvironment 

603 

604 ctx = RuntimeContext( 

605 install_context=InstallContext.PIP, 

606 platform_label="Linux x86_64", 

607 environment=InstallEnvironment( 

608 install_context=InstallContext.PIP, 

609 available_managers=frozenset( 

610 { 

611 PackageManager.UV, 

612 PackageManager.PIP, 

613 PackageManager.CARGO, 

614 PackageManager.RUSTUP, 

615 }, 

616 ), 

617 ), 

618 is_ci=False, 

619 ) 

620 inst = ToolInstaller(registry, ctx) 

621 tool = make_tool(install_type="npm", name="eslint") 

622 

623 result = inst._check_prerequisites(tool) 

624 

625 assert_that(result).is_not_none() 

626 assert_that(result).contains("npm") 

627 

628 

629def test_check_prerequisites_pip_missing( 

630 registry: MagicMock, 

631 make_tool: Callable[..., ManifestTool], 

632) -> None: 

633 """Return skip reason when uv and pip are both unavailable for pip tool. 

634 

635 Args: 

636 registry: Mock ToolRegistry. 

637 make_tool: ManifestTool factory. 

638 """ 

639 from lintro.tools.core.install_strategies.environment import InstallEnvironment 

640 

641 ctx = RuntimeContext( 

642 install_context=InstallContext.PIP, 

643 platform_label="Linux x86_64", 

644 environment=InstallEnvironment( 

645 install_context=InstallContext.PIP, 

646 available_managers=frozenset( 

647 {PackageManager.NPM, PackageManager.CARGO, PackageManager.RUSTUP}, 

648 ), 

649 ), 

650 is_ci=False, 

651 ) 

652 inst = ToolInstaller(registry, ctx) 

653 tool = make_tool(install_type="pip", name="ruff") 

654 

655 result = inst._check_prerequisites(tool) 

656 

657 assert_that(result).is_not_none() 

658 assert_that(result).contains("uv/pip") 

659 

660 

661def test_check_prerequisites_all_met( 

662 installer: ToolInstaller, 

663 make_tool: Callable[..., ManifestTool], 

664) -> None: 

665 """Return None when all prerequisites are met. 

666 

667 Args: 

668 installer: ToolInstaller fixture. 

669 make_tool: ManifestTool factory. 

670 """ 

671 tool = make_tool(install_type="pip") 

672 

673 result = installer._check_prerequisites(tool) 

674 

675 assert_that(result).is_none() 

676 

677 

678# --------------------------------------------------------------------------- 

679# _get_install_command 

680# --------------------------------------------------------------------------- 

681 

682 

683def test_get_install_command_delegates_to_context( 

684 installer: ToolInstaller, 

685 make_tool: Callable[..., ManifestTool], 

686) -> None: 

687 """Generate an install command by delegating to context.install_hint_for_tool. 

688 

689 Args: 

690 installer: ToolInstaller fixture. 

691 make_tool: ManifestTool factory. 

692 """ 

693 tool = make_tool(install_type="pip", install_package="mypkg", version="1.0.0") 

694 

695 result = installer._get_install_command(tool) 

696 

697 # Context has_uv=True so prefix is "uv pip install" 

698 assert_that(result).contains("pip install") 

699 assert_that(result).contains("mypkg") 

700 

701 

702def test_get_install_command_upgrade_pip( 

703 installer: ToolInstaller, 

704 make_tool: Callable[..., ManifestTool], 

705) -> None: 

706 """Add --upgrade flag for pip tools when upgrade=True. 

707 

708 Args: 

709 installer: ToolInstaller fixture. 

710 make_tool: ManifestTool factory. 

711 """ 

712 tool = make_tool(install_type="pip", install_package="mypkg", version="2.0.0") 

713 

714 result = installer._get_install_command(tool, upgrade=True) 

715 

716 assert_that(result).contains("--upgrade") 

717 assert_that(result).contains("mypkg") 

718 

719 

720def test_get_install_command_upgrade_cargo( 

721 installer: ToolInstaller, 

722 make_tool: Callable[..., ManifestTool], 

723) -> None: 

724 """Add --force flag for cargo tools when upgrade=True. 

725 

726 Args: 

727 installer: ToolInstaller fixture. 

728 make_tool: ManifestTool factory. 

729 """ 

730 tool = make_tool(install_type="cargo", install_package="cargo-pkg") 

731 

732 result = installer._get_install_command(tool, upgrade=True) 

733 

734 assert_that(result).contains("--force") 

735 assert_that(result).contains("cargo install") 

736 

737 

738# --------------------------------------------------------------------------- 

739# execute() 

740# --------------------------------------------------------------------------- 

741 

742 

743def test_execute_runs_installs_and_upgrades( 

744 installer: ToolInstaller, 

745 make_tool: Callable[..., ManifestTool], 

746) -> None: 

747 """Execute runs _run_install for each to_install and to_upgrade entry. 

748 

749 Args: 

750 installer: ToolInstaller fixture. 

751 make_tool: ManifestTool factory. 

752 """ 

753 tool_a = make_tool(name="tool_a") 

754 tool_b = make_tool(name="tool_b") 

755 

756 plan = InstallPlan( 

757 to_install=[(tool_a, "pip install tool_a")], 

758 to_upgrade=[(tool_b, "1.0.0", "pip install --upgrade tool_b")], 

759 ) 

760 

761 fake_result = InstallResult( 

762 tool=tool_a, 

763 success=True, 

764 message="ok", 

765 duration_seconds=0.1, 

766 ) 

767 

768 with patch.object(installer, "_run_install", return_value=fake_result) as mock_run: 

769 results = installer.execute(plan) 

770 

771 assert_that(mock_run.call_count).is_equal_to(2) 

772 assert_that(results).is_length(2) 

773 

774 

775def test_execute_empty_plan(installer: ToolInstaller) -> None: 

776 """Return an empty list when the plan has no work. 

777 

778 Args: 

779 installer: ToolInstaller fixture. 

780 """ 

781 plan = InstallPlan() 

782 

783 results = installer.execute(plan) 

784 

785 assert_that(results).is_empty() 

786 

787 

788# --------------------------------------------------------------------------- 

789# _run_install 

790# --------------------------------------------------------------------------- 

791 

792 

793def test_run_install_success( 

794 installer: ToolInstaller, 

795 make_tool: Callable[..., ManifestTool], 

796) -> None: 

797 """Return a successful InstallResult when subprocess returns 0. 

798 

799 Args: 

800 installer: ToolInstaller fixture. 

801 make_tool: ManifestTool factory. 

802 """ 

803 tool = make_tool(install_type="pip") 

804 

805 with patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run: 

806 mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") 

807 result = installer._run_install(tool, "pip install faketool-pkg>=1.2.0") 

808 

809 assert_that(result.success).is_true() 

810 assert_that(result.message).contains("successfully") 

811 assert_that(result.duration_seconds).is_greater_than_or_equal_to(0.0) 

812 

813 

814def test_run_install_failure( 

815 installer: ToolInstaller, 

816 make_tool: Callable[..., ManifestTool], 

817) -> None: 

818 """Return a failed InstallResult when subprocess returns non-zero. 

819 

820 Args: 

821 installer: ToolInstaller fixture. 

822 make_tool: ManifestTool factory. 

823 """ 

824 tool = make_tool(install_type="pip") 

825 

826 with patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run: 

827 mock_run.return_value = MagicMock( 

828 returncode=1, 

829 stdout="", 

830 stderr="error: package not found", 

831 ) 

832 result = installer._run_install(tool, "pip install faketool-pkg>=1.2.0") 

833 

834 assert_that(result.success).is_false() 

835 assert_that(result.message).contains("failed") 

836 

837 

838def test_run_install_timeout( 

839 installer: ToolInstaller, 

840 make_tool: Callable[..., ManifestTool], 

841) -> None: 

842 """Return a timeout InstallResult when subprocess times out. 

843 

844 Args: 

845 installer: ToolInstaller fixture. 

846 make_tool: ManifestTool factory. 

847 """ 

848 tool = make_tool(install_type="pip") 

849 

850 with patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run: 

851 mock_run.side_effect = subprocess.TimeoutExpired(cmd="pip", timeout=300) 

852 result = installer._run_install(tool, "pip install faketool-pkg>=1.2.0") 

853 

854 assert_that(result.success).is_false() 

855 assert_that(result.message).contains("timed out") 

856 

857 

858def test_run_install_os_error( 

859 installer: ToolInstaller, 

860 make_tool: Callable[..., ManifestTool], 

861) -> None: 

862 """Return an OS error InstallResult when subprocess raises OSError. 

863 

864 Args: 

865 installer: ToolInstaller fixture. 

866 make_tool: ManifestTool factory. 

867 """ 

868 tool = make_tool(install_type="pip") 

869 

870 with patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run: 

871 mock_run.side_effect = OSError("No such file or directory") 

872 result = installer._run_install(tool, "pip install faketool-pkg>=1.2.0") 

873 

874 assert_that(result.success).is_false() 

875 assert_that(result.message).contains("OS error") 

876 

877 

878def test_run_install_manual_hint_rejected( 

879 installer: ToolInstaller, 

880 make_tool: Callable[..., ManifestTool], 

881) -> None: 

882 """Return failure when a manual hint slips through to _run_install. 

883 

884 Args: 

885 installer: ToolInstaller fixture. 

886 make_tool: ManifestTool factory. 

887 """ 

888 tool = make_tool(install_type="binary") 

889 

890 with patch.object(installer, "_install_via_script", return_value=None): 

891 result = installer._run_install( 

892 tool, 

893 "See https://github.com/example/releases", 

894 ) 

895 

896 assert_that(result.success).is_false() 

897 assert_that(result.message).contains("Manual install required") 

898 

899 

900# --------------------------------------------------------------------------- 

901# _is_brew_managed (static) 

902# --------------------------------------------------------------------------- 

903 

904 

905def test_is_brew_managed_true() -> None: 

906 """Return True when brew list returns exit code 0. 

907 

908 Simulates Homebrew managing the package. 

909 """ 

910 with ( 

911 patch( 

912 "lintro.tools.core.tool_installer.shutil.which", 

913 return_value="/usr/local/bin/brew", 

914 ), 

915 patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run, 

916 ): 

917 mock_run.return_value = MagicMock(returncode=0) 

918 result = ToolInstaller._is_brew_managed("faketool") 

919 

920 assert_that(result).is_true() 

921 

922 

923def test_is_brew_managed_false() -> None: 

924 """Return False when brew list returns non-zero exit code. 

925 

926 Simulates a package not managed by Homebrew. 

927 """ 

928 with ( 

929 patch( 

930 "lintro.tools.core.tool_installer.shutil.which", 

931 return_value="/usr/local/bin/brew", 

932 ), 

933 patch("lintro.tools.core.tool_installer.subprocess.run") as mock_run, 

934 ): 

935 mock_run.return_value = MagicMock(returncode=1) 

936 result = ToolInstaller._is_brew_managed("faketool") 

937 

938 assert_that(result).is_false() 

939 

940 

941def test_is_brew_managed_no_brew() -> None: 

942 """Return False when brew is not in PATH. 

943 

944 Simulates a system without Homebrew installed. 

945 """ 

946 with patch("lintro.tools.core.tool_installer.shutil.which", return_value=None): 

947 result = ToolInstaller._is_brew_managed("faketool") 

948 

949 assert_that(result).is_false()