Files
unslothai--unsloth/studio/backend/hub/services/datasets/cache_inventory.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

475 lines
16 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
"""Cached dataset inventory and deletion services."""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import Optional
from fastapi import HTTPException
from loggers import get_logger
from hub.services import resolve_destructive_repo_ids
from hub.services.datasets import downloads
from hub.utils import download_manifest
from hub.utils import inventory_scan as hf_cache_scan
from hub.utils.hf_cache_state import (
purge_partial_repo,
purge_repo_cache_dirs,
resolve_destructive_case_matches,
)
from hub.utils.paths import (
hf_default_cache_dir,
is_valid_repo_id as _is_valid_repo_id,
legacy_hf_cache_dir,
resolve_cached_repo_id_case,
)
logger = get_logger(__name__)
def _collect_hf_cache_scans() -> tuple[list, set[str]]:
scans = hf_cache_scan.all_hf_cache_scans()
seen_roots = {
str(cache_dir)
for cache_dir in (getattr(scan, "cache_dir", None) for scan in scans)
if cache_dir is not None
}
return scans, seen_roots
def _hf_hub_cache_roots() -> list[Path]:
roots: list[Path] = []
seen: set[str] = set()
def _add(path: Optional[Path]) -> None:
if path is None or not path.is_dir():
return
try:
resolved = str(path.resolve())
except OSError:
return
if resolved in seen:
return
seen.add(resolved)
roots.append(path)
try:
from huggingface_hub.constants import HF_HUB_CACHE
_add(Path(HF_HUB_CACHE))
except Exception:
pass
hf_hub_cache = os.environ.get("HF_HUB_CACHE")
if hf_hub_cache:
_add(Path(hf_hub_cache).expanduser())
hf_home = os.environ.get("HF_HOME")
if hf_home:
_add(Path(hf_home).expanduser() / "hub")
_add(legacy_hf_cache_dir())
_add(hf_default_cache_dir())
return roots
def _repo_id_from_hub_dataset_dir(name: str) -> str | None:
if not name.startswith("datasets--"):
return None
encoded = name.removeprefix("datasets--")
owner, sep, repo = encoded.partition("--")
if not sep or not owner or not repo:
return None
repo_id = f"{owner}/{repo}"
return repo_id if _is_valid_repo_id(repo_id) else None
def _directory_size(path: Path) -> int:
total = 0
try:
for entry in path.rglob("*"):
try:
if entry.is_file() and not entry.is_symlink():
total += entry.stat().st_size
except OSError:
continue
except OSError:
return 0
return total
def _prefer_dataset_cache_row(candidate: dict, existing: Optional[dict]) -> bool:
if existing is None:
return True
candidate_partial = bool(candidate.get("partial"))
existing_partial = bool(existing.get("partial"))
if candidate_partial != existing_partial:
return not candidate_partial
return int(candidate.get("size_bytes") or 0) > int(existing.get("size_bytes") or 0)
def _hub_dataset_snapshot_count(path: Path) -> int:
snapshots = path / "snapshots"
try:
return sum(1 for entry in snapshots.iterdir() if entry.is_dir())
except OSError:
return 0
def _scan_hub_dataset_cache_dirs() -> list[dict]:
"""Fallback scanner: ``scan_cache_dir()`` skips repos when one cache entry is partially corrupt, so this keeps On Device matching disk."""
seen_lower: dict[str, dict] = {}
for root in _hf_hub_cache_roots():
try:
entries = [entry for entry in root.iterdir() if entry.is_dir()]
except OSError:
continue
for entry in entries:
repo_id = _repo_id_from_hub_dataset_dir(entry.name)
if repo_id is None:
continue
size_bytes = _directory_size(entry / "blobs")
if size_bytes <= 0:
size_bytes = _directory_size(entry)
if size_bytes <= 0:
continue
key = repo_id.lower()
existing = seen_lower.get(key)
snapshot_partial = _hub_dataset_snapshot_count(
entry
) == 0 or hf_cache_scan.is_snapshot_partial("dataset", repo_id, entry)
row = {
"repo_id": repo_id,
"size_bytes": size_bytes,
"cache_path": str(entry.resolve()),
# snapshot_count == 0 catches blobs-but-no-snapshot;
# is_snapshot_partial adds active-row state checks.
"partial": snapshot_partial,
"partial_transport": (
hf_cache_scan.partial_transport_for(
"dataset",
repo_id,
repo_cache_dir = entry,
)
if snapshot_partial
else None
),
}
if _prefer_dataset_cache_row(row, existing):
seen_lower[key] = row
return sorted(seen_lower.values(), key = lambda c: c["repo_id"])
def _hf_datasets_cache_roots() -> list[Path]:
roots: list[Path] = []
seen: set[str] = set()
def _add(path: Optional[Path]) -> None:
if path is None or not path.is_dir():
return
try:
resolved = str(path.resolve())
except OSError:
return
if resolved in seen:
return
seen.add(resolved)
roots.append(path)
env_cache = os.environ.get("HF_DATASETS_CACHE")
if env_cache:
_add(Path(env_cache).expanduser())
try:
from datasets import config as datasets_config
_add(Path(datasets_config.HF_DATASETS_CACHE))
except Exception:
pass
hf_home = os.environ.get("HF_HOME")
if hf_home:
_add(Path(hf_home).expanduser() / "datasets")
xdg_cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")).expanduser()
_add(xdg_cache / "huggingface" / "datasets")
return roots
def _repo_id_from_datasets_cache_dir(name: str) -> str | None:
if "___" not in name:
return None
owner, repo = name.split("___", 1)
repo_id = f"{owner}/{repo}"
return repo_id if _is_valid_repo_id(repo_id) else None
def _processed_dataset_cache_size(path: Path) -> int:
total = 0
try:
for entry in path.rglob("*"):
try:
if entry.is_file():
total += entry.stat().st_size
except OSError:
continue
except OSError:
return 0
return total
def _looks_like_processed_dataset_cache(path: Path) -> bool:
try:
for entry in path.rglob("*"):
if not entry.is_file():
continue
if entry.name in {"dataset_info.json", "state.json"}:
return True
if entry.suffix == ".arrow":
return True
except OSError:
return False
return False
def _scan_processed_dataset_caches() -> list[dict]:
"""`load_dataset()` stores processed Arrow caches separately from the Hub snapshot cache, so they're usable on-device but invisible to `scan_cache_dir()`."""
seen_lower: dict[str, dict] = {}
for root in _hf_datasets_cache_roots():
try:
entries = [entry for entry in root.iterdir() if entry.is_dir()]
except OSError:
continue
for entry in entries:
repo_id = _repo_id_from_datasets_cache_dir(entry.name)
if repo_id is None:
continue
if not _looks_like_processed_dataset_cache(entry):
continue
size_bytes = _processed_dataset_cache_size(entry)
if size_bytes <= 0:
continue
key = repo_id.lower()
existing = seen_lower.get(key)
if existing is None or size_bytes > existing["size_bytes"]:
seen_lower[key] = {
"repo_id": repo_id,
"size_bytes": size_bytes,
"cache_path": str(entry.resolve()),
"processed_cache": True,
"partial": False,
}
return sorted(seen_lower.values(), key = lambda c: c["repo_id"])
def _scan_hf_dataset_caches() -> list[dict]:
scans, seen_roots = _collect_hf_cache_scans()
seen_lower: dict[str, dict] = {}
inspected = 0
for hf_cache in scans:
for repo_info in hf_cache.repos:
inspected += 1
try:
# str(...) guards against the library switching repo_type to an Enum.
if str(repo_info.repo_type) != "dataset":
continue
total_size = int(getattr(repo_info, "size_on_disk", 0) or 0)
if total_size == 0:
unique_blobs: dict[str, int] = {}
for rev in repo_info.revisions:
rev_id = getattr(rev, "commit_hash", None) or str(id(rev))
for f in rev.files:
blob_path = getattr(f, "blob_path", None)
key = str(blob_path) if blob_path else f"{rev_id}:{f.file_name}"
unique_blobs[key] = int(f.size_on_disk or 0)
total_size = sum(unique_blobs.values())
key = repo_info.repo_id.lower()
existing = seen_lower.get(key)
cache_dir = Path(repo_info.repo_path)
snapshot_partial = hf_cache_scan.is_snapshot_partial(
"dataset",
repo_info.repo_id,
cache_dir,
)
row = {
"repo_id": repo_info.repo_id,
"size_bytes": total_size,
"cache_path": str(repo_info.repo_path),
"partial": snapshot_partial,
"partial_transport": (
hf_cache_scan.partial_transport_for(
"dataset",
repo_info.repo_id,
repo_cache_dir = cache_dir,
)
if snapshot_partial
else None
),
}
if _prefer_dataset_cache_row(row, existing):
seen_lower[key] = row
except Exception as exc:
label = getattr(repo_info, "repo_id", "<unknown>")
logger.warning("Skipping cached dataset repo %s: %s", label, exc)
for row in _scan_hub_dataset_cache_dirs():
key = row["repo_id"].lower()
existing = seen_lower.get(key)
if _prefer_dataset_cache_row(row, existing):
seen_lower[key] = row
elif existing is not None and bool(existing.get("partial")) == bool(row.get("partial")):
existing["size_bytes"] = max(existing["size_bytes"], row["size_bytes"])
existing["cache_path"] = existing.get("cache_path") or row.get("cache_path")
if (
existing.get("partial")
and not existing.get("partial_transport")
and row.get("partial_transport")
):
existing["partial_transport"] = row["partial_transport"]
for row in _scan_processed_dataset_caches():
key = row["repo_id"].lower()
existing = seen_lower.get(key)
if existing is None or (bool(existing.get("partial")) and not bool(row.get("partial"))):
seen_lower[key] = row
else:
existing["size_bytes"] = max(existing["size_bytes"], row["size_bytes"])
# Keep the processed-cache marker when a repo is both snapshot and
# processed Arrow cache; merging by size alone dropped it.
if row.get("processed_cache"):
existing["processed_cache"] = True
logger.info(
"Cached dataset scan: roots=%d inspected=%d returned=%d",
len(seen_roots) or len(scans),
inspected,
len(seen_lower),
)
return sorted(seen_lower.values(), key = lambda c: c["repo_id"])
async def list_cached_datasets_response() -> dict:
"""List dataset repos already downloaded into the HF cache."""
try:
return {"cached": await asyncio.to_thread(_scan_hf_dataset_caches)}
except Exception as exc:
logger.error("Error listing cached datasets: %s", exc, exc_info = True)
raise HTTPException(
status_code = 500,
detail = "Failed to read the local dataset cache.",
) from exc
async def delete_cached_dataset_response(repo_id: str) -> dict:
"""Remove a cached dataset repo from the HF cache."""
if not _is_valid_repo_id(repo_id):
raise HTTPException(status_code = 400, detail = "Invalid repo_id format")
repo_key = await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "dataset")
if not downloads.registry.begin_delete(repo_key):
raise HTTPException(
status_code = 400,
detail = "Cancel the active download before deleting.",
)
try:
return await asyncio.to_thread(_delete_cached_dataset_blocking, repo_key)
finally:
downloads.registry.end_delete(repo_key)
hf_cache_scan.invalidate_hf_cache_scans()
def _delete_cached_dataset_blocking(repo_id: str) -> dict:
scans, _seen_roots = _collect_hf_cache_scans()
candidate_entries = []
for hf_cache in scans:
for repo_info in hf_cache.repos:
if str(repo_info.repo_type) != "dataset":
continue
if repo_info.repo_id.lower() == repo_id.lower():
candidate_entries.append((hf_cache, repo_info))
matched_repo_ids = resolve_destructive_repo_ids(
repo_id,
[str(repo_info.repo_id) for _hf_cache, repo_info in candidate_entries],
noun = "datasets",
)
deleted = False
failures: list[str] = []
for hf_cache, repo_info in candidate_entries:
if str(repo_info.repo_id) not in matched_repo_ids:
continue
try:
strategy = hf_cache.delete_revisions(*(rev.commit_hash for rev in repo_info.revisions))
strategy.execute()
deleted = True
except Exception as exc:
failures.append(str(exc))
logger.error(
"Failed deleting cached dataset %s from %s: %s",
repo_id,
getattr(hf_cache, "cache_dir", "<unknown>"),
exc,
exc_info = True,
)
processed_deleted, processed_failures = _delete_processed_dataset_cache(repo_id)
failures.extend(processed_failures)
if failures:
raise HTTPException(
status_code = 500,
detail = (
f"Failed to delete dataset from {len(failures)} cache "
"location(s). Some files may remain."
),
)
# ``scan_cache_dir()`` skips blob-only/corrupt repos the revision delete
# can't touch, yet the fallback scanner shows them; purge the whole dir.
cache_purged = purge_repo_cache_dirs("dataset", repo_id)
partial_purged = purge_partial_repo("dataset", repo_id)
state_purged = download_manifest.purge_all_state_for_repo("dataset", repo_id) > 0
if not (deleted or processed_deleted or cache_purged or partial_purged or state_purged):
raise HTTPException(status_code = 404, detail = "Dataset not found in cache")
return {"status": "deleted", "repo_id": repo_id}
def _delete_processed_dataset_cache(repo_id: str) -> tuple[bool, list[str]]:
import shutil
target = repo_id.replace("/", "___")
folded_target = target.lower()
deleted = False
failures: list[str] = []
for root in _hf_datasets_cache_roots():
try:
entries = [
entry
for entry in root.iterdir()
if entry.is_dir() and entry.name.lower() == folded_target
]
except OSError:
continue
matched_names = resolve_destructive_case_matches(
target,
(entry.name for entry in entries),
)
if not matched_names:
continue
for entry in entries:
if entry.name not in matched_names:
continue
try:
shutil.rmtree(entry)
deleted = True
except Exception as exc:
failures.append(str(exc))
logger.error(
"Failed deleting processed dataset cache %s: %s",
repo_id,
exc,
exc_info = True,
)
return deleted, failures