diff --git a/install.ps1 b/install.ps1 index 1613ec6258a..83576d9c75b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -5,8 +5,9 @@ function Install-UnslothStudio { $ErrorActionPreference = "Stop" - $VenvName = "unsloth_studio" $PythonVersion = "3.13" + $StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio" + $VenvDir = Join-Path $StudioHome "unsloth_studio" Write-Host "" Write-Host "=========================================" @@ -449,20 +450,59 @@ shell.Run cmd, 0, False return } - # ── Create venv (skip if it already exists and has a valid interpreter) ── + # ── Create venv (migrate old layout if possible, otherwise fresh) ── # Pass the resolved executable path to uv so it does not re-resolve # a version string back to a conda interpreter. - $VenvPython = Join-Path $VenvName "Scripts\python.exe" + if (-not (Test-Path $StudioHome)) { + New-Item -ItemType Directory -Path $StudioHome -Force | Out-Null + } + + $VenvPython = Join-Path $VenvDir "Scripts\python.exe" + $_Migrated = $false + + if (Test-Path $VenvPython) { + # New layout already exists -- nuke for fresh install + Write-Host "==> Removing existing environment for fresh install..." + Remove-Item -Recurse -Force $VenvDir + } elseif (Test-Path (Join-Path $StudioHome ".venv\Scripts\python.exe")) { + # Old layout (~/.unsloth/studio/.venv) exists -- validate before migrating + $OldVenv = Join-Path $StudioHome ".venv" + $OldPy = Join-Path $OldVenv "Scripts\python.exe" + Write-Host "==> Found legacy Studio environment, validating..." + $prevEAP2 = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $OldPy -c "import torch; A = torch.ones((2,2)); B = A + A" 2>$null | Out-Null + $torchOk = ($LASTEXITCODE -eq 0) + } catch { $torchOk = $false } + $ErrorActionPreference = $prevEAP2 + if ($torchOk) { + Write-Host " Legacy environment is healthy -- migrating..." + Move-Item -Path $OldVenv -Destination $VenvDir -Force + Write-Host " Moved .venv -> unsloth_studio" + $_Migrated = $true + } else { + Write-Host " Legacy environment failed validation -- creating fresh environment" + Remove-Item -Recurse -Force $OldVenv -ErrorAction SilentlyContinue + } + } elseif (Test-Path (Join-Path $env:USERPROFILE "unsloth_studio\Scripts\python.exe")) { + # CWD-relative venv from old install.ps1 -- migrate to absolute path + $CwdVenv = Join-Path $env:USERPROFILE "unsloth_studio" + Write-Host "==> Found CWD-relative Studio environment, migrating to $VenvDir..." + Move-Item -Path $CwdVenv -Destination $VenvDir -Force + Write-Host " Moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio" + $_Migrated = $true + } + if (-not (Test-Path $VenvPython)) { - if (Test-Path $VenvName) { Remove-Item -Recurse -Force $VenvName } - Write-Host "==> Creating Python $($DetectedPython.Version) virtual environment (${VenvName})..." - uv venv $VenvName --python "$($DetectedPython.Path)" + Write-Host "==> Creating Python $($DetectedPython.Version) virtual environment ($VenvDir)..." + uv venv $VenvDir --python "$($DetectedPython.Path)" if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] Failed to create virtual environment (exit code $LASTEXITCODE)" -ForegroundColor Red return } } else { - Write-Host "==> Virtual environment ${VenvName} already exists, skipping creation." + Write-Host "==> Using migrated environment at $VenvDir" } # ── Detect GPU (robust: PATH + hardcoded fallback paths, mirrors setup.ps1) ── @@ -536,15 +576,26 @@ shell.Run cmd, 0, False # CUDA wheels. Missing dependencies (transformers, trl, peft, etc.) # are still pulled in because they are new, not upgrades. # - Write-Host "==> Installing PyTorch ($TorchIndexUrl)..." - uv pip install --python $VenvPython torch torchvision torchaudio --index-url $TorchIndexUrl - if ($LASTEXITCODE -ne 0) { - Write-Host "[ERROR] Failed to install PyTorch (exit code $LASTEXITCODE)" -ForegroundColor Red - return - } + if ($_Migrated) { + # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state + # in the new venv location, while preserving existing torch/CUDA + Write-Host "==> Upgrading unsloth in migrated environment..." + uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.11" unsloth-zoo + } elseif ($TorchIndexUrl) { + Write-Host "==> Installing PyTorch ($TorchIndexUrl)..." + uv pip install --python $VenvPython torch torchvision torchaudio --index-url $TorchIndexUrl + if ($LASTEXITCODE -ne 0) { + Write-Host "[ERROR] Failed to install PyTorch (exit code $LASTEXITCODE)" -ForegroundColor Red + return + } - Write-Host "==> Installing unsloth (this may take a few minutes)..." - uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.11" + Write-Host "==> Installing unsloth (this may take a few minutes)..." + uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.11" + } else { + # Fallback: GPU detection failed to produce a URL -- let uv resolve torch + Write-Host "==> Installing unsloth (this may take a few minutes)..." + uv pip install --python $VenvPython "unsloth>=2026.3.11" --torch-backend=auto + } if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $LASTEXITCODE)" -ForegroundColor Red return @@ -554,7 +605,7 @@ shell.Run cmd, 0, False # setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools, # CUDA Toolkit, Node.js, and other dependencies automatically via winget. Write-Host "==> Running unsloth studio setup..." - $UnslothExe = Join-Path $VenvName "Scripts\unsloth.exe" + $UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe" if (-not (Test-Path $UnslothExe)) { Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow @@ -562,6 +613,8 @@ shell.Run cmd, 0, False Write-Host " Try re-running the installer or see: https://github.com/unslothai/unsloth?tab=readme-ov-file#-quickstart" -ForegroundColor Yellow return } + # Tell setup.ps1 to skip base package installation (install.ps1 already did it) + $env:SKIP_STUDIO_BASE = "1" & $UnslothExe studio setup if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] unsloth studio setup failed (exit code $LASTEXITCODE)" -ForegroundColor Red @@ -582,12 +635,11 @@ shell.Run cmd, 0, False if ($IsInteractive) { Write-Host "==> Launching Unsloth Studio..." Write-Host "" - $UnslothExe = Join-Path $VenvName "Scripts\unsloth.exe" & $UnslothExe studio -H 0.0.0.0 -p 8888 } else { Write-Host " To launch, run:" Write-Host "" - Write-Host " .\${VenvName}\Scripts\activate" + Write-Host " & `"$VenvDir\Scripts\Activate.ps1`"" Write-Host " unsloth studio -H 0.0.0.0 -p 8888" Write-Host "" } diff --git a/install.sh b/install.sh index 08939559391..ec5008af125 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,35 @@ #!/bin/sh # Unsloth Studio Installer -# Usage (curl): curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/install.sh | sh -# Usage (wget): wget -qO- https://raw.githubusercontent.com/unslothai/unsloth/main/install.sh | sh +# Usage (curl): curl -fsSL https://unsloth.ai/install.sh | sh +# Usage (wget): wget -qO- https://unsloth.ai/install.sh | sh +# Usage (local): ./install.sh --local (install from local repo instead of PyPI) +# Usage (test): ./install.sh --package roland-sloth (install a different package name) set -e -VENV_NAME="unsloth_studio" +# ── Parse flags ── +STUDIO_LOCAL_INSTALL=false +PACKAGE_NAME="unsloth" +_next_is_package=false +for arg in "$@"; do + if [ "$_next_is_package" = true ]; then + PACKAGE_NAME="$arg" + _next_is_package=false + continue + fi + case "$arg" in + --local) STUDIO_LOCAL_INSTALL=true ;; + --package) _next_is_package=true ;; + esac +done + +if [ "$_next_is_package" = true ]; then + echo "❌ ERROR: --package requires an argument." >&2 + exit 1 +fi + PYTHON_VERSION="3.13" +STUDIO_HOME="$HOME/.unsloth/studio" +VENV_DIR="$STUDIO_HOME/unsloth_studio" # ── Helper: download a URL to a file (supports curl and wget) ── download() { @@ -659,32 +683,195 @@ if ! command -v uv >/dev/null 2>&1 || ! _uv_version_ok uv; then export PATH="$HOME/.local/bin:$PATH" fi -# ── Create venv (skip if it already exists and has a valid interpreter) ── -if [ ! -x "$VENV_NAME/bin/python" ]; then - [ -e "$VENV_NAME" ] && rm -rf "$VENV_NAME" - echo "==> Creating Python ${PYTHON_VERSION} virtual environment (${VENV_NAME})..." - uv venv "$VENV_NAME" --python "$PYTHON_VERSION" +# ── Create venv (migrate old layout if possible, otherwise fresh) ── +mkdir -p "$STUDIO_HOME" + +_MIGRATED=false + +if [ -x "$VENV_DIR/bin/python" ]; then + # New layout already exists — nuke for fresh install + rm -rf "$VENV_DIR" +elif [ -x "$STUDIO_HOME/.venv/bin/python" ]; then + # Old layout exists — validate before migrating + echo "==> Found legacy Studio environment, validating..." + if "$STUDIO_HOME/.venv/bin/python" -c " +import torch +device = 'cuda' if torch.cuda.is_available() else 'cpu' +A = torch.ones((10, 10), device=device) +B = torch.ones((10, 10), device=device) +C = torch.ones((10, 10), device=device) +D = A + B +E = D @ C +torch.testing.assert_close(torch.unique(E), torch.tensor((20,), device=E.device, dtype=E.dtype)) +" >/dev/null 2>&1; then + echo "✅ Legacy environment is healthy — migrating..." + mv "$STUDIO_HOME/.venv" "$VENV_DIR" + echo " Moved ~/.unsloth/studio/.venv → $VENV_DIR" + _MIGRATED=true + else + echo "⚠️ Legacy environment failed validation — creating fresh environment" + rm -rf "$STUDIO_HOME/.venv" + fi +fi + +if [ ! -x "$VENV_DIR/bin/python" ]; then + echo "==> Creating Python ${PYTHON_VERSION} virtual environment (${VENV_DIR})..." + uv venv "$VENV_DIR" --python "$PYTHON_VERSION" else - echo "==> Virtual environment ${VENV_NAME} already exists, skipping creation." + echo "==> Using migrated environment at ${VENV_DIR}" fi +# ── Resolve repo root (for --local installs) ── +_REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)" + +# ── Detect GPU and choose PyTorch index URL ── +# Mirrors Get-TorchIndexUrl in install.ps1. +# On CPU-only machines this returns the cpu index, avoiding the solver +# dead-end where --torch-backend=auto resolves to unsloth==2024.8. +get_torch_index_url() { + _base="https://download.pytorch.org/whl" + # macOS: always CPU (no CUDA support) + case "$(uname -s)" in Darwin) echo "$_base/cpu"; return ;; esac + # Try nvidia-smi + _smi="" + if command -v nvidia-smi >/dev/null 2>&1; then + _smi="nvidia-smi" + elif [ -x "/usr/bin/nvidia-smi" ]; then + _smi="/usr/bin/nvidia-smi" + fi + if [ -z "$_smi" ]; then echo "$_base/cpu"; return; fi + # Parse CUDA version from nvidia-smi output (POSIX-safe, no grep -P) + _cuda_ver=$(LC_ALL=C $_smi 2>/dev/null \ + | sed -n 's/.*CUDA Version:[[:space:]]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' \ + | head -1) + if [ -z "$_cuda_ver" ]; then + echo "[WARN] Could not determine CUDA version from nvidia-smi, defaulting to cu126" >&2 + echo "$_base/cu126"; return + fi + _major=${_cuda_ver%%.*} + _minor=${_cuda_ver#*.} + if [ "$_major" -ge 13 ]; then echo "$_base/cu130" + elif [ "$_major" -eq 12 ] && [ "$_minor" -ge 8 ]; then echo "$_base/cu128" + elif [ "$_major" -eq 12 ] && [ "$_minor" -ge 6 ]; then echo "$_base/cu126" + elif [ "$_major" -ge 12 ]; then echo "$_base/cu124" + elif [ "$_major" -ge 11 ]; then echo "$_base/cu118" + else echo "$_base/cpu"; fi +} +TORCH_INDEX_URL=$(get_torch_index_url) + # ── Install unsloth directly into the venv (no activation needed) ── -echo "==> Installing unsloth (this may take a few minutes)..." -uv pip install --python "$VENV_NAME/bin/python" "unsloth>=2026.3.11" --torch-backend=auto +_VENV_PY="$VENV_DIR/bin/python" +if [ "$_MIGRATED" = true ]; then + # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state + # in the new venv location, while preserving existing torch/CUDA + echo "==> Upgrading unsloth in migrated environment..." + uv pip install --python "$_VENV_PY" \ + --reinstall-package unsloth --reinstall-package unsloth-zoo \ + "unsloth>=2026.3.11" unsloth-zoo + if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + echo "==> Overlaying local repo (editable)..." + uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + fi +elif [ -n "$TORCH_INDEX_URL" ]; then + # Fresh: Step 1 - install torch from explicit index + echo "==> Installing PyTorch ($TORCH_INDEX_URL)..." + uv pip install --python "$_VENV_PY" torch torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" + # Fresh: Step 2 - install unsloth, preserving pre-installed torch + echo "==> Installing unsloth (this may take a few minutes)..." + if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + uv pip install --python "$_VENV_PY" \ + --upgrade-package unsloth "unsloth>=2026.3.11" unsloth-zoo + echo "==> Overlaying local repo (editable)..." + uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + else + uv pip install --python "$_VENV_PY" \ + --upgrade-package unsloth "$PACKAGE_NAME" + fi +else + # Fallback: GPU detection failed to produce a URL -- let uv resolve torch + echo "==> Installing unsloth (this may take a few minutes)..." + if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.11" --torch-backend=auto + echo "==> Overlaying local repo (editable)..." + uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + else + uv pip install --python "$_VENV_PY" "$PACKAGE_NAME" --torch-backend=auto + fi +fi # ── Run studio setup ── -# Ensure the venv's Python is on PATH for setup.sh's Python discovery. -# On macOS the system Python may be outside the 3.11-3.13 range that -# setup.sh requires, but uv already installed a compatible interpreter -# inside the venv. -VENV_ABS_BIN="$(cd "$VENV_NAME/bin" && pwd)" +# When --local, use the repo's own setup.sh directly. +# Otherwise, find it inside the installed package. +SETUP_SH="" +if [ "$STUDIO_LOCAL_INSTALL" = true ] && [ -f "$_REPO_ROOT/studio/setup.sh" ]; then + SETUP_SH="$_REPO_ROOT/studio/setup.sh" +fi + +if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then + SETUP_SH=$("$VENV_DIR/bin/python" -c " +import importlib.resources +print(importlib.resources.files('studio') / 'setup.sh') +" 2>/dev/null || echo "") +fi + +# Fallback: search site-packages +if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then + SETUP_SH=$(find "$VENV_DIR" -path "*/studio/setup.sh" -print -quit 2>/dev/null || echo "") +fi + +if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then + echo "❌ ERROR: Could not find studio/setup.sh in the installed package." + exit 1 +fi + +# Ensure the venv's Python is on PATH so setup.sh can find it. +VENV_ABS_BIN="$(cd "$VENV_DIR/bin" && pwd)" if [ -n "$VENV_ABS_BIN" ]; then export PATH="$VENV_ABS_BIN:$PATH" fi -echo "==> Running unsloth studio setup..." -REQUESTED_PYTHON_VERSION="$(cd "$VENV_NAME/bin" && pwd)/python" \ -"$VENV_NAME/bin/unsloth" studio setup Running unsloth setup..." +if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + SKIP_STUDIO_BASE=1 \ + STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \ + STUDIO_LOCAL_INSTALL=1 \ + STUDIO_LOCAL_REPO="$_REPO_ROOT" \ + bash "$SETUP_SH" /dev/null; then + echo '' >> "$_SHELL_PROFILE" + echo '# Added by Unsloth installer' >> "$_SHELL_PROFILE" + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$_SHELL_PROFILE" + echo "==> Added ~/.local/bin to PATH in $_SHELL_PROFILE" + fi + fi + export PATH="$_LOCAL_BIN:$PATH" + ;; +esac create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS" @@ -694,8 +881,32 @@ echo " Unsloth Studio installed!" echo "=========================================" echo "" -echo " To launch, run:" -echo "" -echo " source ${VENV_NAME}/bin/activate" -echo " unsloth studio -H 0.0.0.0 -p 8888" -echo "" +# Launch studio automatically in interactive terminals; +# in non-interactive environments (Docker, CI, cloud-init) just print instructions. +if [ -t 1 ]; then + echo "==> Launching Unsloth Studio..." + echo "" + "$VENV_DIR/bin/unsloth" studio -H 0.0.0.0 -p 8888 + _LAUNCH_EXIT=$? + if [ "$_LAUNCH_EXIT" -ne 0 ] && [ "$_MIGRATED" = true ]; then + echo "" + echo "⚠️ Unsloth Studio failed to start after migration." + echo " Your migrated environment may be incompatible." + echo " To fix, remove the environment and reinstall:" + echo "" + echo " rm -rf $VENV_DIR" + echo " curl -fsSL https://unsloth.ai/install.sh | sh" + echo "" + fi + exit "$_LAUNCH_EXIT" +else + echo " To launch, run:" + echo "" + echo " unsloth studio -H 0.0.0.0 -p 8888" + echo "" + echo " Or activate the environment first:" + echo "" + echo " source ${VENV_DIR}/bin/activate" + echo " unsloth studio -H 0.0.0.0 -p 8888" + echo "" +fi diff --git a/studio/backend/colab.py b/studio/backend/colab.py index 25a9408ccbe..ecf9fc29070 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -26,7 +26,7 @@ def _bootstrap_studio_venv() -> None: site-packages so that packages like structlog, fastapi, etc. are importable from notebook cells and take priority over system copies. """ - venv_lib = Path.home() / ".unsloth" / "studio" / ".venv" / "lib" + venv_lib = Path.home() / ".unsloth" / "studio" / "unsloth_studio" / "lib" if not venv_lib.exists(): import warnings diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index 9b2678f478c..39fec2e6f5d 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -225,9 +225,20 @@ def _translate_pip_args_for_uv(args: tuple[str, ...]) -> list[str]: def _build_pip_cmd(args: tuple[str, ...]) -> list[str]: - """Build a standard pip install command.""" + """Build a standard pip install command. + + Strips uv-only flags like --upgrade-package that pip doesn't understand. + """ cmd = [sys.executable, "-m", "pip", "install"] - cmd.extend(args) + skip_next = False + for arg in args: + if skip_next: + skip_next = False + continue + if arg == "--upgrade-package": + skip_next = True # skip the flag and its value + continue + cmd.append(arg) return cmd @@ -241,7 +252,12 @@ def _build_uv_cmd(args: tuple[str, ...]) -> list[str]: # the system Python (observed on Colab and similar environments). cmd.extend(["--python", sys.executable]) cmd.extend(_translate_pip_args_for_uv(args)) - cmd.append("--torch-backend=auto") + # Torch is pre-installed by install.sh/setup.ps1. Do not add + # --torch-backend by default -- it can cause solver dead-ends on + # CPU-only machines. Callers that need it can set UV_TORCH_BACKEND. + _tb = os.environ.get("UV_TORCH_BACKEND", "") + if _tb: + cmd.append(f"--torch-backend={_tb}") return cmd @@ -325,22 +341,90 @@ def patch_package_file(package_name: str, relative_path: str, url: str) -> None: def install_python_stack() -> int: global USE_UV, _STEP, _TOTAL _STEP = 0 - _TOTAL = 10 if IS_WINDOWS else 11 - # 1. Upgrade pip (needed even with uv as fallback and for bootstrapping) - _progress("pip upgrade") - run("Upgrading pip", [sys.executable, "-m", "pip", "install", "--upgrade", "pip"]) - - # Try to use uv for faster installs + # When called from install.sh (which already installed unsloth into the venv), + # SKIP_STUDIO_BASE=1 is set to avoid redundant reinstallation of base packages. + # When called from "unsloth studio update", it is NOT set so base packages + # (unsloth + unsloth-zoo) are always reinstalled to pick up new versions. + skip_base = os.environ.get("SKIP_STUDIO_BASE", "0") == "1" + # When --package is used, install a different package name (e.g. roland-sloth for testing) + package_name = os.environ.get("STUDIO_PACKAGE_NAME", "unsloth") + # When --local is used, overlay a local repo checkout after updating deps + local_repo = os.environ.get("STUDIO_LOCAL_REPO", "") + base_total = 10 if IS_WINDOWS else 11 + _TOTAL = (base_total - 1) if skip_base else base_total + + # 1. Try to use uv for faster installs (must happen before pip upgrade + # because uv venvs don't include pip by default) USE_UV = _bootstrap_uv() - # 2. Core packages: unsloth-zoo + unsloth - _progress("base packages") - pip_install( - "Installing base packages", - "--no-cache-dir", - req = REQ_ROOT / "base.txt", - ) + # 2. Ensure pip is available (uv venvs created by install.sh don't include pip) + _progress("pip bootstrap") + if USE_UV: + run( + "Bootstrapping pip via uv", + [ + "uv", + "pip", + "install", + "--python", + sys.executable, + "pip", + ], + ) + else: + run( + "Upgrading pip", + [sys.executable, "-m", "pip", "install", "--upgrade", "pip"], + ) + + # 3. Core packages: unsloth-zoo + unsloth (or custom package name) + if skip_base: + print(_green(f"✅ {package_name} already installed — skipping base packages")) + elif local_repo: + # Local dev install: update deps from base.txt, then overlay the + # local checkout as an editable install (--no-deps so torch is + # never re-resolved). + _progress("base packages") + pip_install( + "Updating base packages", + "--no-cache-dir", + "--upgrade-package", + "unsloth", + "--upgrade-package", + "unsloth-zoo", + req = REQ_ROOT / "base.txt", + ) + pip_install( + "Overlaying local repo (editable)", + "--no-cache-dir", + "--no-deps", + "-e", + local_repo, + constrain = False, + ) + elif package_name != "unsloth": + # Custom package name (e.g. roland-sloth for testing) — install directly + _progress("base packages") + pip_install( + f"Installing {package_name}", + "--no-cache-dir", + package_name, + ) + else: + # Update path: upgrade only unsloth + unsloth-zoo while preserving + # existing torch/CUDA installations. Torch is pre-installed by + # install.sh / setup.ps1; --upgrade-package targets only base pkgs. + _progress("base packages") + pip_install( + "Updating base packages", + "--no-cache-dir", + "--upgrade-package", + "unsloth", + "--upgrade-package", + "unsloth-zoo", + req = REQ_ROOT / "base.txt", + ) # 3. Extra dependencies _progress("unsloth extras") diff --git a/studio/setup.ps1 b/studio/setup.ps1 index d429958bf14..c58bcd5c8da 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -250,9 +250,15 @@ function Find-VsBuildTools { # ───────────────────────────────────────────── # Banner # ───────────────────────────────────────────── -Write-Host "+==============================================+" -ForegroundColor Green -Write-Host "| Unsloth Studio Setup (Windows) |" -ForegroundColor Green -Write-Host "+==============================================+" -ForegroundColor Green +if ($env:SKIP_STUDIO_BASE -eq "1") { + Write-Host "+==============================================+" -ForegroundColor Green + Write-Host "| Unsloth Studio Setup (Windows) |" -ForegroundColor Green + Write-Host "+==============================================+" -ForegroundColor Green +} else { + Write-Host "+==============================================+" -ForegroundColor Green + Write-Host "| Unsloth Studio Update (Windows) |" -ForegroundColor Green + Write-Host "+==============================================+" -ForegroundColor Green +} # ========================================================================== # PHASE 1: System-level prerequisites (winget installs, env vars) @@ -1075,9 +1081,9 @@ if (-not $PythonCmd) { Write-Host "[OK] Using $PythonCmd ($(& $PythonCmd --version 2>&1))" -ForegroundColor Green -# Always create a .venv for isolation -- even for pip installs. -# Created in the repo root (parent of studio/). -$VenvDir = Join-Path $env:USERPROFILE ".unsloth\studio\.venv" +# The venv must already exist (created by install.ps1). +# This script (setup.ps1 / "unsloth studio update") only updates packages. +$VenvDir = Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio" # Stale-venv detection: if the venv exists but its torch flavor no longer # matches the current machine, wipe it so we get a clean install. @@ -1140,8 +1146,10 @@ if (Test-Path $VenvDir -PathType Container) { } if (-not (Test-Path $VenvDir)) { - Write-Host " Creating virtual environment at $VenvDir..." -ForegroundColor Cyan - & $PythonCmd -m venv $VenvDir + Write-Host "[ERROR] Virtual environment not found at $VenvDir" -ForegroundColor Red + Write-Host " Run install.ps1 first to create the environment:" -ForegroundColor Yellow + Write-Host " irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Yellow + exit 1 } else { Write-Host " Reusing existing virtual environment at $VenvDir" -ForegroundColor Green } @@ -1582,8 +1590,9 @@ if ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) { # Done # ============================================ Write-Host "" +$doneLine = if ($env:SKIP_STUDIO_BASE -eq "1") { "Setup Complete!" } else { "Update Complete!" } Write-Host "+===============================================+" -ForegroundColor Green -Write-Host "| Setup Complete! |" -ForegroundColor Green +Write-Host "| $doneLine |" -ForegroundColor Green Write-Host "| |" -ForegroundColor Green Write-Host "| Launch with: |" -ForegroundColor Green Write-Host "| unsloth studio -H 0.0.0.0 -p 8888 |" -ForegroundColor Green diff --git a/studio/setup.sh b/studio/setup.sh index 97bfbbfadc7..0e99173755a 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -44,9 +44,15 @@ run_quiet_no_exit() { _run_quiet return "$@" } -echo "╔══════════════════════════════════════╗" -echo "║ Unsloth Studio Setup Script ║" -echo "╚══════════════════════════════════════╝" +if [ "${SKIP_STUDIO_BASE:-0}" = "1" ]; then + echo "╔══════════════════════════════════════╗" + echo "║ Unsloth Studio Setup Script ║" + echo "╚══════════════════════════════════════╝" +else + echo "╔══════════════════════════════════════╗" + echo "║ Unsloth Studio Update Script ║" + echo "╚══════════════════════════════════════╝" +fi # ── Clean up stale Unsloth compiled caches ── rm -rf "$REPO_ROOT/unsloth_compiled_cache" @@ -244,114 +250,31 @@ fi # ── 6. Python venv + deps ── -# ── 6a. Discover best Python >= 3.11 and < 3.14 (i.e. 3.11.x, 3.12.x, or 3.13.x) ── -MIN_PY_MINOR=11 # minimum minor version (>= 3.11) -MAX_PY_MINOR=13 # maximum minor version (< 3.14) -BEST_PY="" -BEST_MINOR=0 - -# If the caller (e.g. install.sh) already chose a Python, use it directly. -if [ -n "${REQUESTED_PYTHON_VERSION:-}" ] && [ -x "$REQUESTED_PYTHON_VERSION" ]; then - _req_ver=$("$REQUESTED_PYTHON_VERSION" --version 2>&1 | awk '{print $2}') - _req_major=$(echo "$_req_ver" | cut -d. -f1) - _req_minor=$(echo "$_req_ver" | cut -d. -f2) - if [ "$_req_major" -eq 3 ] 2>/dev/null && \ - [ "$_req_minor" -ge "$MIN_PY_MINOR" ] 2>/dev/null && \ - [ "$_req_minor" -le "$MAX_PY_MINOR" ] 2>/dev/null; then - BEST_PY="$REQUESTED_PYTHON_VERSION" - echo "Using requested Python version: $BEST_PY" - else - echo "Ignoring requested Python $REQUESTED_PYTHON_VERSION ($_req_ver) -- outside supported range" - fi -fi - -if [ -z "$BEST_PY" ]; then -# Collect candidate python3 binaries (python3, python3.9, python3.10, …) -for candidate in $(compgen -c python3 2>/dev/null | grep -E '^python3(\.[0-9]+)?$' | sort -u); do - if ! command -v "$candidate" &>/dev/null; then - continue - fi - # Get version string, e.g. "Python 3.12.5" - ver_str=$("$candidate" --version 2>&1) || continue - ver_str=$(echo "$ver_str" | awk '{print $2}') - py_major=$(echo "$ver_str" | cut -d. -f1) - py_minor=$(echo "$ver_str" | cut -d. -f2) - - # Skip anything that isn't Python 3 - if [ "$py_major" -ne 3 ] 2>/dev/null; then - continue - fi - - # Skip versions below 3.11 - if [ "$py_minor" -lt "$MIN_PY_MINOR" ] 2>/dev/null; then - continue - fi - - # Skip versions above 3.13 (require < 3.14) - if [ "$py_minor" -gt "$MAX_PY_MINOR" ] 2>/dev/null; then - continue - fi - - # Keep the highest qualifying version - if [ "$py_minor" -gt "$BEST_MINOR" ]; then - BEST_PY="$candidate" - BEST_MINOR="$py_minor" - fi -done -fi - -if [ -z "$BEST_PY" ]; then - echo "❌ ERROR: No Python version between 3.${MIN_PY_MINOR} and 3.${MAX_PY_MINOR} found on this system." - echo " Detected Python 3 installations:" - for candidate in $(compgen -c python3 2>/dev/null | grep -E '^python3(\.[0-9]+)?$' | sort -u); do - if command -v "$candidate" &>/dev/null; then - echo " - $candidate ($($candidate --version 2>&1))" - fi - done - echo "" - echo " Please install Python 3.${MIN_PY_MINOR} or 3.${MAX_PY_MINOR}." - echo " For example: sudo apt install python3.12 python3.12-venv" - exit 1 -fi - -BEST_VER=$("$BEST_PY" --version 2>&1 | awk '{print $2}') -echo "✅ Using $BEST_PY ($BEST_VER) — compatible (3.${MIN_PY_MINOR}.x – 3.${MAX_PY_MINOR}.x)" - -REQ_ROOT="$SCRIPT_DIR/backend/requirements" -SINGLE_ENV_CONSTRAINTS="$REQ_ROOT/single-env/constraints.txt" -SINGLE_ENV_DATA_DESIGNER="$REQ_ROOT/single-env/data-designer.txt" -SINGLE_ENV_DATA_DESIGNER_DEPS="$REQ_ROOT/single-env/data-designer-deps.txt" -SINGLE_ENV_PATCH="$REQ_ROOT/single-env/patch_metadata.py" - -install_python_stack() { - python "$SCRIPT_DIR/install_python_stack.py" -} - -# Create venv under ~/.unsloth/studio/ (shared location, not in repo). -# All platforms (including Colab) use the same isolated venv so that -# studio dependencies are never installed into the system Python. +# The venv must already exist (created by install.sh). +# This script (setup.sh / "unsloth studio update") only updates packages. STUDIO_HOME="$HOME/.unsloth/studio" -VENV_DIR="$STUDIO_HOME/.venv" +VENV_DIR="$STUDIO_HOME/unsloth_studio" VENV_T5_DIR="$STUDIO_HOME/.venv_t5" -mkdir -p "$STUDIO_HOME" # Clean up legacy in-repo venvs if they exist [ -d "$REPO_ROOT/.venv" ] && rm -rf "$REPO_ROOT/.venv" [ -d "$REPO_ROOT/.venv_overlay" ] && rm -rf "$REPO_ROOT/.venv_overlay" [ -d "$REPO_ROOT/.venv_t5" ] && rm -rf "$REPO_ROOT/.venv_t5" +# Note: do NOT delete $STUDIO_HOME/.venv here — install.sh handles migration -rm -rf "$VENV_DIR" -rm -rf "$VENV_T5_DIR" -# Try creating venv with pip; fall back to --without-pip + bootstrap -# (some environments like Colab have broken ensurepip) -if ! "$BEST_PY" -m venv "$VENV_DIR" 2>/dev/null; then - "$BEST_PY" -m venv --without-pip "$VENV_DIR" - source "$VENV_DIR/bin/activate" - curl -sS https://bootstrap.pypa.io/get-pip.py | python > /dev/null -else - source "$VENV_DIR/bin/activate" +if [ ! -x "$VENV_DIR/bin/python" ]; then + echo "❌ ERROR: Virtual environment not found at $VENV_DIR" + echo " Run install.sh first to create the environment:" + echo " curl -fsSL https://unsloth.ai/install.sh | sh" + exit 1 fi +source "$VENV_DIR/bin/activate" + +install_python_stack() { + python "$SCRIPT_DIR/install_python_stack.py" +} + # ── Ensure uv is available (much faster than pip) ── USE_UV=false if command -v uv &>/dev/null; then @@ -370,22 +293,53 @@ fast_install() { } cd "$SCRIPT_DIR" -install_python_stack -# ── 6b. Pre-install transformers 5.x into .venv_t5/ ── -# Models like GLM-4.7-Flash need transformers>=5.3.0. Instead of pip-installing -# at runtime (slow, ~10-15s), we pre-install into a separate directory. -# The training subprocess just prepends .venv_t5/ to sys.path -- instant switch. -echo "" -echo " Pre-installing transformers 5.x for newer model support..." -mkdir -p "$VENV_T5_DIR" -run_quiet "install transformers 5.x" fast_install --target "$VENV_T5_DIR" --no-deps "transformers==5.3.0" -run_quiet "install huggingface_hub for t5" fast_install --target "$VENV_T5_DIR" --no-deps "huggingface_hub==1.7.1" -run_quiet "install hf_xet for t5" fast_install --target "$VENV_T5_DIR" --no-deps "hf_xet==1.4.2" -# tiktoken is needed by Qwen-family tokenizers. Install with deps since -# regex/requests may be missing on Windows. -run_quiet "install tiktoken for t5" fast_install --target "$VENV_T5_DIR" "tiktoken" -echo "✅ Transformers 5.x pre-installed to $VENV_T5_DIR/" +# ── Check if Python deps need updating ── +# Compare installed package version against PyPI latest. +# Skip all Python dependency work if versions match (fast update path). +_PKG_NAME="${STUDIO_PACKAGE_NAME:-unsloth}" +_SKIP_PYTHON_DEPS=false +if [ "${SKIP_STUDIO_BASE:-0}" != "1" ] && [ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then + # Only check when NOT called from install.sh (which just installed the package) + INSTALLED_VER=$("$VENV_DIR/bin/python" -c " +from importlib.metadata import version +print(version('$_PKG_NAME')) +" 2>/dev/null || echo "") + + LATEST_VER=$(curl -fsSL --max-time 5 "https://pypi.org/pypi/$_PKG_NAME/json" 2>/dev/null \ + | "$VENV_DIR/bin/python" -c "import sys,json; print(json.load(sys.stdin)['info']['version'])" 2>/dev/null \ + || echo "") + + if [ -n "$INSTALLED_VER" ] && [ -n "$LATEST_VER" ] && [ "$INSTALLED_VER" = "$LATEST_VER" ]; then + echo "✅ $_PKG_NAME $INSTALLED_VER is up to date (matches PyPI latest)" + _SKIP_PYTHON_DEPS=true + elif [ -n "$INSTALLED_VER" ] && [ -n "$LATEST_VER" ]; then + echo "⬆️ $_PKG_NAME $INSTALLED_VER → $LATEST_VER available, updating dependencies..." + elif [ -z "$LATEST_VER" ]; then + echo "⚠️ Could not reach PyPI, updating dependencies to be safe..." + fi +fi + +if [ "$_SKIP_PYTHON_DEPS" = false ]; then + install_python_stack + + # ── 6b. Pre-install transformers 5.x into .venv_t5/ ── + # Models like GLM-4.7-Flash need transformers>=5.3.0. Instead of pip-installing + # at runtime (slow, ~10-15s), we pre-install into a separate directory. + # The training subprocess just prepends .venv_t5/ to sys.path -- instant switch. + echo "" + echo " Pre-installing transformers 5.x for newer model support..." + mkdir -p "$VENV_T5_DIR" + run_quiet "install transformers 5.x" fast_install --target "$VENV_T5_DIR" --no-deps "transformers==5.3.0" + run_quiet "install huggingface_hub for t5" fast_install --target "$VENV_T5_DIR" --no-deps "huggingface_hub==1.7.1" + run_quiet "install hf_xet for t5" fast_install --target "$VENV_T5_DIR" --no-deps "hf_xet==1.4.2" + # tiktoken is needed by Qwen-family tokenizers. Install with deps since + # regex/requests may be missing on Windows. + run_quiet "install tiktoken for t5" fast_install --target "$VENV_T5_DIR" "tiktoken" + echo "✅ Transformers 5.x pre-installed to $VENV_T5_DIR/" +else + echo "✅ Python dependencies up to date — skipping" +fi # ── 7. WSL: pre-install GGUF build dependencies ── # On WSL, sudo requires a password and can't be entered during GGUF export @@ -651,9 +605,15 @@ rm -rf "$LLAMA_CPP_DIR" fi # end _SKIP_GGUF_BUILD check echo "" +if [ "${SKIP_STUDIO_BASE:-0}" = "1" ]; then + _DONE_LINE="║ Setup Complete! ║" +else + _DONE_LINE="║ Update Complete! ║" +fi + if [ "$IS_COLAB" = true ]; then echo "╔══════════════════════════════════════╗" - echo "║ Setup Complete! ║" + echo "$_DONE_LINE" echo "╠══════════════════════════════════════╣" echo "║ Unsloth Studio is ready to start ║" echo "║ in your Colab notebook! ║" @@ -663,7 +623,7 @@ if [ "$IS_COLAB" = true ]; then echo "╚══════════════════════════════════════╝" else echo "╔══════════════════════════════════════╗" - echo "║ Setup Complete! ║" + echo "$_DONE_LINE" echo "╠══════════════════════════════════════╣" echo "║ Launch with: ║" echo "║ ║" diff --git a/tests/python/__init__.py b/tests/python/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/python/test_cross_platform_parity.py b/tests/python/test_cross_platform_parity.py new file mode 100644 index 00000000000..6dd41be9fab --- /dev/null +++ b/tests/python/test_cross_platform_parity.py @@ -0,0 +1,137 @@ +"""Cross-platform parity tests between install.sh and install.ps1.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +INSTALL_SH = REPO_ROOT / "install.sh" +INSTALL_PS1 = REPO_ROOT / "install.ps1" + + +class TestNoTorchBackendAutoInInstallSh: + """install.sh primary install paths must not use --torch-backend=auto. + + The fallback else-branch (when TORCH_INDEX_URL is empty) is allowed to + use --torch-backend=auto since that is the last-resort recovery path. + """ + + def test_no_torch_backend_auto_outside_fallback(self): + lines = INSTALL_SH.read_text().splitlines() + # Find the fallback block: starts with the "else" after the + # TORCH_INDEX_URL check and ends at the next "fi". + fallback_start = None + fallback_end = None + for i, line in enumerate(lines): + if fallback_start is None and "GPU detection failed" in line: + fallback_start = i + elif ( + fallback_start is not None + and fallback_end is None + and line.strip() == "fi" + ): + fallback_end = i + break + fallback_range = ( + range(fallback_start or 0, (fallback_end or 0) + 1) + if fallback_start + else range(0) + ) + + matches = [ + (i + 1, line) + for i, line in enumerate(lines) + if "--torch-backend=auto" in line + and not line.lstrip().startswith("#") + and i not in fallback_range + ] + assert matches == [], ( + f"install.sh contains --torch-backend=auto outside the fallback block at lines: " + f"{[m[0] for m in matches]}" + ) + + def test_fallback_uses_torch_backend_auto(self): + """The fallback branch should use --torch-backend=auto as recovery.""" + text = INSTALL_SH.read_text() + assert ( + "GPU detection failed" in text + ), "install.sh should have a fallback branch for when GPU detection fails" + + +class TestInstallShHasGpuDetection: + """install.sh must contain the get_torch_index_url function.""" + + def test_function_exists(self): + text = INSTALL_SH.read_text() + assert ( + "get_torch_index_url()" in text + ), "install.sh is missing the get_torch_index_url() function" + + def test_torch_index_url_assigned(self): + text = INSTALL_SH.read_text() + assert ( + "TORCH_INDEX_URL=$(get_torch_index_url)" in text + ), "install.sh should assign TORCH_INDEX_URL from get_torch_index_url()" + + +class TestCudaMappingParity: + """CUDA version thresholds must match between install.sh and install.ps1.""" + + @staticmethod + def _extract_cuda_thresholds_sh(text: str) -> list[str]: + """Extract cu* suffixes from the major/minor comparison chain in install.sh.""" + # Only match lines in the if/elif chain that compare _major/_minor + in_func = False + results = [] + for line in text.splitlines(): + if "get_torch_index_url()" in line: + in_func = True + continue + if in_func and line.startswith("}"): + break + if in_func and ("_major" in line or "_minor" in line): + m = re.search(r"/(cu\d+|cpu)", line) + if m: + results.append(m.group(1)) + return results + + @staticmethod + def _extract_cuda_thresholds_ps1(text: str) -> list[str]: + """Extract cu* suffixes from the major/minor comparison chain in install.ps1.""" + in_func = False + depth = 0 + results = [] + for line in text.splitlines(): + if "function Get-TorchIndexUrl" in line: + in_func = True + depth = 1 + continue + if in_func: + depth += line.count("{") - line.count("}") + if depth <= 0: + break + # Only match the if-chain lines that compare $major/$minor + if "$major" in line or "$minor" in line: + m = re.search(r"/(cu\d+|cpu)", line) + if m: + results.append(m.group(1)) + return results + + def test_same_cuda_suffixes(self): + """Both scripts should produce the same ordered list of CUDA index suffixes.""" + sh_text = INSTALL_SH.read_text() + ps1_text = INSTALL_PS1.read_text() + + sh_thresholds = self._extract_cuda_thresholds_sh(sh_text) + ps1_thresholds = self._extract_cuda_thresholds_ps1(ps1_text) + + assert len(sh_thresholds) > 0, "Could not extract thresholds from install.sh" + assert len(ps1_thresholds) > 0, "Could not extract thresholds from install.ps1" + assert sh_thresholds == ps1_thresholds, ( + f"CUDA mapping mismatch:\n" + f" install.sh: {sh_thresholds}\n" + f" install.ps1: {ps1_thresholds}" + ) diff --git a/tests/python/test_install_python_stack.py b/tests/python/test_install_python_stack.py new file mode 100644 index 00000000000..16538ae42ba --- /dev/null +++ b/tests/python/test_install_python_stack.py @@ -0,0 +1,56 @@ +"""Tests for install_python_stack._build_uv_cmd torch-backend handling.""" + +from __future__ import annotations + +import importlib +import os +import sys +from pathlib import Path +from unittest import mock + +import pytest + +# Add the studio directory so we can import install_python_stack +STUDIO_DIR = Path(__file__).resolve().parents[2] / "studio" +sys.path.insert(0, str(STUDIO_DIR)) + +# _build_uv_cmd lives at module level; import after path setup. +# We need to mock parts of the module that do work at import time. +import install_python_stack as ips + + +class TestBuildUvCmdTorchBackend: + """Verify _build_uv_cmd only adds --torch-backend when UV_TORCH_BACKEND is set.""" + + def _call(self, args: tuple[str, ...] = ()) -> list[str]: + return ips._build_uv_cmd(args) + + def test_default_no_torch_backend(self): + """Without UV_TORCH_BACKEND env var, no --torch-backend flag.""" + env = os.environ.copy() + env.pop("UV_TORCH_BACKEND", None) + with mock.patch.dict(os.environ, env, clear = True): + cmd = self._call(("somepackage",)) + assert not any( + a.startswith("--torch-backend") for a in cmd + ), f"--torch-backend should not appear by default, got: {cmd}" + + def test_uv_torch_backend_auto(self): + """UV_TORCH_BACKEND=auto adds --torch-backend=auto.""" + with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": "auto"}): + cmd = self._call(("somepackage",)) + assert "--torch-backend=auto" in cmd + + def test_uv_torch_backend_cpu(self): + """UV_TORCH_BACKEND=cpu adds --torch-backend=cpu.""" + with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": "cpu"}): + cmd = self._call(("somepackage",)) + assert "--torch-backend=cpu" in cmd + + def test_uv_torch_backend_empty(self): + """UV_TORCH_BACKEND="" (empty string) should NOT add --torch-backend.""" + with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": ""}): + cmd = self._call(("somepackage",)) + assert not any( + a.startswith("--torch-backend") for a in cmd + ), f"Empty UV_TORCH_BACKEND should not add flag, got: {cmd}" diff --git a/tests/run_all.sh b/tests/run_all.sh new file mode 100755 index 00000000000..d7fdb38e741 --- /dev/null +++ b/tests/run_all.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Run all installer tests. +set -e + +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "=== Bash tests ===" +sh "$TESTS_DIR/sh/test_get_torch_index_url.sh" + +echo "" +echo "=== Python tests ===" +python -m pytest "$TESTS_DIR/python/test_install_python_stack.py" -v +python -m pytest "$TESTS_DIR/python/test_cross_platform_parity.py" -v + +echo "" +echo "All tests passed." diff --git a/tests/sh/test_get_torch_index_url.sh b/tests/sh/test_get_torch_index_url.sh new file mode 100755 index 00000000000..63879227129 --- /dev/null +++ b/tests/sh/test_get_torch_index_url.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# Unit tests for get_torch_index_url() from install.sh +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +PASS=0 +FAIL=0 + +# Extract only the get_torch_index_url function from install.sh +# Also replace the hardcoded /usr/bin/nvidia-smi fallback with a +# controllable path so we can test the "no GPU" scenario on GPU machines. +_FUNC_FILE=$(mktemp) +_FAKE_SMI_DIR=$(mktemp -d) +sed -n '/^get_torch_index_url()/,/^}/p' "$INSTALL_SH" \ + | sed "s|/usr/bin/nvidia-smi|$_FAKE_SMI_DIR/nvidia-smi-absent|g" \ + > "$_FUNC_FILE" + +# Save system PATH so we always have basic tools (uname, grep, head, etc.) +_SYS_PATH="/usr/local/bin:/usr/bin:/bin" + +assert_eq() { + _label="$1"; _expected="$2"; _actual="$3" + if [ "$_actual" = "$_expected" ]; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected '$_expected', got '$_actual')" + FAIL=$((FAIL + 1)) + fi +} + +# Helper: create a mock nvidia-smi that prints a given CUDA version string +make_mock_smi() { + _dir=$(mktemp -d) + cat > "$_dir/nvidia-smi" </dev/null || true) + [ -n "$_real" ] && ln -sf "$_real" "$_TOOLS_DIR/$_cmd" +done + +# Helper: run get_torch_index_url with a custom PATH +# $1 = directory with mock nvidia-smi (prepended to PATH), or "none" for no-GPU test +run_func() { + _mock_dir="$1" + if [ "$_mock_dir" = "none" ]; then + # Minimal PATH with only basic tools, no nvidia-smi anywhere + PATH="$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null + else + # Put mock nvidia-smi dir first, then basic tools + PATH="$_mock_dir:$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null + fi +} + +echo "=== test_get_torch_index_url ===" + +# 1) No nvidia-smi available -> cpu +_result=$(run_func "none") +assert_eq "no nvidia-smi -> cpu" "https://download.pytorch.org/whl/cpu" "$_result" + +# 2) CUDA 12.6 -> cu126 +_dir=$(make_mock_smi "12.6") +_result=$(run_func "$_dir") +assert_eq "CUDA 12.6 -> cu126" "https://download.pytorch.org/whl/cu126" "$_result" +rm -rf "$_dir" + +# 3) CUDA 12.8 -> cu128 +_dir=$(make_mock_smi "12.8") +_result=$(run_func "$_dir") +assert_eq "CUDA 12.8 -> cu128" "https://download.pytorch.org/whl/cu128" "$_result" +rm -rf "$_dir" + +# 4) CUDA 13.0 -> cu130 +_dir=$(make_mock_smi "13.0") +_result=$(run_func "$_dir") +assert_eq "CUDA 13.0 -> cu130" "https://download.pytorch.org/whl/cu130" "$_result" +rm -rf "$_dir" + +# 5) CUDA 12.4 -> cu124 +_dir=$(make_mock_smi "12.4") +_result=$(run_func "$_dir") +assert_eq "CUDA 12.4 -> cu124" "https://download.pytorch.org/whl/cu124" "$_result" +rm -rf "$_dir" + +# 6) CUDA 11.8 -> cu118 +_dir=$(make_mock_smi "11.8") +_result=$(run_func "$_dir") +assert_eq "CUDA 11.8 -> cu118" "https://download.pytorch.org/whl/cu118" "$_result" +rm -rf "$_dir" + +# 7) CUDA 10.2 (too old) -> cpu +_dir=$(make_mock_smi "10.2") +_result=$(run_func "$_dir") +assert_eq "CUDA 10.2 -> cpu" "https://download.pytorch.org/whl/cpu" "$_result" +rm -rf "$_dir" + +# 8) Unparseable nvidia-smi output -> cu126 default +_dir=$(mktemp -d) +cat > "$_dir/nvidia-smi" <<'MOCK' +#!/bin/sh +echo "something completely unexpected" +MOCK +chmod +x "$_dir/nvidia-smi" +_result=$(run_func "$_dir") +assert_eq "unparseable -> cu126" "https://download.pytorch.org/whl/cu126" "$_result" +rm -rf "$_dir" + +rm -f "$_FUNC_FILE" +rm -rf "$_FAKE_SMI_DIR" +rm -rf "$_TOOLS_DIR" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/unsloth_cli/__init__.py b/unsloth_cli/__init__.py index 3b9043c5bf6..3a821359b71 100644 --- a/unsloth_cli/__init__.py +++ b/unsloth_cli/__init__.py @@ -6,7 +6,6 @@ from unsloth_cli.commands.train import train from unsloth_cli.commands.inference import inference from unsloth_cli.commands.export import export, list_checkpoints -from unsloth_cli.commands.ui import ui from unsloth_cli.commands.studio import studio_app app = typer.Typer( @@ -18,5 +17,4 @@ app.command()(inference) app.command()(export) app.command("list-checkpoints")(list_checkpoints) -app.command()(ui) app.add_typer(studio_app, name = "studio", help = "Unsloth Studio commands.") diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index 192e138a9c1..c6d398eebda 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -22,9 +22,9 @@ def _studio_venv_python() -> Optional[Path]: """Return the studio venv Python binary, or None if not set up.""" if platform.system() == "Windows": - p = STUDIO_HOME / ".venv" / "Scripts" / "python.exe" + p = STUDIO_HOME / "unsloth_studio" / "Scripts" / "python.exe" else: - p = STUDIO_HOME / ".venv" / "bin" / "python" + p = STUDIO_HOME / "unsloth_studio" / "bin" / "python" return p if p.is_file() else None @@ -44,7 +44,7 @@ def _find_run_py() -> Optional[Path]: "lib/python*/site-packages/studio/backend/run.py", "Lib/site-packages/studio/backend/run.py", ): - for match in (STUDIO_HOME / ".venv").glob(pattern): + for match in (STUDIO_HOME / "unsloth_studio").glob(pattern): return match return None @@ -64,7 +64,7 @@ def _find_setup_script() -> Optional[Path]: f"lib/python*/site-packages/studio/{name}", f"Lib/site-packages/studio/{name}", ): - for match in (STUDIO_HOME / ".venv").glob(pattern): + for match in (STUDIO_HOME / "unsloth_studio").glob(pattern): return match return None @@ -85,7 +85,7 @@ def studio_default( return # Always use the studio venv if it exists and we're not already in it - studio_venv_dir = STUDIO_HOME / ".venv" + studio_venv_dir = STUDIO_HOME / "unsloth_studio" in_studio_venv = sys.prefix.startswith(str(studio_venv_dir)) if not in_studio_venv: @@ -132,7 +132,7 @@ def studio_default( else: os.execvp(str(studio_python), args) else: - typer.echo("Studio not set up. Run 'unsloth studio setup' first.") + typer.echo("Studio not set up. Run install.sh first.") raise typer.Exit(1) from studio.backend.run import run_server @@ -166,12 +166,11 @@ def studio_default( typer.echo("\nShutting down...") -# ── unsloth studio setup ───────────────────────────────────────────── +# ── unsloth studio setup / update ───────────────────────────────────── -@studio_app.command() -def setup(): - """Run one-time Studio environment setup.""" +def _run_setup_script() -> None: + """Find and run the studio setup/update script.""" script = _find_setup_script() if not script: typer.echo("Error: Could not find setup script (setup.sh / setup.ps1).") @@ -188,6 +187,35 @@ def setup(): raise typer.Exit(result.returncode) +@studio_app.command(hidden = True) +def setup(): + """Deprecated: use 'unsloth studio update' or re-run install.sh.""" + typer.echo( + "Note: 'unsloth studio setup' is deprecated. Use 'unsloth studio update' or re-run install.sh." + ) + _run_setup_script() + + +@studio_app.command() +def update( + local: bool = typer.Option( + False, "--local", help = "Install from local repo instead of PyPI" + ), + package: str = typer.Option( + "unsloth", "--package", help = "Package name to install/update (for testing)" + ), +): + """Update Unsloth Studio dependencies and rebuild.""" + os.environ["STUDIO_LOCAL_INSTALL"] = "1" if local else "0" + os.environ["STUDIO_PACKAGE_NAME"] = package + if local: + # Pass the repo root explicitly so install_python_stack.py doesn't + # have to guess from SCRIPT_DIR (which may be inside site-packages). + repo_root = Path(__file__).resolve().parents[2] + os.environ["STUDIO_LOCAL_REPO"] = str(repo_root) + _run_setup_script() + + # ── unsloth studio reset-password ──────────────────────────────────── diff --git a/unsloth_cli/commands/ui.py b/unsloth_cli/commands/ui.py deleted file mode 100644 index 8f766369908..00000000000 --- a/unsloth_cli/commands/ui.py +++ /dev/null @@ -1,103 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -import os -import sys -import time -from pathlib import Path -from typing import Optional - -import typer - - -def ui( - port: int = typer.Option( - 8888, "--port", "-p", help = "Port to run the UI server on." - ), - host: str = typer.Option( - "0.0.0.0", "--host", "-H", help = "Host address to bind to." - ), - frontend: Optional[Path] = typer.Option( - None, "--frontend", "-f", help = "Path to frontend build directory." - ), - silent: bool = typer.Option( - False, "--silent", "-q", help = "Suppress startup messages." - ), -): - """Launch the Unsloth web UI backend server (alias for 'unsloth studio').""" - from unsloth_cli.commands.studio import ( - _studio_venv_python, - _find_run_py, - STUDIO_HOME, - ) - - # Re-execute in studio venv if available and not already inside it - studio_venv_dir = STUDIO_HOME / ".venv" - in_studio_venv = sys.prefix.startswith(str(studio_venv_dir)) - - if not in_studio_venv: - studio_python = _studio_venv_python() - run_py = _find_run_py() - if studio_python and run_py: - if not silent: - typer.echo("Launching Unsloth Studio... Please wait...") - args = [ - str(studio_python), - str(run_py), - "--host", - host, - "--port", - str(port), - ] - if frontend: - args.extend(["--frontend", str(frontend)]) - if silent: - args.append("--silent") - # On Windows, os.execvp() spawns a child but the parent lingers, - # so Ctrl+C only kills the parent leaving the child orphaned. - # Use subprocess.run() on Windows so the parent waits for the child. - if sys.platform == "win32": - import subprocess as _sp - - proc = _sp.Popen(args) - try: - rc = proc.wait() - except KeyboardInterrupt: - # Child has its own signal handler — let it finish - rc = proc.wait() - raise typer.Exit(rc) - else: - os.execvp(str(studio_python), args) - else: - typer.echo("Studio not set up. Run 'unsloth studio setup' first.") - raise typer.Exit(1) - - from studio.backend.run import run_server - - if not silent: - from studio.backend.run import _resolve_external_ip - - display_host = _resolve_external_ip() if host == "0.0.0.0" else host - typer.echo(f"Starting Unsloth Studio on http://{display_host}:{port}") - - run_kwargs = dict(host = host, port = port, silent = silent) - if frontend is not None: - run_kwargs["frontend_path"] = frontend - run_server(**run_kwargs) - - from studio.backend.run import _shutdown_event - - try: - if _shutdown_event is not None: - # NOTE: Event.wait() without a timeout blocks at the C level - # on Linux, preventing Python from delivering SIGINT (Ctrl+C). - while not _shutdown_event.is_set(): - _shutdown_event.wait(timeout = 1) - else: - while True: - time.sleep(1) - except KeyboardInterrupt: - from studio.backend.run import _graceful_shutdown, _server - - _graceful_shutdown(_server) - typer.echo("\nShutting down...")