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:
parent
df3a205726
commit
daf0889804
3 changed files with 123 additions and 45 deletions
60
install.ps1
60
install.ps1
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue