From daf0889804aa3f33bbf2bde74034cad926f4a3bf Mon Sep 17 00:00:00 2001 From: Etherll <61019402+Etherll@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:10:47 +0300 Subject: [PATCH] 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 ` and `-r ` 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 --- install.ps1 | 60 ++++++++++++++++++++------------- studio/install_python_stack.py | 47 ++++++++++++++++++++------ studio/setup.ps1 | 61 +++++++++++++++++++++++++++------- 3 files changed, 123 insertions(+), 45 deletions(-) diff --git a/install.ps1 b/install.ps1 index 77c0034125..2544eaf78a 100644 --- a/install.ps1 +++ b/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 } } diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index 5fe4415970..8ac59d389d 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -359,6 +359,27 @@ def _ensure_rocm_torch() -> None: ) +def _uv_safe_path(path: object) -> str: + # uv 0.11.x: `-c ` 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: diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 24ef3ef1eb..0da30c9229 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -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).