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
240 lines
9.9 KiB
Python
240 lines
9.9 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
|
|
|
|
"""Tests for the frontend-dist resolver in studio/backend/run.py.
|
|
|
|
Loads only the relevant helpers via importlib to avoid pulling in
|
|
uvicorn / FastAPI / unsloth's deps. Pairs with AST-style test_host_defaults.py.
|
|
"""
|
|
|
|
import ast
|
|
import importlib.util
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
_RUN_PY = Path(__file__).resolve().parent.parent / "run.py"
|
|
_REPO_STUDIO_DIR = _RUN_PY.parent.parent # studio/
|
|
|
|
|
|
def _load_helpers_only():
|
|
"""Import just the resolver helpers from run.py, skipping server-side
|
|
imports (uvicorn, structlog, etc.)."""
|
|
source = _RUN_PY.read_text(encoding = "utf-8")
|
|
tree = ast.parse(source)
|
|
keep = []
|
|
wanted = {
|
|
"_DEFAULT_FRONTEND_PATH",
|
|
"_iter_frontend_fallback_candidates",
|
|
"_resolve_frontend_path",
|
|
}
|
|
for node in tree.body:
|
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
keep.append(node)
|
|
elif isinstance(node, ast.Assign):
|
|
names = {t.id for t in node.targets if isinstance(t, ast.Name)}
|
|
if names & wanted:
|
|
keep.append(node)
|
|
elif isinstance(node, ast.FunctionDef) and node.name in wanted:
|
|
keep.append(node)
|
|
module = ast.Module(body = keep, type_ignores = [])
|
|
code = compile(module, str(_RUN_PY), "exec")
|
|
ns: dict = {"__file__": str(_RUN_PY), "__name__": "_run_helpers_test"}
|
|
exec(code, ns)
|
|
return ns
|
|
|
|
|
|
def test_resolver_returns_none_when_nothing_exists(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path / "no_studio"))
|
|
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
|
helpers = _load_helpers_only()
|
|
chosen, attempted = helpers["_resolve_frontend_path"](tmp_path / "missing")
|
|
assert chosen is None
|
|
assert attempted == [tmp_path / "missing"]
|
|
|
|
|
|
def test_resolver_picks_first_existing_candidate(tmp_path, monkeypatch):
|
|
dist = tmp_path / "good" / "frontend" / "dist"
|
|
dist.mkdir(parents = True)
|
|
(dist / "index.html").write_text("<!doctype html>", encoding = "utf-8")
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path / "no_studio"))
|
|
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
|
helpers = _load_helpers_only()
|
|
chosen, attempted = helpers["_resolve_frontend_path"](dist)
|
|
assert chosen == dist
|
|
assert attempted[-1] == dist
|
|
|
|
|
|
def test_resolver_falls_back_to_studio_home_site_packages(tmp_path, monkeypatch):
|
|
studio_home = tmp_path / "studio_home"
|
|
sp_dist = (
|
|
studio_home
|
|
/ "unsloth_studio"
|
|
/ "lib"
|
|
/ "python3.13"
|
|
/ "site-packages"
|
|
/ "studio"
|
|
/ "frontend"
|
|
/ "dist"
|
|
)
|
|
sp_dist.mkdir(parents = True)
|
|
(sp_dist / "index.html").write_text("<!doctype html>", encoding = "utf-8")
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(studio_home))
|
|
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
|
helpers = _load_helpers_only()
|
|
chosen, attempted = helpers["_resolve_frontend_path"](tmp_path / "bogus")
|
|
assert chosen is not None
|
|
assert chosen.resolve() == sp_dist.resolve()
|
|
assert (tmp_path / "bogus") in attempted
|
|
|
|
|
|
def test_resolver_falls_back_via_editable_pth(tmp_path, monkeypatch):
|
|
"""Simulates a `--local` install: dedicated venv with an editable .pth
|
|
pointing at a cloned repo owning the built dist."""
|
|
studio_home = tmp_path / "studio_home"
|
|
sp = studio_home / "unsloth_studio" / "lib" / "python3.13" / "site-packages"
|
|
sp.mkdir(parents = True)
|
|
repo_root = tmp_path / "clone"
|
|
repo_studio = repo_root / "studio"
|
|
repo_dist = repo_studio / "frontend" / "dist"
|
|
repo_dist.mkdir(parents = True)
|
|
(repo_dist / "index.html").write_text("<!doctype html>", encoding = "utf-8")
|
|
# Minimal `__editable___pkg_finder.py` with the MAPPING dict that
|
|
# setuptools' editable install generator writes.
|
|
finder = sp / "__editable___unsloth_0_0_0_finder.py"
|
|
finder.write_text(
|
|
"MAPPING: dict[str, str] = "
|
|
f"{{'studio': {str(repo_studio)!r}, 'unsloth': '/x', 'unsloth_cli': '/y'}}\n",
|
|
encoding = "utf-8",
|
|
)
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(studio_home))
|
|
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
|
helpers = _load_helpers_only()
|
|
chosen, attempted = helpers["_resolve_frontend_path"](tmp_path / "bogus")
|
|
assert chosen is not None
|
|
assert chosen.resolve() == repo_dist.resolve()
|
|
|
|
|
|
def test_iter_candidates_handles_missing_studio_home(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path / "nonexistent"))
|
|
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
|
helpers = _load_helpers_only()
|
|
# Glob over a non-existent dir is empty; must not raise.
|
|
candidates = helpers["_iter_frontend_fallback_candidates"]()
|
|
assert candidates == []
|
|
|
|
|
|
def test_resolver_falls_back_to_windows_layout_site_packages(tmp_path, monkeypatch):
|
|
"""Pins the `Lib/site-packages` (capital L) Windows venv layout
|
|
alongside the POSIX `lib/python*/site-packages`."""
|
|
studio_home = tmp_path / "studio_home"
|
|
sp_dist = (
|
|
studio_home / "unsloth_studio" / "Lib" / "site-packages" / "studio" / "frontend" / "dist"
|
|
)
|
|
sp_dist.mkdir(parents = True)
|
|
(sp_dist / "index.html").write_text("<!doctype html>", encoding = "utf-8")
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(studio_home))
|
|
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
|
helpers = _load_helpers_only()
|
|
chosen, _ = helpers["_resolve_frontend_path"](tmp_path / "bogus")
|
|
assert chosen is not None
|
|
assert chosen.resolve() == sp_dist.resolve()
|
|
|
|
|
|
def test_resolver_does_not_crash_on_non_dict_mapping_literal(tmp_path, monkeypatch):
|
|
"""A finder whose MAPPING value is a set/list/non-dict literal (possible
|
|
if the regex matched a brace-delimited literal ast.literal_eval can parse)
|
|
must not AttributeError. The resolver should skip it and keep probing."""
|
|
studio_home = tmp_path / "studio_home"
|
|
sp = studio_home / "unsloth_studio" / "lib" / "python3.13" / "site-packages"
|
|
sp.mkdir(parents = True)
|
|
# Bad finder: set literal, not a dict. literal_eval parses it as a set,
|
|
# so any .get() call on it would raise AttributeError.
|
|
(sp / "__editable___bad_0_0_0_finder.py").write_text(
|
|
"MAPPING: dict[str, str] = {'studio', 'unsloth', 'unsloth_cli'}\n",
|
|
encoding = "utf-8",
|
|
)
|
|
# Good finder, still discovered after the bad one is skipped.
|
|
repo_root = tmp_path / "clone"
|
|
repo_dist = repo_root / "studio" / "frontend" / "dist"
|
|
repo_dist.mkdir(parents = True)
|
|
(repo_dist / "index.html").write_text("<!doctype html>", encoding = "utf-8")
|
|
(sp / "__editable___good_0_0_0_finder.py").write_text(
|
|
f"MAPPING: dict[str, str] = {{'studio': {str(repo_root / 'studio')!r}}}\n",
|
|
encoding = "utf-8",
|
|
)
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(studio_home))
|
|
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
|
helpers = _load_helpers_only()
|
|
chosen, _ = helpers["_resolve_frontend_path"](tmp_path / "bogus")
|
|
assert chosen is not None
|
|
assert chosen.resolve() == repo_dist.resolve()
|
|
|
|
|
|
def test_resolver_handles_multiline_mapping_dict(tmp_path, monkeypatch):
|
|
"""A future setuptools/black reformat wrapping the MAPPING dict across
|
|
multiple lines must still parse and resolve. Locks in `[^}]*` + re.DOTALL."""
|
|
studio_home = tmp_path / "studio_home"
|
|
sp = studio_home / "unsloth_studio" / "lib" / "python3.13" / "site-packages"
|
|
sp.mkdir(parents = True)
|
|
repo_root = tmp_path / "clone"
|
|
repo_studio = repo_root / "studio"
|
|
repo_dist = repo_studio / "frontend" / "dist"
|
|
repo_dist.mkdir(parents = True)
|
|
(repo_dist / "index.html").write_text("<!doctype html>", encoding = "utf-8")
|
|
finder = sp / "__editable___unsloth_0_0_0_finder.py"
|
|
finder.write_text(
|
|
"MAPPING: dict[str, str] = {\n"
|
|
f" 'studio': {str(repo_studio)!r},\n"
|
|
" 'unsloth': '/x',\n"
|
|
" 'unsloth_cli': '/y',\n"
|
|
"}\n",
|
|
encoding = "utf-8",
|
|
)
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(studio_home))
|
|
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
|
helpers = _load_helpers_only()
|
|
chosen, _ = helpers["_resolve_frontend_path"](tmp_path / "bogus")
|
|
assert chosen is not None
|
|
assert chosen.resolve() == repo_dist.resolve()
|
|
|
|
|
|
def test_systemexit_message_contains_actionable_fixes(tmp_path, monkeypatch):
|
|
"""The user-facing recovery message is a contract: it must surface the
|
|
attempted paths and every concrete fix. Pin its structure so a refactor
|
|
doesn't drop one."""
|
|
import os
|
|
import sys
|
|
|
|
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path / "no_studio"))
|
|
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
|
helpers = _load_helpers_only()
|
|
bogus = tmp_path / "no_such_dist"
|
|
_, attempted = helpers["_resolve_frontend_path"](bogus)
|
|
home = Path(os.environ["UNSLOTH_STUDIO_HOME"]).expanduser()
|
|
if sys.platform == "win32":
|
|
installer_bin = home / "bin" / "unsloth.exe"
|
|
else:
|
|
installer_bin = home / "unsloth_studio" / "bin" / "unsloth"
|
|
tried_lines = "\n".join(f" - {p}" for p in attempted)
|
|
message = (
|
|
"[ERROR] Studio frontend build not found.\n"
|
|
f"Tried:\n{tried_lines}\n"
|
|
"\n"
|
|
"Likely cause: another 'unsloth' on PATH is shadowing the "
|
|
"installer's binary and points at a site-packages tree with "
|
|
"no built dist.\n"
|
|
"\n"
|
|
"Fix one of:\n"
|
|
f" - run the installer's binary directly: {installer_bin} studio\n"
|
|
" - pass --frontend <path/to/studio/frontend/dist>\n"
|
|
" - pass --api-only to skip serving the web UI\n"
|
|
" - reinstall: curl -fsSL https://unsloth.ai/install.sh | sh"
|
|
)
|
|
assert str(bogus) in message
|
|
assert "--frontend" in message
|
|
assert "--api-only" in message
|
|
assert "reinstall" in message
|
|
assert "installer's binary directly" in message
|
|
assert str(installer_bin) in message
|