Coverage for lintro / tools / core / command_builders.py: 89%

163 statements  

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

1"""Command builder registry for language-specific tool execution. 

2 

3This module provides a registry pattern for determining how to invoke 

4external tools based on their runtime environment (Python, Node.js, Cargo, etc.). 

5 

6The registry pattern: 

7- Satisfies ISP (BaseToolPlugin doesn't know about any language) 

8- Satisfies OCP (add new languages without modifying existing code) 

9- Provides extensibility for future languages (Go, Ruby, etc.) 

10 

11Example: 

12 # Register a new language builder 

13 @register_command_builder 

14 class GoBuilder(CommandBuilder): 

15 def can_handle(self, tool_name_enum: ToolName | None) -> bool: 

16 return tool_name_enum in {ToolName.GOLINT, ToolName.STATICCHECK} 

17 

18 def get_command( 

19 self, 

20 tool_name: str, 

21 tool_name_enum: ToolName | None, 

22 ) -> list[str]: 

23 return [tool_name] 

24""" 

25 

26from __future__ import annotations 

27 

28import shutil 

29import sys 

30import sysconfig 

31from abc import ABC, abstractmethod 

32from typing import TYPE_CHECKING, ClassVar 

33 

34from loguru import logger 

35 

36from lintro.plugins.subprocess_executor import is_compiled_binary 

37 

38if TYPE_CHECKING: 

39 from lintro.enums.tool_name import ToolName 

40 

41 

42class CommandBuilder(ABC): 

43 """Abstract base for language-specific command builders. 

44 

45 Subclasses implement language-specific logic for determining 

46 how to invoke tools (e.g., via Python module, npx, cargo). 

47 """ 

48 

49 @abstractmethod 

50 def can_handle(self, tool_name_enum: ToolName | None) -> bool: 

51 """Check if this builder can handle the given tool. 

52 

53 Args: 

54 tool_name_enum: Tool name enum, or None if unknown. 

55 

56 Returns: 

57 True if this builder should handle the tool. 

58 """ 

59 ... 

60 

61 @abstractmethod 

62 def get_command( 

63 self, 

64 tool_name: str, 

65 tool_name_enum: ToolName | None, 

66 ) -> list[str]: 

67 """Get the command to execute the tool. 

68 

69 Args: 

70 tool_name: String name of the tool. 

71 tool_name_enum: Tool name enum, or None if unknown. 

72 

73 Returns: 

74 Command list to execute the tool. 

75 """ 

76 ... 

77 

78 

79class CommandBuilderRegistry: 

80 """Registry for command builders. 

81 

82 Builders are checked in registration order. First builder that 

83 can_handle() the tool wins. 

84 

85 This is a class-level registry that accumulates builders as they 

86 are registered via the @register_command_builder decorator. 

87 """ 

88 

89 _builders: list[CommandBuilder] = [] 

90 

91 @classmethod 

92 def register(cls, builder: CommandBuilder) -> None: 

93 """Register a command builder. 

94 

95 Args: 

96 builder: The command builder instance to register. 

97 """ 

98 cls._builders.append(builder) 

99 

100 @classmethod 

101 def get_command( 

102 cls, 

103 tool_name: str, 

104 tool_name_enum: ToolName | None, 

105 ) -> list[str]: 

106 """Get command for a tool using registered builders. 

107 

108 Iterates through registered builders in order, returning the 

109 command from the first builder that can handle the tool. 

110 

111 Args: 

112 tool_name: String name of the tool. 

113 tool_name_enum: Tool name enum, or None if unknown. 

114 

115 Returns: 

116 Command list, or [tool_name] as fallback. 

117 """ 

118 for builder in cls._builders: 

119 if builder.can_handle(tool_name_enum): 

120 return builder.get_command(tool_name, tool_name_enum) 

121 

122 # Fallback: just use the tool name directly 

123 return [tool_name] 

124 

125 @classmethod 

126 def clear(cls) -> None: 

127 """Clear all registered builders (for testing).""" 

128 cls._builders = [] 

129 

130 @classmethod 

131 def is_registered(cls, tool_name_enum: ToolName | None) -> bool: 

