from __future__ import annotations import os import subprocess from dataclasses import dataclass from pathlib import Path from typing import Protocol, Sequence from .config import Config from .errors import ExecutionError, ValidationError @dataclass(frozen=True) class CommandResult: argv: tuple[str, ...] stdout: str stderr: str class Runner(Protocol): def run(self, argv: Sequence[str]) -> CommandResult: ... class SubprocessRunner: MAX_CAPTURE_CHARS = 16_000 def __init__(self, config: Config): self.config = config def run(self, argv: Sequence[str]) -> CommandResult: command = tuple(argv) if not command or not all(isinstance(item, str) and item for item in command): raise ValidationError("command argv must contain non-empty strings") if any("\x00" in item or "\r" in item or "\n" in item for item in command): raise ValidationError("command argv contains a forbidden control character") if not Path(command[0]).is_absolute(): raise ValidationError("command executable must be an absolute path") environment = os.environ.copy() for key in tuple(environment): if key.startswith("PIP_") or key in {"PYTHONPATH", "PYTHONHOME"}: environment.pop(key, None) environment.update( { "PIP_DISABLE_PIP_VERSION_CHECK": "1", "PIP_NO_INPUT": "1", "PYTHONNOUSERSITE": "1", } ) try: completed = subprocess.run( command, shell=False, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, errors="replace", timeout=self.config.commands.command_timeout_seconds, check=False, env=environment, ) except (OSError, subprocess.TimeoutExpired) as exc: raise ExecutionError(f"command could not complete: {command[0]}") from exc stdout = completed.stdout[-self.MAX_CAPTURE_CHARS :] stderr = completed.stderr[-self.MAX_CAPTURE_CHARS :] if completed.returncode != 0: raise ExecutionError( f"command failed with exit code {completed.returncode}: {command[0]}" ) return CommandResult(command, stdout, stderr)