From 24a026867512d4ff655bdae7e399e38f83c1812a Mon Sep 17 00:00:00 2001 From: Tim Felgentreff Date: Fri, 19 Jul 2024 02:28:28 +0200 Subject: [PATCH] Add GraalPy support (#5141) ## Summary Currently, `uv` refuses to install anything on GraalPy. This is currently blocking GraalPy testing with cibuildwheel, since manylinux includes both `uv` and `graalpy` (but doesn't test with `uv`), whereas cibuildwheel defaults to `uv`. See e.g. https://github.com/pypa/cibuildwheel/actions/runs/9956369360/job/27506182952?pr=1538 where it gives ``` + python -m build /project/sample_proj --wheel --outdir=/tmp/cibuildwheel/built_wheel --installer=uv * Creating isolated environment: venv+uv... * Using external uv from /usr/local/bin/uv * Installing packages in isolated environment: - setuptools >= 40.8.0 > /usr/local/bin/uv pip install "setuptools >= 40.8.0" < error: Unknown implementation: `graalpy` ``` ## Test Plan I simply based the GraalPy support on PyPy and added some small tests. I'm open to discussing how to test this. GraalPy is available for manylinux images and with setup-python, so we should be able to add tests against it to the CI. I locally confirmed by installing `uv` into a GraalPy venv and then trying things like `uv pip install Pillow` and testing those extensions. --- .github/workflows/ci.yml | 131 +++++++++++++++++++++ crates/platform-tags/src/tags.rs | 14 +++ crates/uv-python/src/discovery.rs | 48 ++++++++ crates/uv-python/src/implementation.rs | 6 +- crates/uv-python/src/lib.rs | 156 +++++++++++++++++++++++++ crates/uv-virtualenv/src/virtualenv.rs | 50 ++++++-- 6 files changed, 397 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80ef1b56e227..ee0943b17b4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -692,6 +692,137 @@ jobs: run: | .\uv.exe pip install anyio + integration-test-graalpy-linux: + needs: build-binary-linux + name: "integration test | graalpy on ubuntu" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "graalpy24.0" + + - name: "Download binary" + uses: actions/download-artifact@v4 + with: + name: uv-linux-${{ github.sha }} + + - name: "Prepare binary" + run: chmod +x ./uv + + - name: Graalpy info + run: | + which graalpy + echo "GRAAL_PYTHONHOME=$(graalpy -c 'print(__graalpython__.home)')" >> $GITHUB_ENV + + - name: "Create a virtual environment" + run: | + ./uv venv -p $(which graalpy) + + - name: "Check for executables" + run: | + check_in_bin() { + local executable_name=$1 + local bin_path=".venv/bin" + + if [[ -x "$bin_path/$executable_name" ]]; then + return 0 + else + echo "Executable '$executable_name' not found in folder '$bin_path'." + return 1 + fi + } + + executables=("graalpy" "python3" "python") + + all_found=true + for executable_name in "${executables[@]}"; do + check_in_bin "$executable_name" "$folder_path" + result=$? + + if [[ $result -ne 0 ]]; then + all_found=false + fi + done + + if ! $all_found; then + echo "One or more expected executables were not found." + exit 1 + fi + + - name: "Check version" + run: | + .venv/bin/graalpy --version + .venv/bin/python3 --version + .venv/bin/python --version + + - name: "Check install" + run: | + ./uv pip install anyio + + integration-test-graalpy-windows: + needs: build-binary-windows + name: "integration test | graalpy on windows" + runs-on: windows-latest + + steps: + - uses: timfel/setup-python@fc9bcb4a04f5b1ea7d678c2ca7ea1c479a2468d7 + with: + python-version: "graalpy24.0" + + - name: "Download binary" + uses: actions/download-artifact@v4 + with: + name: uv-windows-${{ github.sha }} + + - name: Graalpy info + run: Get-Command graalpy + + - name: "Create a virtual environment" + run: | + $graalpy = (Get-Command graalpy).source + .\uv.exe venv -p $graalpy + + - name: "Check for executables" + shell: python + run: | + import sys + from pathlib import Path + + def binary_exist(binary): + binaries_path = Path(".venv\\Scripts") + if (binaries_path / binary).exists(): + return True + print(f"Executable '{binary}' not found in folder '{binaries_path}'.") + + all_found = True + expected_binaries = [ + "graalpy.exe", + "python.exe", + "python3.exe", + ] + for binary in expected_binaries: + if not binary_exist(binary): + all_found = False + + if not all_found: + print("One or more expected executables were not found.") + sys.exit(1) + + - name: "Check version" + run: | + & .venv\Scripts\graalpy.exe --version + & .venv\Scripts\python3.exe --version + & .venv\Scripts\python.exe --version + + - name: "Check install" + env: + # Avoid debug build stack overflows. + UV_STACK_SIZE: 2000000 # 2 megabyte, double the default on windows + run: | + .\uv.exe pip install anyio + integration-test-github-actions: needs: build-binary-linux name: "integration test | github actions" diff --git a/crates/platform-tags/src/tags.rs b/crates/platform-tags/src/tags.rs index 09ef84e181d4..b65f610be668 100644 --- a/crates/platform-tags/src/tags.rs +++ b/crates/platform-tags/src/tags.rs @@ -298,6 +298,7 @@ impl std::fmt::Display for Tags { enum Implementation { CPython { gil_disabled: bool }, PyPy, + GraalPy, Pyston, } @@ -310,6 +311,8 @@ impl Implementation { Self::CPython { .. } => format!("cp{}{}", python_version.0, python_version.1), // Ex) `pp39` Self::PyPy => format!("pp{}{}", python_version.0, python_version.1), + // Ex) `graalpy310` + Self::GraalPy => format!("graalpy{}{}", python_version.0, python_version.1), // Ex) `pt38`` Self::Pyston => format!("pt{}{}", python_version.0, python_version.1), } @@ -342,6 +345,16 @@ impl Implementation { implementation_version.0, implementation_version.1 ), + // Ex) `graalpy310_graalpy240_310_native + Self::GraalPy => format!( + "graalpy{}{}_graalpy{}{}_{}{}_native", + python_version.0, + python_version.1, + implementation_version.0, + implementation_version.1, + python_version.0, + python_version.1 + ), // Ex) `pyston38-pyston_23` Self::Pyston => format!( "pyston{}{}-pyston_{}{}", @@ -361,6 +374,7 @@ impl Implementation { // Known and supported implementations. "cpython" => Ok(Self::CPython { gil_disabled }), "pypy" => Ok(Self::PyPy), + "graalpy" => Ok(Self::GraalPy), "pyston" => Ok(Self::Pyston), // Known but unsupported implementations. "python" => Err(TagsError::UnsupportedImplementation(name.to_string())), diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index d7d00a38639c..769966491f39 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -1710,6 +1710,14 @@ mod tests { PythonRequest::parse("pp"), PythonRequest::Implementation(ImplementationName::PyPy) ); + assert_eq!( + PythonRequest::parse("graalpy"), + PythonRequest::Implementation(ImplementationName::GraalPy) + ); + assert_eq!( + PythonRequest::parse("gp"), + PythonRequest::Implementation(ImplementationName::GraalPy) + ); assert_eq!( PythonRequest::parse("cp"), PythonRequest::Implementation(ImplementationName::CPython) @@ -1728,6 +1736,20 @@ mod tests { VersionRequest::from_str("3.10").unwrap() ) ); + assert_eq!( + PythonRequest::parse("graalpy3.10"), + PythonRequest::ImplementationVersion( + ImplementationName::GraalPy, + VersionRequest::from_str("3.10").unwrap() + ) + ); + assert_eq!( + PythonRequest::parse("gp310"), + PythonRequest::ImplementationVersion( + ImplementationName::GraalPy, + VersionRequest::from_str("3.10").unwrap() + ) + ); assert_eq!( PythonRequest::parse("cp38"), PythonRequest::ImplementationVersion( @@ -1749,6 +1771,20 @@ mod tests { VersionRequest::from_str("3.10").unwrap() ) ); + assert_eq!( + PythonRequest::parse("graalpy@3.10"), + PythonRequest::ImplementationVersion( + ImplementationName::GraalPy, + VersionRequest::from_str("3.10").unwrap() + ) + ); + assert_eq!( + PythonRequest::parse("graalpy310"), + PythonRequest::ImplementationVersion( + ImplementationName::GraalPy, + VersionRequest::from_str("3.10").unwrap() + ) + ); let tempdir = TempDir::new().unwrap(); assert_eq!( @@ -1819,6 +1855,18 @@ mod tests { .to_canonical_string(), "pypy@3.10" ); + assert_eq!( + PythonRequest::Implementation(ImplementationName::GraalPy).to_canonical_string(), + "graalpy" + ); + assert_eq!( + PythonRequest::ImplementationVersion( + ImplementationName::GraalPy, + VersionRequest::from_str("3.10").unwrap() + ) + .to_canonical_string(), + "graalpy@3.10" + ); let tempdir = TempDir::new().unwrap(); assert_eq!( diff --git a/crates/uv-python/src/implementation.rs b/crates/uv-python/src/implementation.rs index 869ddbc6a18c..3ce36fd15a76 100644 --- a/crates/uv-python/src/implementation.rs +++ b/crates/uv-python/src/implementation.rs @@ -15,6 +15,7 @@ pub enum ImplementationName { #[default] CPython, PyPy, + GraalPy, } #[derive(Debug, Eq, PartialEq, Clone, Ord, PartialOrd, Hash)] @@ -25,13 +26,14 @@ pub enum LenientImplementationName { impl ImplementationName { pub(crate) fn possible_names() -> impl Iterator { - ["cpython", "pypy", "cp", "pp"].into_iter() + ["cpython", "pypy", "graalpy", "cp", "pp", "gp"].into_iter() } pub fn pretty(self) -> &'static str { match self { Self::CPython => "CPython", Self::PyPy => "PyPy", + Self::GraalPy => "GraalPy", } } } @@ -50,6 +52,7 @@ impl From<&ImplementationName> for &'static str { match v { ImplementationName::CPython => "cpython", ImplementationName::PyPy => "pypy", + ImplementationName::GraalPy => "graalpy", } } } @@ -73,6 +76,7 @@ impl FromStr for ImplementationName { match s.to_ascii_lowercase().as_str() { "cpython" | "cp" => Ok(Self::CPython), "pypy" | "pp" => Ok(Self::PyPy), + "graalpy" | "gp" => Ok(Self::GraalPy), _ => Err(Error::UnknownImplementation(s.to_string())), } } diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index 44e8ec5d83cd..cc59132f695b 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -1896,4 +1896,160 @@ mod tests { Ok(()) } + + #[test] + fn find_python_graalpy() -> Result<()> { + let mut context = TestContext::new()?; + + context.add_python_interpreters(&[( + true, + ImplementationName::GraalPy, + "graalpy", + "3.10.0", + )])?; + let result = context.run(|| { + find_python_installation( + &PythonRequest::Any, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })?; + assert!( + matches!(result, Err(PythonNotFound { .. })), + "We should not the graalpy interpreter if not named `python` or requested; got {result:?}" + ); + + // But we should find it + context.reset_search_path(); + context.add_python_interpreters(&[( + true, + ImplementationName::GraalPy, + "python", + "3.10.1", + )])?; + let python = context.run(|| { + find_python_installation( + &PythonRequest::Any, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.10.1", + "We should find the graalpy interpreter if it's the only one" + ); + + let python = context.run(|| { + find_python_installation( + &PythonRequest::parse("graalpy"), + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.10.1", + "We should find the graalpy interpreter if it's requested" + ); + + Ok(()) + } + + #[test] + fn find_python_graalpy_request_ignores_cpython() -> Result<()> { + let mut context = TestContext::new()?; + context.add_python_interpreters(&[ + (true, ImplementationName::CPython, "python", "3.10.0"), + (true, ImplementationName::GraalPy, "graalpy", "3.10.1"), + ])?; + + let python = context.run(|| { + find_python_installation( + &PythonRequest::parse("graalpy"), + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.10.1", + "We should skip the CPython interpreter" + ); + + let python = context.run(|| { + find_python_installation( + &PythonRequest::Any, + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.10.0", + "We should take the first interpreter without a specific request" + ); + + Ok(()) + } + + #[test] + fn find_python_graalpy_prefers_executable_with_implementation_name() -> Result<()> { + let mut context = TestContext::new()?; + + // We should prefer `graalpy` executables over `python` executables in the same directory + // even if they are both graalpy + TestContext::create_mock_interpreter( + &context.tempdir.join("python"), + &PythonVersion::from_str("3.10.0").unwrap(), + ImplementationName::GraalPy, + true, + )?; + TestContext::create_mock_interpreter( + &context.tempdir.join("graalpy"), + &PythonVersion::from_str("3.10.1").unwrap(), + ImplementationName::GraalPy, + true, + )?; + context.add_to_search_path(context.tempdir.to_path_buf()); + + let python = context.run(|| { + find_python_installation( + &PythonRequest::parse("graalpy@3.10"), + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.10.1", + ); + + // But `python` executables earlier in the search path will take precedence + context.reset_search_path(); + context.add_python_interpreters(&[ + (true, ImplementationName::GraalPy, "python", "3.10.2"), + (true, ImplementationName::GraalPy, "graalpy", "3.10.3"), + ])?; + let python = context.run(|| { + find_python_installation( + &PythonRequest::parse("graalpy@3.10"), + EnvironmentPreference::Any, + PythonPreference::OnlySystem, + &context.cache, + ) + })??; + assert_eq!( + python.interpreter().python_full_version().to_string(), + "3.10.2", + ); + + Ok(()) + } } diff --git a/crates/uv-virtualenv/src/virtualenv.rs b/crates/uv-virtualenv/src/virtualenv.rs index ae4696dd85f8..a1203f902795 100644 --- a/crates/uv-virtualenv/src/virtualenv.rs +++ b/crates/uv-virtualenv/src/virtualenv.rs @@ -178,6 +178,10 @@ pub(crate) fn create( )?; uv_fs::replace_symlink("python", scripts.join("pypy"))?; } + + if interpreter.markers().implementation_name() == "graalpy" { + uv_fs::replace_symlink("python", scripts.join("graalpy"))?; + } } // No symlinking on Windows, at least not on a regular non-dev non-admin Windows install. @@ -189,13 +193,31 @@ pub(crate) fn create( &scripts, python_home, )?; - copy_launcher_windows( - WindowsExecutable::Pythonw, - interpreter, - &base_python, - &scripts, - python_home, - )?; + + if interpreter.markers().implementation_name() == "graalpy" { + copy_launcher_windows( + WindowsExecutable::GraalPy, + interpreter, + &base_python, + &scripts, + python_home, + )?; + copy_launcher_windows( + WindowsExecutable::PythonMajor, + interpreter, + &base_python, + &scripts, + python_home, + )?; + } else { + copy_launcher_windows( + WindowsExecutable::Pythonw, + interpreter, + &base_python, + &scripts, + python_home, + )?; + } if interpreter.markers().implementation_name() == "pypy" { copy_launcher_windows( @@ -319,6 +341,16 @@ pub(crate) fn create( pyvenv_cfg_data.push(("prompt".to_string(), prompt)); } + if cfg!(windows) && interpreter.markers().implementation_name() == "graalpy" { + pyvenv_cfg_data.push(( + "venvlauncher_command".to_string(), + python_home + .join("graalpy.exe") + .simplified_display() + .to_string(), + )); + } + let mut pyvenv_cfg = BufWriter::new(File::create(location.join("pyvenv.cfg"))?); write_cfg(&mut pyvenv_cfg, &pyvenv_cfg_data)?; drop(pyvenv_cfg); @@ -380,6 +412,8 @@ enum WindowsExecutable { PyPyw, /// The `pypy3.w.exe` executable. PyPyMajorMinorw, + // The `graalpy.exe` executable + GraalPy, } impl WindowsExecutable { @@ -417,6 +451,7 @@ impl WindowsExecutable { interpreter.python_minor() ) } + WindowsExecutable::GraalPy => String::from("graalpy.exe"), } } @@ -434,6 +469,7 @@ impl WindowsExecutable { WindowsExecutable::PyPyMajorMinor => "venvlauncher.exe", WindowsExecutable::PyPyw => "venvwlauncher.exe", WindowsExecutable::PyPyMajorMinorw => "venvwlauncher.exe", + WindowsExecutable::GraalPy => "venvlauncher.exe", } } }