mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-21 23:03:58 +00:00
* Ask uv for a Python that can import torch, and skip the one that cannot Fixes #7803. CPython 3.13.8 carries python/cpython#139783: inspect.getsourcelines() drops a function body when a decorator is followed by a comment. That is the shape of the @_overload_method blocks in torch 2.11's nn/modules/rnn.py, which are parsed at import time, so `import torch` dies with IndentationError. 3.13.9 was an expedited release carrying only that fix. install.sh asked uv for a bare "3.13" and let it choose the patch. Measured with uv 0.9.2 and only 3.13.8 present: --python 3.13 -> 3.13.8 then import torch: IndentationError --python >=3.13.9,<3.14 -> 3.13.12 then import torch: 2.11.0+cpu OK So the request is the fix. PYTHON_SKIP names the releases that cannot run the stack and _python_request turns a bare 3.13 into the range; a venv left on a skipped interpreter by an earlier run is recreated, on any platform, which the previous check could not do because it was gated on macOS arm64. UV_MIN_VERSION also moves to 0.9.3, the first uv whose bundled manifest carries 3.13.9. That is belt-and-braces rather than the fix, since the range resolves on 0.9.2 too. Raising it pulls every 0.8.16-0.9.2 host into the refresh block, so an existing uv in that range is no longer fatal when the network is unreachable. Windows reaches such an interpreter differently: uv is handed a resolved path, never a version, so it cannot pick the patch, but Find-CompatiblePython matches on the minor version and would return an already-installed 3.13.8. Remove-SkippedPython turns that into "not found" so the caller installs $PythonFallbackFullVersion (3.13.13). The uv floor is left alone there, since the uv-managed Python path is not taken on Windows. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Screen the skipped patch inside the resolver, and never delete the only venv Windows: Find-CompatiblePython matched on the minor, so the screen sat on its result and every other caller of the resolver -- the two install helpers among them -- could still be handed the interpreter the first call had just rejected. Nulling the result also ended the search, so a host with 3.13.8 and a healthy 3.12 failed instead of using the 3.12. The patch is already in the string the minor comes from, so screening during enumeration costs no extra subprocess and keeps the fallback ladder. install.sh: the legacy-layout migration moves the old environment into $VENV_DIR without arming the rollback, so a plain rm -rf before a recreate that then fails leaves the machine with nothing. Move it aside through the existing rollback machinery instead. Ask uv for the series minus the skipped patches rather than for a floor above them: an offline host, or a uv whose manifest predates 3.13.9, can still have a good cached 3.13.7 that a floor would refuse. Measured with uv 0.10.7, only 3.13.7 and 3.13.8 installed, --offline: "3.13" gives 3.13.8, ">=3.13.9,<3.14" errors, ">=3.13,<3.14,!=3.13.8" gives 3.13.7. Hoist the install.ps1 extractions out of the f-strings: a backslash in an f-string expression is a syntax error before 3.12 and the repo is 3.9+. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Run the resolver driver only where its fake launcher can execute The launcher is a /bin/sh script, and Windows has neither a shebang nor a PATHEXT entry for an extensionless file, so Get-Command never finds it and Find-CompatiblePython reports "none" whatever versions the tree holds. Two cases failed on a real windows-latest runner and the third passed for that reason rather than on merit. Skip the three on Windows, where the rest of the file still covers the screen, and pair the negative case with a positive control so a harness that cannot run the launcher fails instead of quietly agreeing. * Do not screen the interpreter for an install that never imports torch Every entry in the skip list is there for one reason: it cannot import torch. A --no-torch/-NoTorch install never does, so refusing the machine's only 3.13 would send a locked-down GGUF-only host into a download it may not be able to complete, over a package it will not install. Also turn away anything that is not a plain X.Y before the arithmetic: a relative --python path like 3.13/bin/python survived the globs, and dash aborts the whole install with "Illegal number" rather than reaching uv. And keep the uv version probe alive on an image with no awk, which is precisely the host the offline exception around it exists to protect -- the pipeline exits 127 there and set -e was killing the install before the code could treat the version as unreadable. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
333 lines
12 KiB
Python
333 lines
12 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
"""Windows must not build the venv on a CPython that cannot import torch.
|
|
|
|
CPython 3.13.8 carries python/cpython#139783: inspect.getsourcelines() drops a
|
|
function body when a decorator is followed by a comment, which is the shape of
|
|
the @_overload_method blocks torch/nn/modules/rnn.py parses at import time, so
|
|
`import torch` raises IndentationError (#7803).
|
|
|
|
Windows reaches such an interpreter differently from install.sh: uv is handed a
|
|
resolved path rather than a version, so it never picks the patch itself, but
|
|
Find-CompatiblePython matches on the *minor* version and would happily return an
|
|
already-installed 3.13.8. Remove-SkippedPython is what turns that into "not
|
|
found", so the caller installs $PythonFallbackFullVersion instead.
|
|
|
|
The function is extracted from install.ps1 and executed under pwsh rather than
|
|
reimplemented, so the test cannot drift from the text the installer runs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
INSTALL_PS1 = REPO_ROOT / "install.ps1"
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
shutil.which("pwsh") is None, reason = "pwsh is required to execute install.ps1 blocks"
|
|
)
|
|
|
|
|
|
def _extract(pattern: str) -> str:
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
match = re.search(pattern, source, flags = re.DOTALL)
|
|
assert match is not None, f"install.ps1 no longer contains {pattern!r}"
|
|
return match.group(0)
|
|
|
|
|
|
def _blocks() -> tuple:
|
|
"""The skip list and the screen, straight out of install.ps1.
|
|
|
|
Hoisted out of the f-strings below: a backslash inside an f-string expression
|
|
is a syntax error before 3.12, and this repo is 3.9+ (ruff targets py311).
|
|
"""
|
|
return (
|
|
_extract(
|
|
r" # Patch releases the stack cannot run.*?"
|
|
r"if \(\$SkipTorch\) \{ \$PythonSkip = @\(\) \}"
|
|
),
|
|
_extract(r" function Remove-SkippedPython \{.*?\n \}"),
|
|
)
|
|
|
|
|
|
def _fake_python(tmp_path: Path, version: str) -> Path:
|
|
"""An executable that reports ``version`` for the resolver's probe."""
|
|
if os.name == "nt":
|
|
exe = tmp_path / "python.cmd"
|
|
exe.write_text(f"@echo off\r\necho {version}\r\n", encoding = "utf-8")
|
|
return exe
|
|
exe = tmp_path / "python"
|
|
exe.write_text(f'#!/bin/sh\necho "{version}"\n', encoding = "utf-8")
|
|
exe.chmod(exe.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
return exe
|
|
|
|
|
|
def _run(tmp_path: Path, version: str | None) -> str:
|
|
candidate = (
|
|
"$null"
|
|
if version is None
|
|
else f'@{{ Version = "3.13"; Path = "{_fake_python(tmp_path, version)}" }}'
|
|
)
|
|
skip_block, screen_block = _blocks()
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
$SkipTorch = $false
|
|
# Write-Host, like the real substep: Write-Output would put the message on
|
|
# the pipeline, so the function would return @(message, $null) and every
|
|
# `if ($DetectedPython)` downstream would read it as truthy.
|
|
function substep {{ param($m, $c) Write-Host "SUBSTEP: $m" }}
|
|
{skip_block}
|
|
{screen_block}
|
|
$result = Remove-SkippedPython ({candidate})
|
|
if ($null -eq $result) {{ Write-Output "RESULT: rejected" }}
|
|
else {{ Write-Output "RESULT: kept" }}
|
|
"""
|
|
completed = subprocess.run(
|
|
["pwsh", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
capture_output = True,
|
|
text = True,
|
|
)
|
|
return completed.stdout + completed.stderr
|
|
|
|
|
|
def test_a_skipped_patch_is_rejected(tmp_path):
|
|
out = _run(tmp_path, "3.13.8")
|
|
assert "RESULT: rejected" in out, out
|
|
assert "cannot import torch" in out, "the user should be told why: " + out
|
|
|
|
|
|
def test_a_good_patch_of_the_same_minor_is_kept(tmp_path):
|
|
# The screen is per patch: 3.13 itself is fine and must not be refused.
|
|
out = _run(tmp_path, "3.13.13")
|
|
assert "RESULT: kept" in out, out
|
|
|
|
|
|
def test_nothing_found_stays_nothing(tmp_path):
|
|
out = _run(tmp_path, None)
|
|
assert "RESULT: rejected" in out, out
|
|
|
|
|
|
def test_an_unreadable_interpreter_is_not_treated_as_bad(tmp_path):
|
|
# A probe that cannot run is not evidence of a bad version, and refusing it
|
|
# would send a working machine down the install path for no reason.
|
|
missing = tmp_path / "does-not-exist"
|
|
skip_block, screen_block = _blocks()
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
$SkipTorch = $false
|
|
# Write-Host, like the real substep: Write-Output would put the message on
|
|
# the pipeline, so the function would return @(message, $null) and every
|
|
# `if ($DetectedPython)` downstream would read it as truthy.
|
|
function substep {{ param($m, $c) Write-Host "SUBSTEP: $m" }}
|
|
{skip_block}
|
|
{screen_block}
|
|
$result = Remove-SkippedPython (@{{ Version = "3.13"; Path = "{missing}" }})
|
|
if ($null -eq $result) {{ Write-Output "RESULT: rejected" }}
|
|
else {{ Write-Output "RESULT: kept" }}
|
|
"""
|
|
completed = subprocess.run(
|
|
["pwsh", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
capture_output = True,
|
|
text = True,
|
|
)
|
|
assert "RESULT: kept" in completed.stdout + completed.stderr, (
|
|
completed.stdout + completed.stderr
|
|
)
|
|
|
|
|
|
def test_the_resolver_is_screened_at_every_entry_point():
|
|
"""A bare Find-CompatiblePython in the install flow would defeat the screen."""
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
flow = source[source.index("# ── Install Python if no compatible version") :]
|
|
flow = flow[: flow.index("# ── Install uv ──")]
|
|
bare = [
|
|
line.strip()
|
|
for line in flow.splitlines()
|
|
if "= Find-CompatiblePython" in line and "Remove-SkippedPython" not in line
|
|
]
|
|
assert not bare, f"unscreened resolver calls in the install flow: {bare}"
|
|
|
|
|
|
# ── The screen inside the resolver ──
|
|
# The window above starts at the install step, so it never sees the recovery
|
|
# paths: Install-PythonFromPythonOrg and Install-X64Python both end in a bare
|
|
# `return (Find-CompatiblePython)`. Screening every candidate as it is
|
|
# enumerated is what makes those safe, and is also what lets the resolver carry
|
|
# on to its next minor instead of giving up on the machine.
|
|
|
|
|
|
def _every_version_match_screens_the_patch() -> list[str]:
|
|
body = _extract(r" function Find-CompatiblePython \{.*?\n \}")
|
|
lines = body.splitlines()
|
|
unscreened = []
|
|
for i, line in enumerate(lines):
|
|
if 'match "Python' not in line:
|
|
continue
|
|
window = "\n".join(lines[i : i + 9])
|
|
if "$PythonSkip -contains" not in window:
|
|
unscreened.append(line.strip())
|
|
return unscreened
|
|
|
|
|
|
def test_every_enumerated_candidate_is_screened():
|
|
assert (
|
|
len(
|
|
[
|
|
l
|
|
for l in _extract(r" function Find-CompatiblePython \{.*?\n \}").splitlines()
|
|
if 'match "Python' in l
|
|
]
|
|
)
|
|
== 3
|
|
), "the resolver's enumeration sites moved; re-check the screen"
|
|
assert not _every_version_match_screens_the_patch(), (
|
|
"Find-CompatiblePython enumerates a candidate without checking $PythonSkip: "
|
|
f"{_every_version_match_screens_the_patch()}"
|
|
)
|
|
|
|
|
|
# The launcher below is a /bin/sh script. Windows has no shebang and no PATHEXT
|
|
# entry for an extensionless file, so `Get-Command py` does not find it and the
|
|
# resolver reports "none" whatever the versions are -- which would make the
|
|
# negative case pass for the wrong reason. The PowerShell under test is the same
|
|
# text on every platform, and pwsh runs it here, so these three cases run on
|
|
# POSIX and the rest of the file still covers Windows.
|
|
_POSIX_LAUNCHER_ONLY = pytest.mark.skipif(
|
|
os.name == "nt", reason = "the fake py launcher is a /bin/sh script"
|
|
)
|
|
|
|
|
|
def _fake_launcher(root: Path, versions: dict[str, str]) -> Path:
|
|
"""A `py` launcher over fake interpreters, one per minor in ``versions``."""
|
|
root.mkdir(parents = True, exist_ok = True)
|
|
branches = []
|
|
for minor, full in versions.items():
|
|
exe = root / f"python{minor.replace('.', '')}"
|
|
# -S -c "import sys; print(sys.base_prefix)" for the conda screen.
|
|
exe.write_text('#!/bin/sh\necho "/usr"\n', encoding = "utf-8")
|
|
exe.chmod(0o755)
|
|
branches.append(f' {minor}) ver="{full}"; exe="{exe}" ;;')
|
|
launcher = root / "py"
|
|
launcher.write_text(
|
|
"#!/bin/sh\n"
|
|
'case "$1" in\n'
|
|
+ "\n".join(f" -{b.lstrip()}" for b in branches)
|
|
+ "\n *) exit 1 ;;\nesac\n"
|
|
"shift\n"
|
|
'case "$1" in\n'
|
|
' --version) echo "Python $ver" ;;\n'
|
|
' -S) echo "$exe" ;;\n'
|
|
" *) exit 1 ;;\n"
|
|
"esac\n",
|
|
encoding = "utf-8",
|
|
)
|
|
launcher.chmod(0o755)
|
|
return launcher
|
|
|
|
|
|
def _resolve(tmp_path: Path, versions: dict[str, str]) -> str:
|
|
"""Run the real Find-CompatiblePython over ``versions`` and report the hit."""
|
|
root = tmp_path / "bin"
|
|
_fake_launcher(root, versions)
|
|
skip_block, screen_block = _blocks()
|
|
# Hoisted for the same reason as _blocks: a backslash in an f-string
|
|
# expression does not parse before 3.12.
|
|
conda_block = _extract(r" function Test-IsCondaPython \{.*?\n \}")
|
|
tag_block = _extract(r" function Get-PythonPlatformTag \{.*?\n \}")
|
|
resolver_block = _extract(r" function Find-CompatiblePython \{.*?\n \}")
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
$SkipTorch = $false
|
|
$env:PATH = "{root}"
|
|
$PythonVersion = "3.13"
|
|
function substep {{ param($m, $c) Write-Host "SUBSTEP: $m" }}
|
|
function Get-HostMachineArch {{ return "x86_64" }}
|
|
{skip_block}
|
|
$script:CondaSkipPattern = '(?i)(conda|miniconda|anaconda|miniforge|mambaforge)'
|
|
{conda_block}
|
|
{tag_block}
|
|
{resolver_block}
|
|
$found = Find-CompatiblePython
|
|
if ($null -eq $found) {{ Write-Output "RESULT: none" }}
|
|
else {{ Write-Output "RESULT: $($found.Version)" }}
|
|
"""
|
|
completed = subprocess.run(
|
|
["pwsh", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
capture_output = True,
|
|
text = True,
|
|
)
|
|
out = completed.stdout + completed.stderr
|
|
match = re.search(r"RESULT: (\S+)", out)
|
|
assert match is not None, out
|
|
return match.group(1)
|
|
|
|
|
|
@_POSIX_LAUNCHER_ONLY
|
|
def test_the_resolver_falls_through_to_the_next_minor(tmp_path):
|
|
# The offline/locked-down case: 3.13.8 and a healthy 3.12 both installed and
|
|
# nothing installable. Ending the search on the 3.13 would leave the caller
|
|
# with a Python that cannot import torch; refusing it outright would fail a
|
|
# machine that has a perfectly good interpreter one entry down the list.
|
|
assert _resolve(tmp_path, {"3.13": "3.13.8", "3.12": "3.12.11"}) == "3.12"
|
|
|
|
|
|
@_POSIX_LAUNCHER_ONLY
|
|
def test_a_good_preferred_minor_still_wins(tmp_path):
|
|
assert _resolve(tmp_path, {"3.13": "3.13.13", "3.12": "3.12.11"}) == "3.13"
|
|
|
|
|
|
@_POSIX_LAUNCHER_ONLY
|
|
def test_nothing_usable_is_still_nothing(tmp_path):
|
|
# Paired with a positive control over the same tree, because "none" is also
|
|
# what a harness that cannot run the launcher at all reports: without the
|
|
# control this case would pass on a machine where it proves nothing.
|
|
assert _resolve(tmp_path / "good", {"3.13": "3.13.13"}) == "3.13"
|
|
assert _resolve(tmp_path / "bad", {"3.13": "3.13.8"}) == "none"
|
|
|
|
|
|
@_POSIX_LAUNCHER_ONLY
|
|
def test_no_torch_mode_keeps_the_skipped_patch(tmp_path):
|
|
"""The list is about `import torch`; -NoTorch never imports it.
|
|
|
|
A locked-down GGUF-only machine whose only Python is 3.13.8 would otherwise
|
|
be pushed into winget/python.org recovery it may not be able to complete.
|
|
"""
|
|
root = tmp_path / "bin"
|
|
_fake_launcher(root, {"3.13": "3.13.8"})
|
|
skip_block, screen_block = _blocks()
|
|
conda_block = _extract(r" function Test-IsCondaPython \{.*?\n \}")
|
|
tag_block = _extract(r" function Get-PythonPlatformTag \{.*?\n \}")
|
|
resolver_block = _extract(r" function Find-CompatiblePython \{.*?\n \}")
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
$SkipTorch = $true
|
|
$env:PATH = "{root}"
|
|
$PythonVersion = "3.13"
|
|
function substep {{ param($m, $c) Write-Host "SUBSTEP: $m" }}
|
|
function Get-HostMachineArch {{ return "x86_64" }}
|
|
{skip_block}
|
|
$script:CondaSkipPattern = '(?i)(conda|miniconda|anaconda|miniforge|mambaforge)'
|
|
{conda_block}
|
|
{tag_block}
|
|
{resolver_block}
|
|
$found = Find-CompatiblePython
|
|
if ($null -eq $found) {{ Write-Output "RESULT: none" }}
|
|
else {{ Write-Output "RESULT: $($found.Version)" }}
|
|
"""
|
|
completed = subprocess.run(
|
|
["pwsh", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
capture_output = True,
|
|
text = True,
|
|
)
|
|
out = completed.stdout + completed.stderr
|
|
assert "RESULT: 3.13" in out, out
|