104 lines
3.5 KiB
Python
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"
|