71 lines
2.4 KiB
Python
71 lines
2.4 KiB
Python
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)
|