132 """Check if any builder can handle the given tool. 

133 

134 Args: 

135 tool_name_enum: Tool name enum to check. 

136 

137 Returns: 

138 True if a builder exists for this tool. 

139 """ 

140 return any(b.can_handle(tool_name_enum) for b in cls._builders) 

141 

142 

143def register_command_builder(cls: type[CommandBuilder]) -> type[CommandBuilder]: 

144 """Decorator to register a command builder. 

145 

146 Args: 

147 cls: The CommandBuilder subclass to register. 

148 

149 Returns: 

150 The same class, unmodified. 

151 """ 

152 CommandBuilderRegistry.register(cls()) 

153 return cls 

154 

155 

156# ----------------------------------------------------------------------------- 

157# Helper Functions 

158# ----------------------------------------------------------------------------- 

159 

160 

161def _is_compiled_binary() -> bool: 

162 """Detect if running as a Nuitka-compiled binary. 

163 

164 When compiled with Nuitka, sys.executable points to the lintro binary 

165 itself, not a Python interpreter. 

166 

167 Returns: 

168 True if running as a compiled binary, False otherwise. 

169 """ 

170 return is_compiled_binary() 

171 

172 

173def resolve_venv_tool_command(tool_name: str) -> list[str] | None: 

174 """Resolve a Python tool command when running inside a virtualenv. 

175 

176 Checks if the tool exists in the venv's scripts directory (via sysconfig) 

177 and returns the appropriate command. Used by both PythonBundledBuilder 

178 and PytestBuilder to avoid duplicated venv detection logic. 

179 

180 Args: 

181 tool_name: Name of the tool binary (e.g., "ruff", "pytest"). 

182 

183 Returns: 

184 Command list if in a venv and resolved, None if not in a venv. 

185 """ 

186 if sys.prefix == sys.base_prefix: 

187 return None # Not in a venv 

188 

189 scripts_dir = sysconfig.get_path("scripts") 

190 venv_tool = shutil.which(tool_name, path=scripts_dir) if scripts_dir else None 

191 if venv_tool: 

192 python_exe = sys.executable 

193 if python_exe: 

194 logger.debug( 

195 f"Running in venv ({sys.prefix}), " 

196 f"{tool_name} found in venv scripts, " 

197 f"using python -m {tool_name}", 

198 ) 

199 return [python_exe, "-m", tool_name] 

200 

201 # Tool not in venv — try PATH (e.g., separate Homebrew formula) 

202 tool_path = shutil.which(tool_name) 

203 if tool_path: 

204 logger.debug( 

205 f"Running in venv ({sys.prefix}), " 

206 f"{tool_name} not in venv scripts, " 

207 f"found in PATH: {tool_path}", 

208 ) 

209 return [tool_path] 

210 

211 # Last resort: try python -m anyway 

212 python_exe = sys.executable 

213 if python_exe: 

214 logger.debug( 

215 f"Running in venv ({sys.prefix}), " 

216 f"{tool_name} not in venv or PATH, " 

217 f"falling back to python -m {tool_name}", 

218 ) 

219 return [python_exe, "-m", tool_name] 

220 

221 return [tool_name] 

222 

223 

224# ----------------------------------------------------------------------------- 

225# Built-in Builders 

226# ----------------------------------------------------------------------------- 

227 

228 

229@register_command_builder 

230class PythonBundledBuilder(CommandBuilder): 

231 """Builder for Python tools bundled with Lintro. 

232 

233 Handles: ruff, black, bandit, yamllint, mypy. 

234 

235 Prefers PATH-based discovery to support various installation methods 

236 (Homebrew, system packages, pipx, uv tool). Falls back to Python module 

237 execution for pip installs where the binary isn't in PATH. 

238 """ 

239 

240 _tools: frozenset[ToolName] | None = None 

241 

242 @property 

243 def tools(self) -> frozenset[ToolName]: 

244 """Get the set of tools this builder handles. 

245 

246 Returns: 

247 Frozen set of ToolName enums for Python bundled tools. 

248 """ 

249 if self._tools is None: 

250 from lintro.enums.tool_name import ToolName 

251 

