Coverage for src / lilbee / catalog / download_progress.py: 100%
58 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""Download progress callback plumbing shared by all surfaces.
3The TUI runs under Textual which owns the terminal; tqdm output to
4stderr/stdout corrupts the screen. This module provides a tqdm subclass
5(``_CallbackProgressBar``) that suppresses terminal output and forwards
6progress to a plain ``Callable[[int, int], None]`` callback. The
7``_ProgressTracker`` wrapper detects whether progress events actually
8fired so the TUI can detect a cache-hit (no progress events) and render
9``"already downloaded"`` instead of leaving the bar at 0%.
11``make_download_callback`` is the public entry point used by every
12surface to convert raw bytes-progress into ``DownloadProgress`` events.
13"""
15from __future__ import annotations
17import io
18import threading
19import time
20from collections.abc import Callable
21from typing import Any
23from tqdm.auto import tqdm as _base_tqdm
25from lilbee.catalog.models import DownloadProgress
27ProgressCallback = Callable[[int, int], None]
28_BYTES_PER_MB = 1024 * 1024
31def make_download_callback(
32 on_update: Callable[[DownloadProgress], None],
33 *,
34 throttle_interval: float = 0.1,
35) -> ProgressCallback:
36 """Build a download progress callback that converts bytes to human-readable state.
37 *on_update(progress: DownloadProgress)* is called at most once per
38 ``throttle_interval`` seconds with a float percentage (0.0 to 100.0), a
39 ``"<done>/<total> MB"`` detail string, and a cache-hit flag. Both the
40 catalog and setup screens use this so byte-to-MB conversion and
41 cache-hit detection aren't duplicated.
42 """
43 last_update_time = 0.0
44 seen_partial = False
46 def _on_progress(downloaded: int, total: int) -> None:
47 nonlocal last_update_time, seen_partial
49 if total > 0 and downloaded >= total and not seen_partial:
50 on_update(
51 DownloadProgress(percent=100.0, detail="already downloaded", is_cache_hit=True)
52 )
53 return
54 seen_partial = True
56 now = time.monotonic()
57 if now - last_update_time < throttle_interval:
58 return
59 last_update_time = now
61 mb_done = downloaded / _BYTES_PER_MB
62 if total > 0:
63 pct = min(downloaded * 100.0 / total, 100.0)
64 mb_total = total / _BYTES_PER_MB
65 on_update(
66 DownloadProgress(
67 percent=pct,
68 detail=f"{mb_done:.0f}/{mb_total:.0f} MB",
69 is_cache_hit=False,
70 )
71 )
72 else:
73 on_update(DownloadProgress(percent=0.0, detail=f"{mb_done:.0f} MB", is_cache_hit=False))
75 return _on_progress
78class _CallbackProgressBar(_base_tqdm):
79 """tqdm subclass that forwards progress to a plain callback.
80 Fully suppresses terminal output by disabling tqdm rendering and redirecting
81 its file handle to a devnull sink: prevents ANSI escape sequences from leaking
82 into Textual's managed terminal.
84 Overrides ``get_lock`` to return a threading lock instead of tqdm's default
85 multiprocessing lock. Vanilla tqdm acquires ``self._lock`` even on the
86 ``disable=True`` path (std.py:988), and the multiprocessing lock's lazy init
87 raises ``ValueError`` when ``sys.stderr.fileno() == -1`` (Textual, Jupyter,
88 pytest capture). A thread lock sidesteps that fd handling entirely.
89 """
91 _lock = threading.RLock()
92 _callback: Any = None
94 @classmethod
95 def get_lock(cls) -> threading.RLock:
96 return cls._lock
98 def __init__(self, *args: Any, **kwargs: Any):
99 kwargs["disable"] = True
100 kwargs["file"] = io.StringIO() # absorb any accidental tqdm output
101 super().__init__(*args, **kwargs)
102 self._cumulative = 0
104 def update(self, n: float = 1) -> bool | None:
105 self._cumulative += int(n)
106 if self._callback is not None:
107 total = self.total if self.total is not None else 0
108 self._callback(int(self._cumulative), int(total))
109 return None
112class _ProgressTracker:
113 """Wraps a tqdm_class to detect whether progress updates actually fired."""
115 def __init__(self, callback: Any) -> None:
116 self.was_used = False
117 self._callback = callback
119 def make_tqdm_class(self) -> type[_base_tqdm]:
120 tracker = self
122 class _Cls(_CallbackProgressBar):
123 _callback = staticmethod(tracker._callback)
125 def update(self, n: float = 1) -> bool | None:
126 tracker.was_used = True
127 return super().update(n)
129 return _Cls