"""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()