Skip to content

Commit 36ce983

Browse files
authored
Merge pull request #51 from vcvyg/issue-35-cli-download-progress
feat(setup): show live model download progress
2 parents 834d680 + c24405d commit 36ce983

6 files changed

Lines changed: 244 additions & 19 deletions

File tree

src/hebb/cli/commands/setup.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77
import click
88
from click.core import ParameterSource
99
from rich.console import Console
10+
from rich.progress import (
11+
BarColumn,
12+
DownloadColumn,
13+
Progress,
14+
TaskProgressColumn,
15+
TextColumn,
16+
TimeRemainingColumn,
17+
TransferSpeedColumn,
18+
)
1019

1120
from hebb.config.init import default_init_target, initialize_workspace
1221
from hebb.config.loader import find_config_file, load_settings, update_config_field
@@ -71,8 +80,11 @@ def setup_cmd(ctx: click.Context, language: str, region: str, profile: str) -> N
7180
if workspace_model_available(workspace, model_id):
7281
console.print(f"[green]Model already present:[/] {model_cache_dir(workspace, model_id)}")
7382
else:
74-
console.print(f"[cyan]Downloading embedding model[/] ({_download_tier_hint(model_id)})...")
75-
model_path = prefetch_model(model_id, workspace, hf_endpoint=region_selection.hf_endpoint)
83+
model_path = _prefetch_with_progress(
84+
model_id,
85+
workspace,
86+
hf_endpoint=region_selection.hf_endpoint,
87+
)
7688
console.print(f"[green]Model ready:[/] {model_path}")
7789
dimension = _verify_model(model_id, hf_endpoint=region_selection.hf_endpoint)
7890
console.print(f"[green]Embedding verified:[/] dim={dimension}")
@@ -133,6 +145,68 @@ def _download_tier_hint(model_id: str) -> str:
133145
return "size varies"
134146

135147

148+
def _prefetch_with_progress(model_id: str, workspace: Path, *, hf_endpoint: str | None) -> Path:
149+
"""Download one model while rendering live terminal byte progress.
150+
151+
Args:
152+
model_id: HuggingFace repository ID of the model to download.
153+
workspace: Resolved Hebb Mind workspace directory.
154+
hf_endpoint: Optional HuggingFace-compatible endpoint.
155+
156+
Returns:
157+
Local directory containing the downloaded model.
158+
159+
Raises:
160+
Exception: Propagates download failures from :func:`prefetch_model`.
161+
"""
162+
size_hint = _download_tier_hint(model_id)
163+
columns = (
164+
TextColumn("[progress.description]{task.description}"),
165+
BarColumn(),
166+
TaskProgressColumn(),
167+
DownloadColumn(),
168+
TransferSpeedColumn(),
169+
TimeRemainingColumn(),
170+
)
171+
with Progress(*columns, console=console, transient=False) as progress:
172+
task_id = progress.add_task(
173+
f"[cyan]Downloading embedding model[/] [dim]({size_hint})[/]",
174+
total=None,
175+
)
176+
177+
def update_progress(bytes_done: int, bytes_total: int, current_file: str) -> None:
178+
if _is_file_count_progress(current_file):
179+
return
180+
description = current_file.strip() or "Downloading embedding model"
181+
progress.update(
182+
task_id,
183+
description=f"[cyan]{description}[/] [dim]({size_hint})[/]",
184+
completed=bytes_done,
185+
total=bytes_total or None,
186+
)
187+
188+
return prefetch_model(
189+
model_id,
190+
workspace,
191+
hf_endpoint=hf_endpoint,
192+
progress_callback=update_progress,
193+
suppress_native_progress=True,
194+
)
195+
196+
197+
def _is_file_count_progress(description: str) -> bool:
198+
"""Return whether a HuggingFace tqdm event counts files rather than bytes.
199+
200+
Args:
201+
description: Description emitted by the HuggingFace tqdm instance.
202+
203+
Returns:
204+
``True`` for the snapshot-level ``Fetching N files`` counter.
205+
"""
206+
normalized = description.strip().lower()
207+
return normalized.startswith("fetching ") and normalized.endswith(" files")
208+
209+
136210
def _persist_region(hf_endpoint: str | None, config_path: Path) -> None:
137211
update_config_field("hf_endpoint", hf_endpoint or "null", config_path)
138212