252 self._tools = frozenset( 

253 { 

254 ToolName.RUFF, 

255 ToolName.BLACK, 

256 ToolName.BANDIT, 

257 ToolName.YAMLLINT, 

258 ToolName.MYPY, 

259 }, 

260 ) 

261 return self._tools 

262 

263 def can_handle(self, tool_name_enum: ToolName | None) -> bool: 

264 """Check if this builder handles the tool. 

265 

266 Args: 

267 tool_name_enum: Tool name enum to check. 

268 

269 Returns: 

270 True if tool is a Python bundled tool. 

271 """ 

272 return tool_name_enum in self.tools 

273 

274 def get_command( 

275 self, 

276 tool_name: str, 

277 tool_name_enum: ToolName | None, 

278 ) -> list[str]: 

279 """Get command for Python bundled tool. 

280 

281 When running in a virtual environment, uses resolve_venv_tool_command 

282 which prefers python -m if the tool binary lives inside the venv, but 

283 falls back to a PATH-based binary when the tool is installed externally 

284 (e.g. via a separate Homebrew formula). Outside a venv, prefers PATH 

285 binary (works with Homebrew, system packages, pipx, uv tool, etc.). 

286 

287 Args: 

288 tool_name: String name of the tool. 

289 tool_name_enum: Tool name enum. 

290 

291 Returns: 

292 Command list to execute the tool. 

293 """ 

294 # Skip python -m fallback when compiled (sys.executable is the lintro binary) 

295 if _is_compiled_binary(): 

296 tool_path = shutil.which(tool_name) 

297 if tool_path: 

298 logger.debug(f"Found {tool_name} in PATH: {tool_path}") 

299 return [tool_path] 

300 logger.debug( 

301 f"Tool {tool_name} not in PATH and running as compiled binary, " 

302 "skipping python -m fallback", 

303 ) 

304 return [tool_name] 

305 

306 # When running in a venv, resolve using shared helper 

307 venv_cmd = resolve_venv_tool_command(tool_name) 

308 if venv_cmd is not None: 

309 return venv_cmd 

310 

311 # Outside venv: prefer PATH binary (Homebrew, apt, pipx, etc.) 

312 tool_path = shutil.which(tool_name) 

313 if tool_path: 

314 logger.debug(f"Found {tool_name} in PATH: {tool_path}") 

315 return [tool_path] 

316 

317 # Fallback to python -m for pip installs where binary isn't in PATH 

318 python_exe = sys.executable 

319 if python_exe: 

320 logger.debug(f"Tool {tool_name} not in PATH, using python -m") 

321 return [python_exe, "-m", tool_name] 

322 return [tool_name] 

323 

324 

325@register_command_builder 

326class PytestBuilder(CommandBuilder): 

327 """Builder for pytest (special case of Python tool). 

328 

329 Pytest is handled separately because it uses a different module 

330 invocation pattern. Prefers PATH-based discovery like PythonBundledBuilder. 

331 """ 

332 

333 def can_handle(self, tool_name_enum: ToolName | None) -> bool: 

334 """Check if this builder handles pytest. 

335 

336 Args: 

337 tool_name_enum: Tool name enum to check. 

338 

339 Returns: 

340 True if tool is pytest. 

341 """ 

342 from lintro.enums.tool_name import ToolName 

343 

344 return tool_name_enum == ToolName.PYTEST 

345 

346 def get_command( 

347 self, 

348 tool_name: str, 

349 tool_name_enum: ToolName | None, 

350 ) -> list[str]: 

351 """Get command for pytest. 

352 

353 When running in a virtual environment, uses resolve_venv_tool_command 

354 which prefers python -m pytest if the pytest binary lives inside the 

355 venv, but falls back to a PATH-based binary when pytest is installed 

356 externally (e.g. via a separate Homebrew formula). Outside a venv, 

357 prefers PATH binary (works with Homebrew, system packages, pipx, 

358 uv tool, etc.). 

359 

360 Args: 

361 tool_name: String name of the tool. 

362 tool_name_enum: Tool name enum. 

363 

364 Returns: 

365 Command list to execute pytest. 

366 """ 

367 # Skip python -m fallback when compiled (sys.executable is the lintro binary) 

