Studio: auto-recover when shadowed 'unsloth' on PATH hides the frontend dist (#5782)

* Studio: auto-recover when shadowed 'unsloth' on PATH hides the frontend dist

The CLI launcher derives `_PACKAGE_ROOT` from where `unsloth_cli` imports
from, and `studio/backend/run.py` derives its default `frontend_path` from
`Path(__file__).resolve().parent.parent / "frontend" / "dist"`. When
another `unsloth` (a separate venv with `pip install unsloth`, a system
install, an older venv earlier on PATH) wins `which unsloth`, both
resolve into a site-packages tree that ships frontend source files but no
vite-built `dist/`. The backend warned `[WARNING] Frontend not found at
...` and then happily served 200 on every `/api/*` route while returning
`{"detail":"Not Found"}` on `/`. The 404 was silent to users -- the
process was healthy, the log line scrolled by, and the only symptom was a
blank browser tab.

This is a real situation: many devboxes carry a workspace venv with
`unsloth` installed years before the user runs `curl|sh` to install
Studio. The installer-managed binary at `~/.local/bin/unsloth` exists
but loses to the older venv on PATH order.

Three layers of fix, additive:

Layer C -- runtime auto-discovery (unsloth_cli + run.py)
The CLI now resolves `--frontend` explicitly before spawning `run.py`,
probing in order: package-local default, installer venv site-packages
(`$STUDIO_HOME/unsloth_studio/lib/python*/site-packages/...` and the
Windows `Lib/site-packages/...` equivalent), and editable-install source
roots read from `__editable___*_finder.py` MAPPING dicts in the installer
venv. `run.py` does the same probe as a backstop for direct `python
run.py` invocations.

Layer E -- loud structured error
The silent `[WARNING]` is replaced with a `SystemExit` that names every
candidate path tried and lists the four one-line fixes (run the absolute
path, pass `--frontend`, pass `--api-only`, reinstall). Suppressed only
in `--api-only` mode where no UI is served by design.

Layer F -- installer self-check (install.sh + install.ps1)
At the tail of install, both installers compare `command -v unsloth`
(POSIX) / `Get-Command unsloth` (PowerShell) against the just-installed
binary. If a different path wins, a yellow `warning` block names the
shadowing binary and prints the alias / absolute-path / PATH-reorder
fixes. install.sh uses the venv Python for path canonicalization so it
also works on macOS (BSD `readlink` has no `-f`).

Cross-platform notes:
- Glob patterns probe both `lib/python*/site-packages` (POSIX) and
  `Lib/site-packages` (Windows).
- Canonical-binary path branches on `sys.platform == "win32"` to pick
  `unsloth.exe` over `unsloth`.
- install.sh fixed for macOS; install.ps1 is the Windows analog.

Tests: `studio/backend/tests/test_frontend_resolution.py` covers five
cases via AST-load of the helpers (no uvicorn / FastAPI import needed,
matching `test_host_defaults.py`'s style):
1. Resolver returns None when nothing exists anywhere.
2. Resolver picks the first existing candidate when the default works.
3. Fallback to `$UNSLOTH_STUDIO_HOME` site-packages dist when the default
   is missing.
4. Fallback to an editable-install source root via MAPPING parsing.
5. Resolver tolerates a non-existent `$UNSLOTH_STUDIO_HOME`.

All 5 new + 2 existing host-default tests pass.

* Studio: address review feedback on PR 5782 (Windows hardlink, Win path hint, broader tests)

Four parallel platform reviews (Windows, Linux, macOS, general) on the
initial commit surfaced a small batch of correctness items, all addressed
here:

Windows install.ps1 (medium severity, false positive on every install):
The user-facing shim at $StudioHome\bin\unsloth.exe is a hardlink to
$VenvDir\Scripts\unsloth.exe (created at line 1582). Resolve-Path does not
de-duplicate hardlinks, so the previous string compare always saw the two
paths as different and the new "another 'unsloth' wins on PATH" warning
would fire on every fresh Windows install. Switched to content-hash
equality via Get-FileHash, which collapses hardlinks, symlinks, and
identical copies to a single identity. Also restricted the probe to
Get-Command -CommandType Application so PowerShell aliases / functions /
scripts named "unsloth" don't false-trigger.

Windows run.py SystemExit hint (medium severity, defeats the recovery UX):
The structured error printed Path(STUDIO_HOME)/"unsloth_studio"/"bin"/
"unsloth.exe" on every platform, but on Windows the installer places the
shim at $STUDIO_HOME/bin/unsloth.exe (no unsloth_studio segment) and the
venv binary at $STUDIO_HOME/unsloth_studio/Scripts/unsloth.exe. The hint
pointed at a non-existent path on Windows. Branch on sys.platform ==
"win32" to emit the real shim location; Linux / macOS keep the unsloth_
studio/bin/unsloth layout.

MAPPING regex robustness (low):
[^\n]* silently failed if a future setuptools / black reformat wrapped
the MAPPING dict across multiple lines. Tightened to [^}]* + re.DOTALL,
which still rejects nested dicts (setuptools never emits those for
editable installs) but tolerates either single- or multi-line literals.

install.sh broken-venv edge case (low, macOS reviewer):
Previously _canon fell back to echoing the raw input when the venv python
failed, which would make two symlinked-but-identical paths look different
and false-trigger the warning. Now _canon returns empty on failure and
the caller skips the whole comparison if either side is unresolvable.

argparse default + log readability (nits):
run.py's argparse --frontend default now reuses the module-level
_DEFAULT_FRONTEND_PATH constant so it stays in lockstep with run_server's
default. The [OK] log message resolves the chosen path so support output
is always absolute.

Tests grow from 5 to 8 in studio/backend/tests/test_frontend_resolution.
py (10/10 with the existing host-default tests):

- Windows-layout fallback: Lib/site-packages with capital L.
- Multi-line MAPPING dict: locks in the [^}]* + re.DOTALL behaviour.
- SystemExit message contract: every actionable fix string and the
  attempted-paths list must appear; pins the user-facing recovery
  message so a future refactor doesn't drop a bullet.

