"""Core logic for detecting development tools and reading their versions. DevCheck never installs, uninstalls, modifies, or updates anything. Every function in this module is strictly read-only: it looks for executables on the system PATH and, if found, runs a harmless "--version"-style command. """ import shutil import subprocess from typing import List, Optional, Tuple from config import COMMAND_TIMEOUT_SECONDS, TOOL_DEFINITIONS from tool import ToolResult, ToolStatus from version_parser import clean_output, extract_version class EnvironmentChecker: """Scans the local system for a configured list of development tools.""" def __init__(self, tool_definitions: Optional[List[dict]] = None) -> None: """Create a checker. Args: tool_definitions: Optional custom list of tool definitions, mainly used by unit tests. Defaults to config.TOOL_DEFINITIONS. """ self.tool_definitions = ( tool_definitions if tool_definitions is not None else TOOL_DEFINITIONS ) def _find_executable(self, commands: List[str]) -> Optional[str]: """Return the full path to the first matching command found on PATH.""" for command in commands: path = shutil.which(command) if path: return path return None def _run_version_command( self, executable_path: str, version_args: List[str] ) -> Tuple[Optional[str], Optional[str]]: """Run a tool's version flag(s) and return (raw_output, error_message). Tries each argument in version_args in turn (some tools only respond to one particular flag). Handles timeouts, permission errors, and other OS-level failures without raising. """ last_error: Optional[str] = None for arg in version_args: try: completed = subprocess.run( [executable_path, arg], capture_output=True, text=True, timeout=COMMAND_TIMEOUT_SECONDS, check=False, ) output = clean_output(completed.stdout, completed.stderr) if output: return output, None last_error = "Command produced no output" except subprocess.TimeoutExpired: last_error = "Command timed out" except PermissionError: last_error = "Permission denied when executing command" except FileNotFoundError: last_error = "Executable disappeared before it could be run" except OSError as exc: last_error = f"OS error while executing command: {exc}" return None, last_error or "No version output produced" def check_tool(self, definition: dict) -> ToolResult: """Check a single tool given its definition dictionary.""" name = definition["name"] commands = definition["commands"] version_args = definition["version_args"] executable_path = self._find_executable(commands) if not executable_path: return ToolResult(name=name, status=ToolStatus.NOT_INSTALLED) raw_output, error = self._run_version_command(executable_path, version_args) if not raw_output: return ToolResult( name=name, status=ToolStatus.VERSION_UNAVAILABLE, path=executable_path, error=error, ) version = extract_version(raw_output) if not version: return ToolResult( name=name, status=ToolStatus.VERSION_UNAVAILABLE, path=executable_path, error="Could not parse version from command output", ) return ToolResult( name=name, status=ToolStatus.INSTALLED, version=version, path=executable_path ) def check_tool_by_name(self, tool_name: str) -> Optional[ToolResult]: """Check a single tool by (case-insensitive) name. Returns None if unknown.""" for definition in self.tool_definitions: if str(definition["name"]).lower() == tool_name.lower(): return self.check_tool(definition) return None def scan_all(self) -> List[ToolResult]: """Check every configured tool and return a list of results. Any unexpected exception for an individual tool is caught so that one misbehaving tool cannot crash the whole scan. """ results: List[ToolResult] = [] for definition in self.tool_definitions: try: results.append(self.check_tool(definition)) except Exception as exc: # noqa: BLE001 - deliberately broad, must never crash results.append( ToolResult( name=str(definition.get("name", "Unknown")), status=ToolStatus.NOT_INSTALLED, error=f"Unexpected error: {exc}", ) ) return results def list_tool_names(self) -> List[str]: """Return the names of all tools this checker is configured to check.""" return [str(definition["name"]) for definition in self.tool_definitions]