diff --git a/.github/workflows/verify-split-files.yml b/.github/workflows/verify-split-files.yml index 6494fd1..2c7864a 100644 --- a/.github/workflows/verify-split-files.yml +++ b/.github/workflows/verify-split-files.yml @@ -25,6 +25,7 @@ on: - "tests/vm-iso-storage.sh" - "tests/vm-consoles.sh" - "tests/vm-helpers.sh" + - "tests/uv-required-version.sh" - ".github/workflows/verify-split-files.yml" push: branches: [main] @@ -41,6 +42,7 @@ on: - "tests/vm-iso-storage.sh" - "tests/vm-consoles.sh" - "tests/vm-helpers.sh" + - "tests/uv-required-version.sh" - ".github/workflows/verify-split-files.yml" workflow_dispatch: @@ -71,6 +73,9 @@ jobs: - name: VM helper regression tests run: bash tests/vm-helpers.sh + - name: uv pin regression tests + run: bash tests/uv-required-version.sh + - name: Prefetch lists cover every engine file run: | set -euo pipefail diff --git a/lib/API.txt b/lib/API.txt index b7efc7f..c66218d 100644 --- a/lib/API.txt +++ b/lib/API.txt @@ -44,7 +44,10 @@ _setup_nvidia_gpu _setup_rocm _tools_cleanup_temp_dirs _tools_register_temp_cleanup +_uv_pick_version +_uv_release_list _uv_required_version +_uv_ver_ge cache_installed_version check_for_codeberg_release check_for_gh_branch diff --git a/lib/runtime.func b/lib/runtime.func index ec92209..b6b3e57 100644 --- a/lib/runtime.func +++ b/lib/runtime.func @@ -2508,13 +2508,65 @@ setup_rust() { # uv refuses to run when a project pins a required-version it does not match, # in either direction. Only the exact form is read; a range falls through to # whatever the caller would have installed anyway. +# required-version from pyproject.toml or uv.toml. An exact pin ("==0.12.3") +# is returned as is; a range (">=0.12.15,<0.13") resolves to the newest +# published uv inside it, so a project that caps uv keeps getting the +# upgrades it accepts instead of the latest release it rejects. _uv_required_version() { - local dir="${1:?dir}" f v + local dir="${1:?dir}" f spec exact='^(==)?([0-9][0-9A-Za-z.+-]*)$' for f in "$dir/pyproject.toml" "$dir/uv.toml"; do [[ -f "$f" ]] || continue - v="$(grep -m1 -E '^[[:space:]]*required-version[[:space:]]*=' "$f" 2>/dev/null | - sed -E 's/.*=[[:space:]]*"[[:space:]]*==[[:space:]]*([0-9][0-9A-Za-z.+-]*)"[[:space:]]*$/\1/')" - if [[ "$v" =~ ^[0-9] ]]; then + spec="$(grep -m1 -E '^[[:space:]]*required-version[[:space:]]*=' "$f" 2>/dev/null | + sed -nE 's/^[^=]*=[[:space:]]*"([^"]*)".*/\1/p' | tr -d '[:space:]')" + [[ -n "$spec" ]] || continue + if [[ "$spec" =~ $exact ]]; then + echo "${BASH_REMATCH[2]}" + return 0 + fi + _uv_pick_version "$spec" < <(_uv_release_list) + return + done + return 1 +} + +_uv_release_list() { + local tmp + tmp=$(mktemp) || return 1 + if ! github_api_call "https://api.github.com/repos/astral-sh/uv/releases?per_page=100" "$tmp"; then + rm -f "$tmp" + return 1 + fi + jq -r '.[].tag_name' "$tmp" | sed 's/^v//' | sort -rV + rm -f "$tmp" +} + +_uv_ver_ge() { [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" == "$1" ]]; } + +# Newest version on stdin (one per line, newest first) that satisfies every +# comma-separated clause in $1: ==, !=, <, <=, >, >=, ~=. +_uv_pick_version() { + local spec="$1" v c op want ok + local version='^[0-9]+(\.[0-9]+)*$' clause='^(==|!=|<=|>=|<|>|~=)(.+)$' + local -a clauses + IFS=',' read -ra clauses <<<"$spec" + while IFS= read -r v; do + [[ "$v" =~ $version ]] || continue + ok=1 + for c in "${clauses[@]}"; do + [[ "$c" =~ $clause ]] || { ok=0; break; } + op="${BASH_REMATCH[1]}" want="${BASH_REMATCH[2]}" + case "$op" in + '==') [[ "$v" == "$want" ]] || ok=0 ;; + '!=') [[ "$v" != "$want" ]] || ok=0 ;; + '>=') _uv_ver_ge "$v" "$want" || ok=0 ;; + '>') [[ "$v" != "$want" ]] && _uv_ver_ge "$v" "$want" || ok=0 ;; + '<=') _uv_ver_ge "$want" "$v" || ok=0 ;; + '<') [[ "$v" != "$want" ]] && _uv_ver_ge "$want" "$v" || ok=0 ;; + '~=') _uv_ver_ge "$v" "$want" && [[ "$v" == "${want%.*}".* ]] || ok=0 ;; + esac + ((ok)) || break + done + if ((ok)); then echo "$v" return 0 fi diff --git a/tests/uv-required-version.sh b/tests/uv-required-version.sh new file mode 100644 index 0000000..b3913b0 --- /dev/null +++ b/tests/uv-required-version.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +# Only the uv pin helpers; runtime.func drags the whole engine along. +eval "$(awk '/^_uv_(required_version|release_list|ver_ge|pick_version)\(\) \{/,/^}/' "$CORE_DIR/lib/runtime.func")" + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf -- "$TEST_DIR"' EXIT +passed=0 failed=0 + +_uv_release_list() { printf '%s\n' 0.13.1 0.13.0 0.12.24 0.12.23 0.12.15 0.12.14 0.11.9; } + +check() { + if [[ "$3" == "$2" ]]; then + echo "PASS $1" + passed=$((passed + 1)) + else + echo "FAIL $1: expected <$2>, got <$3>" >&2 + failed=$((failed + 1)) + fi +} +pin() { printf '[tool.uv]\nrequired-version = "%s"\n' "$1" >"$TEST_DIR/pyproject.toml"; } +resolve() { _uv_required_version "$TEST_DIR" || true; } + +pin "==0.12.3"; check "exact pin" "0.12.3" "$(resolve)" +pin "0.12.3"; check "bare version" "0.12.3" "$(resolve)" +pin ">=0.12.15,<0.13"; check "range picks the newest 0.12" "0.12.24" "$(resolve)" +pin ">=0.12.15, <0.13"; check "spaces inside the range" "0.12.24" "$(resolve)" +pin "~=0.12.15"; check "compatible release" "0.12.24" "$(resolve)" +pin "<0.12.15"; check "upper bound only" "0.12.14" "$(resolve)" +pin ">=0.12.15,<0.13,!=0.12.24"; check "exclusion" "0.12.23" "$(resolve)" +pin ">0.13.1"; check "nothing published matches" "" "$(resolve)" +rm -f "$TEST_DIR/pyproject.toml"; check "no pin" "" "$(resolve)" + +printf '%s passed, %s failed\n' "$passed" "$failed" +((failed == 0))