This commit is contained in:
2026-08-27 17:50:36 +02:00
commit 5a0e6520f4
51 changed files with 14027 additions and 0 deletions

212
ptlinux/command.py Normal file
View 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