diff --git a/.github/workflows/studio-windows-update-smoke.yml b/.github/workflows/studio-windows-update-smoke.yml index 42d74d47d2..0dcc828e6b 100644 --- a/.github/workflows/studio-windows-update-smoke.yml +++ b/.github/workflows/studio-windows-update-smoke.yml @@ -198,6 +198,31 @@ jobs: fi echo "update path took the prebuilt fast path" + - name: Update must keep the --no-torch install GGUF-only + run: | + # `unsloth studio update` exports no UNSLOTH_NO_TORCH, so setup.ps1 has + # to recover the mode from the install manifest. Without that it reads + # the missing torch as a stale venv and tries to delete the venv it is + # running out of, and the shared dependency pass pulls torch back in. + # The skip line only prints when the dependency pass actually runs, so + # don't demand it if the fast path short-circuited that pass. + if grep -q "running ordered dependency installation" logs/update.log \ + && ! grep -q "skipping direct PyTorch and Triton installation (no-torch mode)" logs/update.log; then + echo "::error::studio update left no-torch mode; it would reinstall PyTorch." + grep -iE "no-torch|stale venv|PyTorch" logs/update.log | tail -40 + exit 1 + fi + PY="$HOME/.unsloth/studio/unsloth_studio/Scripts/python.exe" + if [ ! -f "$PY" ]; then + echo "::error::studio venv interpreter missing at $PY" + exit 1 + fi + if "$PY" -c "import torch" 2>/dev/null; then + echo "::error::torch was reinstalled into the --no-torch venv." + exit 1 + fi + echo "update preserved no-torch mode" + - name: Second update must also be a no-op env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/studio/install_manifest.py b/studio/install_manifest.py index 8f48dcf35d..82bcf0d1f5 100644 --- a/studio/install_manifest.py +++ b/studio/install_manifest.py @@ -30,6 +30,16 @@ from typing import Dict, List, Optional, Tuple MANIFEST_NAME = "unsloth_install_manifest.json" MANIFEST_SCHEMA = 1 +# Canonical truthy set for UNSLOTH_NO_TORCH, matching install.ps1 / install.sh. +NO_TORCH_TRUTHY: Tuple[str, ...] = ("1", "true", "yes", "on") + +# Companion to the no_torch manifest key, next to setup.ps1's .unsloth-studio-owned. +# The manifest is deliberately dropped before every dependency pass, so it cannot +# answer for a run killed mid-pass; this marker is written before that pass and +# outlives it. Without it an interrupted GGUF-only install reads as a stale venv on +# the next update, which then tries to delete the venv it is running out of. +NO_TORCH_MARKER = ".unsloth-no-torch" + # Fingerprinted into the manifest, relative to studio/backend/requirements/. # Editing one (a --local install) invalidates it and forces a dependency pass. TRACKED_REQUIREMENT_FILES: Tuple[str, ...] = ( @@ -116,6 +126,7 @@ def write_manifest( req_root: Optional[Path] = None, steps_total: int = 0, package_name: str = "unsloth", + no_torch: Optional[bool] = None, ) -> Optional[Path]: """Record a completed install. Never raises: no manifest reads as incomplete, which is the safe answer.""" @@ -130,6 +141,14 @@ def write_manifest( "steps_total": steps_total, "requirement_files": requirement_digests(req_root), } + # Additive, so MANIFEST_SCHEMA does not move and every existing manifest stays + # valid. Absent means "unknown", which is NOT False: only a manifest written by + # a build that knew about the key can answer, and callers fall back to their own + # detection otherwise. Recorded because install.ps1 / install.sh export + # UNSLOTH_NO_TORCH for their own run only -- a later `unsloth studio update` + # exports nothing and would otherwise reinstall torch into a GGUF-only venv. + if no_torch is not None: + payload["no_torch"] = bool(no_torch) path = manifest_path(root) try: tmp = path.with_suffix(".json.tmp") @@ -143,7 +162,12 @@ def write_manifest( def read_manifest(root: Optional[Path] = None) -> Optional[dict]: try: raw = manifest_path(root).read_text(encoding = "utf-8") - except OSError: + # UnicodeDecodeError is a ValueError, not an OSError: a manifest re-saved as + # ANSI by an editor (the payload embeds the user profile path, so non-ASCII + # names show up there) or truncated mid-write must read as "no manifest", not + # raise. install_python_stack.py resolves no-torch mode through here at import, + # so anything escaping aborts the whole install. + except (OSError, ValueError): return None try: data = json.loads(raw) @@ -152,6 +176,52 @@ def read_manifest(root: Optional[Path] = None) -> Optional[dict]: return data if isinstance(data, dict) else None +def no_torch_marker_path(root: Optional[Path] = None) -> Path: + return (root or venv_root()) / NO_TORCH_MARKER + + +def set_no_torch_marker(no_torch: bool, root: Optional[Path] = None) -> None: + """Record the mode outside the completion manifest. Never raises. + + Written before the dependency pass so an interrupted install still knows what + it was building. Removed when torch is wanted, so migrating out of no-torch + does not leave a stale marker behind. + """ + path = no_torch_marker_path(root) + try: + if no_torch: + path.write_text("", encoding = "utf-8") + else: + path.unlink(missing_ok = True) + except OSError: + pass + + +def recorded_no_torch(root: Optional[Path] = None) -> Optional[bool]: + """The mode this venv was installed with, or None when unknown. + + None means nothing recorded it: no manifest key and no marker. Callers must + fall back to their own detection on None and never to False, so an install + made before either existed is not silently switched out of no-torch mode. + """ + manifest = read_manifest(root) + if manifest is not None: + value = manifest.get("no_torch") + if isinstance(value, bool): + return value + # Tolerate a hand-edited manifest that used a string. + if isinstance(value, str): + return value.strip().lower() in NO_TORCH_TRUTHY + # No manifest (dropped before the dependency pass, or the install was killed + # during it) or one predating the key: the marker is the durable answer. + try: + if no_torch_marker_path(root).exists(): + return True + except OSError: + pass + return None + + def _parse_requirement_line(line: str) -> Optional[Tuple[str, str, str]]: """(distribution name, marker, specifier) for a requirement, or None. diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index 4004a3b048..8c71d39e16 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -2215,13 +2215,28 @@ def _windows_hidden_subprocess_kwargs() -> dict[str, object]: def _infer_no_torch() -> bool: """Determine whether to run in no-torch (GGUF-only) mode. - Checks UNSLOTH_NO_TORCH first. When unset, falls back to platform - detection so Intel Macs use GGUF-only mode even when invoked from - ``unsloth studio update`` (which does not inject the env var). + Precedence: UNSLOTH_NO_TORCH (install.sh / install.ps1 export it, "false" + included, so an explicit value always wins) -> the mode recorded in this + venv's install manifest -> platform detection, so Intel Macs use GGUF-only + mode even when invoked from ``unsloth studio update``. + + The manifest tier is what keeps ``unsloth studio update`` in no-torch mode: + it injects no env var, so without it every update reinstalls torch into a + GGUF-only venv. Note setup.ps1 resolves the mode itself and re-exports + UNSLOTH_NO_TORCH, because it drops the manifest before invoking this script. + + An empty value counts as unset: PowerShell cannot represent a set-but-empty + variable (assigning "" deletes it), so the two must mean the same thing here. + + Evaluated at import, which is before install_python_stack() drops the + manifest. Do not defer this call into main(). """ env = os.environ.get("UNSLOTH_NO_TORCH") - if env is not None: - return env.strip().lower() in ("1", "true") + if env is not None and env.strip(): + return env.strip().lower() in install_manifest.NO_TORCH_TRUTHY + recorded = install_manifest.recorded_no_torch() + if recorded is not None: + return recorded return IS_MAC_INTEL @@ -2871,6 +2886,11 @@ def install_python_stack() -> int: ) return 1 + # The manifest just went away, so record the mode in a marker that survives a + # pass killed part-way. Otherwise the next update sees neither, reads the + # absent torch as a stale venv, and tries to delete the running environment. + install_manifest.set_no_torch_marker(NO_TORCH) + # 1. Try uv for faster installs (before pip upgrade -- uv venvs don't # include pip by default). USE_UV = _bootstrap_uv() @@ -3256,6 +3276,7 @@ def install_python_stack() -> int: req_root = REQ_ROOT, steps_total = _TOTAL, package_name = package_name, + no_torch = NO_TORCH, ) is None ): diff --git a/studio/setup.ps1 b/studio/setup.ps1 index ea84068809..0734b9c2fa 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -2661,6 +2661,8 @@ $VenvDir = Join-Path $StudioHome "unsloth_studio" # the canonical comparison so an override pointing at the legacy default # still behaves like a default install. $StudioOwnedMarker = ".unsloth-studio-owned" +# Mirrors install_manifest.NO_TORCH_MARKER; keep the two in step. +$NoTorchMarker = ".unsloth-no-torch" $LegacyStudioHome = Join-Path $env:USERPROFILE ".unsloth\studio" $_studioHomeCanon = $StudioHome if (Test-Path -LiteralPath $_studioHomeCanon -PathType Container) { @@ -2704,13 +2706,71 @@ function Mark-StudioOwned { } catch {} } +# The mode this venv was installed with. install.ps1 exports UNSLOTH_NO_TORCH for +# its own run only, so a later `unsloth studio update` (which exports nothing) has +# no other way to know. Two sources, because the completion manifest is dropped +# before every dependency pass and so cannot answer for a run killed mid-pass: +# the manifest key first, then .unsloth-no-torch, which outlives the pass. Neither +# present reads as "install torch" -- the pre-existing behavior. +function Get-PersistedNoTorch { + param([Parameter(Mandatory = $true)][string]$VenvPath) + $manifestPath = Join-Path $VenvPath "unsloth_install_manifest.json" + if (Test-Path -LiteralPath $manifestPath -PathType Leaf) { + $payload = $null + try { + $payload = Get-Content -LiteralPath $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json + } catch { + $payload = $null + } + if ($null -ne $payload -and $null -ne $payload.no_torch) { + return ("$($payload.no_torch)" -match '^\s*(?i:true|1|yes|on)\s*$') + } + } + return (Test-Path -LiteralPath (Join-Path $VenvPath $NoTorchMarker) -PathType Leaf) +} + +# Written before anything that could be interrupted, and cleared when torch is +# wanted so migrating out of no-torch leaves nothing stale behind. +function Set-PersistedNoTorch { + param( + [Parameter(Mandatory = $true)][string]$VenvPath, + [Parameter(Mandatory = $true)][bool]$NoTorch + ) + if (-not (Test-Path -LiteralPath $VenvPath -PathType Container)) { return } + $markerPath = Join-Path $VenvPath $NoTorchMarker + try { + if ($NoTorch) { + [System.IO.File]::WriteAllText($markerPath, "") + } elseif (Test-Path -LiteralPath $markerPath -PathType Leaf) { + Remove-Item -LiteralPath $markerPath -Force -ErrorAction Stop + } + } catch {} +} + # Stale-venv detection: if the venv exists but its torch flavor no longer # matches the current machine, repair according to invocation context. # - install.ps1 sets UNSLOTH_INSTALL_ROLLBACK_MANAGED=1 so setup can delegate # to the installer-level rollback that restores the previous environment. # - direct `unsloth studio update` keeps the pre-existing self-repair behavior. # In no-torch mode, a missing torch package is expected. -$NoTorchMode = $env:UNSLOTH_NO_TORCH -match '^(?i:true|1|yes)$' +$NoTorchMode = $env:UNSLOTH_NO_TORCH -match '^\s*(?i:true|1|yes|on)\s*$' +# No env var at all means `unsloth studio update` / `studio setup` / setup.bat, +# none of which export one. Without the manifest fallback the check below reads a +# GGUF-only venv's missing torch as a stale venv and tries to delete the venv this +# script is itself running out of, which fails on a locked python.exe. +if (-not $NoTorchMode -and [string]::IsNullOrWhiteSpace($env:UNSLOTH_NO_TORCH)) { + $NoTorchMode = Get-PersistedNoTorch -VenvPath $VenvDir + if ($NoTorchMode) { + substep "no-torch install detected -- keeping this environment GGUF-only." "Yellow" + } +} +# Persist before the torch install and the dependency pass below, either of which +# can be interrupted; install_python_stack.py refreshes the same marker. +Set-PersistedNoTorch -VenvPath $VenvDir -NoTorch $NoTorchMode +# install_python_stack.py drops the manifest before its dependency pass, so it +# cannot repeat the lookup above; hand it the resolved answer. This also collapses +# every accepted spelling to one value both sides parse identically. +$env:UNSLOTH_NO_TORCH = if ($NoTorchMode) { "true" } else { "false" } $InstallerManagedSetup = $env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -match '^(?i:true|1|yes)$' if ((Test-Path -LiteralPath $VenvDir -PathType Container) -and -not $NoTorchMode) { $VenvPyExe = Join-Path $VenvDir "Scripts\python.exe" @@ -3214,6 +3274,7 @@ $PyTorchWhlBase = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR # goes through $ROCmIndexUrl; on failure the fallback uses the CPU index, not the ROCm pin. $TorchInstallIndexUrl = if ($ROCmIndexUrl) { "$PyTorchWhlBase/cpu" } elseif ($PinnedTorchIndexUrl) { $PinnedTorchIndexUrl } else { "$PyTorchWhlBase/$CuTag" } +if (-not $NoTorchMode) { $ROCmCpuFallback = $false if ($ROCmIndexUrl) { substep "installing PyTorch (AMD ROCm, $ROCmGfxArch)..." @@ -3324,6 +3385,9 @@ if (-not $ROCmIndexUrl -and ($CuTag -eq "cpu" -or $ROCmCpuFallback)) { substep "Triton for Windows installed (enables torch.compile)" } } +} else { + substep "skipping direct PyTorch and Triton installation (no-torch mode)." "Yellow" +} # No unsloth.exe rename needed. setup.ps1 runs *via* unsloth.exe, so renaming the # running launcher only ever failed (WinError 32) and printed a scary warning. It's diff --git a/tests/python/test_cross_platform_parity.py b/tests/python/test_cross_platform_parity.py index b0a5c763d4..6c2a1d09cf 100644 --- a/tests/python/test_cross_platform_parity.py +++ b/tests/python/test_cross_platform_parity.py @@ -818,3 +818,47 @@ class TestPipNoIndexScrubParity: 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") diff --git a/tests/python/test_e2e_no_torch_sandbox.py b/tests/python/test_e2e_no_torch_sandbox.py index 3e46f4145e..5cc1995ecc 100644 --- a/tests/python/test_e2e_no_torch_sandbox.py +++ b/tests/python/test_e2e_no_torch_sandbox.py @@ -910,11 +910,14 @@ class TestInstallPythonStackFiltering: ): assert ips._infer_no_torch() is False - # Unset on Intel Mac -> True (platform fallback) + # Unset on Intel Mac -> True (platform fallback). Pin the manifest tier to + # "unknown" first, or this reads the manifest of whatever venv pytest runs + # in and the result depends on the developer's machine. env = os.environ.copy() env.pop("UNSLOTH_NO_TORCH", None) with ( mock.patch.dict(os.environ, env, clear = True), + mock.patch.object(ips.install_manifest, "recorded_no_torch", lambda *a, **k: None), mock.patch.object(ips, "IS_MAC_INTEL", True), ): assert ips._infer_no_torch() is True diff --git a/tests/python/test_no_torch_filtering.py b/tests/python/test_no_torch_filtering.py index 732c1b7432..f4e093c94a 100644 --- a/tests/python/test_no_torch_filtering.py +++ b/tests/python/test_no_torch_filtering.py @@ -280,8 +280,21 @@ class TestRealRequirementsFiltering: class TestNoTorchConstant: """Verify NO_TORCH is derived correctly from UNSLOTH_NO_TORCH env var.""" + @staticmethod + def _no_manifest(): + """Pin the manifest tier to "unknown". + + Without this the env-unset cases below read the manifest of whatever venv + pytest happens to run in, so the result would depend on the developer's + machine rather than on the code under test. + """ + return mock.patch.object( + ips.install_manifest, "recorded_no_torch", lambda *args, **kwargs: None + ) + def _reimport_no_torch(self) -> bool: - return os.environ.get("UNSLOTH_NO_TORCH", "false").lower() in ("1", "true") + with self._no_manifest(): + return ips._infer_no_torch() def test_true_lowercase(self): with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "true"}): @@ -315,6 +328,7 @@ class TestNoTorchConstant: env.pop("UNSLOTH_NO_TORCH", None) with ( mock.patch.dict(os.environ, env, clear = True), + self._no_manifest(), mock.patch.object(ips, "IS_MAC_INTEL", True), ): assert ips._infer_no_torch() is True @@ -333,10 +347,52 @@ class TestNoTorchConstant: env.pop("UNSLOTH_NO_TORCH", None) with ( mock.patch.dict(os.environ, env, clear = True), + self._no_manifest(), mock.patch.object(ips, "IS_MAC_INTEL", False), ): assert ips._infer_no_torch() is False + @pytest.mark.parametrize("value", ("1", "true", "TRUE", "yes", "YES", "on", "ON", " true ")) + def test_infer_no_torch_accepts_every_installer_spelling(self, value: str): + """install.ps1 / install.sh accept 1|true|yes|on; this must agree.""" + with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": value}): + assert ips._infer_no_torch() is True + + @pytest.mark.parametrize("recorded", (True, False)) + def test_infer_no_torch_reads_the_manifest_when_env_is_unset(self, recorded: bool): + """`unsloth studio update` injects no env var, so the venv must remember. + + Without this an update reinstalls torch into a GGUF-only venv, and on + Windows reads the missing torch as a stale venv it then fails to delete. + """ + env = os.environ.copy() + env.pop("UNSLOTH_NO_TORCH", None) + with ( + mock.patch.dict(os.environ, env, clear = True), + mock.patch.object(ips.install_manifest, "recorded_no_torch", lambda *a, **k: recorded), + mock.patch.object(ips, "IS_MAC_INTEL", False), + ): + assert ips._infer_no_torch() is recorded + + @pytest.mark.parametrize("value", ("true", "false")) + def test_infer_no_torch_env_var_beats_the_manifest(self, value: str): + """An explicit value wins in both directions, so migrating either way works.""" + with ( + mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": value}), + mock.patch.object( + ips.install_manifest, "recorded_no_torch", lambda *a, **k: value != "true" + ), + ): + assert ips._infer_no_torch() is (value == "true") + + def test_infer_no_torch_treats_empty_as_unset(self): + """PowerShell deletes a variable assigned "", so it cannot mean "explicit".""" + with ( + mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": ""}), + mock.patch.object(ips.install_manifest, "recorded_no_torch", lambda *a, **k: True), + ): + assert ips._infer_no_torch() is True + # ── IS_MACOS constant tests ────────────────────────────────────────── diff --git a/tests/python/test_windows_no_torch_setup.py b/tests/python/test_windows_no_torch_setup.py new file mode 100644 index 0000000000..d16e52d16b --- /dev/null +++ b/tests/python/test_windows_no_torch_setup.py @@ -0,0 +1,171 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""Regression tests for the native Windows setup path honouring --no-torch.""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SETUP_PS1 = REPO_ROOT / "studio" / "setup.ps1" + + +def _powershell_block(source: str, marker: str) -> str: + assert marker in source, f"PowerShell marker not found: {marker!r}" + start = source.index(marker) + brace = source.index("{", start) + depth = 0 + for index in range(brace, len(source)): + char = source[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[start : index + 1] + raise AssertionError(f"Unclosed PowerShell block after {marker!r}") + + +def test_windows_direct_torch_installs_are_skipped_in_no_torch_mode(): + source = SETUP_PS1.read_text(encoding = "utf-8") + guarded = _powershell_block(source, "if (-not $NoTorchMode) {") + + for install_path in ( + "installing PyTorch (AMD ROCm", + "installing PyTorch (CPU-only)", + "installing PyTorch with CUDA support", + "installing Triton for Windows", + ): + assert install_path in guarded + + # The shared dependency pass installs the dedicated no-torch runtime and + # therefore must remain outside the direct torch/Triton guard. + assert 'python "$PSScriptRoot\\install_python_stack.py"' not in guarded + + +def test_no_torch_value_is_normalized_before_shared_dependency_install(): + source = SETUP_PS1.read_text(encoding = "utf-8") + parsed = source.index( + "$NoTorchMode = $env:UNSLOTH_NO_TORCH -match '^\\s*(?i:true|1|yes|on)\\s*$'" + ) + normalized = source.index( + '$env:UNSLOTH_NO_TORCH = if ($NoTorchMode) { "true" } else { "false" }' + ) + stack_install = source.index('python "$PSScriptRoot\\install_python_stack.py"') + + assert parsed < normalized < stack_install + + +def _extract(pattern: str, source: str) -> str: + match = re.search(pattern, source, flags = re.DOTALL) + assert match is not None, f"setup.ps1 block not found: {pattern}" + return match.group(0) + + +def _no_torch_resolution_script() -> str: + """Get-PersistedNoTorch plus the $NoTorchMode resolution, verbatim. + + Extracted rather than reimplemented so the test cannot drift away from the + production text the way a hand-copied predicate would. + """ + source = SETUP_PS1.read_text(encoding = "utf-8") + getter = _extract(r"function Get-PersistedNoTorch \{.*?\n\}\n", source) + setter = _extract(r"function Set-PersistedNoTorch \{.*?\n\}\n", source) + marker = _extract(r'\$NoTorchMarker = "[^"]+"', source) + resolution = _extract( + r"\$NoTorchMode = \$env:UNSLOTH_NO_TORCH -match .*?" + r'\$env:UNSLOTH_NO_TORCH = if \(\$NoTorchMode\) \{ "true" \} else \{ "false" \}', + source, + ) + # substep is defined ~1600 lines earlier; the resolution only uses it to log. + return ( + "function substep { param($a, $b) }\n" + f"{marker}\n{getter}\n{setter}\n{resolution}\n" + 'Write-Output "$NoTorchMode|$env:UNSLOTH_NO_TORCH"' + ) + + +@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable") +@pytest.mark.parametrize( + ("env_value", "manifest", "marker", "expected"), + [ + # The completion manifest is dropped before every dependency pass, so an + # install killed mid-pass leaves only the marker. Without it that venv is + # read as stale and the next update tries to delete itself. + (None, None, True, "True|true"), + (None, {}, True, "True|true"), + # An explicit no_torch key still wins, so migrating out of no-torch is not + # blocked by a marker an earlier run left behind. + (None, {"no_torch": False}, True, "False|false"), + (None, {"no_torch": True}, False, "True|true"), + ] + + [ + (env_value, manifest, False, expected) + for env_value, manifest, expected in [ + # `unsloth studio update` exports nothing, so the manifest decides. This is + # the case that made a GGUF-only venv look stale and get deleted. + (None, {"no_torch": True}, "True|true"), + (None, {"no_torch": False}, "False|false"), + # Manifests written before the key existed, and unreadable ones, keep the + # pre-existing behaviour rather than switching an install to no-torch. + (None, {}, "False|false"), + (None, None, "False|false"), + (None, "{not json", "False|false"), + # An explicit env var always wins over the recorded mode, in both + # directions, so `install.ps1 --no-torch` and a later migration out of + # no-torch both work regardless of what the venv used to be. + ("false", {"no_torch": True}, "False|false"), + ("1", {"no_torch": False}, "True|true"), + # Every spelling install.ps1 / install.sh accept collapses to one value. + ("true", None, "True|true"), + ("yes", None, "True|true"), + ("ON", None, "True|true"), + (" true ", None, "True|true"), + ("0", None, "False|false"), + ("", {"no_torch": True}, "True|true"), + ] + ], +) +def test_no_torch_mode_survives_a_studio_update(tmp_path, env_value, manifest, marker, expected): + venv_dir = tmp_path / "unsloth_studio" + venv_dir.mkdir() + if manifest is not None: + payload = manifest if isinstance(manifest, str) else json.dumps(manifest) + (venv_dir / "unsloth_install_manifest.json").write_text(payload, encoding = "utf-8") + if marker: + (venv_dir / ".unsloth-no-torch").write_text("", encoding = "utf-8") + + env = os.environ.copy() + env.pop("UNSLOTH_NO_TORCH", None) + if env_value is not None: + env["UNSLOTH_NO_TORCH"] = env_value + + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + f'$VenvDir = "{venv_dir.as_posix()}"\n{_no_torch_resolution_script()}', + ], + check = True, + capture_output = True, + text = True, + env = env, + ) + # The exported value matters as much as $NoTorchMode: install_python_stack.py + # drops the manifest before it runs, so the env var is all it has to go on. + assert result.stdout.strip() == expected + + # The resolution also persists what it decided, so the next run survives an + # install killed between here and the manifest being rewritten. + assert (venv_dir / ".unsloth-no-torch").exists() is expected.startswith("True") diff --git a/tests/studio/install/test_install_manifest.py b/tests/studio/install/test_install_manifest.py index 79b2c1db50..4313315b2d 100644 --- a/tests/studio/install/test_install_manifest.py +++ b/tests/studio/install/test_install_manifest.py @@ -201,3 +201,80 @@ def test_unwritable_root_degrades_to_incomplete(tmp_path, req_root): assert im.write_manifest(root = missing_root, req_root = req_root) is None state = im.verify_install(root = missing_root, req_root = req_root, package_name = "pytest") assert state["ok"] is False + + +def test_no_torch_mode_round_trips_through_the_manifest(install_root, req_root): + # `unsloth studio update` injects no UNSLOTH_NO_TORCH, so the venv has to + # remember how it was built or the update reinstalls torch into a GGUF-only + # environment (and on Windows deletes the venv it is running out of). + for recorded in (True, False): + im.write_manifest( + root = install_root, + req_root = req_root, + package_name = "pytest", + no_torch = recorded, + ) + assert im.recorded_no_torch(root = install_root) is recorded + assert ( + json.loads((install_root / im.MANIFEST_NAME).read_text(encoding = "utf-8"))["no_torch"] + is recorded + ) + + +def test_manifest_without_the_no_torch_key_reads_as_unknown(install_root, req_root): + # Manifests written before the key existed must keep verifying, and must + # report None rather than False so callers fall back to their own detection + # instead of silently switching an install out of no-torch mode. + im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest") + payload = json.loads((install_root / im.MANIFEST_NAME).read_text(encoding = "utf-8")) + assert "no_torch" not in payload + + assert im.recorded_no_torch(root = install_root) is None + state = im.verify_install(root = install_root, req_root = req_root, package_name = "pytest") + assert state["manifest_ok"] is True + + +def test_recorded_no_torch_tolerates_a_hand_edited_manifest(install_root, req_root): + im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest") + path = install_root / im.MANIFEST_NAME + payload = json.loads(path.read_text(encoding = "utf-8")) + + for value, expected in (("true", True), ("ON", True), ("0", False), (123, None)): + payload["no_torch"] = value + path.write_text(json.dumps(payload), encoding = "utf-8") + assert im.recorded_no_torch(root = install_root) is expected + + +def test_recorded_no_torch_reports_unknown_without_a_manifest(install_root): + assert im.recorded_no_torch(root = install_root) is None + + +def test_marker_preserves_no_torch_across_the_manifest_drop(install_root, req_root): + # remove_manifest() runs before every dependency pass, so a run killed during + # it leaves no manifest. The marker is what stops the next update reading the + # absent torch as a stale venv and deleting the environment it runs out of. + im.set_no_torch_marker(True, root = install_root) + im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest", no_torch = True) + assert im.recorded_no_torch(root = install_root) is True + + im.remove_manifest(root = install_root) + assert im.recorded_no_torch(root = install_root) is True + + +def test_manifest_key_overrides_a_stale_marker(install_root, req_root): + # Migrating out of no-torch must not be blocked by a marker left behind. + im.set_no_torch_marker(True, root = install_root) + im.write_manifest(root = install_root, req_root = req_root, package_name = "pytest", no_torch = False) + assert im.recorded_no_torch(root = install_root) is False + + +def test_set_no_torch_marker_clears_itself_and_never_raises(install_root): + im.set_no_torch_marker(True, root = install_root) + assert im.no_torch_marker_path(root = install_root).exists() + + im.set_no_torch_marker(False, root = install_root) + assert not im.no_torch_marker_path(root = install_root).exists() + assert im.recorded_no_torch(root = install_root) is None + + # Absent directory: must degrade quietly, it runs mid-install. + im.set_no_torch_marker(True, root = install_root / "does" / "not" / "exist")