mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-21 05:11:50 +00:00
Comment and docstring changes only, in test files, split out of #12968 so the test-only part can merge first.
1340 lines
55 KiB
Python
1340 lines
55 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Setup must place the runtimes at UNSLOTH_HOME, not under the studio home the CLI exports."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# tests/conftest.py puts tests/_shared on sys.path; see unsloth_pwsh_runner for why a bare
|
|
# pwsh subprocess is not enough under xdist.
|
|
from unsloth_pwsh_runner import PWSH, run_pwsh
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
SETUP_SH = REPO_ROOT / "studio" / "setup.sh"
|
|
SETUP_PS1 = REPO_ROOT / "studio" / "setup.ps1"
|
|
|
|
|
|
# Needs POSIX bash: Git Bash on Windows runs these against Windows paths and UTF-16.
|
|
NEEDS_POSIX_BASH = pytest.mark.skipif(
|
|
shutil.which("bash") is None or os.name == "nt",
|
|
reason = "runs studio/setup.sh blocks: needs bash on a POSIX filesystem",
|
|
)
|
|
|
|
|
|
def _ps_quote(value: str) -> str:
|
|
"""A PowerShell single-quoted literal: only the quote itself needs escaping."""
|
|
return "'" + value.replace("'", "''") + "'"
|
|
|
|
|
|
def _slice(src: str, start: str, end: str) -> str:
|
|
begin = src.index(start)
|
|
return src[begin : src.index(end, begin)]
|
|
|
|
|
|
def _runtime_parent(env: dict[str, str]) -> tuple[str, str]:
|
|
"""Runs the shipped setup.sh blocks rather than matching text, so reordering them still fails."""
|
|
src = SETUP_SH.read_text(encoding = "utf-8")
|
|
master = _slice(src, "# Stripped before anything else", "# Directory-local evidence")
|
|
node = _slice(src, "# Mirror the llama.cpp UNSLOTH_HOME derivation", "NODE_DIR=")
|
|
llama = _slice(src, 'if [ -n "$STAGE_ROOT" ]; then\n UNSLOTH_HOME=', "LLAMA_CPP_DIR=")
|
|
script = "\n".join(
|
|
(
|
|
"set -u",
|
|
master,
|
|
node,
|
|
llama,
|
|
'printf "%s\\n%s\\n" "$_NODE_PARENT" "$UNSLOTH_HOME"',
|
|
)
|
|
)
|
|
completed = subprocess.run(
|
|
["bash", "-c", script],
|
|
env = env,
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
node_parent, llama_parent = completed.stdout.splitlines()
|
|
return node_parent, llama_parent
|
|
|
|
|
|
def _env(home: Path, **overrides: str) -> dict[str, str]:
|
|
env = {
|
|
"HOME": str(home),
|
|
"PATH": "/usr/bin:/bin",
|
|
"STAGE_ROOT": "",
|
|
"RUNTIME_ROOT": "",
|
|
"STUDIO_HOME": str(home / ".unsloth" / "studio"),
|
|
"_STUDIO_HOME_IS_CUSTOM": "false",
|
|
}
|
|
env.update(overrides)
|
|
return env
|
|
|
|
|
|
def _master_root_answer(tmp_path: Path, environment: str) -> str:
|
|
"""Runs the shipped _MasterRoot from uninstall.ps1 so a rewrite that keeps only the words fails."""
|
|
script = tmp_path / "probe.ps1"
|
|
script.write_text(
|
|
f"""$txt = Get-Content -Raw "{UNINSTALL_PS1}"
|
|
foreach ($n in @("_ExpandTilde", "_MasterRoot")) {{
|
|
$m = [regex]::Match($txt, "(?ms)^ function $n \\{{.*?^ \\}}")
|
|
if (-not $m.Success) {{ Write-Output "EXTRACT-FAILED:$n"; exit 1 }}
|
|
Invoke-Expression $m.Value
|
|
}}
|
|
{environment}
|
|
""",
|
|
encoding = "utf-8",
|
|
)
|
|
out = run_pwsh(
|
|
[PWSH, "-NoProfile", "-File", str(script)],
|
|
capture_output = True,
|
|
text = True,
|
|
check = True,
|
|
).stdout.strip()
|
|
assert "EXTRACT-FAILED" not in out, out
|
|
return out
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_a_master_root_puts_the_runtimes_beside_studio(tmp_path):
|
|
root = tmp_path / "portable"
|
|
(root / "studio").mkdir(parents = True)
|
|
env = _env(
|
|
tmp_path / "home",
|
|
UNSLOTH_HOME = str(root),
|
|
# What unsloth_cli/commands/studio.py exports before it runs setup.
|
|
STUDIO_HOME = str(root / "studio"),
|
|
_STUDIO_HOME_IS_CUSTOM = "true",
|
|
)
|
|
node_parent, llama_parent = _runtime_parent(env)
|
|
assert Path(node_parent) == root
|
|
assert Path(llama_parent) == root
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_no_master_root_keeps_the_studio_home_layout(tmp_path):
|
|
"""The pre-existing custom-root behaviour, which the new branch must not disturb."""
|
|
studio = tmp_path / "elsewhere" / "studio"
|
|
studio.mkdir(parents = True)
|
|
env = _env(
|
|
tmp_path / "home",
|
|
STUDIO_HOME = str(studio),
|
|
_STUDIO_HOME_IS_CUSTOM = "true",
|
|
)
|
|
node_parent, llama_parent = _runtime_parent(env)
|
|
assert Path(node_parent) == studio
|
|
assert Path(llama_parent) == studio
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_a_default_install_still_uses_the_legacy_root(tmp_path):
|
|
home = tmp_path / "home"
|
|
(home / ".unsloth" / "studio").mkdir(parents = True)
|
|
node_parent, llama_parent = _runtime_parent(_env(home))
|
|
assert Path(node_parent) == home / ".unsloth"
|
|
assert Path(llama_parent) == home / ".unsloth"
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_a_staging_root_still_outranks_the_master_root(tmp_path):
|
|
stage = tmp_path / "stage"
|
|
stage.mkdir()
|
|
env = _env(
|
|
tmp_path / "home",
|
|
UNSLOTH_HOME = str(tmp_path / "portable"),
|
|
STAGE_ROOT = str(stage),
|
|
RUNTIME_ROOT = str(stage),
|
|
)
|
|
node_parent, llama_parent = _runtime_parent(env)
|
|
assert Path(node_parent) == stage
|
|
assert Path(llama_parent) == stage
|
|
|
|
|
|
def test_setup_ps1_derives_both_runtimes_from_the_master_root():
|
|
"""No PowerShell on the Linux runners, so the Windows half is held structurally: both
|
|
derivations must go through the one helper, and it must read UNSLOTH_HOME."""
|
|
src = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "function Get-MasterRootOverride" in src
|
|
helper = _slice(src, "function Get-MasterRootOverride", "function Get-ManagedLlamaCppDir")
|
|
assert "$env:UNSLOTH_HOME" in helper
|
|
llama = _slice(src, "function Get-ManagedLlamaCppDir", "\n# Failure reason when the managed")
|
|
assert "Get-MasterRootOverride" in llama
|
|
# The master root must lose to an explicit staging root, as in setup.sh.
|
|
assert llama.index("$StagingRoot") < llama.index("Get-MasterRootOverride")
|
|
node = _slice(src, " $_masterRoot = Get-MasterRootOverride", " $NodeDir = Join-Path")
|
|
assert "$NodeParent = $_masterRoot" in node
|
|
assert node.index("$StageRoot") < node.index("$NodeParent = $_masterRoot")
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_a_padded_master_root_names_the_same_directory(tmp_path):
|
|
"""storage_roots.unsloth_home() and the CLI both .strip(), so setup has to as well: an
|
|
unstripped value installs under a directory whose name carries the whitespace."""
|
|
root = tmp_path / "portable"
|
|
(root / "studio").mkdir(parents = True)
|
|
env = _env(
|
|
tmp_path / "home",
|
|
UNSLOTH_HOME = f" {root} ",
|
|
STUDIO_HOME = str(root / "studio"),
|
|
_STUDIO_HOME_IS_CUSTOM = "true",
|
|
)
|
|
node_parent, llama_parent = _runtime_parent(env)
|
|
assert Path(node_parent) == root
|
|
assert Path(llama_parent) == root
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_a_blank_master_root_counts_as_unset(tmp_path):
|
|
home = tmp_path / "home"
|
|
(home / ".unsloth" / "studio").mkdir(parents = True)
|
|
node_parent, llama_parent = _runtime_parent(_env(home, UNSLOTH_HOME = " "))
|
|
assert Path(node_parent) == home / ".unsloth"
|
|
assert Path(llama_parent) == home / ".unsloth"
|
|
|
|
|
|
BUILD_WHISPER = REPO_ROOT / "scripts" / "build_whisper_cpp.sh"
|
|
|
|
|
|
def _whisper_root_block() -> str:
|
|
"""The shipped root selection, from the normalizer down to the INSTALL_DIR it decides."""
|
|
src = BUILD_WHISPER.read_text(encoding = "utf-8")
|
|
return _slice(src, "_root_value() {", "STUDIO_OWNED_MARKER=")
|
|
|
|
|
|
@pytest.fixture
|
|
def whisper_path(tmp_path_factory):
|
|
"""Stubs git and cmake on PATH so the shipped whisper block's command -v preflight passes."""
|
|
stub_bin = tmp_path_factory.mktemp("stubbin")
|
|
for tool in ("git", "cmake"):
|
|
path = stub_bin / tool
|
|
path.write_text("#!/bin/sh\nexit 0\n", encoding = "utf-8")
|
|
path.chmod(0o755)
|
|
return f"{stub_bin}:/usr/bin:/bin"
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_the_whisper_builder_installs_under_the_master_root(tmp_path, whisper_path):
|
|
"""setup.sh runs this with UNSLOTH_STUDIO_HOME=<root>/studio still inherited, so a builder
|
|
that preferred it would install a level below _managed_whisper_cpp_dir()."""
|
|
root = tmp_path / "portable"
|
|
src = BUILD_WHISPER.read_text(encoding = "utf-8")
|
|
block = _whisper_root_block()
|
|
script = "\n".join(("set -eu", block, 'printf "%s\\n" "$INSTALL_DIR"'))
|
|
completed = subprocess.run(
|
|
["bash", "-c", script],
|
|
env = {
|
|
"HOME": str(tmp_path / "home"),
|
|
"PATH": whisper_path,
|
|
"UNSLOTH_HOME": str(root),
|
|
"UNSLOTH_STUDIO_HOME": str(root / "studio"),
|
|
},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert Path(completed.stdout.strip()) == root / "whisper.cpp"
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_the_whisper_builder_still_honours_a_studio_home_alone(tmp_path, whisper_path):
|
|
studio = tmp_path / "elsewhere" / "studio"
|
|
src = BUILD_WHISPER.read_text(encoding = "utf-8")
|
|
block = _whisper_root_block()
|
|
script = "\n".join(("set -eu", block, 'printf "%s\\n" "$INSTALL_DIR"'))
|
|
completed = subprocess.run(
|
|
["bash", "-c", script],
|
|
env = {
|
|
"HOME": str(tmp_path / "home"),
|
|
"PATH": whisper_path,
|
|
"UNSLOTH_STUDIO_HOME": str(studio),
|
|
},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert Path(completed.stdout.strip()) == studio / "whisper.cpp"
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_a_blank_master_root_does_not_outrank_the_whisper_studio_home(tmp_path, whisper_path):
|
|
"""${VAR:-} only treats the EMPTY string as unset, so an unstripped whitespace value would
|
|
win the new precedence and name a relative " /whisper.cpp"."""
|
|
studio = tmp_path / "elsewhere" / "studio"
|
|
completed = subprocess.run(
|
|
[
|
|
"bash",
|
|
"-c",
|
|
"\n".join(
|
|
(
|
|
"set -eu",
|
|
_whisper_root_block(),
|
|
'printf "%s\\n" "$INSTALL_DIR"',
|
|
)
|
|
),
|
|
],
|
|
env = {
|
|
"HOME": str(tmp_path / "home"),
|
|
"PATH": whisper_path,
|
|
"UNSLOTH_HOME": " ",
|
|
"UNSLOTH_STUDIO_HOME": str(studio),
|
|
},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert Path(completed.stdout.strip()) == studio / "whisper.cpp"
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_a_tilde_master_root_expands_for_the_whisper_builder(tmp_path, whisper_path):
|
|
home = tmp_path / "home"
|
|
completed = subprocess.run(
|
|
[
|
|
"bash",
|
|
"-c",
|
|
"\n".join(
|
|
(
|
|
"set -eu",
|
|
_whisper_root_block(),
|
|
'printf "%s\\n" "$INSTALL_DIR"',
|
|
)
|
|
),
|
|
],
|
|
env = {"HOME": str(home), "PATH": whisper_path, "UNSLOTH_HOME": "~/portable"},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert Path(completed.stdout.strip()) == home / "portable" / "whisper.cpp"
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_a_relative_master_root_resolves_against_the_caller(tmp_path):
|
|
"""Node is chosen before setup.sh's first `cd "$SCRIPT_DIR"` and llama.cpp after it, so a
|
|
value left relative names two different directories and the backend's neither."""
|
|
caller = tmp_path / "caller"
|
|
caller.mkdir()
|
|
src = SETUP_SH.read_text(encoding = "utf-8")
|
|
script = "\n".join(
|
|
(
|
|
"set -u",
|
|
_slice(src, "# Stripped before anything else", "# Directory-local evidence"),
|
|
'printf "%s\\n" "$_MASTER_ROOT"',
|
|
)
|
|
)
|
|
completed = subprocess.run(
|
|
["bash", "-c", script],
|
|
cwd = str(caller),
|
|
env = {
|
|
"HOME": str(tmp_path / "home"),
|
|
"PATH": "/usr/bin:/bin",
|
|
"PWD": str(caller),
|
|
"UNSLOTH_HOME": "not-created-yet",
|
|
"_STUDIO_HOME_IS_CUSTOM": "false",
|
|
},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert Path(completed.stdout.strip()) == caller / "not-created-yet"
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_a_master_root_alone_still_asserts_ownership_of_the_runtimes(tmp_path):
|
|
"""With only UNSLOTH_HOME set, STUDIO_HOME stays legacy and _STUDIO_HOME_IS_CUSTOM stays
|
|
false, which is what licenses the installers to os.replace() and rm -rf without checking
|
|
the Unsloth-owned marker. The runtimes moved, so the guard has to follow them."""
|
|
root = tmp_path / "portable"
|
|
root.mkdir()
|
|
home = tmp_path / "home"
|
|
(home / ".unsloth" / "studio").mkdir(parents = True)
|
|
src = SETUP_SH.read_text(encoding = "utf-8")
|
|
script = "\n".join(
|
|
(
|
|
"set -u",
|
|
_slice(src, "# Stripped before anything else", "# Directory-local evidence"),
|
|
'printf "%s %s\\n" "$_STUDIO_HOME_IS_CUSTOM" "$_RUNTIME_ROOT_IS_CUSTOM"',
|
|
)
|
|
)
|
|
completed = subprocess.run(
|
|
["bash", "-c", script],
|
|
env = {
|
|
"HOME": str(home),
|
|
"PATH": "/usr/bin:/bin",
|
|
"UNSLOTH_HOME": str(root),
|
|
"_STUDIO_HOME_IS_CUSTOM": "false",
|
|
},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert completed.stdout.split() == ["false", "true"]
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
@pytest.mark.parametrize(
|
|
"environment, expected",
|
|
[
|
|
# Staging: STAGE_ROOT beats the master root, so the master root must not decide ownership.
|
|
pytest.param(
|
|
{"UNSLOTH_STUDIO_STAGE_ROOT": "STAGE", "_STUDIO_HOME_IS_CUSTOM": "false"},
|
|
["false", "false"],
|
|
id = "a staged update ignores the master root",
|
|
),
|
|
# The flag must be assigned, not only raised, or pre-marker legacy roots demand markers.
|
|
pytest.param(
|
|
{"UNSLOTH_HOME": "LEGACY", "_STUDIO_HOME_IS_CUSTOM": "true"},
|
|
["true", "false"],
|
|
id = "a custom studio home whose runtimes stay legacy",
|
|
),
|
|
],
|
|
)
|
|
def test_the_ownership_flag_follows_where_the_runtimes_land(tmp_path, environment, expected):
|
|
home = tmp_path / "home"
|
|
(home / ".unsloth" / "studio").mkdir(parents = True)
|
|
stage = tmp_path / "stage"
|
|
stage.mkdir()
|
|
resolved = {
|
|
key: str(stage)
|
|
if value == "STAGE"
|
|
else str(home / ".unsloth")
|
|
if value == "LEGACY"
|
|
else value
|
|
for key, value in environment.items()
|
|
}
|
|
if "UNSLOTH_HOME" not in resolved:
|
|
resolved["UNSLOTH_HOME"] = str(tmp_path / "portable")
|
|
(tmp_path / "portable").mkdir()
|
|
src = SETUP_SH.read_text(encoding = "utf-8")
|
|
script = "\n".join(
|
|
(
|
|
"set -u",
|
|
f'STAGE_ROOT="{resolved.pop("UNSLOTH_STUDIO_STAGE_ROOT", "")}"',
|
|
_slice(src, "# Stripped before anything else", "# Directory-local evidence"),
|
|
'printf "%s %s\\n" "$_STUDIO_HOME_IS_CUSTOM" "$_RUNTIME_ROOT_IS_CUSTOM"',
|
|
)
|
|
)
|
|
completed = subprocess.run(
|
|
["bash", "-c", script],
|
|
env = {"HOME": str(home), "PATH": "/usr/bin:/bin", **resolved},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert completed.stdout.split() == expected
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_no_master_root_leaves_the_ownership_flag_alone(tmp_path):
|
|
src = SETUP_SH.read_text(encoding = "utf-8")
|
|
script = "\n".join(
|
|
(
|
|
"set -u",
|
|
_slice(src, "# Stripped before anything else", "# Directory-local evidence"),
|
|
'printf "%s %s\\n" "$_STUDIO_HOME_IS_CUSTOM" "$_RUNTIME_ROOT_IS_CUSTOM"',
|
|
)
|
|
)
|
|
for flag in ("false", "true"):
|
|
completed = subprocess.run(
|
|
["bash", "-c", script],
|
|
env = {
|
|
"HOME": str(tmp_path / "home"),
|
|
"PATH": "/usr/bin:/bin",
|
|
"_STUDIO_HOME_IS_CUSTOM": flag,
|
|
},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
assert completed.stdout.split() == [flag, flag]
|
|
|
|
|
|
def test_every_runtime_ownership_guard_uses_the_runtime_flag():
|
|
"""The Studio home and its venvs keep _STUDIO_HOME_IS_CUSTOM; every guard that names a
|
|
runtime child has to move, or a master-root install loses the marker check on it."""
|
|
src = SETUP_SH.read_text(encoding = "utf-8")
|
|
for line in src.splitlines():
|
|
if "_STUDIO_HOME_IS_CUSTOM" not in line:
|
|
continue
|
|
assert not any(
|
|
name in line for name in ("$NODE_DIR", "$LLAMA_CPP_DIR", "$WHISPER_CPP_DIR")
|
|
), line
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
# Seeded from the Studio flag, raised only when the master root is elsewhere, as setup.sh.
|
|
assert "$RuntimeRootIsCustom = $StudioHomeIsCustom\n" in ps
|
|
assert "$_masterRootForOwnership -ine $_legacyRuntimeRoot" in ps
|
|
for line in ps.splitlines():
|
|
if "$StudioHomeIsCustom" not in line:
|
|
continue
|
|
assert not any(
|
|
name in line for name in ("$LlamaCppDir", "$WhisperCppDir", "$NodeDir")
|
|
), line
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_the_legacy_root_named_explicitly_is_not_custom(tmp_path):
|
|
"""UNSLOTH_HOME=$HOME/.unsloth names the root a default install already uses.
|
|
|
|
Nothing moves, so demanding an owner marker there rejects an update that used to reuse a
|
|
legacy source-built ~/.unsloth/llama.cpp, which predates the marker entirely. Customness has
|
|
to come from where the runtimes LAND, not from whether the variable was set.
|
|
"""
|
|
home = tmp_path / "home"
|
|
(home / ".unsloth" / "studio").mkdir(parents = True)
|
|
src = SETUP_SH.read_text(encoding = "utf-8")
|
|
block = _slice(src, "# Stripped before anything else", "# Directory-local evidence")
|
|
script = "\n".join(
|
|
(
|
|
"set -u",
|
|
"_STUDIO_HOME_IS_CUSTOM=false",
|
|
block,
|
|
'printf "%s\\n" "$_RUNTIME_ROOT_IS_CUSTOM"',
|
|
)
|
|
)
|
|
|
|
def flag(master: str) -> str:
|
|
done = subprocess.run(
|
|
["bash", "-c", script],
|
|
env = {"HOME": str(home), "PATH": "/usr/bin:/bin", "UNSLOTH_HOME": master},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert done.returncode == 0, done.stderr
|
|
return done.stdout.strip()
|
|
|
|
assert flag(str(home / ".unsloth")) == "false"
|
|
assert flag(str(tmp_path / "portable")) == "true"
|
|
assert flag("") == "false"
|
|
|
|
|
|
@pytest.mark.skipif(PWSH is None, reason = "needs pwsh")
|
|
def test_the_windows_legacy_root_named_explicitly_is_not_custom(tmp_path):
|
|
"""The Windows half of the test above, run rather than pattern-matched.
|
|
|
|
setup.ps1 read `$StudioHomeIsCustom -or [bool](Get-MasterRootOverride)`, so
|
|
UNSLOTH_HOME=%USERPROFILE%\\.unsloth turned the ownership guard on for a root a default
|
|
install is already using. Nothing moves there, and the guard then demands an owner marker
|
|
from a markerless source-built .unsloth\\llama.cpp, so Assert-StudioOwnedOrAbsent exits an
|
|
update that used to reuse that build. setup.sh adopts it, which made the two halves answer
|
|
differently for one environment.
|
|
"""
|
|
profile = tmp_path / "profile"
|
|
(profile / ".unsloth" / "studio").mkdir(parents = True)
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
block = _slice(ps, "$RuntimeRootIsCustom = $StudioHomeIsCustom", "$LlamaCppDir = ")
|
|
script = tmp_path / "probe.ps1"
|
|
script.write_text(
|
|
"\n".join(
|
|
(
|
|
f"$txt = Get-Content -Raw '{SETUP_PS1}'",
|
|
'foreach ($n in @("Get-PathState", "Test-AccessDeniedError", "Get-CanonicalDir",'
|
|
' "Get-MasterRootOverride")) {',
|
|
' $m = [regex]::Match($txt, "(?ms)^function $n \\{.*?^\\}")',
|
|
' if (-not $m.Success) { Write-Output "EXTRACT-FAILED:$n"; exit 1 }',
|
|
" Invoke-Expression $m.Value",
|
|
"}",
|
|
# Test-StudioHomeIsCustom closes over $StudioHome, so it is stated, not extracted.
|
|
"$StudioHomeIsCustom = $false",
|
|
block,
|
|
"Write-Output $RuntimeRootIsCustom",
|
|
)
|
|
),
|
|
encoding = "utf-8",
|
|
)
|
|
|
|
def flag(master: str) -> str:
|
|
out = run_pwsh(
|
|
[PWSH, "-NoProfile", "-File", str(script)],
|
|
env = {
|
|
"PATH": os.environ.get("PATH", ""),
|
|
"HOME": str(profile),
|
|
"USERPROFILE": str(profile),
|
|
"UNSLOTH_HOME": master,
|
|
},
|
|
capture_output = True,
|
|
text = True,
|
|
check = True,
|
|
).stdout.strip()
|
|
assert "EXTRACT-FAILED" not in out, out
|
|
return out.splitlines()[-1].strip()
|
|
|
|
assert flag(str(profile / ".unsloth")) == "False"
|
|
assert flag(str(tmp_path / "portable")) == "True"
|
|
assert flag("") == "False"
|
|
|
|
|
|
@pytest.mark.skipif(PWSH is None, reason = "needs pwsh")
|
|
def test_the_windows_uninstaller_only_removes_a_root_that_is_really_empty(tmp_path):
|
|
"""The master root and its .staging go only when empty, and by an operation that cannot do
|
|
anything else.
|
|
|
|
Windows asked with Get-ChildItem -ErrorAction SilentlyContinue and then called _RemovePath,
|
|
which is Remove-Item -Recurse -Force, in the one directory this branch teaches users to
|
|
point at their own files. Two separate calls, so a file arriving between the two was taken
|
|
by the -Recurse; and a swallowed enumeration error read as an empty directory.
|
|
uninstall.sh uses rmdir for both paths, which is one call that refuses a non-empty
|
|
directory, and this file's own %TEMP% prune already asks with -ErrorAction Stop.
|
|
|
|
The behavioural halves below are what can be shown on a POSIX host: a recursive delete needs
|
|
to enumerate too, so an unlistable directory survives either version here and the ACL shape
|
|
that separates DELETE from LIST is Windows-only. What is checked instead is the mechanism,
|
|
which is what removes the race as well.
|
|
"""
|
|
ps = UNINSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert "function _RemoveDirIfEmpty" in ps
|
|
helper = _slice(ps, " function _RemoveDirIfEmpty {", "\n # The exact shape")
|
|
# One atomic call: no -Recurse, and no separate emptiness check.
|
|
assert "[System.IO.Directory]::Delete($Path, $false)" in helper
|
|
assert "-Recurse" not in helper, helper
|
|
assert "Get-ChildItem" not in helper, helper
|
|
master_block = _slice(ps, "$masterRoot = $masterRootToStop", "if ($defaultLlamaCpp)")
|
|
assert master_block.count("_RemoveDirIfEmpty") == 2, master_block
|
|
assert "-ErrorAction SilentlyContinue)) {\n _RemovePath" not in master_block
|
|
|
|
script = tmp_path / "probe.ps1"
|
|
script.write_text(
|
|
"\n".join(
|
|
(
|
|
f"$txt = Get-Content -Raw '{UNINSTALL_PS1}'",
|
|
'$m = [regex]::Match($txt, "(?ms)^ function _RemoveDirIfEmpty \\{.*?^ \\}")',
|
|
'if (-not $m.Success) { Write-Output "EXTRACT-FAILED"; exit 1 }',
|
|
"Invoke-Expression $m.Value",
|
|
"function _Substep { param($a, $b) }",
|
|
"_RemoveDirIfEmpty $args[0]",
|
|
'Write-Output ("EXISTS=" + (Test-Path -LiteralPath $args[0]))',
|
|
)
|
|
),
|
|
encoding = "utf-8",
|
|
)
|
|
|
|
def still_there(path: Path) -> bool:
|
|
out = run_pwsh(
|
|
[PWSH, "-NoProfile", "-File", str(script), str(path)],
|
|
env = {"PATH": os.environ.get("PATH", ""), "HOME": str(tmp_path)},
|
|
capture_output = True,
|
|
text = True,
|
|
check = True,
|
|
).stdout
|
|
assert "EXTRACT-FAILED" not in out, out
|
|
assert "EXISTS=" in out, out
|
|
return out.strip().splitlines()[-1] == "EXISTS=True"
|
|
|
|
empty = tmp_path / "empty"
|
|
empty.mkdir()
|
|
assert not still_there(empty), "an empty root is the one case that may be removed"
|
|
|
|
occupied = tmp_path / "occupied"
|
|
occupied.mkdir()
|
|
(occupied / "theirs.txt").write_text("mine", encoding = "utf-8")
|
|
assert still_there(occupied)
|
|
assert (occupied / "theirs.txt").is_file(), "a non-empty root must keep its contents"
|
|
|
|
# A dir holding only a subdir: rmdir refuses it, a -Recurse would not.
|
|
nested = tmp_path / "nested"
|
|
(nested / "child").mkdir(parents = True)
|
|
assert still_there(nested)
|
|
assert (nested / "child").is_dir()
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_only_a_directory_can_be_adopted_at_a_runtime_path(tmp_path):
|
|
"""A dangling symlink and a regular file are both things the user put there.
|
|
|
|
-d alone read either as "nothing is here", and the caller then rm -rf'd the path or let
|
|
install_*_prebuilt.py os.replace() over it. A dangling link is the ordinary case: its target
|
|
volume is simply not mounted, and resolving it later installs onto somebody's other disk.
|
|
"""
|
|
src = SETUP_SH.read_text(encoding = "utf-8")
|
|
block = _slice(src, "_studio_path_shape() {", "\n_packaged_frontend_available")
|
|
script = "\n".join(
|
|
(
|
|
"set -u",
|
|
"_STUDIO_OWNED_MARKER=.unsloth-studio-owned",
|
|
"_studio_owned_adoptable() { return 1; }",
|
|
"_studio_dir_unsearchable() { return 1; }",
|
|
"_path_access_denied() { echo DENIED; exit 9; }",
|
|
"setup_fail() { echo REFUSED; exit 1; }",
|
|
block,
|
|
'_assert_studio_owned_or_absent "$1" llama.cpp true',
|
|
"echo ALLOWED",
|
|
)
|
|
)
|
|
|
|
def verdict(path: pathlib.Path) -> str:
|
|
done = subprocess.run(
|
|
["bash", "-c", script, "_", str(path)],
|
|
env = {"HOME": str(tmp_path), "PATH": "/usr/bin:/bin"},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
return done.stdout.strip().splitlines()[-1] if done.stdout.strip() else done.stderr[-120:]
|
|
|
|
assert verdict(tmp_path / "absent") == "ALLOWED"
|
|
|
|
dangling = tmp_path / "dangling"
|
|
dangling.symlink_to(tmp_path / "no-such-volume" / "llama.cpp")
|
|
assert verdict(dangling) == "REFUSED"
|
|
|
|
plain = tmp_path / "afile"
|
|
plain.write_text("mine")
|
|
assert verdict(plain) == "REFUSED"
|
|
|
|
owned = tmp_path / "owned"
|
|
owned.mkdir()
|
|
(owned / ".unsloth-studio-owned").touch()
|
|
assert verdict(owned) == "ALLOWED"
|
|
|
|
|
|
def test_the_windows_inductor_cache_agrees_with_the_resolver():
|
|
"""TORCHINDUCTOR_CACHE_DIR must match the resolver, except long-paths-off and space/apostrophe paths."""
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
block = _slice(ps, "$TorchCacheDir = $null", "$env:TORCHINDUCTOR_CACHE_DIR = $TorchCacheDir")
|
|
assert 'Join-Path (Join-Path $StudioHome "cache") "torchinductor"' in block
|
|
assert "$LongPathsEnabled" in block
|
|
refusal = re.search(r"-notmatch\s+'(\[[^']*(?:''[^']*)*\])'", block)
|
|
assert refusal, "the unparseable-path refusal is gone"
|
|
assert "\\s" in refusal.group(1), "the whitespace refusal is gone"
|
|
assert "''" in refusal.group(1), "the apostrophe refusal is gone"
|
|
# The refusals differ: an apostrophe-only path publishes nothing since C:\tc is shared.
|
|
assert "$TorchCacheUnparseable" in block, "the apostrophe case lost its separate route"
|
|
assert "-not $TorchCacheUnparseable" in block
|
|
assert '"C:\\tc"' in block
|
|
|
|
roots = (REPO_ROOT / "studio" / "backend" / "utils" / "paths" / "storage_roots.py").read_text(
|
|
encoding = "utf-8"
|
|
)
|
|
# Same key named by both sides, so they cannot drift apart.
|
|
assert '"TORCHINDUCTOR_CACHE_DIR",' in roots
|
|
assert 'str(root / "torchinductor")' in roots
|
|
|
|
|
|
def test_the_windows_node_guard_covers_a_master_root():
|
|
"""The Node ownership guard and marker must also cover a master root, not only $NodeOverride."""
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
# Keyed on $NodeOverride, not $NodeDir, whose probe moved and would pass by seeing less.
|
|
lines = ps.splitlines()
|
|
start = next(i for i, line in enumerate(lines) if "$nodeEntry = if (" in line)
|
|
guards = [line for line in lines[start:] if "$NodeOverride" in line]
|
|
# Fewer means the block was restructured and this test would pass by finding nothing.
|
|
assert len(guards) >= 2, guards
|
|
for line in guards:
|
|
assert "$RuntimeRootIsCustom" in line, line
|
|
|
|
|
|
UNINSTALL_SH = REPO_ROOT / "scripts" / "uninstall.sh"
|
|
UNINSTALL_PS1 = REPO_ROOT / "scripts" / "uninstall.ps1"
|
|
|
|
|
|
@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "pwsh not available")
|
|
def test_the_windows_uninstaller_resolves_a_relative_root_like_setup(tmp_path):
|
|
"""setup.ps1 resolves through PowerShell's own location; uninstall.ps1 used
|
|
[IO.Path]::GetFullPath, which anchors at [Environment]::CurrentDirectory.
|
|
|
|
PowerShell does not keep those two in step, so after a Set-Location a relative UNSLOTH_HOME
|
|
named one install to setup and a different one to the uninstaller. The owner marker does not
|
|
save you there: it spares trees that are not Unsloth's, and the wrongly resolved path is
|
|
another Unsloth install, marker and all.
|
|
|
|
Runs the shipped function rather than matching its text, so a rewrite that keeps the words
|
|
and loses the behaviour still fails.
|
|
"""
|
|
initial = tmp_path / "initial"
|
|
chosen = tmp_path / "chosen"
|
|
(chosen / "portable").mkdir(parents = True)
|
|
initial.mkdir()
|
|
out = _master_root_answer(
|
|
tmp_path,
|
|
f"""[System.Environment]::CurrentDirectory = "{initial}"
|
|
Set-Location "{chosen}"
|
|
$env:UNSLOTH_HOME = "portable"
|
|
$env:USERPROFILE = "{tmp_path}/profile"
|
|
Write-Output (_MasterRoot)
|
|
""",
|
|
)
|
|
assert out == str(chosen / "portable"), out
|
|
|
|
|
|
def test_neither_uninstaller_recurses_into_an_install_lock_path():
|
|
"""Install lock names are always regular files; a directory there is the user's, so never recurse."""
|
|
sh = UNINSTALL_SH.read_text(encoding = "utf-8")
|
|
ps = UNINSTALL_PS1.read_text(encoding = "utf-8")
|
|
|
|
assert "_remove_lock_file() {" in sh
|
|
assert "function _RemoveLockFile" in ps
|
|
|
|
# Call sites do not all spell out the lock name, so match these variable spellings too.
|
|
lockish = ("install.lock", "lockName", "$stale", "_stale", "_mr_lock")
|
|
for text, remover, shape in (
|
|
(sh, "_remove_path ", "_remove_lock_file"),
|
|
(ps, "_RemovePath ", "_RemoveLockFile"),
|
|
):
|
|
hits = 0
|
|
for line in text.splitlines():
|
|
if line.lstrip().startswith("#"):
|
|
continue
|
|
if remover not in line and shape not in line:
|
|
continue
|
|
if not any(token in line for token in lockish):
|
|
continue
|
|
hits += 1
|
|
assert shape in line, line
|
|
# Counts sites found, not wrong ones; zero would mean the tokens stopped matching.
|
|
assert hits >= 4, (remover, hits)
|
|
|
|
assert "install.lock.stale" in ps
|
|
assert '"*.install.lock.stale.*"' not in ps
|
|
|
|
|
|
def test_both_uninstallers_clear_the_master_root_children():
|
|
"""Uninstallers must also clear the master root's runtime children, not only the legacy siblings."""
|
|
sh = UNINSTALL_SH.read_text(encoding = "utf-8")
|
|
ps = UNINSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert "_master_root() {" in sh
|
|
assert "function _MasterRoot" in ps
|
|
for src, marker in ((sh, ".unsloth-studio-owned"), (ps, ".unsloth-studio-owned")):
|
|
block = _slice(src, "master root's own children", "llama.cpp build + cache")
|
|
assert marker in block, block
|
|
for child in ("llama.cpp", "node", "whisper.cpp"):
|
|
assert child in block, (child, block)
|
|
assert '_is_unsafe_root "$_mr_root"' in sh
|
|
assert "_IsUnsafeRoot $masterRoot" in ps
|
|
# Adds its studio child only when neither override is set, as storage_roots.studio_root().
|
|
assert '_emit "$(_master_root)/studio"' in sh
|
|
assert '$envRoot = (Join-Path $master "studio")' in ps
|
|
|
|
|
|
def test_the_stop_pass_covers_the_master_root_runtimes():
|
|
"""Stop only marker-owned runtimes under master root before removing; Windows locks loaded binaries."""
|
|
ps = UNINSTALL_PS1.read_text(encoding = "utf-8")
|
|
stop_line = next(l for l in ps.splitlines() if l.strip().startswith("$stopRoots = "))
|
|
assert "$masterChildrenToStop" in stop_line, stop_line
|
|
block = _slice(ps, "$masterRootToStop = _MasterRoot", "$stopRoots = ")
|
|
assert ".unsloth-studio-owned" in block, block
|
|
assert "_IsUnsafeRoot $masterRootToStop" in block, block
|
|
assert ps.index("$masterRootToStop = _MasterRoot") < ps.index(
|
|
"_StopProcessesLockingRoots -Roots"
|
|
)
|
|
|
|
|
|
def test_a_shared_staging_directory_is_pruned_not_deleted():
|
|
"""The prebuilt installers share <root>/.staging and prune it only when empty, so anything
|
|
left in a user-chosen root is not ours to delete recursively."""
|
|
sh = UNINSTALL_SH.read_text(encoding = "utf-8")
|
|
ps = UNINSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert 'rmdir "$_mr_root/.staging"' in sh
|
|
assert '_remove_path "$_mr_root/.staging"' not in sh
|
|
staging = _slice(
|
|
ps, '_RemoveDirIfEmpty (Join-Path $masterRoot ".staging")', "# Shared llama.cpp build"
|
|
)
|
|
assert "_RemovePath" not in staging, staging
|
|
|
|
|
|
def test_the_windows_uninstaller_refuses_a_note_carried_in_from_elsewhere(tmp_path):
|
|
"""Refuse a note copied from another install; owner markers cannot tell the two trees apart."""
|
|
original = tmp_path / "original"
|
|
(original / "studio").mkdir(parents = True)
|
|
copied = tmp_path / "copied"
|
|
(copied / "studio" / "share").mkdir(parents = True)
|
|
(copied / "studio" / "share" / ".unsloth-master-root").write_text(
|
|
f"{original}\n", encoding = "utf-8"
|
|
)
|
|
profile = tmp_path / "profile"
|
|
(profile / ".unsloth" / "studio" / "share").mkdir(parents = True)
|
|
|
|
script = tmp_path / "probe.ps1"
|
|
script.write_text(
|
|
f"""$txt = Get-Content -Raw "{UNINSTALL_PS1}"
|
|
foreach ($n in @("_ExpandTilde", "_RootFromConf", "_MasterRoot")) {{
|
|
$m = [regex]::Match($txt, "(?ms)^ function $n \\{{.*?^ \\}}")
|
|
if (-not $m.Success) {{ Write-Output "EXTRACT-FAILED:$n"; exit 1 }}
|
|
Invoke-Expression $m.Value
|
|
}}
|
|
$env:UNSLOTH_HOME = ""
|
|
$env:STUDIO_HOME = ""
|
|
$env:UNSLOTH_STUDIO_HOME = "{copied}/studio"
|
|
$env:USERPROFILE = "{profile}"
|
|
$answer = _MasterRoot
|
|
Write-Output "ANSWER:$answer"
|
|
""",
|
|
encoding = "utf-8",
|
|
)
|
|
out = run_pwsh(
|
|
[PWSH, "-NoProfile", "-File", str(script)],
|
|
capture_output = True,
|
|
text = True,
|
|
check = True,
|
|
).stdout.strip()
|
|
assert "EXTRACT-FAILED" not in out, out
|
|
assert out == "ANSWER:", out
|
|
|
|
|
|
def test_the_windows_uninstaller_does_not_borrow_another_installs_note(tmp_path):
|
|
"""_MasterRoot must not follow a note from a legacy install when the named Studio tree has none."""
|
|
profile = tmp_path / "profile"
|
|
(profile / ".unsloth" / "studio" / "share").mkdir(parents = True)
|
|
borrowed = tmp_path / "borrowed"
|
|
(borrowed / "studio").mkdir(parents = True)
|
|
(profile / ".unsloth" / "studio" / "share" / ".unsloth-master-root").write_text(
|
|
f"{borrowed}\n", encoding = "utf-8"
|
|
)
|
|
named = tmp_path / "named"
|
|
(named / "share").mkdir(parents = True)
|
|
|
|
out = _master_root_answer(
|
|
tmp_path,
|
|
f"""$env:UNSLOTH_HOME = ""
|
|
$env:STUDIO_HOME = ""
|
|
$env:UNSLOTH_STUDIO_HOME = "{named}"
|
|
$env:USERPROFILE = "{profile}"
|
|
$answer = _MasterRoot
|
|
Write-Output "ANSWER:$answer"
|
|
""",
|
|
)
|
|
assert out == "ANSWER:", out
|
|
|
|
|
|
def test_the_windows_uninstaller_finds_a_master_root_from_the_note(tmp_path):
|
|
"""The uninstaller must read the master-root note, since an ordinary shell has no UNSLOTH_HOME set."""
|
|
master = tmp_path / "portable"
|
|
(master / "studio" / "share").mkdir(parents = True)
|
|
(master / "studio" / "share" / ".unsloth-master-root").write_text(
|
|
f"{master}\n", encoding = "utf-8"
|
|
)
|
|
profile = tmp_path / "profile"
|
|
(profile / ".unsloth" / "studio" / "share").mkdir(parents = True)
|
|
|
|
out = _master_root_answer(
|
|
tmp_path,
|
|
f"""$env:UNSLOTH_HOME = ""
|
|
$env:STUDIO_HOME = ""
|
|
$env:UNSLOTH_STUDIO_HOME = "{master}/studio"
|
|
$env:USERPROFILE = "{profile}"
|
|
Write-Output (_MasterRoot)
|
|
""",
|
|
)
|
|
assert out == str(master), out
|
|
|
|
|
|
def test_the_windows_setup_records_the_master_root_for_the_uninstaller():
|
|
"""Write the master-root note only for a real master root; elsewhere it could only go stale."""
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
block = _slice(
|
|
ps, "# Record the master root inside the Studio tree", "$WithLlamaCppDir = $null"
|
|
)
|
|
assert "(Get-MasterRootOverride)" in block
|
|
assert "-not $StageRoot" in block
|
|
assert '".unsloth-master-root"' in block
|
|
# Staged then renamed: this note licenses deletions, so it must never be half-written.
|
|
assert "$noteTmp" in block and "Move-Item" in block
|
|
# The 3-argument overwrite overload is .NET Core only, and setup.ps1 runs under 5.1.
|
|
assert "[System.IO.File]::Move(" not in block
|
|
|
|
|
|
def test_the_refused_windows_cache_path_is_taken_away_on_upgrade():
|
|
"""A refused cache path this installer wrote must be cleared on every launch, not only in deps block."""
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
body = _slice(
|
|
ps, "function Clear-UnparseableTorchCacheEnv {", "\nClear-UnparseableTorchCacheEnv"
|
|
)
|
|
code = "\n".join(line for line in body.splitlines() if not line.lstrip().startswith("#"))
|
|
assert "GetEnvironmentVariable('TORCHINDUCTOR_CACHE_DIR', 'User')" in code, code
|
|
assert "[NullString]::Value, 'User'" in code, code
|
|
assert "Remove-Item -LiteralPath Env:TORCHINDUCTOR_CACHE_DIR" in code, code
|
|
assert code.count("Test-UnparseableManagedTorchCache") == 2, code
|
|
predicate = _slice(
|
|
ps, "function Test-UnparseableManagedTorchCache", "\nfunction Clear-Unparseable"
|
|
)
|
|
rule = "\n".join(l for l in predicate.splitlines() if not l.lstrip().startswith("#"))
|
|
assert "-notmatch '[\\s'']'" in rule, rule
|
|
# Nothing records who set the variable, so compare against this run's managed path.
|
|
assert "$Managed" in rule and "-ieq" in rule, rule
|
|
assert '$managedTorchCache = Join-Path (Join-Path $StudioHome "cache") "torchinductor"' in code
|
|
assert "-not $StageRoot" in code, code
|
|
|
|
lines = ps.splitlines()
|
|
gate = lines.index("if (-not $SkipPythonDeps) {")
|
|
depth = 0
|
|
for close, line in enumerate(lines[gate:], start = gate):
|
|
depth += line.count("{") - line.count("}")
|
|
if depth == 0:
|
|
break
|
|
calls = [i for i, line in enumerate(lines) if line.strip() == "Clear-UnparseableTorchCacheEnv"]
|
|
assert calls, "nothing calls the cleanup"
|
|
assert any(
|
|
i < gate for i in calls
|
|
), "the cleanup only runs on a dependency pass, which the fast paths skip"
|
|
|
|
|
|
def test_the_windows_uninstaller_clears_the_inductor_path_it_persisted():
|
|
"""The uninstaller clears TORCHINDUCTOR_CACHE_DIR only when it lies inside a root this run owned."""
|
|
ps = UNINSTALL_PS1.read_text(encoding = "utf-8")
|
|
block = _slice(
|
|
ps, "# Clear the persisted Inductor cache path", "# Remove HKCU\\Software\\Unsloth"
|
|
)
|
|
assert "GetEnvironmentVariable('TORCHINDUCTOR_CACHE_DIR', 'User')" in block
|
|
assert "[NullString]::Value, 'User'" in block
|
|
# $knownRoots includes roots the gates refused to delete.
|
|
assert "$ownedRoots" in block and "$knownRoots" not in block
|
|
# Strip comments first: prose naming C:\tc is not code naming it.
|
|
code = "\n".join(line for line in block.splitlines() if not line.lstrip().startswith("#"))
|
|
assert "C:\\tc" not in code
|
|
|
|
|
|
def test_the_windows_node_guard_treats_a_file_as_occupied():
|
|
"""Files and symlinks count as occupied, since ownership evidence lives inside directories."""
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
block = _slice(
|
|
ps,
|
|
" $nodeEntry = if ($NodeOverride -or $RuntimeRootIsCustom) {",
|
|
"install_node_prebuilt.py",
|
|
)
|
|
code = "\n".join(line for line in block.splitlines() if not line.lstrip().startswith("#"))
|
|
# Get-Item -Force, not Test-Path: 5.1 Test-Path follows links, so a dangling one reads absent.
|
|
assert "Get-Item -LiteralPath $NodeDir -Force" in code, code
|
|
assert "Test-Path -LiteralPath $NodeDir" not in code, code
|
|
assert "$nodeEntry.PSIsContainer" in code and "ReparsePoint" in code, code
|
|
assert "$nodeIsDir" in code and "-not $nodeIsDir -or" in code
|
|
|
|
|
|
def test_the_windows_runtime_guard_treats_a_file_as_occupied():
|
|
"""Assert-StudioOwnedOrAbsent must treat a regular file as occupied, not only a directory."""
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
block = _slice(ps, "function Assert-StudioOwnedOrAbsent", "function Mark-StudioOwned")
|
|
code = "\n".join(line for line in block.splitlines() if not line.lstrip().startswith("#"))
|
|
assert "Get-Item -LiteralPath $Path -Force" in code
|
|
assert "if (-not $isCustomRoot) { return }" in code
|
|
assert "already exists and is not a directory" in block
|
|
# Fatal even in -NonFatal mode: that mode rescues a denial, not somebody else's file.
|
|
refusal = code[
|
|
code.index("Get-Item -LiteralPath $Path -Force") : code.index("Exit-SetupFailure")
|
|
]
|
|
assert "$NonFatal" not in refusal, refusal
|
|
|
|
|
|
def test_neither_uninstaller_takes_a_studio_root_that_is_also_the_master_root():
|
|
"""Never delete a Studio root that is also the master root; a whole-directory delete takes user
|
|
files."""
|
|
sh = UNINSTALL_SH.read_text(encoding = "utf-8")
|
|
ps = UNINSTALL_PS1.read_text(encoding = "utf-8")
|
|
|
|
sh_block = _slice(sh, "_crf_canon=", '_remove_root_recording_db "$_custom_root"')
|
|
sh_code = "\n".join(l for l in sh_block.splitlines() if not l.lstrip().startswith("#"))
|
|
assert "_MASTER_ROOT_SAVED" in sh_code and "continue" in sh_code
|
|
# Canonicalised on both sides, or a symlinked path compares unequal to itself.
|
|
assert "cd -P --" in sh_code
|
|
|
|
ps_block = _slice(ps, "$flatMaster = $masterRootToStop", "_RemoveRootRecordingDb $r")
|
|
ps_code = "\n".join(l for l in ps_block.splitlines() if not l.lstrip().startswith("#"))
|
|
assert "$masterRootToStop" in ps_code and "continue" in ps_code
|
|
# Case-insensitive: Windows paths differing only in case are the same directory.
|
|
assert "-ieq" in ps_code
|
|
|
|
|
|
def test_neither_uninstaller_re_resolves_the_master_root_after_deleting_it():
|
|
"""Resolve the master root once before deleting anything, since its note lives inside a Studio tree."""
|
|
sh = UNINSTALL_SH.read_text(encoding = "utf-8")
|
|
ps = UNINSTALL_PS1.read_text(encoding = "utf-8")
|
|
|
|
sh_code = "\n".join(l for l in sh.splitlines() if not l.lstrip().startswith("#"))
|
|
saved = sh_code.index('_MASTER_ROOT_SAVED="$(_master_root)"')
|
|
removal = sh_code.index("_custom_studio_roots | while IFS= read -r _custom_root")
|
|
# Resolved before the first deletion; calls before the loop are fine.
|
|
assert saved < removal
|
|
after = sh_code[removal:]
|
|
assert "_master_root" not in after, after[: after.index("_master_root") + 200]
|
|
assert after.count('"$_MASTER_ROOT_SAVED"') >= 2
|
|
|
|
ps_code = "\n".join(l for l in ps.splitlines() if not l.lstrip().startswith("#"))
|
|
ps_saved = ps_code.index("$masterRootToStop = _MasterRoot")
|
|
ps_removal = ps_code.index("_RemoveRootRecordingDb $r")
|
|
assert ps_saved < ps_removal
|
|
ps_after = ps_code[ps_removal:]
|
|
assert "_MasterRoot" not in ps_after, ps_after[: ps_after.index("_MasterRoot") + 200]
|
|
assert ps_after.count("$masterRootToStop") >= 1
|
|
|
|
|
|
def test_neither_uninstaller_sweeps_a_stale_lock_name_it_did_not_make():
|
|
"""prebuilt_core.py leaves <name>.stale.<pid>. A dotted glob also matched a user's own
|
|
".backup.install.lock.stale.copy", and in a root the user chose that file is theirs.
|
|
Behaviour for the POSIX half is in tests/sh/test_uninstall_master_root.sh."""
|
|
sh = UNINSTALL_SH.read_text(encoding = "utf-8")
|
|
ps = UNINSTALL_PS1.read_text(encoding = "utf-8")
|
|
|
|
sh_code = "\n".join(l for l in sh.splitlines() if not l.lstrip().startswith("#"))
|
|
assert ".*.install.lock.stale.*" not in sh_code
|
|
assert sh_code.count("*[!0-9]*) continue ;;") >= 2
|
|
|
|
ps_code = "\n".join(l for l in ps.splitlines() if not l.lstrip().startswith("#"))
|
|
assert '-like ".*.install.lock.stale.*"' not in ps_code
|
|
assert "$script:StaleLockPattern" in ps_code
|
|
assert "[0-9]+$" in ps_code
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_the_master_root_note_does_not_write_through_a_planted_link(tmp_path):
|
|
"""The note is staged under an unpredictable name, so a link left in share/ is not followed.
|
|
|
|
The staging name used to be "$notePath.$$", and a shell redirection into an existing symlink
|
|
truncates its target. Anyone who could write share/ -- a portable volume mounted for two
|
|
accounts is the shape that matters, since a master root is what this note records -- could
|
|
therefore point that name at a file of theirs and have setup empty it. The attacker's
|
|
capability is simulated exactly rather than approximated: the link is created by the same
|
|
shell that then runs the shipped block, so "$$" matches the way a guessed pid would.
|
|
|
|
setup.sh already states this rule for the uv cache probe ("mktemp (O_EXCL) not $$"); the note
|
|
writer is now held to it too.
|
|
"""
|
|
src = SETUP_SH.read_text(encoding = "utf-8")
|
|
block = _slice(src, "_master_root_note_is_honoured() {", "LLAMA_CPP_DIR=")
|
|
|
|
# Contained, so the honoured-note gate passes and the writer actually runs.
|
|
master = tmp_path / "portable"
|
|
studio_home = master / "studio"
|
|
(studio_home / "share").mkdir(parents = True)
|
|
victim = tmp_path / "victim.txt"
|
|
victim.write_text("do not truncate me\n", encoding = "utf-8")
|
|
|
|
script = "\n".join(
|
|
(
|
|
"set -u",
|
|
'ln -s "$VICTIM" "$STUDIO_HOME/share/.unsloth-master-root.$$"',
|
|
block,
|
|
)
|
|
)
|
|
completed = subprocess.run(
|
|
["bash", "-c", script],
|
|
env = {
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
"STUDIO_HOME": str(studio_home),
|
|
"UNSLOTH_HOME": str(master),
|
|
"_MASTER_ROOT": str(master),
|
|
"STAGE_ROOT": "",
|
|
"VICTIM": str(victim),
|
|
},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
|
|
assert victim.read_text(encoding = "utf-8") == "do not truncate me\n"
|
|
note = studio_home / "share" / ".unsloth-master-root"
|
|
assert note.read_text(encoding = "utf-8").strip() == str(master)
|
|
assert not note.is_symlink()
|
|
leftovers = sorted(
|
|
p.name
|
|
for p in (studio_home / "share").iterdir()
|
|
if p.name.startswith(".unsloth-master-root.") and not p.is_symlink()
|
|
)
|
|
assert leftovers == [], leftovers
|
|
|
|
|
|
def test_the_windows_note_writer_creates_its_staging_file_exclusively():
|
|
"""The setup.ps1 half of the test above. Held structurally: the Linux runners have no
|
|
Windows filesystem to plant a link on, and CreateNew is the thing that must not regress."""
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
block = _slice(ps, "if ((Get-MasterRootOverride) -and -not $StageRoot -and", "\n# ")
|
|
# Comments stripped: the block mentions the old staging name in prose.
|
|
block = "\n".join(l for l in block.splitlines() if not l.lstrip().startswith("#"))
|
|
|
|
assert "$notePath.$PID" not in block, "predictable staging name is back"
|
|
assert "GetRandomFileName()" in block
|
|
assert "[System.IO.FileMode]::CreateNew" in block
|
|
# WriteAllText opens an existing path rather than failing on it.
|
|
assert "WriteAllText($noteTmp" not in block
|
|
|
|
|
|
def _run_note_block(
|
|
tmp_path,
|
|
studio_home,
|
|
master,
|
|
*,
|
|
existing_note = None,
|
|
home = None,
|
|
):
|
|
"""Executes the note gate, writer and legacy sweep as one block, since they are one decision."""
|
|
src = SETUP_SH.read_text(encoding = "utf-8")
|
|
block = _slice(src, "_master_root_note_is_honoured() {", "LLAMA_CPP_DIR=")
|
|
note = studio_home / "share" / ".unsloth-master-root"
|
|
note.parent.mkdir(parents = True, exist_ok = True)
|
|
if existing_note is not None:
|
|
note.write_text(str(existing_note) + "\n", encoding = "utf-8")
|
|
completed = subprocess.run(
|
|
["bash", "-c", "set -u\n" + block],
|
|
env = {
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
|
"HOME": str(home if home is not None else tmp_path / "home"),
|
|
"STUDIO_HOME": str(studio_home),
|
|
"UNSLOTH_HOME": str(master),
|
|
"_MASTER_ROOT": str(master),
|
|
"STAGE_ROOT": "",
|
|
},
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = 60,
|
|
)
|
|
assert completed.returncode == 0, completed.stderr
|
|
value = note.read_text(encoding = "utf-8").strip() if note.exists() else None
|
|
return value, completed.stderr
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_the_master_root_note_is_only_written_when_a_reader_honours_it(tmp_path):
|
|
"""A note whose Studio tree is not INSIDE the root it names is refused by every reader --
|
|
storage_roots.py, the CLI and both uninstallers all require containment -- so writing one
|
|
records nothing and hides the fact that nothing was recorded.
|
|
|
|
This is not a corner. install.sh does not read UNSLOTH_HOME yet, so
|
|
`UNSLOTH_HOME=/mnt/portable unsloth studio update` on an ordinary install is exactly this
|
|
shape: Studio stays at ~/.unsloth/studio while llama.cpp, node and whisper.cpp go to
|
|
/mnt/portable. That is the case the note was added for, and the case in which it was written
|
|
and then thrown away by every reader. Warn instead.
|
|
"""
|
|
home = tmp_path / "home"
|
|
studio_home = home / ".unsloth" / "studio"
|
|
master = tmp_path / "portable"
|
|
master.mkdir()
|
|
value, stderr = _run_note_block(tmp_path, studio_home, master, home = home)
|
|
assert value is None, f"wrote a note no reader will honour: {value}"
|
|
assert "cannot be recorded" in stderr, stderr
|
|
|
|
contained = master / "studio"
|
|
value, _ = _run_note_block(tmp_path, contained, master, home = home)
|
|
assert value == str(master)
|
|
|
|
|
|
@NEEDS_POSIX_BASH
|
|
def test_the_legacy_default_root_is_never_recorded_and_an_old_note_is_cleared(tmp_path):
|
|
"""`UNSLOTH_HOME=$HOME/.unsloth` names the directory the install is already in. Both
|
|
uninstallers refuse that value outright, so a note carrying it licenses nothing -- but
|
|
storage_roots honours it, and honouring it flips portable_mode() on for every later BARE
|
|
launch, which moves HF_HUB_CACHE off ~/.cache/huggingface. One command that named the default
|
|
root therefore turned a shared model cache into a private one, permanently, with no variable
|
|
set at the time.
|
|
|
|
The write is refused, and an existing note carrying that value is cleared on the next run --
|
|
without the sweep the damage survives the fix, since the writer only runs when UNSLOTH_HOME
|
|
is set again and the user who hit this set it once.
|
|
"""
|
|
home = tmp_path / "home"
|
|
studio_home = home / ".unsloth" / "studio"
|
|
legacy_master = home / ".unsloth"
|
|
studio_home.mkdir(parents = True)
|
|
|
|
# Contained (studio is inside ~/.unsloth), so only the legacy-root rule can refuse it.
|
|
value, _ = _run_note_block(tmp_path, studio_home, legacy_master, home = home)
|
|
assert value is None, f"recorded the legacy default root: {value}"
|
|
|
|
value, _ = _run_note_block(
|
|
tmp_path,
|
|
studio_home,
|
|
legacy_master,
|
|
existing_note = legacy_master,
|
|
home = home,
|
|
)
|
|
assert value is None, "a stale legacy-root note survived"
|
|
|
|
# A note naming another root is left alone: it may describe an install this run cannot see.
|
|
other = tmp_path / "portable"
|
|
other.mkdir()
|
|
value, _ = _run_note_block(
|
|
tmp_path,
|
|
studio_home,
|
|
legacy_master,
|
|
existing_note = other,
|
|
home = home,
|
|
)
|
|
assert value == str(other)
|
|
|
|
|
|
def test_the_windows_note_gate_holds_the_same_two_rules():
|
|
"""The setup.ps1 half of the two tests above. Structural: no PowerShell-on-Linux run can
|
|
exercise %USERPROFILE% canonicalisation the way a real Windows profile does."""
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
gate = _slice(ps, "function Test-MasterRootNoteIsHonoured {", "\nif ((Get-MasterRootOverride)")
|
|
assert "$legacy = Get-CanonicalDir" in gate, "the legacy default root is not refused"
|
|
assert "StartsWith($norm + $sep" in gate, "containment is not checked"
|
|
assert "Test-MasterRootNoteIsHonoured -Root $UnslothHome -StudioRoot $StudioHome" in ps
|
|
sweep = _slice(ps, "$staleNote = Join-Path", "} catch {")
|
|
assert "Remove-Item -LiteralPath $staleNote" in sweep
|
|
|
|
|
|
def test_the_windows_note_writer_is_a_no_op_when_the_note_already_says_this():
|
|
"""Skip the write when the note already matches: Move-Item -Force is not atomic and can leave no
|
|
note."""
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
block = _slice(
|
|
ps, ' $notePath = Join-Path $noteDir ".unsloth-master-root"', "\n } catch {"
|
|
)
|
|
assert "Remove-Item -LiteralPath $noteTmp" in block, "the staging file is never cleaned up"
|
|
assert "$noteSame" in block, "the note is rewritten even when it already says this"
|
|
|
|
|
|
@pytest.mark.skipif(PWSH is None, reason = "needs pwsh")
|
|
def test_the_windows_note_writer_rewrites_only_when_the_value_changed(tmp_path):
|
|
"""The behaviour, measured: same value twice leaves the file untouched; a different value
|
|
replaces it; and no staging file survives either way."""
|
|
ps = SETUP_PS1.read_text(encoding = "utf-8")
|
|
block = _slice(
|
|
ps, ' $notePath = Join-Path $noteDir ".unsloth-master-root"', "\n } catch {"
|
|
)
|
|
note_dir = tmp_path / "share"
|
|
note_dir.mkdir()
|
|
script = tmp_path / "write_note.ps1"
|
|
script.write_text(
|
|
f"$noteDir = {_ps_quote(str(note_dir))}\n"
|
|
f"$UnslothHome = {_ps_quote(str(tmp_path / 'master'))}\n"
|
|
+ block
|
|
+ "\n$final = Join-Path $noteDir '.unsloth-master-root'\n"
|
|
"$stamp = (Get-Item -LiteralPath $final).LastWriteTimeUtc.Ticks\n"
|
|
"Write-Output ((Get-Content -LiteralPath $final -Raw) + '|' + $stamp)\n",
|
|
encoding = "utf-8",
|
|
)
|
|
argv = [PWSH, "-NoProfile", "-File", str(script)]
|
|
first = run_pwsh(argv, capture_output = True, text = True, check = True)
|
|
note = note_dir / ".unsloth-master-root"
|
|
|
|
# Back-dated: two runs a millisecond apart can land on one filesystem tick.
|
|
old_stamp = 1_000_000_000
|
|
os.utime(note, (old_stamp, old_stamp))
|
|
|
|
second = run_pwsh(argv, capture_output = True, text = True, check = True)
|
|
|
|
body_1, _ = first.stdout.strip().rsplit("|", 1)
|
|
body_2, _ = second.stdout.strip().rsplit("|", 1)
|
|
assert body_1 == body_2 == str(tmp_path / "master") + "\n"
|
|
assert (
|
|
int(note.stat().st_mtime) == old_stamp
|
|
), "an unchanged note was rewritten, re-entering the delete-then-move window"
|
|
leftovers = [p.name for p in note_dir.iterdir() if p.name != ".unsloth-master-root"]
|
|
assert leftovers == [], leftovers
|
|
|
|
# A changed value must still be written, or the check above passes by doing nothing.
|
|
script.write_text(
|
|
script.read_text(encoding = "utf-8").replace(
|
|
_ps_quote(str(tmp_path / "master")), _ps_quote(str(tmp_path / "moved"))
|
|
),
|
|
encoding = "utf-8",
|
|
)
|
|
run_pwsh(argv, capture_output = True, text = True, check = True)
|
|
assert note.read_text(encoding = "utf-8").strip() == str(tmp_path / "moved")
|
|
assert int(note.stat().st_mtime) != old_stamp
|