diff --git a/cibuildwheel/platforms/android.py b/cibuildwheel/platforms/android.py index 229401e4..e2866840 100644 --- a/cibuildwheel/platforms/android.py +++ b/cibuildwheel/platforms/android.py @@ -7,7 +7,7 @@ import shlex import shutil import subprocess import sysconfig -from collections.abc import Iterable, Iterator +from collections.abc import Iterable, Iterator, MutableMapping from dataclasses import dataclass from os.path import relpath from pathlib import Path @@ -390,6 +390,9 @@ def setup_android_env( for key in ["CFLAGS", "CXXFLAGS"]: android_env[key] += " " + opt + # Cargo target linker needs to be specified after CC is set + setup_rust(config, python_dir, android_env) + # Format the environment so it can be pasted into a shell when debugging. for key, value in sorted(android_env.items()): if os.environ.get(key) != value: @@ -398,6 +401,34 @@ def setup_android_env( return android_env +def setup_rust( + config: PythonConfiguration, + python_dir: Path, + env: MutableMapping[str, str], +) -> None: + cargo_target = android_triplet(config.identifier) + + # CARGO_BUILD_TARGET is the variable used by Cargo and setuptools_rust + env["CARGO_BUILD_TARGET"] = cargo_target + + # The linker needs to be specified after CC is set by android-env.sh + cargo_target_linker_env_name = f"CARGO_TARGET_{cargo_target.upper().replace('-', '_')}_LINKER" + # CC has already been set by calling android.py (it calls android-env.sh) + env[cargo_target_linker_env_name] = env["CC"] + + # All Python extension modules must be explicitly linked against libpython3.x.so when building for Android. + # See: https://peps.python.org/pep-0738/#linkage + # For projects using PyO3, this requires setting PYO3_CROSS_LIB_DIR to the directory containing libpython3.x.so. + # See: https://pyo3.rs/v0.27.1/building-and-distribution.html#cross-compiling + env["PYO3_CROSS_LIB_DIR"] = str(python_dir / "prefix" / "lib") + + venv_bin = Path(env["VIRTUAL_ENV"]) / "bin" + for tool in ["cargo", "rustup"]: + shim_path = venv_bin / tool + shutil.copy(resources.PATH / "_rust_shim.py", shim_path) + shim_path.chmod(0o755) + + def before_build(state: BuildState) -> None: if state.options.before_build: log.step("Running before_build...") diff --git a/cibuildwheel/resources/_rust_shim.py b/cibuildwheel/resources/_rust_shim.py new file mode 100755 index 00000000..d88087ea --- /dev/null +++ b/cibuildwheel/resources/_rust_shim.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 + +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def main() -> None: + # CIBW_HOST_TRIPLET is set in the android_env to the Android target triplet. + target = os.environ.get("CIBW_HOST_TRIPLET") + + cmd_name = Path(sys.argv[0]).name + + # Find the real command in PATH, excluding the current script's directory + path_env = os.environ.get("PATH", "") + script_dir = Path(__file__).resolve().parent + + paths = path_env.split(os.pathsep) + # Filter out the script directory to avoid recursion + filtered_paths = [p for p in paths if Path(p).resolve() != script_dir] + filtered_path_env = os.pathsep.join(filtered_paths) + + real_cmd = shutil.which(cmd_name, path=filtered_path_env) + + if not real_cmd: + sys.stderr.write(f"cibuildwheel: Error: Could not find system {cmd_name}\n") + sys.exit(1) + + # If we have a target (i.e. we are in the android_env), try to install it. + if target: + # Check if rustup is available to install the target + rustup_path = shutil.which("rustup", path=filtered_path_env) + + if rustup_path: + try: + # We call rustup to ensure the target is installed. + subprocess.run( + [rustup_path, "target", "add", target], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + except subprocess.CalledProcessError as e: + sys.stderr.write( + f"cibuildwheel: Error: Failed to install Rust target {target}: {e.stderr}\n" + ) + sys.exit(1) + + # Execute the real command + os.execv(real_cmd, [real_cmd, *sys.argv[1:]]) + + +if __name__ == "__main__": + main() diff --git a/test/test_android.py b/test/test_android.py index 79934055..eb16e2b1 100644 --- a/test/test_android.py +++ b/test/test_android.py @@ -4,7 +4,7 @@ import re import sys from dataclasses import dataclass from pathlib import Path -from shutil import rmtree +from shutil import rmtree, which from subprocess import CalledProcessError from textwrap import dedent from zipfile import ZipFile @@ -496,3 +496,162 @@ def test_libcxx(tmp_path, capfd): assert len(wheels) == 1 for name in ZipFile(output_dir / wheels[0]).namelist(): assert ".libs" not in name + + +@needs_emulator +def test_setuptools_rust(tmp_path, capfd): + """ + Test Android cross-compilation using the setuptools-rust toolchain. + """ + if not which("rustup") or not which("cargo"): + pytest.skip("rustup and cargo are required for this test") + + # Initialize a basic project and override files for setuptools-rust + project = new_c_project() + project.files["pyproject.toml"] = dedent( + """\ + [build-system] + requires = ["setuptools", "wheel", "setuptools-rust"] + """ + ) + project.files["setup.py"] = dedent( + """\ + from setuptools import setup + from setuptools_rust import Binding, RustExtension + + setup( + name="spam", + version="0.1.0", + rust_extensions=[RustExtension("spam.rust_ext", binding=Binding.PyO3)], + zip_safe=False, + ) + """ + ) + project.files["Cargo.toml"] = dedent( + """\ + [package] + name = "rust_ext" + version = "0.1.0" + edition = "2021" + + [lib] + name = "rust_ext" + crate-type = ["cdylib"] + + [dependencies] + pyo3 = { version = "0.23.3", features = ["extension-module"] } + """ + ) + + # Create the Rust source directory and file + (tmp_path / "src").mkdir() + project.files["src/lib.rs"] = dedent( + """\ + use pyo3::prelude::*; + + #[pyfunction] + fn hello() -> PyResult { + Ok("Hello from Rust via setuptools-rust!".to_string()) + } + + #[pymodule] + fn rust_ext(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(hello, m)?)?; + Ok(()) + } + """ + ) + + project.generate(tmp_path) + + # Command to verify the built extension on the Android emulator + test_command = "python -c 'import spam.rust_ext; print(spam.rust_ext.hello())'" + + # Run cibuildwheel to build and test the wheel + wheels = cibuildwheel_run( + tmp_path, + add_env={ + **cp313_env, + "CIBW_TEST_COMMAND": test_command, + "CIBW_BUILD_VERBOSITY": "1", + }, + ) + + # Verification + assert len(wheels) == 1 + stdout = capfd.readouterr().out + assert "Hello from Rust via setuptools-rust!" in stdout + + +@needs_emulator +def test_maturin(tmp_path, capfd): + """ + Test Android cross-compilation using the maturin backend. + """ + if not which("rustup") or not which("cargo"): + pytest.skip("rustup and cargo are required for this test") + + project = new_c_project() + project.files["pyproject.toml"] = dedent( + """\ + [build-system] + # maturin >= 1.11.0 is required for reliable Android cross-compilation support. + requires = ["maturin>=1.11.0,<2.0"] + build-backend = "maturin" + + [project] + name = "spam" + version = "0.1.0" + """ + ) + project.files["Cargo.toml"] = dedent( + """\ + [package] + name = "spam" + version = "0.1.0" + edition = "2021" + + [lib] + name = "spam" + crate-type = ["cdylib"] + + [dependencies] + pyo3 = { version = "0.23.3", features = ["extension-module"] } + """ + ) + + (tmp_path / "src").mkdir() + project.files["src/lib.rs"] = dedent( + """\ + use pyo3::prelude::*; + + #[pyfunction] + fn hello() -> PyResult { + Ok("Hello from Rust via maturin!".to_string()) + } + + #[pymodule] + fn spam(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(hello, m)?)?; + Ok(()) + } + """ + ) + + project.generate(tmp_path) + (tmp_path / "setup.py").unlink() + + test_command = "python -c 'import spam; print(spam.hello())'" + + wheels = cibuildwheel_run( + tmp_path, + add_env={ + **cp313_env, + "CIBW_TEST_COMMAND": test_command, + "CIBW_BUILD_VERBOSITY": "1", + }, + ) + + assert len(wheels) == 1 + stdout = capfd.readouterr().out + assert "Hello from Rust via maturin!" in stdout