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
30 changes: 30 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,33 @@ 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: Install dependencies
run: |
make develop
uv pip install pyodide-build==0.39.0

- name: Build Pyodide fixture
run: |
mkdir -p dist/pyodide
pyodide build hatch_cpp/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 import answer; assert answer() == 42"
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ hatch_cpp/labextension

# Emscripten SDK (locally installed)
emsdk
.pyodide_build
.venv-pyodide

# Mac
.DS_Store
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,5 +181,21 @@ options:

`hatch-cpp` will respect standard environment variables for compiler control, e.g. `CC`, `CXX`, `LD`, `CMAKE_GENERATOR`, `OSX_DEPLOYMENT_TARGET`, etc.

### Pyodide

Pyodide builds are detected from `PYODIDE_ABI_VERSION`. The hook preserves Pyodide's Emscripten compiler wrappers, gives extension modules CPython's Emscripten suffix, and emits the corresponding `pyemscripten` wheel platform tag. No project-specific build hook is required.

```toml
[tool.hatch.build.hooks.hatch-cpp]
libraries = [
{name = "project/extension", sources = ["cpp/extension.cpp"], binding = "pybind11"},
]

[tool.cibuildwheel.pyodide]
test-command = "python -c 'from project.extension import answer; assert answer() == 42'"
```

When a `vcpkg.json` manifest is active, Emscripten builds use vcpkg's `wasm32-emscripten` community triplet. Package support varies by port.

> [!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).
17 changes: 13 additions & 4 deletions hatch_cpp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,28 @@ def generate(self):
if "vcpkg" in self._active_toolchains:
log.warning("vcpkg toolchain is active; ensure that your compiler is configured to use vcpkg includes and libs.")

for library in self.libraries:
for library_index, library in enumerate(self.libraries):
compile_flags = self.platform.get_compile_flags(library, self.build_type)
link_flags = self.platform.get_link_flags(library, self.build_type)
self.commands.append(
f"{self.platform.cc if library.language == 'c' else self.platform.cxx} {' '.join(library.sources)} {compile_flags} {link_flags}"
)
compiler = self.platform.cc if library.language == "c" else self.platform.cxx
if self.platform.platform == "emscripten":
objects = []
for source_index, source in enumerate(library.sources):
obj = Path("build/hatch-cpp") / f"{library_index}-{source_index}-{Path(source).stem}.o"
objects.append(str(obj))
self.commands.append(f"{compiler} -c {source} {compile_flags} -o {obj}")
self.commands.append(f"{compiler} {' '.join(objects)} {link_flags}")
else:
self.commands.append(f"{compiler} {' '.join(library.sources)} {compile_flags} {link_flags}")

if "cmake" in self._active_toolchains:
self.commands.extend(self.cmake.generate(self))

return self.commands

def execute(self):
if self.platform.platform == "emscripten" and "vanilla" in self._active_toolchains:
Path("build/hatch-cpp").mkdir(parents=True, exist_ok=True)
for command in self.commands:
ret = system_call(command)
if ret != 0:
Expand Down
46 changes: 27 additions & 19 deletions hatch_cpp/plugin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

from os import environ
from pathlib import Path
from platform import machine as platform_machine
from sys import platform as sys_platform, version_info
from sys import version_info
from typing import Any

from hatch_build import parse_extra_args_model
Expand All @@ -14,6 +15,23 @@
__all__ = ("HatchCppBuildHook",)


def _wheel_tag(platform: str, machine: str, version_major: int, version_minor: int, abi3: bool) -> str:
if platform == "emscripten":
abi_version = environ.get("PYODIDE_ABI_VERSION")
if not abi_version:
raise ValueError("PYODIDE_ABI_VERSION is required for Emscripten wheel tags.")
return f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-pyemscripten_{abi_version}_wasm32"

if platform == "darwin":
os_name = "macosx_11_0"
elif platform == "linux":
os_name = "linux"
else:
os_name = "win"
abi = "abi3" if abi3 else f"cp{version_major}{version_minor}"
return f"cp{version_major}{version_minor}-{abi}-{os_name}_{machine}"


class HatchCppBuildHook(BuildHookInterface[HatchCppBuildConfig]):
"""The hatch-cpp build hook."""

Expand Down Expand Up @@ -76,29 +94,19 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None:
machine = platform_machine()
version_major = version_info.major
version_minor = version_info.minor
if "darwin" in sys_platform:
os_name = "macosx_11_0"
elif "linux" in sys_platform:
os_name = "linux"
else:
os_name = "win"
if all(lib.py_limited_api for lib in build_plan.libraries):
build_data["tag"] = f"cp{version_major}{version_minor}-abi3-{os_name}_{machine}"
else:
build_data["tag"] = f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-{os_name}_{machine}"
build_data["tag"] = _wheel_tag(
build_plan.platform.platform,
machine,
version_major,
version_minor,
all(lib.py_limited_api for lib in build_plan.libraries),
)
else:
build_data["pure_python"] = False
machine = platform_machine()
version_major = version_info.major
version_minor = version_info.minor
# TODO abi3
if "darwin" in sys_platform:
os_name = "macosx_11_0"
elif "linux" in sys_platform:
os_name = "linux"
else:
os_name = "win"
build_data["tag"] = f"cp{version_major}{version_minor}-cp{version_major}{version_minor}-{os_name}_{machine}"
build_data["tag"] = _wheel_tag(build_plan.platform.platform, machine, version_major, version_minor, False)

# force include libraries
for path in Path(".").rglob("*"):
Expand Down
9 changes: 9 additions & 0 deletions hatch_cpp/tests/test_project_pyodide/cpp/extension.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#include <pybind11/pybind11.h>