End-to-end re-verified on this box: shadowing workspace_22/bin/unsloth
still serves 200 on / through the editable-finder fallback, with the
follow-up resolve-then-log change yielding [OK] Frontend loaded from
/mnt/disks/unslothai/ubuntu/unsloth/studio/frontend/dist.

Out of scope (called out by reviewers but deferred):

- _resolve_frontend_path candidate ordering still tries _PACKAGE_ROOT
  first. For the rare case where a shadowing install carries an older
  built dist, this serves the stale UI instead of the fresh one. Fix is
  non-trivial (the --local workflow intentionally wants _PACKAGE_ROOT to
  win when the cloned repo is the source of truth), so leaving it for a
  follow-up.
- studio/backend/colab.py still bails out on missing frontend instead of
  routing through the new resolver. Pre-existing behaviour, separate PR.
- _resolve_frontend_path is duplicated across run.py and unsloth_cli/
  commands/studio.py. Minor maintenance concern; consolidation is
  natural in a later refactor.

* Studio: guard ast.literal_eval result with isinstance(dict)

Addresses gemini-code-assist[bot] high-priority inline review on PR 5782
flagging that `mapping.get('studio')` could raise AttributeError if the
MAPPING regex matched a brace-delimited literal that ast.literal_eval
parsed as a non-dict (set, list, None). The regex `\{[^}]*\}` happily
matches `{1, 2, 3}` and literal_eval returns a set; the previous code
then crashed on .get().

Setuptools's editable-install template only emits dict literals so this
is defensive rather than a live bug, but the guard is one line per call
site and prevents a future template change from taking out backend
startup or CLI invocation.

Both call sites (studio/backend/run.py:558 and
unsloth_cli/commands/studio.py:234) now bail out on the finder file when
isinstance(mapping, dict) is False; the resolver keeps probing the
remaining finders, so a malformed entry in one finder cannot poison the
discovery of a good one elsewhere.

Adds test_resolver_does_not_crash_on_non_dict_mapping_literal to
test_frontend_resolution.py, which writes one bad finder (MAPPING is a
set literal) alongside one good finder (MAPPING is a real dict) and
asserts the resolver returns the good finder's dist path. Without the
guard this test crashes with AttributeError; with the guard it passes.

