diff --git a/.github/actions/setup-uv-python/action.yml b/.github/actions/setup-uv-python/action.yml index 9d51774..19ecfcc 100644 --- a/.github/actions/setup-uv-python/action.yml +++ b/.github/actions/setup-uv-python/action.yml @@ -1,33 +1,31 @@ name: "Setup uv & Python" -description: "Installs uv, sets up Python via uv, and configures PYO3_PYTHON environment variable" +description: "Installs a UV-managed Python for PyO3 builds" inputs: python-version: description: 'Python version to install (e.g., "3.12")' required: true default: "3.12" outputs: - python-path: - description: "Path to the uv-managed Python interpreter used for PyO3" - value: ${{ steps.python.outputs.python-path }} - resolved-version: - description: "Full Python version reported by the selected interpreter" - value: ${{ steps.python.outputs.resolved-version }} - python-abi-version: - description: "Major.minor Python ABI version used in libpython names" - value: ${{ steps.python.outputs.python-abi-version }} python-prefix: - description: "Prefix of the uv-managed Python installation" + description: "Prefix of the UV-managed Python installation" value: ${{ steps.python.outputs.python-prefix }} runs: using: "composite" steps: - name: Install uv uses: astral-sh/setup-uv@v3 - - name: Install Python via uv and set PYO3_PYTHON + - name: Install managed Python id: python shell: bash - run: bash scripts/ci_setup_uv_python.sh "${{ inputs.python-version }}" - - name: Fix libpython install name for portable builds - if: runner.os == 'macOS' - shell: bash - run: bash scripts/ci_fix_libpython_install_name.sh "${{ steps.python.outputs.python-prefix }}" "${{ steps.python.outputs.python-abi-version }}" + env: + REQUESTED_PYTHON: ${{ inputs.python-version }} + run: | + set -euo pipefail + uv python install --no-config --managed-python "${REQUESTED_PYTHON}" + python="$(uv python find --no-config --no-project --managed-python "${REQUESTED_PYTHON}")" + prefix="$(dirname "$(dirname "${python}")")" + { + printf 'PYO3_PYTHON=%s\n' "${python}" + printf 'R2X_PYTHON_VERSION=%s\n' "${REQUESTED_PYTHON}" + } >> "${GITHUB_ENV}" + printf 'python-prefix=%s\n' "${prefix}" >> "${GITHUB_OUTPUT}" diff --git a/.github/build-setup.yml b/.github/build-setup.yml index e6cbccc..4b83e19 100644 --- a/.github/build-setup.yml +++ b/.github/build-setup.yml @@ -1,10 +1,6 @@ # Build setup steps for r2x CI -# These steps are used as a reference for setting up the build environment. - name: Setup uv & Python uses: ./.github/actions/setup-uv-python with: python-version: ${{ env.R2X_PYTHON_VERSION }} -- name: PyO3 diagnostics - shell: bash - run: bash scripts/ci_pyo3_diagnostics.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7534b8c..51746c3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,8 +19,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Setup Python - uses: actions/setup-python@v6 + - name: Setup uv & Python + uses: ./.github/actions/setup-uv-python with: python-version: "3.12" - name: Install ShellCheck @@ -31,15 +31,9 @@ jobs: run: bash scripts/ci_check_shell_scripts.sh - name: Python script tests run: | - PYTHONPATH=scripts python3 -m unittest \ - scripts.tests.test_python_version_sh \ - scripts.tests.test_resolve_pyo3_python \ - scripts.tests.test_ci_python_version \ + PYTHONPATH=scripts uv run --no-config --no-project --managed-python --python 3.12 -- python -m unittest \ scripts.tests.test_format_benchmark_summary \ - scripts.tests.test_compare_benchmark_summary \ - scripts.tests.test_patch_dist_installer \ - scripts.tests.test_detect_uv_python \ - scripts.tests.test_fix_python_dylib + scripts.tests.test_compare_benchmark_summary lint: name: Lint (stable, Python 3.12) @@ -56,9 +50,6 @@ jobs: uses: ./.github/actions/setup-uv-python with: python-version: "3.12" - - name: PyO3 diagnostics - shell: bash - run: bash scripts/ci_pyo3_diagnostics.sh - name: Format check run: cargo fmt --all -- --check - name: Clippy @@ -120,9 +111,6 @@ jobs: uses: ./.github/actions/setup-uv-python with: python-version: ${{ matrix.python-version }} - - name: PyO3 diagnostics - shell: bash - run: bash scripts/ci_pyo3_diagnostics.sh - name: Test env: PYTHON_PREFIX: ${{ steps.python.outputs.python-prefix }} @@ -171,8 +159,5 @@ jobs: uses: ./.github/actions/setup-uv-python with: python-version: ${{ env.R2X_PYTHON_VERSION }} - - name: PyO3 diagnostics - shell: bash - run: bash scripts/ci_pyo3_diagnostics.sh - name: Cargo publish dry-run run: cargo publish --workspace --dry-run diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index 249d30f..d08cfec 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -31,8 +31,7 @@ jobs: group: release-plz-pr-${{ github.ref }} cancel-in-progress: true steps: - - &checkout - name: Checkout repository + - name: Checkout repository uses: actions/checkout@v7 with: fetch-depth: 0 @@ -52,11 +51,6 @@ jobs: with: python-version: ${{ env.R2X_PYTHON_VERSION }} - - &pyo3_diagnostics - name: PyO3 diagnostics - shell: bash - run: bash scripts/ci_pyo3_diagnostics.sh - - name: Run release-plz (release-pr) uses: release-plz/action@v0.5 with: @@ -89,7 +83,6 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.ref_name }} - *setup_rust - *setup_python - - *pyo3_diagnostics - name: Run release-plz (release) uses: release-plz/action@v0.5 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 449bb3b..1c7c18a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -147,12 +147,6 @@ jobs: with: python-version: ${{ env.R2X_PYTHON_VERSION }} - - name: PyO3 diagnostics - shell: bash - env: - CARGO_BUILD_TARGET: ${{ join(matrix.targets, ' ') }} - run: bash scripts/ci_pyo3_diagnostics.sh - - name: Install Rust toolchain if: ${{ matrix.container }} uses: actions-rust-lang/setup-rust-toolchain@v1 @@ -176,7 +170,7 @@ jobs: run: | ${{ matrix.packages_install }} - - name: Build and fix Python library paths (container) + - name: Build runtime binaries (container) if: ${{ matrix.container }} shell: bash env: @@ -188,29 +182,19 @@ jobs: echo "Building dist-profile binaries first..." TARGETS="${{ join(matrix.targets, ' ') }}" for target in $TARGETS; do - cargo build --profile dist -p r2x --target "$target" + cargo build --profile dist -p r2x --bins --target "$target" done - echo "Fixing Python library paths..." - while IFS= read -r -d '' bin; do - ./scripts/fix_python_dylib.sh "$bin" - done < <(find target -path "*/dist/r2x" -type f -print0) - - - name: Build and fix Python library paths (host) + - name: Build runtime binaries (host) if: ${{ !matrix.container && runner.os != 'Windows' }} shell: bash run: | echo "Building dist-profile binaries first..." TARGETS="${{ join(matrix.targets, ' ') }}" for target in $TARGETS; do - cargo build --profile dist -p r2x --target "$target" + cargo build --profile dist -p r2x --bins --target "$target" done - echo "Fixing Python library paths..." - while IFS= read -r -d '' bin; do - ./scripts/fix_python_dylib.sh "$bin" - done < <(find target -path "*/dist/r2x" -type f -print0) - - name: Build artifacts (container) if: ${{ matrix.container }} shell: bash @@ -351,21 +335,6 @@ jobs: cp dist-manifest.json "$BUILD_MANIFEST_NAME" - - name: Patch shell installer for Python runtime bootstrap - shell: bash - run: | - shopt -s nullglob - installers=(target/distrib/*-installer.sh) - - if [ ${#installers[@]} -eq 0 ]; then - echo "No shell installer artifacts found in target/distrib" - exit 1 - fi - - for installer in "${installers[@]}"; do - ./scripts/patch_dist_installer.sh "$installer" - done - - name: "Upload artifacts" uses: actions/upload-artifact@v7 with: diff --git a/README.md b/README.md index a1251a2..4104389 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,13 @@ r2x --version ``` > [!NOTE] -> Pre-built binaries require Python shared libraries at runtime. -> If `r2x --version` fails with a missing `libpython` error, run -> `uv python install 3.12` to make the shared library available. -> Source builds can target supported Python versions by setting -> `R2X_PYTHON_VERSION=` at build time. +> On first run, R2X uses UV to provision its managed CPython runtime and +> creates the R2X virtual environment. It does not require `python3.12` in +> `PATH` or `~/.local/bin`. UV owns the CPython installation; R2X owns only +> the plugin virtual environment, which references that interpreter. Use +> `uv python list` to inspect UV's available interpreters. +> Source builds select a supported version with `PYO3_PYTHON` from +> `uv python find --managed-python` and `R2X_PYTHON_VERSION`. ## Upgrading @@ -203,16 +205,19 @@ r2x run plugin r2x-reeds.reeds-parser --show-help r2x run r2x-reeds.reeds-parser --repeat 10 --benchmark solve_year=2030 # Compare two benchmark outputs (baseline vs current) -python3 scripts/compare_benchmark_summary.py --baseline baseline.txt --current current.txt +uv run --no-config --no-project --managed-python --python 3.12 -- \ + python scripts/compare_benchmark_summary.py --baseline baseline.txt --current current.txt # Emit machine-readable status line in stderr -python3 scripts/compare_benchmark_summary.py \ +uv run --no-config --no-project --managed-python --python 3.12 -- \ + python scripts/compare_benchmark_summary.py \ --baseline baseline.txt \ --current current.txt \ --print-status-line # Fail when regression exceeds 15% -python3 scripts/compare_benchmark_summary.py \ +uv run --no-config --no-project --managed-python --python 3.12 -- \ + python scripts/compare_benchmark_summary.py \ --baseline baseline.txt \ --current current.txt \ --fail-on-regression-pct 15 @@ -320,23 +325,27 @@ r2x read system.json --exec script.py -i r2x config show # Set values -r2x config set python-version 3.13 r2x config set cache-path /path/to/cache # Reset everything r2x config reset -y ``` +R2X uses the Python major.minor ABI that its PyO3 runtime was built against. +Use `uv python list` and `uv python install` to inspect or manage interpreter +installations directly. A configured Python patch version must use that same +major.minor ABI. +
Python and virtual environment management -The `r2x python` command provides shortcuts for Python runtime management: +The `r2x python` command manages R2X's UV-backed plugin environment: ```bash -# Install a Python version -r2x python install 3.13 +# Create or refresh the managed venv with UV +r2x python install -# Show installed Python versions +# Show the R2X venv's Python configuration r2x python show # Get the Python executable path @@ -483,25 +492,24 @@ uv python install 3.12 ```bash git clone https://github.com/NatLabRockies/r2x-cli && cd r2x-cli -R2X_PYTHON_VERSION=3.12 cargo install --path crates/r2x-cli --force --locked +PYO3_PYTHON="$(uv python find --managed-python 3.12)" \ + R2X_PYTHON_VERSION=3.12 cargo install --path crates/r2x-cli --bins --force --locked ``` To build against another supported Python version: ```bash uv python install 3.13 -R2X_PYTHON_VERSION=3.13 cargo install --path crates/r2x-cli --force --locked +PYO3_PYTHON="$(uv python find --managed-python 3.13)" \ + R2X_PYTHON_VERSION=3.13 cargo install --path crates/r2x-cli --bins --force --locked ``` -`cargo` now resolves `R2X_PYTHON_VERSION` through `uv python find` automatically. -For patch requests (for example `3.13.1`), r2x falls back to the matching ABI -request (`3.13`) if the exact patch is unavailable. -If the requested interpreter is missing, the build fails with -fallback-aware guidance such as -`uv python install || uv python install ` and -`uv python find || uv python find `. +Set `PYO3_PYTHON` from `uv python find --managed-python` so PyO3 builds +against the UV-managed interpreter. R2X passes the requested version directly +to UV. Install that exact version first with `uv python install `. -This places the `r2x` binary in `~/.cargo/bin/`. +This places `r2x` and its adjacent `r2x-runtime` payload in `~/.cargo/bin/`. +Run `r2x`; keep both files together when moving the installation. ```bash r2x --version @@ -511,25 +519,23 @@ r2x --version Manual build (custom install path) ```bash -R2X_PYTHON_VERSION=3.12 cargo build --release +PYO3_PYTHON="$(uv python find --managed-python 3.12)" \ + R2X_PYTHON_VERSION=3.12 cargo build --release -p r2x --bins ``` -The binary lands at `target/release/r2x`. Copy it wherever you -like. +The build produces `target/release/r2x` and `target/release/r2x-runtime`. +Copy them to the same directory, then invoke `r2x`. The justfile also honors `R2X_PYTHON_VERSION`, for example `R2X_PYTHON_VERSION=3.13 just test`. -`R2X_PYTHON_VERSION` and `r2x config set python-version` accept major.minor -versions such as `3.13` and patch versions such as `3.13.1`. r2x uses the -requested version for uv, then uses the matching major.minor ABI version for -`libpython` paths. +`R2X_PYTHON_VERSION` selects the Python ABI for a source build. The installed +CLI accepts only the same major.minor ABI for `r2x config set python-version`; +a patch version such as `3.12.1` is allowed for a binary built against `3.12`. -When `R2X_PYTHON_VERSION` is set for a just task, an interpreter with the same -major.minor ABI must exist; install it first with `uv python install ` -(or for patch requests, `uv python install || uv python install `). -This avoids accidentally building PyO3 against a different Python ABI than the -one requested. +When `R2X_PYTHON_VERSION` is set for a just task, install that version first +with `uv python install `. This avoids accidentally building PyO3 +against a different Python ABI than the one requested. If you also set `PYO3_PYTHON` manually, it must point to an interpreter with the same major.minor ABI as `R2X_PYTHON_VERSION`. @@ -554,9 +560,8 @@ just all # fmt + clippy + test - If the build fails with a Python error, verify `R2X_PYTHON_VERSION` is set to a supported version (for example `3.12`, - `3.13`, or `3.13.1`) and `uv python find ` returns a valid path. - You may need `uv python install ` first, or for patch requests, - `uv python install || uv python install `. + `3.13`, or `3.13.1`) and `uv python find --managed-python ` + returns a valid path. You may need `uv python install ` first. - If `r2x` is not found after install, check that `~/.cargo/bin` is in your `$PATH`. - On HPC systems with older glibc, building from source is diff --git a/crates/r2x-build-support/src/lib.rs b/crates/r2x-build-support/src/lib.rs index 8a5016e..d86079d 100644 --- a/crates/r2x-build-support/src/lib.rs +++ b/crates/r2x-build-support/src/lib.rs @@ -5,29 +5,18 @@ pub fn detect_build_python_version() -> Result, String> { .map(|value| requested_python_abi_version("R2X_PYTHON_VERSION", value)) .transpose()?; - if let Some(python) = std::env::var("PYO3_PYTHON") + let Some(python) = std::env::var("PYO3_PYTHON") .ok() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) - { - let version = detect_python_version(&python) - .map_err(|error| format!("PYO3_PYTHON={python:?} is not usable: {error}"))?; - ensure_selected_python_matches_request("PYO3_PYTHON", &version, requested_abi.as_deref())?; - return Ok(Some(version)); - } - - if let Some(requested_version) = requested_version { - return detect_requested_python_via_uv("uv", &requested_version).map(Some); - } - - for python in ["python3", "python"] { - if let Ok(version) = detect_python_version(python) { - ensure_selected_python_matches_request(python, &version, requested_abi.as_deref())?; - return Ok(Some(version)); - } - } + else { + return Ok(None); + }; - Ok(None) + let version = detect_python_version(&python) + .map_err(|error| format!("PYO3_PYTHON={python:?} is not usable: {error}"))?; + ensure_selected_python_matches_request("PYO3_PYTHON", &version, requested_abi.as_deref())?; + Ok(Some(version)) } pub fn detect_python_version(python: &str) -> Result { @@ -55,7 +44,6 @@ pub fn detect_python_version(python: &str) -> Result { } ensure_supported_python_version(version)?; - Ok(version.to_string()) } @@ -66,87 +54,6 @@ fn requested_build_python_version() -> Option { .filter(|value| !value.is_empty()) } -pub fn detect_requested_python_via_uv(uv: &str, requested_version: &str) -> Result { - let requested_abi = requested_python_abi_version("R2X_PYTHON_VERSION", requested_version)?; - let mut selected_python = - uv_python_find(uv, requested_version, requested_version, &requested_abi)?; - - if selected_python.is_none() && is_patch_version(requested_version) { - selected_python = uv_python_find(uv, &requested_abi, requested_version, &requested_abi)?; - } - - let Some(python) = selected_python else { - return Err(format!( - "Requested R2X_PYTHON_VERSION={requested_version} was not found.\nTry: {}\nVerify with: {}", - requested_python_install_hint(requested_version, &requested_abi), - requested_python_find_hint(requested_version, &requested_abi) - )); - }; - - let version = detect_python_version(&python) - .map_err(|error| format!("uv resolved {python:?}, but it is not usable: {error}"))?; - ensure_selected_python_matches_request("uv python find", &version, Some(&requested_abi))?; - Ok(version) -} - -fn uv_python_find( - uv: &str, - query_version: &str, - requested_version: &str, - requested_abi: &str, -) -> Result, String> { - let output = std::process::Command::new(uv) - .args(["python", "find", query_version]) - .output() - .map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - format!( - "uv is required to resolve R2X_PYTHON_VERSION={requested_version}, but `{uv}` was not found in PATH.\nInstall uv, then run: {}", - requested_python_install_hint(requested_version, requested_abi) - ) - } else { - format!( - "failed to execute `{uv}` while resolving R2X_PYTHON_VERSION={requested_version}: {error}" - ) - } - })?; - - if !output.status.success() { - return Ok(None); - } - - let python = String::from_utf8(output.stdout) - .map_err(|error| format!("uv printed non-UTF-8 output: {error}"))?; - let python = python.trim(); - if python.is_empty() { - return Err(format!( - "uv python find {query_version} did not print an interpreter path" - )); - } - - Ok(Some(python.to_string())) -} - -fn is_patch_version(version: &str) -> bool { - version.trim().split('.').count() == 3 -} - -fn requested_python_install_hint(requested_version: &str, requested_abi: &str) -> String { - if requested_version == requested_abi { - format!("uv python install {requested_version}") - } else { - format!("uv python install {requested_version} || uv python install {requested_abi}") - } -} - -fn requested_python_find_hint(requested_version: &str, requested_abi: &str) -> String { - if requested_version == requested_abi { - format!("uv python find {requested_version}") - } else { - format!("uv python find {requested_version} || uv python find {requested_abi}") - } -} - fn ensure_selected_python_matches_request( label: &str, selected_version: &str, diff --git a/crates/r2x-cli/Cargo.toml b/crates/r2x-cli/Cargo.toml index 6fbc790..2b0314d 100644 --- a/crates/r2x-cli/Cargo.toml +++ b/crates/r2x-cli/Cargo.toml @@ -9,6 +9,7 @@ homepage = { workspace = true } documentation = { workspace = true } rust-version = { workspace = true } build = "build.rs" +autobins = false description = "A framework plugin manager for the r2x power systems modeling ecosystem." readme = { workspace = true } keywords = ["power-systems", "r2x", "cli", "plugin-manager", "uv"] @@ -20,6 +21,14 @@ path-guid = "B2C9C3FE-FD0C-4209-910C-A07D1A71C539" license = false eula = false +[[bin]] +name = "r2x" +path = "src/bin/r2x.rs" + +[[bin]] +name = "r2x-runtime" +path = "src/main.rs" + [features] default = ["self-update"] # Adds self-update functionality. Package-manager builds can disable this with diff --git a/crates/r2x-cli/build.rs b/crates/r2x-cli/build.rs index ce8ba52..6124a92 100644 --- a/crates/r2x-cli/build.rs +++ b/crates/r2x-cli/build.rs @@ -1,64 +1,17 @@ fn main() { - println!("cargo:rerun-if-env-changed=PYO3_PYTHON"); - let Ok(target) = std::env::var("TARGET") else { return; }; if target.contains("apple-darwin") { - // Relative rpaths for portable installation add_rpath("@executable_path"); add_rpath("@executable_path/../lib"); - - // Homebrew locations - add_rpath("/opt/homebrew/lib"); - add_rpath("/usr/local/lib"); - - // Python framework - add_rpath("/Library/Frameworks/Python.framework/Versions/Current/lib"); - - // Build-time Python LIBDIR (useful for local dev, harmless in release) - if let Some(libdir) = find_python_libdir() { - add_rpath(&libdir); - } } else if target.contains("linux") { - // Relative rpaths for portable installation add_rpath("$ORIGIN"); add_rpath("$ORIGIN/../lib"); - - // Standard system library paths - add_rpath("/usr/lib"); - add_rpath("/usr/lib64"); - add_rpath("/usr/local/lib"); - - // Build-time Python LIBDIR - if let Some(libdir) = find_python_libdir() { - add_rpath(&libdir); - } } } fn add_rpath(path: &str) { println!("cargo:rustc-link-arg=-Wl,-rpath,{path}"); } - -/// Ask the Python interpreter where libpython lives. -fn find_python_libdir() -> Option { - let python = std::env::var("PYO3_PYTHON").ok()?; - let output = std::process::Command::new(&python) - .args([ - "-c", - "import sysconfig; print(sysconfig.get_config_var('LIBDIR'))", - ]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let libdir = String::from_utf8(output.stdout).ok()?; - let libdir = libdir.trim(); - if libdir.is_empty() { - return None; - } - Some(libdir.to_string()) -} diff --git a/crates/r2x-cli/src/bin/r2x.rs b/crates/r2x-cli/src/bin/r2x.rs new file mode 100644 index 0000000..bf63b03 --- /dev/null +++ b/crates/r2x-cli/src/bin/r2x.rs @@ -0,0 +1,197 @@ +//! Launch the PyO3 runtime through R2X's UV-managed virtual environment. + +use anyhow::{bail, ensure, Context, Result}; +use r2x_config::Config; +use std::env; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const PYTHON_RUNTIME_PATHS: &str = + "import sys, sysconfig\nprint(sysconfig.get_config_var('LIBDIR') or '')\nprint(sys.base_prefix)"; + +struct Runtime { + uv: PathBuf, + venv: PathBuf, +} + +impl Runtime { + fn load() -> Result { + let mut config = Config::load().context("failed to load R2X configuration")?; + let uv = PathBuf::from( + config + .ensure_uv_path() + .context("failed to find the UV executable")?, + ); + let venv = PathBuf::from( + config + .reconcile_venv_path() + .context("failed to create the UV-managed R2X virtual environment")?, + ); + + Ok(Self { uv, venv }) + } +} + +fn main() -> Result<()> { + let args = env::args_os().skip(1).collect::>(); + launch(&args) +} + +fn launch(args: &[OsString]) -> Result<()> { + let runtime = Runtime::load()?; + let library_dir = python_library_dir(&runtime)?; + let payload = payload_path()?; + + let mut command = uv_run_in_venv(&runtime); + command.arg(&payload).args(args); + configure_python_loader(&mut command, &library_dir)?; + + let status = command.status().context("failed to start R2X through uv")?; + match status.code() { + Some(0) => Ok(()), + Some(code) => bail!("R2X exited with status {code}"), + None => bail!("R2X terminated without an exit code"), + } +} + +fn payload_path() -> Result { + let launcher = env::current_exe().context("failed to locate the R2X launcher")?; + let payload = payload_path_next_to(&launcher)?; + ensure!( + payload.is_file(), + "R2X runtime not found: {}", + payload.display() + ); + Ok(payload) +} + +fn payload_path_next_to(launcher: &Path) -> Result { + let directory = launcher + .parent() + .context("R2X launcher does not have a parent directory")?; + Ok(directory.join(payload_name())) +} + +#[cfg(target_os = "windows")] +const fn payload_name() -> &'static str { + "r2x-runtime.exe" +} + +#[cfg(not(target_os = "windows"))] +const fn payload_name() -> &'static str { + "r2x-runtime" +} + +fn uv_run_in_venv(runtime: &Runtime) -> Command { + let mut command = Command::new(&runtime.uv); + command + .args(["run", "--no-config", "--no-project", "--active", "--"]) + .env("VIRTUAL_ENV", &runtime.venv) + .env_remove("PYTHONHOME") + .env_remove("PYTHONPATH"); + command +} + +fn python_library_dir(runtime: &Runtime) -> Result { + let output = uv_run_in_venv(runtime) + .args(["python", "-I", "-S", "-c", PYTHON_RUNTIME_PATHS]) + .output() + .with_context(|| { + format!( + "failed to probe the UV-managed Python in {}", + runtime.venv.display() + ) + })?; + ensure!( + output.status.success(), + "Python probe failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + + let output = + String::from_utf8(output.stdout).context("Python probe returned non-UTF-8 output")?; + let mut paths = output.lines(); + + #[cfg(target_os = "windows")] + let library_dir = { + let _ = paths.next().context("Python probe did not return LIBDIR")?; + PathBuf::from( + paths + .next() + .context("Python probe did not return the base prefix")?, + ) + }; + #[cfg(not(target_os = "windows"))] + let library_dir = PathBuf::from(paths.next().context("Python probe did not return LIBDIR")?); + + ensure!( + library_dir.is_dir(), + "Python library directory does not exist: {}", + library_dir.display() + ); + Ok(library_dir) +} + +fn configure_python_loader(command: &mut Command, library_dir: &Path) -> Result<()> { + #[cfg(target_os = "macos")] + command.env( + "DYLD_LIBRARY_PATH", + prepend_path(library_dir, env::var_os("DYLD_LIBRARY_PATH"))?, + ); + + #[cfg(all(unix, not(target_os = "macos")))] + command.env( + "LD_LIBRARY_PATH", + prepend_path(library_dir, env::var_os("LD_LIBRARY_PATH"))?, + ); + + #[cfg(target_os = "windows")] + command.env("PATH", prepend_path(library_dir, env::var_os("PATH"))?); + + #[cfg(not(any(unix, target_os = "windows")))] + bail!("R2X supports macOS, Linux, and Windows only"); + + Ok(()) +} + +fn prepend_path(prefix: &Path, existing: Option) -> Result { + let mut paths = vec![prefix.to_path_buf()]; + if let Some(existing) = existing { + paths.extend(env::split_paths(&existing)); + } + env::join_paths(paths).context("could not construct a process search path") +} + +#[cfg(test)] +mod tests { + use super::{payload_name, payload_path_next_to, prepend_path}; + use anyhow::Result; + use std::env; + use std::path::{Path, PathBuf}; + + #[test] + fn places_the_runtime_next_to_the_launcher() -> Result<()> { + let payload = payload_path_next_to(Path::new("bin/r2x"))?; + assert_eq!(payload, Path::new("bin").join(payload_name())); + Ok(()) + } + + #[test] + fn preserves_existing_search_paths() -> Result<()> { + let paths = prepend_path( + Path::new("/runtime/python/lib"), + Some(env::join_paths(["/usr/local/bin", "/usr/bin"])?), + )?; + + assert_eq!( + env::split_paths(&paths).collect::>(), + [ + PathBuf::from("/runtime/python/lib"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + ] + ); + Ok(()) + } +} diff --git a/crates/r2x-cli/src/main.rs b/crates/r2x-cli/src/main.rs index 3fbec64..5bce3ec 100644 --- a/crates/r2x-cli/src/main.rs +++ b/crates/r2x-cli/src/main.rs @@ -206,7 +206,11 @@ fn main() { colored::control::set_override(false); } - let cli = Cli::parse_from(normalize_run_global_args(std::env::args_os().collect())); + let mut args = std::env::args_os().collect::>(); + if let Some(program) = args.first_mut() { + *program = OsString::from("r2x"); + } + let cli = Cli::parse_from(normalize_run_global_args(args)); let mut startup_config = match config_manager::Config::load() { Ok(cfg) => Some(cfg), diff --git a/crates/r2x-cli/tests/fixtures/config.toml b/crates/r2x-cli/tests/fixtures/config.toml index 5193a9d..d92c7cf 100644 --- a/crates/r2x-cli/tests/fixtures/config.toml +++ b/crates/r2x-cli/tests/fixtures/config.toml @@ -1,6 +1,5 @@ cache_path = "/tmp/r2x-cache" uv_path = "/Users/psanchez/.local/bin/uv" -python_version = "3.12" log_python = false no_stdout = false log_max_size = 26214400 diff --git a/crates/r2x-cli/tests/integration.rs b/crates/r2x-cli/tests/integration.rs index a9a0988..7d97e0c 100644 --- a/crates/r2x-cli/tests/integration.rs +++ b/crates/r2x-cli/tests/integration.rs @@ -45,13 +45,13 @@ fn isolated_fixture_config_path() -> PathBuf { } fn r2x_cmd() -> Command { - let mut cmd = cargo_bin_cmd!("r2x"); + let mut cmd = cargo_bin_cmd!("r2x-runtime"); cmd.env("R2X_CONFIG", isolated_fixture_config_path()); cmd } fn r2x_cmd_with_config(config_path: &Path) -> Command { - let mut cmd = cargo_bin_cmd!("r2x"); + let mut cmd = cargo_bin_cmd!("r2x-runtime"); cmd.env("R2X_CONFIG", config_path); cmd } @@ -108,7 +108,7 @@ fn test_list_plugins_setup_failure_exits_nonzero() { .arg("list") .assert() .failure() - .stderr(predicate::str::contains("Failed to resolve site-packages")); + .stderr(predicate::str::contains("Failed to setup venv")); } #[test] @@ -150,7 +150,7 @@ fn test_venv_create_creates_missing_default_venv_without_prompt_hint() { } let venv_path = config_dir.join(".venv"); - let mut cmd = cargo_bin_cmd!("r2x"); + let mut cmd = cargo_bin_cmd!("r2x-runtime"); cmd.env("HOME", home) // Isolate XDG dirs so migrate_legacy_venv() cannot escape to the real // runner home (e.g. via XDG_CONFIG_HOME set by the CI environment). @@ -641,15 +641,16 @@ fn test_config_set_python_version_normalizes_value() { }; let config_path = temp_dir.path().join("config.toml"); + let version = format!("{}.1", test_python_version()); r2x_cmd_with_config(&config_path) - .args(["config", "set", "python-version", " 3.14.1 "]) + .args(["config", "set", "python-version", &format!(" {version} ")]) .assert() .success(); let Ok(contents) = fs::read_to_string(config_path) else { return; }; - assert!(contents.contains("python_version = \"3.14.1\"")); + assert!(contents.contains(&format!("python_version = \"{version}\""))); } #[test] @@ -1277,6 +1278,7 @@ struct PipelineHarness { _home: TempDir, config_path: PathBuf, site_packages: PathBuf, + venv_path: PathBuf, reeds_pipeline: PathBuf, s2p_pipeline: PathBuf, } @@ -1350,15 +1352,17 @@ impl PipelineHarness { _home: home, config_path, site_packages, + venv_path, reeds_pipeline, s2p_pipeline, }) } fn command(&self) -> Command { - let mut cmd = cargo_bin_cmd!("r2x"); + let mut cmd = cargo_bin_cmd!("r2x-runtime"); cmd.env("HOME", self.home_path()); cmd.env("R2X_CONFIG", &self.config_path); + cmd.env("VIRTUAL_ENV", &self.venv_path); cmd.env( "PYTHONPATH", self.site_packages.to_string_lossy().to_string(), @@ -1367,9 +1371,10 @@ impl PipelineHarness { } fn std_command(&self) -> StdCommand { - let mut cmd = StdCommand::new(cargo_bin("r2x")); + let mut cmd = StdCommand::new(cargo_bin("r2x-runtime")); cmd.env("HOME", self.home_path()); cmd.env("R2X_CONFIG", &self.config_path); + cmd.env("VIRTUAL_ENV", &self.venv_path); cmd.env( "PYTHONPATH", self.site_packages.to_string_lossy().to_string(), @@ -1394,32 +1399,32 @@ fn create_real_venv(venv_path: &Path) -> io::Result<()> { if venv_path.exists() { fs::remove_dir_all(venv_path)?; } - if let Some(uv) = find_tool(&["uv"]) { - let status = StdCommand::new(uv) - .arg("venv") - .arg(venv_path) - .arg("--python") - .arg(test_python_version()) - .status()?; - if status.success() { - return Ok(()); - } - } - if let Some(py) = find_tool(&["python3", "python"]) { - let status = StdCommand::new(py) - .arg("-m") - .arg("venv") - .arg(venv_path) - .status()?; - if status.success() { - return Ok(()); - } + let Some(uv) = find_tool(&["uv"]) else { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "failed to create test venv: uv is not available", + )); + }; + let python_version = test_python_version(); + let status = StdCommand::new(uv) + .args([ + "venv", + "--no-config", + "--no-project", + "--managed-python", + "--python", + &python_version, + ]) + .arg(venv_path) + .status()?; + if status.success() { + return Ok(()); } Err(io::Error::new( io::ErrorKind::Other, - "failed to create test venv (uv/python not available)", + "failed to create test venv with uv", )) } @@ -1435,7 +1440,7 @@ fn find_tool(candidates: &[&str]) -> Option { #[cfg(not(target_os = "windows"))] fn uv_can_find_python(uv_path: &str, python_version: &str) -> bool { StdCommand::new(uv_path) - .args(["python", "find", python_version]) + .args(["python", "find", python_version, "--managed-python"]) .output() .is_ok_and(|output| output.status.success()) } @@ -1454,19 +1459,11 @@ fn default_site_packages_path(venv_path: &Path) -> PathBuf { } fn test_python_version() -> String { - if let Ok(python) = std::env::var("PYO3_PYTHON") { - if let Some(version) = python_minor_version(&python) { - return version; - } - } - - for python in ["python3", "python"] { - if let Some(version) = python_minor_version(python) { - return version; - } - } + let Ok(python) = std::env::var("PYO3_PYTHON") else { + return "3.12".to_string(); + }; - "3.12".to_string() + python_minor_version(&python).unwrap_or_else(|| "3.12".to_string()) } fn python_minor_version(python: &str) -> Option { diff --git a/crates/r2x-cli/tests/uv_launcher.rs b/crates/r2x-cli/tests/uv_launcher.rs new file mode 100644 index 0000000..c7bb11b --- /dev/null +++ b/crates/r2x-cli/tests/uv_launcher.rs @@ -0,0 +1,80 @@ +//! Public launcher regressions for UV-managed Python startup. + +#![cfg(unix)] + +use assert_cmd::cargo::cargo_bin; +use std::fs; +use std::path::Path; +use std::process::Command; +use tempfile::TempDir; +use which::which; + +#[test] +fn launcher_uses_uv_without_a_python_executable_on_path() { + let Some(uv) = which("uv").ok() else { + return; + }; + let python_version = r2x_config::default_python_version(); + if !Command::new(&uv) + .args(["python", "find", python_version, "--managed-python"]) + .output() + .is_ok_and(|output| output.status.success()) + { + return; + } + + let Ok(temp_dir) = TempDir::new() else { + return; + }; + let path = temp_dir.path().join("path-without-python"); + if fs::create_dir_all(&path).is_err() || !add_uv_runtime_tool(&path) { + return; + } + + let config_path = temp_dir.path().join("config.toml"); + let venv_path = temp_dir.path().join(".venv"); + if fs::write( + &config_path, + format!( + "uv_path = \"{}\"\npython_version = \"{}\"\nvenv_path = \"{}\"\n", + uv.display(), + python_version, + venv_path.display(), + ), + ) + .is_err() + { + return; + } + + let output = Command::new(cargo_bin("r2x")) + .arg("--version") + .env("PATH", &path) + .env("R2X_CONFIG", &config_path) + .env("PYTHONHOME", "poison") + .env("PYTHONPATH", "poison") + .output(); + + assert!( + output.as_ref().is_ok_and(|output| output.status.success()), + "launcher failed: {output:?}" + ); + assert!(venv_path.join("pyvenv.cfg").is_file()); +} + +fn add_uv_runtime_tool(path: &Path) -> bool { + #[cfg(target_os = "macos")] + { + let install_name_tool = Path::new("/usr/bin/install_name_tool"); + if !install_name_tool.is_file() { + return false; + } + std::os::unix::fs::symlink(install_name_tool, path.join("install_name_tool")).is_ok() + } + + #[cfg(not(target_os = "macos"))] + { + let _ = path; + true + } +} diff --git a/crates/r2x-cli/wix/main.wxs b/crates/r2x-cli/wix/main.wxs index 5dfa848..8e07d49 100644 --- a/crates/r2x-cli/wix/main.wxs +++ b/crates/r2x-cli/wix/main.wxs @@ -128,6 +128,14 @@ Source='$(var.CargoTargetBinDir)\r2x.exe' KeyPath='yes'/> + + + @@ -150,6 +158,7 @@ + self.cache_path = Some(value), "uv-path" => self.uv_path = Some(value), - "python-version" => self.python_version = Some(normalize_python_version(&value)?), + "python-version" => { + let version = PythonRuntimeVersion::parse(&value)?; + ensure_build_python_abi(&version)?; + self.python_version = Some(version.requested().to_string()); + } "venv-path" => self.venv_path = Some(value), "r2x-core-version" => self.r2x_core_version = Some(value), "log-python" => { @@ -469,6 +473,22 @@ impl Config { } pub fn ensure_venv_path(&mut self) -> Result { + let venv_path = self.get_venv_path(); + let active_venv = std::env::var_os("VIRTUAL_ENV"); + if active_venv + .as_deref() + .is_some_and(|active| Path::new(active) == Path::new(&venv_path)) + && Path::new(&venv_path).is_dir() + { + // The launcher has already asked UV to reconcile this venv. + return Ok(venv_path); + } + + self.reconcile_venv_path() + } + + /// Create or repair the configured virtual environment through UV. + pub fn reconcile_venv_path(&mut self) -> Result { use std::process::Command; // Attempt one-time migration from legacy venv location @@ -476,45 +496,42 @@ impl Config { let venv_path = self.get_venv_path(); - // Check if venv already exists - if std::path::Path::new(&venv_path).exists() { - return Ok(venv_path); - } - // Ensure uv is installed first (this will auto-install if needed) let uv_path = self.ensure_uv_path()?; // Use the Python version from config, or the build-selected default. let python_version = self.runtime_python_version()?; - // Create the venv using uv, with patch->ABI fallback for patch requests. - let mut last_stderr: Option = None; - for python_query in python_version.query_candidates() { - let output = Command::new(&uv_path) - .args(["venv", &venv_path, "--python", python_query]) - .output()?; - - if output.status.success() { - return Ok(venv_path); - } - - last_stderr = Some(String::from_utf8_lossy(&output.stderr).trim().to_string()); + // UV owns interpreter selection and creates the venv when needed. + let status = Command::new(&uv_path) + .args([ + "venv", + "--no-config", + "--no-project", + "--allow-existing", + "--managed-python", + "--python", + python_version.requested(), + &venv_path, + ]) + .status()?; + + if status.success() { + return Ok(venv_path); } - let details = last_stderr - .filter(|stderr| !stderr.is_empty()) - .unwrap_or_else(|| "uv venv command failed".to_string()); Err(ConfigError::VenvCreation(format!( - "Failed to create venv for Python {} (ABI {}): {}\nTry: {}", + "Failed to create venv for Python {} (ABI {}): uv venv exited with {status}\nTry: uv python install {}", python_version.requested(), python_version.abi(), - details, - python_version.install_hint() + python_version.requested(), ))) } - pub fn runtime_python_version(&self) -> Result { - runtime_python_version(self.python_version.as_deref()) + fn runtime_python_version(&self) -> Result { + let version = runtime_python_version(self.python_version.as_deref())?; + ensure_build_python_abi(&version)?; + Ok(version) } } @@ -523,58 +540,26 @@ pub fn default_python_version() -> &'static str { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct PythonRuntimeVersion { +struct PythonRuntimeVersion { requested: String, abi: String, } impl PythonRuntimeVersion { - pub fn parse(value: &str) -> Result { + fn parse(value: &str) -> Result { let requested = normalize_python_version(value)?; let abi = python_abi_version(&requested); Ok(Self { requested, abi }) } - pub fn requested(&self) -> &str { + fn requested(&self) -> &str { &self.requested } - pub fn abi(&self) -> &str { + fn abi(&self) -> &str { &self.abi } - - pub fn query_candidates(&self) -> Vec<&str> { - let mut candidates = vec![self.requested()]; - if self.abi() != self.requested() { - candidates.push(self.abi()); - } - candidates - } - - pub fn install_hint(&self) -> String { - if self.requested() == self.abi() { - format!("uv python install {}", self.requested()) - } else { - format!( - "uv python install {} || uv python install {}", - self.requested(), - self.abi() - ) - } - } - - pub fn find_hint(&self) -> String { - if self.requested() == self.abi() { - format!("uv python find {}", self.requested()) - } else { - format!( - "uv python find {} || uv python find {}", - self.requested(), - self.abi() - ) - } - } } fn runtime_python_version( @@ -588,6 +573,22 @@ fn runtime_python_version( PythonRuntimeVersion::parse(requested) } +fn ensure_build_python_abi(version: &PythonRuntimeVersion) -> Result<(), ConfigError> { + let build_version = PythonRuntimeVersion::parse(default_python_version())?; + if version.abi() == build_version.abi() { + return Ok(()); + } + + Err(ConfigError::InvalidValue { + key: "python-version".to_string(), + message: format!( + "Python ABI {} is incompatible with this r2x binary, which was built against Python {}", + version.abi(), + build_version.abi() + ), + }) +} + pub fn normalize_python_version(value: &str) -> Result { let version = value.trim(); if version.is_empty() { @@ -684,30 +685,14 @@ mod tests { } #[cfg(unix)] - fn detect_requested_python_via_uv_with_retry( - uv: &std::path::Path, - requested: &str, - ) -> Result { - let uv = uv.to_string_lossy(); - match r2x_build_support::detect_requested_python_via_uv(&uv, requested) { - Ok(version) => Ok(version), - Err(error) if error.contains("Text file busy") => { - thread::sleep(Duration::from_millis(20)); - r2x_build_support::detect_requested_python_via_uv(&uv, requested) - } - Err(error) => Err(error), - } - } - - #[cfg(unix)] - fn ensure_venv_path_with_retry(config: &mut Config) -> Result { - match config.ensure_venv_path() { + fn reconcile_venv_path_with_retry(config: &mut Config) -> Result { + match config.reconcile_venv_path() { Ok(path) => Ok(path), Err(ConfigError::Io(error)) if error.kind() == std::io::ErrorKind::ExecutableFileBusy => { thread::sleep(Duration::from_millis(20)); - config.ensure_venv_path() + config.reconcile_venv_path() } Err(error) => Err(error), } @@ -811,40 +796,18 @@ mod tests { ); } - #[test] - fn test_python_runtime_version_query_candidates_include_patch_then_abi() { - let Ok(patch) = PythonRuntimeVersion::parse("3.13.1") else { - return; - }; - assert_eq!(patch.query_candidates(), vec!["3.13.1", "3.13"]); - assert_eq!( - patch.install_hint(), - "uv python install 3.13.1 || uv python install 3.13" - ); - assert_eq!( - patch.find_hint(), - "uv python find 3.13.1 || uv python find 3.13" - ); - - let Ok(abi) = PythonRuntimeVersion::parse("3.13") else { - return; - }; - assert_eq!(abi.query_candidates(), vec!["3.13"]); - assert_eq!(abi.install_hint(), "uv python install 3.13"); - assert_eq!(abi.find_hint(), "uv python find 3.13"); - } - #[test] fn test_config_runtime_python_version_uses_config_or_default() { + let configured_version = format!("{}.2", default_python_version()); let configured = Config { - python_version: Some("3.14.2".to_string()), + python_version: Some(configured_version.clone()), ..Config::default() }; assert_eq!( configured.runtime_python_version().ok(), Some(PythonRuntimeVersion { - requested: "3.14.2".to_string(), - abi: "3.14".to_string(), + requested: configured_version, + abi: default_python_version().to_string(), }) ); @@ -869,9 +832,46 @@ mod tests { #[test] fn test_config_set_python_version_normalizes_before_storing() { let mut config = Config::default(); - assert!(config.set("python-version", " 3.14.1 ".to_string()).is_ok()); + let version = format!("{}.1", default_python_version()); + assert!(config.set("python-version", format!(" {version} ")).is_ok()); + + assert_eq!(config.python_version.as_deref(), Some(version.as_str())); + } + + #[test] + fn test_config_rejects_python_abi_other_than_the_build() { + let incompatible = if default_python_version() == "3.12" { + "3.13" + } else { + "3.12" + }; + let mut config = Config::default(); + + let result = config.set("python-version", incompatible.to_string()); + + assert!(matches!( + result, + Err(ConfigError::InvalidValue { key, .. }) if key == "python-version" + )); + assert!(config.python_version.is_none()); + } + + #[test] + fn test_runtime_rejects_a_saved_python_abi_other_than_the_build() { + let incompatible = if default_python_version() == "3.12" { + "3.13" + } else { + "3.12" + }; + let config = Config { + python_version: Some(incompatible.to_string()), + ..Config::default() + }; - assert_eq!(config.python_version.as_deref(), Some("3.14.1")); + assert!(matches!( + config.runtime_python_version(), + Err(ConfigError::InvalidValue { key, .. }) if key == "python-version" + )); } #[test] @@ -976,180 +976,7 @@ mod tests { #[test] #[cfg(unix)] - fn test_build_python_version_uses_uv_for_requested_python_version() { - let Ok(temp_dir) = tempfile::tempdir() else { - return; - }; - let python = temp_dir.path().join("python"); - assert!(fs::write(&python, "#!/usr/bin/env sh\nprintf '3.13\\n'\n").is_ok()); - let uv = temp_dir.path().join("uv"); - assert!( - fs::write( - &uv, - format!( - "#!/usr/bin/env sh\nif [ \"$1\" = python ] && [ \"$2\" = find ] && [ \"$3\" = 3.13.1 ]; then\n printf '{}\\n'\n exit 0\nfi\nexit 1\n", - python.display() - ) - ) - .is_ok() - ); - for executable in [&python, &uv] { - let Ok(metadata) = fs::metadata(executable) else { - return; - }; - let mut permissions = metadata.permissions(); - permissions.set_mode(0o755); - assert!(fs::set_permissions(executable, permissions).is_ok()); - } - - assert_eq!( - detect_requested_python_via_uv_with_retry(&uv, "3.13.1") - .ok() - .as_deref(), - Some("3.13") - ); - } - - #[test] - #[cfg(unix)] - fn test_build_python_version_falls_back_to_requested_abi_when_patch_missing() { - let Ok(temp_dir) = tempfile::tempdir() else { - return; - }; - let python = temp_dir.path().join("python"); - assert!(fs::write(&python, "#!/usr/bin/env sh\nprintf '3.13\\n'\n").is_ok()); - let uv = temp_dir.path().join("uv"); - assert!( - fs::write( - &uv, - format!( - "#!/usr/bin/env sh\nif [ \"$1\" = python ] && [ \"$2\" = find ] && [ \"$3\" = 3.13.1 ]; then\n exit 1\nfi\nif [ \"$1\" = python ] && [ \"$2\" = find ] && [ \"$3\" = 3.13 ]; then\n printf '{}\\n'\n exit 0\nfi\nexit 1\n", - python.display() - ) - ) - .is_ok() - ); - for executable in [&python, &uv] { - let Ok(metadata) = fs::metadata(executable) else { - return; - }; - let mut permissions = metadata.permissions(); - permissions.set_mode(0o755); - assert!(fs::set_permissions(executable, permissions).is_ok()); - } - - assert_eq!( - detect_requested_python_via_uv_with_retry(&uv, "3.13.1") - .ok() - .as_deref(), - Some("3.13") - ); - } - - #[test] - #[cfg(unix)] - fn test_build_python_version_rejects_uv_requested_python_mismatch() { - let Ok(temp_dir) = tempfile::tempdir() else { - return; - }; - let python = temp_dir.path().join("python"); - assert!(fs::write(&python, "#!/usr/bin/env sh\nprintf '3.12\\n'\n").is_ok()); - let uv = temp_dir.path().join("uv"); - assert!( - fs::write( - &uv, - format!( - "#!/usr/bin/env sh\nif [ \"$1\" = python ] && [ \"$2\" = find ]; then\n printf '{}\\n'\n exit 0\nfi\nexit 1\n", - python.display() - ) - ) - .is_ok() - ); - for executable in [&python, &uv] { - let Ok(metadata) = fs::metadata(executable) else { - return; - }; - let mut permissions = metadata.permissions(); - permissions.set_mode(0o755); - assert!(fs::set_permissions(executable, permissions).is_ok()); - } - - let result = detect_requested_python_via_uv_with_retry(&uv, "3.13"); - - assert!(result.is_err()); - if let Err(error) = result { - assert!( - error.contains("uv python find resolves to Python 3.12"), - "unexpected error: {error}" - ); - } - } - - #[test] - #[cfg(unix)] - fn test_build_python_version_reports_missing_requested_uv_python() { - let result = r2x_build_support::detect_requested_python_via_uv("false", "3.13"); - - assert!(result.is_err()); - if let Err(error) = result { - assert!( - error.contains("uv python install 3.13"), - "unexpected error: {error}" - ); - } - } - - #[test] - #[cfg(unix)] - fn test_build_python_version_reports_missing_requested_patch_and_abi() { - let result = r2x_build_support::detect_requested_python_via_uv("false", "3.13.1"); - - assert!(result.is_err()); - if let Err(error) = result { - assert!( - error.contains("Requested R2X_PYTHON_VERSION=3.13.1 was not found."), - "unexpected error: {error}" - ); - assert!( - error.contains("uv python install 3.13.1"), - "unexpected error: {error}" - ); - } - } - - #[test] - #[cfg(unix)] - fn test_build_python_version_reports_missing_uv_binary_for_requested_python() { - let result = - r2x_build_support::detect_requested_python_via_uv("/definitely/missing/uv", "3.13"); - - assert!(result.is_err()); - if let Err(error) = result { - assert!( - error.contains("Install uv, then run: uv python install 3.13"), - "unexpected error: {error}" - ); - } - } - - #[test] - #[cfg(unix)] - fn test_build_python_version_reports_patch_install_fallback_when_uv_is_missing() { - let result = - r2x_build_support::detect_requested_python_via_uv("/definitely/missing/uv", "3.13.1"); - - assert!(result.is_err()); - if let Err(error) = result { - assert!( - error.contains("uv python install 3.13.1 || uv python install 3.13"), - "unexpected error: {error}" - ); - } - } - - #[test] - #[cfg(unix)] - fn test_ensure_venv_path_falls_back_to_abi_when_patch_query_fails() { + fn test_reconcile_venv_path_uses_managed_uv() { let Ok(temp_dir) = tempfile::tempdir() else { return; }; @@ -1157,12 +984,12 @@ mod tests { let uv = temp_dir.path().join("uv"); let calls_path = temp_dir.path().join("uv-calls.log"); let venv_path = temp_dir.path().join(".venv"); - + let python_version = format!("{}.1", default_python_version()); assert!( write_test_executable( &uv, &format!( - "#!/usr/bin/env sh\necho \"$@\" >> \"{}\"\nif [ \"$1\" != \"venv\" ]; then\n echo \"unexpected command: $@\" >&2\n exit 1\nfi\nvenv_path=\"\"\npython_query=\"\"\nwhile [ \"$#\" -gt 0 ]; do\n case \"$1\" in\n venv)\n shift\n venv_path=\"${{1:-}}\"\n ;;\n --python)\n shift\n python_query=\"${{1:-}}\"\n ;;\n esac\n shift || break\ndone\ncase \"$python_query\" in\n 3.13.1)\n echo \"patch unavailable\" >&2\n exit 1\n ;;\n 3.13)\n mkdir -p \"$venv_path\"\n exit 0\n ;;\nesac\necho \"unexpected python query: $python_query\" >&2\nexit 1\n", + "#!/usr/bin/env sh\necho \"$@\" >> \"{}\"\nvenv_path=\"\"\nwhile [ \"$#\" -gt 0 ]; do\n case \"$1\" in\n --python) shift ;;\n venv|--no-config|--no-project|--allow-existing|--managed-python) ;;\n *) venv_path=\"$1\" ;;\n esac\n shift || break\ndone\nmkdir -p \"$venv_path\"\n", calls_path.display() ) ) @@ -1171,61 +998,66 @@ mod tests { let mut config = Config { uv_path: Some(uv.to_string_lossy().to_string()), - python_version: Some("3.13.1".to_string()), + python_version: Some(python_version.clone()), venv_path: Some(venv_path.to_string_lossy().to_string()), ..Config::default() }; - let result = ensure_venv_path_with_retry(&mut config); + let result = reconcile_venv_path_with_retry(&mut config); let calls = fs::read_to_string(&calls_path).unwrap_or_default(); assert!( matches!(result.as_deref(), Ok(path) if path == venv_path.to_string_lossy()), "ensure_venv_path failed: {result:?}\nuv calls:\n{calls}" ); - assert!( - calls.contains("--python 3.13.1"), - "missing patch query: {calls}" + calls.contains(&format!("--python {python_version}")), + "missing requested Python query: {calls}" ); - assert!( - calls.contains("--python 3.13"), - "missing abi fallback query: {calls}" + assert_eq!( + calls.matches("--python").count(), + 1, + "unexpected fallback: {calls}" ); + assert!(calls.contains("--allow-existing")); + assert!(calls.contains("--managed-python")); + assert!(calls.contains("--no-config --no-project")); } #[test] #[cfg(unix)] - fn test_ensure_venv_path_error_includes_patch_fallback_hint() { + fn test_reconcile_venv_path_error_includes_native_uv_hint() { let Ok(temp_dir) = tempfile::tempdir() else { return; }; let uv = temp_dir.path().join("uv"); - assert!( - write_test_executable( - &uv, - "#!/usr/bin/env sh\nif [ \"$1\" = \"venv\" ]; then\n echo 'no interpreter found' >&2\n exit 1\nfi\nexit 1\n" - ) - .is_ok() - ); + let python_abi = default_python_version(); + let python_version = format!("{python_abi}.1"); + assert!(write_test_executable( + &uv, + "#!/usr/bin/env sh\nif [ \"$1\" = \"venv\" ]; then\n exit 1\nfi\nexit 1\n" + ) + .is_ok()); let mut config = Config { uv_path: Some(uv.to_string_lossy().to_string()), - python_version: Some("3.13.1".to_string()), + python_version: Some(python_version.clone()), venv_path: Some(temp_dir.path().join(".venv").to_string_lossy().to_string()), ..Config::default() }; - let result = ensure_venv_path_with_retry(&mut config); + let result = reconcile_venv_path_with_retry(&mut config); assert!(result.is_err()); if let Err(error) = result { let message = error.to_string(); assert!( - message.contains("Failed to create venv for Python 3.13.1 (ABI 3.13)"), + message.contains(&format!( + "Failed to create venv for Python {python_version} (ABI {python_abi})" + )), "unexpected error: {message}" ); assert!( - message.contains("uv python install 3.13.1 || uv python install 3.13"), + message.contains(&format!("uv python install {python_version}")), "unexpected error: {message}" ); } diff --git a/crates/r2x-python/build.rs b/crates/r2x-python/build.rs index d451bf7..0f263f7 100644 --- a/crates/r2x-python/build.rs +++ b/crates/r2x-python/build.rs @@ -2,6 +2,20 @@ fn main() { println!("cargo:rerun-if-env-changed=PYO3_PYTHON"); println!("cargo:rerun-if-env-changed=R2X_PYTHON_VERSION"); + let pyo3_python = std::env::var("PYO3_PYTHON") + .ok() + .filter(|python| !python.trim().is_empty()); + if pyo3_python.is_none() { + let requested = std::env::var("R2X_PYTHON_VERSION") + .ok() + .filter(|version| !version.trim().is_empty()) + .unwrap_or_else(|| "3.12".to_string()); + println!( + "cargo:error=PYO3_PYTHON is required. Set it to a UV-managed interpreter with: uv python find --managed-python {requested}" + ); + std::process::exit(1); + } + // Only r2x-config needs R2X_BUILD_PYTHON_VERSION; this crate // validates the build environment so the user catches a misconfigured // PYO3_PYTHON or R2X_PYTHON_VERSION early, before the actual build. diff --git a/crates/r2x-python/src/errors.rs b/crates/r2x-python/src/errors.rs index 033cc83..05eecf0 100644 --- a/crates/r2x-python/src/errors.rs +++ b/crates/r2x-python/src/errors.rs @@ -23,9 +23,6 @@ pub enum BridgeError { #[error("Failed to initialize Python interpreter: {0}")] Initialization(String), - #[error("Python library not found: {0}")] - PythonLibraryNotFound(String), - #[error("Plugin '{0}' not found")] PluginNotFound(String), diff --git a/crates/r2x-python/src/python_bridge.rs b/crates/r2x-python/src/python_bridge.rs index 579a78f..f3522db 100644 --- a/crates/r2x-python/src/python_bridge.rs +++ b/crates/r2x-python/src/python_bridge.rs @@ -15,13 +15,12 @@ use crate::utils::{resolve_python_path, resolve_site_package_path}; use once_cell::sync::{Lazy, OnceCell}; use pyo3::prelude::*; use pyo3::types::{PyDict, PyModule}; -use r2x_config::{Config, PythonRuntimeVersion}; +use r2x_config::Config; use r2x_logger as logger; use std::collections::HashSet; use std::env; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; use std::sync::Mutex; /// The Python bridge for plugin execution @@ -86,10 +85,6 @@ impl Bridge { // Add site-packages to PYTHONPATH Self::configure_python_path(&site_packages); - // Check if Python library is available before initializing - let python_version = runtime_python_version(&config)?; - check_python_library_available(&python_version)?; - // Initialize PyO3 logger::debug("Initializing PyO3..."); let pyo3_start = std::time::Instant::now(); @@ -404,233 +399,6 @@ fn is_python_executable_name(name: &str) -> bool { false } -/// Select the Python runtime version for venv creation and library discovery. -fn runtime_python_version(config: &Config) -> Result { - config.runtime_python_version().map_err(|error| { - BridgeError::Initialization(format!("Invalid configured Python version: {}", error)) - }) -} - -/// Check if Python library is available before attempting to initialize PyO3. -/// -/// This provides better error messages than the cryptic dyld errors on macOS -/// or DLL loading errors on Windows. -fn check_python_library_available( - python_version: &PythonRuntimeVersion, -) -> Result<(), BridgeError> { - #[cfg(any(target_os = "macos", target_os = "linux"))] - { - #[cfg(target_os = "macos")] - let (lib_names, search_paths, env_var) = ( - vec![format!("libpython{}.dylib", python_version.abi())], - &[ - "/opt/homebrew/lib", - "/usr/local/lib", - "/Library/Frameworks/Python.framework/Versions/Current/lib", - ][..], - "DYLD_LIBRARY_PATH", - ); - - #[cfg(target_os = "linux")] - let (lib_names, search_paths, env_var) = ( - vec![ - format!("libpython{}.so", python_version.abi()), - format!("libpython{}.so.1.0", python_version.abi()), - ], - &[ - "/usr/lib", - "/usr/lib64", - "/usr/local/lib", - "/usr/local/lib64", - ][..], - "LD_LIBRARY_PATH", - ); - - // Check environment variable paths first - if let Ok(paths) = env::var(env_var) { - if find_lib_in_paths(paths.split(':'), &lib_names) { - return Ok(()); - } - } - - // Check standard system locations - if find_lib_in_paths(search_paths.iter().copied(), &lib_names) { - return Ok(()); - } - - // Try to find Python via uv and set up the library path - if let Some(lib_dir) = find_python_lib_via_uv(python_version, &lib_names) { - prepend_to_env_path(env_var, &lib_dir); - logger::debug_lazy(|| format!("Set {} to include: {}", env_var, lib_dir.display())); - return Ok(()); - } - - // Library not found in expected locations, but don't fail - - // let PyO3 try to load it via rpath or other mechanisms. - logger::debug("Python library not found in standard locations, relying on rpath"); - Ok(()) - } - - #[cfg(target_os = "windows")] - { - // On Windows, try to set up the DLL path (best effort) - if let Err(e) = setup_windows_dll_path(python_version) { - logger::debug_lazy(|| format!("Windows DLL path setup note: {}", e)); - } - Ok(()) - } - - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - { - // For other platforms, just proceed and let PyO3 handle it - Ok(()) - } -} - -/// Search for any of the library names in the given paths. -/// Returns true if found, logging the discovery. -#[cfg(any(target_os = "macos", target_os = "linux"))] -fn find_lib_in_paths(paths: I, lib_names: &[String]) -> bool -where - I: Iterator, - S: AsRef, -{ - for path in paths { - for lib_name in lib_names { - let lib_path = PathBuf::from(path.as_ref()).join(lib_name); - if lib_path.exists() { - logger::debug_lazy(|| format!("Found Python library at: {}", lib_path.display())); - return true; - } - } - } - false -} - -/// Try to find Python library via uv python find command. -/// Returns the lib directory path if found. -#[cfg(any(target_os = "macos", target_os = "linux"))] -fn find_python_lib_via_uv( - python_version: &PythonRuntimeVersion, - lib_names: &[String], -) -> Option { - for python_query in python_version.query_candidates() { - let output = Command::new("uv") - .args(["python", "find", python_query]) - .output() - .ok()?; - - if !output.status.success() { - continue; - } - - let python_path = String::from_utf8_lossy(&output.stdout); - let python_path = python_path.trim(); - - // Python binary is in bin/, lib is in ../lib/ - let lib_dir = PathBuf::from(python_path).parent()?.parent()?.join("lib"); - - for lib_name in lib_names { - let lib_path = lib_dir.join(lib_name); - if lib_path.exists() { - logger::debug_lazy(|| { - format!("Found Python library via uv: {}", lib_path.display()) - }); - return Some(lib_dir); - } - } - } - - None -} - -/// Prepend a directory to an environment path variable. -#[cfg(any(target_os = "macos", target_os = "linux"))] -fn prepend_to_env_path(env_var: &str, dir: &Path) { - if let Some(existing) = env::var_os(env_var) { - let mut paths = env::split_paths(&existing).collect::>(); - paths.insert(0, dir.to_path_buf()); - if let Ok(new_path) = env::join_paths(&paths) { - env::set_var(env_var, new_path); - } - } else { - env::set_var(env_var, dir); - } -} - -/// Setup Windows DLL search path for Python -#[cfg(target_os = "windows")] -fn setup_windows_dll_path(python_version: &PythonRuntimeVersion) -> Result<(), BridgeError> { - let dll_name = format!("python{}.dll", python_version.abi().replace('.', "")); - - // Try to find Python via uv first - for python_query in python_version.query_candidates() { - let output = Command::new("uv") - .args(["python", "find", python_query]) - .output(); - - if let Ok(output) = output { - if output.status.success() { - let python_path = String::from_utf8_lossy(&output.stdout); - let python_path = python_path.trim(); - if let Some(parent) = PathBuf::from(python_path).parent() { - // On Windows, Python DLL is usually in the same directory as python.exe - let dll_path = parent.join(&dll_name); - if dll_path.exists() { - // Add the directory to PATH so Windows can find the DLL - if let Ok(current_path) = env::var("PATH") { - let new_path = format!("{};{}", parent.display(), current_path); - env::set_var("PATH", &new_path); - logger::debug_lazy(|| { - format!( - "Added {} to PATH for Python DLL discovery", - parent.display() - ) - }); - return Ok(()); - } - } - } - } - } - } - - // Try to find Python in PATH - if let Ok(output) = Command::new("where").arg("python").output() { - if output.status.success() { - let python_path = String::from_utf8_lossy(&output.stdout); - if let Some(first_line) = python_path.lines().next() { - if let Some(parent) = PathBuf::from(first_line.trim()).parent() { - let dll_path = parent.join(&dll_name); - if dll_path.exists() { - logger::debug_lazy(|| { - format!("Found Python DLL at: {}", dll_path.display()) - }); - return Ok(()); - } - } - } - } - } - - let find_hint = python_version.find_hint(); - let install_hint = python_version.install_hint(); - Err(BridgeError::PythonLibraryNotFound(format!( - "Could not find {}.\n\n\ - This binary requires Python {} to be installed.\n\n\ - To fix this on Windows:\n\ - 1. Install Python via uv: {}\n\ - 2. Or download from https://www.python.org/downloads/\n\ - 3. Ensure Python is in your PATH\n\n\ - If you installed Python via uv, try running:\n\ - {}", - dll_name, - python_version.requested(), - install_hint, - find_hint - ))) -} - /// Configure the Python virtual environment (legacy API compatibility) pub fn configure_python_venv() -> Result { let mut config = Config::load() @@ -654,265 +422,5 @@ pub struct PythonEnvCompat { } #[cfg(test)] -mod tests { - use crate::python_bridge::*; - use r2x_config::{default_python_version, PythonRuntimeVersion}; - #[cfg(unix)] - use std::env; - use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use tempfile::TempDir; - - #[cfg(unix)] - static PATH_TEST_LOCK: once_cell::sync::Lazy> = - once_cell::sync::Lazy::new(|| std::sync::Mutex::new(())); - - #[test] - fn test_bridge_struct() { - // Test that Bridge can be created - let _bridge = Bridge { _marker: () }; - } - - #[test] - fn test_runtime_python_version_defaults_to_build_python_version() { - let config = Config::default(); - assert_eq!( - runtime_python_version(&config) - .ok() - .map(|version| (version.requested().to_string(), version.abi().to_string())), - Some(( - default_python_version().to_string(), - default_python_version().to_string() - )) - ); - } - - #[test] - fn test_runtime_python_version_uses_configured_version() { - let config = Config { - python_version: Some("3.13".to_string()), - ..Config::default() - }; - assert_eq!( - runtime_python_version(&config) - .ok() - .map(|version| (version.requested().to_string(), version.abi().to_string())), - Some(("3.13".to_string(), "3.13".to_string())) - ); - } - - #[test] - fn test_runtime_python_version_keeps_patch_request_but_uses_minor_abi() { - let config = Config { - python_version: Some("3.13.1".to_string()), - ..Config::default() - }; - assert_eq!( - runtime_python_version(&config) - .ok() - .map(|version| (version.requested().to_string(), version.abi().to_string())), - Some(("3.13.1".to_string(), "3.13".to_string())) - ); - } - - #[test] - fn test_runtime_python_version_ignores_blank_config_value() { - let config = Config { - python_version: Some(" ".to_string()), - ..Config::default() - }; - assert_eq!( - runtime_python_version(&config) - .ok() - .map(|version| (version.requested().to_string(), version.abi().to_string())), - Some(( - default_python_version().to_string(), - default_python_version().to_string() - )) - ); - } - - #[test] - fn test_runtime_python_version_rejects_unsupported_configured_version() { - let config = Config { - python_version: Some("3.10".to_string()), - ..Config::default() - }; - - assert!(runtime_python_version(&config).is_err()); - } - - #[test] - fn test_post_import_log_module_cache_skips_previously_enabled_modules() { - let module_a = "r2x_test_cache_alpha"; - let module_b = "r2x_test_cache_beta"; - - let pending = pending_post_import_log_modules(&[module_a, module_b]); - assert!(pending.contains(&module_a.to_string())); - assert!(pending.contains(&module_b.to_string())); - - mark_post_import_log_modules_enabled(&[module_a.to_string()]); - - let pending = pending_post_import_log_modules(&[module_a, module_b]); - assert!(!pending.contains(&module_a.to_string())); - assert!(pending.contains(&module_b.to_string())); - } - - #[test] - fn test_is_python_executable_name_variants() { - assert!(is_python_executable_name("python")); - assert!(is_python_executable_name("python.exe")); - assert!(is_python_executable_name("python3")); - assert!(is_python_executable_name("python3.exe")); - assert!(is_python_executable_name("python3.12")); - assert!(is_python_executable_name("python3.12.exe")); - assert!(is_python_executable_name("PYTHON3.13.EXE")); - assert!(!is_python_executable_name("pythonw.exe")); - assert!(!is_python_executable_name("python-3.12.exe")); - } - - #[test] - fn test_normalize_python_home_bin_dir() { - let home = PathBuf::from("/opt/python/bin"); - assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python")); - } - - #[test] - fn test_normalize_python_home_scripts_dir() { - let home = PathBuf::from("/opt/python/Scripts"); - assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python")); - } - - #[test] - fn test_normalize_python_home_python_executable() { - let home = PathBuf::from("/opt/python/python3.12"); - assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python")); - } - - #[test] - fn test_normalize_python_home_prefix_value() { - let home = PathBuf::from("/opt/python/cpython-3.12.9-windows-x86_64-none"); - assert_eq!(normalize_python_home(&home), home); - } - - #[test] - fn test_resolve_python_home_preserves_prefix_from_pyvenv_cfg() { - let Ok(temp_dir) = TempDir::new() else { - return; - }; - let venv_path = temp_dir.path().join(".venv"); - if fs::create_dir_all(&venv_path).is_err() { - return; - } - - let expected_prefix = temp_dir.path().join("uv-python-prefix"); - let pyvenv_cfg = format!("home = {}\n", expected_prefix.to_string_lossy()); - if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() { - return; - } - - let result = resolve_python_home(&venv_path); - assert!(result.is_ok()); - assert!(result.is_ok_and(|path| path == expected_prefix)); - } - - #[test] - fn test_resolve_python_home_converts_bin_home_to_prefix() { - let Ok(temp_dir) = TempDir::new() else { - return; - }; - let venv_path = temp_dir.path().join(".venv"); - if fs::create_dir_all(&venv_path).is_err() { - return; - } - - let expected_prefix = temp_dir.path().join("python-prefix"); - let home_bin = expected_prefix.join("bin"); - let pyvenv_cfg = format!("home = {}\n", home_bin.to_string_lossy()); - if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() { - return; - } - - let result = resolve_python_home(&venv_path); - assert!(result.is_ok()); - assert!(result.is_ok_and(|path| path == expected_prefix)); - } - - #[test] - #[cfg(any(target_os = "linux", target_os = "macos"))] - fn test_find_python_lib_via_uv_falls_back_from_patch_to_abi_query() { - let Ok(_lock) = PATH_TEST_LOCK.lock() else { - return; - }; - let Ok(temp_dir) = TempDir::new() else { - return; - }; - - let python_prefix = temp_dir.path().join("cpython-3.13"); - let python_bin = python_prefix.join("bin").join("python3.13"); - let lib_dir = python_prefix.join("lib"); - if fs::create_dir_all(python_bin.parent().unwrap_or(temp_dir.path())).is_err() { - return; - } - if fs::create_dir_all(&lib_dir).is_err() { - return; - } - if fs::write(&python_bin, "").is_err() { - return; - } - - let lib_name = if cfg!(target_os = "macos") { - "libpython3.13.dylib" - } else { - "libpython3.13.so" - }; - if fs::write(lib_dir.join(lib_name), "").is_err() { - return; - } - - let uv = temp_dir.path().join("uv"); - if fs::write( - &uv, - format!( - "#!/usr/bin/env sh\nif [ \"$1\" = \"python\" ] && [ \"$2\" = \"find\" ] && [ \"$3\" = \"3.13.1\" ]; then\n exit 1\nfi\nif [ \"$1\" = \"python\" ] && [ \"$2\" = \"find\" ] && [ \"$3\" = \"3.13\" ]; then\n printf '{}\\n'\n exit 0\nfi\nexit 1\n", - python_bin.display() - ), - ) - .is_err() - { - return; - } - let Ok(metadata) = fs::metadata(&uv) else { - return; - }; - let mut permissions = metadata.permissions(); - permissions.set_mode(0o755); - if fs::set_permissions(&uv, permissions).is_err() { - return; - } - - let original_path = env::var_os("PATH"); - let mut path_entries = vec![temp_dir.path().to_path_buf()]; - if let Some(existing) = original_path.as_ref() { - path_entries.extend(env::split_paths(existing)); - } - let Ok(new_path) = env::join_paths(path_entries) else { - return; - }; - env::set_var("PATH", &new_path); - - let Ok(version) = PythonRuntimeVersion::parse("3.13.1") else { - return; - }; - let found = find_python_lib_via_uv(&version, &[lib_name.to_string()]); - - if let Some(path) = original_path { - env::set_var("PATH", path); - } else { - env::remove_var("PATH"); - } - - assert_eq!(found, Some(lib_dir)); - } -} +#[path = "python_bridge/tests.rs"] +mod tests; diff --git a/crates/r2x-python/src/python_bridge/tests.rs b/crates/r2x-python/src/python_bridge/tests.rs new file mode 100644 index 0000000..24ae086 --- /dev/null +++ b/crates/r2x-python/src/python_bridge/tests.rs @@ -0,0 +1,105 @@ +use super::*; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +#[test] +fn bridge_struct_can_be_created() { + let _bridge = Bridge { _marker: () }; +} + +#[test] +fn post_import_log_module_cache_skips_previously_enabled_modules() { + let module_a = "r2x_test_cache_alpha"; + let module_b = "r2x_test_cache_beta"; + + let pending = pending_post_import_log_modules(&[module_a, module_b]); + assert!(pending.contains(&module_a.to_string())); + assert!(pending.contains(&module_b.to_string())); + + mark_post_import_log_modules_enabled(&[module_a.to_string()]); + + let pending = pending_post_import_log_modules(&[module_a, module_b]); + assert!(!pending.contains(&module_a.to_string())); + assert!(pending.contains(&module_b.to_string())); +} + +#[test] +fn recognizes_python_executable_names() { + assert!(is_python_executable_name("python")); + assert!(is_python_executable_name("python.exe")); + assert!(is_python_executable_name("python3")); + assert!(is_python_executable_name("python3.exe")); + assert!(is_python_executable_name("python3.12")); + assert!(is_python_executable_name("python3.12.exe")); + assert!(is_python_executable_name("PYTHON3.13.EXE")); + assert!(!is_python_executable_name("pythonw.exe")); + assert!(!is_python_executable_name("python-3.12.exe")); +} + +#[test] +fn normalizes_python_home_bin_dir() { + let home = PathBuf::from("/opt/python/bin"); + assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python")); +} + +#[test] +fn normalizes_python_home_scripts_dir() { + let home = PathBuf::from("/opt/python/Scripts"); + assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python")); +} + +#[test] +fn normalizes_python_home_executable() { + let home = PathBuf::from("/opt/python/python3.12"); + assert_eq!(normalize_python_home(&home), PathBuf::from("/opt/python")); +} + +#[test] +fn preserves_python_home_prefix() { + let home = PathBuf::from("/opt/python/cpython-3.12.9-windows-x86_64-none"); + assert_eq!(normalize_python_home(&home), home); +} + +#[test] +fn resolves_python_home_prefix_from_pyvenv_cfg() { + let Ok(temp_dir) = TempDir::new() else { + return; + }; + let venv_path = temp_dir.path().join(".venv"); + if fs::create_dir_all(&venv_path).is_err() { + return; + } + + let expected_prefix = temp_dir.path().join("uv-python-prefix"); + let pyvenv_cfg = format!("home = {}\n", expected_prefix.to_string_lossy()); + if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() { + return; + } + + let result = resolve_python_home(&venv_path); + assert!(result.is_ok()); + assert!(result.is_ok_and(|path| path == expected_prefix)); +} + +#[test] +fn resolves_python_home_from_bin_in_pyvenv_cfg() { + let Ok(temp_dir) = TempDir::new() else { + return; + }; + let venv_path = temp_dir.path().join(".venv"); + if fs::create_dir_all(&venv_path).is_err() { + return; + } + + let expected_prefix = temp_dir.path().join("python-prefix"); + let home_bin = expected_prefix.join("bin"); + let pyvenv_cfg = format!("home = {}\n", home_bin.to_string_lossy()); + if fs::write(venv_path.join("pyvenv.cfg"), pyvenv_cfg).is_err() { + return; + } + + let result = resolve_python_home(&venv_path); + assert!(result.is_ok()); + assert!(result.is_ok_and(|path| path == expected_prefix)); +} diff --git a/dist-workspace.toml b/dist-workspace.toml index a62e981..1c043d1 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -32,7 +32,7 @@ compression = "xz" [dist.min-glibc-version] "*" = "2.28" -# No longer need to copy Python shim libraries - Python is discovered at runtime +# The launcher resolves the UV-managed Python library at runtime. # copy-cdylibs = true # package-libraries = ["cdylib", "cstaticlib"] diff --git a/hawk.toml b/hawk.toml index 657e43d..0913a96 100644 --- a/hawk.toml +++ b/hawk.toml @@ -5,23 +5,3 @@ preserve-uniform-field-visibility = true package = "r2x" bin = "r2x" reason = "shipped command-line application" - -# These helpers are used by the Windows Python DLL setup, which is outside -# the host-target analysis performed in CI. -[[override]] -lint = "hawk::unnecessary_public" -crate = "r2x_config" -item = "PythonRuntimeVersion::install_hint" -kind = "inherent_method" -level = "expect" -target = "cfg(not(windows))" -reason = "used by the Windows Python runtime setup" - -[[override]] -lint = "hawk::unnecessary_public" -crate = "r2x_config" -item = "PythonRuntimeVersion::find_hint" -kind = "inherent_method" -level = "expect" -target = "cfg(not(windows))" -reason = "used by the Windows Python runtime setup" diff --git a/justfile b/justfile index a8986ef..f744996 100644 --- a/justfile +++ b/justfile @@ -4,12 +4,11 @@ R2X_BIN := "target/debug/r2x" PYTHON_VERSION := env_var_or_default("R2X_PYTHON_VERSION", "3.12") # Auto-detect Python for PyO3 builds -export PYO3_PYTHON := `R2X_DEFAULT_PYTHON_VERSION={{PYTHON_VERSION}} ./scripts/resolve_pyo3_python.sh` +export PYO3_PYTHON := shell('uv python find --no-config --no-project --managed-python "$1"', PYTHON_VERSION) +PYTHON_PREFIX := shell('dirname "$(dirname "$1")"', PYO3_PYTHON) prepare-r2x: - {{CARGO}} build -p {{R2X_PKG}} - if [ "$(uname)" = "Darwin" ]; then install_name_tool -change @rpath/libiconv.2.dylib /usr/lib/libiconv.2.dylib {{R2X_BIN}}; fi - ./scripts/fix_python_dylib.sh {{R2X_BIN}} + {{CARGO}} build -p {{R2X_PKG}} --bins smoke-r2x: prepare-r2x {{R2X_BIN}} --help > /dev/null @@ -32,7 +31,7 @@ build: {{CARGO}} build --workspace --all-features test: - {{CARGO}} test --workspace --all-features + bash scripts/ci_run_with_python_lib_path.sh "{{PYTHON_PREFIX}}" {{CARGO}} test --workspace --all-features run-reeds: prepare-r2x {{R2X_BIN}} run pipeline.yaml reeds-test diff --git a/scripts/ci_compare_benchmark_baseline.sh b/scripts/ci_compare_benchmark_baseline.sh index 74329e8..10c14dd 100755 --- a/scripts/ci_compare_benchmark_baseline.sh +++ b/scripts/ci_compare_benchmark_baseline.sh @@ -13,7 +13,9 @@ main() { threshold_args+=(--fail-on-regression-pct "${R2X_BENCHMARK_REGRESSION_PCT}") fi - python3 scripts/compare_benchmark_summary.py \ + uv run --no-config --no-project --managed-python \ + --python "${R2X_PYTHON_VERSION:-3.12}" -- \ + python scripts/compare_benchmark_summary.py \ --baseline "${baseline_path}" \ --current "${current_path}" \ --baseline-run-id "${baseline_run_id}" \ diff --git a/scripts/ci_fix_libpython_install_name.sh b/scripts/ci_fix_libpython_install_name.sh deleted file mode 100755 index e02b31f..0000000 --- a/scripts/ci_fix_libpython_install_name.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -main() { - local python_prefix="${1:?Usage: ci_fix_libpython_install_name.sh }" - local python_abi_version="${2:?Usage: ci_fix_libpython_install_name.sh }" - local libpython="${python_prefix}/lib/libpython${python_abi_version}.dylib" - - if [[ ! -f "${libpython}" ]]; then - printf 'libpython not found at %s (skipping install name fix)\n' "${libpython}" - return 0 - fi - - printf 'Fixing libpython install name for portable binary linking\n' - printf ' Before: %s\n' "$(otool -D "${libpython}" | tail -1)" - install_name_tool -id "@rpath/libpython${python_abi_version}.dylib" "${libpython}" - printf ' After: %s\n' "$(otool -D "${libpython}" | tail -1)" -} - -main "$@" diff --git a/scripts/ci_format_benchmark_summary.sh b/scripts/ci_format_benchmark_summary.sh index 7e8376f..b8dd8f5 100755 --- a/scripts/ci_format_benchmark_summary.sh +++ b/scripts/ci_format_benchmark_summary.sh @@ -5,7 +5,9 @@ main() { local input_path="${1:?Usage: ci_format_benchmark_summary.sh }" local output_path="${2:?Usage: ci_format_benchmark_summary.sh }" - python3 scripts/format_benchmark_summary.py \ + uv run --no-config --no-project --managed-python \ + --python "${R2X_PYTHON_VERSION:-3.12}" -- \ + python scripts/format_benchmark_summary.py \ --input "${input_path}" \ --output "${output_path}" \ --append-summary diff --git a/scripts/ci_pyo3_diagnostics.sh b/scripts/ci_pyo3_diagnostics.sh deleted file mode 100755 index 33ecf02..0000000 --- a/scripts/ci_pyo3_diagnostics.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/python_version.sh -source "$script_dir/python_version.sh" - -python_bin="${PYO3_PYTHON:-}" -build_target="${CARGO_BUILD_TARGET:-${TARGET:-default}}" -diagnostic_status="ok" -diagnostic_error="" -diagnostic_remediation="" -python_version="" -python_executable="" -python_prefix="" -python_abi="" -python_libdir="" -requested_python_abi="" - -set_diagnostic_failure() { - diagnostic_status="error" - diagnostic_error="$1" - diagnostic_remediation="$2" -} - -uv_find_alignment_hint() { - local requested_version="$1" - local requested_abi="$2" - if [[ -n "$requested_abi" && "$requested_abi" != "$requested_version" ]]; then - echo "uv python find ${requested_version} || uv python find ${requested_abi}" - else - echo "uv python find ${requested_version}" - fi -} - -python_find_hint_for_version() { - local requested_version="$1" - local requested_abi="" - if requested_abi="$(r2x_python_abi_version "R2X_PYTHON_VERSION" "$requested_version" 2>/dev/null)"; then - uv_find_alignment_hint "$requested_version" "$requested_abi" - else - echo "uv python find ${requested_version}" - fi -} - -python_install_hint_for_version() { - local requested_version="$1" - local requested_abi="" - if requested_abi="$(r2x_python_abi_version "R2X_PYTHON_VERSION" "$requested_version" 2>/dev/null)"; then - if [[ "$requested_abi" != "$requested_version" ]]; then - echo "uv python install ${requested_version} || uv python install ${requested_abi}" - else - echo "uv python install ${requested_version}" - fi - else - echo "uv python install ${requested_version}" - fi -} - -recommended_python_version() { - if [[ -n "${R2X_PYTHON_VERSION:-}" ]]; then - echo "${R2X_PYTHON_VERSION}" - else - echo "3.12" - fi -} - -collect_python_diagnostics() { - if [[ -z "$python_bin" ]]; then - local recommended - local recommended_find_hint - recommended="$(recommended_python_version)" - recommended_find_hint="$(python_find_hint_for_version "$recommended")" - set_diagnostic_failure \ - "PYO3_PYTHON is unset" \ - "Set PYO3_PYTHON to a valid interpreter path (example: ${recommended_find_hint})" - return 1 - fi - - if [[ ! -x "$python_bin" ]]; then - local recommended - local recommended_find_hint - local recommended_install_hint - recommended="$(recommended_python_version)" - recommended_find_hint="$(python_find_hint_for_version "$recommended")" - recommended_install_hint="$(python_install_hint_for_version "$recommended")" - set_diagnostic_failure \ - "PYO3_PYTHON is not executable: $python_bin" \ - "Install Python with ${recommended_install_hint}, then reset PYO3_PYTHON (example: ${recommended_find_hint})" - return 1 - fi - - python_version="$("$python_bin" --version 2>&1 || true)" - python_executable="$("$python_bin" -c 'import sys; print(sys.executable)' 2>/dev/null || true)" - python_prefix="$("$python_bin" -c 'import sys; print(sys.prefix)' 2>/dev/null || true)" - python_abi="$("$python_bin" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || true)" - python_libdir="$("$python_bin" -c 'import sysconfig; print(sysconfig.get_config_var("LIBDIR") or "")' 2>/dev/null || true)" - - if [[ -z "$python_abi" ]]; then - set_diagnostic_failure \ - "PYO3_PYTHON did not report a Python ABI version: $python_bin" \ - "Set PYO3_PYTHON to a working Python 3.11+ interpreter" - return 1 - fi - - if ! diagnostic_error="$(r2x_python_abi_version "PYO3_PYTHON" "$python_abi" 2>&1 >/dev/null)"; then - set_diagnostic_failure \ - "$diagnostic_error" \ - "Install and use a supported interpreter (Python 3.11 or newer) for PYO3_PYTHON" - return 1 - fi - diagnostic_error="" - diagnostic_remediation="" - - if [[ -n "${R2X_PYTHON_VERSION:-}" ]]; then - if ! requested_python_abi="$(r2x_python_abi_version "R2X_PYTHON_VERSION" "$R2X_PYTHON_VERSION" 2>&1)"; then - set_diagnostic_failure \ - "$requested_python_abi" \ - "Set R2X_PYTHON_VERSION to major.minor or patch format (for example 3.12 or 3.13.1)" - return 1 - fi - if [[ "$requested_python_abi" != "$python_abi" ]]; then - local align_cmd - align_cmd="$(uv_find_alignment_hint "$R2X_PYTHON_VERSION" "$requested_python_abi")" - set_diagnostic_failure \ - "R2X_PYTHON_VERSION requests Python ABI $requested_python_abi but PYO3_PYTHON reports $python_abi" \ - "Align them by setting PYO3_PYTHON to ${align_cmd}" - return 1 - fi - fi -} - -emit_table() { - local summary_path="$1" - { - echo "### PyO3 build context" - echo - echo "| Setting | Value |" - echo "| --- | --- |" - echo "| PYO3_PYTHON | \`${python_bin:-unset}\` |" - echo "| PYO3_CONFIG_FILE | \`${PYO3_CONFIG_FILE:-unset}\` |" - echo "| PYO3_CROSS | \`${PYO3_CROSS:-unset}\` |" - echo "| PYO3_CROSS_LIB_DIR | \`${PYO3_CROSS_LIB_DIR:-unset}\` |" - echo "| PYO3_CROSS_PYTHON_VERSION | \`${PYO3_CROSS_PYTHON_VERSION:-unset}\` |" - echo "| R2X_PYTHON_VERSION | \`${R2X_PYTHON_VERSION:-unset}\` |" - if [[ -n "$requested_python_abi" ]]; then - echo "| requested Python ABI | \`${requested_python_abi}\` |" - fi - echo "| status | \`${diagnostic_status}\` |" - if [[ -n "$diagnostic_error" ]]; then - echo "| error | \`${diagnostic_error}\` |" - fi - if [[ -n "$diagnostic_remediation" ]]; then - echo "| remediation | \`${diagnostic_remediation}\` |" - fi - echo "| rustc | \`$(rustc --version 2>/dev/null || echo unavailable)\` |" - echo "| cargo | \`$(cargo --version 2>/dev/null || echo unavailable)\` |" - echo "| cargo target | \`${build_target}\` |" - if [[ "$diagnostic_status" == "ok" ]]; then - echo "| Python version | \`${python_version}\` |" - echo "| Python executable | \`${python_executable}\` |" - echo "| Python prefix | \`${python_prefix}\` |" - echo "| Python ABI | \`${python_abi}\` |" - echo "| LIBDIR | \`${python_libdir}\` |" - else - echo "| Python version | \`unavailable\` |" - fi - } >> "$summary_path" -} - -collect_python_diagnostics || true - -if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then - emit_table "$GITHUB_STEP_SUMMARY" -fi - -if [[ "$diagnostic_status" == "ok" ]]; then - echo "PyO3 Python: $python_bin ($python_version)" -else - echo "PyO3 Python: ${diagnostic_error}" - if [[ -n "$diagnostic_remediation" ]]; then - echo "PyO3 remediation: ${diagnostic_remediation}" - fi -fi -echo "R2X_PYTHON_VERSION: ${R2X_PYTHON_VERSION:-unset}" -echo "rustc: $(rustc --version 2>/dev/null || echo unavailable)" -echo "cargo: $(cargo --version 2>/dev/null || echo unavailable)" - -if [[ "$diagnostic_status" != "ok" ]]; then - exit 1 -fi diff --git a/scripts/ci_run_with_python_lib_path.sh b/scripts/ci_run_with_python_lib_path.sh index 8d25f41..bd8f22f 100755 --- a/scripts/ci_run_with_python_lib_path.sh +++ b/scripts/ci_run_with_python_lib_path.sh @@ -10,7 +10,21 @@ main() { return 1 fi - export LD_LIBRARY_PATH="${python_prefix}/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" + # Rust test binaries embed Python rather than starting it through uv. + export PYTHONHOME="${python_prefix}" + + case "$(uname -s)" in + Darwin) + export DYLD_LIBRARY_PATH="${python_prefix}/lib${DYLD_LIBRARY_PATH:+:${DYLD_LIBRARY_PATH}}" + ;; + Linux) + export LD_LIBRARY_PATH="${python_prefix}/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" + ;; + MINGW* | MSYS* | CYGWIN*) + export PATH="${python_prefix}${PATH:+:${PATH}}" + ;; + esac + "$@" } diff --git a/scripts/ci_setup_uv_python.sh b/scripts/ci_setup_uv_python.sh deleted file mode 100755 index 205a13d..0000000 --- a/scripts/ci_setup_uv_python.sh +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -readonly script_dir -# shellcheck source=scripts/python_version.sh -source "${script_dir}/python_version.sh" - -gha_error() { - local message="$1" - printf '::error::%s\n' "${message}" >&2 -} - -append_output() { - local key="$1" - local value="$2" - - if [[ -n "${GITHUB_OUTPUT:-}" ]]; then - printf '%s=%s\n' "${key}" "${value}" >>"${GITHUB_OUTPUT}" - fi -} - -append_env() { - local key="$1" - local value="$2" - - if [[ -n "${GITHUB_ENV:-}" ]]; then - printf '%s=%s\n' "${key}" "${value}" >>"${GITHUB_ENV}" - fi -} - -append_summary() { - local requested_version="$1" - local requested_abi="$2" - local resolved_query="$3" - local python_version="$4" - local python_abi="$5" - local python_bin="$6" - local python_prefix="$7" - local uv_version="$8" - - if [[ -z "${GITHUB_STEP_SUMMARY:-}" ]]; then - return 0 - fi - - { - printf '### Python runtime\n\n' - printf '| Setting | Value |\n' - printf '| --- | --- |\n' - printf "| Requested | \`%s\` |\n" "${requested_version}" - printf "| Requested ABI | \`%s\` |\n" "${requested_abi}" - printf "| Resolved query | \`%s\` |\n" "${resolved_query}" - printf "| Resolved | \`%s\` |\n" "${python_version}" - printf "| ABI | \`%s\` |\n" "${python_abi}" - printf "| Interpreter | \`%s\` |\n" "${python_bin}" - printf "| Prefix | \`%s\` |\n" "${python_prefix}" - printf "| uv | \`%s\` |\n" "${uv_version}" - } >>"${GITHUB_STEP_SUMMARY}" -} - -install_requested_python() { - local requested_version="$1" - local requested_abi="$2" - local install_hint="$3" - - if uv python install "${requested_version}"; then - return 0 - fi - - if [[ "${requested_abi}" != "${requested_version}" ]]; then - if uv python install "${requested_abi}"; then - return 0 - fi - gha_error "unable to install requested Python version ${requested_version} (fallback ABI ${requested_abi} also failed)" - else - gha_error "unable to install requested Python version ${requested_version}" - fi - - gha_error "Install it with: ${install_hint}" - return 1 -} - -resolve_python() { - local requested_version="$1" - local requested_abi="$2" - - local resolved_query="${requested_version}" - local python_bin - python_bin="$(uv python find "${resolved_query}" 2>/dev/null || true)" - - if [[ -z "${python_bin}" && "${requested_abi}" != "${requested_version}" ]]; then - resolved_query="${requested_abi}" - python_bin="$(uv python find "${resolved_query}" 2>/dev/null || true)" - fi - - printf '%s\n%s\n' "${resolved_query}" "${python_bin}" -} - -main() { - local requested_version="${1:?Usage: ci_setup_uv_python.sh }" - local requested_abi - if ! requested_abi="$(r2x_python_abi_version "python-version" "${requested_version}" 2>&1)"; then - gha_error "${requested_abi}" - return 1 - fi - - local install_hint find_hint - install_hint="$(r2x_python_install_hint "${requested_version}")" - find_hint="$(r2x_python_find_hint "${requested_version}")" - - install_requested_python "${requested_version}" "${requested_abi}" "${install_hint}" - - local resolved_python resolved_query python_bin - resolved_python="$(resolve_python "${requested_version}" "${requested_abi}")" - resolved_query="$(printf '%s\n' "${resolved_python}" | sed -n '1p')" - python_bin="$(printf '%s\n' "${resolved_python}" | sed -n '2p')" - if [[ -z "${python_bin}" ]]; then - gha_error "unable to resolve Python for requested version ${requested_version} (tried ABI ${requested_abi})" - gha_error "Install it with: ${install_hint}" - gha_error "Verify with: ${find_hint}" - return 1 - fi - - local python_prefix python_version python_abi uv_version - python_prefix="$(dirname "$(dirname "${python_bin}")")" - python_version="$("${python_bin}" --version)" - python_abi="$("${python_bin}" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" - uv_version="$(uv --version)" - - if [[ "${python_abi}" != "${requested_abi}" ]]; then - gha_error "python-version requested ABI ${requested_abi} but uv resolved ABI ${python_abi} at ${python_bin}" - return 1 - fi - - append_env "PYO3_PYTHON" "${python_bin}" - append_env "R2X_PYTHON_VERSION" "${requested_version}" - append_output "python-path" "${python_bin}" - append_output "resolved-version" "${python_version}" - append_output "python-abi-version" "${python_abi}" - append_output "python-prefix" "${python_prefix}" - - printf 'Setting PYO3_PYTHON => %s (%s)\n' "${python_bin}" "${python_version}" - append_summary "${requested_version}" "${requested_abi}" "${resolved_query}" "${python_version}" "${python_abi}" "${python_bin}" "${python_prefix}" "${uv_version}" -} - -main "$@" diff --git a/scripts/detect_uv_python.py b/scripts/detect_uv_python.py deleted file mode 100644 index 9cfafb2..0000000 --- a/scripts/detect_uv_python.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python3 -""" -Utility to locate the prefix path of a uv-managed Python installation. - -Usage: - python3 scripts/detect_uv_python.py [--version 3.12] - -Prints the install prefix (parent of the `bin` directory) to stdout. -""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -from pathlib import Path -from typing import Any, Dict, Iterable, Optional - -DEFAULT_VERSION = "3.12" - - -def validate_python_version(version: str) -> str: - """Return a supported Python version string or raise ValueError.""" - version = version.strip() - parts = version.split(".") - if len(parts) not in (2, 3) or any(not part.isdigit() for part in parts): - raise ValueError( - f"expected a Python version like 3.12 or 3.12.1, got: {version}" - ) - - major = int(parts[0]) - minor = int(parts[1]) - if major != 3 or minor < 11: - raise ValueError( - f"Python {version} is not supported; r2x requires Python 3.11 or newer" - ) - - return version - - -def python_abi_version(version: str) -> str: - """Normalize 3.x(.patch) to 3.x ABI form.""" - parts = version.strip().split(".") - return f"{parts[0]}.{parts[1]}" - - -def load_uv_python_list(version: str) -> Iterable[Dict[str, Any]]: - """Invoke `uv python list` and return parsed JSON entries.""" - result = subprocess.run( - [ - "uv", - "python", - "list", - "--only-installed", - "--output-format", - "json", - version, - ], - check=False, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - if result.returncode != 0: - raise RuntimeError( - f"uv python list failed for {version}: {result.stderr.strip() or f'exit {result.returncode}'}" - ) - data = json.loads(result.stdout) - if not isinstance(data, list): - raise RuntimeError("uv python list returned unexpected JSON payload") - return data - - -def choose_uv_prefix(entries: Iterable[Dict[str, Any]]) -> Optional[Path]: - """ - Pick the best python prefix directory from uv entries. - - Preference order: - 1. Paths under ~/.local/share/uv/python - 2. Paths containing AppData\\Local\\uv\\python (Windows) - 3. First available path entry - """ - preferred: Optional[Path] = None - fallback: Optional[Path] = None - - for entry in entries: - raw_path = entry.get("path") - if not raw_path: - continue - path = Path(raw_path) - normalized = str(path).replace("\\", "/") - - if ".local/share/uv/python" in normalized or "AppData/Local/uv/python" in normalized: - parent = path.parent # bin directory - return parent.parent - - if fallback is None: - fallback = path.parent.parent - - return preferred or fallback - - -def main(argv: Optional[Iterable[str]] = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--version", - default=os.environ.get("R2X_PYTHON_VERSION") - or os.environ.get("PY_VERSION") - or DEFAULT_VERSION, - help="Python version to locate (default: %(default)s)", - ) - args = parser.parse_args(list(argv) if argv is not None else None) - - try: - version = validate_python_version(args.version) - except ValueError as error: - print(f"error: {error}", file=sys.stderr) - return 1 - - is_patch_request = version.count(".") == 2 - fallback_error: Optional[Exception] = None - try: - entries = load_uv_python_list(version) - prefix = choose_uv_prefix(entries) - except Exception as error: - entries = [] - prefix = None - fallback_error = error - - if (not prefix) and is_patch_request: - requested_abi = python_abi_version(version) - if requested_abi != version: - try: - entries = load_uv_python_list(requested_abi) - prefix = choose_uv_prefix(entries) - except Exception: - prefix = None - - if not prefix and fallback_error is not None and not is_patch_request: - print(f"error: {fallback_error}", file=sys.stderr) - return 1 - - if not prefix: - if fallback_error is not None and is_patch_request: - print( - f"error: unable to determine uv-managed python path (requested {version}, fallback ABI {python_abi_version(version)})", - file=sys.stderr, - ) - return 1 - print("error: unable to determine uv-managed python path", file=sys.stderr) - return 1 - - print(prefix) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/fix_python_dylib.sh b/scripts/fix_python_dylib.sh deleted file mode 100755 index 0beebaf..0000000 --- a/scripts/fix_python_dylib.sh +++ /dev/null @@ -1,270 +0,0 @@ -#!/bin/bash -# Fix hardcoded Python library paths in r2x binaries -# -# This script fixes the issue where PyO3 embeds absolute paths to libpython -# at compile-time (e.g., /Users/runner/.local/share/uv/python/.../libpython3.12.dylib). -# -# On macOS: Uses install_name_tool to convert to @rpath-relative paths -# On Linux: Uses patchelf to set appropriate rpath -# -# Usage: ./scripts/fix_python_dylib.sh - -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/python_version.sh -source "$script_dir/python_version.sh" - -# Add rpath entry, ignoring "already exists" errors -add_rpath() { - install_name_tool -add_rpath "$1" "$2" 2>/dev/null || true -} - -python_abi_version() { - r2x_python_abi_version "PYTHON_VERSION" "$1" -} - -detect_pyo3_python_abi() { - local python_bin="$1" - - if [[ ! -x "$python_bin" ]]; then - echo "PYO3_PYTHON is set but not executable: $python_bin" >&2 - return 1 - fi - - local python_version - python_version=$("$python_bin" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null || true) - if [[ -z "$python_version" ]]; then - echo "PYO3_PYTHON is set but did not report a Python version: $python_bin" >&2 - return 1 - fi - - r2x_python_abi_version "PYO3_PYTHON" "$python_version" -} - -validate_pyo3_python_matches_configured_version() { - local python_bin="${PYO3_PYTHON:-}" - local configured_version="$1" - - if [[ -z "$configured_version" || -z "$python_bin" ]]; then - return 0 - fi - - local requested_abi pyo3_abi - requested_abi=$(r2x_python_abi_version "R2X_PYTHON_VERSION" "$configured_version") - pyo3_abi=$(detect_pyo3_python_abi "$python_bin") - - if [[ "$pyo3_abi" != "$requested_abi" ]]; then - echo "PYO3_PYTHON resolves to Python $pyo3_abi but R2X_PYTHON_VERSION requests $requested_abi" >&2 - return 1 - fi -} - -resolve_uv_python_tag() { - local python_bin="${PYO3_PYTHON:-}" - local configured_version="${R2X_PYTHON_VERSION:-${PYTHON_VERSION:-}}" - local python_version="${configured_version:-3.12}" - - if [[ -n "$configured_version" ]]; then - r2x_validate_python_version "R2X_PYTHON_VERSION" "$configured_version" - validate_pyo3_python_matches_configured_version "$configured_version" - elif [[ -n "$python_bin" ]]; then - detect_pyo3_python_abi "$python_bin" >/dev/null - fi - - if [[ -z "$python_bin" ]] && command -v uv &> /dev/null; then - if [[ -n "$configured_version" ]]; then - python_bin=$(r2x_find_uv_python "$python_version" || true) - else - python_bin=$(r2x_find_uv_python "$python_version" || r2x_find_uv_python 3.11 || true) - fi - fi - - if [[ -z "$python_bin" ]]; then - return 1 - fi - - local python_dir uv_tag - python_dir=$(dirname "$python_bin") - uv_tag=$(basename "$(dirname "$python_dir")") - - if [[ -z "$uv_tag" ]]; then - return 1 - fi - - echo "$uv_tag" -} - -resolve_python_version() { - local python_bin="${PYO3_PYTHON:-}" - local configured_version="${R2X_PYTHON_VERSION:-${PYTHON_VERSION:-}}" - - if [[ -n "$configured_version" ]]; then - r2x_validate_python_version "R2X_PYTHON_VERSION" "$configured_version" - validate_pyo3_python_matches_configured_version "$configured_version" - python_abi_version "$configured_version" - return 0 - fi - - if [[ -z "$python_bin" ]] && command -v uv &> /dev/null; then - python_bin=$(uv python find 3.12 2>/dev/null || uv python find 3.11 2>/dev/null || true) - fi - - if [[ -n "$python_bin" && -x "$python_bin" ]]; then - detect_pyo3_python_abi "$python_bin" - return 0 - fi - - echo "3.12" -} - -fix_macos() { - local binary="$1" - - echo "Fixing Python dylib paths for macOS binary: $binary" - - if otool -L "$binary" | grep -q '@rpath/libpython'; then - echo "Python library already uses @rpath" - else - # Find the libpython reference (try specific pattern first, then broader) - local python_lib - python_lib=$(otool -L "$binary" | grep -o '/.*libpython[0-9.]*\.dylib' | head -1 || true) - - if [[ -z "$python_lib" ]]; then - python_lib=$(otool -L "$binary" | grep -o '/.*python.*\.dylib' | head -1 || true) - fi - - if [[ -z "$python_lib" ]]; then - echo "No Python library reference found. Binary may be statically linked or already fixed." - return 0 - fi - - echo "Found: $python_lib" - - local lib_name new_path - lib_name=$(basename "$python_lib") - new_path="@rpath/$lib_name" - - echo "Converting to: $new_path" - install_name_tool -change "$python_lib" "$new_path" "$binary" - fi - - # Add common rpath locations for finding libpython - add_rpath "@executable_path/../lib" "$binary" - - local uv_tag - if uv_tag=$(resolve_uv_python_tag); then - add_rpath "@executable_path/../share/uv/python/$uv_tag/lib" "$binary" - else - case "$(uname -m)" in - arm64) - add_rpath "/opt/homebrew/lib" "$binary" - ;; - x86_64) - add_rpath "/usr/local/lib" "$binary" - ;; - *) - add_rpath "/usr/local/lib" "$binary" - ;; - esac - fi - - # Verify - echo "" - echo "Python references after fix:" - otool -L "$binary" | grep -i python || echo " (none)" - echo "" - echo "rpath entries:" - otool -l "$binary" | grep -A2 LC_RPATH | grep path || echo " (none)" - echo "" - echo "Done! Users need libpython accessible via rpath or DYLD_LIBRARY_PATH." -} - -fix_linux() { - local binary="$1" - - echo "Fixing Python library paths for Linux binary: $binary" - - if ! command -v patchelf &> /dev/null; then - echo "Error: patchelf is required but not installed." - echo " RHEL/Rocky: dnf install -y epel-release patchelf" - echo " Debian/Ubuntu: apt-get install -y patchelf" - exit 1 - fi - - # Check for ANY libpython reference (resolved or "=> not found") - local python_refs - python_refs=$(ldd "$binary" 2>/dev/null | grep -i 'libpython' || true) - - if [[ -z "$python_refs" ]]; then - echo "No libpython reference found in binary." - return 0 - fi - - # Log what ldd found - local python_lib - python_lib=$(echo "$python_refs" | grep -o '/.*libpython[0-9.]*\.so[0-9.]*' | head -1 || true) - - if [[ -n "$python_lib" ]]; then - echo "Found: $python_lib" - else - echo "Found unresolved libpython reference (not in current search path):" - echo " $(echo "$python_refs" | head -1)" - fi - - # $ORIGIN allows finding libs relative to the binary - local uv_rpath="" - local uv_tag - if uv_tag=$(resolve_uv_python_tag); then - uv_rpath=":\$ORIGIN/../share/uv/python/$uv_tag/lib" - fi - - local python_version - python_version=$(resolve_python_version) - local new_rpath="\$ORIGIN/../lib:\$ORIGIN:\$ORIGIN/../lib/python${python_version}/config-${python_version}-x86_64-linux-gnu:/usr/local/lib:/usr/lib:/usr/lib64${uv_rpath}" - - echo "Setting rpath to: $new_rpath" - patchelf --set-rpath "$new_rpath" "$binary" - - # Verify - echo "" - echo "rpath after fix:" - patchelf --print-rpath "$binary" - echo "" - echo "Python references:" - ldd "$binary" | grep -i python || echo " (none - may use dlopen)" - echo "" - echo "Done! Users need libpython accessible via rpath or LD_LIBRARY_PATH." -} - -main() { - local binary="${1:-}" - - if [[ -z "$binary" ]]; then - echo "Usage: $0 " - echo "Example: $0 target/debug/r2x" - exit 1 - fi - - if [[ ! -f "$binary" ]]; then - echo "Error: Binary not found: $binary" - exit 1 - fi - - case "$(uname -s)" in - Darwin) - fix_macos "$binary" - ;; - Linux) - fix_linux "$binary" - ;; - *) - echo "Unsupported platform: $(uname -s)" - exit 1 - ;; - esac -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi diff --git a/scripts/patch_dist_installer.sh b/scripts/patch_dist_installer.sh deleted file mode 100755 index bf64a5d..0000000 --- a/scripts/patch_dist_installer.sh +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/python_version.sh -source "$script_dir/python_version.sh" - -installer_path="${1:-}" -python_request_version="${R2X_PYTHON_VERSION:-${PYTHON_VERSION:-3.12}}" - -if [[ -z "${installer_path}" ]]; then - echo "Usage: $0 " - exit 1 -fi - -if [[ ! -f "${installer_path}" ]]; then - echo "Installer not found: ${installer_path}" - exit 1 -fi - -python_abi_version="$(r2x_python_abi_version "R2X_PYTHON_VERSION" "${python_request_version}")" -python_install_hint="$(r2x_python_install_hint "${python_request_version}")" -python_find_hint="$(r2x_python_find_hint "${python_request_version}")" - -if grep -q "ensure_python_runtime_for_r2x()" "${installer_path}"; then - echo "Installer already patched: ${installer_path}" - exit 0 -fi - -tmp_file="$(mktemp)" -trap 'rm -f "${tmp_file}"' EXIT - -awk -v python_request_version="${python_request_version}" -v python_abi_version="${python_abi_version}" -v python_install_hint="${python_install_hint}" -v python_find_hint="${python_find_hint}" ' -BEGIN { - inserted_function = 0 - inserted_call = 0 -} -{ - if ($0 == "check_for_shadowed_bins() {") { - print "ensure_python_runtime_for_r2x() {" - print " local _install_dir=\"$1\"" - print " local _arch=\"$2\"" - print "" - print " if [ \"$APP_NAME\" != \"r2x\" ]; then" - print " return 0" - print " fi" - print "" - print " case \"$_arch\" in" - print " *-unknown-linux-gnu)" - print " local _python_request_version=\"" python_request_version "\"" - print " local _python_abi_version=\"" python_abi_version "\"" - print " local _primary_lib=\"libpython${_python_abi_version}.so.1.0\"" - print " local _secondary_lib=\"libpython${_python_abi_version}.so\"" - print " ;;" - print " *-apple-darwin)" - print " local _python_request_version=\"" python_request_version "\"" - print " local _python_abi_version=\"" python_abi_version "\"" - print " local _primary_lib=\"libpython${_python_abi_version}.dylib\"" - print " local _secondary_lib=\"\"" - print " ;;" - print " *)" - print " return 0" - print " ;;" - print " esac" - print "" - print " if \"$_install_dir/$APP_NAME\" --version >/dev/null 2>&1; then" - print " return 0" - print " fi" - print "" - print " if ! command -v uv >/dev/null 2>&1; then" - print " say \"warning: $APP_NAME requires Python ${_python_abi_version} shared libraries.\"" - print " say \"Install uv and run: " python_install_hint "\"" - print " say \"Then re-run this installer or copy libpython into the install directory.\"" - print " return 0" - print " fi" - print "" - print " uv python install \"$_python_request_version\" >/dev/null 2>&1 || true" - print " local _python_bin" - print " _python_bin=\"$(uv python find \"$_python_request_version\" 2>/dev/null || true)\"" - print " if [ -z \"$_python_bin\" ] && [ \"$_python_abi_version\" != \"$_python_request_version\" ]; then" - print " _python_bin=\"$(uv python find \"$_python_abi_version\" 2>/dev/null || true)\"" - print " fi" - print " if [ -z \"$_python_bin\" ]; then" - print " say \"warning: unable to locate Python ${_python_request_version} via uv\"" - print " say \"Run: " python_install_hint "\"" - print " say \"Verify with: " python_find_hint "\"" - print " return 0" - print " fi" - print "" - print " local _python_prefix" - print " _python_prefix=\"$(dirname \"$(dirname \"$_python_bin\")\")\"" - print " local _python_lib_dir=\"$_python_prefix/lib\"" - print "" - print " if [ ! -f \"$_python_lib_dir/$_primary_lib\" ]; then" - print " say \"warning: expected Python library not found at $_python_lib_dir/$_primary_lib\"" - print " say \"$APP_NAME may not work until libpython is available.\"" - print " return 0" - print " fi" - print "" - print " ensure cp -f \"$_python_lib_dir/$_primary_lib\" \"$_install_dir/$_primary_lib\"" - print " ensure chmod +x \"$_install_dir/$_primary_lib\"" - print "" - print " if [ -n \"$_secondary_lib\" ] && [ -f \"$_python_lib_dir/$_secondary_lib\" ]; then" - print " ensure cp -f \"$_python_lib_dir/$_secondary_lib\" \"$_install_dir/$_secondary_lib\"" - print " ensure chmod +x \"$_install_dir/$_secondary_lib\"" - print " fi" - print "" - print " if \"$_install_dir/$APP_NAME\" --version >/dev/null 2>&1; then" - print " say_verbose \"installed Python runtime libraries for $APP_NAME\"" - print " fi" - print "}" - print "" - inserted_function = 1 - } - - if ($0 ~ /^ say "everything/ && $0 ~ /installed!"$/) { - print " ensure_python_runtime_for_r2x \"$_install_dir\" \"$_arch\"" - inserted_call = 1 - } - - print $0 -} -END { - if (inserted_function != 1) { - print "Failed to insert runtime helper function" > "/dev/stderr" - exit 1 - } - if (inserted_call != 1) { - print "Failed to insert runtime helper call" > "/dev/stderr" - exit 1 - } -} -' "${installer_path}" > "${tmp_file}" - -mv "${tmp_file}" "${installer_path}" -chmod +x "${installer_path}" -echo "Patched installer: ${installer_path}" diff --git a/scripts/python_version.sh b/scripts/python_version.sh deleted file mode 100644 index a16d204..0000000 --- a/scripts/python_version.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env bash - -r2x_python_abi_version() { - local label="$1" - local version="$2" - - if [[ ! "$version" =~ ^3\.([0-9]+)(\.[0-9]+)?$ ]]; then - echo "$label must be a Python version like 3.12 or 3.12.1, got: $version" >&2 - return 1 - fi - - local minor="${BASH_REMATCH[1]}" - if ((10#$minor < 11)); then - echo "$label=$version is not supported; r2x requires Python 3.11 or newer" >&2 - return 1 - fi - - echo "3.${minor}" -} - -r2x_validate_python_version() { - local label="$1" - local version="$2" - - r2x_python_abi_version "$label" "$version" >/dev/null -} - -r2x_python_install_hint() { - local requested_version="$1" - local requested_abi="" - - if ! requested_abi="$(r2x_python_abi_version "R2X_PYTHON_VERSION" "$requested_version" 2>/dev/null)"; then - echo "uv python install $requested_version" - return 0 - fi - - if [[ "$requested_abi" != "$requested_version" ]]; then - echo "uv python install $requested_version || uv python install $requested_abi" - else - echo "uv python install $requested_version" - fi -} - -r2x_python_find_hint() { - local requested_version="$1" - local requested_abi="" - - if ! requested_abi="$(r2x_python_abi_version "R2X_PYTHON_VERSION" "$requested_version" 2>/dev/null)"; then - echo "uv python find $requested_version" - return 0 - fi - - if [[ "$requested_abi" != "$requested_version" ]]; then - echo "uv python find $requested_version || uv python find $requested_abi" - else - echo "uv python find $requested_version" - fi -} - -r2x_find_uv_python() { - local requested_version="$1" - - command -v uv >/dev/null 2>&1 || return 1 - - local python_bin="" - python_bin="$(uv python find "$requested_version" 2>/dev/null || true)" - if [[ -n "$python_bin" ]]; then - echo "$python_bin" - return 0 - fi - - local requested_abi="" - if requested_abi="$(r2x_python_abi_version "R2X_PYTHON_VERSION" "$requested_version" 2>/dev/null)"; then - if [[ "$requested_version" == *.*.* ]] && [[ "$requested_abi" != "$requested_version" ]]; then - python_bin="$(uv python find "$requested_abi" 2>/dev/null || true)" - if [[ -n "$python_bin" ]]; then - echo "$python_bin" - return 0 - fi - fi - fi - - return 1 -} diff --git a/scripts/resolve_pyo3_python.sh b/scripts/resolve_pyo3_python.sh deleted file mode 100755 index f596425..0000000 --- a/scripts/resolve_pyo3_python.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=scripts/python_version.sh -source "$script_dir/python_version.sh" - -requested_version="${R2X_PYTHON_VERSION:-}" -default_version="${R2X_DEFAULT_PYTHON_VERSION:-3.12}" - -detect_python_version() { - local python="$1" - "$python" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' -} - -if [[ -n "${PYO3_PYTHON:-}" ]]; then - if [[ -x "$PYO3_PYTHON" ]]; then - python_version="$(detect_python_version "$PYO3_PYTHON" 2>/dev/null || true)" - if [[ -z "$python_version" ]]; then - echo "PYO3_PYTHON is set but did not report a Python version: $PYO3_PYTHON" >&2 - exit 1 - fi - python_abi="$(r2x_python_abi_version "PYO3_PYTHON" "$python_version")" - if [[ -n "$requested_version" ]]; then - requested_abi="$(r2x_python_abi_version "R2X_PYTHON_VERSION" "$requested_version")" - if [[ "$python_abi" != "$requested_abi" ]]; then - echo "PYO3_PYTHON resolves to Python $python_abi but R2X_PYTHON_VERSION requests $requested_abi" >&2 - exit 1 - fi - fi - echo "$PYO3_PYTHON" - exit 0 - fi - echo "PYO3_PYTHON is set but not executable: $PYO3_PYTHON" >&2 - exit 1 -fi - -if [[ -n "$requested_version" ]]; then - r2x_validate_python_version "R2X_PYTHON_VERSION" "$requested_version" - if python_bin=$(r2x_find_uv_python "$requested_version"); then - echo "$python_bin" - exit 0 - fi - install_hint="$(r2x_python_install_hint "$requested_version")" - find_hint="$(r2x_python_find_hint "$requested_version")" - echo "Requested R2X_PYTHON_VERSION=$requested_version was not found." >&2 - echo "Install it with: $install_hint" >&2 - echo "Verify with: $find_hint" >&2 - exit 1 -fi - -r2x_validate_python_version "R2X_DEFAULT_PYTHON_VERSION" "$default_version" -for version in "$default_version" 3.12 3.11; do - if python_bin=$(r2x_find_uv_python "$version"); then - echo "$python_bin" - exit 0 - fi -done - -if command -v python3 >/dev/null 2>&1; then - command -v python3 - exit 0 -fi - -default_install_hint="$(r2x_python_install_hint "$default_version")" -default_find_hint="$(r2x_python_find_hint "$default_version")" -echo "Unable to find Python for PyO3. Install uv and run: $default_install_hint" >&2 -echo "Verify with: $default_find_hint" >&2 -exit 1 diff --git a/scripts/tests/test_ci_python_version.py b/scripts/tests/test_ci_python_version.py deleted file mode 100644 index 48c5489..0000000 --- a/scripts/tests/test_ci_python_version.py +++ /dev/null @@ -1,339 +0,0 @@ -import os -import stat -import subprocess -import tempfile -import unittest -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[2] -DIAGNOSTICS_SCRIPT = REPO_ROOT / "scripts" / "ci_pyo3_diagnostics.sh" - - -def write_executable(path: Path, content: str) -> None: - path.write_text(content) - path.chmod(path.stat().st_mode | stat.S_IXUSR) - - -class CiPythonVersionTests(unittest.TestCase): - def test_readme_documents_r2x_python_version_for_direct_cargo_builds(self): - readme = (REPO_ROOT / "README.md").read_text() - - self.assertIn( - "R2X_PYTHON_VERSION=3.12 cargo install --path crates/r2x-cli --force --locked", - readme, - ) - self.assertIn( - "R2X_PYTHON_VERSION=3.13 cargo install --path crates/r2x-cli --force --locked", - readme, - ) - self.assertIn("R2X_PYTHON_VERSION=3.12 cargo build --release", readme) - self.assertIn( - "`cargo` now resolves `R2X_PYTHON_VERSION` through `uv python find` automatically.", - readme, - ) - - def test_release_workflow_uses_single_python_version_env(self): - workflow = (REPO_ROOT / ".github" / "workflows" / "release.yml").read_text() - - self.assertIn('R2X_PYTHON_VERSION: "3.12"', workflow) - self.assertIn("python-version: ${{ env.R2X_PYTHON_VERSION }}", workflow) - self.assertNotIn('python-version: "3.12"', workflow) - - def test_build_setup_reference_uses_python_version_env(self): - build_setup = (REPO_ROOT / ".github" / "build-setup.yml").read_text() - - self.assertIn("python-version: ${{ env.R2X_PYTHON_VERSION }}", build_setup) - self.assertNotIn('python-version: "3.12"', build_setup) - - def test_setup_action_preserves_requested_version_and_exports_abi(self): - action = (REPO_ROOT / ".github" / "actions" / "setup-uv-python" / "action.yml").read_text() - setup_script = (REPO_ROOT / "scripts" / "ci_setup_uv_python.sh").read_text() - macos_script = (REPO_ROOT / "scripts" / "ci_fix_libpython_install_name.sh").read_text() - - self.assertIn('bash scripts/ci_setup_uv_python.sh "${{ inputs.python-version }}"', action) - self.assertIn("python-abi-version", action) - self.assertIn('source "${script_dir}/python_version.sh"', setup_script) - self.assertIn('r2x_python_abi_version "python-version" "${requested_version}"', setup_script) - self.assertIn("Requested ABI", setup_script) - self.assertIn("Resolved query", setup_script) - self.assertIn('if [[ "${python_abi}" != "${requested_abi}" ]]; then', setup_script) - self.assertIn("python-version requested ABI", setup_script) - self.assertNotIn('REQUESTED_PYTHON_VERSION" =~', action) - self.assertIn('append_env "R2X_PYTHON_VERSION" "${requested_version}"', setup_script) - self.assertNotIn("R2X_PYTHON_VERSION=$PYTHON_ABI_VERSION", action + setup_script) - self.assertIn('uv python install "${requested_version}"', setup_script) - self.assertIn('uv python install "${requested_abi}"', setup_script) - self.assertIn('install_hint="$(r2x_python_install_hint "${requested_version}")"', setup_script) - self.assertIn('find_hint="$(r2x_python_find_hint "${requested_version}")"', setup_script) - self.assertIn("unable to install requested Python version", setup_script) - self.assertIn('local resolved_query="${requested_version}"', setup_script) - self.assertIn('uv python find "${resolved_query}"', setup_script) - self.assertIn('resolved_query="${requested_abi}"', setup_script) - self.assertIn("unable to resolve Python for requested version", setup_script) - self.assertIn("Verify with: ${find_hint}", setup_script) - self.assertIn("libpython${python_abi_version}.dylib", macos_script) - self.assertNotIn("libpython${{ inputs.python-version }}.dylib", action) - - def test_ci_workflows_emit_pyo3_diagnostics(self): - build = (REPO_ROOT / ".github" / "workflows" / "build.yml").read_text() - release = (REPO_ROOT / ".github" / "workflows" / "release.yml").read_text() - build_setup = (REPO_ROOT / ".github" / "build-setup.yml").read_text() - diagnostics = (REPO_ROOT / "scripts" / "ci_pyo3_diagnostics.sh").read_text() - format_benchmark = (REPO_ROOT / "scripts" / "ci_format_benchmark_summary.sh").read_text() - compare_benchmark = (REPO_ROOT / "scripts" / "ci_compare_benchmark_baseline.sh").read_text() - download_baseline = (REPO_ROOT / "scripts" / "ci_download_benchmark_baseline.sh").read_text() - - self.assertGreaterEqual(build.count("bash scripts/ci_pyo3_diagnostics.sh"), 2) - self.assertIn("bash scripts/ci_pyo3_diagnostics.sh", release) - self.assertIn("CARGO_BUILD_TARGET: ${{ join(matrix.targets, ' ') }}", release) - self.assertIn("bash scripts/ci_pyo3_diagnostics.sh", build_setup) - self.assertIn("PYO3_PYTHON", diagnostics) - self.assertIn("PYO3_CONFIG_FILE", diagnostics) - self.assertIn("PYO3_CROSS_LIB_DIR", diagnostics) - self.assertIn("PYO3_CROSS_PYTHON_VERSION", diagnostics) - self.assertIn("GITHUB_STEP_SUMMARY", diagnostics) - self.assertIn("cargo target", diagnostics) - self.assertNotIn("| target |", diagnostics) - self.assertIn("Benchmark fixture (parser repeat)", build) - self.assertIn("R2X_BENCHMARK_SUMMARY_PATH", build) - self.assertIn("test_run_plugin_benchmark_repeat_outputs_summary", build) - self.assertIn("scripts.tests.test_format_benchmark_summary", build) - self.assertIn("scripts.tests.test_compare_benchmark_summary", build) - self.assertIn("Format benchmark summary table", build) - self.assertIn("bash scripts/ci_format_benchmark_summary.sh", build) - self.assertIn("scripts/format_benchmark_summary.py", format_benchmark) - self.assertIn("Download baseline benchmark artifact", build) - self.assertIn("Compare benchmark against baseline", build) - self.assertIn("bash scripts/ci_compare_benchmark_baseline.sh", build) - self.assertIn("scripts/compare_benchmark_summary.py", compare_benchmark) - self.assertIn("BASELINE_BENCHMARK_PATH", build) - self.assertIn("BASELINE_BENCHMARK_RUN_ID", build) - self.assertIn("BASELINE_BENCHMARK_RUN_URL", build) - self.assertIn("No baseline artifact found from recent successful", download_baseline) - self.assertIn("workflow_runs[]", download_baseline) - self.assertIn("--baseline-run-id", compare_benchmark) - self.assertIn("--baseline-run-url", compare_benchmark) - self.assertIn("R2X_BENCHMARK_REGRESSION_PCT", compare_benchmark) - self.assertIn("--fail-on-regression-pct", compare_benchmark) - self.assertIn("--print-status-line", compare_benchmark) - self.assertIn("--write-github-output", compare_benchmark) - self.assertIn("r2x-plugin-benchmark-summary", build) - self.assertIn("r2x-plugin-benchmark.md", build) - self.assertIn("r2x-plugin-benchmark-delta.md", build) - self.assertIn("actions/upload-artifact@v7", build) - self.assertIn("r2x-plugin-benchmark-summary", build) - - def test_diagnostics_accepts_supported_pyo3_python(self): - with tempfile.TemporaryDirectory() as tmp: - python = Path(tmp) / "python" - summary = Path(tmp) / "summary.md" - write_executable( - python, - """#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "Python 3.13.1" - exit 0 -fi -if [ "$1" = "-c" ]; then - case "$2" in - *"sys.executable"*) echo "$0" ;; - *"sys.prefix"*) echo "/fake/python" ;; - *"version_info.major"*) echo "3.13" ;; - *"LIBDIR"*) echo "/fake/python/lib" ;; - esac - exit 0 -fi -exit 1 -""", - ) - - env = os.environ.copy() - env["PYO3_PYTHON"] = str(python) - env["PYO3_CROSS"] = "1" - env["PYO3_CROSS_LIB_DIR"] = "/fake/cross/lib" - env["PYO3_CROSS_PYTHON_VERSION"] = "3.13" - env["R2X_PYTHON_VERSION"] = "3.13.1" - env["GITHUB_STEP_SUMMARY"] = str(summary) - result = subprocess.run( - ["bash", str(DIAGNOSTICS_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - summary_text = summary.read_text() - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("PyO3 Python:", result.stdout) - self.assertIn("| status | `ok` |", summary_text) - self.assertIn("| PYO3_CROSS | `1` |", summary_text) - self.assertIn("| PYO3_CROSS_LIB_DIR | `/fake/cross/lib` |", summary_text) - self.assertIn("| PYO3_CROSS_PYTHON_VERSION | `3.13` |", summary_text) - self.assertIn("| requested Python ABI | `3.13` |", summary_text) - self.assertIn("| Python ABI | `3.13` |", summary_text) - - def test_diagnostics_rejects_unsupported_pyo3_python(self): - with tempfile.TemporaryDirectory() as tmp: - python = Path(tmp) / "python" - summary = Path(tmp) / "summary.md" - write_executable( - python, - """#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "Python 3.10.13" - exit 0 -fi -if [ "$1" = "-c" ]; then - case "$2" in - *"version_info.major"*) echo "3.10" ;; - *) echo "" ;; - esac - exit 0 -fi -exit 1 -""", - ) - - env = os.environ.copy() - env["PYO3_PYTHON"] = str(python) - env["GITHUB_STEP_SUMMARY"] = str(summary) - result = subprocess.run( - ["bash", str(DIAGNOSTICS_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - summary_text = summary.read_text() - - self.assertNotEqual(result.returncode, 0) - self.assertIn("requires Python 3.11 or newer", result.stdout) - self.assertIn( - "PyO3 remediation: Install and use a supported interpreter (Python 3.11 or newer) for PYO3_PYTHON", - result.stdout, - ) - self.assertIn("| status | `error` |", summary_text) - self.assertIn("requires Python 3.11 or newer", summary_text) - self.assertIn("| remediation | `Install and use a supported interpreter", summary_text) - - def test_diagnostics_rejects_unset_pyo3_python(self): - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - result = subprocess.run( - ["bash", str(DIAGNOSTICS_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("PYO3_PYTHON is unset", result.stdout) - self.assertIn( - "PyO3 remediation: Set PYO3_PYTHON to a valid interpreter path", - result.stdout, - ) - - def test_diagnostics_unset_pyo3_python_with_patch_request_suggests_abi_fallback(self): - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - env["R2X_PYTHON_VERSION"] = "3.13.1" - result = subprocess.run( - ["bash", str(DIAGNOSTICS_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "PyO3 remediation: Set PYO3_PYTHON to a valid interpreter path (example: uv python find 3.13.1 || uv python find 3.13)", - result.stdout, - ) - - def test_diagnostics_non_executable_pyo3_python_with_patch_request_suggests_abi_fallback(self): - with tempfile.TemporaryDirectory() as tmp: - python = Path(tmp) / "python" - python.write_text("#!/usr/bin/env bash\nexit 0\n") - python.chmod(stat.S_IRUSR | stat.S_IWUSR) - - env = os.environ.copy() - env["PYO3_PYTHON"] = str(python) - env["R2X_PYTHON_VERSION"] = "3.13.1" - result = subprocess.run( - ["bash", str(DIAGNOSTICS_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("PYO3_PYTHON is not executable", result.stdout) - self.assertIn( - "PyO3 remediation: Install Python with uv python install 3.13.1 || uv python install 3.13, then reset PYO3_PYTHON (example: uv python find 3.13.1 || uv python find 3.13)", - result.stdout, - ) - - def test_diagnostics_rejects_requested_and_selected_python_mismatch(self): - with tempfile.TemporaryDirectory() as tmp: - python = Path(tmp) / "python" - summary = Path(tmp) / "summary.md" - write_executable( - python, - """#!/usr/bin/env bash -if [ "$1" = "--version" ]; then - echo "Python 3.12.10" - exit 0 -fi -if [ "$1" = "-c" ]; then - case "$2" in - *"sys.executable"*) echo "$0" ;; - *"sys.prefix"*) echo "/fake/python" ;; - *"version_info.major"*) echo "3.12" ;; - *"LIBDIR"*) echo "/fake/python/lib" ;; - esac - exit 0 -fi -exit 1 -""", - ) - - env = os.environ.copy() - env["PYO3_PYTHON"] = str(python) - env["R2X_PYTHON_VERSION"] = "3.13.1" - env["GITHUB_STEP_SUMMARY"] = str(summary) - result = subprocess.run( - ["bash", str(DIAGNOSTICS_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - summary_text = summary.read_text() - - self.assertNotEqual(result.returncode, 0) - self.assertIn("requests Python ABI 3.13", result.stdout) - self.assertIn("PYO3_PYTHON reports 3.12", result.stdout) - self.assertIn( - "PyO3 remediation: Align them by setting PYO3_PYTHON to uv python find 3.13.1 || uv python find 3.13", - result.stdout, - ) - self.assertIn("| requested Python ABI | `3.13` |", summary_text) - self.assertIn("| status | `error` |", summary_text) - self.assertIn( - "| remediation | `Align them by setting PYO3_PYTHON to uv python find 3.13.1 || uv python find 3.13` |", - summary_text, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/tests/test_detect_uv_python.py b/scripts/tests/test_detect_uv_python.py deleted file mode 100644 index 03ff78e..0000000 --- a/scripts/tests/test_detect_uv_python.py +++ /dev/null @@ -1,146 +0,0 @@ -import json -import unittest -from io import StringIO -from pathlib import Path -from unittest.mock import call, patch - -from detect_uv_python import choose_uv_prefix, main, python_abi_version, validate_python_version - - -class ChooseUvPrefixTests(unittest.TestCase): - def test_prefers_uv_cache_paths(self): - entries = [ - {"path": "/usr/local/bin/python3.12"}, - { - "path": "/Users/test/.local/share/uv/python/cpython-3.12.5-macos-aarch64-none/bin/python3.12", - }, - ] - prefix = choose_uv_prefix(entries) - self.assertEqual( - prefix, - Path("/Users/test/.local/share/uv/python/cpython-3.12.5-macos-aarch64-none"), - ) - - def test_fallback_to_first_entry(self): - entries = [{"path": "/opt/homebrew/bin/python3.12"}] - prefix = choose_uv_prefix(entries) - self.assertEqual(prefix, Path("/opt/homebrew")) - - def test_main_prefers_r2x_python_version_env(self): - entries = [{"path": "/uv/python/cpython-3.13.1-linux-x86_64-gnu/bin/python3.13"}] - with ( - patch.dict("os.environ", {"R2X_PYTHON_VERSION": "3.13", "PY_VERSION": "3.12"}), - patch("detect_uv_python.load_uv_python_list", return_value=entries) as load, - patch("sys.stdout", new_callable=StringIO), - ): - result = main([]) - - self.assertEqual(result, 0) - load.assert_called_once_with("3.13") - - def test_validate_python_version_accepts_patch_version(self): - self.assertEqual(validate_python_version(" 3.13.1 "), "3.13.1") - - def test_python_abi_version_strips_patch_component(self): - self.assertEqual(python_abi_version("3.13.1"), "3.13") - - def test_main_falls_back_to_requested_abi_when_patch_has_no_entries(self): - patch_entries = [] - abi_entries = [{"path": "/uv/python/cpython-3.13-linux-x86_64-gnu/bin/python3.13"}] - with ( - patch.dict("os.environ", {"R2X_PYTHON_VERSION": "3.13.1"}), - patch("detect_uv_python.load_uv_python_list", side_effect=[patch_entries, abi_entries]) as load, - patch("sys.stdout", new_callable=StringIO), - ): - result = main([]) - - self.assertEqual(result, 0) - self.assertEqual( - load.call_args_list, - [call("3.13.1"), call("3.13")], - ) - - def test_main_falls_back_to_requested_abi_when_patch_query_errors(self): - abi_entries = [{"path": "/uv/python/cpython-3.13-linux-x86_64-gnu/bin/python3.13"}] - with ( - patch.dict("os.environ", {"R2X_PYTHON_VERSION": "3.13.1"}), - patch( - "detect_uv_python.load_uv_python_list", - side_effect=[RuntimeError("patch not found"), abi_entries], - ) as load, - patch("sys.stdout", new_callable=StringIO), - ): - result = main([]) - - self.assertEqual(result, 0) - self.assertEqual( - load.call_args_list, - [call("3.13.1"), call("3.13")], - ) - - def test_main_falls_back_to_requested_abi_when_patch_query_has_malformed_json(self): - abi_entries = [{"path": "/uv/python/cpython-3.13-linux-x86_64-gnu/bin/python3.13"}] - malformed = json.JSONDecodeError("Expecting value", "not json", 0) - with ( - patch.dict("os.environ", {"R2X_PYTHON_VERSION": "3.13.1"}), - patch( - "detect_uv_python.load_uv_python_list", - side_effect=[malformed, abi_entries], - ) as load, - patch("sys.stdout", new_callable=StringIO), - ): - result = main([]) - - self.assertEqual(result, 0) - self.assertEqual( - load.call_args_list, - [call("3.13.1"), call("3.13")], - ) - - def test_main_falls_back_to_requested_abi_when_patch_query_payload_is_unexpected(self): - abi_entries = [{"path": "/uv/python/cpython-3.13-linux-x86_64-gnu/bin/python3.13"}] - with ( - patch.dict("os.environ", {"R2X_PYTHON_VERSION": "3.13.1"}), - patch( - "detect_uv_python.load_uv_python_list", - side_effect=[RuntimeError("uv python list returned unexpected JSON payload"), abi_entries], - ) as load, - patch("sys.stdout", new_callable=StringIO), - ): - result = main([]) - - self.assertEqual(result, 0) - self.assertEqual( - load.call_args_list, - [call("3.13.1"), call("3.13")], - ) - - def test_main_reports_error_when_patch_and_abi_queries_fail(self): - with ( - patch.dict("os.environ", {"R2X_PYTHON_VERSION": "3.13.1"}), - patch( - "detect_uv_python.load_uv_python_list", - side_effect=[RuntimeError("patch not found"), RuntimeError("abi not found")], - ), - patch("sys.stderr", new_callable=StringIO) as stderr, - ): - result = main([]) - - self.assertEqual(result, 1) - self.assertIn("fallback ABI 3.13", stderr.getvalue()) - - def test_main_rejects_unsupported_python_version_before_uv_lookup(self): - with ( - patch.dict("os.environ", {"R2X_PYTHON_VERSION": "3.10"}), - patch("detect_uv_python.load_uv_python_list") as load, - patch("sys.stderr", new_callable=StringIO) as stderr, - ): - result = main([]) - - self.assertEqual(result, 1) - self.assertIn("requires Python 3.11 or newer", stderr.getvalue()) - load.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/tests/test_fix_python_dylib.py b/scripts/tests/test_fix_python_dylib.py deleted file mode 100644 index 7251a04..0000000 --- a/scripts/tests/test_fix_python_dylib.py +++ /dev/null @@ -1,235 +0,0 @@ -import os -import stat -import subprocess -import tempfile -import unittest -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[2] -FIX_SCRIPT = REPO_ROOT / "scripts" / "fix_python_dylib.sh" - - -def run_bash(command: str) -> str: - result = subprocess.run( - ["bash", "-c", command], - check=True, - cwd=REPO_ROOT, - stdout=subprocess.PIPE, - text=True, - ) - return result.stdout.strip() - - -def write_executable(path: Path, content: str) -> None: - path.write_text(content) - path.chmod(path.stat().st_mode | stat.S_IXUSR) - - -class FixPythonDylibTests(unittest.TestCase): - def test_python_abi_version_strips_patch_component(self): - output = run_bash(f'source "{FIX_SCRIPT}"; python_abi_version 3.13.1') - - self.assertEqual(output, "3.13") - - def test_resolve_python_version_normalizes_configured_version(self): - output = run_bash( - f'source "{FIX_SCRIPT}"; R2X_PYTHON_VERSION=3.13.1 resolve_python_version' - ) - - self.assertEqual(output, "3.13") - - def test_resolve_python_version_rejects_unsupported_configured_version(self): - result = subprocess.run( - [ - "bash", - "-c", - f'source "{FIX_SCRIPT}"; R2X_PYTHON_VERSION=3.10 resolve_python_version', - ], - cwd=REPO_ROOT, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("requires Python 3.11 or newer", result.stderr) - - def test_resolve_python_version_rejects_pyo3_mismatch_with_configured_version(self): - with tempfile.TemporaryDirectory() as tmp: - python = Path(tmp) / "python" - write_executable( - python, - """#!/usr/bin/env bash -if [ "$1" = "-c" ]; then - echo 3.12 - exit 0 -fi -exit 0 -""", - ) - - env = os.environ.copy() - env["PYO3_PYTHON"] = str(python) - env["R2X_PYTHON_VERSION"] = "3.13.1" - result = subprocess.run( - ["bash", "-c", f'source "{FIX_SCRIPT}"; resolve_python_version'], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "PYO3_PYTHON resolves to Python 3.12 but R2X_PYTHON_VERSION requests 3.13", - result.stderr, - ) - - def test_resolve_uv_python_tag_does_not_fallback_when_version_configured(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir() - uv = bin_dir / "uv" - write_executable( - uv, - """#!/usr/bin/env bash -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.12" ]; then - echo /uv/python/cpython-3.12-linux-x86_64-gnu/bin/python3.12 - exit 0 -fi -exit 1 -""", - ) - - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - env["R2X_PYTHON_VERSION"] = "3.13" - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - ["bash", "-c", f'source "{FIX_SCRIPT}"; resolve_uv_python_tag'], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertEqual(result.stdout.strip(), "") - - def test_resolve_uv_python_tag_falls_back_to_configured_patch_abi(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir() - uv = bin_dir / "uv" - write_executable( - uv, - """#!/usr/bin/env bash -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.13.1" ]; then - exit 1 -fi -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.13" ]; then - echo /uv/python/cpython-3.13-linux-x86_64-gnu/bin/python3.13 - exit 0 -fi -exit 1 -""", - ) - - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - env["R2X_PYTHON_VERSION"] = "3.13.1" - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - ["bash", "-c", f'source "{FIX_SCRIPT}"; resolve_uv_python_tag'], - check=True, - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - text=True, - ) - - self.assertEqual(result.stdout.strip(), "cpython-3.13-linux-x86_64-gnu") - - def test_resolve_uv_python_tag_rejects_unsupported_configured_version_before_uv_lookup(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir() - uv = bin_dir / "uv" - uv_marker = Path(tmp) / "uv-was-called" - write_executable( - uv, - f"""#!/usr/bin/env bash -touch "{uv_marker}" -exit 1 -""", - ) - - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - env["R2X_PYTHON_VERSION"] = "3.10" - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - ["bash", "-c", f'source "{FIX_SCRIPT}"; resolve_uv_python_tag'], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - uv_was_called = uv_marker.exists() - - self.assertNotEqual(result.returncode, 0) - self.assertIn("requires Python 3.11 or newer", result.stderr) - self.assertFalse(uv_was_called, "uv should not run for unsupported versions") - - def test_resolve_uv_python_tag_rejects_pyo3_mismatch_with_configured_version(self): - with tempfile.TemporaryDirectory() as tmp: - python_root = Path(tmp) / "cpython-3.12-linux-x86_64-gnu" - bin_dir = python_root / "bin" - bin_dir.mkdir(parents=True) - python = bin_dir / "python3.12" - write_executable( - python, - """#!/usr/bin/env bash -if [ "$1" = "-c" ]; then - echo 3.12 - exit 0 -fi -exit 0 -""", - ) - - env = os.environ.copy() - env["PYO3_PYTHON"] = str(python) - env["R2X_PYTHON_VERSION"] = "3.13" - result = subprocess.run( - ["bash", "-c", f'source "{FIX_SCRIPT}"; resolve_uv_python_tag'], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertEqual(result.stdout.strip(), "") - self.assertIn("PYO3_PYTHON resolves to Python 3.12", result.stderr) - - def test_main_reports_usage_without_binary_argument(self): - result = subprocess.run( - [str(FIX_SCRIPT)], - cwd=REPO_ROOT, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("Usage:", result.stdout) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/tests/test_patch_dist_installer.py b/scripts/tests/test_patch_dist_installer.py deleted file mode 100644 index 41c81c7..0000000 --- a/scripts/tests/test_patch_dist_installer.py +++ /dev/null @@ -1,103 +0,0 @@ -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[2] -PATCH_SCRIPT = REPO_ROOT / "scripts" / "patch_dist_installer.sh" - - -class PatchDistInstallerTests(unittest.TestCase): - def test_patches_installer_with_configured_python_version(self): - with tempfile.TemporaryDirectory() as tmp: - installer = Path(tmp) / "installer.sh" - installer.write_text( - "\n".join( - [ - "#!/usr/bin/env bash", - "check_for_shadowed_bins() {", - " :", - "}", - "install() {", - ' say "everything installed!"', - "}", - "", - ] - ) - ) - - env = os.environ.copy() - env["R2X_PYTHON_VERSION"] = "3.13.1" - subprocess.run( - [str(PATCH_SCRIPT), str(installer)], - check=True, - cwd=REPO_ROOT, - env=env, - ) - - patched = installer.read_text() - self.assertIn('local _python_request_version="3.13.1"', patched) - self.assertIn('local _python_abi_version="3.13"', patched) - self.assertIn('local _primary_lib="libpython${_python_abi_version}.so.1.0"', patched) - self.assertIn('local _primary_lib="libpython${_python_abi_version}.dylib"', patched) - self.assertIn('uv python install "$_python_request_version"', patched) - self.assertIn('uv python find "$_python_request_version"', patched) - self.assertIn( - 'if [ -z "$_python_bin" ] && [ "$_python_abi_version" != "$_python_request_version" ]; then', - patched, - ) - self.assertIn('uv python find "$_python_abi_version"', patched) - self.assertIn( - 'say "Install uv and run: uv python install 3.13.1 || uv python install 3.13"', - patched, - ) - self.assertIn( - 'say "Run: uv python install 3.13.1 || uv python install 3.13"', - patched, - ) - self.assertIn( - 'say "Verify with: uv python find 3.13.1 || uv python find 3.13"', - patched, - ) - self.assertNotIn("libpython3.12.so", patched) - self.assertNotIn("libpython3.13.1", patched) - - def test_rejects_unsupported_python_version_without_patching_installer(self): - with tempfile.TemporaryDirectory() as tmp: - installer = Path(tmp) / "installer.sh" - original = "\n".join( - [ - "#!/usr/bin/env bash", - "check_for_shadowed_bins() {", - " :", - "}", - "install() {", - ' say "everything installed!"', - "}", - "", - ] - ) - installer.write_text(original) - - env = os.environ.copy() - env["R2X_PYTHON_VERSION"] = "3.10" - result = subprocess.run( - [str(PATCH_SCRIPT), str(installer)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - patched = installer.read_text() - - self.assertNotEqual(result.returncode, 0) - self.assertIn("requires Python 3.11 or newer", result.stderr) - self.assertEqual(patched, original) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/tests/test_python_version_sh.py b/scripts/tests/test_python_version_sh.py deleted file mode 100644 index e588650..0000000 --- a/scripts/tests/test_python_version_sh.py +++ /dev/null @@ -1,182 +0,0 @@ -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[2] -VERSION_SCRIPT = REPO_ROOT / "scripts" / "python_version.sh" - - -def run_bash(command: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", "-c", command], - cwd=REPO_ROOT, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - -class PythonVersionShellTests(unittest.TestCase): - def test_python_abi_version_accepts_patch_version(self): - result = run_bash( - f'source "{VERSION_SCRIPT}"; r2x_python_abi_version R2X_PYTHON_VERSION 3.13.1' - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "3.13") - - def test_python_abi_version_rejects_unsupported_version(self): - result = run_bash( - f'source "{VERSION_SCRIPT}"; r2x_python_abi_version R2X_PYTHON_VERSION 3.10' - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("requires Python 3.11 or newer", result.stderr) - - def test_validate_python_version_rejects_malformed_version(self): - result = run_bash( - f'source "{VERSION_SCRIPT}"; r2x_validate_python_version R2X_PYTHON_VERSION 3.13-dev' - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("must be a Python version like 3.12 or 3.12.1", result.stderr) - - def test_python_install_hint_for_patch_version_includes_abi_fallback(self): - result = run_bash( - f'source "{VERSION_SCRIPT}"; r2x_python_install_hint 3.13.1' - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - result.stdout.strip(), - "uv python install 3.13.1 || uv python install 3.13", - ) - - def test_python_find_hint_for_patch_version_includes_abi_fallback(self): - result = run_bash( - f'source "{VERSION_SCRIPT}"; r2x_python_find_hint 3.13.1' - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - result.stdout.strip(), - "uv python find 3.13.1 || uv python find 3.13", - ) - - def test_python_hints_for_major_minor_version_use_single_command(self): - install = run_bash( - f'source "{VERSION_SCRIPT}"; r2x_python_install_hint 3.13' - ) - find = run_bash( - f'source "{VERSION_SCRIPT}"; r2x_python_find_hint 3.13' - ) - - self.assertEqual(install.returncode, 0, install.stderr) - self.assertEqual(find.returncode, 0, find.stderr) - self.assertEqual(install.stdout.strip(), "uv python install 3.13") - self.assertEqual(find.stdout.strip(), "uv python find 3.13") - - def test_find_uv_python_falls_back_to_patch_abi(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir(parents=True, exist_ok=True) - uv = bin_dir / "uv" - uv.write_text( - """#!/usr/bin/env bash -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.13.1" ]; then - exit 1 -fi -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.13" ]; then - echo /uv/python/3.13/bin/python3.13 - exit 0 -fi -exit 1 -""" - ) - uv.chmod(0o755) - - env = dict(os.environ) - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - [ - "bash", - "-c", - f'source "{VERSION_SCRIPT}"; r2x_find_uv_python 3.13.1', - ], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout.strip(), "/uv/python/3.13/bin/python3.13") - - def test_find_uv_python_returns_nonzero_when_no_match(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir(parents=True, exist_ok=True) - uv = bin_dir / "uv" - uv.write_text("#!/usr/bin/env bash\nexit 1\n") - uv.chmod(0o755) - - env = dict(os.environ) - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - [ - "bash", - "-c", - f'source "{VERSION_SCRIPT}"; r2x_find_uv_python 3.13.1', - ], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - - def test_find_uv_python_does_not_fallback_for_major_minor_request(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir(parents=True, exist_ok=True) - uv = bin_dir / "uv" - uv.write_text( - """#!/usr/bin/env bash -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.13" ]; then - exit 1 -fi -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.12" ]; then - echo /uv/python/3.12/bin/python3.12 - exit 0 -fi -exit 1 -""" - ) - uv.chmod(0o755) - - env = dict(os.environ) - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - [ - "bash", - "-c", - f'source "{VERSION_SCRIPT}"; r2x_find_uv_python 3.13', - ], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/tests/test_resolve_pyo3_python.py b/scripts/tests/test_resolve_pyo3_python.py deleted file mode 100644 index cbe4ade..0000000 --- a/scripts/tests/test_resolve_pyo3_python.py +++ /dev/null @@ -1,344 +0,0 @@ -import os -import stat -import subprocess -import tempfile -import unittest -from pathlib import Path - - -REPO_ROOT = Path(__file__).resolve().parents[2] -RESOLVE_SCRIPT = REPO_ROOT / "scripts" / "resolve_pyo3_python.sh" - - -def write_executable(path: Path, content: str) -> None: - path.write_text(content) - path.chmod(path.stat().st_mode | stat.S_IXUSR) - - -class ResolvePyo3PythonTests(unittest.TestCase): - def test_uses_explicit_pyo3_python_when_executable(self): - with tempfile.TemporaryDirectory() as tmp: - python = Path(tmp) / "python" - write_executable( - python, - """#!/usr/bin/env bash -if [ "$1" = "-c" ]; then - echo 3.13 - exit 0 -fi -exit 0 -""", - ) - - env = os.environ.copy() - env["PYO3_PYTHON"] = str(python) - result = subprocess.run( - [str(RESOLVE_SCRIPT)], - check=True, - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - text=True, - ) - - self.assertEqual(result.stdout.strip(), str(python)) - - def test_explicit_pyo3_python_rejects_unsupported_interpreter(self): - with tempfile.TemporaryDirectory() as tmp: - python = Path(tmp) / "python" - write_executable( - python, - """#!/usr/bin/env bash -if [ "$1" = "-c" ]; then - echo 3.10 - exit 0 -fi -exit 0 -""", - ) - - env = os.environ.copy() - env["PYO3_PYTHON"] = str(python) - result = subprocess.run( - [str(RESOLVE_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("requires Python 3.11 or newer", result.stderr) - - def test_explicit_pyo3_python_must_match_requested_python_abi(self): - with tempfile.TemporaryDirectory() as tmp: - python = Path(tmp) / "python" - write_executable( - python, - """#!/usr/bin/env bash -if [ "$1" = "-c" ]; then - echo 3.12 - exit 0 -fi -exit 0 -""", - ) - - env = os.environ.copy() - env["PYO3_PYTHON"] = str(python) - env["R2X_PYTHON_VERSION"] = "3.13.1" - result = subprocess.run( - [str(RESOLVE_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "PYO3_PYTHON resolves to Python 3.12 but R2X_PYTHON_VERSION requests 3.13", - result.stderr, - ) - - def test_explicit_pyo3_python_accepts_requested_patch_version_with_same_abi(self): - with tempfile.TemporaryDirectory() as tmp: - python = Path(tmp) / "python" - write_executable( - python, - """#!/usr/bin/env bash -if [ "$1" = "-c" ]; then - echo 3.13 - exit 0 -fi -exit 0 -""", - ) - - env = os.environ.copy() - env["PYO3_PYTHON"] = str(python) - env["R2X_PYTHON_VERSION"] = "3.13.1" - result = subprocess.run( - [str(RESOLVE_SCRIPT)], - check=True, - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - text=True, - ) - - self.assertEqual(result.stdout.strip(), str(python)) - - def test_requested_version_must_resolve_exactly(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir() - uv = bin_dir / "uv" - write_executable( - uv, - """#!/usr/bin/env bash -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.13" ]; then - echo /uv/python/3.13/bin/python3.13 - exit 0 -fi -exit 1 -""", - ) - - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - env["R2X_PYTHON_VERSION"] = "3.13" - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - [str(RESOLVE_SCRIPT)], - check=True, - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - text=True, - ) - - self.assertEqual(result.stdout.strip(), "/uv/python/3.13/bin/python3.13") - - def test_requested_version_does_not_fallback_to_other_versions(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir() - uv = bin_dir / "uv" - write_executable( - uv, - """#!/usr/bin/env bash -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.12" ]; then - echo /uv/python/3.12/bin/python3.12 - exit 0 -fi -exit 1 -""", - ) - - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - env["R2X_PYTHON_VERSION"] = "3.13" - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - [str(RESOLVE_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("uv python install 3.13", result.stderr) - self.assertIn("uv python find 3.13", result.stderr) - - def test_requested_patch_version_falls_back_to_requested_abi(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir() - uv = bin_dir / "uv" - write_executable( - uv, - """#!/usr/bin/env bash -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.13.1" ]; then - exit 1 -fi -if [ "$1" = "python" ] && [ "$2" = "find" ] && [ "$3" = "3.13" ]; then - echo /uv/python/3.13/bin/python3.13 - exit 0 -fi -exit 1 -""", - ) - - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - env["R2X_PYTHON_VERSION"] = "3.13.1" - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - [str(RESOLVE_SCRIPT)], - check=True, - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - text=True, - ) - - self.assertEqual(result.stdout.strip(), "/uv/python/3.13/bin/python3.13") - - def test_requested_patch_version_reports_fallback_hints_when_unavailable(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir() - uv = bin_dir / "uv" - write_executable( - uv, - """#!/usr/bin/env bash -exit 1 -""", - ) - - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - env["R2X_PYTHON_VERSION"] = "3.13.1" - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - [str(RESOLVE_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "Install it with: uv python install 3.13.1 || uv python install 3.13", - result.stderr, - ) - self.assertIn( - "Verify with: uv python find 3.13.1 || uv python find 3.13", - result.stderr, - ) - - def test_requested_version_rejects_unsupported_python_before_uv_lookup(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir() - uv = bin_dir / "uv" - uv_marker = Path(tmp) / "uv-was-called" - write_executable( - uv, - f"""#!/usr/bin/env bash -touch "{uv_marker}" -exit 1 -""", - ) - - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - env["R2X_PYTHON_VERSION"] = "3.10" - env["PATH"] = f"{bin_dir}:{env['PATH']}" - result = subprocess.run( - [str(RESOLVE_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - uv_was_called = uv_marker.exists() - - self.assertNotEqual(result.returncode, 0) - self.assertIn("requires Python 3.11 or newer", result.stderr) - self.assertFalse(uv_was_called, "uv should not run for unsupported versions") - - def test_default_patch_version_reports_fallback_hints_when_no_python_found(self): - with tempfile.TemporaryDirectory() as tmp: - bin_dir = Path(tmp) / "bin" - bin_dir.mkdir() - bash_path = subprocess.run( - ["which", "bash"], - check=True, - stdout=subprocess.PIPE, - text=True, - ).stdout.strip() - dirname_path = subprocess.run( - ["which", "dirname"], - check=True, - stdout=subprocess.PIPE, - text=True, - ).stdout.strip() - (bin_dir / "bash").symlink_to(bash_path) - (bin_dir / "dirname").symlink_to(dirname_path) - - env = os.environ.copy() - env.pop("PYO3_PYTHON", None) - env.pop("R2X_PYTHON_VERSION", None) - env["R2X_DEFAULT_PYTHON_VERSION"] = "3.13.1" - env["PATH"] = str(bin_dir) - result = subprocess.run( - [str(RESOLVE_SCRIPT)], - cwd=REPO_ROOT, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "Install uv and run: uv python install 3.13.1 || uv python install 3.13", - result.stderr, - ) - self.assertIn( - "Verify with: uv python find 3.13.1 || uv python find 3.13", - result.stderr, - ) - - -if __name__ == "__main__": - unittest.main()