diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 746eda4..16cf79a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -97,3 +97,37 @@ jobs: with: name: dist-${{matrix.os}} path: dist + + pyodide: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Python + uses: actions-ext/python/setup@6e1b91a408ea89f49bc8aff8724f83826f7b5446 + with: + version: "3.14" + + - name: Setup Rust + uses: actions-ext/rust/setup@main + + - name: Install dependencies + run: | + make develop + uv pip install pyodide-build==0.39.0 + rustup target add wasm32-unknown-emscripten + + - name: Build Pyodide fixture + run: | + mkdir -p dist/pyodide + pyodide build hatch_rs/tests/test_project_pyodide --outdir dist/pyodide --no-isolation --skip-dependency-check + + - name: Test in Pyodide + run: | + pyodide venv .venv-pyodide + .venv-pyodide/bin/pip install dist/pyodide/*.whl + .venv-pyodide/bin/python -c "from pyodide_project.pyodide_project import answer; assert answer() == 42" diff --git a/.gitignore b/.gitignore index 6eb907b..233177a 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,7 @@ hatch_rs/labextension # Emscripten SDK (locally installed) emsdk +.pyodide_build # Mac .DS_Store diff --git a/README.md b/README.md index 4ac2df8..9b8a425 100644 --- a/README.md +++ b/README.md @@ -155,5 +155,26 @@ test-command = "python -c \"import project\"" When cross-building outside cibuildwheel, set `wheel-platform-tag` only if the final wheel platform tag is known, for example `manylinux_2_28_x86_64`. +### Pyodide + +Pyodide builds are detected from `CARGO_BUILD_TARGET=wasm32-unknown-emscripten`. +The hook collects Cargo's `.wasm` cdylib, gives it CPython's Emscripten extension +suffix, and uses `PYODIDE_ABI_VERSION` for the `pyemscripten` wheel platform tag. +No project-specific build hook is required. + +```toml +[tool.hatch.build.hooks.hatch-rs] +module = "project" +path = "." + +[tool.cibuildwheel.pyodide] +xbuild-tools = ["cargo", "rustc", "rustup"] +test-command = "python -c 'import project.project'" +``` + +Rust 1.95 or newer is required to emit an Emscripten shared library for a +`cdylib` automatically. `wheel-platform-tag` remains available as an explicit +override when building outside Pyodide's configured environment. + > [!NOTE] > This library was generated using [copier](https://copier.readthedocs.io/en/stable/) from the [Base Python Project Template repository](https://github.com/python-project-templates/base). diff --git a/hatch_rs/structs.py b/hatch_rs/structs.py index 6e2508a..023b92d 100644 --- a/hatch_rs/structs.py +++ b/hatch_rs/structs.py @@ -41,7 +41,7 @@ InstallScheme = Literal["package", "shared-data", "shared-scripts", "validate-only"] Language = Literal["c", "c++"] Binding = Literal["cpython", "pybind11", "nanobind", "generic"] -Platform = Literal["linux", "darwin", "win32"] +Platform = Literal["linux", "darwin", "win32", "emscripten"] @dataclass(frozen=True) @@ -149,6 +149,8 @@ def _normalize_platform(platform: str) -> str: return "darwin" if normalized.startswith(("linux", "manylinux", "musllinux")): return "linux" + if normalized.startswith(("emscripten", "pyemscripten")): + return "emscripten" return normalized @@ -181,6 +183,8 @@ def _explicit_target(target: str, *, platform: str, machine: str) -> ResolvedTar return ResolvedTarget(platform="darwin", machine=_target_machine(target), triple=target) if "-linux-" in target: return ResolvedTarget(platform="linux", machine=_target_machine(target), triple=target) + if target.endswith("-emscripten"): + return ResolvedTarget(platform="emscripten", machine=_target_machine(target), triple=target) return ResolvedTarget(platform=platform, machine=machine, triple=target) @@ -198,6 +202,8 @@ def shared_library_name(library: str, *, platform: str | None = None) -> str: return f"lib{library}.dylib" if platform == "linux": return f"lib{library}.so" + if platform == "emscripten": + return f"{library}.wasm" raise ValueError(f"Unsupported platform: {platform}") @@ -209,12 +215,21 @@ def executable_name(name: str, *, platform: str | None = None) -> str: return name -def python_extension_name(source_stem: str, *, abi3: bool = False, platform: str | None = None) -> str: +def python_extension_name( + source_stem: str, + *, + abi3: bool = False, + platform: str | None = None, + python_version: tuple[int, int] | None = None, +) -> str: """Render the Python extension filename for a Cargo cdylib artifact stem.""" platform = _normalize_platform(platform or environ.get("HATCH_RUST_PLATFORM", sys_platform)) module_name = source_stem.removeprefix("lib") if platform == "win32": return f"{module_name}.pyd" + if platform == "emscripten": + major, minor = python_version or (version_info.major, version_info.minor) + return f"{module_name}.cpython-{major}{minor}-wasm32-emscripten.so" if abi3: return f"{module_name}.abi3.so" return f"{module_name}.so" @@ -224,6 +239,7 @@ def _resolve_target(target: str | None = None, *, platform: str | None = None, m raw_platform = platform or environ.get("HATCH_RUST_PLATFORM", sys_platform) platform = _normalize_platform(raw_platform) machine = _normalize_machine(machine or environ.get("HATCH_RUST_MACHINE", platform_machine())) + target = target or environ.get("CARGO_BUILD_TARGET") if target: return _explicit_target(target, platform=platform, machine=machine) @@ -292,6 +308,13 @@ def _wheel_platform(resolved_target: ResolvedTarget, platform_tag: str | None) - raise _unsupported_machine("macOS wheel", resolved_target.machine, darwin_arches) from error if resolved_target.platform == "linux": return _linux_wheel_platform(resolved_target, platform_tag) + if resolved_target.platform == "emscripten": + if platform_tag: + return platform_tag + abi_version = environ.get("PYODIDE_ABI_VERSION") + if not abi_version: + raise ValueError("PYODIDE_ABI_VERSION or wheel-platform-tag is required for Emscripten wheel tags.") + return f"pyemscripten_{abi_version}_wasm32" raise ValueError(f"Unsupported platform for wheel tag: {resolved_target.platform}") @@ -308,7 +331,7 @@ def wheel_tag( """Render a wheel tag for the resolved Rust target using packaging.tags.""" resolved = resolved_target or _resolve_target(target, platform=platform, machine=machine) version = python_version or (version_info.major, version_info.minor) - abis = ["abi3"] if abi3 else None + abis = [f"cp{version[0]}{version[1]}"] if resolved.platform == "emscripten" else (["abi3"] if abi3 else None) return str(next(cpython_tags(python_version=version, abis=abis, platforms=[_wheel_platform(resolved, platform_tag)]))) @@ -319,6 +342,8 @@ def _artifact_patterns(platform: str) -> tuple[str, ...]: return ("*.so",) if platform == "darwin": return ("*.dylib",) + if platform == "emscripten": + return ("*.wasm",) raise ValueError(f"Unsupported platform machine: {platform_machine()}") diff --git a/hatch_rs/tests/test_project_pyodide/Cargo.lock b/hatch_rs/tests/test_project_pyodide/Cargo.lock new file mode 100644 index 0000000..d59f44b --- /dev/null +++ b/hatch_rs/tests/test_project_pyodide/Cargo.lock @@ -0,0 +1,132 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "hatch-rs-test-pyodide" +version = "0.1.0" +dependencies = [ + "pyo3", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/hatch_rs/tests/test_project_pyodide/Cargo.toml b/hatch_rs/tests/test_project_pyodide/Cargo.toml new file mode 100644 index 0000000..4305006 --- /dev/null +++ b/hatch_rs/tests/test_project_pyodide/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "hatch-rs-test-pyodide" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +name = "pyodide_project" +path = "src/lib.rs" +crate-type = ["cdylib"] + +[dependencies] +pyo3 = { version = "0.29.0", features = ["abi3", "extension-module"] } + +[profile.release] +panic = "abort" diff --git a/hatch_rs/tests/test_project_pyodide/pyodide_project/__init__.py b/hatch_rs/tests/test_project_pyodide/pyodide_project/__init__.py new file mode 100644 index 0000000..ceebd7c --- /dev/null +++ b/hatch_rs/tests/test_project_pyodide/pyodide_project/__init__.py @@ -0,0 +1 @@ +"""Pyodide integration-test package.""" diff --git a/hatch_rs/tests/test_project_pyodide/pyproject.toml b/hatch_rs/tests/test_project_pyodide/pyproject.toml new file mode 100644 index 0000000..fd0e8ab --- /dev/null +++ b/hatch_rs/tests/test_project_pyodide/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["hatchling>=1.20"] +build-backend = "hatchling.build" + +[project] +name = "hatch-rs-test-pyodide" +version = "0.1.0" +requires-python = ">=3.14" + +[tool.hatch.build.targets.wheel] +packages = ["pyodide_project"] + +[tool.hatch.build.hooks.hatch-rs] +abi3 = true +module = "pyodide_project" +path = "." diff --git a/hatch_rs/tests/test_project_pyodide/src/lib.rs b/hatch_rs/tests/test_project_pyodide/src/lib.rs new file mode 100644 index 0000000..abe5e11 --- /dev/null +++ b/hatch_rs/tests/test_project_pyodide/src/lib.rs @@ -0,0 +1,12 @@ +use pyo3::prelude::*; + +#[pyfunction] +fn answer() -> u8 { + 42 +} + +#[pymodule] +fn pyodide_project(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(answer, module)?)?; + Ok(()) +} diff --git a/hatch_rs/tests/test_structs.py b/hatch_rs/tests/test_structs.py index 7d34bc5..0c065e1 100644 --- a/hatch_rs/tests/test_structs.py +++ b/hatch_rs/tests/test_structs.py @@ -2,6 +2,7 @@ from json import loads from pathlib import Path, PureWindowsPath +from sys import version_info import pytest @@ -11,6 +12,8 @@ @pytest.fixture(autouse=True) def clear_cargo_target_dir(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("CARGO_TARGET_DIR", raising=False) + monkeypatch.delenv("CARGO_BUILD_TARGET", raising=False) + monkeypatch.delenv("PYODIDE_ABI_VERSION", raising=False) @pytest.mark.parametrize( @@ -44,6 +47,12 @@ def test_resolve_target_triple_uses_explicit_musl_target(): assert resolve_target_triple("aarch64-unknown-linux-musl", platform="linux", machine="x86_64") == "aarch64-unknown-linux-musl" +def test_resolve_target_triple_uses_cargo_build_target(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("CARGO_BUILD_TARGET", "wasm32-unknown-emscripten") + + assert resolve_target_triple(platform="linux", machine="x86_64") == "wasm32-unknown-emscripten" + + def test_resolve_target_triple_rejects_wheel_platform_tag_as_rust_target(): with pytest.raises(ValueError, match="manylinux.*wheel platform tags"): resolve_target_triple("x86_64-manylinux_2_28") @@ -75,6 +84,17 @@ def test_wheel_tag_uses_auditwheel_platform(monkeypatch: pytest.MonkeyPatch): assert wheel_tag(target="x86_64-unknown-linux-gnu", python_version=(3, 11)) == "cp311-cp311-manylinux_2_28_x86_64" +def test_wheel_tag_uses_pyodide_abi(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PYODIDE_ABI_VERSION", "2026_0") + + assert wheel_tag(abi3=True, target="wasm32-unknown-emscripten", python_version=(3, 14)) == "cp314-cp314-pyemscripten_2026_0_wasm32" + + +def test_wheel_tag_requires_emscripten_platform_version(): + with pytest.raises(ValueError, match="PYODIDE_ABI_VERSION"): + wheel_tag(target="wasm32-unknown-emscripten", python_version=(3, 14)) + + @pytest.mark.parametrize( ("platform", "expected"), [ @@ -83,6 +103,7 @@ def test_wheel_tag_uses_auditwheel_platform(monkeypatch: pytest.MonkeyPatch): ("linux", "libmylib.so"), ("manylinux_2_28_x86_64", "libmylib.so"), ("musllinux_1_2_x86_64", "libmylib.so"), + ("emscripten", "mylib.wasm"), ], ) def test_shared_library_name(platform: str, expected: str): @@ -96,10 +117,30 @@ def test_shared_library_name(platform: str, expected: str): ("project", "linux", False, "project.so"), ("libproject", "darwin", True, "project.abi3.so"), ("project", "win32", True, "project.pyd"), + ("project", "emscripten", True, "project.cpython-314-wasm32-emscripten.so"), ], ) def test_python_extension_name(source_stem: str, platform: str, abi3: bool, expected: str): - assert python_extension_name(source_stem, abi3=abi3, platform=platform) == expected + assert python_extension_name(source_stem, abi3=abi3, platform=platform, python_version=(3, 14)) == expected + + +def test_build_plan_copies_emscripten_python_extension(tmp_path): + plan = HatchRustBuildPlan( + module="project", + path=tmp_path, + target="wasm32-unknown-emscripten", + ) + plan.generate() + planned_artifact = plan._artifact_plans[0] + source = tmp_path / "target" / "wasm32-unknown-emscripten" / "release" / "project.wasm" + source.parent.mkdir(parents=True) + source.write_bytes(b"webassembly") + + plan._copy_outputs(planned_artifact, build_root=tmp_path) + + copied = tmp_path / "project" / f"project.cpython-{version_info.major}{version_info.minor}-wasm32-emscripten.so" + assert copied.read_bytes() == b"webassembly" + assert plan.libraries == [f"project/{copied.name}"] @pytest.mark.parametrize(