368 if _is_compiled_binary(): 

369 tool_path = shutil.which("pytest") 

370 if tool_path: 

371 logger.debug(f"Found pytest in PATH: {tool_path}") 

372 return [tool_path] 

373 logger.debug( 

374 "pytest not in PATH and running as compiled binary, " 

375 "skipping python -m fallback", 

376 ) 

377 return ["pytest"] 

378 

379 # When running in a venv, resolve using shared helper 

380 venv_cmd = resolve_venv_tool_command("pytest") 

381 if venv_cmd is not None: 

382 return venv_cmd 

383 

384 # Outside venv: prefer PATH binary (Homebrew, apt, pipx, etc.) 

385 tool_path = shutil.which("pytest") 

386 if tool_path: 

387 logger.debug(f"Found pytest in PATH: {tool_path}") 

388 return [tool_path] 

389 

390 # Fallback to python -m for pip installs where binary isn't in PATH 

391 python_exe = sys.executable 

392 if python_exe: 

393 logger.debug("pytest not in PATH, using python -m pytest") 

394 return [python_exe, "-m", "pytest"] 

395 return ["pytest"] 

396 

397 

398@register_command_builder 

399class NodeJSBuilder(CommandBuilder): 

400 """Builder for Node.js tools (Astro, Markdownlint, TypeScript, Vue-tsc). 

401 

402 Uses bunx to run Node.js tools when available, falling back to 

403 direct tool invocation if bunx is not found. 

404 """ 

405 

406 _package_names: dict[ToolName, str] | None = None 

407 _binary_names: dict[ToolName, str] | None = None 

408 

409 @property 

410 def package_names(self) -> dict[ToolName, str]: 

411 """Get mapping of tools to npm package names. 

412 

413 Returns: 

414 Dictionary mapping ToolName to npm package name. 

415 """ 

416 if self._package_names is None: 

417 from lintro.enums.tool_name import ToolName 

418 

419 self._package_names = { 

420 ToolName.ASTRO_CHECK: "astro", 

421 ToolName.MARKDOWNLINT: "markdownlint-cli2", 

422 ToolName.OXFMT: "oxfmt", 

423 ToolName.OXLINT: "oxlint", 

424 ToolName.SVELTE_CHECK: "svelte-check", 

425 ToolName.TSC: "typescript", 

426 ToolName.VUE_TSC: "vue-tsc", 

427 } 

428 return self._package_names 

429 

430 @property 

431 def binary_names(self) -> dict[ToolName, str]: 

432 """Get mapping of tools to executable binary names. 

433 

434 For most tools, the binary name matches the package name. 

435 This mapping is only needed when they differ (e.g., typescript -> tsc). 

436 

437 Returns: 

438 Dictionary mapping ToolName to binary name. 

439 """ 

440 if self._binary_names is None: 

441 from lintro.enums.tool_name import ToolName 

442 

443 self._binary_names = { 

444 ToolName.TSC: "tsc", # Package is "typescript", binary is "tsc" 

445 } 

446 return self._binary_names 

447 

448 def can_handle(self, tool_name_enum: ToolName | None) -> bool: 

449 """Check if this builder handles the tool. 

450 

451 Args: 

452 tool_name_enum: Tool name enum to check. 

453 

454 Returns: 

455 True if tool is a Node.js tool. 

456 """ 

457 return tool_name_enum in self.package_names 

458 

459 def get_command( 

460 self, 

461 tool_name: str, 

462 tool_name_enum: ToolName | None, 

463 ) -> list[str]: 

464 """Get command for Node.js tool. 

465 

466 Args: 

467 tool_name: String name of the tool. 

468 tool_name_enum: Tool name enum. 

469 

470 Returns: 

471 Command list to execute the tool via bunx or directly. 

472 """ 

473 if tool_name_enum is None: 

474 return [tool_name] 

475 

476 # Get binary name (falls back to package name if not specified) 

477 binary_name = self.binary_names.get( 

478 tool_name_enum, 

479 self.package_names.get(tool_name_enum, tool_name), 

480 ) 

481 

482 # Prefer bunx (bun), fall back to npx (npm), then direct tool invocation 

