mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
144 lines
5.0 KiB
Python
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)
|