dashboard/app/utils/subprocess.py
2026-08-31 00:47:02 +02:00

104 lines
3.5 KiB
Python

import asyncio
import json
import subprocess
from collections.abc import Sequence
from typing import Any
def run(cmd: Sequence[str], *, timeout: float | None = None) -> tuple[int, str, str]:
"""Run a command synchronously and capture its output.
Args:
cmd: program and arguments.
timeout: seconds before the child is killed, or None to wait.
Returns:
(returncode, stdout, stderr), both decoded. Spawn failures
(missing binary, other OSError) and timeouts are reported as
returncode -1 with the reason in stderr instead of raising.
"""
try:
proc = subprocess.run(cmd, capture_output=True, timeout=timeout)
except FileNotFoundError:
return -1, "", f"{cmd[0]} not found in PATH"
except subprocess.TimeoutExpired:
return -1, "", f"{cmd[0]} timed out"
except (OSError, subprocess.SubprocessError) as e:
return -1, "", str(e)[:200]
return proc.returncode, proc.stdout.decode(errors="replace"), proc.stderr.decode(errors="replace")
async def run_async(cmd: Sequence[str], *, timeout: float | None = None) -> tuple[int, str, str]:
"""Run a command asynchronously and capture its output.
The child is killed when the timeout expires.
Args:
cmd: program and arguments.
timeout: seconds before the child is killed, or None to wait.
Returns:
(returncode, stdout, stderr), both decoded. Spawn failures
(missing binary, other OSError) and timeouts are reported as
returncode -1 with the reason in stderr instead of raising.
"""
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
return -1, "", f"{cmd[0]} not found in PATH"
except OSError as e:
return -1, "", str(e)[:200]
try:
if timeout is None:
out, err = await proc.communicate()
else:
out, err = await asyncio.wait_for(proc.communicate(), timeout)
except TimeoutError:
try:
proc.kill()
except ProcessLookupError:
pass
return -1, "", f"{cmd[0]} timed out"
return proc.returncode or 0, out.decode(errors="replace"), err.decode(errors="replace")
def run_json(cmd: Sequence[str], *, timeout: float | None = None) -> tuple[Any, str]:
"""Run a command synchronously and parse its stdout as JSON.
Args:
cmd: program and arguments.
timeout: seconds before the child is killed, or None to wait.
Returns:
(parsed JSON, "") on success, else (None, error description).
"""
rc, out, err = run(cmd, timeout=timeout)
if rc != 0:
return None, err or f"{cmd[0]} failed (rc={rc})"
try:
return json.loads(out), ""
except ValueError:
return None, f"{cmd[0]} returned invalid JSON"
async def run_json_async(cmd: Sequence[str], *, timeout: float | None = None) -> tuple[Any, str]:
"""Run a command asynchronously and parse its stdout as JSON.
Args:
cmd: program and arguments.
timeout: seconds before the child is killed, or None to wait.
Returns:
(parsed JSON, "") on success, else (None, error description).
"""
rc, out, err = await run_async(cmd, timeout=timeout)
if rc != 0:
return None, err or f"{cmd[0]} failed (rc={rc})"
try:
return json.loads(out), ""
except ValueError:
return None, f"{cmd[0]} returned invalid JSON"