From 0763d117e73ee45cf6f6083559098c10acf555ff Mon Sep 17 00:00:00 2001 From: Oliver Cieliszak Date: Thu, 13 Aug 2026 10:51:02 -0700 Subject: [PATCH 1/2] fix(gh-9): require both uv and uvx before skipping bootstrap download The skip guard in install.sh and install.ps1 checked uv's version only, so a prefix created before uvx existed could match the pinned version and skip the download, leaving uvx missing while setup.py still wrapped it - a dangling bin/uvx and a broken $..._UVX, with the install reporting success throughout. Both guards now require uv and uvx to be present. Since they ship in one archive, a missing uvx simply re-downloads the pair. setup.py gains _require_bootstrapped() as a backstop for standalone runs: it refuses to configure a prefix whose binaries are absent. It runs before the cooldown check on purpose - _validate_cooldown silently no-ops when uv cannot be run, so validating the prefix first keeps that check honest. TESTING.md gains Tests 9 and 10 plus a Windows counterpart. Co-Authored-By: Claude Opus 5 (1M context) --- TESTING.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ install.ps1 | 11 +++++++++-- install.sh | 6 +++++- setup.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/TESTING.md b/TESTING.md index aa713b0..0a27b8f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -137,6 +137,43 @@ Values that MUST be rejected: `yesterday`, `1 fortnight`, `P`, `2026-01-01T:`, ` Values that MUST be accepted: `P1D`, `PT12H`, `P2W`, `P1DT2H`, `3 days`, `2026-01-01`, `2026-01-01T00:00:00Z` +### Test 9 — Missing `uvx` forces a re-download + +Guards against the bug in [#9](https://github.com/redmatter/managed-python/issues/9): the skip +condition used to check `uv`'s version only, so a prefix predating `uvx` could keep a broken +`bin/uvx` forever. + +```bash +./install.sh --prefix /tmp/mp-test --python 3.10 --env-prefix TEST --isolated +rm -f /tmp/mp-test/uvx /tmp/mp-test/bin/uvx +./install.sh --prefix /tmp/mp-test --python 3.10 --env-prefix TEST --isolated +source /tmp/mp-test/env.sh && "$TEST_UVX" --version +``` + +**Expect:** the second install prints `→ Downloading uv X.Y.Z` (not `✓ uv X.Y.Z`), keeps +`✓ venv already exists`, and `$TEST_UVX --version` prints a version rather than +`No such file or directory`. + +Then re-run Test 3 — a **complete** prefix must still print `✓ uv X.Y.Z` and skip the download. + +### Test 10 — setup.py refuses to wrap a missing binary + +`setup.py` will not create a wrapper pointing at thin air, even when run on its own. + +```bash +mv /tmp/mp-test/uvx /tmp/uvx.bak +/tmp/mp-test/venv/bin/python setup.py --prefix /tmp/mp-test --python 3.10 --env-prefix TEST --isolated +echo "exit=$?" +mv /tmp/uvx.bak /tmp/mp-test/uvx +``` + +**Expect:** exits `1` with `ERROR: bootstrap incomplete - these are missing from the prefix:` +naming `uvx`, before any `==>` step header is printed, and with **no** `bin/` wrappers or env +files rewritten. + +A missing `uv` must be caught the same way — that ordering matters, because cooldown validation +silently no-ops when `uv` cannot be run. + --- ## Windows (PowerShell) @@ -205,6 +242,20 @@ Select-String cooldown C:\Users\Quickemu\temp\mp-test\distro.toml Repeat with `-Cooldown "P0D"` and expect both assignments commented out. +### Windows: Test 6 — Missing `uvx.exe` forces a re-download + +The Windows half of Test 9. + +```powershell +Remove-Item C:\Users\Quickemu\temp\mp-test\uvx.exe, C:\Users\Quickemu\temp\mp-test\bin\uvx.cmd -Force +.\install.ps1 -Prefix "C:\Users\Quickemu\temp\mp-test" -Python "3.10" -EnvPrefix "TEST" +. C:\Users\Quickemu\temp\mp-test\env.ps1 +& $env:TEST_UVX --version +``` + +**Expect:** `→ Downloading uv X.Y.Z` rather than `✓ uv X.Y.Z`, `✓ venv already exists`, and +`uvx --version` prints a version. Re-running on the restored prefix must skip the download again. + --- ## Testing from a release ZIP diff --git a/install.ps1 b/install.ps1 index 7c5c6b4..0eb5dc3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -52,6 +52,7 @@ $ErrorActionPreference = "Stop" $Prefix = [Environment]::ExpandEnvironmentVariables($Prefix) $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $UvExe = Join-Path $Prefix "uv.exe" +$UvxExe = Join-Path $Prefix "uvx.exe" $VenvPy = Join-Path $Prefix "venv\Scripts\python.exe" # Read pinned uv version from distro.toml @@ -67,7 +68,9 @@ Write-Msg "" # Bootstrap uv $currentVer = if (Test-Path $UvExe) { try { (& $UvExe --version 2>$null) -split " " | Select-Object -Last 1 } catch { "" } } else { "" } -if ($currentVer -eq $UvVersion) { +# uvx.exe must exist too - a prefix predating uvx can match the pinned version +# while missing it entirely, and skipping the download would leave it that way. +if (($currentVer -eq $UvVersion) -and (Test-Path $UvxExe)) { Write-Msg " ✓ uv $UvVersion" } else { Write-Msg " → Downloading uv $UvVersion" @@ -96,7 +99,7 @@ if ($currentVer -eq $UvVersion) { Copy-Item $uvSrc.FullName $UvExe -Force $uvxSrc = Get-ChildItem $tmpDir -Filter "uvx.exe" -Recurse | Select-Object -First 1 if (-not $uvxSrc) { Write-Error "Failed to locate uvx.exe in archive"; exit 1 } - Copy-Item $uvxSrc.FullName (Join-Path $Prefix "uvx.exe") -Force + Copy-Item $uvxSrc.FullName $UvxExe -Force } catch { Write-Error "Failed to download uv $UvVersion from $url`: $_" exit 1 @@ -107,6 +110,10 @@ if ($currentVer -eq $UvVersion) { Write-Error "Failed to download uv $UvVersion — binary not found after extraction" exit 1 } + if (-not (Test-Path $UvxExe)) { + Write-Error "Failed to download uv $UvVersion — uvx.exe not found after extraction" + exit 1 + } Write-Msg " ✓ uv $UvVersion installed" } diff --git a/install.sh b/install.sh index 710857e..de8845d 100755 --- a/install.sh +++ b/install.sh @@ -39,7 +39,11 @@ _bootstrap_uv() { local prefix="$1" uv_version="$2" distro_toml="$3" local uv_bin="${prefix}/uv" - if [[ -x "$uv_bin" ]] && \ + # Both binaries must be present, not just uv - a prefix from before uvx + # existed can match the pinned version yet have no uvx at all, and a + # version-only check would happily skip the download and leave it missing. + # A missing uvx therefore re-downloads the pair; they ship in one archive. + if [[ -x "$uv_bin" ]] && [[ -x "${prefix}/uvx" ]] && \ [[ "$("$uv_bin" --version 2>/dev/null | awk '{print $2}')" == "$uv_version" ]]; then _msg " ✓ uv $uv_version"; return fi diff --git a/setup.py b/setup.py index 086fa64..eb9180d 100755 --- a/setup.py +++ b/setup.py @@ -220,6 +220,34 @@ def _symlink(link: Path, target: Path) -> None: link.symlink_to(target) +def _require_bootstrapped(prefix: Path) -> None: + """Fail loudly if the bootstrap phase did not leave every binary behind. + + A wrapper pointing at thin air is never a valid outcome: on POSIX it is a + dangling symlink, on Windows a .cmd that fails later with a baffling error. + Better to stop here, while the cause is still obvious. + + Args: + prefix: The install prefix that the bootstrap phase populated. + + Raises: + SystemExit: Exit code 1 when any expected binary is missing. + """ + if _IS_WINDOWS: + expected = [prefix / "uv.exe", prefix / "uvx.exe", prefix / "venv" / "Scripts" / "python.exe"] + else: + expected = [prefix / "uv", prefix / "uvx", prefix / "venv" / "bin" / "python"] + missing = [p for p in expected if not p.exists()] + if missing: + listed = "\n".join(f" {p}" for p in missing) + print( + f"ERROR: bootstrap incomplete - these are missing from the prefix:\n{listed}\n" + " Re-run the installer to fetch them before configuring this prefix.", + file=sys.stderr, + ) + sys.exit(1) + + def _create_bin(prefix: Path) -> None: _step("Creating bin/ wrappers") bin_dir = prefix / "bin" @@ -517,6 +545,9 @@ def main() -> None: # Validate before writing anything — a bad cooldown would otherwise poison # every uv invocation of everyone who sources the generated env files. + # Order matters: the bootstrap check runs first so that uv is known to be + # present, otherwise _validate_cooldown quietly skips validation entirely. + _require_bootstrapped(prefix) rejection = _validate_cooldown(prefix, args.cooldown) if rejection: print(f"ERROR: --cooldown {args.cooldown!r} was rejected by uv:\n {rejection}", file=sys.stderr) From 0e11c8afc3b4a7506f28cacc284fcbd2dc95010c Mon Sep 17 00:00:00 2001 From: Oliver Cieliszak Date: Thu, 13 Aug 2026 10:53:42 -0700 Subject: [PATCH 2/2] docs(gh-9): correct the idempotency note for the new skip condition Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f3abf3c..d0400fd 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ isolated = false Re-running `install.sh` with the same args is always safe: -- uv download skipped if pinned version already installed +- uv download skipped only if **both** `uv` and `uvx` are present and `uv` matches the pinned version — a missing `uvx` re-downloads the pair, since they ship in one archive - venv creation skipped if `venv/bin/python` already works - All generated files (`env.sh`, `env.ps1`, `bin/`, `distro.toml`) are always regenerated (cheap, ensures correctness)