from __future__ import annotations import subprocess from dataclasses import dataclass from pathlib import Path from typing import Callable, Mapping, Sequence @dataclass(slots=True) class CommandFailed(RuntimeError): cmd: Sequence[str] returncode: int stdout: str stderr: str def __str__(self) -> str: # pragma: no cover cmd_str = " ".join(self.cmd) return f"Command failed ({self.returncode}): {cmd_str}" def run( cmd: Sequence[str], *, cwd: Path | None = None, env: Mapping[str, str] | None = None, input_text: str | None = None, timeout: float | None = None, ) -> subprocess.CompletedProcess[str]: return subprocess.run( list(cmd), cwd=str(cwd) if cwd is not None else None, env=dict(env) if env is not None else None, input=input_text, text=True, capture_output=True, timeout=timeout, ) def check( cmd: Sequence[str], *, cwd: Path | None = None, env: Mapping[str, str] | None = None, input_text: str | None = None, timeout: float | None = None, ) -> subprocess.CompletedProcess[str]: proc = run(cmd, cwd=cwd, env=env, input_text=input_text, timeout=timeout) if proc.returncode != 0: raise CommandFailed(cmd=cmd, returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr) return proc def run_streaming( cmd: Sequence[str], *, cwd: Path | None = None, env: Mapping[str, str] | None = None, stdin_text: str | None = None, on_line: Callable[[str], None] | None = None, ) -> int: """Run a subprocess and stream stdout/stderr lines to `on_line`. Returns the process return code. """ p = subprocess.Popen( list(cmd), cwd=str(cwd) if cwd is not None else None, env=dict(env) if env is not None else None, stdin=subprocess.PIPE if stdin_text is not None else None, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) try: if stdin_text is not None and p.stdin is not None: p.stdin.write(stdin_text) p.stdin.close() if p.stdout is not None: for line in p.stdout: if on_line is not None: on_line(line.rstrip("\n")) return p.wait() finally: try: if p.stdout is not None: p.stdout.close() except Exception: pass