mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 08:42:25 +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>
1087 lines
45 KiB
Python
1087 lines
45 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
|
|
|
"""Refuse new interactive yes/no prompts in the installer and setup scripts.
|
|
|
|
#7016 added `Open Unsloth Studio in your default browser after launch?
|
|
[Y/n]` and had to be reverted in #8040: extra questions stall a piped
|
|
install and persist an answer nobody can find again. The only preference
|
|
setup may ask about is whether to start Studio when it finishes.
|
|
|
|
Allowlist, not a ban: every prompt in the tree is listed in
|
|
`APPROVED_PROMPTS` with its reason, so a new one fails with instructions
|
|
instead of landing quietly. Two passes catch it: literal `[Y/n]` markers,
|
|
and interactive read sites (shell `read` and `select`, `Read-Host` and
|
|
the console reads, `input()`, `set /p`) for a marker built from a
|
|
variable, as #7016's was. Text only, so it runs on every platform in the
|
|
parity matrix.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
# What a user runs to install, update or remove Unsloth. Everything these launch
|
|
# in turn is scanned too (`unsloth studio update` runs setup.sh, which builds
|
|
# whisper.cpp; install.sh fetches and runs the WSL bootstrap), since a question
|
|
# down there stalls the same install.
|
|
ENTRY_POINTS = (
|
|
"install.sh",
|
|
"install.ps1",
|
|
"studio/setup.sh",
|
|
"studio/setup.ps1",
|
|
"studio/setup.bat",
|
|
)
|
|
|
|
SCANNED_SCRIPTS = ENTRY_POINTS + (
|
|
"scripts/build_whisper_cpp.sh",
|
|
"scripts/install_gemma4_mlx.sh",
|
|
"scripts/install_qwen3_6_mlx.sh",
|
|
"scripts/install_rocm_wsl_strixhalo.sh",
|
|
"scripts/uninstall.sh",
|
|
"scripts/uninstall.ps1",
|
|
"studio/install_llama_prebuilt.py",
|
|
"studio/install_manifest.py",
|
|
"studio/install_node_prebuilt.py",
|
|
"studio/install_python_stack.py",
|
|
"studio/install_sd_cpp_prebuilt.py",
|
|
"studio/install_whisper_prebuilt.py",
|
|
# install_python_stack runs this one with sys.executable.
|
|
"studio/backend/requirements/single-env/patch_metadata.py",
|
|
)
|
|
|
|
# Every question these scripts may ask, keyed by (script, normalised
|
|
# question) because line numbers move and wording does not. Do NOT add an
|
|
# entry just to turn a build green: decide the prompt is wanted first.
|
|
APPROVED_PROMPTS: dict[tuple[str, str], str] = {
|
|
("install.sh", "start unsloth studio now?"): (
|
|
"The one sanctioned preference prompt: launch Studio after install."
|
|
),
|
|
("install.ps1", "start unsloth studio now?"): (
|
|
"Windows half of the sanctioned launch prompt above."
|
|
),
|
|
("install.sh", "accept?"): (
|
|
"Consent before `sudo apt-get install` of missing system packages, "
|
|
"not a preference. Declining prints the command to run by hand."
|
|
),
|
|
("studio/setup.sh", "accept?"): (
|
|
"Same sudo consent, for the packages llama.cpp needs to build. "
|
|
"Declining skips the build rather than aborting."
|
|
),
|
|
}
|
|
|
|
_MARKER = re.compile(
|
|
r"\[\s*[yn]\s*/\s*[yn]\s*\]|\(\s*[yn]\s*/\s*[yn]\s*\)|\byes\s*/\s*no\b", re.IGNORECASE
|
|
)
|
|
|
|
# Anything that blocks waiting on a human. `-p` is matched as an option word, not
|
|
# anchored on whitespace: it may be bundled (`read -rp`) or follow `read` directly.
|
|
# The scan stops at `;|&`, since a `mkdir -p` later on the line is another command.
|
|
_POSIX_READ = re.compile(
|
|
r"(?:^|[\s;&|(])read\s+(?![a-zA-Z_]+=)"
|
|
r"(?:-[a-zA-Z]*p(?=[\s\"']|$)|[^\n;|&]*?(?:<\s*/dev/tty|\s-[a-zA-Z]*p(?=[\s\"']|$)))"
|
|
)
|
|
|
|
# An unredirected `read` takes the terminal it inherited, so it blocks too. Loop
|
|
# and pipeline reads are fed by the `done < ...` or the pipe: data, not questions.
|
|
# Options then variable names to end of line, in command position, so the word
|
|
# `read` in a heredoc of prose is not a prompt.
|
|
_BARE_READ = re.compile(
|
|
r"(?:^|[;&(]|\b(?:if|elif|then|else)\b)\s*!?\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*"
|
|
# No variable name at all is valid: the answer lands in $REPLY.
|
|
r"read(?:\s+(?:-[a-zA-Z0-9]+|[\d.]+|\"\"|''))*(?:\s+[A-Za-z_][A-Za-z0-9_]*)*"
|
|
r"(?:\s*(?:;|\|\||&&).*)?\s*(?:#.*)?$"
|
|
)
|
|
_LOOP = re.compile(r"\b(?:while|until|for)\b")
|
|
|
|
# `select reply in Yes No` is the other builtin that blocks for an answer; its
|
|
# question is the PS3 assignment above it, which the nearby scan already reads.
|
|
_SELECT = re.compile(r"(?:^|[;&(])\s*select\s+[A-Za-z_][A-Za-z0-9_]*\s+in\s")
|
|
|
|
_PWSH_READ = re.compile(
|
|
r"Read-Host|PromptForChoice|(?:Console\]::(?:In\.)?|UI\.)Read(?:Line|Key)?\b|ReadKey\s*\(",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# The Python helpers setup runs, and the batch launcher.
|
|
_PY_READ = re.compile(
|
|
r"(?<![.\w])(?:input|getpass)\s*\(|getpass\.getpass\s*\("
|
|
r"|click\.confirm\s*\(|sys\.stdin(?:\.buffer)?\.read(?:line)?\s*\("
|
|
)
|
|
# In command position: starting the line or a `&`-joined command, after `do`, or
|
|
# as the body of a single-line `if`. Echoing the word `choice` is not a prompt.
|
|
# `pause` asks nothing but waits for a keypress, which stalls setup just the same.
|
|
_BAT_READ = re.compile(
|
|
r"(?:^\s*|[&(]\s*|\bdo\s+)@?\s*(?:set\s+/p\b|choice\b|pause\b)"
|
|
r"|^\s*@?\s*(?:if|else)\b.*?\s(?:set\s+/p\b|choice\b|pause\b)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# A helper filename, with or without its `scripts/` prefix: sibling invocations
|
|
# such as "$SCRIPT_DIR/build_deps.sh" carry no prefix. Resolved against the repo
|
|
# before it counts, so a name that is not a real script is ignored.
|
|
_HELPER_REF = re.compile(r"(?<![$\w])((?:[A-Za-z0-9_.-]+[\\/])*[A-Za-z0-9_.-]+\.(?:sh|ps1|py|bat))")
|
|
|
|
_QUOTED = re.compile(r'"([^"\\]*(?:\\.[^"\\]*)*)"' r"|'([^']*)'")
|
|
|
|
# An f-string field that calls something.
|
|
_FIELD = re.compile(r"\{[^}]*\(")
|
|
|
|
# `<# ... #>` opened and closed on one line.
|
|
_INLINE_BLOCK = re.compile(r"<#.*?#>", re.DOTALL)
|
|
|
|
# `@' ... '@` is literal, unlike the expandable `@" ... "@`. The uninstaller prints
|
|
# its help from one and install.ps1 embeds source in the other.
|
|
_HERESTRING_OPEN = re.compile(r"@'\s*$")
|
|
_HERESTRING_CLOSE = re.compile(r"^\s*'@")
|
|
|
|
# Regex/glob syntax: a `sed` pattern next to a prompt also contains a `?`.
|
|
_REGEXY = re.compile(r"\\|\(\?|\^|\$\(|\[0|\{[0-9]|\.\*|\|")
|
|
|
|
_ESCAPE = re.compile(r"\\[nrte]")
|
|
|
|
# A conversion or variable becomes a placeholder rather than vanishing, so wording
|
|
# spliced into the middle of an approved question cannot normalise back onto it.
|
|
_SUBSTITUTION = re.compile(r"%[-#0 +]*\d*(?:\.\d+)*[sdfxbq%]|\$\{?[A-Za-z_][A-Za-z0-9_]*\}?")
|
|
_TRAILING_SUBSTITUTION = re.compile(r"(?:\s*<var>)+$")
|
|
|
|
|
|
def _is_comment(line: str) -> bool:
|
|
"""Whole-line `#` comment. Inline ones need a parser to tell from a `#` inside
|
|
a string, and a false positive only costs an allowlist entry."""
|
|
return line.lstrip().startswith("#")
|
|
|
|
|
|
def _blank_strings(line: str, script: str = "") -> str:
|
|
"""Blank quoted text so a message naming an input API is not one. A string that
|
|
still executes is kept: a `$(...)` in a double-quoted shell or PowerShell string,
|
|
a field in an f-string. Single quotes and a missing f prefix are literal."""
|
|
|
|
def keep(match: re.Match) -> str:
|
|
text = match.group(0)
|
|
if script.endswith(".py"):
|
|
# Past the quotes, so a triple-quoted f-string keeps its prefix.
|
|
prefix = line[: match.start()].rstrip("\"'")[-2:].lower()
|
|
expands = "f" in prefix and _FIELD.search(text)
|
|
else:
|
|
expands = text.startswith('"') and "$(" in text
|
|
return text if expands else '""'
|
|
|
|
return _QUOTED.sub(keep, line)
|
|
|
|
|
|
def _is_interactive_read(
|
|
line: str,
|
|
script: str,
|
|
*,
|
|
loop_input: bool = False,
|
|
) -> bool:
|
|
"""A read that waits on a person: a prompt option, /dev/tty, `select`, `input()`,
|
|
`set /p`, or plain inherited stdin. Redirected and loop reads consume a file, not
|
|
a person. Quoted text is blanked first, so a message naming `Read-Host` is not
|
|
one."""
|
|
code = _blank_strings(line, script)
|
|
if script.endswith(".ps1"):
|
|
return bool(_PWSH_READ.search(code))
|
|
if script.endswith(".py"):
|
|
return bool(_PY_READ.search(code))
|
|
if script.endswith(".bat"):
|
|
# `set /p version=<VERSION.txt` reads the file, not the user, but the
|
|
# redirection belongs to that command alone.
|
|
return any("<" not in part and _BAT_READ.search(part) for part in code.split("&"))
|
|
if _POSIX_READ.search(code) or _SELECT.search(code):
|
|
return True
|
|
# Inside a file-fed loop only a bare read consumes the file: an explicit
|
|
# /dev/tty or -p read above overrode it and still waits on the terminal.
|
|
if loop_input:
|
|
return False
|
|
# Per command: in `read -r reply; echo done | tee log` the pipe is the echo's.
|
|
# `||` is a fallback, not a pipeline, and leaves the read on the terminal.
|
|
for command in code.split(";"):
|
|
piped = "|" in command.replace("||", "")
|
|
if "<" not in command and not piped and _BARE_READ.search(command):
|
|
return True
|
|
return False
|
|
|
|
|
|
# `<<EOF`, not the `<<<` here-string, and not inside a quoted string: install.sh
|
|
# prints a shell-profile marker containing `# <<< Unsloth ... <<<`.
|
|
_HEREDOC = re.compile(r"<<(?!<)-?\s*[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?")
|
|
_INTERPRETER = re.compile(r"\b(?:python[0-9.]*|node|perl|ruby|osascript)\b[^<]*<<")
|
|
|
|
|
|
def _blank_heredocs(lines: list[str]) -> list[str]:
|
|
"""Blank heredoc bodies, keeping the line count. A `read -r reply` shown in a
|
|
help text is documentation: scripts/uninstall.sh already prints one."""
|
|
out = list(lines)
|
|
terminator = ""
|
|
start = 0
|
|
for index, line in enumerate(lines):
|
|
if terminator:
|
|
if line.strip() == terminator:
|
|
out[start:index] = [""] * (index - start)
|
|
terminator = ""
|
|
continue
|
|
# Openers come from code: not from a string, not from an inline comment.
|
|
code = _blank_strings(line).split("#")[0]
|
|
match = _HEREDOC.search(code)
|
|
# `python - <<PY` runs its body. Blanking that would hide real code, which
|
|
# is the one thing worse than scanning it as shell.
|
|
if match and not _INTERPRETER.search(code):
|
|
terminator, start = match.group(1), index + 1
|
|
# An unterminated opener was not one. Blanking to EOF would silently blind the
|
|
# scan for the rest of the file, prompts included.
|
|
return out
|
|
|
|
|
|
def _redirected_loop_bodies(lines: list[str]) -> set[int]:
|
|
"""Indices inside a `do ... done < file` block. The redirection feeds every read
|
|
in the body, but it sits on the `done`, so the read line alone looks interactive."""
|
|
inside: set[int] = set()
|
|
opened: list[int] = []
|
|
for index, line in enumerate(lines):
|
|
if re.search(r"(?:^|[;&])\s*do\b|\bdo\s*$", line):
|
|
opened.append(index)
|
|
if re.match(r"\s*done\b", line):
|
|
if not opened:
|
|
continue
|
|
start = opened.pop()
|
|
# Only stdin feeds them: `done | tee` pipes the output away, and
|
|
# `done 3<config` opens a spare descriptor.
|
|
if re.search(r"(?<![1-9])<", line):
|
|
inside.update(range(start, index))
|
|
return inside
|
|
|
|
|
|
def _outside_docstring(line: str, delimiter: str) -> tuple[str, str]:
|
|
"""Strip triple-quoted regions from a Python line, returning what is left and the
|
|
delimiter still open. A docstring showing an `input()` example documents the
|
|
script, it does not prompt."""
|
|
while True:
|
|
if delimiter:
|
|
end = line.find(delimiter)
|
|
if end == -1:
|
|
return "", delimiter
|
|
line, delimiter = line[end + 3 :], ""
|
|
continue
|
|
opener = min((i for i in (line.find('"""'), line.find("'''")) if i != -1), default = -1)
|
|
if opener == -1:
|
|
return line, ""
|
|
# An f-string field executes, so this one is code, not documentation.
|
|
if "f" in line[max(0, opener - 2) : opener].lower():
|
|
return line, ""
|
|
head, delimiter = line[:opener], line[opener : opener + 3]
|
|
rest, delimiter = _outside_docstring(line[opener + 3 :], delimiter)
|
|
return head + rest, delimiter
|
|
|
|
|
|
def blank_comments(source: str, script: str) -> str:
|
|
"""Blank comments, keeping the line count so numbers still line up. `.ps1` files
|
|
carry `<# ... #>` blocks, where a documented Read-Host example would otherwise
|
|
fail CI over a prompt that cannot run. `.bat` comments are REM or `::`."""
|
|
lines = []
|
|
in_block = False
|
|
in_docstring = ""
|
|
powershell = script.endswith(".ps1")
|
|
batch = script.endswith(".bat")
|
|
python = script.endswith(".py")
|
|
opened = -1
|
|
in_herestring = False
|
|
for index, line in enumerate(source.splitlines()):
|
|
if powershell and in_block:
|
|
# Code can follow the terminator on the same line.
|
|
lines.append(line.split("#>", 1)[1] if "#>" in line else "")
|
|
in_block = "#>" not in line
|
|
continue
|
|
# A comment naming a delimiter is not one: reading it as an opener would
|
|
# blank the rest of the file and take every prompt in it out of the scan.
|
|
if _is_comment(line) or (batch and re.match(r"\s*(?:REM\b|::)", line, re.IGNORECASE)):
|
|
lines.append("")
|
|
continue
|
|
if python:
|
|
line, in_docstring = _outside_docstring(line, in_docstring)
|
|
if in_docstring and opened < 0:
|
|
opened = index
|
|
elif not in_docstring:
|
|
opened = -1
|
|
if powershell and in_herestring:
|
|
lines.append("")
|
|
in_herestring = not _HERESTRING_CLOSE.match(line)
|
|
continue
|
|
if powershell:
|
|
if _HERESTRING_OPEN.search(_blank_strings(line)):
|
|
in_herestring = True
|
|
line = _INLINE_BLOCK.sub("", line)
|
|
if "<#" in line:
|
|
lines.append(line.split("<#", 1)[0])
|
|
in_block, opened = True, index
|
|
continue
|
|
lines.append(line)
|
|
if in_block or in_docstring:
|
|
# Unterminated, so it was never a comment or a docstring. Put the lines back.
|
|
original = source.splitlines()
|
|
lines[opened + 1 :] = original[opened + 1 :]
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _quoted_strings(line: str) -> list[str]:
|
|
"""Outer strings first, then anything quoted inside an executable region."""
|
|
found = []
|
|
for match in _QUOTED.finditer(line):
|
|
for group in match.groups():
|
|
if not group:
|
|
continue
|
|
found.append(group)
|
|
if "$(" in group or _FIELD.search(group):
|
|
found.extend(_quoted_strings(group))
|
|
return found
|
|
|
|
|
|
def normalise_question(text: str) -> str:
|
|
"""Reduce a prompt string to a stable allowlist key: drop the marker and edge
|
|
punctuation, placeholder the substitutions, then lowercase."""
|
|
text = _MARKER.sub(" ", text)
|
|
text = _ESCAPE.sub(" ", text)
|
|
text = _SUBSTITUTION.sub("<var>", text)
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
# A trailing one is the `[Y/n]` hint the marker pass already read.
|
|
text = _TRAILING_SUBSTITUTION.sub("", text)
|
|
return text.strip(" -:>*_=").lower()
|
|
|
|
|
|
def _looks_like_question(text: str) -> bool:
|
|
"""Prose with a question mark, not a `sed`/`-match` pattern that has one."""
|
|
return "?" in text and not _REGEXY.search(text)
|
|
|
|
|
|
def _nearby_questions(lines: list[str], index: int, *, direction: int) -> list[str]:
|
|
"""Readable questions around `lines[index]`, nearest first. Walk back (-1) from a
|
|
read site to the `printf` that drew it, forward (+1) from a bare `[Y/n]` hint
|
|
assigned into a variable and interpolated below (the #7016 shape)."""
|
|
candidates = list(_quoted_strings(lines[index]))
|
|
for step in range(1, 9):
|
|
neighbour = index + direction * step
|
|
if not 0 <= neighbour < len(lines):
|
|
break
|
|
line = lines[neighbour]
|
|
if not line.strip():
|
|
continue
|
|
candidates.extend(_quoted_strings(line))
|
|
|
|
questions = []
|
|
for text in candidates:
|
|
if not _looks_like_question(text):
|
|
continue
|
|
normalised = normalise_question(text)
|
|
if normalised and normalised not in questions:
|
|
questions.append(normalised)
|
|
return questions
|
|
|
|
|
|
def _questions_for_read(lines: list[str], index: int) -> list[str]:
|
|
"""Allowlist keys for a read site. Every question in reach, not just the nearest:
|
|
one read can serve a branch each, and validating only the closest lets the other
|
|
branch through. Falls back to the read line so an unlabelled prompt still has to
|
|
be allowlisted rather than ignored."""
|
|
return _nearby_questions(lines, index, direction = -1) or [
|
|
f"<unlabelled read: {normalise_question(lines[index])}>"
|
|
]
|
|
|
|
|
|
def find_prompts(script: str, source: str) -> list[tuple[str, int, str]]:
|
|
"""Return (script, line_number, normalised question) for every prompt site."""
|
|
lines = blank_comments(source, script).splitlines()
|
|
if not script.endswith((".ps1", ".py", ".bat")):
|
|
lines = _blank_heredocs(lines)
|
|
redirected = _redirected_loop_bodies(lines)
|
|
found: dict[tuple[str, str], tuple[str, int, str]] = {}
|
|
|
|
for index, line in enumerate(lines):
|
|
line_number = index + 1
|
|
|
|
for text in _quoted_strings(line):
|
|
if not _MARKER.search(text):
|
|
continue
|
|
# A bare `[Y/n]` is a hint variable; its question is printed below.
|
|
forward = _nearby_questions(lines, index, direction = 1)
|
|
question = (
|
|
normalise_question(text)
|
|
or (forward[0] if forward else "")
|
|
or f"<yes/no marker with no question: {text.strip()}>"
|
|
)
|
|
found.setdefault((script, question), (script, line_number, question))
|
|
|
|
if _is_interactive_read(line, script, loop_input = index in redirected):
|
|
for question in _questions_for_read(lines, index):
|
|
found.setdefault((script, question), (script, line_number, question))
|
|
|
|
return sorted(found.values(), key = lambda item: item[1])
|
|
|
|
|
|
def _failure_message(script: str, line_number: int, question: str) -> str:
|
|
return (
|
|
f"\n"
|
|
f"A new interactive yes/no prompt was added to {script} (line {line_number}):\n"
|
|
f"\n"
|
|
f" {question!r}\n"
|
|
f"\n"
|
|
f"Installers and setup scripts must not grow new questions. The only\n"
|
|
f"preference the setup is allowed to ask about is 'Start Unsloth Studio\n"
|
|
f"now?'; the remaining approved prompts are sudo consent before we\n"
|
|
f"elevate. A prompt added here stalls `curl ... | sh` installs and\n"
|
|
f"persists an answer the user cannot easily find again. This is what\n"
|
|
f"#7016 did and why it was reverted in #8040.\n"
|
|
f"\n"
|
|
f"VERIFY THAT THIS PROMPT IS SUPPOSED TO BE HERE.\n"
|
|
f"\n"
|
|
f" - If it is not: remove it. Take the setting as a flag or an\n"
|
|
f" environment variable with a non-interactive default instead.\n"
|
|
f" - If it genuinely is: add\n"
|
|
f"\n"
|
|
f' ({script!r}, {question!r}): "why this prompt is needed",\n'
|
|
f"\n"
|
|
f" to APPROVED_PROMPTS in tests/test_installer_interactive_prompts.py\n"
|
|
f" in the same PR, so the decision shows up in the diff.\n"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("script", SCANNED_SCRIPTS)
|
|
def test_no_unapproved_interactive_prompts(script: str):
|
|
path = REPO_ROOT / script
|
|
assert path.is_file(), f"{script} is missing -- update SCANNED_SCRIPTS if it moved"
|
|
|
|
for found_script, line_number, question in find_prompts(
|
|
script, path.read_text(encoding = "utf-8")
|
|
):
|
|
if (found_script, question) not in APPROVED_PROMPTS:
|
|
pytest.fail(_failure_message(found_script, line_number, question), pytrace = False)
|
|
|
|
|
|
def test_approved_prompts_all_still_exist():
|
|
"""Delete a prompt, delete its entry: a stale one waves through a future prompt
|
|
that happens to reuse the wording."""
|
|
live = set()
|
|
for script in SCANNED_SCRIPTS:
|
|
source = (REPO_ROOT / script).read_text(encoding = "utf-8")
|
|
live.update(
|
|
(found_script, question) for found_script, _, question in find_prompts(script, source)
|
|
)
|
|
|
|
stale = sorted(key for key in APPROVED_PROMPTS if key not in live)
|
|
assert not stale, (
|
|
f"APPROVED_PROMPTS lists prompts that no longer exist: {stale}. "
|
|
f"Remove the entries from tests/test_installer_interactive_prompts.py."
|
|
)
|
|
|
|
|
|
def test_every_installer_script_is_scanned():
|
|
"""A prompt in a script nobody scans is the same regression, one step removed."""
|
|
patterns = (
|
|
"install*.sh",
|
|
"install*.ps1",
|
|
"studio/setup*.sh",
|
|
"studio/setup*.ps1",
|
|
"studio/setup*.bat",
|
|
"setup*.sh",
|
|
"setup*.ps1",
|
|
"setup*.bat",
|
|
"install*.bat",
|
|
"install*.py",
|
|
"studio/install_*.py",
|
|
"scripts/install*.sh",
|
|
"scripts/install*.ps1",
|
|
"uninstall*.sh",
|
|
"uninstall*.ps1",
|
|
"uninstall*.bat",
|
|
"uninstall*.py",
|
|
"scripts/uninstall*.sh",
|
|
"scripts/uninstall*.ps1",
|
|
)
|
|
on_disk = {
|
|
path.relative_to(REPO_ROOT).as_posix()
|
|
for pattern in patterns
|
|
for path in REPO_ROOT.glob(pattern)
|
|
}
|
|
unscanned = sorted(on_disk - set(SCANNED_SCRIPTS))
|
|
assert not unscanned, (
|
|
f"installer/setup scripts not covered by the prompt guard: {unscanned}. "
|
|
f"Add them to SCANNED_SCRIPTS in tests/test_installer_interactive_prompts.py "
|
|
f"and to the paths filter in .github/workflows/cross-platform-parity-ci.yml."
|
|
)
|
|
|
|
|
|
def test_helpers_the_installers_invoke_are_scanned():
|
|
"""Naming the helpers by hand only ever covers the ones we thought of, so read
|
|
them back out instead: whatever the installers reach, the guard scans. Every
|
|
scanned script is a source, not just the entry points, so a helper that grows
|
|
a helper of its own is caught as soon as the first one is listed here."""
|
|
referenced = set()
|
|
for script in SCANNED_SCRIPTS:
|
|
path = REPO_ROOT / script
|
|
source = blank_comments(path.read_text(encoding = "utf-8"), script)
|
|
for match in _HELPER_REF.finditer(source):
|
|
# Below the script that names it, below the repo, or in scripts/, by
|
|
# full path and by name so a URL still resolves to the local copy.
|
|
# What resolves nowhere is a filename in a message, not an invocation.
|
|
reference = match.group(1).replace("\\", "/")
|
|
name = reference.rsplit("/", 1)[-1]
|
|
for candidate in (
|
|
path.parent / reference,
|
|
REPO_ROOT / reference,
|
|
path.parent / name,
|
|
REPO_ROOT / "scripts" / name,
|
|
):
|
|
if candidate.is_file():
|
|
referenced.add(candidate.resolve().relative_to(REPO_ROOT).as_posix())
|
|
|
|
unscanned = sorted(referenced - set(SCANNED_SCRIPTS))
|
|
assert not unscanned, (
|
|
f"helpers invoked by the installers but not scanned: {unscanned}. "
|
|
f"Add them to SCANNED_SCRIPTS in tests/test_installer_interactive_prompts.py "
|
|
f"and to the paths filter in .github/workflows/cross-platform-parity-ci.yml."
|
|
)
|
|
|
|
|
|
def test_the_workflow_runs_for_every_scanned_script():
|
|
"""A scanned script whose path filter is missing is only checked on the PR that
|
|
registers it. Read the filters back rather than keeping two lists in step by
|
|
hand. Parsed with a regex, not yaml: the parity runner installs pytest only."""
|
|
workflow = (REPO_ROOT / ".github/workflows/cross-platform-parity-ci.yml").read_text(
|
|
encoding = "utf-8"
|
|
)
|
|
blocks, collecting = [], None
|
|
for line in workflow.splitlines():
|
|
entry = re.match(r"^\s*-\s*'([^']+)'\s*$", line)
|
|
if collecting is not None and entry:
|
|
collecting.append(entry.group(1))
|
|
continue
|
|
# A comment or a blank line inside the list does not end it. Treating one as the end
|
|
# dropped every filter after it, so this guard passed while reading nothing.
|
|
if collecting is not None and (not line.strip() or line.strip().startswith("#")):
|
|
continue
|
|
if collecting:
|
|
blocks.append(collecting)
|
|
collecting = [] if line.strip() == "paths:" else None
|
|
if collecting:
|
|
blocks.append(collecting)
|
|
assert blocks, "no paths: filters found in cross-platform-parity-ci.yml"
|
|
|
|
def matcher(pattern: str) -> re.Pattern:
|
|
# GitHub's `*` stops at a path separator; `**` does not.
|
|
body = (
|
|
re.escape(pattern)
|
|
.replace(r"\*\*", "\x00")
|
|
.replace(r"\*", "[^/]*")
|
|
.replace("\x00", ".*")
|
|
)
|
|
return re.compile(body + "$")
|
|
|
|
def covered(script: str, patterns: list[str]) -> bool:
|
|
# GitHub applies `!` exclusions in order, so the last match decides.
|
|
included = False
|
|
for pattern in patterns:
|
|
negated = pattern.startswith("!")
|
|
if matcher(pattern.lstrip("!")).match(script):
|
|
included = not negated
|
|
return included
|
|
|
|
# Each event on its own: a filter present only on push leaves pull requests bare.
|
|
for patterns in blocks:
|
|
uncovered = sorted(script for script in SCANNED_SCRIPTS if not covered(script, patterns))
|
|
assert not uncovered, (
|
|
f"scanned scripts with no path filter in cross-platform-parity-ci.yml: {uncovered}. "
|
|
f"A later PR touching only one of these would not run this guard."
|
|
)
|
|
|
|
|
|
def test_approved_prompts_are_documented():
|
|
for key, reason in APPROVED_PROMPTS.items():
|
|
assert reason.strip(), f"APPROVED_PROMPTS[{key}] needs a reason, not an empty string"
|
|
|
|
|
|
# Detector self-tests: a scan that silently stops matching passes everything.
|
|
|
|
|
|
def test_detects_literal_marker_prompt():
|
|
source = 'printf " Enable telemetry? [Y/n] "\nread -r _reply </dev/tty || _reply="n"\n'
|
|
assert find_prompts("install.sh", source) == [("install.sh", 1, "enable telemetry?")]
|
|
|
|
|
|
def test_detects_prompt_whose_marker_comes_from_a_variable():
|
|
"""The #7016 shape: only the read site gives it away."""
|
|
source = (
|
|
'_browser_hint="[Y/n]"\n'
|
|
'printf " Open Unsloth Studio in your default browser after launch? %s " "$_browser_hint"\n'
|
|
'read -r _browser_reply </dev/tty || _browser_reply=""\n'
|
|
)
|
|
questions = [question for _, _, question in find_prompts("install.sh", source)]
|
|
assert "open unsloth studio in your default browser after launch?" in questions
|
|
|
|
|
|
def test_detects_powershell_prompt_whose_marker_comes_from_a_variable():
|
|
source = (
|
|
"$_browserHint = if ($_existingPref -eq '0') { '[y/N]' } else { '[Y/n]' }\n"
|
|
'$_browserReply = Read-Host " Open Unsloth Studio in your default browser after launch? $_browserHint"\n'
|
|
)
|
|
assert [question for _, _, question in find_prompts("install.ps1", source)] == [
|
|
"open unsloth studio in your default browser after launch?",
|
|
]
|
|
|
|
|
|
def test_detects_read_dash_p():
|
|
source = 'read -p "Keep existing config? [y/N] " _reply\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"keep existing config?",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"read_line",
|
|
(
|
|
'read -p " Build llama.cpp with CUDA support? " _reply',
|
|
'read -rp " Build llama.cpp with CUDA support? " _reply',
|
|
'read -rsp " Build llama.cpp with CUDA support? " _reply',
|
|
'read -r -p " Build llama.cpp with CUDA support? " _reply',
|
|
'read -p" Build llama.cpp with CUDA support? " _reply',
|
|
),
|
|
)
|
|
def test_detects_read_dash_p_without_a_marker(read_line: str):
|
|
"""`-p` carries the prompt itself, so there is no marker and only the read pass
|
|
stands between these and a stalled install. Bundling is the usual bash
|
|
spelling, so it must not hinge on a space before `-p`."""
|
|
assert [question for _, _, question in find_prompts("studio/setup.sh", read_line + "\n")] == [
|
|
"build llama.cpp with cuda support?",
|
|
]
|
|
|
|
|
|
def test_ignores_dash_p_belonging_to_a_later_command():
|
|
"""`mkdir -p` after a non-interactive read is not a prompt: matching it fails CI
|
|
on an ordinary installer edit."""
|
|
source = (
|
|
'read -r _line < "$config_file"; mkdir -p "$_dest"\n'
|
|
'while IFS= read -r _root; do mkdir -p "$_root"; done < "$manifest"\n'
|
|
'read -r _v < "$_pci_vendor" && install -p "$_v" "$_dest"\n'
|
|
)
|
|
assert find_prompts("install.sh", source) == []
|
|
|
|
|
|
def test_detects_powershell_read_host():
|
|
source = '$reply = Read-Host " Install desktop shortcuts? [Y/n]"\n'
|
|
assert [question for _, _, question in find_prompts("install.ps1", source)] == [
|
|
"install desktop shortcuts?",
|
|
]
|
|
|
|
|
|
def test_detects_powershell_console_readline():
|
|
"""Read-Host is the usual spelling, but the console reads block just as hard and
|
|
can carry their question in a variable, out of the marker pass's reach."""
|
|
source = (
|
|
'Write-Host " Install desktop shortcuts? $_hint" -NoNewline\n'
|
|
"$reply = [Console]::ReadLine()\n"
|
|
)
|
|
assert [question for _, _, question in find_prompts("install.ps1", source)] == [
|
|
"install desktop shortcuts?",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("read_line", ("read -r _reply", "read _reply", " read -r _reply"))
|
|
def test_detects_a_bare_read_from_inherited_stdin(read_line: str):
|
|
"""The commonest prompt of all: a question printed with no marker, answered by a
|
|
read with no redirection. It takes the terminal the installer inherited."""
|
|
source = f'printf " Continue with installation? "\n{read_line}\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"continue with installation?",
|
|
]
|
|
|
|
|
|
def test_detects_a_bare_read_with_a_fallback():
|
|
"""`|| reply=n` is the house style for an EOF default, and a fallback is not a
|
|
pipeline: the read still takes the terminal."""
|
|
source = 'printf " Continue with installation? "\nread -r _reply || _reply=n\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"continue with installation?",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"read_line", ("read -r -n 1 _reply", "read -r -t 10 _reply", "read -rn1 _reply")
|
|
)
|
|
def test_detects_a_bare_read_with_option_arguments(read_line: str):
|
|
"""A one-character or timed confirmation is still a confirmation."""
|
|
source = f'printf " Continue with installation? "\n{read_line}\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"continue with installation?",
|
|
]
|
|
|
|
|
|
def test_detects_an_assignment_prefixed_read():
|
|
"""`IFS= read -r reply` is one command, not an assignment."""
|
|
source = 'printf " Continue with installation? "\nIFS= read -r _reply\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"continue with installation?",
|
|
]
|
|
|
|
|
|
def test_a_comment_naming_a_delimiter_opens_nothing():
|
|
"""The heredoc lesson applied to the other two languages: reading a delimiter out
|
|
of a comment blanks the rest of the file and takes every prompt in it with it."""
|
|
python_source = '# opening delimiter is """\nprint(" Enable telemetry? ")\n_reply = input()\n'
|
|
assert [
|
|
question for _, _, question in find_prompts("studio/install_python_stack.py", python_source)
|
|
] == ["enable telemetry?"]
|
|
pwsh_source = '# block comments begin with <#\n$reply = Read-Host " Enable telemetry? "\n'
|
|
assert [question for _, _, question in find_prompts("install.ps1", pwsh_source)] == [
|
|
"enable telemetry?",
|
|
]
|
|
|
|
|
|
def test_an_unterminated_docstring_blanks_nothing():
|
|
source = '_text = """open\nprint(" Enable telemetry? ")\n_reply = input()\n'
|
|
questions = [
|
|
question for _, _, question in find_prompts("studio/install_python_stack.py", source)
|
|
]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_detects_a_read_with_a_trailing_comment():
|
|
source = 'printf " Enable telemetry? "\nread -r _reply # use the inherited terminal\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"enable telemetry?",
|
|
]
|
|
|
|
|
|
def test_an_inline_comment_does_not_open_a_heredoc():
|
|
source = ': # example uses <<EOF\nprintf " Enable telemetry? "\nread -r _reply\n'
|
|
questions = [question for _, _, question in find_prompts("install.sh", source)]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_an_unterminated_heredoc_opener_blanks_nothing():
|
|
"""Blanking to end of file would silently blind the scan for the rest of it."""
|
|
source = 'cat <<EOF\nstuff\nprintf " Enable telemetry? "\nread -r _reply\n'
|
|
questions = [question for _, _, question in find_prompts("install.sh", source)]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_a_pipe_on_a_later_command_does_not_feed_the_read():
|
|
source = 'printf " Enable telemetry? "\nread -r _reply; echo done | tee install.log\n'
|
|
questions = [question for _, _, question in find_prompts("install.sh", source)]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_a_spare_descriptor_on_the_done_is_not_stdin():
|
|
source = (
|
|
'while true; do\n printf " Enable telemetry? "\n read -r _reply\ndone 3<config\n'
|
|
)
|
|
questions = [question for _, _, question in find_prompts("install.sh", source)]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_ignores_code_shaped_text_in_a_literal_string():
|
|
"""Single quotes do not expand in PowerShell, and a plain Python string has no
|
|
fields. Keeping those would fail CI on a diagnostic message."""
|
|
assert find_prompts("install.ps1", "Write-Host 'Example: $(Read-Host \"Q?\")'\n") == []
|
|
assert (
|
|
find_prompts("studio/install_python_stack.py", "print(\"Example: {input('Q?')}\")\n") == []
|
|
)
|
|
|
|
|
|
def test_ignores_a_batch_read_from_a_file():
|
|
assert find_prompts("studio/setup.bat", "set /p version=<VERSION.txt\n") == []
|
|
|
|
|
|
def test_detects_a_read_followed_by_another_command():
|
|
source = 'printf " Continue with installation? "\nread -r _reply; echo done\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"continue with installation?",
|
|
]
|
|
|
|
|
|
def test_keeps_a_loop_read_when_only_the_output_is_piped():
|
|
"""`done | tee` consumes the loop's stdout. Its reads still take the terminal."""
|
|
source = 'while true; do\n printf " Enable telemetry? "\n read -r _reply\ndone | tee install.log\n'
|
|
questions = [question for _, _, question in find_prompts("install.sh", source)]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_ignores_a_command_documented_in_a_heredoc():
|
|
source = "_usage() {\n cat <<EOF\nExample: read -r reply\nEOF\n}\n"
|
|
assert find_prompts("scripts/uninstall.sh", source) == []
|
|
|
|
|
|
def test_a_here_string_does_not_open_a_heredoc():
|
|
"""`<<<` is a here-string, and install.sh prints one inside a profile marker.
|
|
Reading it as a heredoc blanked the rest of the file, prompts included."""
|
|
source = (
|
|
"printf '# <<< Unsloth marker <<<\\n'\nprintf \" Enable telemetry? \"\nread -r _reply\n"
|
|
)
|
|
questions = [question for _, _, question in find_prompts("install.sh", source)]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_detects_a_prompt_inside_a_triple_quoted_f_string():
|
|
source = 'print(f"""Answer: {input(\'Enable telemetry? \')}""")\n'
|
|
questions = [
|
|
question for _, _, question in find_prompts("studio/install_python_stack.py", source)
|
|
]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_detects_a_directly_imported_getpass():
|
|
source = 'print(" Continue with installation? ")\n_reply = getpass()\n'
|
|
assert [
|
|
question for _, _, question in find_prompts("studio/install_python_stack.py", source)
|
|
] == ["continue with installation?"]
|
|
|
|
|
|
def test_detects_a_batch_pause():
|
|
"""It asks nothing, but an unattended install still stops dead on it."""
|
|
source = "echo Review the notes above.\npause\n"
|
|
assert len(find_prompts("studio/setup.bat", source)) == 1
|
|
|
|
|
|
def test_detects_a_batch_prompt_in_a_conditional():
|
|
source = 'if exist config choice /M "Enable telemetry?"\n'
|
|
assert [question for _, _, question in find_prompts("studio/setup.bat", source)] == [
|
|
"enable telemetry?",
|
|
]
|
|
|
|
|
|
def test_detects_a_negated_read():
|
|
"""`if ! cmd` is house style in these scripts; a read is no different."""
|
|
source = 'printf " Enable telemetry? "\nif ! read -r _reply; then _reply=n; fi\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"enable telemetry?",
|
|
]
|
|
|
|
|
|
def test_an_interpreter_heredoc_is_not_blanked():
|
|
"""`python - <<PY` runs its body. Hiding real code is worse than reading it as
|
|
shell, which still leaves the marker pass looking at it."""
|
|
source = 'python - <<PY\nprint("Enable telemetry? [Y/n]")\nPY\n'
|
|
questions = [question for _, _, question in find_prompts("install.sh", source)]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_detects_a_read_used_as_a_condition():
|
|
source = 'printf " Continue with installation? "\nif read -r _reply; then :; fi\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"continue with installation?",
|
|
]
|
|
|
|
|
|
def test_keeps_an_explicit_terminal_read_inside_a_fed_loop():
|
|
"""`</dev/tty` overrides the loop's stdin, so only a bare read consumes the file."""
|
|
source = (
|
|
'while true; do\n printf " Enable telemetry? "\n'
|
|
" read -r _reply </dev/tty\ndone < manifest\n"
|
|
)
|
|
questions = [question for _, _, question in find_prompts("install.sh", source)]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_a_batch_redirection_belongs_to_its_own_command():
|
|
source = 'choice /M "Enable telemetry?" & set /p version=<VERSION.txt\n'
|
|
questions = [question for _, _, question in find_prompts("studio/setup.bat", source)]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_ignores_a_powershell_literal_here_string():
|
|
"""`@' ... '@` cannot expand, and the uninstaller prints its help from one."""
|
|
source = "Write-Host @'\nRead-Host \"Enable telemetry? [Y/n]\"\n'@\n"
|
|
assert find_prompts("scripts/uninstall.ps1", source) == []
|
|
|
|
|
|
def test_ignores_a_read_in_a_loop_redirected_at_the_done():
|
|
"""The redirection feeds the read, it just sits three lines below it."""
|
|
source = 'while true; do\n read -r _line || break\ndone < "$manifest"\n'
|
|
assert find_prompts("install.sh", source) == []
|
|
|
|
|
|
def test_detects_a_python_stdin_read():
|
|
source = 'print(" Continue with installation? ")\n_reply = sys.stdin.readline()\n'
|
|
assert [
|
|
question for _, _, question in find_prompts("studio/install_python_stack.py", source)
|
|
] == [
|
|
"continue with installation?",
|
|
]
|
|
|
|
|
|
def test_detects_a_prompt_inside_an_f_string():
|
|
"""An f-string field executes, so blanking the string cannot blank it."""
|
|
source = "print(f\"Answer: {input('Enable telemetry? ')}\")\n"
|
|
questions = [
|
|
question for _, _, question in find_prompts("studio/install_python_stack.py", source)
|
|
]
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_ignores_a_python_function_named_confirm():
|
|
source = "def confirm(value):\n return value\n"
|
|
assert find_prompts("studio/install_python_stack.py", source) == []
|
|
|
|
|
|
def test_detects_a_batch_prompt_with_echo_suppressed():
|
|
source = '@choice /M "Enable telemetry?"\n'
|
|
assert [question for _, _, question in find_prompts("studio/setup.bat", source)] == [
|
|
"enable telemetry?",
|
|
]
|
|
|
|
|
|
def test_detects_a_read_with_no_variable():
|
|
"""`read -r` on its own is valid: the answer lands in $REPLY."""
|
|
source = 'printf " Continue with installation? "\nread -r\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"continue with installation?",
|
|
]
|
|
|
|
|
|
def test_detects_a_prompt_inside_an_expandable_string():
|
|
"""A `$(...)` subexpression executes, so blanking quoted text cannot blank it."""
|
|
source = "Write-Host \"Response: $(Read-Host 'Enable telemetry?')\"\n"
|
|
assert [question for _, _, question in find_prompts("install.ps1", source)] == [
|
|
"enable telemetry?",
|
|
]
|
|
|
|
|
|
def test_ignores_a_batch_input_command_named_in_a_message():
|
|
source = 'echo choice /M "Enable telemetry?" is not supported here\n'
|
|
assert find_prompts("studio/setup.bat", source) == []
|
|
|
|
|
|
def test_ignores_a_python_docstring_example():
|
|
source = '"""Doc.\n\nreply = input("Enable telemetry? [Y/n]")\n"""\nvalue = 1\n'
|
|
assert find_prompts("studio/install_python_stack.py", source) == []
|
|
|
|
|
|
def test_helper_reference_keeps_a_windows_subdirectory():
|
|
assert _HELPER_REF.findall(r'& "$PSScriptRoot\helpers\setup_extra.ps1"') == [
|
|
r"helpers\setup_extra.ps1",
|
|
]
|
|
|
|
|
|
def test_detects_a_python_helper_prompt():
|
|
"""setup.sh runs these with `python <helper>.py`, so `input()` blocks the update."""
|
|
source = 'reply = input("Enable telemetry? [Y/n] ")\n'
|
|
assert [question for _, _, question in find_prompts("studio/install_python_stack.py", source)][
|
|
0
|
|
] == "enable telemetry?"
|
|
|
|
|
|
def test_detects_a_batch_prompt():
|
|
source = 'set /p _reply="Enable telemetry? [Y/n] "\n'
|
|
assert [question for _, _, question in find_prompts("studio/setup.bat", source)] == [
|
|
"enable telemetry?",
|
|
]
|
|
|
|
|
|
def test_keeps_code_after_a_block_comment_terminator():
|
|
source = '<#\n.SYNOPSIS\n#> $reply = Read-Host "Enable telemetry? [Y/n]"\n'
|
|
assert [question for _, _, question in find_prompts("install.ps1", source)] == [
|
|
"enable telemetry?",
|
|
]
|
|
|
|
|
|
def test_detects_powershell_host_ui_readline():
|
|
source = 'Write-Host " Continue? " -NoNewline\n$reply = $Host.UI.ReadLine()\n'
|
|
assert [question for _, _, question in find_prompts("install.ps1", source)] == ["continue?"]
|
|
|
|
|
|
def test_helper_reference_keeps_its_subdirectory():
|
|
"""A nested helper resolves by its path, a URL by its name."""
|
|
assert _HELPER_REF.findall('sh "$SCRIPT_DIR/helpers/build_deps.sh"') == [
|
|
"helpers/build_deps.sh",
|
|
]
|
|
|
|
|
|
def test_detects_a_select_loop():
|
|
"""The other builtin that blocks for an answer. PS3 holds the question."""
|
|
source = 'PS3=" Continue with installation? "\nselect _reply in Yes No; do break; done\n'
|
|
assert [question for _, _, question in find_prompts("install.sh", source)] == [
|
|
"continue with installation?",
|
|
]
|
|
|
|
|
|
def test_interpolation_cannot_rewrite_an_approved_question():
|
|
"""Splicing new wording into an approved prompt is a new question. A substitution
|
|
at the end is only the marker hint, so that one still normalises away."""
|
|
assert normalise_question("Start Unsloth Studio %s now? [Y/n]") == (
|
|
"start unsloth studio <var> now?"
|
|
)
|
|
assert normalise_question(" Start Unsloth Studio now? %s ") == "start unsloth studio now?"
|
|
|
|
|
|
def test_ignores_an_input_api_named_in_a_message():
|
|
source = 'Write-Host "Read-Host is unavailable on this terminal"\n'
|
|
assert find_prompts("install.ps1", source) == []
|
|
|
|
|
|
def test_ignores_a_single_line_powershell_block_comment():
|
|
source = '<# Read-Host "Enable telemetry? [Y/n]" #>\nWrite-Host "done"\n'
|
|
assert find_prompts("install.ps1", source) == []
|
|
|
|
|
|
def test_detects_powershell_console_read():
|
|
source = 'Write-Host " Continue? " -NoNewline\n$key = [Console]::Read()\n'
|
|
assert [question for _, _, question in find_prompts("install.ps1", source)] == ["continue?"]
|
|
|
|
|
|
def test_validates_every_question_a_read_can_ask():
|
|
"""One read serving a branch each: approving the nearest question would wave the
|
|
other one through under an allowlisted key."""
|
|
source = (
|
|
'if [ "$_telemetry" = ask ]; then\n'
|
|
' printf " Enable telemetry? [Y/n] "\n'
|
|
"else\n"
|
|
' printf " Start Unsloth Studio now? [Y/n] "\n'
|
|
"fi\n"
|
|
'read -r _reply </dev/tty || _reply="n"\n'
|
|
)
|
|
questions = {question for _, _, question in find_prompts("install.sh", source)}
|
|
assert "enable telemetry?" in questions
|
|
|
|
|
|
def test_ignores_a_powershell_block_comment():
|
|
"""Comment-based help documents what the script does, sometimes by example. A
|
|
prompt that cannot run must not fail CI: studio/setup.ps1 opens with such a block."""
|
|
source = (
|
|
"<#\n"
|
|
".SYNOPSIS\n"
|
|
' Historically this asked Read-Host "Enable telemetry? [Y/n]" here.\n'
|
|
"#>\n"
|
|
'Write-Host "done"\n'
|
|
)
|
|
assert find_prompts("install.ps1", source) == []
|
|
|
|
|
|
def test_helper_reference_matches_a_sibling_path():
|
|
"""Helpers invoke each other by their own directory, with no scripts/ prefix."""
|
|
assert _HELPER_REF.findall('run_quiet "$SCRIPT_DIR/build_deps.sh" --yes') == ["build_deps.sh"]
|
|
|
|
|
|
def test_detects_unlabelled_read():
|
|
source = 'read -r _reply </dev/tty || _reply="n"\n'
|
|
questions = [question for _, _, question in find_prompts("install.sh", source)]
|
|
assert len(questions) == 1 and questions[0].startswith("<unlabelled read:")
|
|
|
|
|
|
def test_ignores_comments_and_non_interactive_reads():
|
|
source = (
|
|
"# Ask the user [Y/n] before doing anything.\n"
|
|
'read -r _line < "$config_file"\n'
|
|
'while IFS= read -r _entry; do :; done < "$manifest"\n'
|
|
"read _major _minor <<EOF\n1 2\nEOF\n"
|
|
)
|
|
assert find_prompts("install.sh", source) == []
|
|
|
|
|
|
def test_reports_the_prompt_in_the_failure_message():
|
|
message = _failure_message("install.sh", 42, "enable telemetry?")
|
|
assert "enable telemetry?" in message
|
|
assert "APPROVED_PROMPTS" in message
|
|
assert "#8040" in message
|