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
475 lines
16 KiB
Python
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
|