src/hebb/embedding/catalog.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ class ProbeResult:
8080
# Skipping these in ``snapshot_download`` roughly halves the bytes pulled for
8181
# repos that ship ONNX/OpenVINO/TensorFlow exports alongside the torch weights.
8282
# NOTE: keep ``*.bin`` and ``*.safetensors`` — ``model_dir_complete`` requires a
83-
# real weight file (``model.safetensors`` OR ``pytorch_model.bin``).
83+
# real weight file (``model.safetensors`` OR ``pytorch_model.bin``). The helper
84+
# below conditionally excludes the exact redundant .bin filename only for
85+
# built-in repositories verified to publish safetensors.
8486
PREFETCH_IGNORE_PATTERNS: tuple[str, ...] = (
8587
"*.onnx",
8688
"onnx/**",
@@ -93,6 +95,23 @@ class ProbeResult:
9395
"*.pb",
9496
)
9597

98+
# These built-in models publish a safetensors checkpoint alongside redundant
99+
# framework-specific copies. Prefer the safe format while retaining
100+
# ``pytorch_model.bin`` for BGE-M3 and unknown/custom repositories that may not
101+
# publish safetensors at all.
102+
_SAFETENSORS_MODELS = frozenset(
103+
{
104+
"all-MiniLM-L6-v2",
105+
"sentence-transformers/all-MiniLM-L6-v2",
106+
"intfloat/multilingual-e5-small",
107+
"BAAI/bge-large-en-v1.5",
108+
}
109+
)
110+
_REDUNDANT_TORCH_WEIGHT_PATTERNS: tuple[str, ...] = (
111+
"pytorch_model.bin",
112+
"rust_model.ot",
113+
)
114+
96115

