Files
unslothai--unsloth/studio/backend/hub/utils/paths.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

528 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
"""Path validators and storage roots for the Hub layer."""
from __future__ import annotations
import json
import os
import re
import sys
import tempfile
import threading
from collections import OrderedDict
from pathlib import Path
from typing import Optional
from loggers import get_logger
logger = get_logger(__name__)
def _infer_studio_home_from_venv() -> Optional[Path]:
try:
prefix = Path(sys.prefix).resolve()
except (OSError, ValueError):
return None
if prefix.name != "unsloth_studio":
return None
candidate = prefix.parent
shim_name = "unsloth.exe" if os.name == "nt" else "unsloth"
try:
if (candidate / "share" / "studio.conf").is_file() or (
candidate / "bin" / shim_name
).is_file():
return candidate
except OSError:
return None
return None
def studio_root() -> Path:
override = (os.environ.get("UNSLOTH_STUDIO_HOME") or "").strip()
if not override:
override = (os.environ.get("STUDIO_HOME") or "").strip()
if override:
try:
return Path(override).expanduser().resolve()
except (OSError, ValueError):
return Path(override).expanduser()
inferred = _infer_studio_home_from_venv()
if inferred is not None:
return inferred
return Path.home() / ".unsloth" / "studio"
def cache_root() -> Path:
return studio_root() / "cache"
def assets_root() -> Path:
return studio_root() / "assets"
def datasets_root() -> Path:
return assets_root() / "datasets"
def dataset_uploads_root() -> Path:
return datasets_root() / "uploads"
def recipe_datasets_root() -> Path:
return datasets_root() / "recipes"
def outputs_root() -> Path:
return studio_root() / "outputs"
def exports_root() -> Path:
return studio_root() / "exports"
def tmp_root() -> Path:
return Path(tempfile.gettempdir()) / "unsloth-studio"
def ensure_dir(path: Path) -> Path:
path.mkdir(parents = True, exist_ok = True)
return path
def legacy_hf_cache_dir() -> Path:
return cache_root() / "huggingface" / "hub"
def hf_default_cache_dir() -> Path:
return Path.home() / ".cache" / "huggingface" / "hub"
def _is_wsl() -> bool:
if sys.platform == "win32":
return False
try:
return "microsoft" in Path("/proc/version").read_text().lower()
except Exception:
return False
_IS_WSL = _is_wsl()
def _wsl_automount_root() -> str:
"""DrvFs root under which WSL maps Windows drives, with a trailing slash.
Defaults to ``/mnt/`` but is user-configurable via ``/etc/wsl.conf``
(``[automount] root``), so hard-coding ``/mnt/`` mistranslates Windows paths
on a host with a custom root (e.g. ``root = /`` → ``C:`` at ``/c/``)."""
default = "/mnt/"
if not _IS_WSL:
return default
try:
import configparser
parser = configparser.ConfigParser(inline_comment_prefixes = ("#", ";"))
parser.read("/etc/wsl.conf")
root = parser.get("automount", "root", fallback = "").strip().strip("\"'")
except Exception:
return default
if not root:
return default
return root if root.endswith("/") else f"{root}/"
_WSL_AUTOMOUNT_ROOT = _wsl_automount_root()
def normalize_path(path: str) -> str:
if not path:
return path
if len(path) >= 3 and path[1] == ":" and path[2] in ("\\", "/"):
if _IS_WSL:
drive = path[0].lower()
rest = path[3:].replace("\\", "/")
return f"{_WSL_AUTOMOUNT_ROOT}{drive}/{rest}"
return path.replace("\\", "/")
return path.replace("\\", "/")
def _host_path(path: str | Path) -> Path:
return Path(normalize_path(str(path))).expanduser()
def is_local_path(path: str) -> bool:
if not path:
return False
normalized = normalize_path(path)
has_local_syntax = (
path.startswith(("/", ".", "~"))
or ":" in path
or "\\" in path
or os.path.isabs(path)
or os.path.isabs(normalized)
)
if path.count("/") == 1 and not has_local_syntax:
return False
try:
if has_local_syntax and Path(normalized).expanduser().exists():
return True
except Exception:
pass
return has_local_syntax
_VALID_REPO_ID_SEGMENT = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$")
_MAX_REPO_ID_LENGTH = 96
def is_valid_repo_id(repo_id: str) -> bool:
"""Validate Hugging Face ``repo_name`` or ``namespace/repo_name`` IDs."""
if not repo_id or repo_id != repo_id.strip():
return False
if repo_id.endswith(".git"):
return False
if "--" in repo_id or ".." in repo_id:
return False
segments = repo_id.split("/")
if len(segments) not in (1, 2):
return False
# Match huggingface_hub.validate_repo_id: the 96-char limit applies per
# segment (repo name / namespace), not to the whole "namespace/repo_name"
# string, so long-but-valid repo names are not falsely rejected.
return all(
segment not in ("", ".", "..")
and len(segment) <= _MAX_REPO_ID_LENGTH
and _VALID_REPO_ID_SEGMENT.fullmatch(segment) is not None
for segment in segments
)
_GGUF_VARIANT_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
_MAX_GGUF_VARIANT_LENGTH = 512
def is_valid_gguf_variant(variant: str) -> bool:
"""Validate Hub GGUF variant keys.
Known quant labels are short tokens (``Q4_K_M``), but unknown GGUF layouts
use a snapshot-relative key derived from the filename and may contain
slashes or spaces.
"""
if not variant or variant != variant.strip():
return False
if len(variant) > _MAX_GGUF_VARIANT_LENGTH:
return False
if _GGUF_VARIANT_CONTROL_CHARS.search(variant) or not variant.isprintable():
return False
normalized = variant.replace("\\", "/")
return all(segment not in ("", ".", "..") for segment in normalized.split("/"))
def ollama_model_dirs() -> list[Path]:
"""Return Ollama model directories that exist on disk."""
dirs: list[Path] = []
seen: set[str] = set()
def _add(p: Path | str) -> None:
try:
expanded = _host_path(p)
resolved = expanded.resolve()
is_dir = expanded.is_dir()
except (OSError, RuntimeError, ValueError):
return
key = str(resolved)
if key in seen or not is_dir:
return
seen.add(key)
dirs.append(expanded)
ollama_env = os.environ.get("OLLAMA_MODELS")
if ollama_env:
_add(ollama_env)
_add(Path.home() / ".ollama" / "models")
_add(Path("/usr/share/ollama/.ollama/models"))
_add(Path("/var/lib/ollama/.ollama/models"))
return dirs
# Per-process memo for resolve_cached_repo_id_case. Bounded LRU so a long-lived
# process touching many repo ids can't grow it without limit; evicted cold
# entries simply recompute on next use.
_CACHE_CASE_RESOLUTION_MEMO_MAX = 512
_CACHE_CASE_RESOLUTION_MEMO: "OrderedDict[tuple[str, str], str]" = OrderedDict()
_CACHE_CASE_RESOLUTION_LOCK = threading.Lock()
def _memo_get(memo_key: tuple[str, str]) -> Optional[str]:
with _CACHE_CASE_RESOLUTION_LOCK:
value = _CACHE_CASE_RESOLUTION_MEMO.get(memo_key)
if value is not None:
_CACHE_CASE_RESOLUTION_MEMO.move_to_end(memo_key)
return value
def _memo_set(memo_key: tuple[str, str], value: str) -> None:
with _CACHE_CASE_RESOLUTION_LOCK:
_CACHE_CASE_RESOLUTION_MEMO[memo_key] = value
_CACHE_CASE_RESOLUTION_MEMO.move_to_end(memo_key)
while len(_CACHE_CASE_RESOLUTION_MEMO) > _CACHE_CASE_RESOLUTION_MEMO_MAX:
_CACHE_CASE_RESOLUTION_MEMO.popitem(last = False)
def _memo_drop(memo_key: tuple[str, str]) -> None:
with _CACHE_CASE_RESOLUTION_LOCK:
_CACHE_CASE_RESOLUTION_MEMO.pop(memo_key, None)
def _hf_hub_cache_dir() -> Path:
try:
from huggingface_hub.constants import HF_HUB_CACHE
return Path(HF_HUB_CACHE)
except Exception as exc:
logger.debug("Could not read huggingface_hub HF_HUB_CACHE, using default: %s", exc)
return Path.home() / ".cache" / "huggingface" / "hub"
def _hf_hub_cache_dirs() -> list[Path]:
roots: list[Path] = []
seen: set[str] = set()
def _add(path: Path) -> None:
try:
resolved = path.resolve()
except OSError:
return
key = str(resolved)
if key in seen or not resolved.is_dir():
return
seen.add(key)
roots.append(resolved)
_add(_hf_hub_cache_dir())
try:
_add(legacy_hf_cache_dir())
_add(hf_default_cache_dir())
except Exception as exc:
logger.debug("Could not enumerate secondary HF cache roots: %s", exc)
return roots
def lmstudio_model_dirs() -> list[Path]:
dirs: list[Path] = []
seen: set[str] = set()
def _add(path: Path | str) -> None:
try:
expanded = _host_path(path)
resolved = expanded.resolve()
except (OSError, RuntimeError, ValueError):
return
key = str(resolved)
if key in seen or not expanded.is_dir():
return
seen.add(key)
dirs.append(expanded)
settings_path = Path.home() / ".lmstudio" / "settings.json"
if settings_path.is_file():
try:
settings = json.loads(settings_path.read_text(encoding = "utf-8"))
downloads = settings.get("downloadsFolder", "")
if downloads:
_add(downloads)
except Exception:
pass
_add(Path.home() / ".lmstudio" / "models")
_add(Path.home() / ".cache" / "lm-studio" / "models")
return dirs
def well_known_model_dirs() -> list[Path]:
candidates: list[Path] = []
candidates.extend(lmstudio_model_dirs())
candidates.extend(ollama_model_dirs())
candidates.append(Path.home() / ".cache" / "huggingface" / "hub")
candidates.append(Path.home() / "models")
candidates.append(Path.home() / "Models")
out: list[Path] = []
seen: set[str] = set()
for path in candidates:
try:
resolved = path.resolve()
except OSError:
continue
key = str(resolved)
if key in seen or not resolved.is_dir():
continue
seen.add(key)
out.append(resolved)
return out
def _assert_contained(resolved: Path, root: Path) -> None:
try:
resolved_real = Path(os.path.realpath(resolved))
root_real = Path(os.path.realpath(root))
except OSError as exc:
raise ValueError(f"path resolution failed: {exc}") from exc
try:
resolved_real.relative_to(root_real)
except ValueError as exc:
raise ValueError(f"path escapes root: {resolved!s}") from exc
def path_is_same_or_child(path: Path, root: Path) -> bool:
"""True when *path* is *root* or lives beneath it.
Compares real (symlink-resolved, case-normalized) paths so the check holds
through symlinks and on case-insensitive filesystems, where a plain
``Path.is_relative_to`` would miss a casing-only match. Returns False on any
resolution error rather than raising.
"""
try:
path_real = os.path.normcase(os.path.realpath(str(path)))
root_real = os.path.normcase(os.path.realpath(str(root)))
return os.path.commonpath([path_real, root_real]) == root_real
except (OSError, ValueError):
return False
def resolve_dataset_path(path_value: str) -> Path:
raw = str(path_value or "").strip()
if "\x00" in raw:
raise ValueError("dataset path may not contain null bytes")
# Normalize first so Windows/UNC and backslash paths resolve like the rest
# of the Hub path layer (e.g. C:\data -> /mnt/c/data on WSL) and a
# backslashed '..' is caught by the traversal guard below.
normalized = normalize_path(raw)
path = Path(normalized).expanduser()
if ".." in path.parts:
raise ValueError(f"dataset path may not contain '..' segments: {raw!r}")
if path.is_absolute():
for root in (datasets_root(), dataset_uploads_root(), recipe_datasets_root()):
try:
_assert_contained(path, root)
return path
except ValueError:
continue
raise ValueError(f"dataset path must be relative or under a dataset root: {raw!r}")
parts = [part for part in Path(normalized).parts if part not in ("", ".")]
if parts[:2] == ["assets", "datasets"]:
parts = parts[2:]
if parts and parts[0] == "uploads":
cleaned = Path(*parts[1:]) if len(parts) > 1 else Path()
return dataset_uploads_root() / cleaned
if parts and parts[0] == "recipes":
cleaned = Path(*parts[1:]) if len(parts) > 1 else Path()
return recipe_datasets_root() / cleaned
cleaned = Path(*parts) if parts else Path()
candidates = [
dataset_uploads_root() / cleaned,
recipe_datasets_root() / cleaned,
datasets_root() / cleaned,
dataset_uploads_root() / cleaned.name,
recipe_datasets_root() / cleaned.name,
]
for candidate in candidates:
if candidate.exists():
return candidate
return candidates[0]
def resolve_cached_repo_id_case(
model_name: str,
use_memo: bool = True,
repo_type: str = "model",
) -> str:
"""Resolve repo_id to the exact casing already present in local HF cache.
Prefers the requested casing, but if a case-variant already exists in
local HF cache, reuses that exact cached spelling so we don't trigger
a duplicate download.
"""
if not model_name or "/" not in model_name:
return model_name
cache_dirs = _hf_hub_cache_dirs()
if not cache_dirs:
return model_name
prefix = f"{repo_type}s--"
expected_dir = f"{prefix}{model_name.replace('/', '--')}"
memo_key = (repo_type, model_name)
for cache_dir in cache_dirs:
exact_path = cache_dir / expected_dir
if exact_path.is_dir():
if use_memo:
_memo_set(memo_key, model_name)
return model_name
if use_memo:
cached = _memo_get(memo_key)
if cached is not None:
if any(
(cache_dir / f"{prefix}{cached.replace('/', '--')}").is_dir()
for cache_dir in cache_dirs
):
return cached
_memo_drop(memo_key)
expected_lower = expected_dir.lower()
try:
candidates: set[str] = set()
for cache_dir in cache_dirs:
for entry in cache_dir.iterdir():
if not entry.is_dir():
continue
if entry.name.lower() != expected_lower:
continue
# The lowercased full-name match already proves the prefix
# matches; a case-sensitive startswith would reject a mixed-case
# imported dir such as Models--Org--Repo.
repo_part = entry.name[len(prefix) :]
if not repo_part:
continue
candidates.add(repo_part.replace("--", "/"))
if candidates:
resolved = sorted(candidates)[0]
if use_memo:
_memo_set(memo_key, resolved)
return resolved
except Exception as exc:
logger.debug(f"resolve_cached_repo_id_case failed for {model_name!r}: {exc}")
return model_name
__all__ = [
"assets_root",
"cache_root",
"dataset_uploads_root",
"datasets_root",
"ensure_dir",
"exports_root",
"hf_default_cache_dir",
"is_local_path",
"is_valid_gguf_variant",
"is_valid_repo_id",
"legacy_hf_cache_dir",
"lmstudio_model_dirs",
"normalize_path",
"ollama_model_dirs",
"outputs_root",
"path_is_same_or_child",
"recipe_datasets_root",
"resolve_cached_repo_id_case",
"resolve_dataset_path",
"studio_root",
"tmp_root",
"well_known_model_dirs",
]