mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-17 12:53:59 +00:00
* Make install.ps1 work with the user's PowerShell profile loaded Installing from a normal console failed where the same install from a console started with -NoProfile succeeded. A profile runs before `irm https://unsloth.ai/install.ps1 | iex` does and shares its scope, and that entry point has no script file to re-launch without it, so the individual couplings are cut instead. install.ps1, at the top of Install-UnslothStudio: - Set-StrictMode -Off. The script tests environment variables that are legitimately unset and reads $script: state only some branches assign, both of which a profile's `Set-StrictMode -Version Latest` turns into terminating errors. - $PSDefaultParameterValues is filtered down to proxy keys. An entry like 'Start-Process:WindowStyle' silently rebinds cmdlets here and fails the install with an error naming none of it. Proxy entries are kept because they can only ever enable a download, and on a locked-down host may be the only route to python.org and the uv release. - $PSNativeCommandUseErrorActionPreference = $false. With a profile turning it on, the "Stop" preference makes a failing native command throw out of the `unsloth studio setup` handoff instead of reaching Exit-InstallFailure, skipping rollback and the Tauri error record. All three assign without a scope qualifier, so they apply to the installer and everything it calls and leave the caller's session alone. uv is resolved once through Resolve-UvExecutable, which uses `Get-Command uv -CommandType Application -All` and falls back to the bare token when nothing is on PATH. PowerShell ranks aliases and functions above PATH, so a profile `Set-Alias uv ...` was answering the version probe and ending the install at "uv could not be installed" on machines that had a working uv. Test-UvVersionOk pins the executable that answered in $script:UvExe, and the 27 install scriptblocks invoke that path. $script:UvExe and $script:UvInstallDestDir are reset per invocation, since $script: is the caller's session under irm | iex. unsloth_cli/commands/studio.py passes -NoProfile to setup.ps1 unconditionally. It was only added when stdout was not a tty, which is never the case for the console install this fixes, so setup.ps1 ran under the profile with its own bare uv calls exposed. tests/test_installer_profile_hardening.py runs the extracted prologue and uv probe under a hostile profile and checks the caller's session is left intact. The four existing tests that anchored on the literal `uv venv $VenvDir` are re-anchored past the command token. * Plant the real-profile fixture where pwsh actually looks test_a_real_profile_reproduces_the_same_state failed on ubuntu-latest with every probed setting at its default, meaning the planted profile never loaded. It passed here and on macos-14. PowerShell resolves $PROFILE from $XDG_CONFIG_HOME when that is set and only falls back to $HOME/.config when it is not, and GitHub's ubuntu image writes XDG_CONFIG_HOME into /etc/environment. The fixture redirected HOME alone, so on a hosted runner the inherited value went on naming the real account and the profile was written to a path pwsh never opened. Setting XDG_CONFIG_HOME to the same directory HOME already implies reproduces the CI failure exactly on this machine, and removing it makes the failure go away. _hostile_env now redirects XDG_CONFIG_HOME alongside HOME, so the two rules agree whichever one the host applies, and the test asks pwsh for the path instead of hardcoding the fallback branch. The two guards are precise rather than blanket: a machine-wide profile loads into the real leg only, and a $PROFILE that lands outside the fixture cannot be planted into. Neither is reachable on Linux or macOS with the redirect in place. The other tests in the file never shared the premise; every other pwsh launch there passes -NoProfile and dot-sources the profile explicitly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Close the uv wrapper hole, harden winget the same way, and restore module autoloading Validating the profile hardening against real pwsh turned up three things the first pass missed. Six of the nineteen hostile-profile scenarios I exercised are genuinely broken on main and genuinely fixed by this branch, so the shape of the fix is right -- these are gaps in its coverage, not a change of direction. Resolve-UvExecutable still handed back the bare token when nothing named uv was on PATH. That was meant to keep a working non-Application uv working, but it reopens the exact hole the function exists to close: a profile `function uv { Write-Output "uv 99.0.0" }` clears the version gate, gets pinned into $script:UvExe, and then receives every install command the script runs, with the user's torch, index URL and venv path as arguments. The existing test missed it because its hostile alias reports no version at all, and an alias to a missing file fails loudly. Follow an alias as far as an Application and return that resolved path, since aliasing uv at a specific build is a legitimate thing to do; return $null for anything else, which puts the caller back on its install-uv branch and the gate re-probes against the real thing. winget had the identical defect and was left untouched. It is detected with a bare Get-Command and invoked as a bare token at five sites, and it is what installs both Python and uv -- so a `function winget` wrapper, which people write to inject --accept-* or pin a source, owns the whole bootstrap. Same treatment: resolve once to an Application and invoke through the path. A profile setting $PSModuleAutoLoadingPreference to 'None' is fatal here and was not covered. PowerShell 7 loads no modules at startup, so that one line removes Test-Path, Write-Host, Select-Object, ConvertFrom-Json, Get-FileHash, Invoke-WebRequest, Expand-Archive, Start-Process and Get-Content, and the script dies on its first step naming a cmdlet the reader assumes is always there. Windows PowerShell 5.1 preloads Utility and Management and survives, which is exactly what makes this reproduce on one machine and not another. Also: use [regex]::IsMatch in the defaults filter so it leaves no $Matches behind; add -NoProfile unconditionally in _refresh_desktop_shortcuts, which launches install.ps1 and had it gated on the hidden branch, so the visible console path -- the one where a profile IS loaded -- was the one that missed it; and record that the preserved proxy defaults do not reach setup.ps1, which is launched with -NoProfile, along with why that trade is accepted. Tests: the assertion that the bare token must come back now asserts the opposite, and there are new ones for a convincing uv function, an alias to a real uv, the winget call sites and the autoloading reset. The two new pwsh tests execute against a genuinely planted profile rather than reading source. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Carry the profile proxy across the setup handoff, not just inside install.ps1 install.ps1 deliberately keeps proxy-shaped $PSDefaultParameterValues entries out of the profile table it discards, because on a locked-down corporate host that entry can be the only route out. Adding -NoProfile to the setup launch unconditionally then threw them away one process later, and setup.ps1 downloads on its own: the VC++ runtime through Invoke-WebRequest and the uv installer through Invoke-RestMethod. A PowerShell variable does not cross a process boundary, so the kept entries travel as JSON in _UNSLOTH_PS_PROXY_DEFAULTS and the child re-applies them before running setup.ps1. Nothing else from the profile comes with them. A credential is left behind on purpose: PSCredential does not survive ConvertTo-Json, and the environment is the wrong place for one. A stale variable is cleared when there is nothing to hand off. Three tests, one static and two driving real pwsh, covering the round trip, the credential and non-proxy keys being dropped, and the prelude staying silent when the variable is absent, empty or corrupt. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Harden the proxy handoff: ordering, key casing, uri values, scope, and the standalone update Five follow-ups, all on the handoff added last round. The handoff serializes with ConvertTo-Json, from Microsoft.PowerShell.Utility, and ran before the module-autoloading reset. Under a profile's $PSModuleAutoLoadingPreference = 'None' a fresh PowerShell 7 session therefore died right there, taking out the one configuration the handoff exists to support. The reset moves to the front of the prologue. The key filter was a case-sensitive .NET regex, but cmdlet and parameter names bind case-insensitively, so 'invoke-webrequest:proxy' was dropped. And [uri] is the type the Proxy parameter actually takes, so a careful profile assigns one; the serializer accepted only string and bool, and it disappeared at the process boundary. Both are accepted now, a uri by its AbsoluteUri. A PSCredential is still deliberately left behind. Under "irm ... | iex" the prologue runs in the caller's own session, so writing the environment variable there outlived the install on every path, early returns included, and a later `unsloth studio update` from that console would reapply stale JSON over a proxy that had since changed. The prologue now holds the value and it is published around the setup child only, saved and restored beside the other child-scoped variables. A standalone `unsloth studio update` has no installer above it, so there was nothing to restore and -NoProfile left it with no route out. It now asks: a throwaway PowerShell that does load the profile prints just the proxy-shaped defaults as JSON, validated before use, entirely best effort. Same filter as install.ps1's. Five tests, two driving real pwsh, including one against a profile with strict mode on, autoloading off, a lowercase key and a uri value. * Ask the profile the caller actually has, and follow a uv alias first - the standalone update probed powershell.exe only, so a proxy living in the PowerShell 7 profile never reached the -NoProfile child; both editions are asked now, the caller's first, and their answers merged. - Resolve-UvExecutable checked PATH before the alias, which is the reverse of PowerShell's own resolution and made the alias branch unreachable on any machine with some uv on PATH. - the parity workflow did not run this suite when unsloth_cli/commands/studio.py changed, though the suite asserts that module directly. Its own path-filter parser also treated a comment inside the list as the end of it, which would have hidden the addition. * Give the parity job the imports it needs, and fold proxy keys the way PowerShell does Three tests in the profile-hardening suite import unsloth_cli.commands.studio to drive the profile probe directly, and that pulls typer, pyyaml, pydantic and click. The job installed pip and pytest only, so on a clean setup-python both matrix legs died with ModuleNotFoundError before a single test ran. Installed, with a test that keeps the step in step with what the suite imports. $PSDefaultParameterValues keys are case-insensitive and a Python dict is not, so "Invoke-WebRequest:Proxy" from the caller's own host and "invoke-webrequest:proxy" from the other one both crossed over; the prelude then replayed them in order and the lower-priority host's value landed last, reversing the earlier-host-wins rule this merge exists for. Keys are folded now, first spelling seen wins, within one profile's answer as well as across two. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Frame the proxy record, and quote the union so 3.9 can still import the CLI The probe runs after the profile, and the profile is free to print: a MOTD, a "loading personal and system profiles took 812ms" line, a corporate banner. With the record bare, that arrived ahead of the JSON, the parse threw and the whole answer was dropped -- so the locked-down host that needed the proxy handed the -NoProfile child nothing and every download failed, which is worse than before, since the old visible-console path loaded the profile itself. The record is emitted between two markers now and cut out of whatever else was said. And `str | list[str]` was evaluated at def time in a module with no postponed annotations, so on the 3.9 this project still supports it raised TypeError and took the whole CLI import with it. Quoted, with a test that walks every annotation in the module for an unquoted PEP 604 union. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Let the uv gate move past a stale alias, and decode the probe output lossily An alias pointing at a real but stale uv was the only binary the version gate ever probed, so a current uv already on PATH -- or one winget or the pinned release had just installed -- could not rescue the run and the install ended at "uv could not be installed" on a machine that had one. The resolver hands back every candidate in the order the bare token would pick them, alias first, and the gate walks them until one passes, pinning the one that answered. The profile probe decoded its child with text=True alone, which is the locale codec with STRICT errors. A UTF-8 banner on an ANSI console then raised UnicodeDecodeError, which is neither OSError nor SubprocessError, so it escaped the handler and took the update down before the -NoProfile child ever ran -- and before the framing could discard the banner. UTF-8 with replacement now; the record itself is ASCII. rich is named in the parity job's install line too. It arrives through typer today, but unsloth_cli imports it directly, and this suite's imports should not rest on somebody else's dependency list. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Assert the proxy handoff by parsing it, not by substring CodeQL reads the bare membership test as an incomplete URL sanitization, which is a fair reading of the shape even though this is an assertion on a compressed JSON payload rather than a check on untrusted input. Parsing it and comparing the value exactly is the stronger assertion anyway. * Read the caller edition by order, pin the probe's encoding, claim cmdlets whole A machine can carry both PowerShell module trees on PSModulePath at once, so inferring the caller from the absence of the other edition handed precedence to the wrong profile and let its proxy override the console the command was typed into. Each host puts its own module directory first, so the earliest tree names the caller; neither present keeps the previous order. Windows PowerShell 5.1 writes redirected output in the console code page while this process decodes UTF-8, so a non-ASCII proxy value came back with replacement characters, still parsed as JSON, and handed setup a proxy that does not resolve. The probe pins its own output encoding first. And the merge claims a cmdlet whole rather than filling missing companion parameters from the other profile, which built a configuration neither host had -- one profile's proxy with the other's credential forwarding. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Evaluate script-block proxy defaults, read the caller's host profile, drop the secret A profile can set a dynamic default as a script block, which is PowerShell's supported form and which Invoke-WebRequest evaluates per call. Both serializers dropped it, so the caller downloaded fine and the -NoProfile setup child got no proxy at all. Both now invoke the block and hand over the resulting URI or string; executable code does not cross the handoff. The probe spawns pwsh.exe or powershell.exe, which load the CONSOLEHOST profile. A caller in the VS Code Integrated Console or the ISE keeps its defaults in Microsoft.VSCode_profile.ps1 or Microsoft.PowerShellISE_profile.ps1 instead, so the probe reported no proxy on exactly the host that needed one. It dot-sources the caller's other CurrentUser host profiles, from their own directory, before reading the table. And the prelude clears _UNSLOTH_PS_PROXY_DEFAULTS the moment it has read it. A profile proxy routinely carries credentials, and every native process setup.ps1 starts inherited the environment it was launched with. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Probe only the caller's own host profile, on one shared budget Sourcing every Microsoft.*_profile.ps1 in the profile directory ran profiles belonging to hosts nobody was using: they can overwrite the console's own $PSDefaultParameterValues, have side effects, or exit before the framed record is written. The probe now sources exactly one, named by _UNSLOTH_PS_HOST_PROFILE, and only when the caller identifies itself (VS Code does, via TERM_PROGRAM). A host we cannot name gets no extra profile rather than someone else's. install.ps1 removed the handoff variable when it had no proxy to pass, and its absence is precisely how the CLI recognises a standalone update -- so an installer launch, including one started with -NoProfile or by the desktop app, went and reloaded the profiles it had deliberately discarded. It publishes an explicit empty handoff instead, and the CLI keys on presence. And the probe's timeout is one budget for the whole call rather than one per host, so two installed editions with two hung profiles no longer cost twice the documented best-effort delay before setup starts. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Run the proxy probe with -NoProfile and dot-source the caller's own two Without -NoProfile the probe host loaded its own ConsoleHost profile before the script ran, so an unrelated profile could print, rewrite $PSDefaultParameterValues or exit before the record was written -- and it still was not the profile a VS Code caller keeps its defaults in. The child runs with -NoProfile now and dot-sources exactly the two the caller's session would have loaded: $PROFILE.CurrentUserAllHosts and either the host profile named in _UNSLOTH_PS_HOST_PROFILE or $PROFILE.CurrentUserCurrentHost. $PROFILE is fully populated under -NoProfile, since the paths are computed rather than loaded, so this is exact rather than incidental. Checked against pwsh with a fixture profile directory: a VS Code caller picks up its own profile plus the all-hosts one and never runs the console profile's banner, and a plain console caller picks up the console profile plus the all-hosts one. * Probe the all-users profiles too, and clear profile defaults before emitting A machine-managed proxy commonly lives in AllUsersAllHosts on a domain-joined box while the user's own profile never mentions it, so sourcing only the current-user pair reported no proxy on exactly the host that has one. The probe now walks PowerShell's own startup order, all-users first, so the user's profile still gets the last word. The profile's $PSDefaultParameterValues was also still active when the record was serialized. ConvertTo-Json:AsArray = $true is a legitimate setting and turns the payload into a JSON array, which the reader rejects for not being a dictionary. $out already holds copies by then, so the table is cleared first. * Harden the proxy probe against profile overrides, and drop the handoff copy Five fixes from the review round: install.ps1 kept the serialized proxy defaults in $script:, which under the documented irm | iex path IS the caller's session scope, so an authenticated proxy URI stayed readable in that console after the installer returned. Cleared in the same finally that restores the environment handoff. A profile setting [Console]::OutputEncoding overrode the probe's UTF-8 pin, and the parent decodes that stream as UTF-8, so the framed record could come back corrupted. Re-pinned after the last profile is sourced. The record was emitted through bare Write-Output and ConvertTo-Json, which a profile alias or function shadows; clearing $PSDefaultParameterValues does not cover a command override. Both are module-qualified now. TERM_PROGRAM=vscode is set by every VS Code integrated terminal, not only the PowerShell extension's host, so substituting Microsoft.VSCode_profile.ps1 for the current-host profile missed the proxy a plain pwsh terminal there actually has. The named host profile is added rather than substituted, with the current-host profile last. The per-cmdlet ownership check compared command strings literally, so a wildcard key from one host and a literal key for a matching cmdlet from the other were both merged, which is how one invocation ends up configured from two profiles. Overlap is matched in either direction now. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Hold the proxy handoff in the frame, and treat two wildcards as one family Three fixes from the review round: The serialized handoff lived in $script:, which under the documented irm | iex path is the caller's session scope, and the only cleanup ran after the setup child. Dozens of exits return earlier -- ShortcutsOnly, an argument error, lock contention, a failed dependency install -- so an authenticated proxy URI stayed readable in that console. It is a function-local now, which dies with the frame on every path including a throw. install.ps1 serialized that record through a bare ConvertTo-Json, which a profile alias or function shadows exactly as it does in the probe. Module qualified. The cmdlet-ownership check compared two wildcard patterns as strings, and Invoke-Web* and *-WebRequest both apply to Invoke-WebRequest while neither matches the other. Two patterns are now assumed to overlap: the cost is a second host's unrelated wildcard entry going unmerged, against handing setup a credential setting from a profile that never asked for one. * Tighten the profile-hardening comments Comments, docstrings and whitespace only; no code changes. Each comment keeps the reason it records and drops the retelling. * Cut the profile-hardening comments down again Comments, docstrings and whitespace only. The install.ps1 prologue and the studio.py proxy probe kept one causal claim per decision, with the probe's profile-loading essay split into a short note beside each line it justifies. * Pin the probe's add-both profile order in its own test name * Keep disjoint wildcard proxy families, and give each probed host its own module path * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Daniel Han <moonshotaisubstack@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
427 lines
18 KiB
Python
427 lines
18 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
"""Focused contracts for Windows Python wrapper and venv validation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
INSTALL_PS1 = REPO_ROOT / "install.ps1"
|
|
POWERSHELLS = [shell for shell in ("pwsh", "powershell") if shutil.which(shell)]
|
|
|
|
|
|
def _extract(pattern: str, source: str) -> str:
|
|
match = re.search(pattern, source, flags = re.DOTALL)
|
|
assert match is not None, f"install.ps1 block not found: {pattern}"
|
|
return match.group(0)
|
|
|
|
|
|
def _link_dir(link: Path, target: Path) -> None:
|
|
"""Directory link, without needing SeCreateSymbolicLinkPrivilege on Windows.
|
|
|
|
A junction is also the reparse point a Windows venv actually runs into, so this
|
|
is the faithful construct there rather than a stand-in.
|
|
"""
|
|
if os.name == "nt":
|
|
subprocess.run(
|
|
["cmd", "/c", "mklink", "/J", str(link), str(target)],
|
|
check = True,
|
|
capture_output = True,
|
|
text = True,
|
|
)
|
|
else:
|
|
os.symlink(target, link, target_is_directory = True)
|
|
|
|
|
|
def _run_powershell(shell: str, script: str, env: dict[str, str]) -> str:
|
|
result = subprocess.run(
|
|
[shell, "-NoProfile", "-NonInteractive", "-Command", script],
|
|
check = True,
|
|
capture_output = True,
|
|
text = True,
|
|
env = env,
|
|
timeout = 30,
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
|
|
@pytest.mark.parametrize("shell", POWERSHELLS)
|
|
def test_path_python_wrapper_resolves_to_real_executable(tmp_path: Path, shell: str):
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
finder = _extract(r" function Find-CompatiblePython \{.*?\n \}\n", source)
|
|
(tmp_path / "sitecustomize.py").write_text('print("STARTUP_BANNER")\n', encoding = "utf-8")
|
|
if os.name == "nt":
|
|
wrapper = tmp_path / "python.bat"
|
|
wrapper.write_text(f'@"{sys.executable}" %*\n', encoding = "utf-8")
|
|
else:
|
|
wrapper = tmp_path / "python-wrapper"
|
|
wrapper.write_text(
|
|
f'#!/bin/sh\nexec {shlex.quote(sys.executable)} "$@"\n', encoding = "utf-8"
|
|
)
|
|
wrapper.chmod(0o755)
|
|
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
$PythonVersion = "3.13"
|
|
$script:CondaSkipPattern = '(?i)(conda|miniconda|anaconda)'
|
|
function Get-HostMachineArch {{ return "x86_64" }}
|
|
function Test-IsCondaPython {{ param([string]$Exe) return $false }}
|
|
function Get-PythonPlatformTag {{ param([string]$Exe) return "win-amd64" }}
|
|
function Get-Command {{
|
|
param([Parameter(Position = 0)][string]$Name,
|
|
[Parameter(ValueFromRemainingArguments = $true)]$Rest)
|
|
if ($Name -eq "python") {{
|
|
return @([pscustomobject]@{{ Source = $env:TEST_PYTHON_WRAPPER }})
|
|
}}
|
|
return @()
|
|
}}
|
|
{finder}
|
|
$found = Find-CompatiblePython
|
|
Write-Output $found.Path
|
|
"""
|
|
env = os.environ.copy()
|
|
env["TEST_PYTHON_WRAPPER"] = str(wrapper)
|
|
env["PYTHONPATH"] = str(tmp_path)
|
|
assert Path(_run_powershell(shell, script, env)).resolve() == Path(sys.executable).resolve()
|
|
|
|
|
|
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
|
|
@pytest.mark.parametrize("shell", POWERSHELLS)
|
|
def test_arch_probe_ignores_startup_output(tmp_path: Path, shell: str):
|
|
"""Startup output must not reach the arch tag.
|
|
|
|
The caller compares the tag with -eq "win-amd64", so a contaminated answer reads
|
|
as "unknown" and Windows on ARM silently settles for a native ARM64 interpreter.
|
|
"""
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
probe = _extract(r" function Get-PythonPlatformTag \{.*?\n \}\n", source)
|
|
(tmp_path / "sitecustomize.py").write_text('print("STARTUP_BANNER")\n', encoding = "utf-8")
|
|
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
{probe}
|
|
Write-Output (Get-PythonPlatformTag $env:TEST_PYTHON)
|
|
"""
|
|
env = os.environ.copy()
|
|
env["TEST_PYTHON"] = sys.executable
|
|
env["PYTHONPATH"] = str(tmp_path)
|
|
tag = _run_powershell(shell, script, env)
|
|
assert tag and "\n" not in tag and "startup_banner" not in tag, tag
|
|
|
|
|
|
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
|
|
@pytest.mark.parametrize("shell", POWERSHELLS)
|
|
def test_venv_base_home_comes_from_pyvenv_config(tmp_path: Path, shell: str):
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
reader = _extract(r" function Get-VenvBaseHome \{.*?\n \}\n", source)
|
|
expected = tmp_path / "removed-base-python"
|
|
(tmp_path / "pyvenv.cfg").write_text(f"home = {expected}\n", encoding = "utf-8")
|
|
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
{reader}
|
|
Write-Output (Get-VenvBaseHome -VenvRoot $env:TEST_VENV_ROOT)
|
|
"""
|
|
env = os.environ.copy()
|
|
env["TEST_VENV_ROOT"] = str(tmp_path)
|
|
assert _run_powershell(shell, script, env) == str(expected)
|
|
|
|
|
|
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
|
|
@pytest.mark.parametrize("shell", POWERSHELLS)
|
|
@pytest.mark.parametrize("case", ["partial", "clean"])
|
|
def test_rollback_keeps_state_when_the_move_stops_partway(tmp_path: Path, shell: str, case: str):
|
|
"""A half-finished rename must not be read as "the rename never happened".
|
|
|
|
On Windows an open handle inside the tree fails Move-Item after it has already
|
|
walked part of it, so entries exist at both paths. Testing only the source then
|
|
clears StudioVenvRollbackDir -- the sole record of where the other half went --
|
|
and the environment is stranded with no way to restore or even name it.
|
|
"""
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
rollback = _extract(r" function Start-StudioVenvRollback \{.*?\n \}\n", source)
|
|
existing = tmp_path / "unsloth_studio"
|
|
(existing / "Scripts").mkdir(parents = True)
|
|
(existing / "Scripts" / "unsloth.exe").write_text("locked", encoding = "utf-8")
|
|
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
$StudioHome = $env:TEST_STUDIO_HOME
|
|
function substep {{ param([string]$Text, [string]$Color) }}
|
|
# The split-move warning goes through install.ps1's UTF-8 stdout sink. Echo it so
|
|
# the assertions below can read it; without this the call is a command-not-found
|
|
# terminating error that the try/catch swallows, and the warning is simply lost.
|
|
function Write-StudioLine {{ param([string]$Message, [string]$ForegroundColor) Write-Host $Message }}
|
|
function Move-Item {{
|
|
param([string]$LiteralPath, [string]$Destination, [string]$ErrorAction, [switch]$Force)
|
|
if ($env:TEST_ROLLBACK_CASE -eq "partial") {{
|
|
# The entries walked before the locked one are already at the destination.
|
|
[System.IO.Directory]::CreateDirectory((Join-Path $Destination "Lib")) | Out-Null
|
|
}}
|
|
throw "The process cannot access the file because it is being used by another process."
|
|
}}
|
|
{rollback}
|
|
try {{ Start-StudioVenvRollback -ExistingDir $env:TEST_EXISTING_DIR }} catch {{ }}
|
|
Write-Output ("active=" + $script:StudioVenvRollbackActive)
|
|
Write-Output ("dir=" + [string]$script:StudioVenvRollbackDir)
|
|
"""
|
|
env = os.environ.copy()
|
|
env["TEST_STUDIO_HOME"] = str(tmp_path)
|
|
env["TEST_EXISTING_DIR"] = str(existing)
|
|
env["TEST_ROLLBACK_CASE"] = case
|
|
out = _run_powershell(shell, script, env)
|
|
state = dict(
|
|
line.split("=", 1) for line in out.splitlines() if line.startswith(("active=", "dir="))
|
|
)
|
|
|
|
if case == "clean":
|
|
# Nothing moved, so the original is intact and there is nothing to restore.
|
|
assert state["active"] == "False", out
|
|
assert state["dir"] == "", out
|
|
return
|
|
|
|
assert state["active"] == "True", out
|
|
assert state["dir"].startswith(os.path.join(str(tmp_path), "unsloth_studio.rollback.")), out
|
|
assert Path(state["dir"]).is_dir(), out
|
|
# Both halves are named, so the user is not left hunting for the moved tree.
|
|
# Match the warning lines themselves rather than the bare paths: $existing is a
|
|
# prefix of the rollback dir, so "str(existing) in out" alone is satisfied by the
|
|
# dir= line and would stay green even with the warning missing entirely.
|
|
assert f"still in place: {existing}" in out, out
|
|
assert f"moved aside: {state['dir']}" in out, out
|
|
|
|
|
|
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
|
|
@pytest.mark.parametrize("shell", POWERSHELLS)
|
|
def test_restoring_a_split_move_never_deletes_the_half_left_behind(tmp_path: Path, shell: str):
|
|
"""Restoration must merge the two halves, not clear the destination first.
|
|
|
|
After a partway move the target holds the entries the move never got to -- not
|
|
an incomplete *new* environment. The committed-replacement path removes the
|
|
target before moving the backup back, which for a split tree deletes files that
|
|
exist nowhere else. Keeping the rollback active is only safe if restoration
|
|
takes a merge path, so this pins the file that never moved to being still there.
|
|
"""
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
blocks = "".join(
|
|
_extract(rf" function {name} \{{.*?\n \}}\n", source)
|
|
for name in (
|
|
"Remove-StudioVenvTreeWithRetry",
|
|
"Merge-StudioVenvRollbackTree",
|
|
"Restore-StudioVenvRollback",
|
|
)
|
|
)
|
|
target = tmp_path / "unsloth_studio"
|
|
backup = tmp_path / "unsloth_studio.rollback.20260804120000.999"
|
|
# The half the interrupted move left behind, and the half that got across.
|
|
(target / "Scripts").mkdir(parents = True)
|
|
(target / "Scripts" / "unsloth.exe").write_text("irreplaceable", encoding = "utf-8")
|
|
(backup / "Lib" / "site-packages").mkdir(parents = True)
|
|
(backup / "Lib" / "site-packages" / "marker.txt").write_text("moved", encoding = "utf-8")
|
|
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
function substep {{ param([string]$Text, [string]$Color) }}
|
|
# The merge/restore helpers warn through install.ps1's UTF-8 stdout sink on their
|
|
# conflict branches. This run does not take one, but leaving the sink undefined
|
|
# means any future case that does would die on a command-not-found instead.
|
|
function Write-StudioLine {{ param([string]$Message, [string]$ForegroundColor) Write-Host $Message }}
|
|
{blocks}
|
|
$script:StudioVenvRollbackActive = $true
|
|
$script:StudioVenvRollbackDir = $env:TEST_BACKUP_DIR
|
|
$script:StudioVenvRollbackTarget = $env:TEST_TARGET_DIR
|
|
$script:StudioVenvRollbackPartial = $true
|
|
Restore-StudioVenvRollback
|
|
Write-Output ("active=" + $script:StudioVenvRollbackActive)
|
|
"""
|
|
env = os.environ.copy()
|
|
env["TEST_BACKUP_DIR"] = str(backup)
|
|
env["TEST_TARGET_DIR"] = str(target)
|
|
out = _run_powershell(shell, script, env)
|
|
|
|
# The file that never moved is the whole point: the pre-merge path deleted it.
|
|
assert (target / "Scripts" / "unsloth.exe").read_text(encoding = "utf-8") == "irreplaceable", out
|
|
# ...and the half that did move comes back rather than being stranded.
|
|
assert (target / "Lib" / "site-packages" / "marker.txt").is_file(), out
|
|
assert not backup.exists(), out
|
|
assert "active=False" in out, out
|
|
|
|
|
|
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
|
|
@pytest.mark.parametrize("shell", POWERSHELLS)
|
|
def test_merging_a_split_move_keeps_every_sibling_at_its_own_path(tmp_path: Path, shell: str):
|
|
"""Each entry must land at its own path, not nested under the previous one.
|
|
|
|
PowerShell variable names are case-insensitive, so a per-entry $destination
|
|
reassigns the $Destination parameter. Only the first sibling at a level then
|
|
lands correctly and the rest are appended to its path, so a restored venv comes
|
|
back with pyvenv.cfg buried inside Lib. One entry per level hides it, so this
|
|
uses several.
|
|
"""
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
blocks = "".join(
|
|
_extract(rf" function {name} \{{.*?\n \}}\n", source)
|
|
for name in (
|
|
"Remove-StudioVenvTreeWithRetry",
|
|
"Merge-StudioVenvRollbackTree",
|
|
"Restore-StudioVenvRollback",
|
|
)
|
|
)
|
|
target = tmp_path / "unsloth_studio"
|
|
backup = tmp_path / "unsloth_studio.rollback.20260804120000.999"
|
|
# The half left behind, and a moved half with several siblings at two levels.
|
|
(target / "Scripts").mkdir(parents = True)
|
|
(target / "Scripts" / "unsloth.exe").write_text("irreplaceable", encoding = "utf-8")
|
|
(target / "Lib").mkdir()
|
|
(target / "Lib" / "stayed.py").write_text("stayed", encoding = "utf-8")
|
|
(backup / "Lib" / "site-packages").mkdir(parents = True)
|
|
(backup / "Lib" / "site-packages" / "marker.txt").write_text("moved", encoding = "utf-8")
|
|
(backup / "Lib" / "other.py").write_text("other", encoding = "utf-8")
|
|
(backup / "pyvenv.cfg").write_text("cfg", encoding = "utf-8")
|
|
(backup / "unsloth_install_manifest.json").write_text("{}", encoding = "utf-8")
|
|
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
function substep {{ param([string]$Text, [string]$Color) }}
|
|
# The merge/restore helpers warn through install.ps1's UTF-8 stdout sink on their
|
|
# conflict branches. This run does not take one, but leaving the sink undefined
|
|
# means any future case that does would die on a command-not-found instead.
|
|
function Write-StudioLine {{ param([string]$Message, [string]$ForegroundColor) Write-Host $Message }}
|
|
{blocks}
|
|
$script:StudioVenvRollbackActive = $true
|
|
$script:StudioVenvRollbackDir = $env:TEST_BACKUP_DIR
|
|
$script:StudioVenvRollbackTarget = $env:TEST_TARGET_DIR
|
|
$script:StudioVenvRollbackPartial = $true
|
|
Restore-StudioVenvRollback
|
|
Write-Output ("active=" + $script:StudioVenvRollbackActive)
|
|
"""
|
|
env = os.environ.copy()
|
|
env["TEST_BACKUP_DIR"] = str(backup)
|
|
env["TEST_TARGET_DIR"] = str(target)
|
|
out = _run_powershell(shell, script, env)
|
|
|
|
restored = sorted(
|
|
str(p.relative_to(target)).replace("\\", "/") for p in target.rglob("*") if p.is_file()
|
|
)
|
|
assert restored == [
|
|
"Lib/other.py",
|
|
"Lib/site-packages/marker.txt",
|
|
"Lib/stayed.py",
|
|
"Scripts/unsloth.exe",
|
|
"pyvenv.cfg",
|
|
"unsloth_install_manifest.json",
|
|
], out
|
|
assert not backup.exists(), out
|
|
assert "active=False" in out, out
|
|
|
|
|
|
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
|
|
@pytest.mark.parametrize("shell", POWERSHELLS)
|
|
@pytest.mark.parametrize("side", ["destination", "source"])
|
|
def test_merging_a_split_move_never_walks_through_a_link(tmp_path: Path, shell: str, side: str):
|
|
"""A junction on either side is a leaf, not a subtree to recurse into.
|
|
|
|
Recursing through one moves venv files to wherever the link points, outside
|
|
$StudioHome, and replaces the link with a real directory. Either half can carry
|
|
the link, so both directions are pinned here.
|
|
"""
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
blocks = "".join(
|
|
_extract(rf" function {name} \{{.*?\n \}}\n", source)
|
|
for name in (
|
|
"Remove-StudioVenvTreeWithRetry",
|
|
"Merge-StudioVenvRollbackTree",
|
|
"Restore-StudioVenvRollback",
|
|
)
|
|
)
|
|
target = tmp_path / "unsloth_studio"
|
|
backup = tmp_path / "unsloth_studio.rollback.20260804120000.999"
|
|
# A sibling of the environment, never under it.
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
(outside / "keep.txt").write_text("untouched", encoding = "utf-8")
|
|
target.mkdir()
|
|
backup.mkdir()
|
|
|
|
linked, real = (target, backup) if side == "destination" else (backup, target)
|
|
_link_dir(linked / "Lib", outside)
|
|
(real / "Lib").mkdir()
|
|
(real / "Lib" / "payload.txt").write_text("venv-only", encoding = "utf-8")
|
|
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
function substep {{ param([string]$Text, [string]$Color) }}
|
|
# Restore-StudioVenvRollback warns through install.ps1's UTF-8 stdout sink. Echo it
|
|
# rather than swallowing it, so a warning stays visible in the assertion message.
|
|
function Write-StudioLine {{ param([string]$Message, [string]$ForegroundColor) Write-Host $Message }}
|
|
{blocks}
|
|
$script:StudioVenvRollbackActive = $true
|
|
$script:StudioVenvRollbackDir = $env:TEST_BACKUP_DIR
|
|
$script:StudioVenvRollbackTarget = $env:TEST_TARGET_DIR
|
|
$script:StudioVenvRollbackPartial = $true
|
|
Restore-StudioVenvRollback
|
|
Write-Output ("active=" + $script:StudioVenvRollbackActive)
|
|
"""
|
|
env = os.environ.copy()
|
|
env["TEST_BACKUP_DIR"] = str(backup)
|
|
env["TEST_TARGET_DIR"] = str(target)
|
|
out = _run_powershell(shell, script, env)
|
|
|
|
# Nothing from the environment may be written through the link.
|
|
assert not (outside / "payload.txt").exists(), out
|
|
assert sorted(p.name for p in outside.iterdir()) == ["keep.txt"], out
|
|
assert (outside / "keep.txt").read_text(encoding = "utf-8") == "untouched", out
|
|
# An unresolved conflict keeps both copies, so the rollback stays tracked.
|
|
assert "active=True" in out, out
|
|
|
|
|
|
@pytest.mark.skipif(not POWERSHELLS, reason = "PowerShell is unavailable")
|
|
@pytest.mark.parametrize("shell", POWERSHELLS)
|
|
@pytest.mark.parametrize("case", ["missing", "unlaunchable", "working"])
|
|
def test_managed_python_readiness_probe(tmp_path: Path, shell: str, case: str):
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
readiness = _extract(r" function Test-VenvPythonReady \{.*?\n \}\n", source)
|
|
python_exe = tmp_path / "broken-python.cmd"
|
|
expected = "False"
|
|
if case == "unlaunchable":
|
|
python_exe.write_text("@exit /b 17\n", encoding = "utf-8")
|
|
elif case == "working":
|
|
python_exe = Path(sys.executable)
|
|
expected = "True"
|
|
|
|
script = f"""
|
|
$ErrorActionPreference = "Stop"
|
|
{readiness}
|
|
Write-Output (Test-VenvPythonReady -PythonExe $env:TEST_MANAGED_PYTHON)
|
|
"""
|
|
env = os.environ.copy()
|
|
env["TEST_MANAGED_PYTHON"] = str(python_exe)
|
|
assert _run_powershell(shell, script, env) == expected
|
|
|
|
|
|
def test_readiness_gate_precedes_installs_and_names_both_interpreters():
|
|
source = INSTALL_PS1.read_text(encoding = "utf-8")
|
|
gate = source.index("if (-not (Test-VenvPythonReady -PythonExe $VenvPython))")
|
|
marker = source.index(
|
|
'[System.IO.File]::WriteAllText((Join-Path $VenvDir ".unsloth-studio-owned"), "")'
|
|
)
|
|
# Anchored past the command token: uv is invoked as the resolved $script:UvExe.
|
|
first_uv_pip = source.index("pip install --python $VenvPython")
|
|
gpu_detection = source.index("function Invoke-AmdSmiNoElevate")
|
|
|
|
assert marker < gate < gpu_detection < first_uv_pip
|
|
assert 'Write-StudioLine " Managed Python: $VenvPython"' in source
|
|
assert 'Write-StudioLine " Recorded base Python home: $recordedBaseHome"' in source
|
|
assert 'return (Exit-InstallFailure "Managed Python is unavailable' in source
|