diff --git a/components/l4d2-host-lib/src/l4d2host/process.py b/components/l4d2-host-lib/src/l4d2host/process.py new file mode 100644 index 0000000..067e94d --- /dev/null +++ b/components/l4d2-host-lib/src/l4d2host/process.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +import subprocess +import sys +import threading +from typing import Callable, Sequence + + +@dataclass(slots=True) +class CommandResult: + returncode: int + stdout: str + stderr: str + + +def run_command( + cmd: Sequence[str], + *, + on_stdout: Callable[[str], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + passthrough: bool = False, +) -> CommandResult: + stdout_lines: list[str] = [] + stderr_lines: list[str] = [] + + proc = subprocess.Popen( + list(cmd), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + + def pump( + stream: subprocess.Popen[str].stdout, + sink: list[str], + callback: Callable[[str], None] | None, + output_stream, + ) -> None: + if stream is None: + return + for raw in iter(stream.readline, ""): + line = raw.rstrip("\n") + sink.append(line) + if callback is not None: + callback(line) + if passthrough: + print(line, file=output_stream) + stream.close() + + stdout_thread = threading.Thread( + target=pump, + args=(proc.stdout, stdout_lines, on_stdout, sys.stdout), + daemon=True, + ) + stderr_thread = threading.Thread( + target=pump, + args=(proc.stderr, stderr_lines, on_stderr, sys.stderr), + daemon=True, + ) + stdout_thread.start() + stderr_thread.start() + + returncode = proc.wait() + stdout_thread.join() + stderr_thread.join() + + result = CommandResult( + returncode=returncode, + stdout="\n".join(stdout_lines), + stderr="\n".join(stderr_lines), + ) + if returncode != 0: + raise subprocess.CalledProcessError( + returncode=returncode, + cmd=list(cmd), + output=result.stdout, + stderr=result.stderr, + ) + return result diff --git a/components/l4d2-host-lib/tests/test_process.py b/components/l4d2-host-lib/tests/test_process.py new file mode 100644 index 0000000..0791034 --- /dev/null +++ b/components/l4d2-host-lib/tests/test_process.py @@ -0,0 +1,22 @@ +import subprocess + +import pytest + +from l4d2host.process import run_command + + +def test_callbacks_receive_lines() -> None: + out: list[str] = [] + err: list[str] = [] + run_command( + ["python3", "-c", "import sys; print('ok'); print('warn', file=sys.stderr)"], + on_stdout=out.append, + on_stderr=err.append, + ) + assert out == ["ok"] + assert err == ["warn"] + + +def test_nonzero_exit_raises() -> None: + with pytest.raises(subprocess.CalledProcessError): + run_command(["python3", "-c", "import sys; sys.exit(7)"])