97116
def resolve_language(language: str = "auto", environ: dict[str, str] | None = None) -> LanguageSelection:
98117
"""Resolve the content language strategy.
@@ -273,13 +292,14 @@ def prefetch_model(
273292
workspace: Path,
274293
hf_endpoint: str | None = None,
275294
progress_callback: ProgressCallback | None = None,
295+
suppress_native_progress: bool = False,
276296
) -> Path:
277297
"""Download a HuggingFace model into the Hebb Mind workspace.
278298
279-
Redundant weight variants (ONNX, OpenVINO, TensorFlow, msgpack) are skipped
280-
via :data:`PREFETCH_IGNORE_PATTERNS`; the PyTorch ``*.bin`` / ``*.safetensors``
281-
weights plus config, tokenizer, and sentence-transformers module files are
282-
always fetched.
299+
Redundant weight variants (ONNX, OpenVINO, TensorFlow, msgpack) are skipped.
300+
Built-in repositories verified to publish safetensors also skip duplicate
301+
``pytorch_model.bin`` / ``rust_model.ot`` copies, while repositories without
302+
safetensors retain their PyTorch ``*.bin`` checkpoint.
283303
284304
Args:
285305
model_id: HuggingFace repository ID.
@@ -288,6 +308,8 @@ def prefetch_model(
288308
progress_callback: Optional callback receiving (bytes_done, bytes_total,
289309
current_file_desc) on every tqdm tick. Used by the web console to
290310
surface real-time download progress.
311+
suppress_native_progress: Hide HuggingFace's tqdm rendering when the
312+
callback is displayed by another terminal progress UI.
291313
292314
Returns:
293315
Local model directory.
@@ -321,12 +343,15 @@ def prefetch_model(
321343
snapshot_kwargs: dict = {
322344
"repo_id": model_id,
323345
"local_dir": str(local_dir),
324-
"ignore_patterns": list(PREFETCH_IGNORE_PATTERNS),
346+
"ignore_patterns": _prefetch_ignore_patterns(model_id),
325347
}
326348
if progress_callback is not None:
327349
from hebb.embedding.progress import make_progress_tqdm
328350

329-
snapshot_kwargs["tqdm_class"] = make_progress_tqdm(progress_callback)
351+
snapshot_kwargs["tqdm_class"] = make_progress_tqdm(
352+
progress_callback,
353+
silent=suppress_native_progress,
354+
)
330355

331356
try:
332357
snapshot_download(**snapshot_kwargs)
@@ -342,6 +367,21 @@ def prefetch_model(
342367
return local_dir
343368

344369

370+
def _prefetch_ignore_patterns(model_id: str) -> list[str]:
371+
"""Return safe redundant-file exclusions for one model repository.
372+
373+
Args:
374+
model_id: HuggingFace repository ID.
375+
376+
Returns:
377+
Ignore patterns that retain at least one supported PyTorch checkpoint.
378+
"""
379+
patterns = list(PREFETCH_IGNORE_PATTERNS)
380+
if model_id in _SAFETENSORS_MODELS:
381+
patterns.extend(_REDUNDANT_TORCH_WEIGHT_PATTERNS)
382+
return patterns
383+
384+
345385
def _detect_locale(environ: dict[str, str]) -> str | None:
346386
for key in ("LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"):
347387
value = environ.get(key)

src/hebb/embedding/progress.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,38 @@
2020
"""
2121

2222

23-
def make_progress_tqdm(callback: ProgressCallback) -> type:
24-
"""Return a tqdm subclass that calls ``callback`` on every update."""
23+
class _SilentProgressOutput:
24+
"""Discard tqdm rendering while preserving its internal counters."""
25+
26+
def write(self, text: str) -> int:
27+
return len(text)
28+
29+
def flush(self) -> None:
30+
return None
31+
32+
33+
_SILENT_PROGRESS_OUTPUT = _SilentProgressOutput()
34+
35+
36+
def make_progress_tqdm(callback: ProgressCallback, *, silent: bool = False) -> type:
37+
"""Return a tqdm subclass that calls ``callback`` on every update.
38+
39+
Args:
40+
callback: Consumer of byte progress updates.
41+
silent: Suppress tqdm's own terminal rendering when another UI renders
42+
the callback events.
43+
44+
Returns:
45+
A tqdm-compatible progress class.
46+
"""
2547
from tqdm import tqdm # type: ignore[import-untyped]
2648

2749
class ProgressTqdm(tqdm): # type: ignore[misc]
50+
def __init__(self, *args: Any, **kwargs: Any) -> None:
51+
if silent:
52+
kwargs["file"] = _SILENT_PROGRESS_OUTPUT
53+
super().__init__(*args, **kwargs)
54+
2855
def update(self, n: int | float = 1) -> bool | None:
2956
ret = super().update(n)
3057
try:

tests/unit/cli/commands/test_setup.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ def test_setup_initializes_and_selects_english_model(monkeypatch, tmp_path: Path
2323
monkeypatch.setenv("LANG", "en_US.UTF-8")
2424
monkeypatch.setattr(
2525
"hebb.cli.commands.setup.prefetch_model",
26-
lambda model_id, workspace, hf_endpoint=None: workspace / "models" / model_id,
26+
lambda model_id, workspace, hf_endpoint=None, progress_callback=None, suppress_native_progress=False: (
27+
workspace / "models" / model_id
28+
),
2729
)
2830
monkeypatch.setattr("hebb.cli.commands.setup._verify_model", lambda model_id, hf_endpoint: 384)
2931

@@ -46,7 +48,9 @@ def test_setup_initializes_and_selects_chinese_model(monkeypatch, tmp_path: Path
4648
monkeypatch.setenv("LANG", "zh_CN.UTF-8")
4749
monkeypatch.setattr(
4850
"hebb.cli.commands.setup.prefetch_model",
49-
lambda model_id, workspace, hf_endpoint=None: workspace / "models" / model_id,
51+
lambda model_id, workspace, hf_endpoint=None, progress_callback=None, suppress_native_progress=False: (
52+
workspace / "models" / model_id
53+
),
5054
)
5155
monkeypatch.setattr("hebb.cli.commands.setup._verify_model", lambda model_id, hf_endpoint: 384)
5256

@@ -68,7 +72,9 @@ def test_setup_best_profile_selects_bge_english(monkeypatch, tmp_path: Path) ->
6872
monkeypatch.setenv("LANG", "en_US.UTF-8")
6973
monkeypatch.setattr(
7074
"hebb.cli.commands.setup.prefetch_model",
71-
lambda model_id, workspace, hf_endpoint=None: workspace / "models" / model_id,
75+
lambda model_id, workspace, hf_endpoint=None, progress_callback=None, suppress_native_progress=False: (
76+
workspace / "models" / model_id
77+
),
7278
)
7379
monkeypatch.setattr("hebb.cli.commands.setup._verify_model", lambda model_id, hf_endpoint: 1024)
7480

@@ -88,7 +94,9 @@ def test_setup_explicit_language_and_region_are_independent(monkeypatch, tmp_pat
8894
monkeypatch.setenv("HEBB_HOME", str(home))
8995
monkeypatch.setattr(
9096
"hebb.cli.commands.setup.prefetch_model",
91-
lambda model_id, workspace, hf_endpoint=None: workspace / "models" / model_id,
97+
lambda model_id, workspace, hf_endpoint=None, progress_callback=None, suppress_native_progress=False: (
98+
workspace / "models" / model_id
99+
),
92100
)
93101
monkeypatch.setattr("hebb.cli.commands.setup._verify_model", lambda model_id, hf_endpoint: 384)
94102

@@ -118,7 +126,9 @@ def test_setup_keeps_custom_model_without_explicit_language(monkeypatch, tmp_pat
118126

119127
monkeypatch.setattr(
120128
"hebb.cli.commands.setup.prefetch_model",
121-
lambda model_id, workspace, hf_endpoint=None: workspace / "models" / model_id,
129+
lambda model_id, workspace, hf_endpoint=None, progress_callback=None, suppress_native_progress=False: (
130+
workspace / "models" / model_id
131+
),
122132
)
123133
monkeypatch.setattr("hebb.cli.commands.setup._verify_model", lambda model_id, hf_endpoint: 777)
124134
result = runner.invoke(setup_cmd, ["--region", "global"])
@@ -155,11 +165,52 @@ def _fail_prefetch(*args: object, **kwargs: object) -> Path:
155165
assert result.exit_code == 0, result.output
156166
assert called["prefetch"] is False
157167
assert "Model already present" in result.output
168+
assert "Downloading embedding model" not in result.output
158169
config = json.loads((home / "hebb.json").read_text())
159170
assert config["embedding_model"] == "sentence-transformers/all-MiniLM-L6-v2"
160171
assert config["embedding_dim"] == 384
161172

162173

174+
def test_setup_renders_live_download_progress(monkeypatch, tmp_path: Path) -> None:
175+
home = tmp_path / "home"
176+
monkeypatch.setenv("HEBB_HOME", str(home))
177+
_clear_locale_env(monkeypatch)
178+
monkeypatch.setenv("LANG", "en_US.UTF-8")
179+
monkeypatch.setattr("hebb.cli.commands.setup.workspace_model_available", lambda workspace, model_id: False)
180+
181+
callback_received = False
182+
183+
def fake_prefetch(
184+
model_id: str,
185+
workspace: Path,
186+
hf_endpoint: str | None = None,
187+
progress_callback=None,
188+
suppress_native_progress: bool = False,
189+
) -> Path:
190+
nonlocal callback_received
191+
assert progress_callback is not None
192+
assert suppress_native_progress is True
193+
callback_received = True
194+
progress_callback(512, 1024, "model.safetensors")
195+
progress_callback(1024, 1024, "model.safetensors")
196+
progress_callback(12, 12, "Fetching 12 files")
197+
return workspace / "models" / model_id
198+
199+
monkeypatch.setattr("hebb.cli.commands.setup.prefetch_model", fake_prefetch)
200+
monkeypatch.setattr("hebb.cli.commands.setup._verify_model", lambda model_id, hf_endpoint: 384)
201+
202+
runner = CliRunner()
203+
with runner.isolated_filesystem(temp_dir=tmp_path):
204+
result = runner.invoke(setup_cmd, ["--region", "global"])
205+
206+
assert result.exit_code == 0, result.output
207+
assert callback_received is True
208+
assert "model.safetensors" in result.output
209+
assert "small ~90MB" in result.output
210+
assert "100%" in result.output
211+
assert "Fetching 12 files" not in result.output
212+
213+
163214
def test_initialize_workspace_uses_hebb_home(monkeypatch, tmp_path: Path) -> None:
164215
home = tmp_path / "home"
165216
monkeypatch.setenv("HEBB_HOME", str(home))

tests/unit/embedding/test_catalog.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,33 @@ def fake_snapshot_download(**kwargs: object) -> str:
132132
# Redundant heavy variants are skipped.
133133
assert "*.onnx" in ignored
134134
assert "openvino/**" in ignored
135-
# The actual weight files must NEVER be excluded — model_dir_complete
136-
# requires safetensors OR pytorch_model.bin.
137-
assert "*.safetensors" not in ignored
135+
# This built-in model ships three equivalent checkpoints; retain only
136+
# safetensors so the advertised ~90MB download remains accurate.
137+
assert "pytorch_model.bin" in ignored
138+
assert "rust_model.ot" in ignored
139+
# Never use a broad pattern that would also exclude custom/BGE-M3
140+
# repositories whose only supported checkpoint is a .bin file.
138141
assert "*.bin" not in ignored
142+
assert "*.safetensors" not in ignored
139143
assert "model.safetensors" not in ignored
144+
145+
def test_prefetch_keeps_bin_for_model_without_safetensors(self, tmp_path: Path, monkeypatch) -> None:
146+
captured: dict[str, object] = {}
147+
148+
def fake_snapshot_download(**kwargs: object) -> str:
149+
captured.update(kwargs)
150+
return str(kwargs["local_dir"])
151+
152+
import huggingface_hub
153+
154+
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
155+
156+
catalog.prefetch_model("BAAI/bge-m3", tmp_path)
157+
158+
ignored = captured["ignore_patterns"]
159+
assert isinstance(ignored, list)
140160
assert "pytorch_model.bin" not in ignored
161+
assert "*.bin" not in ignored
141162

142163

143164
def test_region_auto_prefers_official_when_faster(monkeypatch) -> None:

0 commit comments

Comments
 (0)