unsloth/tests/python/test_installer_master_root.py
oobabooga e72a79fb51
Studio: honor UNSLOTH_HOME in installers (#14293)
With only `UNSLOTH_HOME` set, the installers put Studio's venv in the
default directory, but the CLI looks in `<UNSLOTH_HOME>/studio`. Linux
and macOS finish installing, then fail to launch with `Unsloth Studio
not set up`. Windows fails during setup because it cannot find the venv.

Make both installers and setup scripts use `<UNSLOTH_HOME>/studio`.
`UNSLOTH_STUDIO_HOME` and `STUDIO_HOME` still take precedence. On Linux
and macOS, the installer writes `<UNSLOTH_HOME>/llama.cpp` to the
existing `studio/share/studio.conf`, which the launcher reads on
startup.

Windows setup reuses Python from that venv before looking for system
Python. Direct `install.sh --tauri` runs reject custom Studio paths
because the desktop cannot find them. The CLI keeps its fallback for
older installations.

Validation:

- Live A/B on Linux, macOS and Windows: reproduced the failures above;
with this change, fresh installs launch and generate text from a GGUF.
Windows logs:
[before](https://github.com/oobabooga/unsloth/actions/runs/39305114211),
[after](https://github.com/oobabooga/unsloth/actions/runs/39306042012).
- Verified launch from a new shell and after `unsloth studio update`.
Uninstall with `UNSLOTH_STUDIO_HOME` set removes the installed runtimes
and preserves unrelated files.
- 51 focused tests passed, including 22 regression cases that fail
before the fix. Native macOS and [Windows PowerShell
5.1](https://github.com/oobabooga/unsloth/actions/runs/39313291674)
tests also passed.

Live installs used `--local --no-torch`. Training was not tested.

---------

Co-authored-by: Lyxot <longyixing331@gmail.com>
2026-10-20 22:13:20 +08:00

314 lines
12 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import os
from pathlib import Path
import subprocess
import sys
import pytest
from unsloth_pwsh_runner import PWSH, run_pwsh
REPO = Path(__file__).resolve().parents[2]
POSIX = pytest.mark.skipif(os.name == "nt", reason = "POSIX")
WINDOWS = pytest.mark.skipif(not PWSH, reason = "PowerShell")
PS_PRELUDE = """$ErrorActionPreference = 'Stop'
function Write-StudioLine { param($Message, $ForegroundColor) }
function substep { param($Message) }
function Restore-StudioTempEnvironment {}
function Exit-SetupFailure { param($Message) throw $Message }
"""
def _part(filename, start, end):
source = (REPO / filename).read_text(encoding = "utf-8")
begin = source.index(start)
return source[begin : source.index(end, begin)]
@pytest.fixture
def env(tmp_path):
result = dict(os.environ, HOME = str(tmp_path), USERPROFILE = str(tmp_path))
for key in (
"UNSLOTH_HOME",
"UNSLOTH_STUDIO_HOME",
"STUDIO_HOME",
"UNSLOTH_LLAMA_CPP_PATH",
"UNSLOTH_SETUP_PYTHON",
"UNSLOTH_STUDIO_STAGE_ROOT",
):
result.pop(key, None)
return result
def _run(
script,
env,
cwd,
powershell = False,
expected_exit = 0,
):
if (powershell and not PWSH) or (not powershell and os.name == "nt"):
pytest.skip("shell unavailable")
argv = [PWSH, "-NoProfile", "-NonInteractive", "-Command"] if powershell else ["sh", "-c"]
run = run_pwsh if powershell else subprocess.run
result = run(argv + [script], env = env, cwd = cwd, capture_output = True, text = True, timeout = 60)
assert result.returncode == expected_exit, result.stdout + result.stderr
return result
def _root_script(filename):
if filename == "install.sh":
body = _part(filename, "_resolve_studio_destinations() {", "\n}\n")
return (
"set -eu\nsubstep() { :; }\n"
+ body
+ '\n}\n_resolve_studio_destinations\necho "$STUDIO_HOME|$_STUDIO_HOME_REDIRECT"'
)
if filename == "studio/setup.sh":
return (
'set -eu\nsetup_fail() { exit "$1"; }\n'
+ _part(filename, '_studio_override_var=""', "\nSTAGE_ROOT=")
+ '\necho "$STUDIO_HOME"'
)
if filename == "install.ps1":
body = "$defaultProfile = $null\n$defaultDataDir = 'unused'\n$tauriChosenRoot = $false\n"
body += "$tauriProfile = $env:USERPROFILE\n"
body += _part(filename, " $envOverrideVar = $null", " $defaultProfile = $null")
for name in (
"Resolve-StudioLinkTarget",
"Get-StudioSubstTarget",
"Get-StudioLexicalPath",
"Resolve-StudioFinalPathInfo",
"Get-StudioFinalPath",
"Test-StudioSameDirectoryByProbe",
"Split-StudioExistingAncestor",
):
body += _part(filename, f" function {name} {{", "\n }\n") + "\n }\n"
body += "function Get-StudioPythonFinalPath { param($Path) return $null }\n"
body += "function Write-StudioFinalPathDegraded { param($Reason) }\n"
body += _part(
filename,
" # Custom Unsloth roots are not supported with --tauri",
" # LOCALAPPDATA may be unset",
)
body += _part(
filename, " # Cleared per invocation:", " $VenvDir = Join-Path $StudioHome"
)
return PS_PRELUDE + body + '\n"$StudioHome|$StudioRedirectMode"'
return (
PS_PRELUDE
+ _part(filename, "$_studioOverrideVar = $null", "$StudioOwnedMarker = ")
+ '\n"$StudioHome"\n'
)
@pytest.mark.parametrize(
"filename", ["install.sh", "studio/setup.sh", "install.ps1", "studio/setup.ps1", "cli"]
)
@pytest.mark.parametrize(
"studio, alias, master, expected",
[
("", "", "", ".unsloth/studio"),
("chosen", "", "", "chosen"),
("", "alias", "", "alias"),
("chosen", "alias", "", "chosen"),
("chosen", "", "portable", "chosen"),
("", "alias", "portable", "alias"),
("", "", "{home}/portable [1]", "portable [1]/studio"),
(" \t ", " ", " portable [1] ", "portable [1]/studio"),
("chosen", "ignored", "portable", "chosen"),
(" \t ", "alias", "portable", "alias"),
("", "", "~/portable", "portable/studio"),
("", "", "~", "studio"),
("~/chosen space [1]", "", "", "chosen space [1]"),
("", "~/alias space [1]", "", "alias space [1]"),
("", "", " \t ", ".unsloth/studio"),
],
)
def test_install_and_setup_root_precedence(
tmp_path, env, filename, studio, alias, master, expected
):
for key, value in zip(
("UNSLOTH_STUDIO_HOME", "STUDIO_HOME", "UNSLOTH_HOME"), (studio, alias, master)
):
env[key] = value.format(home = tmp_path)
root = tmp_path / expected
if filename.startswith("studio/"):
root.mkdir(parents = True)
if filename == "cli":
env["PYTHONPATH"] = str(REPO)
result = subprocess.run(
[
sys.executable,
"-c",
"from unsloth_cli.commands.studio import STUDIO_HOME; print(STUDIO_HOME)",
],
env = env,
cwd = tmp_path,
capture_output = True,
text = True,
timeout = 60,
)
assert result.returncode == 0, result.stderr
assert Path(result.stdout.strip()).resolve() == root.resolve()
return
output = _run(
_root_script(filename), env, tmp_path, powershell = filename.endswith(".ps1")
).stdout.strip()
chosen, *mode = output.split("|")
assert Path(chosen).resolve() == root.resolve()
if mode and master.strip():
assert mode == ["env"]
@POSIX
@pytest.mark.parametrize(
"master, studio, alias, accepted",
[
(" portable [1] ", "", "", False),
("portable", " \t ", " ", False),
("", " \t ", "custom", False),
("portable", "{home}/.unsloth/studio", "ignored", True),
("~/.unsloth/", "", "", True),
(".unsloth", "", "", True),
("./.unsloth", "", "", True),
("missing/../.unsloth", "", "", True),
("", ".unsloth/studio", "", True),
("", "", "./.unsloth/studio", True),
],
)
def test_desktop_guard_uses_selected_root(tmp_path, env, master, studio, alias, accepted):
for key, value in zip(
("UNSLOTH_HOME", "UNSLOTH_STUDIO_HOME", "STUDIO_HOME"), (master, studio, alias)
):
env[key] = value.format(home = tmp_path)
# Run argument parsing and the guard, stopping before any install work.
script = (
_part("install.sh", "#!/bin/sh", "\n_is_verbose() {")
+ "\necho accepted\n}\n_unsloth_main --tauri"
)
result = _run(script, env, tmp_path, expected_exit = 0 if accepted else 1)
assert (
"accepted" in result.stdout if accepted else "not supported with --tauri" in result.stderr
)
assert not any(tmp_path.iterdir())
@WINDOWS
@pytest.mark.parametrize("handoff", [None, "UNSLOTH_STUDIO_STAGE_ROOT", "UNSLOTH_SETUP_PYTHON"])
def test_setup_reuses_python_without_system_python(tmp_path, env, handoff):
studio = tmp_path / "portable" / "studio"
studio.mkdir(parents = True)
env["UNSLOTH_HOME"] = str(studio.parent)
runtime = tmp_path / "handoff [1]" if handoff else studio
venv = runtime / "unsloth_studio"
subprocess.run(
[sys.executable, "-m", "venv", "--without-pip", str(venv)], check = True, timeout = 60
)
python = venv / "Scripts" / "python.exe"
if os.name != "nt":
python.parent.mkdir()
python.symlink_to(venv / "bin" / "python")
if handoff:
env[handoff] = str(python if handoff == "UNSLOTH_SETUP_PYTHON" else runtime)
script = (
_root_script("studio/setup.ps1")
+ """
$env:PATH = ''
function winget { throw 'Unexpected system Python installation' }
"""
)
script += _part(
"studio/setup.ps1", "$CondaSkipPattern = ", "# Add user-scheme Python Scripts dir"
)
script += (
'\nif (-not $PythonOk) { throw "Python prerequisite failed" }\n"$ValidatedSetupPython"'
)
output = _run(script, env, tmp_path, powershell = True).stdout.splitlines()
assert output[-1] == str(python)
@WINDOWS
def test_windows_relative_master_survives_directory_change(tmp_path, env):
env["UNSLOTH_HOME"] = "portable"
(tmp_path / "elsewhere").mkdir()
script = PS_PRELUDE + "function Get-PathState { param($Path, $PathType) return 'Missing' }\n"
script += _part("install.ps1", " function Get-CanonicalDir {", "\n }\n") + "\n }\n"
script += _part(
"install.ps1", " function Get-MasterRootOverride {", " # Explicit staging root,"
)
script += "\nSet-Location -LiteralPath 'elsewhere'\nGet-MasterRootOverride"
assert (
Path(_run(script, env, tmp_path, powershell = True).stdout.strip()) == tmp_path / "portable"
)
@WINDOWS
def test_windows_install_root_uses_powershell_working_directory(tmp_path, env):
caller = tmp_path / "caller space [1]"
caller.mkdir()
env["UNSLOTH_HOME"] = "portable"
script = "Set-Location -LiteralPath 'caller space [1]'\n" + _root_script("install.ps1")
output = _run(script, env, tmp_path, powershell = True).stdout.strip()
assert Path(output.split("|")[0]) == caller / "portable" / "studio"
assert not (tmp_path / "portable").exists()
@WINDOWS
@pytest.mark.parametrize("variable", ["UNSLOTH_HOME", "UNSLOTH_STUDIO_HOME", "STUDIO_HOME"])
@pytest.mark.parametrize("accepted", [False, True])
def test_windows_tauri_root_uses_powershell_working_directory(tmp_path, env, variable, accepted):
caller = tmp_path / "caller space [1]"
caller.mkdir()
env["USERPROFILE"] = str(caller)
env.pop("UNSLOTH_DESKTOP_CHOSEN_STUDIO_HOME", None)
root = ".unsloth" if accepted else "custom"
env[variable] = root if variable == "UNSLOTH_HOME" else root + "/studio"
script = "$TauriMode = $true\nSet-Location -LiteralPath 'caller space [1]'\n"
result = _run(
script + _root_script("install.ps1"),
env,
tmp_path,
powershell = True,
expected_exit = 0 if accepted else 1,
)
if accepted:
assert Path(result.stdout.strip().split("|")[0]) == caller / ".unsloth" / "studio"
else:
assert "not supported with --tauri" in result.stderr
assert not any(caller.iterdir())
assert not (tmp_path / root).exists()
@POSIX
@pytest.mark.parametrize("prefix", ["", "~/"])
def test_saved_launcher_keeps_runtime_path_without_master_env(tmp_path, env, prefix):
master = tmp_path / "portable space's [1]"
studio = master / "studio"
(studio / "share").mkdir(parents = True)
(master / "llama.cpp").mkdir()
config = studio / "share" / "studio.conf"
saved_env = dict(
env,
UNSLOTH_HOME = f" {prefix}{master.name} ",
STUDIO_HOME = str(studio),
_STUDIO_HOME_REDIRECT = "env",
_css_data_dir = str(config.parent),
_css_quoted_exe = "unused",
_css_id_file = "unused",
)
block = _part(
"install.sh",
" {\n printf '%s\\n' \"UNSLOTH_EXE=",
' > "$_css_data_dir/studio.conf"',
)
_run(block + ' > "$_css_data_dir/studio.conf"', saved_env, tmp_path)
env["TEST_CONFIG"] = str(config)
for override in ("", str(tmp_path / "external llama")):
env["UNSLOTH_LLAMA_CPP_PATH"] = override
script = (
'. "$TEST_CONFIG"\nprintf "%s\\n%s\\n" "$UNSLOTH_STUDIO_HOME" "$UNSLOTH_LLAMA_CPP_PATH"'
)
output = _run(script, env, tmp_path).stdout.splitlines()
assert output == [str(studio), override or str(master / "llama.cpp")]