Files
unslothai--unsloth/studio/backend/hub/utils/inventory_scan.py
T
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

590 lines
20 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""HF cache inventory scanner.
Read-only walks of the HuggingFace hub cache plus legacy/default
cache locations. Builds the foundation that Hub inventory endpoints
and the DownloadRegistry both consume.
The worker spawn / transport-marker preparation / DownloadRegistry
layers built on top of these primitives live in download_registry.py.
"""
from __future__ import annotations
import hashlib
import re
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from loggers import get_logger
logger = get_logger(__name__)
from hub.utils.gguf import (
extract_quant_label,
is_gguf_filename,
is_mmproj_filename,
is_mtp_drafter_path,
)
from hub.utils.state_dir import RepoType
from hub.utils.hf_cache_state import (
INCOMPLETE_SUFFIX,
has_incomplete_blobs,
hf_cache_root,
iter_repo_cache_dirs,
latest_snapshot_dir,
repo_cache_dir_has_incomplete_blobs,
)
# Inventory is invalidated explicitly on every app-driven cache mutation, so
# this TTL only bounds staleness from out-of-band edits while skipping re-walks
# on rapid UI navigation.
_HF_CACHE_SCANS_TTL_SECONDS = 15.0
_GGUF_SPLIT_RE = re.compile(r"-(\d{3,})-of-(\d{3,})(?=\.gguf$)", re.IGNORECASE)
_hf_cache_scans_lock = threading.Lock()
@dataclass
class _HfCacheScanFlight:
event: threading.Event
epoch: int
result: Optional[list] = None
error: Optional[BaseException] = None
_hf_cache_scans_flight: Optional[_HfCacheScanFlight] = None
_hf_cache_scans_result: Optional[list] = None
_hf_cache_scans_cached_at: float = 0.0
# Bumped on every invalidation. A scan tags itself with the epoch it began
# under; an invalidation mid-scan changes the epoch so the in-flight result is
# neither cached nor served to callers that arrived after the mutation.
_hf_cache_scans_epoch: int = 0
def invalidate_hf_cache_scans() -> None:
global _hf_cache_scans_result, _hf_cache_scans_cached_at, _hf_cache_scans_epoch
with _hf_cache_scans_lock:
_hf_cache_scans_result = None
_hf_cache_scans_cached_at = 0.0
_hf_cache_scans_epoch += 1
def all_hf_cache_scans() -> list:
global _hf_cache_scans_flight, _hf_cache_scans_result, _hf_cache_scans_cached_at
now = time.monotonic()
with _hf_cache_scans_lock:
if (
_hf_cache_scans_result is not None
and (now - _hf_cache_scans_cached_at) < _HF_CACHE_SCANS_TTL_SECONDS
):
return list(_hf_cache_scans_result)
start_epoch = _hf_cache_scans_epoch
flight = _hf_cache_scans_flight
# Only coalesce onto an in-flight scan from the current epoch; one that
# began before an intervening invalidation is superseded so
# post-mutation callers never receive pre-mutation data.
if flight is None or flight.epoch != start_epoch:
flight = _HfCacheScanFlight(event = threading.Event(), epoch = start_epoch)
_hf_cache_scans_flight = flight
owner = True
else:
owner = False
if not owner:
flight.event.wait()
if flight.error is not None:
raise flight.error
return list(flight.result or [])
try:
scans = _compute_all_hf_cache_scans()
with _hf_cache_scans_lock:
flight.result = scans
if _hf_cache_scans_epoch == flight.epoch:
_hf_cache_scans_result = scans
_hf_cache_scans_cached_at = time.monotonic()
return scans
except Exception as exc:
with _hf_cache_scans_lock:
if _hf_cache_scans_epoch == flight.epoch:
_hf_cache_scans_result = None
_hf_cache_scans_cached_at = 0.0
flight.error = exc
raise
finally:
with _hf_cache_scans_lock:
if _hf_cache_scans_flight is flight:
_hf_cache_scans_flight = None
flight.event.set()
def _compute_all_hf_cache_scans() -> list:
from huggingface_hub import scan_cache_dir
from hub.utils.paths import legacy_hf_cache_dir, hf_default_cache_dir
scans: list = []
seen: set[str] = set()
try:
from huggingface_hub.constants import HF_HUB_CACHE
active = Path(HF_HUB_CACHE).resolve()
seen.add(str(active))
if active.is_dir():
scans.append(scan_cache_dir())
except Exception as exc:
logger.warning("Could not scan active HF cache: %s", exc)
for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir):
try:
extra = extra_fn()
# is_dir()/resolve() can raise on an inaccessible path; skip it.
if not extra.is_dir():
continue
resolved = str(extra.resolve())
if resolved in seen:
continue
seen.add(resolved)
scans.append(scan_cache_dir(cache_dir = str(extra)))
except Exception as exc:
logger.warning("Could not scan HF cache %s: %s", extra_fn.__name__, exc)
return scans
def token_fingerprint(hf_token: Optional[str]) -> str:
"""16-char SHA256 prefix used as a cache-key qualifier for gated repos.
Lets per-token size/snapshot caches refuse to serve a previously
fetched value back to a different token (a private/gated repo's
metadata is only valid for the credential that fetched it).
"""
if not hf_token:
return ""
return hashlib.sha256(hf_token.encode()).hexdigest()[:16]
def resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
"""Pick the most useful on-disk path for a HF cache repo dir.
Prefers the most-recent snapshot dir (what ``from_pretrained``
actually points at). Falls back to the cache repo root. Returns the
resolved realpath so symlinks under ``snapshots/`` are followed back
to ``blobs/``.
"""
try:
latest = latest_snapshot_dir(repo_dir)
if latest is not None:
return str(latest.resolve())
return str(repo_dir.resolve())
except Exception:
return None
def resolve_snapshot_dir_for_scan(
repo_type: str,
repo_id: str,
repo_cache_dir: Optional[Path] = None,
) -> Optional[Path]:
"""Latest snapshot dir for a cache row, or the first populated HF cache root.
Scanner-side counterpart to snapshot_download()'s return value (which the
scanner cannot access). With a *repo_cache_dir*, returns its newest
snapshot. Otherwise scans roots in priority order (active, legacy, default)
and returns the newest snapshot in the first root that holds one; active is
where snapshot_download writes, so it is authoritative. Within a root,
picks by mtime (what from_pretrained resolves to) rather than refs/main,
since the user may have downloaded a non-main commit.
"""
if repo_cache_dir is not None:
latest = latest_snapshot_dir(repo_cache_dir)
if latest is None:
return None
try:
return latest.resolve()
except OSError:
return None
for repo_dir in iter_repo_cache_dirs(repo_type, repo_id):
latest = latest_snapshot_dir(repo_dir)
if latest is None:
continue
try:
return latest.resolve()
except OSError:
continue
return None
def _compose_partial(*signals: Callable[[], bool]) -> bool:
return any(signal() for signal in signals)
def _state_applies_to_repo_cache_dir(repo_cache_dir: Optional[Path]) -> bool:
if repo_cache_dir is None:
return True
root = hf_cache_root()
if root is None:
return False
try:
return repo_cache_dir.resolve().parent == root.resolve()
except OSError:
return False
def _legacy_partial(
repo_type: str,
repo_id: str,
repo_cache_dir: Optional[Path] = None,
) -> bool:
if repo_cache_dir is not None:
return repo_cache_dir_has_incomplete_blobs(repo_cache_dir)
return has_incomplete_blobs(repo_type, repo_id)
def _repo_cache_dir_incomplete_hashes(repo_cache_dir: Path) -> set[str]:
blobs_dir = repo_cache_dir / "blobs"
if not blobs_dir.is_dir():
return set()
hashes: set[str] = set()
try:
entries = list(blobs_dir.iterdir())
except OSError:
return hashes
for blob in entries:
try:
if blob.is_file() and blob.name.endswith(INCOMPLETE_SUFFIX):
hashes.add(blob.name[: -len(INCOMPLETE_SUFFIX)])
except OSError:
continue
return hashes
def _repo_cache_dir_has_non_gguf_broken_snapshot_symlinks(repo_cache_dir: Path) -> bool:
latest = latest_snapshot_dir(repo_cache_dir)
if latest is None:
return False
try:
entries = list(latest.rglob("*"))
except OSError:
return False
for entry in entries:
try:
if not entry.is_symlink() or entry.exists():
continue
rel = entry.relative_to(latest).as_posix()
if is_gguf_filename(rel):
continue
return True
except OSError:
continue
return False
def _gguf_variant_manifest_blob_hashes(repo_id: str) -> frozenset[str]:
from hub.utils import download_manifest
hashes: set[str] = set()
for variant, _path in download_manifest.iter_variant_manifests("model", repo_id):
manifest = download_manifest.read_manifest("model", repo_id, variant)
if manifest is None:
continue
for expected in manifest.expected_files:
if expected.sha256 and is_gguf_filename(expected.path):
hashes.add(expected.sha256)
return frozenset(hashes)
def _repo_cache_dir_has_snapshot_legacy_partial(
repo_cache_dir: Path, *, ignored_blob_hashes: frozenset[str]
) -> bool:
incomplete_hashes = _repo_cache_dir_incomplete_hashes(repo_cache_dir)
if any(blob_hash not in ignored_blob_hashes for blob_hash in incomplete_hashes):
return True
return _repo_cache_dir_has_non_gguf_broken_snapshot_symlinks(repo_cache_dir)
def _snapshot_legacy_partial(
repo_type: str,
repo_id: str,
repo_cache_dir: Optional[Path] = None,
) -> bool:
if repo_type != "model":
return _legacy_partial(repo_type, repo_id, repo_cache_dir)
ignored_hashes = _gguf_variant_manifest_blob_hashes(repo_id)
if repo_cache_dir is not None:
return _repo_cache_dir_has_snapshot_legacy_partial(
repo_cache_dir,
ignored_blob_hashes = ignored_hashes,
)
return any(
_repo_cache_dir_has_snapshot_legacy_partial(
entry,
ignored_blob_hashes = ignored_hashes,
)
for entry in iter_repo_cache_dirs(repo_type, repo_id)
)
def _completed_gguf_variants(snapshot_dir: Optional[Path]) -> set[str]:
if snapshot_dir is None:
return set()
complete: set[str] = set()
split_groups: dict[str, dict[int, set[int]]] = {}
try:
paths = list(snapshot_dir.rglob("*"))
except OSError:
return set()
for path in paths:
try:
if not path.is_file() or path.stat().st_size <= 0:
continue
except OSError:
continue
rel = path.relative_to(snapshot_dir).as_posix()
if not is_gguf_filename(rel) or is_mmproj_filename(rel) or is_mtp_drafter_path(rel):
continue
quant = extract_quant_label(rel)
split = _GGUF_SPLIT_RE.search(path.name)
if split is None:
complete.add(quant)
continue
index = int(split.group(1))
total = int(split.group(2))
if index <= 0 or total <= 0 or index > total:
continue
split_groups.setdefault(quant, {}).setdefault(total, set()).add(index)
for quant, groups in split_groups.items():
for total, indices in groups.items():
if indices == set(range(1, total + 1)):
complete.add(quant)
break
return complete
def _manifest_partial(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
snapshot_dir: Optional[Path] = None,
repo_cache_dir: Optional[Path] = None,
) -> bool:
from hub.utils import download_manifest
if not _state_applies_to_repo_cache_dir(repo_cache_dir):
return False
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
if manifest is None:
return False
resolved = (
snapshot_dir
if snapshot_dir is not None
else resolve_snapshot_dir_for_scan(repo_type, repo_id, repo_cache_dir)
)
if resolved is None:
return True
if repo_type == "model" and variant is not None:
if download_manifest.verify_against_disk(manifest, resolved).ok:
return False
for candidate in _manifest_snapshot_dirs(repo_type, repo_id, repo_cache_dir):
if candidate == resolved:
continue
if download_manifest.verify_against_disk(manifest, candidate).ok:
return False
return True
return not download_manifest.verify_against_disk(manifest, resolved).ok
def _manifest_snapshot_dirs(
repo_type: RepoType,
repo_id: str,
repo_cache_dir: Optional[Path] = None,
) -> list[Path]:
repo_dirs = (
[repo_cache_dir]
if repo_cache_dir is not None
else list(iter_repo_cache_dirs(repo_type, repo_id))
)
snapshots: list[Path] = []
seen: set[str] = set()
for repo_dir in repo_dirs:
if repo_dir is None:
continue
snapshots_dir = repo_dir / "snapshots"
try:
if not snapshots_dir.is_dir():
continue
entries = list(snapshots_dir.iterdir())
except OSError:
continue
for entry in entries:
try:
if not entry.is_dir():
continue
resolved = entry.resolve()
except OSError:
continue
key = str(resolved)
if key in seen:
continue
seen.add(key)
snapshots.append(resolved)
return snapshots
def is_snapshot_partial(
repo_type: RepoType,
repo_id: str,
repo_cache_dir: Optional[Path] = None,
) -> bool:
"""Repo-row partial flag for snapshot-style downloads (full-snapshot
models — safetensors/adapter/checkpoint — and all datasets).
Composes three signals, cheapest first:
1. Cancel marker (single stat).
2. Snapshot-attributed legacy .incomplete blob / broken-symlink check.
3. Manifest walk (stat per expected file under the latest snapshot).
A manifest without a resolvable snapshot is partial: the worker got
far enough to record expectations but did not leave a usable snapshot."""
from hub.utils import download_manifest
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
return _compose_partial(
lambda: state_applies and download_manifest.has_cancel_marker(repo_type, repo_id, None),
lambda: _snapshot_legacy_partial(repo_type, repo_id, repo_cache_dir),
lambda: _manifest_partial(
repo_type,
repo_id,
None,
None,
repo_cache_dir,
),
)
def is_variant_partial(
repo_id: str,
variant: str,
snapshot_dir: Optional[Path] = None,
*,
incomplete_blob_hashes: Optional[set[str]] = None,
variant_blob_hashes: Optional[frozenset[str]] = None,
repo_cache_dir: Optional[Path] = None,
) -> bool:
"""Per-variant partial detection. Owns its manifest, owns its marker.
Used by the GGUF variants endpoint to flag a specific quant as broken
without contaminating other quants in the same repo.
snapshot_dir is an optional hint to avoid re-walking the cache when a
caller is checking many variants of the same repo (see
is_gguf_repo_partial for that usage)."""
from hub.utils import download_manifest
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
return _compose_partial(
lambda: state_applies and download_manifest.has_cancel_marker("model", repo_id, variant),
lambda: bool(
incomplete_blob_hashes
and variant_blob_hashes
and incomplete_blob_hashes.intersection(variant_blob_hashes)
),
lambda: _manifest_partial(
"model",
repo_id,
variant,
snapshot_dir,
repo_cache_dir,
),
)
def is_gguf_repo_partial(repo_id: str, repo_cache_dir: Optional[Path] = None) -> bool:
"""Repo-row partial flag for a GGUF repo. The inventory shows ONE row per
GGUF repo (requires_variant=True); per-variant detail lives in
GET /api/models/gguf-variants and uses is_variant_partial.
*** DO NOT simplify this to "any variant partial -> repo partial" ***
Tripwire scenario: user downloads Q8_0 fully, then starts Q4_K_M and
cancels. Both variants share ONE inventory row. If row.partial flips True,
_capabilities_for_format flips can_chat=False, so the user can no longer
chat with the perfectly-good Q8_0 because of an unrelated cancelled Q4_K_M.
Correct semantics: partial=True only when at least one variant is broken
AND no other variant is clean. "Simplifying" to the obvious "any broken"
form re-introduces this Q8+Q4 mixed-state regression.
Composes signals:
1. Cheap legacy fast-path (.incomplete blobs / broken symlinks).
2. Per-variant manifest + marker enumeration, gated on "all broken".
"""
from hub.utils import download_manifest
has_legacy_partial = _legacy_partial("model", repo_id, repo_cache_dir)
state_applies = _state_applies_to_repo_cache_dir(repo_cache_dir)
snapshot_dir = resolve_snapshot_dir_for_scan(
"model",
repo_id,
repo_cache_dir,
)
variants: set[str] = set(_completed_gguf_variants(snapshot_dir))
if state_applies:
for variant, _path in download_manifest.iter_variant_manifests(
"model",
repo_id,
):
variants.add(variant)
for variant, _path in download_manifest.iter_variant_markers(
"model",
repo_id,
):
variants.add(variant)
if not variants:
return has_legacy_partial
has_clean = False
has_broken = has_legacy_partial
for variant in variants:
if is_variant_partial(
repo_id,
variant,
snapshot_dir,
repo_cache_dir = repo_cache_dir,
):
has_broken = True
else:
has_clean = True
return has_broken and not has_clean
def partial_transport_for(
repo_type: RepoType,
repo_id: str,
variant: Optional[str] = None,
repo_cache_dir: Optional[Path] = None,
) -> Optional[str]:
"""Transport to surface on a partial row's resume affordance.
Prefers the cancel marker's transport, then the manifest's. The fallback
matters for rows partial without a marker (an errored/interrupted download
leaves the manifest but no marker) so the UI can still show HTTP-resume vs
XET-redownload instead of the neutral retry label. ``None`` when neither is
available."""
from hub.utils import download_manifest
if not _state_applies_to_repo_cache_dir(repo_cache_dir):
return None
marker_transport = download_manifest.read_cancel_marker_transport(
repo_type,
repo_id,
variant,
)
if marker_transport is not None:
return marker_transport
manifest = download_manifest.read_manifest(repo_type, repo_id, variant)
return manifest.transport if manifest is not None else None