from __future__ import annotations import errno import os import pty import select import subprocess from collections.abc import Sequence from typing import Callable StreamCallback = Callable[[str], None] def run_streaming_cmd( cmd: str | Sequence[str], cwd: str | None = None, env: dict | None = None, stdin_text: str | None = None, on_stdout: StreamCallback | None = None, on_stderr: StreamCallback | None = None, ) -> int: """Run a command with PTY-backed stdout/stderr and stream output chunks live.""" argv: list[str] if isinstance(cmd, str): argv = ["bash", "-lc", cmd] else: argv = list(cmd) out_master, out_slave = pty.openpty() err_master, err_slave = pty.openpty() stdin_handle = subprocess.PIPE if stdin_text is not None else None proc = None try: proc = subprocess.Popen( argv, cwd=cwd, env=env, stdin=stdin_handle, stdout=out_slave, stderr=err_slave, text=False, close_fds=True, ) finally: os.close(out_slave) os.close(err_slave) if stdin_text is not None and proc.stdin: try: proc.stdin.write(stdin_text.encode("utf-8", errors="replace")) proc.stdin.close() except Exception: pass callbacks: dict[int, StreamCallback | None] = { out_master: on_stdout, err_master: on_stderr, } try: while callbacks: ready, _, _ = select.select(list(callbacks.keys()), [], [], 0.1) if not ready: if proc.poll() is not None: # Drain any remaining data after process exit. ready = list(callbacks.keys()) else: continue for fd in ready: try: chunk = os.read(fd, 4096) except OSError as exc: if exc.errno == errno.EIO: chunk = b"" else: raise if not chunk: os.close(fd) callbacks.pop(fd, None) continue callback = callbacks.get(fd) if callback: callback(chunk.decode("utf-8", errors="replace")) return proc.wait() finally: for fd in list(callbacks.keys()): try: os.close(fd) except OSError: pass