11/11 tests green.
This commit is contained in:
Daniel Han 2026-05-26 05:29:42 -07:00 committed by GitHub
commit c49cc6daf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 514 additions and 9 deletions

View file

@ -1626,6 +1626,33 @@ shell.Run cmd, 0, False
# New-StudioShortcuts gates the .lnk shortcuts on env-mode internally.
New-StudioShortcuts -UnslothExePath $UnslothExe
# Warn if another 'unsloth' wins on PATH (different venv, system pip).
# Mirrors install.sh; absolute path is still the most reliable launch.
# Uses content-hash equality (Get-FileHash) so hardlinks, symlinks, and
# identical copies of the installer's shim don't false-trigger. CommandType
# Application restricts the probe to real executables (skips aliases,
# functions, scripts).
try {
$_pathCmd = Get-Command unsloth -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1
if ($_pathCmd) {
$_pathExe = $_pathCmd.Source
$_installedHash = (Get-FileHash -LiteralPath $UnslothExe -Algorithm SHA256 -ErrorAction SilentlyContinue).Hash
$_pathHash = (Get-FileHash -LiteralPath $_pathExe -Algorithm SHA256 -ErrorAction SilentlyContinue).Hash
if ($_installedHash -and $_pathHash -and ($_installedHash -ne $_pathHash)) {
Write-Host ""
step "warning" "another 'unsloth' wins on PATH:" "Yellow"
substep $_pathExe
substep "this installer's binary is at:"
substep $UnslothExe
substep "to use this install, call the absolute path above,"
substep "or put its dir earlier on PATH."
Write-Host ""
}
}
} catch {
# Diagnostic only; never block install on a probe failure.
}
# In interactive terminals, ask the user before starting Studio.
# In non-interactive environments (CI, Docker) just print instructions.
$IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)

View file

@ -2263,6 +2263,38 @@ if [ "$TAURI_MODE" = true ]; then
exit 0
fi
# Warn if another 'unsloth' wins on PATH (different venv, system pip, etc).
# Users typing `unsloth studio` later would hit that binary instead of the
# one just installed; the runtime now falls back via UNSLOTH_STUDIO_HOME
# but the absolute path is still the most reliable launch.
# Uses the venv python (just created above) for path canonicalization so
# this works on macOS (BSD readlink has no -f) as well as Linux/WSL.
_installed_bin="$VENV_DIR/bin/unsloth"
_path_unsloth=$(command -v unsloth 2>/dev/null || true)
if [ -n "$_path_unsloth" ] && [ -x "$VENV_DIR/bin/python" ]; then
# Canonicalize via the venv python (BSD readlink lacks -f on macOS).
# If either side fails to resolve, skip the check entirely rather than
# comparing raw paths (which would false-trigger on symlink targets).
_canon() {
"$VENV_DIR/bin/python" -c \
'import os, sys; print(os.path.realpath(sys.argv[1]))' \
"$1" 2>/dev/null
}
_installed_real=$(_canon "$_installed_bin")
_path_real=$(_canon "$_path_unsloth")
if [ -n "$_installed_real" ] && [ -n "$_path_real" ] \
&& [ "$_installed_real" != "$_path_real" ]; then
echo ""
step "warning" "another 'unsloth' wins on PATH:" "$C_WARN"
substep "$_path_unsloth"
substep "this installer's binary is at:"
substep "$_installed_bin"
substep "to use this install, run the absolute path above,"
substep "alias unsloth, or put its dir earlier on PATH."
echo ""
fi
fi
echo ""
printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!"
printf " ${C_DIM}%s${C_RST}\n" "$RULE"

View file

