steam-backlog-enforcer/steam_backlog_enforcer/jobs/_job_io.py
2026-10-10 16:07:42 +02:00

144 lines
5.0 KiB
Python

"""Turn a command's output and progress into job events.
Inside a job the command code is unchanged: it still calls ``_echo``,
``current_progress()`` and ``logging``. The runner installs the three
adapters here, which write ``log`` and ``progress`` events instead of a
terminal.
"""
from __future__ import annotations
import logging
import re
import time
from typing import TYPE_CHECKING, Literal
from steam_backlog_enforcer._progress import eta_seconds
if TYPE_CHECKING:
from steam_backlog_enforcer.jobs._events import EventWriter
LogLevel = Literal["info", "warning", "error"]
# At most this many progress events per second, plus every phase start and
# the final step: enough for a smooth bar, cheap for SSE and the event fold.
_MIN_PROGRESS_INTERVAL = 0.2
_CONTROL = re.compile(r"(\r|\n)")
def _level_for(line: str) -> LogLevel:
"""Guess a CLI line's severity from how the commands phrase it."""
lowered = line.lower()
if lowered.startswith(("error", "failed")):
return "error"
if lowered.startswith(("warning", "⚠")) or "⚠" in line:
return "warning"
return "info"
class JobLogSink:
"""An ``_echo`` sink that behaves like a terminal, one event per line.
A carriage return discards the unfinished line, exactly as a terminal
overwrites it, so the CLI's carriage-return progress meters collapse into the
single line they finally show instead of thousands of events.
"""
def __init__(self, writer: EventWriter) -> None:
"""Bind to the job's event writer."""
self._writer = writer
self._buffer = ""
self.last_line: str | None = None
"""The last line with any text: the job's default summary."""
self.last_error: str | None = None
"""The last error-level line, if any: a printed failure."""
def __call__(self, text: str) -> None:
"""Consume raw ``_echo`` output (message plus ``end``)."""
for part in _CONTROL.split(text):
if part == "\n":
self.flush()
elif part == "\r":
self._buffer = ""
else:
self._buffer += part
def flush(self) -> None:
"""Emit the buffered line, if it has any visible text."""
line = self._buffer.strip()
self._buffer = ""
if line:
if any(ch.isalnum() for ch in line): # Not a "────" rule.
self.last_line = line
level = _level_for(line)
if level == "error":
self.last_error = line
self._writer.emit("log", level=level, message=line)
class JobProgress:
"""A :class:`~steam_backlog_enforcer._progress.Progress` writing events."""
def __init__(self, writer: EventWriter) -> None:
"""Bind to the job's event writer."""
self._writer = writer
self._label = ""
self._total: int | None = None
self._step = 0
self._item: str | None = None
self._started = time.monotonic()
self._last_emit = 0.0
def phase(self, label: str, total: int | None = None) -> None:
"""Start a phase and announce it at once."""
self._label, self._total, self._step, self._item = label, total, 0, None
self._started = time.monotonic()
self._emit(force=True)
def advance(self, item: str | None = None, *, step: int | None = None) -> None:
"""Record a step; emitted unless one went out a moment ago."""
self._step = self._step + 1 if step is None else step
self._item = item
done = self._total is not None and self._step >= self._total
self._emit(force=done)
def _emit(self, *, force: bool) -> None:
"""Write a ``progress`` event, throttled unless *force*."""
now = time.monotonic()
if not force and now - self._last_emit < _MIN_PROGRESS_INTERVAL:
return
self._last_emit = now
fields: dict[str, object] = {
"step": self._step,
"total": self._total,
"label": self._label,
}
if self._item:
fields["item"] = self._item
eta = eta_seconds(now - self._started, self._step, self._total)
if eta is not None:
fields["eta_seconds"] = eta
self._writer.emit("progress", **fields)
class EventLogHandler(logging.Handler):
"""Forwards WARNING-and-above log records as ``log`` events.
INFO chatter stays in the job's ``output.log``; what a user should see
(a failed uninstall, an unreachable Steam) reaches the UI.
"""
def __init__(self, writer: EventWriter) -> None:
"""Bind to the job's event writer."""
super().__init__(level=logging.WARNING)
self._writer = writer
def emit(self, record: logging.LogRecord) -> None:
"""Write one record as a ``log`` event."""
level: LogLevel = "error" if record.levelno >= logging.ERROR else "warning"
try:
message = record.getMessage()
except TypeError, ValueError:
message = str(record.msg)
self._writer.emit("log", level=level, message=message)