Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
51 changes: 51 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"
}

Expand Down
6 changes: 5 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down