int answer() {
return 42;
}

PYBIND11_MODULE(extension, module) {
module.def("answer", &answer);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .extension import answer

__all__ = ("answer",)
20 changes: 20 additions & 0 deletions hatch_cpp/tests/test_project_pyodide/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[build-system]
requires = ["hatchling>=1.20"]
build-backend = "hatchling.build"

[project]
name = "hatch-cpp-test-project-pyodide"
version = "0.1.0"
requires-python = ">=3.14"

[tool.hatch.build.targets.wheel]
packages = ["pyodide_project"]

[tool.hatch.build.hooks.hatch-cpp]
verbose = true
libraries = [
{name = "pyodide_project/extension", sources = ["cpp/extension.cpp"], binding = "pybind11"},
]

[tool.cibuildwheel.pyodide]
test-command = "python -c 'from pyodide_project import answer; assert answer() == 42'"
49 changes: 49 additions & 0 deletions hatch_cpp/tests/test_structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from toml import loads

from hatch_cpp import HatchCppBuildConfig, HatchCppBuildPlan, HatchCppLibrary, HatchCppPlatform
from hatch_cpp.plugin import _wheel_tag
from hatch_cpp.toolchains.common import _normalize_rpath


Expand Down Expand Up @@ -58,6 +59,54 @@ def test_platform_toolchain_override(self):
assert "clang++" in hatch_build_config.platform.cxx
assert hatch_build_config.platform.toolchain == "gcc"

def test_pyodide_platform(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("PYODIDE_ABI_VERSION", "2026_0")
monkeypatch.setenv("CC", "/toolchain/cc")
monkeypatch.setenv("CXX", "/toolchain/c++")

platform = HatchCppPlatform.default()
library = HatchCppLibrary(name="project/extension", sources=["extension.cpp"], binding="pybind11")

assert platform.platform == "emscripten"
assert platform.toolchain == "clang"
assert platform.cc == "/toolchain/cc"
assert platform.cxx == "/toolchain/c++"
assert library.get_qualified_name(platform.platform) == (
f"project/extension.cpython-{version_info.major}{version_info.minor}-wasm32-emscripten.so"
)
assert "-undefined dynamic_lookup" not in platform.get_link_flags(library)

def test_pyodide_wheel_tag(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("PYODIDE_ABI_VERSION", "2026_0")

assert _wheel_tag("emscripten", "wasm32", 3, 14, False) == "cp314-cp314-pyemscripten_2026_0_wasm32"

def test_pyodide_wheel_tag_requires_abi_version(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("PYODIDE_ABI_VERSION", raising=False)

with pytest.raises(ValueError, match="PYODIDE_ABI_VERSION"):
_wheel_tag("emscripten", "wasm32", 3, 14, False)

def test_pyodide_build_plan_compiles_objects_before_linking(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("PYODIDE_ABI_VERSION", "2026_0")
monkeypatch.setenv("CC", "/toolchain/cc")
monkeypatch.setenv("CXX", "/toolchain/c++")
build_plan = HatchCppBuildPlan(
name="pyodide-project",
libraries=[HatchCppLibrary(name="pyodide_project/extension", sources=["cpp/extension.cpp"], binding="pybind11")],
vcpkg=None,
)

build_plan.generate()

assert len(build_plan.commands) == 2
assert build_plan.commands[0].startswith("/toolchain/c++ -c cpp/extension.cpp ")
assert build_plan.commands[0].endswith(" -o build/hatch-cpp/0-0-extension.o")
assert build_plan.commands[1].startswith("/toolchain/c++ build/hatch-cpp/0-0-extension.o ")
assert build_plan.commands[1].endswith(
f" -shared -o pyodide_project/extension.cpython-{version_info.major}{version_info.minor}-wasm32-emscripten.so"
)

def test_cmake_args_env_variable(self):
"""Test that CMAKE_ARGS environment variable is respected."""
txt = (Path(__file__).parent / "test_project_cmake" / "pyproject.toml").read_text()
Expand Down
15 changes: 15 additions & 0 deletions hatch_cpp/tests/test_vcpkg_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from pathlib import Path
from types import SimpleNamespace

from hatch_cpp.toolchains.vcpkg import (
HatchCppVcpkgConfiguration,
Expand Down Expand Up @@ -87,6 +88,10 @@ def test_linux_arm64_triplet(self):
cfg = HatchCppVcpkgConfiguration(vcpkg_triplet="arm64-linux")
assert cfg.vcpkg_triplet == "arm64-linux"

def test_emscripten_triplet(self):
cfg = HatchCppVcpkgConfiguration(vcpkg_triplet="wasm32-emscripten")
assert cfg.vcpkg_triplet == "wasm32-emscripten"


class TestResolveVcpkgRef:
"""Tests for _resolve_vcpkg_ref priority logic."""
Expand Down Expand Up @@ -178,6 +183,16 @@ def test_generate_detects_linux_arm64_triplet(self, tmp_path, monkeypatch):

assert "./vcpkg/vcpkg install --triplet arm64-linux" in commands

def test_generate_detects_emscripten_triplet(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
self._make_vcpkg_env(tmp_path)
build_plan = SimpleNamespace(platform=SimpleNamespace(platform="emscripten"))

cfg = HatchCppVcpkgConfiguration()
commands = cfg.generate(build_plan)

assert "./vcpkg/vcpkg install --triplet wasm32-emscripten" in commands

def test_generate_uses_windows_command_paths(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setattr("hatch_cpp.toolchains.vcpkg.sys_platform", "win32")
Expand Down
Loading