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
« 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.
3This module provides a registry pattern for determining how to invoke
4external tools based on their runtime environment (Python, Node.js, Cargo, etc.).
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.)
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}
18 def get_command(
19 self,
20 tool_name: str,
21 tool_name_enum: ToolName | None,
22 ) -> list[str]:
23 return [tool_name]
24"""
26from __future__ import annotations
28import shutil
29import sys
30import sysconfig
31from abc import ABC, abstractmethod
32from typing import TYPE_CHECKING, ClassVar
34from loguru import logger
36from lintro.plugins.subprocess_executor import is_compiled_binary
38if TYPE_CHECKING:
39 from lintro.enums.tool_name import ToolName
42class CommandBuilder(ABC):
43 """Abstract base for language-specific command builders.
45 Subclasses implement language-specific logic for determining
46 how to invoke tools (e.g., via Python module, npx, cargo).
47 """
49 @abstractmethod
50 def can_handle(self, tool_name_enum: ToolName | None) -> bool:
51 """Check if this builder can handle the given tool.
53 Args:
54 tool_name_enum: Tool name enum, or None if unknown.
56 Returns:
57 True if this builder should handle the tool.
58 """
59 ...
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.
69 Args:
70 tool_name: String name of the tool.
71 tool_name_enum: Tool name enum, or None if unknown.
73 Returns:
74 Command list to execute the tool.
75 """
76 ...
79class CommandBuilderRegistry:
80 """Registry for command builders.
82 Builders are checked in registration order. First builder that
83 can_handle() the tool wins.
85 This is a class-level registry that accumulates builders as they
86 are registered via the @register_command_builder decorator.
87 """
89 _builders: list[CommandBuilder] = []
91 @classmethod
92 def register(cls, builder: CommandBuilder) -> None:
93 """Register a command builder.
95 Args:
96 builder: The command builder instance to register.
97 """
98 cls._builders.append(builder)
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.
108 Iterates through registered builders in order, returning the
109 command from the first builder that can handle the tool.
111 Args:
112 tool_name: String name of the tool.
113 tool_name_enum: Tool name enum, or None if unknown.
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)
122 # Fallback: just use the tool name directly
123 return [tool_name]
125 @classmethod
126 def clear(cls) -> None:
127 """Clear all registered builders (for testing)."""
128 cls._builders = []
130 @classmethod
131 def is_registered(cls, tool_name_enum: ToolName | None) -> bool:
132 """Check if any builder can handle the given tool.
134 Args:
135 tool_name_enum: Tool name enum to check.
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)
143def register_command_builder(cls: type[CommandBuilder]) -> type[CommandBuilder]:
144 """Decorator to register a command builder.
146 Args:
147 cls: The CommandBuilder subclass to register.
149 Returns:
150 The same class, unmodified.
151 """
152 CommandBuilderRegistry.register(cls())
153 return cls
156# -----------------------------------------------------------------------------
157# Helper Functions
158# -----------------------------------------------------------------------------
161def _is_compiled_binary() -> bool:
162 """Detect if running as a Nuitka-compiled binary.
164 When compiled with Nuitka, sys.executable points to the lintro binary
165 itself, not a Python interpreter.
167 Returns:
168 True if running as a compiled binary, False otherwise.
169 """
170 return is_compiled_binary()
173def resolve_venv_tool_command(tool_name: str) -> list[str] | None:
174 """Resolve a Python tool command when running inside a virtualenv.
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.
180 Args:
181 tool_name: Name of the tool binary (e.g., "ruff", "pytest").
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
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]
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]
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]
221 return [tool_name]
224# -----------------------------------------------------------------------------
225# Built-in Builders
226# -----------------------------------------------------------------------------
229@register_command_builder
230class PythonBundledBuilder(CommandBuilder):
231 """Builder for Python tools bundled with Lintro.
233 Handles: ruff, black, bandit, yamllint, mypy.
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 """
240 _tools: frozenset[ToolName] | None = None
242 @property
243 def tools(self) -> frozenset[ToolName]:
244 """Get the set of tools this builder handles.
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
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
263 def can_handle(self, tool_name_enum: ToolName | None) -> bool:
264 """Check if this builder handles the tool.
266 Args:
267 tool_name_enum: Tool name enum to check.
269 Returns:
270 True if tool is a Python bundled tool.
271 """
272 return tool_name_enum in self.tools
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.
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.).
287 Args:
288 tool_name: String name of the tool.
289 tool_name_enum: Tool name enum.
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]
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
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]
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]
325@register_command_builder
326class PytestBuilder(CommandBuilder):
327 """Builder for pytest (special case of Python tool).
329 Pytest is handled separately because it uses a different module
330 invocation pattern. Prefers PATH-based discovery like PythonBundledBuilder.
331 """
333 def can_handle(self, tool_name_enum: ToolName | None) -> bool:
334 """Check if this builder handles pytest.
336 Args:
337 tool_name_enum: Tool name enum to check.
339 Returns:
340 True if tool is pytest.
341 """
342 from lintro.enums.tool_name import ToolName
344 return tool_name_enum == ToolName.PYTEST
346 def get_command(
347 self,
348 tool_name: str,
349 tool_name_enum: ToolName | None,
350 ) -> list[str]:
351 """Get command for pytest.
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.).
360 Args:
361 tool_name: String name of the tool.
362 tool_name_enum: Tool name enum.
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"]
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
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]
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"]
398@register_command_builder
399class NodeJSBuilder(CommandBuilder):
400 """Builder for Node.js tools (Astro, Markdownlint, TypeScript, Vue-tsc).
402 Uses bunx to run Node.js tools when available, falling back to
403 direct tool invocation if bunx is not found.
404 """
406 _package_names: dict[ToolName, str] | None = None
407 _binary_names: dict[ToolName, str] | None = None
409 @property
410 def package_names(self) -> dict[ToolName, str]:
411 """Get mapping of tools to npm package names.
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
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
430 @property
431 def binary_names(self) -> dict[ToolName, str]:
432 """Get mapping of tools to executable binary names.
434 For most tools, the binary name matches the package name.
435 This mapping is only needed when they differ (e.g., typescript -> tsc).
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
443 self._binary_names = {
444 ToolName.TSC: "tsc", # Package is "typescript", binary is "tsc"
445 }
446 return self._binary_names
448 def can_handle(self, tool_name_enum: ToolName | None) -> bool:
449 """Check if this builder handles the tool.
451 Args:
452 tool_name_enum: Tool name enum to check.
454 Returns:
455 True if tool is a Node.js tool.
456 """
457 return tool_name_enum in self.package_names
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.
466 Args:
467 tool_name: String name of the tool.
468 tool_name_enum: Tool name enum.
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]
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 )
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]
490@register_command_builder
491class CargoBuilder(CommandBuilder):
492 """Builder for Cargo/Rust tools (Clippy, cargo-audit, cargo-deny).
494 Invokes Rust tools via cargo subcommands.
495 """
497 def can_handle(self, tool_name_enum: ToolName | None) -> bool:
498 """Check if this builder handles the tool.
500 Args:
501 tool_name_enum: Tool name enum to check.
503 Returns:
504 True if tool is a Cargo/Rust tool.
505 """
506 from lintro.enums.tool_name import ToolName
508 return tool_name_enum in {
509 ToolName.CLIPPY,
510 ToolName.CARGO_AUDIT,
511 ToolName.CARGO_DENY,
512 }
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.
521 Args:
522 tool_name: String name of the tool.
523 tool_name_enum: Tool name enum.
525 Returns:
526 Command list to execute the tool via cargo.
527 """
528 from lintro.enums.tool_name import ToolName
530 if tool_name_enum is None:
531 return ["cargo", "clippy"]
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]
543@register_command_builder
544class StandaloneBuilder(CommandBuilder):
545 """Builder for standalone binary tools.
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 """
552 _tools: frozenset[ToolName] | None = None
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 }
560 @property
561 def tools(self) -> frozenset[ToolName]:
562 """Get the set of tools this builder handles.
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
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
583 def can_handle(self, tool_name_enum: ToolName | None) -> bool:
584 """Check if this builder handles the tool.
586 Args:
587 tool_name_enum: Tool name enum to check.
589 Returns:
590 True if tool is a standalone binary.
591 """
592 return tool_name_enum in self.tools
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.
601 Args:
602 tool_name: String name of the tool.
603 tool_name_enum: Tool name enum.
605 Returns:
606 Command list containing the binary name.
607 """
608 return [self.TOOL_BINARY_MAP.get(tool_name, tool_name)]