Init
This commit is contained in:
212
ptlinux/command.py
Normal file
212
ptlinux/command.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""Inspectable subprocess execution for installer operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Mapping, Sequence, TextIO
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandOutcome:
|
||||
argv: list[str]
|
||||
returncode: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_seconds: float
|
||||
timed_out: bool
|
||||
dry_run: bool
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
"""Raised when a required installer command fails."""
|
||||
|
||||
def __init__(self, outcome: CommandOutcome) -> None:
|
||||
self.outcome = outcome
|
||||
returncode = "unknown" if outcome.returncode is None else str(outcome.returncode)
|
||||
message = f"command failed ({returncode}): {outcome.argv!r}"
|
||||
if outcome.timed_out:
|
||||
message = f"command timed out: {outcome.argv!r}"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
"""Run argument arrays with timeouts and append-only JSONL logging."""
|
||||
|
||||
def __init__(self, log_path: Path, *, dry_run: bool = False) -> None:
|
||||
self.log_path = log_path
|
||||
self.dry_run = dry_run
|
||||
|
||||
def run(
|
||||
self,
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
env_overrides: Mapping[str, str] | None = None,
|
||||
timeout_seconds: float = 300.0,
|
||||
check: bool = True,
|
||||
stream_output: bool = False,
|
||||
) -> CommandOutcome:
|
||||
arguments = [str(value) for value in argv]
|
||||
if not arguments:
|
||||
raise ValueError("command argv cannot be empty")
|
||||
overrides = dict(env_overrides or {})
|
||||
started = time.monotonic()
|
||||
if self.dry_run:
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr="",
|
||||
duration_seconds=0.0,
|
||||
timed_out=False,
|
||||
dry_run=True,
|
||||
)
|
||||
self._record(outcome, cwd, overrides)
|
||||
return outcome
|
||||
|
||||
environment = os.environ.copy()
|
||||
environment.update(overrides)
|
||||
if stream_output:
|
||||
outcome = self._run_streamed(
|
||||
arguments,
|
||||
cwd=cwd,
|
||||
environment=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
started=started,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
arguments,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=environment,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=False,
|
||||
dry_run=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None,
|
||||
stdout=_as_text(exc.stdout),
|
||||
stderr=_as_text(exc.stderr),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=True,
|
||||
dry_run=False,
|
||||
)
|
||||
self._record(outcome, cwd, overrides)
|
||||
if check and (outcome.timed_out or outcome.returncode != 0):
|
||||
raise CommandError(outcome)
|
||||
return outcome
|
||||
|
||||
@staticmethod
|
||||
def _run_streamed(
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None,
|
||||
environment: Mapping[str, str],
|
||||
timeout_seconds: float,
|
||||
started: float,
|
||||
) -> CommandOutcome:
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=dict(environment),
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1,
|
||||
)
|
||||
stdout: list[str] = []
|
||||
stderr: list[str] = []
|
||||
|
||||
def relay(
|
||||
stream: TextIO,
|
||||
capture: list[str],
|
||||
destination: TextIO,
|
||||
) -> None:
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return
|
||||
capture.append(line)
|
||||
destination.write(line)
|
||||
destination.flush()
|
||||
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
stdout_thread = threading.Thread(
|
||||
target=relay,
|
||||
args=(process.stdout, stdout, sys.stdout),
|
||||
daemon=True,
|
||||
)
|
||||
stderr_thread = threading.Thread(
|
||||
target=relay,
|
||||
args=(process.stderr, stderr, sys.stderr),
|
||||
daemon=True,
|
||||
)
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
timed_out = False
|
||||
try:
|
||||
returncode = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
process.kill()
|
||||
returncode = process.wait()
|
||||
stdout_thread.join()
|
||||
stderr_thread.join()
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
return CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None if timed_out else returncode,
|
||||
stdout="".join(stdout),
|
||||
stderr="".join(stderr),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=timed_out,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
def _record(
|
||||
self,
|
||||
outcome: CommandOutcome,
|
||||
cwd: Path | None,
|
||||
env_overrides: Mapping[str, str],
|
||||
) -> None:
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
entry = {
|
||||
"timestamp": datetime.now().astimezone().isoformat(),
|
||||
"cwd": str(cwd) if cwd is not None else None,
|
||||
"env_overrides": dict(sorted(env_overrides.items())),
|
||||
**asdict(outcome),
|
||||
}
|
||||
with self.log_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(entry, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _as_text(value: bytes | str | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return value
|
||||
Reference in New Issue
Block a user