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).