483 if shutil.which("bunx"): 

484 return ["bunx", binary_name] 

485 if shutil.which("npx"): 

486 return ["npx", binary_name] 

487 return [binary_name] 

488 

489 

490@register_command_builder 

491class CargoBuilder(CommandBuilder): 

492 """Builder for Cargo/Rust tools (Clippy, cargo-audit, cargo-deny). 

493 

494 Invokes Rust tools via cargo subcommands. 

495 """ 

496 

497 def can_handle(self, tool_name_enum: ToolName | None) -> bool: 

498 """Check if this builder handles the tool. 

499 

500 Args: 

501 tool_name_enum: Tool name enum to check. 

502 

503 Returns: 

504 True if tool is a Cargo/Rust tool. 

505 """ 

506 from lintro.enums.tool_name import ToolName 

507 

508 return tool_name_enum in { 

509 ToolName.CLIPPY, 

510 ToolName.CARGO_AUDIT, 

511 ToolName.CARGO_DENY, 

512 } 

513 

514 def get_command( 

515 self, 

516 tool_name: str, 

517 tool_name_enum: ToolName | None, 

518 ) -> list[str]: 

519 """Get command for Cargo tool. 

520 

521 Args: 

522 tool_name: String name of the tool. 

523 tool_name_enum: Tool name enum. 

524 

525 Returns: 

526 Command list to execute the tool via cargo. 

527 """ 

528 from lintro.enums.tool_name import ToolName 

529 

530 if tool_name_enum is None: 

531 return ["cargo", "clippy"] 

532 

533 # Mapping of cargo tools to their subcommands for extensibility 

534 cargo_subcommands: dict[ToolName, str] = { 

535 ToolName.CARGO_AUDIT: "audit", 

536 ToolName.CARGO_DENY: "deny", 

537 ToolName.CLIPPY: "clippy", 

538 } 

539 subcommand = cargo_subcommands.get(tool_name_enum, "clippy") 

540 return ["cargo", subcommand] 

541 

542 

543@register_command_builder 

544class StandaloneBuilder(CommandBuilder): 

545 """Builder for standalone binary tools. 

546 

547 These tools are invoked directly by name without any wrapper. 

548 Uses an explicit mapping for tools whose binary name differs 

549 from their internal tool name. 

550 """ 

551 

552 _tools: frozenset[ToolName] | None = None 

553 

554 # Explicit mapping from internal tool name to binary name. 

555 # Only tools whose binary name differs need an entry here. 

556 TOOL_BINARY_MAP: ClassVar[dict[str, str]] = { 

557 "osv_scanner": "osv-scanner", 

558 } 

559 

560 @property 

561 def tools(self) -> frozenset[ToolName]: 

562 """Get the set of tools this builder handles. 

563 

564 Returns: 

565 Frozen set of ToolName enums for standalone tools. 

566 """ 

567 if self._tools is None: 

568 from lintro.enums.tool_name import ToolName 

569 

570 self._tools = frozenset( 

571 { 

572 ToolName.ACTIONLINT, 

573 ToolName.GITLEAKS, 

574 ToolName.HADOLINT, 

575 ToolName.OSV_SCANNER, 

576 ToolName.SHELLCHECK, 

577 ToolName.SHFMT, 

578 ToolName.SEMGREP, 

579 }, 

580 ) 

581 return self._tools 

582 

583 def can_handle(self, tool_name_enum: ToolName | None) -> bool: 

584 """Check if this builder handles the tool. 

585 

586 Args: 

587 tool_name_enum: Tool name enum to check. 

588 

589 Returns: 

590 True if tool is a standalone binary. 

591 """ 

592 return tool_name_enum in self.tools 

593 

594 def get_command( 

595 self, 

596 tool_name: str, 

597 tool_name_enum: ToolName | None, 

598 ) -> list[str]: 

599 """Get command for standalone tool. 

600 

601 Args: 

602 tool_name: String name of the tool. 

603 tool_name_enum: Tool name enum. 

604 

605 Returns: 

606 Command list containing the binary name. 

607 """ 

608 return [self.TOOL_BINARY_MAP.get(tool_name, tool_name)]