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

126 lines
4.2 KiB
Python

"""Progress reporting for long operations, shared by the CLI and web jobs.
Long loops (library scan, HLTB lookups, per-game uninstall, tampering check)
report *what* they are doing through :func:`current_progress`; *who* listens
is decided by the caller. The CLI listens to nothing — it already prints its
own meters with ``_echo``, and they stay byte-identical — while a web job
installs a reporter that writes ``progress`` events.
The active reporter lives in a context variable rather than a parameter so
the loops, which sit several calls below the command entry points, report
without every signature in between growing an argument.
"""
from __future__ import annotations
import asyncio
from contextlib import contextmanager
from contextvars import ContextVar
from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Iterator, Sequence
class Progress(Protocol):
"""Receives the phases and steps of a long operation."""
def phase(self, label: str, total: int | None = None) -> None:
"""Start a new phase; ``total`` is ``None`` when it is indeterminate."""
def advance(self, item: str | None = None, *, step: int | None = None) -> None:
"""Move one step forward, or to ``step`` when the caller counts itself.
Args:
item: What the step was about (a game name), shown next to it.
step: Absolute step number; ``None`` means "one more than before".
"""
class NullProgress:
"""The CLI reporter: does nothing, the CLI prints its own meters."""
def phase(self, label: str, total: int | None = None) -> None:
"""Ignore a phase start."""
del label, total
def advance(self, item: str | None = None, *, step: int | None = None) -> None:
"""Ignore a step."""
del item, step
_ACTIVE: ContextVar[Progress | None] = ContextVar("progress", default=None)
_NULL = NullProgress()
def current_progress() -> Progress:
"""Return the reporter the running command should talk to."""
return _ACTIVE.get() or _NULL
@contextmanager
def use_progress(progress: Progress) -> Iterator[Progress]:
"""Make *progress* the active reporter for the duration of the context."""
token = _ACTIVE.set(progress)
try:
yield progress
finally:
_ACTIVE.reset(token)
def tracked[T](items: Sequence[T], label: str, name: Callable[[T], str]) -> Iterator[T]:
"""Yield *items*, reporting one determinate step per item.
The step is reported *after* the caller finishes with the item, so the
count shown is always "done", never "started".
Args:
items: The work list; its length is the phase total.
label: Phase label, e.g. ``"Uninstalling games"``.
name: Turns an item into the text shown next to its step.
"""
progress = current_progress()
progress.phase(label, len(items))
for item in items:
yield item
progress.advance(name(item))
async def gather_tracked[T](
label: str, coros: Sequence[Awaitable[T]], names: Sequence[str] | None = None
) -> list[T]:
"""``asyncio.gather`` that reports one determinate step per finished awaitable.
Steps count completions (in whatever order they finish), so the bar moves
while the batch runs instead of jumping to full at the end.
Args:
label: Phase label, e.g. ``"Fetching HLTB detail pages"``.
coros: The awaitables; their count is the phase total.
names: Per-awaitable text shown next to its step, if any.
"""
progress = current_progress()
progress.phase(label, len(coros))
done = 0
async def one(coro: Awaitable[T], name: str | None) -> T:
nonlocal done
result = await coro
done += 1
progress.advance(name, step=done)
return result
labels = names if names is not None else [None] * len(coros)
return list(await asyncio.gather(*map(one, coros, labels, strict=True)))
def eta_seconds(elapsed: float, step: int, total: int | None) -> int | None:
"""Estimate the seconds left from the average time per finished step.
Returns:
``None`` until at least one step finished or when there is no total.
"""
if total is None or step <= 0:
return None
return max(0, round(elapsed / step * (total - step)))