Fix Windows no-torch setup (#7511)

* Fix Windows no-torch setup

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix no-torch env normalization on Windows

* Accept on for Windows no-torch mode

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Keep no-torch mode across studio update on Windows

Guarding the direct torch/Triton install made `install.ps1 --no-torch`
actually produce a torch-free venv, which then broke the next
`unsloth studio update`. That path exports no UNSLOTH_NO_TORCH, so
$NoTorchMode was false, the stale-venv check read the missing torch as a
broken venv, and setup tried to delete the venv it was running out of:

  [ERROR] Could not remove stale venv: Access to the path 'python.exe' is denied.

That teardown can never succeed there, because setup.ps1 runs via
unsloth.exe out of that same venv. The same gap also let the shared
dependency pass reinstall torch from PyPI, unpinned, into a GGUF-only
environment.

install_python_stack.py now records the mode in the install manifest and
setup.ps1 reads it back when no env var is exported, then re-exports a
canonical value for the dependency pass (setup.ps1 drops the manifest
before invoking it, so the child cannot repeat the lookup). The key is
additive and MANIFEST_SCHEMA is unchanged, so existing manifests stay
valid and a missing key keeps today's behaviour.

Also:
- read_manifest() caught only OSError, but UnicodeDecodeError is a
  ValueError. That is now on the installer's import path, so a manifest
  re-saved as ANSI or truncated mid-write would abort every install.
- The env predicate now trims surrounding whitespace, matching the
  Python side.
- The Windows update smoke workflow asserts the update leaves the venv
  GGUF-only, which is what would have caught this.

Known follow-up, pre-existing: an install killed between the manifest
drop and the dependency pass leaves no recorded mode, so a later update
still walks the stale-venv path. Closing that needs a marker the
installer never drops.

* Persist no-torch mode in a marker the dependency pass cannot drop

The install manifest alone was not enough. Both setup.ps1 and
install_python_stack.py remove it before every dependency pass, and it is
only rewritten on success, so a no-torch install interrupted in between
left nothing recording the mode. The next update then resolved no-torch
as false, read the expected missing torch as a stale venv, and tried to
delete the environment whose python.exe was running it, which leaves the
install unrepairable from the CLI.

Add .unsloth-no-torch next to the existing .unsloth-studio-owned marker,
written before the pass and cleared when torch is wanted. setup.ps1
writes it as soon as the mode resolves, so the window between the
manifest drop and its own torch install is covered too.

Read order stays manifest key first, then marker, so migrating out of
no-torch is never blocked by a marker an earlier run left behind. Neither
present still reads as "install torch", so nothing changes for installs
made before either existed.

Also adds the AGPL-3.0 header the new test file was missing.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
This commit is contained in:
Lee Jackson 2026-07-28 13:54:25 +01:00 committed by GitHub
commit d7594ec10f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 540 additions and 9 deletions

View file

@ -198,6 +198,31 @@ jobs:
fi fi
echo "update path took the prebuilt fast path" 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 - name: Second update must also be a no-op
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -30,6 +30,16 @@ from typing import Dict, List, Optional, Tuple
MANIFEST_NAME = "unsloth_install_manifest.json" MANIFEST_NAME = "unsloth_install_manifest.json"
MANIFEST_SCHEMA = 1 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/. # Fingerprinted into the manifest, relative to studio/backend/requirements/.
# Editing one (a --local install) invalidates it and forces a dependency pass. # Editing one (a --local install) invalidates it and forces a dependency pass.
TRACKED_REQUIREMENT_FILES: Tuple[str, ...] = ( TRACKED_REQUIREMENT_FILES: Tuple[str, ...] = (
@ -116,6 +126,7 @@ def write_manifest(
req_root: Optional[Path] = None, req_root: Optional[Path] = None,
steps_total: int = 0, steps_total: int = 0,
package_name: str = "unsloth", package_name: str = "unsloth",
no_torch: Optional[bool] = None,
) -> Optional[Path]: ) -> Optional[Path]:
"""Record a completed install. Never raises: no manifest reads as incomplete, """Record a completed install. Never raises: no manifest reads as incomplete,
which is the safe answer.""" which is the safe answer."""
@ -130,6 +141,14 @@ def write_manifest(
"steps_total": steps_total, "steps_total": steps_total,
"requirement_files": requirement_digests(req_root), "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) path = manifest_path(root)
try: try:
tmp = path.with_suffix(".json.tmp") tmp = path.with_suffix(".json.tmp")
@ -143,7 +162,12 @@ def write_manifest(
def read_manifest(root: Optional[Path] = None) -> Optional[dict]: def read_manifest(root: Optional[Path] = None) -> Optional[dict]:
try: try:
raw = manifest_path(root).read_text(encoding = "utf-8") 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 return None
try: try:
data = json.loads(raw) 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 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]]: def _parse_requirement_line(line: str) -> Optional[Tuple[str, str, str]]:
"""(distribution name, marker, specifier) for a requirement, or None. """(distribution name, marker, specifier) for a requirement, or None.

View file

@ -2215,13 +2215,28 @@ def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
def _infer_no_torch() -> bool: def _infer_no_torch() -> bool:
"""Determine whether to run in no-torch (GGUF-only) mode. """Determine whether to run in no-torch (GGUF-only) mode.
Checks UNSLOTH_NO_TORCH first. When unset, falls back to platform Precedence: UNSLOTH_NO_TORCH (install.sh / install.ps1 export it, "false"
detection so Intel Macs use GGUF-only mode even when invoked from included, so an explicit value always wins) -> the mode recorded in this
``unsloth studio update`` (which does not inject the env var). 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") env = os.environ.get("UNSLOTH_NO_TORCH")
if env is not None: if env is not None and env.strip():
return env.strip().lower() in ("1", "true") 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 return IS_MAC_INTEL
@ -2871,6 +2886,11 @@ def install_python_stack() -> int:
) )
return 1 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 # 1. Try uv for faster installs (before pip upgrade -- uv venvs don't
# include pip by default). # include pip by default).
USE_UV = _bootstrap_uv() USE_UV = _bootstrap_uv()
@ -3256,6 +3276,7 @@ def install_python_stack() -> int:
req_root = REQ_ROOT, req_root = REQ_ROOT,
steps_total = _TOTAL, steps_total = _TOTAL,
package_name = package_name, package_name = package_name,
no_torch = NO_TORCH,
) )
is None is None
): ):

