"""Subprocess runner and logging utilities.""" from __future__ import annotations import os import subprocess from dataclasses import dataclass from datetime import datetime, timezone from typing import Iterable, Mapping @dataclass class RunResult: cmd: list[str] returncode: int stdout: str stderr: str def log_line(level: str, scope: str, message: str) -> None: ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") print(f"{ts} {level:<5} {scope}: {message}") def run( cmd: Iterable[str], *, log_file: str | None = None, env: Mapping[str, str] | None = None, allow_fail: bool = False, dry_run: bool = False, ) -> RunResult: """Run a command and optionally tee output to a log file.""" cmd_list = list(cmd) if dry_run: if log_file: with open(log_file, "a", encoding="utf-8") as fh: fh.write(f"[dry-run] {' '.join(cmd_list)}\n") return RunResult(cmd=cmd_list, returncode=0, stdout="", stderr="") env_map = os.environ.copy() if env: env_map.update(env) proc = subprocess.Popen( cmd_list, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env_map, ) stdout, stderr = proc.communicate() if log_file: with open(log_file, "a", encoding="utf-8") as fh: if stdout: fh.write(stdout) if stderr: fh.write(stderr) if proc.returncode != 0 and not allow_fail: detail = "" if stderr: detail = f" stderr: {stderr.strip()}" raise RuntimeError( f"command failed: {' '.join(cmd_list)} (exit {proc.returncode}){detail}" ) return RunResult(cmd=cmd_list, returncode=proc.returncode, stdout=stdout, stderr=stderr)