* Windows: unblock the consumer install on clean and no-winget machines Four independent things stop a clean Windows box today. git was a hard Exit-SetupFailure in setup.ps1, justified as required by pip for git+https:// deps and by npm. Neither holds on the consumer path: the unsloth-zoo git+https URL is only used under STUDIO_LOCAL_INSTALL, node is a pinned nodejs.org prebuilt that never touches system npm, and the frontend lockfile has no VCS dependencies. It stays fatal for --local, where it really is needed. Ensure-VCRedist was winget-only, so on hosts without winget (LTSC, Server, managed corporate images) it silently did nothing while the install reported success, and torch then failed to import on a missing VCRUNTIME140.dll. Adds a direct aka.ms/vs/17/release/vc_redist.<arch>.exe download with /quiet /norestart, accepting exit codes 0 and 3010. The redistributable stays required: it is the runtime the prebuilt llama-server and torch link against, not the MSVC compiler, which is already detection-only. Windows on ARM has no PyTorch at all. Measured with uv against download.pytorch.org/whl/cpu and PyPI for aarch64-pc-windows-msvc / cp313: torch, torchvision and torchaudio all resolve to nothing, wheels exist only for win_amd64 and the manylinux targets. The installer burned three uv retries on an unsatisfiable resolution and reported a bare 'Failed to install PyTorch (exit code 1)'. Now it says what is actually wrong and points at --no-torch, which works because llama.cpp does publish windows-arm64-cpu. install_node_prebuilt.py hit '[WinError 5] Access is denied' on os.replace of the freshly extracted directory during a FRESH install, which is a scanner or indexer holding handles for a moment. Retries only winerror 5, 32 and 145 with capped exponential backoff; any other OSError still raises immediately. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Give the ARM64 dead end a recovery that works for web installs The only remedy printed was .\install.ps1 --no-torch, but the documented path is irm | iex, where no file exists and flags cannot be forwarded. Name the env var the script already honours at line 145. * Windows on ARM: drop torchaudio, do not abort the install The fail-fast was based on a wrong premise. Counted against download.pytorch.org/whl/cpu: torch has 42 win_arm64 wheels and torchvision 60; only torchaudio has none. PyTorch has shipped Arm-native Windows builds since April 2025, so aborting blocked a platform that mostly works. Drop the one unsatisfiable pin instead. Decide from the interpreter uv will resolve for, not the PowerShell host: an x64 CPython under emulation gets working win_amd64 wheels on an ARM64 box, and powershell.exe inherits PROCESSOR_ARCHITECTURE from its parent. * Carry the ARM64 torchaudio omission into studio setup Dropping it from the first PyTorch command was not enough: install.ps1 then runs studio setup with SKIP_STUDIO_BASE=1 and setup.ps1 reinstalls the bare trio from the CPU index, so the ARM64 path still aborted. Apply the same interpreter-based test there. An unreadable platform keeps the full trio. * Build the torch spec list outside the verbose branch The ARM64 guard landed inside `if ($script:UnslothVerbose)`, so on the default path $_torchTrio was never assigned and the splat expanded to nothing: uv ran as `uv pip install --index-url ...` with no package, exit 2, straight to Exit-SetupFailure. That broke the ordinary Windows install. Hoist it above the branch and use substep, which prints on both paths. Realign the two parity guards to the splat form; they asserted the pre-refactor literal command and were the actual cause of the red parity legs. Both halves are still checked: the bounded list is built, and it reaches the install. * Tighten the comments on the Windows install path * Windows install: honour the ARM64 torchaudio skip everywhere and keep git for source builds Hoist the venv-interpreter platform probe above every torch branch in studio/setup.ps1 so the win_arm64 torchaudio omission applies to the ROCm, CPU and CUDA/custom paths. A pinned index whose leaf is not cpu routed an ARM64 host into the CUDA/custom branch, which still asked for torchaudio. Require git again when a llama.cpp source build is opted into up front (UNSLOTH_LLAMA_FORCE_COMPILE, UNSLOTH_LLAMA_PR / PR_FORCE, a non-upstream source). Those paths git clone in phase 4, so setup used to report git as not required, install the build toolchain, then fail at the clone. A local llama.cpp dir overrides them, and the automatic source fallback after a failed prebuilt download stays non-fatal. Also tighten the comments across the changed install paths. * Install the x64 VC++ runtime unconditionally in the direct-download fallback The winget branch always installs Microsoft.VCRedist.2015+.x64, but the direct-download fallback picked the package from PROCESSOR_ARCHITECTURE, which reports the architecture of the running PowerShell process rather than the interpreter that will load the DLLs. Find-CompatiblePython in install.ps1 selects an interpreter on version and non-Conda status alone, with no architecture predicate, so a native ARM64 shell can settle on an emulated x64 Python whose win_amd64 torch and prebuilt llama-server need the x64 runtime, while the fallback had just installed the ARM64-only package. Ensure-VCRedist also runs well before the venv exists, so the interpreter cannot be probed at that point. Microsoft ships the x64 redistributable as an Arm64X superset that carries both ARM64 and x64 binaries, so it is correct on both machines and the manual instruction printed on failure already pointed at it. * Windows on ARM: prefer an x64 Python interpreter An ARM64 host cannot complete the install with a native ARM64 interpreter. pyarrow, pulled in by unsloth -> datasets, has never published a win_arm64 wheel on any version, and neither has hf-transfer, a direct dependency. Both therefore fall back to a source build: pyarrow dies in scikit-build-core CMake configuration and hf-transfer dies in openssl-sys for want of perl, several minutes into a run that looked healthy. torch and torchvision are not the problem, they have win_arm64 wheels and install fine. Windows 11 on ARM runs x64 binaries under emulation and both packages ship win_amd64 wheels, so an x64 interpreter installs cleanly. Find-CompatiblePython accepted an interpreter on version and non-Conda status alone. It now ranks candidates by architecture on ARM64 hosts and returns an x64 one when present, asking each interpreter for its own sysconfig.get_platform() rather than guessing from its path. Host architecture comes from PROCESSOR_ARCHITEW6432 and OSArchitecture as well as PROCESSOR_ARCHITECTURE, which describes only the current process and reads AMD64 in an emulated shell. This is a preference, not a requirement. If only ARM64 is found, x64 is bootstrapped through winget --architecture x64 or the python.org fallback, and if neither works the installer names pyarrow and hf-transfer up front instead of failing later on a CMake or Rust error. The ARM64 torchaudio skip stays live for that path. Non-ARM hosts return on the first match exactly as before, with no extra interpreter probing. * Windows install: three correctness fixes on the ARM64 and git-less paths Ensure-VCRedist never reached its x64 download on an ARM64 machine that already had the arm64 redistributable: Test-VCRedistInstalled accepted System32\vcruntime140_1.dll regardless of architecture, and there that file can be the pure-ARM64 package. An ARM64 PE cannot load into an emulated x64 process, so the x64 Python this branch now prefers would have been left without a usable runtime. The x64 registry entry is the only x64-specific proof, and Microsoft registers Runtimes\{x86|x64|arm64} per architecture, so vc_redist.x64.exe still writes Runtimes\x64 on an ARM64 host and the check cannot loop. The DLL probe stays for x64 hosts. Phase 1 demanded git for any non-blank UNSLOTH_LLAMA_PR_FORCE, but the promotion that actually turns it into a source build requires a positive integer, so PR_FORCE=0 or a non-numeric value aborted a git-less consumer install for a build that never runs. Both sites now use the same predicate. The automatic fallback after a failed prebuilt llama.cpp download reached git clone with no git check anywhere in between, and Invoke-SetupCommand returns 0 for a command-not-found, so a git-less host did not stop there: it continued into an empty directory and reported a cmake configure failure instead. Git is now resolved where the source build is decided, with a last winget attempt, and a missing git degrades exactly like a missing cmake rather than aborting, since the opt-in source triggers already required git in Phase 1. Also tightened the comments across the changed Windows install code, keeping the reasons on the guards that prevent a specific failure. * Rank ARM64 Python candidates by minor version before architecture The x64 preference filtered the whole candidate list on architecture, which outranks the version preference the candidates were collected in. With UNSLOTH_PYTHON=3.12 on a Windows ARM64 box holding an ARM64 3.12 and an x64 3.13, it returned the x64 3.13: the explicit pin was silently broken, and because a x64 interpreter was found the caller never ran Install-X64Python to fetch an x64 3.12. With no pin it was worse still, since an x64 3.11 outranked a newer ARM64 3.13 and defeated the newest-first fallback. Walk $minors in order and take the x64 build of the best minor available, falling back to that minor's ARM64 build so the caller bootstraps x64 for the version actually requested. x64 still wins within a minor, and non-ARM hosts are untouched. * Windows install: see every registered Python, order git before the toolchain Find-CompatiblePython only ever probed `py -3.X`, which runs the launcher's preferred build for that minor. On an ARM64 box that is the native ARM64 interpreter, so a same-minor x64 install that is registered with the launcher but neither preferred nor on PATH never became a candidate. The x64 preference then lost to ARM64, and Install-X64Python re-downloaded an x64 CPython that was already on the machine; when that download is unavailable the install continues on ARM64 and source-builds pyarrow and hf-transfer, which publish no win_arm64 wheels. Enumerate `py -0p` on ARM64 hosts and probe each listed path. The `-3.12-64` suffix cannot be used for this: it has meant "not 32-bit" since 3.11 and does not distinguish arm64 from amd64. studio/setup.ps1 ran Ensure-BuildToolsForLlamaSourceBuild before checking git in Phase 4. That helper calls Exit-SetupFailure when Visual Studio Build Tools cannot be installed, so on a clean no-winget box the git degraded path added by this PR was unreachable and a standalone update aborted instead of finishing in limited mode; where winget does exist it spent a multi-GB Build Tools download on a clone that could never run. Check and install git first, skip the toolchain helper when git is still missing, and report the git branch before the cmake branch so the message names the real cause. _swap_into_place retried the forward rename for about 16 seconds but rolled back with a bare os.replace. A scanner holding the backup for the same WinError 5/32 then left no install_dir at all and stranded the working runtime in .old-*, and its exception replaced the original failure. The rollback now uses the same backoff and logs instead of masking the error it is recovering from. * Installer: use an already installed x64 Python on ARM64 when none can be downloaded Find-CompatiblePython ranks x64 within one minor and returns the native build when that minor is ARM64-only, leaving Install-X64Python to bootstrap x64. On an offline or winget-less box that bootstrap fails, and the retry went through the same resolver, so an x64 build of a lower-priority supported minor already on the machine was never picked up and setup continued on ARM64 Python, where pyarrow and hf-transfer have no wheels. Add an -X64Only mode that returns the best installed x64 interpreter or nothing, and call it as the last resort in Install-X64Python. The version-first preference is unchanged: x64 of the requested minor is still bootstrapped first. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Tighten comments in the Windows ARM64 installer changes * Setup: require Git for a source build behind an unbuilt local llama.cpp dir UNSLOTH_LOCAL_LLAMA_CPP_DIR only overrides the source-build opt-ins once the directory holds a reusable llama-server.exe. Pointing it at the canonical install location with nothing built there falls through to the normal install, so the Phase 1 gate now probes the same layout candidates as the Phase 4 reuse check before dropping the requirement. * Setup: require Git when UNSLOTH_LLAMA_TAG=master forces a source build * Tighten comments in the Windows installer changes * Setup: negotiate TLS 1.2 for the direct VC++ runtime download --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothai@gmail.com>
947 lines
48 KiB
Python
947 lines
48 KiB
Python
"""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"
|
|
SETUP_PS1 = REPO_ROOT / "studio" / "setup.ps1"
|
|
STACK_PY = REPO_ROOT / "studio" / "install_python_stack.py"
|
|
|
|
|
|
class TestNoTorchBackendAutoInInstallSh:
|
|
"""install.sh primary paths must not use --torch-backend=auto (only the fallback else-branch may)."""
|
|
|
|
def test_no_torch_backend_auto_outside_fallback(self):
|
|
lines = INSTALL_SH.read_text(encoding = "utf-8").splitlines()
|
|
# Fallback block: from "GPU detection failed" to 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(encoding = "utf-8")
|
|
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(encoding = "utf-8")
|
|
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(encoding = "utf-8")
|
|
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(encoding = "utf-8")
|
|
ps1_text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
|
|
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}"
|
|
)
|
|
|
|
|
|
class TestPyTorchMirrorEnvVar:
|
|
"""Both install scripts must support the UNSLOTH_PYTORCH_MIRROR env var."""
|
|
|
|
def test_install_sh_has_mirror_var(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
"UNSLOTH_PYTORCH_MIRROR" in text
|
|
), "install.sh should reference UNSLOTH_PYTORCH_MIRROR"
|
|
|
|
def test_install_ps1_has_mirror_var(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert (
|
|
"UNSLOTH_PYTORCH_MIRROR" in text
|
|
), "install.ps1 should reference UNSLOTH_PYTORCH_MIRROR"
|
|
|
|
|
|
class TestUvBytecodeCompileTimeout:
|
|
"""Installers should relax uv bytecode compilation timeout by default."""
|
|
|
|
@staticmethod
|
|
def _version_tuple(version: str) -> tuple[int, ...]:
|
|
return tuple(int(part) for part in version.split("."))
|
|
|
|
def test_install_sh_uses_uv_version_with_timeout_env(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
match = re.search(r'^UV_MIN_VERSION="([^"]+)"$', text, re.MULTILINE)
|
|
assert match, "install.sh should declare UV_MIN_VERSION"
|
|
assert self._version_tuple(match.group(1)) >= self._version_tuple("0.7.22")
|
|
|
|
def test_install_ps1_uses_uv_version_with_timeout_env(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
match = re.search(r'^\s*\$UvMinVersion = "([^"]+)"$', text, re.MULTILINE)
|
|
assert match, "install.ps1 should declare $UvMinVersion"
|
|
assert self._version_tuple(match.group(1)) >= self._version_tuple("0.7.22")
|
|
assert "function Test-UvVersionOk" in text
|
|
assert "if (-not (Test-UvVersionOk))" in text
|
|
|
|
def test_install_sh_preserves_timeout_override(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
': "${UV_COMPILE_BYTECODE_TIMEOUT:=180}"' in text
|
|
), "install.sh should default UV_COMPILE_BYTECODE_TIMEOUT without overwriting callers"
|
|
assert (
|
|
"export UV_COMPILE_BYTECODE_TIMEOUT" in text
|
|
), "install.sh should export UV_COMPILE_BYTECODE_TIMEOUT for uv subprocesses"
|
|
|
|
def test_install_ps1_preserves_timeout_override(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert (
|
|
"if (-not $env:UV_COMPILE_BYTECODE_TIMEOUT)" in text
|
|
), "install.ps1 should preserve caller UV_COMPILE_BYTECODE_TIMEOUT overrides"
|
|
assert (
|
|
'$env:UV_COMPILE_BYTECODE_TIMEOUT = "180"' in text
|
|
), "install.ps1 should default UV_COMPILE_BYTECODE_TIMEOUT"
|
|
|
|
|
|
class TestTorchIndexOverrideParity:
|
|
"""Every installer must honor UNSLOTH_TORCH_INDEX_URL / _FAMILY so a pinned wheel
|
|
index wins over GPU probing on all platforms (no asymmetric, per-OS coverage)."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[INSTALL_SH, INSTALL_PS1, SETUP_PS1, STACK_PY],
|
|
ids = ["install.sh", "install.ps1", "setup.ps1", "install_python_stack.py"],
|
|
)
|
|
def test_installer_reads_override_env(self, path):
|
|
text = path.read_text(encoding = "utf-8")
|
|
for var in ("UNSLOTH_TORCH_INDEX_URL", "UNSLOTH_TORCH_INDEX_FAMILY"):
|
|
assert var in text, f"{path.name} does not honor {var}"
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
[INSTALL_PS1, SETUP_PS1],
|
|
ids = ["install.ps1", "setup.ps1"],
|
|
)
|
|
def test_amd_reroute_guarded_when_pinned(self, path):
|
|
# The AMD ROCm reroute must be skipped when the index is explicitly pinned,
|
|
# so an explicit cpu / cu* / rocm pin on an AMD host is not overwritten.
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert (
|
|
"TorchIndexPinned" in text
|
|
), f"{path.name} should gate the AMD ROCm reroute on a pinned-index flag"
|
|
|
|
def test_cuda_pin_overrides_cvd_hide_gate(self):
|
|
# A pinned cu* index skips ALL host-GPU probing, so the CUDA repair must clear the
|
|
# CUDA_VISIBLE_DEVICES hide gate too (else the GPU-less CI case bails).
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
m = re.search(r"def _ensure_cuda_torch\(\).*?(?=\ndef )", text, re.DOTALL)
|
|
assert m, "could not locate _ensure_cuda_torch"
|
|
body = m.group(0)
|
|
assert "_cuda_pinned" in body, (
|
|
"_ensure_cuda_torch should compute a CUDA-pin flag so the pin can "
|
|
"override the CVD hide gate"
|
|
)
|
|
assert re.search(
|
|
r"if not _cuda_pinned and _cvd is not None", body
|
|
), "the CVD hide gate must be bypassed when a CUDA index is pinned"
|
|
|
|
def test_cpu_repair_pins_supported_torch_range(self):
|
|
# The explicit-CPU repair must use the bounded CPU/CUDA spec, not a bare trio (the
|
|
# /cpu index serves torch 2.11+, so a bare install could resolve out of range).
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
m = re.search(r"def _ensure_cpu_torch\(\).*?(?=\ndef )", text, re.DOTALL)
|
|
assert m, "could not locate _ensure_cpu_torch"
|
|
body = m.group(0)
|
|
assert "_CPU_TORCH_PKG_SPEC" in body, (
|
|
"_ensure_cpu_torch should install the bounded _CPU_TORCH_PKG_SPEC, "
|
|
"not a bare torch/torchvision/torchaudio trio"
|
|
)
|
|
|
|
def test_setup_ps1_stale_check_gates_rocm_on_supported_arch(self):
|
|
# The stale check must expect ROCm torch only for arches the install path maps to a
|
|
# repo.amd.com index; expecting "rocm" for an unmapped arch marks a good CPU venv stale.
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "_rocmWheelArches" in text, (
|
|
"setup.ps1 stale check should restrict the ROCm expected-tag to the "
|
|
"supported gfx wheel arches"
|
|
)
|
|
|
|
|
|
class TestGfx211AllowlistParity:
|
|
"""The gfx per-arch 2.11-floor leaves must be the SAME set in every installer
|
|
and its stale/mismatch check. When they diverged, a pinned gfx110X-all /
|
|
gfx90a / gfx908 wheel (<2.11) was force-reinstalled every update.
|
|
|
|
Each test extracts the set each installer actually holds and compares it
|
|
against EXPECTED, rather than matching one hardcoded ordering. Order and
|
|
spacing are free; membership is not. The earlier literal-string form had to
|
|
be edited in four places whenever a leaf was added, which is how adding
|
|
gfx1152 (Krackan Point) turned this class red without any installer
|
|
actually disagreeing with another."""
|
|
|
|
EXPECTED = {"gfx120x-all", "gfx1151", "gfx1150", "gfx1152"}
|
|
|
|
@staticmethod
|
|
def _leaves(blob: str) -> set[str]:
|
|
"""The gfx leaves named in an allowlist literal, quoting-agnostic."""
|
|
return set(re.findall(r"gfx[0-9a-z-]+", blob.lower()))
|
|
|
|
def test_install_sh_allowlist(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8").lower()
|
|
# install.sh: the TORCH_CONSTRAINT case (rocm7.2|gfx...|gfx...).
|
|
m = re.search(r"^\s*(rocm7\.2\|[a-z0-9|.\-]*)\)", text, re.MULTILINE)
|
|
assert m, "install.sh gfx-2.11 allowlist case not found / changed"
|
|
assert self._leaves(m.group(1)) == self.EXPECTED, (
|
|
f"install.sh gfx-2.11 allowlist is {sorted(self._leaves(m.group(1)))}, "
|
|
f"expected {sorted(self.EXPECTED)}"
|
|
)
|
|
|
|
def test_install_ps1_allowlist(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8").lower()
|
|
m = re.search(r"\$_pingfx211\s*=\s*@\(([^)]*)\)", text)
|
|
assert m, "install.ps1 $_pinGfx211 allowlist not found / changed"
|
|
assert self._leaves(m.group(1)) == self.EXPECTED, (
|
|
f"install.ps1 $_pinGfx211 is {sorted(self._leaves(m.group(1)))}, "
|
|
f"expected {sorted(self.EXPECTED)}"
|
|
)
|
|
|
|
def test_setup_ps1_defines_single_allowlist_helper(self):
|
|
# setup.ps1 must define the allowlist once (Test-RocmGfx211Leaf) and reuse it, so
|
|
# the stale check and install spec can't disagree.
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert (
|
|
"function Test-RocmGfx211Leaf" in text
|
|
), "setup.ps1 should define a single Test-RocmGfx211Leaf allowlist helper"
|
|
m = re.search(r"function test-rocmgfx211leaf[\s\S]{0,400}?@\(([^)]*)\)", text.lower())
|
|
assert m, "Test-RocmGfx211Leaf should hold the gfx-2.11 allowlist"
|
|
assert self._leaves(m.group(1)) == self.EXPECTED, (
|
|
f"Test-RocmGfx211Leaf holds {sorted(self._leaves(m.group(1)))}, "
|
|
f"expected {sorted(self.EXPECTED)}"
|
|
)
|
|
assert "$_pinGfx211 = Test-RocmGfx211Leaf" in text, (
|
|
"setup.ps1 install-spec path should reuse Test-RocmGfx211Leaf, not "
|
|
"re-hardcode the allowlist (they must not diverge)"
|
|
)
|
|
|
|
def test_stack_py_allowlist(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8").lower()
|
|
m = re.search(r"_rocm_gfx_torch211_leaves[^=]*=\s*frozenset\(\s*\{([^}]*)\}", text)
|
|
assert m, "install_python_stack.py _ROCM_GFX_TORCH211_LEAVES not found / changed"
|
|
assert self._leaves(m.group(1)) == self.EXPECTED, (
|
|
f"_ROCM_GFX_TORCH211_LEAVES is {sorted(self._leaves(m.group(1)))}, "
|
|
f"expected {sorted(self.EXPECTED)}"
|
|
)
|
|
|
|
|
|
class TestCudaLeafDigitParity:
|
|
"""A wheel-family leaf is CUDA only when it is "cu" + digits (cu118/cu128/...).
|
|
A bare cu* glob wrongly catches mirror leaves like /custom or /current; when
|
|
that happened the venv was marked stale and rebuilt on every run. Every
|
|
installer must require a digit after "cu" in its family/CUDA classification."""
|
|
|
|
def test_stack_py_requires_cu_digit(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
# EXACT cu+digits: a custom leaf like cu128-private must route to the
|
|
# verbatim/unknown path, not be compared against the installed +cu128 tag.
|
|
assert re.search(
|
|
r'r"cu\[0-9\]\+"', text
|
|
), "install_python_stack.py _is_cuda_family_leaf must fullmatch cu[0-9]+"
|
|
|
|
def test_setup_ps1_requires_cu_digit(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
# EXACT cu+digits: cu128-private must not classify as CUDA (it would become
|
|
# the expected tag and rebuild the venv on every update).
|
|
assert re.search(
|
|
r"'\^cu\[0-9\]\+\$'", text
|
|
), "setup.ps1 Test-CudaFamilyLeaf must match ^cu[0-9]+$, not a cu* prefix"
|
|
# The stale-venv branch must go through the digit-guarded helper.
|
|
assert (
|
|
"Test-CudaFamilyLeaf $_pinLeaf" in text
|
|
), "setup.ps1 stale check should classify CUDA via Test-CudaFamilyLeaf"
|
|
|
|
def test_install_ps1_requires_cu_digit_in_gpu_branch(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert re.search(
|
|
r"'\^cu\[0-9\]'", text
|
|
), "install.ps1 Get-TauriGpuBranch must require a digit after cu"
|
|
|
|
def test_install_sh_requires_cu_digit_in_gpu_branch(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
# The _tauri_gpu_branch cuda case must be cu[0-9]*, not a bare cu*.
|
|
assert re.search(
|
|
r"cu\[0-9\]\*\)\s*echo \"cuda\"", text
|
|
), "install.sh _tauri_gpu_branch cuda case must be cu[0-9]*, not cu*"
|
|
|
|
def test_install_sh_backend_export_requires_cu_digit(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
# Brand CUDA only on cu[0-9]*; a bare catch-all *) -> cuda would mis-brand
|
|
# /current, /custom pins and skip ROCm repair on AMD hosts.
|
|
assert re.search(
|
|
r'cu\[0-9\]\*\)\s*export UNSLOTH_TORCH_BACKEND="cuda"', text
|
|
), "install.sh backend export must brand cuda only on cu[0-9]*"
|
|
# An unknown leaf must NOT commit a cuda backend (it unsets instead).
|
|
assert re.search(
|
|
r"\*\)\s*unset UNSLOTH_TORCH_BACKEND", text
|
|
), "install.sh backend export must unset (not force cuda) on an unknown leaf"
|
|
|
|
def test_install_sh_lowercases_backend_leaf(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
# The leaf feeding both the backend case and the 2.11 floor case must be
|
|
# lowercased so the canonical gfx120X-all (capital X) matches.
|
|
assert re.search(
|
|
r"_torch_index_leaf=\$\(printf '%s' \"\$_torch_index_leaf\" \| tr '\[:upper:\]' '\[:lower:\]'\)",
|
|
text,
|
|
), "install.sh must lowercase _torch_index_leaf before the gfx/rocm/cu case matches"
|
|
|
|
|
|
class TestKnown211SetParity:
|
|
"""The KNOWN-2.11 rocm/gfx set must be identical across all four installers:
|
|
exactly {rocm7.2} plus TestGfx211AllowlistParity.EXPECTED.
|
|
rocm7.3 / torch 2.12 do not exist, so no side may floor them speculatively."""
|
|
|
|
def test_install_sh_known_211_leaf_is_rocm72_and_gfx_allowlist(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
# The 2.11 floor case matches exactly rocm7.2 + the gfx allowlist, in
|
|
# any order: it is the same set as TestGfx211AllowlistParity.EXPECTED,
|
|
# asserted here so the rocm-version half cannot drift on its own.
|
|
m = re.search(r"^\s*(rocm7\.2\|[a-zA-Z0-9|.\-]*)\)", text, re.MULTILINE)
|
|
assert m, "install.sh 2.11 floor case (rocm7.2|gfx...) not found / changed"
|
|
alternatives = set(m.group(1).lower().split("|"))
|
|
assert alternatives == {"rocm7.2"} | TestGfx211AllowlistParity.EXPECTED, (
|
|
f"install.sh 2.11 floor is {sorted(alternatives)}, expected "
|
|
f"{sorted({'rocm7.2'} | TestGfx211AllowlistParity.EXPECTED)}"
|
|
)
|
|
# No speculative rocm7.3 anywhere.
|
|
assert "rocm7.3" not in text, "install.sh must not reference a non-existent rocm7.3"
|
|
|
|
def test_python_known_211_versions_is_only_rocm72(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert "_ROCM_KNOWN_TORCH211_VERSIONS" in text
|
|
# The frozenset literal is exactly {(7, 2)}.
|
|
m = re.search(r"_ROCM_KNOWN_TORCH211_VERSIONS[^=]*=\s*frozenset\(\{([^}]*)\}\)", text)
|
|
assert m is not None, "install_python_stack.py must define _ROCM_KNOWN_TORCH211_VERSIONS"
|
|
assert "(7, 2)" in m.group(1)
|
|
assert "7, 3" not in m.group(1) and "7, 1" not in m.group(1)
|
|
|
|
def test_setup_ps1_known_211_helper_is_only_rocm72(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "Test-RocmKnown211Version" in text
|
|
# The predicate is Major -eq 7 -and Minor -eq 2 (only rocm7.2).
|
|
assert re.search(
|
|
r"Test-RocmKnown211Version[\s\S]{0,400}\$Major -eq 7 -and \$Minor -eq 2", text
|
|
), "setup.ps1 Test-RocmKnown211Version must accept only rocm7.2"
|
|
|
|
def test_install_ps1_pin_floor_is_only_rocm72(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
# The pinned-ROCm install-spec floor must be Major -eq 7 -and Minor -eq 2,
|
|
# not the speculative >= 2 that would floor a non-existent rocm7.3.
|
|
assert re.search(
|
|
r"\$_pinRocm211 = \(\[int\]\$Matches\[1\] -eq 7 -and \[int\]\$Matches\[2\] -eq 2\)",
|
|
text,
|
|
), "install.ps1 pinned-ROCm floor must be rocm7.2 only (no speculative >= 2)"
|
|
|
|
def test_ps1_pin_floor_gate_is_anchored(self):
|
|
"""The floor-selection gate that reads $_pinRocm211 from the raw leaf must anchor
|
|
the rocm match ($), or a suffixed custom leaf (rocm7.2-private) matches the rocm7.2
|
|
prefix, takes the 2.11-floor branch, and is force-routed through the ROCm path
|
|
before the exact-match elseif can send it to the verbatim install (Codex P2)."""
|
|
for path, label in ((INSTALL_PS1, "install.ps1"), (SETUP_PS1, "setup.ps1")):
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert "-match '^rocm(\\d+)\\.(\\d+)$'" in text, (
|
|
f"{label} floor gate must anchor the rocm match (^rocm(\\d+)\\.(\\d+)$) so a "
|
|
"suffixed custom leaf is not floored/routed as rocm7.2"
|
|
)
|
|
assert (
|
|
"-match '^rocm(\\d+)\\.(\\d+)'\n" not in text
|
|
), f"{label} floor gate must not use the unanchored ^rocm(\\d+)\\.(\\d+) prefix"
|
|
|
|
def test_install_ps1_bounds_unknown_leaf_pinned_torch(self):
|
|
"""install.ps1's pinned-torch install must bound BOTH companions on EVERY
|
|
index, cu<digits> families included: torchaudio 2.11 dropped its exact torch
|
|
pin from the wheel metadata, so a bare companion beside torch<2.11 can
|
|
resolve a mismatched 2.11.0 build (Codex P2, then unconditional per the
|
|
torchaudio 2.11 unpinning)."""
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert (
|
|
'$_pinVisionSpec = "torchvision>=0.19,<0.26.0"' in text
|
|
), "install.ps1 custom-pin install must bound torchvision (>=0.19,<0.26.0)"
|
|
assert (
|
|
'$_pinAudioSpec = "torchaudio>=2.4,<2.11.0"' in text
|
|
), "install.ps1 custom-pin install must bound torchaudio (>=2.4,<2.11.0)"
|
|
# No cu-family exemption: the bounds apply unconditionally.
|
|
assert (
|
|
"$_pinCuLeaf" not in text
|
|
), "install.ps1 must bound companions on every index (no cu-family exemption)"
|
|
# The bounded companions must actually be passed to the install command.
|
|
# Specs are splatted, so check both halves: the list is built, and it is passed.
|
|
assert (
|
|
'$_torchSpecs = @("torch>=2.4,<2.11.0", $_pinVisionSpec, $_pinAudioSpec)' in text
|
|
), "install.ps1 custom-pin install must build the bounded spec list"
|
|
assert (
|
|
"@_torchSpecs --default-index $TorchIndexUrl" in text
|
|
), "install.ps1 custom-pin install must pass the bounded companion specs to uv"
|
|
|
|
def test_gfx_allowlist_matches_across_installers(self):
|
|
# The gfx 2.11 allowlist {gfx120x-all, gfx1151, gfx1150} must appear in each.
|
|
gfx = ("gfx120x-all", "gfx1151", "gfx1150")
|
|
for path, label in (
|
|
(INSTALL_SH, "install.sh"),
|
|
(INSTALL_PS1, "install.ps1"),
|
|
(SETUP_PS1, "setup.ps1"),
|
|
(STACK_PY, "install_python_stack.py"),
|
|
):
|
|
low = path.read_text(encoding = "utf-8").lower()
|
|
for g in gfx:
|
|
assert g in low, f"{label} missing gfx 2.11 allowlist member {g}"
|
|
|
|
|
|
class TestPinnedRocmLeafDigitParity:
|
|
"""A pinned index is a pip ROCm --default-index family only when its leaf is an
|
|
EXACT rocm+digits (rocm7 / rocm7.2) or gfx*. A ^rocm[0-9] PREFIX (or a bare rocm*
|
|
glob) wrongly catches a custom mirror / find-links leaf (rocm-current /
|
|
rocm-rel-7.2.1) AND a suffixed private-mirror leaf (rocm7.2-private / rocm7-current),
|
|
routing it through the ROCm install path (which silently falls back to CPU on
|
|
failure) or skipping the custom-index companion bounds, instead of the verbatim
|
|
--default-index install. All installers must match the family EXACTLY: Python and
|
|
install.sh via a shared _is_pip_rocm_family_leaf, setup.ps1 via Test-PipRocmFamilyLeaf,
|
|
install.ps1 via an anchored ^rocm[0-9]+(\\.[0-9]+)?$ reroute."""
|
|
|
|
def test_install_ps1_pinned_reroute_requires_rocm_digit(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
# The pinned gfx*/rocm reroute must match rocm EXACTLY (anchored), so a suffixed
|
|
# rocm7.2-private / rocm-current falls through to the verbatim --default-index path.
|
|
assert "-match '^rocm[0-9]+(\\.[0-9]+)?$'" in text, (
|
|
"install.ps1 pinned-index reroute must anchor the rocm match "
|
|
"(^rocm[0-9]+(\\.[0-9]+)?$), not a bare -like 'rocm*' or an unanchored ^rocm\\d"
|
|
)
|
|
# Neither the broad glob nor the unanchored prefix may drive that reroute.
|
|
assert (
|
|
"-like 'rocm*'" not in text
|
|
), "install.ps1 must not route a pinned index on a bare -like 'rocm*' glob"
|
|
assert (
|
|
"-match '^rocm\\d'" not in text
|
|
), "install.ps1 must not route a pinned index on an unanchored -match '^rocm\\d'"
|
|
|
|
def test_setup_ps1_pinned_reroute_requires_rocm_digit(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
# setup.ps1 routes every family decision through Test-PipRocmFamilyLeaf, which
|
|
# anchors the rocm match so a suffixed custom leaf stays on the verbatim path.
|
|
assert (
|
|
"function Test-PipRocmFamilyLeaf" in text
|
|
), "setup.ps1 must define Test-PipRocmFamilyLeaf (the exact rocm/gfx family gate)"
|
|
assert "'^rocm[0-9]+(\\.[0-9]+)?$'" in text, (
|
|
"setup.ps1 Test-PipRocmFamilyLeaf must anchor the rocm match "
|
|
"(^rocm[0-9]+(\\.[0-9]+)?$) so rocm7.2-private / rocm-current stay verbatim"
|
|
)
|
|
pinned_block = text[text.find("$_pinGfx211 = Test-RocmGfx211Leaf") :][:2000]
|
|
assert (
|
|
"-like 'rocm*'" not in pinned_block
|
|
), "setup.ps1 pinned reroute must not route on a bare -like 'rocm*' glob"
|
|
|
|
def test_install_sh_repairable_requires_rocm_digit(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
# _torch_index_repairable routes rocm/gfx through the exact-match helper.
|
|
assert (
|
|
"_is_pip_rocm_family_leaf" in text
|
|
), "install.sh must define/use _is_pip_rocm_family_leaf for the exact rocm gate"
|
|
# gfx needs a following digit: gfx-private / gfxfoo are custom verbatim pins.
|
|
assert re.search(
|
|
r'case "\$1" in\n\s*gfx\[0-9\]\*\) return 0', text
|
|
), "install.sh _is_pip_rocm_family_leaf must treat only gfx<digit>* as a family"
|
|
assert not re.search(
|
|
r'case "\$1" in\n\s*gfx\*\) return 0', text
|
|
), "install.sh _is_pip_rocm_family_leaf must not family-match a bare gfx* glob"
|
|
|
|
def test_stack_py_pip_rocm_family_requires_digit(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert re.search(
|
|
r'fullmatch\(r"rocm\\d\+\(\?:\\\.\\d\+\)\?", leaf\)', text
|
|
), "install_python_stack.py _is_pip_rocm_family_leaf must fullmatch rocm\\d+(?:\\.\\d+)?"
|
|
# The unanchored prefix must be gone from the family/flavor gates.
|
|
assert (
|
|
're.match(r"^rocm\\d"' not in text
|
|
), "install_python_stack.py must not gate a family on an unanchored re.match(^rocm\\d)"
|
|
|
|
def test_install_sh_rocm_side_effects_digit_gated(self):
|
|
"""The AMD bitsandbytes + 'repair ROCm torch' side effects must fire only on
|
|
an EXACT ROCm family (rocm7.2/gfx*), not a bare */rocm* whole-URL glob nor a
|
|
^rocm[0-9] prefix that catches a custom CPU/CUDA index like /rocm-current or a
|
|
suffixed /rocm7.2-private and force-repairs it from the wrong --default-index."""
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
'if _is_pip_rocm_family_leaf "$_torch_index_leaf"; then\n _torch_index_is_rocm_family=true'
|
|
in text
|
|
), "install.sh must set _torch_index_is_rocm_family from the exact-match helper"
|
|
assert (
|
|
'[ "$_torch_index_is_rocm_family" = true ]' in text
|
|
), "install.sh ROCm bnb/repair hooks must gate on _torch_index_is_rocm_family"
|
|
assert (
|
|
"*/rocm*|*/gfx*)\n _install_bnb_rocm" not in text
|
|
), "install.sh must not gate _install_bnb_rocm on a bare */rocm* whole-URL glob"
|
|
|
|
|
|
class TestPinnedIndexClearsUvEnvParity:
|
|
"""Every installer must neutralise the uv index env vars for a pinned torch
|
|
install (#6898). uv treats the default index (--index-url / --default-index) as
|
|
lowest priority, so an inherited UV_INDEX / UV_EXTRA_INDEX_URL mirror would win
|
|
under uv's first-index strategy and pull torch from the wrong index -- after
|
|
which the pinned wheel index is silently never used."""
|
|
|
|
UV_VARS = ("UV_DEFAULT_INDEX", "UV_INDEX_URL", "UV_INDEX", "UV_EXTRA_INDEX_URL")
|
|
|
|
def test_install_sh_clears_uv_index_vars(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
"env -u UV_DEFAULT_INDEX -u UV_INDEX_URL -u UV_INDEX -u UV_EXTRA_INDEX_URL" in text
|
|
), "install.sh run_install_cmd must clear the uv index vars for --default-index installs"
|
|
|
|
def test_install_ps1_clears_uv_index_vars(self):
|
|
text = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
for var in self.UV_VARS:
|
|
assert var in text, f"install.ps1 must clear {var} for pinned installs"
|
|
|
|
def test_setup_ps1_clears_uv_index_vars(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
for var in self.UV_VARS:
|
|
assert var in text, f"setup.ps1 must clear {var} for pinned installs"
|
|
|
|
def test_stack_py_clears_uv_index_vars(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert "_install_env_for_cmd" in text, (
|
|
"install_python_stack.py must scrub inherited uv index vars for pinned "
|
|
"installs via _install_env_for_cmd (parity with install.sh #6898)"
|
|
)
|
|
for var in self.UV_VARS:
|
|
assert var in text, f"install_python_stack.py must clear {var} for pinned installs"
|
|
|
|
def test_all_installers_clear_uv_torch_backend(self):
|
|
"""uv's torch backend redirects torch resolution to its own per-backend
|
|
index even against an explicit pin, so every installer's pinned-install
|
|
scrub must clear UV_TORCH_BACKEND too."""
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "-u UV_TORCH_BACKEND" in sh, "install.sh pinned scrub must clear UV_TORCH_BACKEND"
|
|
for path in (INSTALL_PS1, SETUP_PS1):
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert (
|
|
"'UV_TORCH_BACKEND'" in text
|
|
), f"{path.name} pinned scrub must clear UV_TORCH_BACKEND"
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
assert (
|
|
'"UV_TORCH_BACKEND",' in stack
|
|
), "install_python_stack.py strip tuple must include UV_TORCH_BACKEND"
|
|
|
|
def test_stack_py_strips_pip_extra_index_for_pip_fallback(self):
|
|
"""The pip fallback honours PIP_EXTRA_INDEX_URL (pip adds it IN ADDITION
|
|
to --index-url), so the pinned-command scrub must strip it."""
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
assert (
|
|
'"PIP_EXTRA_INDEX_URL",' in stack
|
|
), "install_python_stack.py strip tuple must include PIP_EXTRA_INDEX_URL"
|
|
|
|
def test_all_installers_scrub_find_links(self):
|
|
"""uv's --find-links (env UV_FIND_LINKS) adds candidate locations that can
|
|
satisfy torch off a pinned index; every pinned-install scrub must clear it."""
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "-u UV_FIND_LINKS" in sh
|
|
for path in (INSTALL_PS1, SETUP_PS1):
|
|
assert "'UV_FIND_LINKS'" in path.read_text(encoding = "utf-8"), path.name
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
assert '"UV_FIND_LINKS",' in stack and '"PIP_FIND_LINKS",' in stack
|
|
|
|
def test_setup_ps1_scrub_covers_pip_fallback(self):
|
|
"""setup.ps1's Fast-Install must keep the scrub active through the pip
|
|
fallback (pip honours PIP_EXTRA_INDEX_URL / PIP_FIND_LINKS in addition to
|
|
--index-url); restoring the vars before the fallback reopens the hole."""
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
fi = text[text.find("function Fast-Install") :][:2500]
|
|
assert "'PIP_EXTRA_INDEX_URL'" in fi and "'PIP_FIND_LINKS'" in fi
|
|
# the pip fallback must sit INSIDE the try whose finally restores the vars
|
|
assert fi.find("python -m pip install") < fi.find(
|
|
"finally"
|
|
), "pip fallback must run before the scrub is restored"
|
|
|
|
def test_all_installers_disable_uv_config_for_pinned_installs(self):
|
|
"""A DISCOVERED uv.toml / pyproject [tool.uv] outranks the CLI pin
|
|
(verified with uv 0.10: [pip] torch-backend = "cpu" and a non-default
|
|
[[index]] both resolve torch+cpu against an explicit --index-url /
|
|
--default-index cu126 pin; UV_NO_CONFIG=1 restores the pin). Every
|
|
installer's pinned scrub must set UV_NO_CONFIG=1 and drop UV_CONFIG_FILE."""
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "-u UV_CONFIG_FILE UV_NO_CONFIG=1" in sh, (
|
|
"install.sh run_install_cmd must set UV_NO_CONFIG=1 and drop "
|
|
"UV_CONFIG_FILE for --default-index installs"
|
|
)
|
|
for path in (INSTALL_PS1, SETUP_PS1):
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert "'UV_CONFIG_FILE'" in text, f"{path.name} must drop UV_CONFIG_FILE"
|
|
assert (
|
|
"$env:UV_NO_CONFIG = '1'" in text
|
|
), f"{path.name} must set UV_NO_CONFIG=1 for pinned installs"
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
assert (
|
|
'"UV_CONFIG_FILE",' in stack
|
|
), "install_python_stack.py strip tuple must include UV_CONFIG_FILE"
|
|
assert (
|
|
'env["UV_NO_CONFIG"] = "1"' in stack
|
|
), "_install_env_for_cmd must set UV_NO_CONFIG=1 for pinned installs"
|
|
|
|
def test_pip_fallbacks_disable_pip_config_files(self):
|
|
"""The pip FALLBACK (uv missing/failed) honours user/site pip config files
|
|
even with the PIP_* env vars stripped: `pip config set
|
|
global.extra-index-url` still adds indexes to a pinned install. pip loads
|
|
NO configuration files when PIP_CONFIG_FILE is the platform devnull, so
|
|
the two installers that HAVE a pip fallback (install_python_stack.py and
|
|
setup.ps1's Fast-Install) must set it in their pinned scrub. install.sh
|
|
and install.ps1 are uv-only (no python -m pip fallback) and need no
|
|
equivalent."""
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
assert 'env["PIP_CONFIG_FILE"] = os.devnull' in stack, (
|
|
"_install_env_for_cmd must point PIP_CONFIG_FILE at os.devnull for "
|
|
"pinned installs (pip fallback isolation)"
|
|
)
|
|
setup = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "$env:PIP_CONFIG_FILE = 'nul'" in setup, (
|
|
"setup.ps1 Fast-Install pinned scrub must point PIP_CONFIG_FILE at nul "
|
|
"(Windows devnull) so the pip fallback ignores user/site pip config"
|
|
)
|
|
assert (
|
|
"'PIP_CONFIG_FILE'" in setup
|
|
), "setup.ps1 must save/restore PIP_CONFIG_FILE around the pinned scrub"
|
|
|
|
def test_setup_ps1_bounds_unknown_leaf_pinned_torch(self):
|
|
"""A first-time/changed unknown-leaf custom pin routes through setup.ps1's
|
|
CUDA branch; install.ps1's fresh pinned install, install.sh, and the Python
|
|
verbatim path bound the WHOLE trio, so the Windows update path must too -- a
|
|
private mirror serving newer torch OR newer companions must not lift the venv
|
|
above the supported range under the pin."""
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
# The custom-leaf branch bounds torch AND both companions (parity with the
|
|
# other installers' custom-pin trio bounds), gated on a non-cu-family leaf.
|
|
for spec in (
|
|
'$cudaTorchSpec = "torch>=2.4,<2.11.0"',
|
|
'$cudaVisionSpec = "torchvision>=0.19,<0.26.0"',
|
|
'$cudaAudioSpec = "torchaudio>=2.4,<2.11.0"',
|
|
):
|
|
assert spec in text, f"setup.ps1 must bound the custom-leaf trio: {spec}"
|
|
assert (
|
|
"if ($TorchIndexPinned -and -not (Test-CudaFamilyLeaf $CuTag)) {" in text
|
|
), "the custom-leaf trio bounds must be gated on a pinned non-cu-family leaf"
|
|
# Specs are splatted, so check both halves: the list is built, and it is passed.
|
|
assert (
|
|
"$_cudaTrio = @($cudaTorchSpec, $cudaVisionSpec, $cudaAudioSpec)" in text
|
|
), "setup.ps1's CUDA branch must build the trio from the bounded spec variables"
|
|
assert (
|
|
"Fast-Install @_cudaTrio @cudaForce" in text
|
|
), "setup.ps1's CUDA branch must install the trio it built"
|
|
|
|
def test_setup_ps1_bounds_pinned_cpu_torch(self):
|
|
"""setup.ps1's CPU branch must bound the trio under an explicit pin (parity with
|
|
_CPU_TORCH_PKG_SPEC): the /cpu index serves newer torch, and _ensure_cpu_torch
|
|
keeps any CPU build, so a bare pinned trio could land an unsupported version.
|
|
An unpinned CPU host keeps the bare trio (pre-pin behavior unchanged)."""
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
for spec in (
|
|
'$cpuTorchSpec = "torch>=2.4,<2.12.0"',
|
|
'$cpuVisionSpec = "torchvision>=0.19,<0.27.0"',
|
|
'$cpuAudioSpec = "torchaudio>=2.4,<2.12.0"',
|
|
):
|
|
assert spec in text, f"setup.ps1 must bound the pinned CPU trio: {spec}"
|
|
assert (
|
|
"if ($TorchIndexPinned) {" in text
|
|
), "the CPU trio bounds must be gated on an explicit pin"
|
|
assert (
|
|
"$_torchTrio = @($cpuTorchSpec, $cpuVisionSpec, $cpuAudioSpec)" in text
|
|
), "setup.ps1's CPU branch must build the trio from the spec variables"
|
|
assert (
|
|
"Fast-Install @_torchTrio @cpuForce" in text
|
|
), "setup.ps1's CPU branch must install the trio it built"
|
|
# The ceilings mirror the Python repair spec exactly.
|
|
stack = STACK_PY.read_text(encoding = "utf-8")
|
|
spec_block = re.search(r"_CUDA_TORCH_PKG_SPEC[^(]*\(\s*(.*?)\)", stack, re.DOTALL)
|
|
assert spec_block and '"torch>=2.4,<2.12.0"' in spec_block.group(1), (
|
|
"_CPU_TORCH_PKG_SPEC (via _CUDA_TORCH_PKG_SPEC) must keep the torch<2.12 "
|
|
"ceiling the setup.ps1 pinned CPU branch mirrors"
|
|
)
|
|
|
|
def test_setup_ps1_stale_check_requires_rocm_digit(self):
|
|
"""The stale-venv check must use the same EXACT rocm/gfx gate as the install
|
|
selection (Test-PipRocmFamilyLeaf), or a custom rocm-* / suffixed rocm7.2-private
|
|
leaf is stale-compared as a family and force-reinstalls on every studio update."""
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
anchor = text.find("$_pinLeaf = Get-TorchIndexLeaf $_pinnedIdx")
|
|
assert anchor >= 0, "setup.ps1 stale check must classify the pinned leaf"
|
|
stale = text[anchor:][:2500]
|
|
assert (
|
|
"Test-PipRocmFamilyLeaf" in stale
|
|
), "setup.ps1 stale check must gate rocm leaves via the exact Test-PipRocmFamilyLeaf"
|
|
assert (
|
|
stale.count("-like 'rocm*'") == 0
|
|
), "setup.ps1 stale check must not use a bare -like 'rocm*' glob"
|
|
assert (
|
|
"-match '^rocm\\d'" not in stale
|
|
), "setup.ps1 stale check must not use an unanchored -match '^rocm\\d'"
|
|
|
|
|
|
class TestIndexPathSlashTrimParity:
|
|
"""Every installer must trim trailing PATH slashes only on the verbatim
|
|
UNSLOTH_TORCH_INDEX_URL override, preserving a ?query/#fragment token: a whole-URL
|
|
strip corrupts a base64 token ending in "/", a single strip leaves a double-slash leaf
|
|
empty. The helper must be DEFINED and WIRED into the override return in all four."""
|
|
|
|
def test_helper_defined_in_all_installers(self):
|
|
assert "def _trim_index_path_slashes(" in STACK_PY.read_text(encoding = "utf-8")
|
|
assert "_trim_index_path_slashes()" in INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "function Trim-IndexPathSlashes" in INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert "function Trim-IndexPathSlashes" in SETUP_PS1.read_text(encoding = "utf-8")
|
|
|
|
def test_helper_wired_into_override_in_all_installers(self):
|
|
assert "_trim_index_path_slashes(url)" in STACK_PY.read_text(encoding = "utf-8")
|
|
assert '_url=$(_trim_index_path_slashes "$_url")' in INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "Trim-IndexPathSlashes $env:UNSLOTH_TORCH_INDEX_URL" in INSTALL_PS1.read_text(
|
|
encoding = "utf-8"
|
|
)
|
|
assert "Trim-IndexPathSlashes $env:UNSLOTH_TORCH_INDEX_URL" in SETUP_PS1.read_text(
|
|
encoding = "utf-8"
|
|
)
|
|
|
|
|
|
class TestInstallOutputRedactionParity:
|
|
"""uv/pip failure text embeds the failing --index-url verbatim, so a captured install
|
|
log dumped on error can leak a user:token@ or ?token= secret. Every installer must
|
|
DEFINE a redaction helper and WIRE it into the captured-output print path."""
|
|
|
|
def test_helper_defined_in_all_installers(self):
|
|
assert "def _redact_install_output(" in STACK_PY.read_text(encoding = "utf-8")
|
|
assert "_redact_install_output()" in INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "function Redact-InstallOutput" in INSTALL_PS1.read_text(encoding = "utf-8")
|
|
assert "function Redact-InstallOutput" in SETUP_PS1.read_text(encoding = "utf-8")
|
|
|
|
def test_helper_wired_into_failure_print(self):
|
|
# install.sh dumps the captured log through the redactor on failure.
|
|
assert '_redact_install_output "$_log"' in INSTALL_SH.read_text(encoding = "utf-8")
|
|
# Both ps1 installers redact the captured $output before Write-Host on non-zero exit.
|
|
assert (
|
|
"Write-Host (Redact-InstallOutput $output) -ForegroundColor Red"
|
|
in INSTALL_PS1.read_text(encoding = "utf-8")
|
|
)
|
|
assert (
|
|
"Write-Host (Redact-InstallOutput $output) -ForegroundColor Red"
|
|
in SETUP_PS1.read_text(encoding = "utf-8")
|
|
)
|
|
# Python redacts the captured stdout before printing.
|
|
assert "_redact_install_output(" in STACK_PY.read_text(encoding = "utf-8")
|
|
|
|
|
|
class TestPipNoIndexScrubParity:
|
|
"""The plain-pip fallback honours PIP_*: PIP_NO_INDEX=1 makes it ignore ALL indexes
|
|
(defeating the pinned --index-url) and PIP_INDEX_URL replaces the pin. The two installers
|
|
that HAVE a plain-pip fallback (Python + setup.ps1) must scrub both for a pinned install.
|
|
install.sh / install.ps1 are uv-only (--default-index), which ignores pip config/env."""
|
|
|
|
def test_python_scrubs_pip_no_index_and_pip_index_url(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert '"PIP_NO_INDEX"' in text
|
|
assert '"PIP_INDEX_URL"' in text
|
|
|
|
def test_setup_ps1_scrubs_pip_no_index_and_pip_index_url(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "'PIP_NO_INDEX'" in text
|
|
assert "'PIP_INDEX_URL'" in text
|
|
|
|
|
|
class TestNoTorchPersistenceParity:
|
|
"""No-torch mode must outlive the process that requested it.
|
|
|
|
install.sh / install.ps1 export UNSLOTH_NO_TORCH for their own run only.
|
|
`unsloth studio update` exports nothing, so both the PowerShell setup and the
|
|
shared Python stack have to recover the mode from the install manifest, or an
|
|
update reinstalls PyTorch into a GGUF-only venv. On Windows it is worse than
|
|
cosmetic: setup.ps1 reads the missing torch as a stale venv and tries to delete
|
|
the venv it is itself running out of, which fails on a locked python.exe."""
|
|
|
|
def test_the_stack_records_the_mode_it_installed(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert "no_torch = NO_TORCH" in text
|
|
assert "install_manifest.recorded_no_torch()" in text
|
|
# Written after the manifest is dropped and before the dependency pass, so
|
|
# a pass killed part-way still leaves the mode recorded somewhere.
|
|
assert text.index("install_manifest.set_no_torch_marker(NO_TORCH)") > text.index(
|
|
"if not install_manifest.remove_manifest():"
|
|
)
|
|
|
|
def test_both_sides_use_the_same_marker_filename(self):
|
|
manifest = (REPO_ROOT / "studio" / "install_manifest.py").read_text(encoding = "utf-8")
|
|
assert 'NO_TORCH_MARKER = ".unsloth-no-torch"' in manifest
|
|
assert '$NoTorchMarker = ".unsloth-no-torch"' in SETUP_PS1.read_text(encoding = "utf-8")
|
|
|
|
def test_setup_ps1_recovers_the_mode_when_no_env_var_is_exported(self):
|
|
text = SETUP_PS1.read_text(encoding = "utf-8")
|
|
assert "function Get-PersistedNoTorch" in text
|
|
assert "function Set-PersistedNoTorch" in text
|
|
# setup.ps1 drops the manifest before running install_python_stack.py, so
|
|
# the resolved answer has to be handed down through the environment.
|
|
assert text.index("Get-PersistedNoTorch -VenvPath $VenvDir") < text.index(
|
|
'$env:UNSLOTH_NO_TORCH = if ($NoTorchMode) { "true" } else { "false" }'
|
|
)
|
|
|
|
def test_both_sides_accept_the_same_spellings(self):
|
|
# install.ps1 / install.sh accept 1|true|yes|on; the two consumers must not
|
|
# be narrower, or a value one layer honours another silently ignores.
|
|
assert "'^\\s*(?i:true|1|yes|on)\\s*$'" in SETUP_PS1.read_text(encoding = "utf-8")
|
|
manifest = (REPO_ROOT / "studio" / "install_manifest.py").read_text(encoding = "utf-8")
|
|
assert 'NO_TORCH_TRUTHY: Tuple[str, ...] = ("1", "true", "yes", "on")' in manifest
|
|
assert "install_manifest.NO_TORCH_TRUTHY" in STACK_PY.read_text(encoding = "utf-8")
|
|
|
|
|
|
class TestAmdBnbFloorParity:
|
|
"""bitsandbytes <= 0.49.2 NaNs at 4-bit decode shape on every AMD GPU; the ROCm
|
|
4-bit GEMV fix (bnb #1887) first ships on PyPI in 0.50.0. The `amd` extra,
|
|
install.sh and the Studio stack resolve bitsandbytes independently, so all three
|
|
must carry the same floor or an unreachable pre-release wheel silently reinstates
|
|
the broken range."""
|
|
|
|
FLOOR = "0.50.0"
|
|
PYPROJECT = REPO_ROOT / "pyproject.toml"
|
|
|
|
def test_amd_extra_floor(self):
|
|
text = self.PYPROJECT.read_text(encoding = "utf-8")
|
|
amd = re.search(r"^amd = \[(.*?)^\]", text, re.S | re.M)
|
|
assert amd, "pyproject.toml must define an `amd` extra"
|
|
specs = re.findall(r'"(bitsandbytes[^"]*)"', amd.group(1))
|
|
assert specs, "the amd extra must pin bitsandbytes"
|
|
for spec in specs:
|
|
assert spec.startswith(
|
|
f"bitsandbytes>={self.FLOOR}"
|
|
), f"amd extra bitsandbytes floor must be >={self.FLOOR}, got {spec!r}"
|
|
|
|
def test_install_sh_pypi_fallback_floor(self):
|
|
text = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert (
|
|
f'_BNB_ROCM_PYPI_FALLBACK="bitsandbytes>={self.FLOOR}"' in text
|
|
), f"install.sh _install_bnb_rocm PyPI fallback must floor at {self.FLOOR}"
|
|
|
|
def test_stack_py_pypi_fallback_floor(self):
|
|
text = STACK_PY.read_text(encoding = "utf-8")
|
|
assert (
|
|
f'_BNB_ROCM_PYPI_FALLBACK = "bitsandbytes>={self.FLOOR}"' in text
|
|
), f"install_python_stack.py PyPI fallback must floor at {self.FLOOR}"
|
|
|
|
def test_no_installer_still_allows_the_broken_range(self):
|
|
for path in (INSTALL_SH, INSTALL_PS1, SETUP_PS1, STACK_PY, self.PYPROJECT):
|
|
text = path.read_text(encoding = "utf-8")
|
|
for line in text.splitlines():
|
|
if "bitsandbytes>=0.49" in line and not line.lstrip().startswith(("#", "//")):
|
|
raise AssertionError(
|
|
f"{path.name} still floors bitsandbytes in the broken ROCm range: {line.strip()!r}"
|
|
)
|
|
|
|
def test_fallback_is_not_reported_as_broken(self):
|
|
"""The fallback now installs the first fixed release, so neither installer
|
|
may still call 4-bit decode broken on ROCm."""
|
|
for path in (INSTALL_SH, STACK_PY):
|
|
text = path.read_text(encoding = "utf-8")
|
|
assert (
|
|
"4-bit decode broken on ROCm" not in text
|
|
), f"{path.name} still reports the repaired PyPI fallback as broken"
|
|
assert (
|
|
"4-bit decode will be broken on ROCm" not in text
|
|
), f"{path.name} still reports the repaired PyPI fallback as broken"
|
|
|
|
def test_aarch64_is_not_told_it_has_a_rocm_backend(self):
|
|
"""bitsandbytes ships no ROCm kernels in its aarch64 wheel at any version, so
|
|
neither installer may hand aarch64 the x86_64 "carries the ROCm 4-bit fix"
|
|
message, and both must warn that 4-bit needs a source build there."""
|
|
sh = INSTALL_SH.read_text(encoding = "utf-8")
|
|
assert "_bnb_rocm_arch_has_binary()" in sh
|
|
assert "_warn_bnb_no_rocm_binary()" in sh
|
|
assert (
|
|
sh.count("_warn_bnb_no_rocm_binary\n") >= 2
|
|
), "install.sh must warn on aarch64 after both the pre-release and the fallback install"
|
|
py = STACK_PY.read_text(encoding = "utf-8")
|
|
assert "def _bnb_rocm_arch_has_binary(" in py
|
|
assert "_bnb_rocm_arch_has_binary()" in py
|
|
for text, name in ((sh, "install.sh"), (py, "install_python_stack.py")):
|
|
assert (
|
|
"4-bit QLoRA needs a source build" in text
|
|
), f"{name} must tell aarch64 users 4-bit needs a source build"
|