View file

@ -2661,6 +2661,8 @@ $VenvDir = Join-Path $StudioHome "unsloth_studio"
# the canonical comparison so an override pointing at the legacy default # the canonical comparison so an override pointing at the legacy default
# still behaves like a default install. # still behaves like a default install.
$StudioOwnedMarker = ".unsloth-studio-owned" $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" $LegacyStudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
$_studioHomeCanon = $StudioHome $_studioHomeCanon = $StudioHome
if (Test-Path -LiteralPath $_studioHomeCanon -PathType Container) { if (Test-Path -LiteralPath $_studioHomeCanon -PathType Container) {
@ -2704,13 +2706,71 @@ function Mark-StudioOwned {
} catch {} } 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 # Stale-venv detection: if the venv exists but its torch flavor no longer
# matches the current machine, repair according to invocation context. # matches the current machine, repair according to invocation context.
# - install.ps1 sets UNSLOTH_INSTALL_ROLLBACK_MANAGED=1 so setup can delegate # - install.ps1 sets UNSLOTH_INSTALL_ROLLBACK_MANAGED=1 so setup can delegate
# to the installer-level rollback that restores the previous environment. # to the installer-level rollback that restores the previous environment.
# - direct `unsloth studio update` keeps the pre-existing self-repair behavior. # - direct `unsloth studio update` keeps the pre-existing self-repair behavior.
# In no-torch mode, a missing torch package is expected. # 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)$' $InstallerManagedSetup = $env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -match '^(?i:true|1|yes)$'
if ((Test-Path -LiteralPath $VenvDir -PathType Container) -and -not $NoTorchMode) { if ((Test-Path -LiteralPath $VenvDir -PathType Container) -and -not $NoTorchMode) {
$VenvPyExe = Join-Path $VenvDir "Scripts\python.exe" $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. # 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" } $TorchInstallIndexUrl = if ($ROCmIndexUrl) { "$PyTorchWhlBase/cpu" } elseif ($PinnedTorchIndexUrl) { $PinnedTorchIndexUrl } else { "$PyTorchWhlBase/$CuTag" }
if (-not $NoTorchMode) {
$ROCmCpuFallback = $false $ROCmCpuFallback = $false
if ($ROCmIndexUrl) { if ($ROCmIndexUrl) {
substep "installing PyTorch (AMD ROCm, $ROCmGfxArch)..." 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)" 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 # 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 # running launcher only ever failed (WinError 32) and printed a scary warning. It's

View file

@ -818,3 +818,47 @@ class TestPipNoIndexScrubParity:
text = SETUP_PS1.read_text(encoding = "utf-8") text = SETUP_PS1.read_text(encoding = "utf-8")
assert "'PIP_NO_INDEX'" in text assert "'PIP_NO_INDEX'" in text
assert "'PIP_INDEX_URL'" 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")

View file

@ -910,11 +910,14 @@ class TestInstallPythonStackFiltering:
): ):
assert ips._infer_no_torch() is False 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 = os.environ.copy()
env.pop("UNSLOTH_NO_TORCH", None) env.pop("UNSLOTH_NO_TORCH", None)
with ( with (
mock.patch.dict(os.environ, env, clear = True), 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), mock.patch.object(ips, "IS_MAC_INTEL", True),
): ):
assert ips._infer_no_torch() is True assert ips._infer_no_torch() is True

View file

@ -280,8 +280,21 @@ class TestRealRequirementsFiltering:
class TestNoTorchConstant: class TestNoTorchConstant:
"""Verify NO_TORCH is derived correctly from UNSLOTH_NO_TORCH env var.""" """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: 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): def test_true_lowercase(self):
with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "true"}): with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "true"}):
@ -315,6 +328,7 @@ class TestNoTorchConstant:
env.pop("UNSLOTH_NO_TORCH", None) env.pop("UNSLOTH_NO_TORCH", None)
with ( with (
mock.patch.dict(os.environ, env, clear = True), mock.patch.dict(os.environ, env, clear = True),
self._no_manifest(),
mock.patch.object(ips, "IS_MAC_INTEL", True), mock.patch.object(ips, "IS_MAC_INTEL", True),
): ):
assert ips._infer_no_torch() is True assert ips._infer_no_torch() is True
@ -333,10 +347,52 @@ class TestNoTorchConstant:
env.pop("UNSLOTH_NO_TORCH", None) env.pop("UNSLOTH_NO_TORCH", None)
with ( with (
mock.patch.dict(os.environ, env, clear = True), mock.patch.dict(os.environ, env, clear = True),
self._no_manifest(),
mock.patch.object(ips, "IS_MAC_INTEL", False), mock.patch.object(ips, "IS_MAC_INTEL", False),
): ):
assert ips._infer_no_torch() is 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 ────────────────────────────────────────── # ── IS_MACOS constant tests ──────────────────────────────────────────

View file

@ -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")

View file

@ -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 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") state = im.verify_install(root = missing_root, req_root = req_root, package_name = "pytest")
assert state["ok"] is False 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")