@ -9,6 +9,7 @@ Works independently and can be moved to any directory.
import os
import sys
from pathlib import Path
from typing import Optional
# Suppress annoying C-level dependency warnings globally (e.g. SwigPyPacked)
os.environ["PYTHONWARNINGS"] = "ignore"
@ -512,10 +513,94 @@ _server = None
_shutdown_event = None
_DEFAULT_FRONTEND_PATH = Path(__file__).resolve().parent.parent / "frontend" / "dist"
def _iter_frontend_fallback_candidates() -> "list[Path]":
"""Yield `studio/frontend/dist` paths to try when the default is missing.
Covers PATH-shadowed binaries whose __file__ resolves into a
site-packages tree that never received a vite build (e.g. plain
`pip install unsloth` from PyPI).
"""
import ast
import re
out: list[Path] = []
home_str = (
os.environ.get("UNSLOTH_STUDIO_HOME")
or os.environ.get("STUDIO_HOME")
or str(Path.home() / ".unsloth" / "studio")
)
venv_dir = Path(home_str).expanduser() / "unsloth_studio"
# Installer venv site-packages.
for pattern in (
"lib/python*/site-packages/studio/frontend/dist",
"Lib/site-packages/studio/frontend/dist",
):
out.extend(venv_dir.glob(pattern))
# Editable source roots referenced from the installer venv.
for sp_pattern in ("lib/python*/site-packages", "Lib/site-packages"):
for sp in venv_dir.glob(sp_pattern):
for finder in sp.glob("__editable___*_finder.py"):
try:
src = finder.read_text(encoding = "utf-8")
except OSError:
continue
# Tolerate single- or multi-line dict literals; [^}]* still
# rejects nested dicts, which the setuptools template never
# emits for editable installs.
m = re.search(
r"^MAPPING\s*(?::[^=]*)?=\s*(\{[^}]*\})", src, re.M | re.S
)
if not m:
continue
try:
mapping = ast.literal_eval(m.group(1))
except (SyntaxError, ValueError):
continue
# Defensive: literal_eval can return a set / list / None if the
# matched literal is not a dict (regex captures `{...}`).
if not isinstance(mapping, dict):
continue
studio_pkg = mapping.get("studio")
if studio_pkg:
out.append(Path(studio_pkg) / "frontend" / "dist")
return out
def _resolve_frontend_path(frontend_path: Path) -> tuple[Optional[Path], list[Path]]:
"""Pick a frontend dir that actually contains `index.html`.
Returns (chosen, attempted). `chosen` is None if nothing servable was
found; `attempted` is the full ordered list for diagnostics.
"""
attempted: list[Path] = []
seen: set[Path] = set()
def _try(p: Path) -> bool:
try:
key = p.resolve()
except OSError:
key = p
if key in seen:
return False
seen.add(key)
attempted.append(p)
return (p / "index.html").is_file()
if _try(Path(frontend_path)):
return attempted[-1], attempted
for alt in _iter_frontend_fallback_candidates():
if _try(alt):
return attempted[-1], attempted
return None, attempted
def run_server(
host: str = "127.0.0.1",
port: int = 8888,
frontend_path: Path = Path(__file__).resolve().parent.parent / "frontend" / "dist",
frontend_path: Path = _DEFAULT_FRONTEND_PATH,
silent: bool = False,
api_only: bool = False,
llama_parallel_slots: int = 1,
@ -584,14 +669,48 @@ def run_server(
print("=" * 50)
print("")
# Setup frontend if path provided (skip in api-only mode)
# Setup frontend if path provided (skip in api-only mode).
# Falls back through alternate locations if the default lacks a built
# dist; errors out loudly rather than silently serving 404 on `/`.
if frontend_path and not api_only:
if setup_frontend(app, frontend_path):
chosen, attempted = _resolve_frontend_path(Path(frontend_path))
if chosen is not None and setup_frontend(app, chosen):
if not silent:
print(f"[OK] Frontend loaded from {frontend_path}")
# Resolve so logs always show an absolute path for support.
try:
display = chosen.resolve()
except OSError:
display = chosen
print(f"[OK] Frontend loaded from {display}")
else:
if not silent:
print(f"[WARNING] Frontend not found at {frontend_path}")
home_str = (
os.environ.get("UNSLOTH_STUDIO_HOME")
or os.environ.get("STUDIO_HOME")
or str(Path.home() / ".unsloth" / "studio")
)
# Windows ships the user-facing shim at $STUDIO_HOME/bin/unsloth.exe
# (a hardlink to the venv exe); Linux/macOS use the venv binary
# at $STUDIO_HOME/unsloth_studio/bin/unsloth.
home = Path(home_str).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) or " (none)"
raise SystemExit(
"[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"
)
# Resolve once; shared by the log rewrite and the banner.
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
@ -718,7 +837,7 @@ if __name__ == "__main__":
parser.add_argument(
"--frontend",
type = str,
default = Path(__file__).resolve().parent.parent / "frontend" / "dist",
default = _DEFAULT_FRONTEND_PATH,
help = "Path to frontend build",
)
parser.add_argument("--silent", action = "store_true", help = "Suppress output")

View file

@ -0,0 +1,248 @@
# 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 so the test does not pull in
uvicorn / FastAPI / unsloth's full dependency tree. Pairs with the 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 without executing the
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 that owns 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` carrying a 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` path."""
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 file whose MAPPING value is a set / list / non-dict literal
(theoretically possible if the regex matched a brace-delimited literal
that ast.literal_eval can parse) must not AttributeError. The resolver
should skip that finder 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. ast.literal_eval parses it as set;
# 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 that should still be 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 that wraps the MAPPING dict
across multiple lines must still parse and resolve. Locks in the
`[^}]*` + re.DOTALL behaviour."""
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 future
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

View file

@ -206,6 +206,79 @@ def _find_setup_script() -> Optional[Path]:
return None
def _iter_editable_studio_source_roots(venv_dir: Path):
"""Yield repo roots from setuptools `__editable___*_finder.py` files in
*venv_dir*'s site-packages whose MAPPING includes a `studio` entry.
Returns the parent dir of the mapped `studio` package (i.e. the repo
root), so callers can append `/studio/...` to reach any subdir.
"""
import ast
import re
for sp_pattern in ("lib/python*/site-packages", "Lib/site-packages"):
for sp in venv_dir.glob(sp_pattern):
for finder in sp.glob("__editable___*_finder.py"):
try:
src = finder.read_text(encoding = "utf-8")
except OSError:
continue
# Tolerate single- or multi-line dict literals; [^}]* still
# rejects nested dicts, which the setuptools template never
# emits for editable installs.
m = re.search(
r"^MAPPING\s*(?::[^=]*)?=\s*(\{[^}]*\})", src, re.M | re.S
)
if not m:
continue
try:
mapping = ast.literal_eval(m.group(1))
except (SyntaxError, ValueError):
continue
# Defensive: literal_eval can return a set / list / None if the
# matched literal is not a dict (regex captures `{...}`).
if not isinstance(mapping, dict):
continue
studio_pkg = mapping.get("studio")
if studio_pkg:
yield Path(studio_pkg).parent
def _find_frontend_dist() -> Optional[Path]:
"""Locate a built `studio/frontend/dist` (containing index.html).
Probes (in order): package-local default, installer venv site-packages,
editable source roots referenced from the installer venv. Returns None
if nothing servable is found, so callers can decide to error or proceed
in `--api-only` mode.
Fixes the silent 404 when another `unsloth` on PATH shadows the
installer's binary and points `_PACKAGE_ROOT` at a site-packages copy
that never received a vite build.
"""
candidates: List[Path] = [_PACKAGE_ROOT / "studio" / "frontend" / "dist"]
venv_dir = STUDIO_HOME / "unsloth_studio"
for pattern in (
"lib/python*/site-packages/studio/frontend/dist",
"Lib/site-packages/studio/frontend/dist",
):
candidates.extend(venv_dir.glob(pattern))
for repo_root in _iter_editable_studio_source_roots(venv_dir):
candidates.append(repo_root / "studio" / "frontend" / "dist")
seen: set[Path] = set()
for c in candidates:
try:
resolved = c.resolve()
except OSError:
resolved = c
if resolved in seen:
continue
seen.add(resolved)
if (c / "index.html").is_file():
return c
return None
# ── helpers for `unsloth studio run` ────────────────────────────────
@ -539,8 +612,14 @@ def studio_default(
"--port",
str(port),
]
if frontend:
args.extend(["--frontend", str(frontend)])
# Resolve frontend explicitly so the spawned run.py uses a real
# built dist regardless of where its __file__ lands. Skip in
# --api-only (no UI served).
resolved_frontend = frontend
if resolved_frontend is None and not api_only:
resolved_frontend = _find_frontend_dist()
if resolved_frontend is not None:
args.extend(["--frontend", str(resolved_frontend)])
if silent:
args.append("--silent")
if api_only: