From c49cc6daf58623032ea552b05cc77bdc8d86573c Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Tue, 26 May 2026 05:29:42 -0700 Subject: [PATCH] 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. --- install.ps1 | 27 ++ install.sh | 32 +++ studio/backend/run.py | 133 +++++++++- .../backend/tests/test_frontend_resolution.py | 248 ++++++++++++++++++ unsloth_cli/commands/studio.py | 83 +++++- 5 files changed, 514 insertions(+), 9 deletions(-) create mode 100644 studio/backend/tests/test_frontend_resolution.py diff --git a/install.ps1 b/install.ps1 index 3911236d87..52766370d1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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) diff --git a/install.sh b/install.sh index cc92fd52c2..d12abe298f 100755 --- a/install.sh +++ b/install.sh @@ -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" diff --git a/studio/backend/run.py b/studio/backend/run.py index d5ccc49022..3bde8abd3c 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -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 \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") diff --git a/studio/backend/tests/test_frontend_resolution.py b/studio/backend/tests/test_frontend_resolution.py new file mode 100644 index 0000000000..2b49763386 --- /dev/null +++ b/studio/backend/tests/test_frontend_resolution.py @@ -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("", 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("", 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("", 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("", 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("", 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("", 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 \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 diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index 67395a8378..e37cd0a8d8 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -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: