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: