big upgrade UwU

This commit is contained in:
2026-07-27 15:16:01 +02:00
parent 9fb2bf0a74
commit 1ff979f1e5
30 changed files with 3304 additions and 183 deletions
+158
View File
@@ -0,0 +1,158 @@
"""Console reporting for a download run."""
from __future__ import annotations
import sys
from typing import IO, Protocol
from sticker_downloader.results import (
FileOutcome,
FileStatus,
PackResult,
RunTotals,
format_size,
)
class ProgressReporter(Protocol):
"""Everything the downloader and CLI report as a run unfolds."""
def info(self, message: str) -> None: ...
def warn(self, message: str) -> None: ...
def error(self, message: str) -> None: ...
def pack_started(
self, reference: str, name: str, total_stickers: int, total_files: int
) -> None: ...
def file_finished(self, outcome: FileOutcome) -> None: ...
def pack_finished(self, result: PackResult) -> None: ...
def run_finished(self, results: list[PackResult]) -> None: ...
class NullProgress:
"""Reports nothing. Used by ``--quiet`` and by tests."""
def info(self, message: str) -> None: ...
def warn(self, message: str) -> None: ...
def error(self, message: str) -> None: ...
def pack_started(
self, reference: str, name: str, total_stickers: int, total_files: int
) -> None: ...
def file_finished(self, outcome: FileOutcome) -> None: ...
def pack_finished(self, result: PackResult) -> None: ...
def run_finished(self, results: list[PackResult]) -> None: ...
class ConsoleProgress:
"""Writes human-friendly progress to a stream.
On a terminal the per-pack counters are redrawn in place; when the output is
piped to a file each pack reports a single summary line instead, so logs stay
readable.
"""
def __init__(
self,
stream: IO[str] | None = None,
*,
verbose: bool = False,
use_ansi: bool | None = None,
) -> None:
self._stream = stream if stream is not None else sys.stderr
self._verbose = verbose
if use_ansi is None:
use_ansi = bool(getattr(self._stream, "isatty", lambda: False)())
self._use_ansi = use_ansi
self._live_width = 0
self._pack_total = 0
self._pack_done = 0
# -- plain messages ---------------------------------------------------
def info(self, message: str) -> None:
self._write_line(message)
def warn(self, message: str) -> None:
self._write_line(f"warning: {message}")
def error(self, message: str) -> None:
self._write_line(f"error: {message}")
# -- run lifecycle ----------------------------------------------------
def pack_started(
self, reference: str, name: str, total_stickers: int, total_files: int
) -> None:
self._pack_total = total_files
self._pack_done = 0
self._write_line(f"\n{name}: {total_stickers} stickers, {total_files} files")
def file_finished(self, outcome: FileOutcome) -> None:
self._pack_done += 1
if outcome.status is FileStatus.FAILED:
self.error(f"{outcome.name}: {outcome.error}")
return
if self._verbose:
detail = (
f" ({format_size(outcome.size)})"
if outcome.status is FileStatus.DOWNLOADED
else ""
)
self._write_line(f" {outcome.status.value}: {outcome.name}{detail}")
return
total = self._pack_total or self._pack_done
self._draw_live(f" {self._pack_done}/{total} files")
def pack_finished(self, result: PackResult) -> None:
self._clear_live()
if result.error is not None:
self.error(f"{result.reference}: {result.error}")
return
location = f" -> {result.directory}" if result.directory else ""
self._write_line(f" {result.summary()}{location}")
def run_finished(self, results: list[PackResult]) -> None:
self._clear_live()
totals = RunTotals.from_results(results)
if totals.packs == 0:
self._write_line("\nNothing to do.")
return
written = format_size(totals.bytes_written)
lines = [
"",
f"Done: {totals.packs_ok}/{totals.packs} packs",
f" files downloaded : {totals.downloaded} ({written})",
]
if totals.planned:
lines.append(f" files planned : {totals.planned}")
if totals.skipped:
lines.append(f" already present : {totals.skipped}")
if totals.failed:
lines.append(f" files failed : {totals.failed}")
if totals.packs_failed:
lines.append(f" packs failed : {totals.packs_failed}")
for result in results:
if not result.ok:
reason = result.error or f"{result.failed} file(s) failed"
lines.append(f" - {result.reference}: {reason}")
self._write_line("\n".join(lines))
# -- internals --------------------------------------------------------
def _draw_live(self, text: str) -> None:
if not self._use_ansi:
return
padding = " " * max(0, self._live_width - len(text))
self._stream.write(f"\r{text}{padding}")
self._stream.flush()
self._live_width = len(text)
def _clear_live(self) -> None:
if self._use_ansi and self._live_width:
self._stream.write("\r" + " " * self._live_width + "\r")
self._stream.flush()
self._live_width = 0
def _write_line(self, text: str) -> None:
self._clear_live()
self._stream.write(f"{text}\n")
self._stream.flush()