159 lines
5.4 KiB
Python
159 lines
5.4 KiB
Python
"""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()
|