Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ hatch_rs/labextension

# Emscripten SDK (locally installed)
emsdk
.pyodide_build

# Mac
.DS_Store
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
31 changes: 28 additions & 3 deletions hatch_rs/structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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)


Expand All @@ -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}")


Expand All @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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}")


Expand All @@ -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)])))


Expand All @@ -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()}")


Expand Down
132 changes: 132 additions & 0 deletions hatch_rs/tests/test_project_pyodide/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions hatch_rs/tests/test_project_pyodide/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Pyodide integration-test package."""
16 changes: 16 additions & 0 deletions hatch_rs/tests/test_project_pyodide/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = "."
12 changes: 12 additions & 0 deletions hatch_rs/tests/test_project_pyodide/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
Loading