"""Inspectable subprocess execution for installer operations.""" from __future__ import annotations from dataclasses import asdict, dataclass from datetime import datetime import json import os from pathlib import Path import subprocess import sys import threading import time from typing import Mapping, Sequence, TextIO @dataclass(frozen=True) class CommandOutcome: argv: list[str] returncode: int | None stdout: str stderr: str duration_seconds: float timed_out: bool dry_run: bool class CommandError(RuntimeError): """Raised when a required installer command fails.""" def __init__(self, outcome: CommandOutcome) -> None: self.outcome = outcome returncode = "unknown" if outcome.returncode is None else str(outcome.returncode) message = f"command failed ({returncode}): {outcome.argv!r}" if outcome.timed_out: message = f"command timed out: {outcome.argv!r}" super().__init__(message) class CommandRunner: """Run argument arrays with timeouts and append-only JSONL logging.""" def __init__(self, log_path: Path, *, dry_run: bool = False) -> None: self.log_path = log_path self.dry_run = dry_run def run( self, argv: Sequence[str], *, cwd: Path | None = None, env_overrides: Mapping[str, str] | None = None, timeout_seconds: float = 300.0, check: bool = True, stream_output: bool = False, ) -> CommandOutcome: arguments = [str(value) for value in argv] if not arguments: raise ValueError("command argv cannot be empty") overrides = dict(env_overrides or {}) started = time.monotonic() if self.dry_run: outcome = CommandOutcome( argv=arguments, returncode=0, stdout="", stderr="", duration_seconds=0.0, timed_out=False, dry_run=True, ) self._record(outcome, cwd, overrides) return outcome environment = os.environ.copy() environment.update(overrides) if stream_output: outcome = self._run_streamed( arguments, cwd=cwd, environment=environment, timeout_seconds=timeout_seconds, started=started, ) else: try: completed = subprocess.run( arguments, cwd=str(cwd) if cwd is not None else None, env=environment, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout_seconds, check=False, ) outcome = CommandOutcome( argv=arguments, returncode=completed.returncode, stdout=completed.stdout, stderr=completed.stderr, duration_seconds=time.monotonic() - started, timed_out=False, dry_run=False, ) except subprocess.TimeoutExpired as exc: outcome = CommandOutcome( argv=arguments, returncode=None, stdout=_as_text(exc.stdout), stderr=_as_text(exc.stderr), duration_seconds=time.monotonic() - started, timed_out=True, dry_run=False, ) self._record(outcome, cwd, overrides) if check and (outcome.timed_out or outcome.returncode != 0): raise CommandError(outcome) return outcome @staticmethod def _run_streamed( arguments: list[str], *, cwd: Path | None, environment: Mapping[str, str], timeout_seconds: float, started: float, ) -> CommandOutcome: process = subprocess.Popen( arguments, cwd=str(cwd) if cwd is not None else None, env=dict(environment), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=1, ) stdout: list[str] = [] stderr: list[str] = [] def relay( stream: TextIO, capture: list[str], destination: TextIO, ) -> None: while True: line = stream.readline() if not line: return capture.append(line) destination.write(line) destination.flush() assert process.stdout is not None assert process.stderr is not None stdout_thread = threading.Thread( target=relay, args=(process.stdout, stdout, sys.stdout), daemon=True, ) stderr_thread = threading.Thread( target=relay, args=(process.stderr, stderr, sys.stderr), daemon=True, ) stdout_thread.start() stderr_thread.start() timed_out = False try: returncode = process.wait(timeout=timeout_seconds) except subprocess.TimeoutExpired: timed_out = True process.kill() returncode = process.wait() stdout_thread.join() stderr_thread.join() process.stdout.close() process.stderr.close() return CommandOutcome( argv=arguments, returncode=None if timed_out else returncode, stdout="".join(stdout), stderr="".join(stderr), duration_seconds=time.monotonic() - started, timed_out=timed_out, dry_run=False, ) def _record( self, outcome: CommandOutcome, cwd: Path | None, env_overrides: Mapping[str, str], ) -> None: self.log_path.parent.mkdir(parents=True, exist_ok=True) entry = { "timestamp": datetime.now().astimezone().isoformat(), "cwd": str(cwd) if cwd is not None else None, "env_overrides": dict(sorted(env_overrides.items())), **asdict(outcome), } with self.log_path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(entry, sort_keys=True) + "\n") def _as_text(value: bytes | str | None) -> str: if value is None: return "" if isinstance(value, bytes): return value.decode("utf-8", errors="replace") return value