Fix Windows install when paths contain spaces or Python 3.14 is on PATH (#5201)

* fix(studio): use py.exe to detect supported Python on Windows

  Description:
  The previous detection looked at `python --version` on PATH and
  hard-failed if the resolved Python wasn't 3.11-3.13. On systems
  where Python 3.14 sits ahead of 3.13 in PATH order, this aborted
  the installer even though a supported interpreter was installed.

  Prefer the py.exe launcher and probe `py -3.13`, `py -3.12`,
  `py -3.11` in turn. Fall back to `python --version` only when py.exe
  is absent, and surface a clearer error when no supported version
  can be found via either path.

* Studio: consolidate Windows studio overlay into single Tauri-gated block

  Replace the in-file sentinel hotfix and the unconditional file-copy
  overlay with a single block gated on $TauriMode. Hash-compare makes
  re-runs no-ops, removing the sentinel-clobbering bug that occurred
  when the second copy path overwrote the marker without re-adding it.

  Non-Tauri --local installs no longer need a copy overlay: the
  editable install above (uv pip install -e $RepoRoot --no-deps) makes
  _PACKAGE_ROOT in unsloth_cli/commands/studio.py resolve to the repo
  source tree via PEP 660 __file__-relative resolution, so
  `unsloth studio setup` finds the local setup.ps1 and
  install_python_stack.py without any file copying.

  Plain PyPI installs invoked from a checked-out repo directory are
  also no longer silently overlaid from cwd.

* fix(studio): work around uv space-in-path truncation on Windows

  uv 0.11.x truncates `-c <path>` and `-r <path>` arguments at the
  first space, breaking installs on Windows when the venv or repo
  sits under a path containing spaces (e.g. C:\Users\First Last\...).

  Pass paths through GetShortPathNameW to convert to 8.3 short form
  before handing them to uv. Plain pip is unaffected and keeps the
  original long path. No-op on Linux/Mac (gated on IS_WINDOWS and
  on the path actually containing a space).

* Refactor Python stack overlay logic in install.ps1

Refactor overlay logic for Python stack installation and improve handling of missing target directories.

* Update Python installation logic in setup.ps1
This commit is contained in:
Etherll 2026-04-28 11:10:47 +03:00 committed by GitHub
commit daf0889804
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 123 additions and 45 deletions

View file

@ -1027,32 +1027,46 @@ shell.Run cmd, 0, False
}
}
# Hotfix: patch install_python_stack.py for Windows GUI stdout
# The PyPI version crashes with OSError when stdout is piped from a GUI app.
# Copy our fixed version (bundled by Tauri) over the installed one.
# Remove this block once PyPI ships the fix from commit 18c5aae7.
# Overlay Tauri-bundled studio fixes that may be ahead of PyPI. Skipped
# for --local: the editable install above already makes _PACKAGE_ROOT in
# unsloth_cli/commands/studio.py resolve to the repo (PEP 660 __file__).
# Source paths match the Tauri bundle layout in studio/src-tauri/tauri.conf.json,
# which bundles install_python_stack.py at the bundle root next to install.ps1.
if ($TauriMode) {
$rawPath = if ($PSCommandPath) { $PSCommandPath } else { $MyInvocation.ScriptName }
$scriptDir = Split-Path -Parent ($rawPath -replace '^\\\\\?\\', '')
$fixedPy = Join-Path $scriptDir "install_python_stack.py"
$target = Join-Path $VenvDir "Lib\site-packages\studio\install_python_stack.py"
$sentinel = "# UNSLOTH_DESKTOP_HOTFIX_APPLIED_v1"
$sentinelPattern = [regex]::Escape($sentinel)
if ((Test-Path $fixedPy) -and (Test-Path $target)) {
$installed = Get-Content $target -Raw
if ($installed -notmatch $sentinelPattern) {
Copy-Item $fixedPy $target -Force
Add-Content -Path $target -Value "`n$sentinel"
substep "patched install_python_stack.py (stdout fix)"
} else {
substep "install_python_stack.py already has stdout fix"
if ($rawPath) {
# Strip leading \\?\ extended-length prefix if the launcher passed one.
$scriptDir = Split-Path -Parent ($rawPath -replace '^\\\\\?\\', '')
$overlayMap = [ordered]@{
"install_python_stack.py" = "Lib\site-packages\studio\install_python_stack.py"
}
foreach ($rel in $overlayMap.Keys) {
$src = Join-Path $scriptDir $rel
$dst = Join-Path $VenvDir $overlayMap[$rel]
if (-not (Test-Path $src)) { continue }
$dstParent = Split-Path -Parent $dst
if (-not (Test-Path $dstParent)) {
Write-Host "[WARN] Overlay target dir missing: $dstParent; studio setup may use stale bundled file" -ForegroundColor Yellow
continue
}
try {
if (-not (Test-Path $dst)) {
# Backfill: target file missing but parent dir exists.
Copy-Item $src $dst -Force
substep ("backfilled bundled " + (Split-Path -Leaf $rel))
} else {
# Hash-compare so re-runs are no-ops when files already match.
$srcHash = (Get-FileHash $src -Algorithm SHA256).Hash
$dstHash = (Get-FileHash $dst -Algorithm SHA256).Hash
if ($srcHash -ne $dstHash) {
Copy-Item $src $dst -Force
substep ("applied bundled " + (Split-Path -Leaf $rel))
}
}
} catch {
Write-Host "[WARN] Could not overlay $($rel): $($_.Exception.Message); studio setup may use stale bundled file" -ForegroundColor Yellow
}
}
} elseif ((Test-Path $fixedPy) -and (Test-Path (Split-Path $target))) {
Copy-Item $fixedPy $target -Force
Add-Content -Path $target -Value "`n$sentinel"
substep "patched install_python_stack.py (stdout fix)"
} else {
Write-Host "[WARN] Could not patch install_python_stack.py (bundled file or target dir missing)" -ForegroundColor Yellow
}
}

View file

@ -359,6 +359,27 @@ def _ensure_rocm_torch() -> None:
)
def _uv_safe_path(path: object) -> str:
# uv 0.11.x: `-c <path with space>` truncates at the space; use 8.3 short form.
s = str(path)
if not IS_WINDOWS or " " not in s:
return s
try:
import ctypes
from ctypes import wintypes
get_short = ctypes.windll.kernel32.GetShortPathNameW
get_short.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD]
get_short.restype = wintypes.DWORD
buf = ctypes.create_unicode_buffer(32768)
rc = get_short(s, buf, 32768)
if 0 < rc < 32768 and " " not in buf.value:
return buf.value
except Exception:
pass
return s
def _windows_hidden_subprocess_kwargs() -> dict[str, object]:
"""Return Windows-only subprocess kwargs that suppress console windows."""
if not IS_WINDOWS:
@ -751,14 +772,16 @@ def pip_install_try(
"""Like pip_install but returns False on failure instead of exiting.
For optional installs with a follow-up fallback.
"""
constraint_args: list[str] = []
constraint_args_pip: list[str] = []
constraint_args_uv: list[str] = []
if constrain and CONSTRAINTS.is_file():
constraint_args = ["-c", str(CONSTRAINTS)]
constraint_args_pip = ["-c", str(CONSTRAINTS)]
constraint_args_uv = ["-c", _uv_safe_path(CONSTRAINTS)]
if USE_UV:
cmd = _build_uv_cmd(args) + constraint_args
cmd = _build_uv_cmd(args) + constraint_args_uv
else:
cmd = _build_pip_cmd(args) + constraint_args
cmd = _build_pip_cmd(args) + constraint_args_pip
if VERBOSE:
_step(_LABEL, f"{label}...", _dim)
@ -781,9 +804,11 @@ def pip_install(
constrain: bool = True,
) -> None:
"""Build and run a pip install command (uses uv when available, falls back to pip)."""
constraint_args: list[str] = []
constraint_args_pip: list[str] = []
constraint_args_uv: list[str] = []
if constrain and CONSTRAINTS.is_file():
constraint_args = ["-c", str(CONSTRAINTS)]
constraint_args_pip = ["-c", str(CONSTRAINTS)]
constraint_args_uv = ["-c", _uv_safe_path(CONSTRAINTS)]
actual_req = req
temp_reqs: list[Path] = []
@ -793,13 +818,15 @@ def pip_install(
if actual_req is not None and NO_TORCH and NO_TORCH_SKIP_PACKAGES:
actual_req = _filter_requirements(actual_req, NO_TORCH_SKIP_PACKAGES)
temp_reqs.append(actual_req)
req_args: list[str] = []
req_args_pip: list[str] = []
req_args_uv: list[str] = []
if actual_req is not None:
req_args = ["-r", str(actual_req)]
req_args_pip = ["-r", str(actual_req)]
req_args_uv = ["-r", _uv_safe_path(actual_req)]
try:
if USE_UV:
uv_cmd = _build_uv_cmd(args) + constraint_args + req_args
uv_cmd = _build_uv_cmd(args) + constraint_args_uv + req_args_uv
if VERBOSE:
print(f" {label}...")
result = subprocess.run(
@ -814,7 +841,7 @@ def pip_install(
if result.stdout:
print(result.stdout.decode(errors = "replace"))
pip_cmd = _build_pip_cmd(args) + constraint_args + req_args
pip_cmd = _build_pip_cmd(args) + constraint_args_pip + req_args_pip
run(f"{label} (pip)" if USE_UV else label, pip_cmd)
finally:
for temp_req in temp_reqs:

View file

@ -1145,28 +1145,58 @@ if ($IsPipInstall) {
}
}
# ============================================
# 1g. Python (>= 3.11 and < 3.14, matching setup.sh)
# ============================================
# 1g. Python (>= 3.11 and < 3.14). Prefer py.exe so a 3.14 ahead of 3.13 on PATH does not trip the gate.
$HasPython = $null -ne (Get-Command python -ErrorAction SilentlyContinue)
$PyLauncher = Get-Command py -CommandType Application -ErrorAction SilentlyContinue
$PythonOk = $false
$DetectedPyVer = $null
if ($HasPython) {
if ($PyLauncher) {
foreach ($minor in @("3.13", "3.12", "3.11")) {
try {
$out = & $PyLauncher.Source "-$minor" --version 2>&1 | Out-String
if ($out -match 'Python (3\.\d+\.\d+)') {
$DetectedPyVer = $Matches[1]
# Make `python` resolvable for the rest of setup. Without this,
# py-launcher-only installs (no python.exe on PATH) pass the gate
# and then crash on the first bare `python` call below.
try {
$resolvedExe = (& $PyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Select-Object -First 1)
if ($resolvedExe -and (Test-Path $resolvedExe)) {
$resolvedDir = Split-Path -Parent $resolvedExe
$alreadyOnPath = ($env:PATH -split ';' | Where-Object { $_.TrimEnd('\') -ieq $resolvedDir.TrimEnd('\') }).Count -gt 0
if (-not $alreadyOnPath) {
$env:PATH = "$resolvedDir;$env:PATH"
}
$HasPython = $true
}
} catch { }
$PythonOk = $true
break
}
} catch { }
}
}
if (-not $PythonOk -and $HasPython) {
$PyVer = python --version 2>&1
if ($PyVer -match "(\d+)\.(\d+)") {
$PyMajor = [int]$Matches[1]; $PyMinor = [int]$Matches[2]
if ($PyMajor -eq 3 -and $PyMinor -ge 11 -and $PyMinor -lt 14) {
substep "Python $PyVer"
$DetectedPyVer = "$PyMajor.$PyMinor"
$PythonOk = $true
} else {
Write-Host "[ERROR] Python $PyVer is outside supported range (need >= 3.11 and < 3.14)." -ForegroundColor Red
Write-Host " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow
exit 1
}
}
} else {
# No Python at all -- install 3.12
Write-Host "Python not found -- installing Python 3.12 via winget..." -ForegroundColor Yellow
}
if ($PythonOk) {
substep "Python $DetectedPyVer"
} elseif (-not $HasPython) {
# No `python` on PATH (and py.exe either absent or only had unsupported
# minors). Try winget as before -- gating on $HasPython alone, not also
# on $PyLauncher, so a launcher-only install with just 3.14 still gets
# an automatic 3.12 install instead of a hard error.
Write-Host "Python 3.11-3.13 not found -- installing Python 3.12 via winget..." -ForegroundColor Yellow
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
if ($HasWinget) {
winget install -e --id Python.Python.3.12 --source winget --accept-package-agreements --accept-source-agreements
@ -1180,6 +1210,13 @@ if ($HasPython) {
}
step "python" "$(python --version 2>&1)"
$PythonOk = $true
} else {
# python.exe is on PATH but its version is unsupported, and py.exe (if
# present) had no supported minor either.
Write-Host "[ERROR] No supported Python (3.11-3.13) found on this system." -ForegroundColor Red
Write-Host " py.exe could not locate -3.11/-3.12/-3.13 and `python` on PATH is unsupported." -ForegroundColor Yellow
Write-Host " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow
exit 1
}
# Add user-scheme Python Scripts dir to PATH (nt_user only, no venv fallback).