# 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", "") 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", ""), 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