* fix(models): disk fallback when scan_cache_dir raises WinError 448 (#128) plan-01 fix-sequence step 2. On Windows, huggingface_hub's scan_cache_dir() raises WinError 448 "untrusted mount point"; the three call sites in setup/models.py swallowed it and reported "not cached", so the app re-downloaded models it already had — looping 5× and giving up (#117/#118). - _is_cached_on_disk / _scan_cache_on_disk: walk the canonical HF layout <cache>/models--<org>--<name>/snapshots/<rev>/ directly (honours HF_HUB_CACHE/HF_HOME, so a relocated models dir works too). - is_cached / list_models / recommendations now fall back to the disk scan when scan_cache_dir() raises. An empty snapshot dir is not counted. Symlink-disable env + local_dir_use_symlinks=False were already shipped (main.py, setup/download.py); this closes the remaining failure path. Tests (TDD, fail-before/pass-after): tests/test_hf_cache_fallback.py (4). No regression on the non-Windows path (fallback only triggers on raise). Closes #117, #118. Addresses #128 (#64 configurable-dir is the follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(models): probe HF /hub subdir + close scandir handle (bot review) Addresses #137 review: - CodeRabbit (critical): hf_cache_dir() returns HF_HOME when HF_HUB_CACHE is unset, but repos live under $HF_HOME/hub/models--…. Added _hub_cache_roots() so the WinError-448 fallback probes both <dir> (HF_HUB_CACHE-set case) and <dir>/hub (HF_HOME-only case); previously it could miss the cache and re-download. Regression test added (HF_HOME-only layout). - Greptile: wrap os.scandir() in `with` so the dir handle closes even when any() short-circuits (avoids handle leaks on repeated /models polls). - CodeQL: drop unused `os` import in the test. 5 tests pass, incl. -W error::ResourceWarning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
75 lines
3.2 KiB
Python
75 lines
3.2 KiB
Python
"""plan-01 (#128) — HF cache detection must survive scan_cache_dir failures.
|
||
|
||
On Windows, huggingface_hub's ``scan_cache_dir()`` can raise
|
||
``OSError WinError 448 'untrusted mount point'``. The old code caught that and
|
||
returned "not cached", so the app re-downloaded models it already had — looping
|
||
5× and giving up (#117/#118). These tests force ``scan_cache_dir`` to raise and
|
||
assert a direct-filesystem fallback still recognises a cached repo.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from api.routers.setup import models
|
||
|
||
|
||
def _make_fake_cache(tmp_path, repo_id="k2-fsa/OmniVoice"):
|
||
"""Create the canonical HF cache layout for repo_id under tmp_path."""
|
||
name = "models--" + repo_id.replace("/", "--")
|
||
snap = tmp_path / name / "snapshots" / "abc123def456"
|
||
snap.mkdir(parents=True)
|
||
(snap / "model.bin").write_bytes(b"x" * 2048)
|
||
return repo_id
|
||
|
||
|
||
def _raise_winerror(*a, **k):
|
||
raise OSError(22, "[WinError 448] The specified network resource is no longer available")
|
||
|
||
|
||
def test_is_cached_falls_back_to_disk_when_scan_raises(tmp_path, monkeypatch):
|
||
repo = _make_fake_cache(tmp_path)
|
||
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
|
||
monkeypatch.setattr("huggingface_hub.scan_cache_dir", _raise_winerror)
|
||
models.invalidate_cache()
|
||
assert models.is_cached(repo) is True
|
||
|
||
|
||
def test_is_cached_false_for_uncached_repo_when_scan_raises(tmp_path, monkeypatch):
|
||
_make_fake_cache(tmp_path) # a different repo is present
|
||
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
|
||
monkeypatch.setattr("huggingface_hub.scan_cache_dir", _raise_winerror)
|
||
assert models.is_cached("not-here/model") is False
|
||
|
||
|
||
def test_disk_scan_reports_size_and_files(tmp_path, monkeypatch):
|
||
repo = _make_fake_cache(tmp_path)
|
||
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
|
||
found = models._scan_cache_on_disk()
|
||
assert repo in found
|
||
assert found[repo]["nb_files"] >= 1
|
||
assert found[repo]["size_on_disk"] >= 2048
|
||
|
||
|
||
def test_hf_home_only_finds_repo_under_hub_subdir(tmp_path, monkeypatch):
|
||
# When only HF_HOME is set, HF stores repos under $HF_HOME/hub/models--…
|
||
# The fallback must probe the /hub subdir, not just the root (CodeRabbit #137).
|
||
repo_id = "k2-fsa/OmniVoice"
|
||
name = "models--" + repo_id.replace("/", "--")
|
||
snap = tmp_path / "hub" / name / "snapshots" / "rev1"
|
||
snap.mkdir(parents=True)
|
||
(snap / "model.bin").write_bytes(b"x" * 1024)
|
||
monkeypatch.delenv("HF_HUB_CACHE", raising=False)
|
||
monkeypatch.delenv("HUGGINGFACE_HUB_CACHE", raising=False)
|
||
monkeypatch.setenv("HF_HOME", str(tmp_path))
|
||
monkeypatch.setattr("huggingface_hub.scan_cache_dir", _raise_winerror)
|
||
models.invalidate_cache()
|
||
assert models.is_cached(repo_id) is True
|
||
assert repo_id in models._scan_cache_on_disk()
|
||
|
||
|
||
def test_empty_snapshot_dir_not_counted_as_cached(tmp_path, monkeypatch):
|
||
# A repo dir with an empty snapshots/<rev>/ (interrupted download) is NOT cached.
|
||
name = "models--org--half"
|
||
(tmp_path / name / "snapshots" / "rev0").mkdir(parents=True)
|
||
monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path))
|
||
monkeypatch.setattr("huggingface_hub.scan_cache_dir", _raise_winerror)
|
||
assert models.is_cached("org/half") is False
|