diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index de31189..c494012 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -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" diff --git a/.gitignore b/.gitignore index 4bcf775..74c1376 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,8 @@ hatch_cpp/labextension # Emscripten SDK (locally installed) emsdk +.pyodide_build +.venv-pyodide # Mac .DS_Store diff --git a/README.md b/README.md index b68da15..e997c68 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/hatch_cpp/config.py b/hatch_cpp/config.py index 089c762..ca8b946 100644 --- a/hatch_cpp/config.py +++ b/hatch_cpp/config.py @@ -93,12 +93,19 @@ 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)) @@ -106,6 +113,8 @@ def 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: diff --git a/hatch_cpp/plugin.py b/hatch_cpp/plugin.py index c486584..dc79b94 100644 --- a/hatch_cpp/plugin.py +++ b/hatch_cpp/plugin.py @@ -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 @@ -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.""" @@ -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("*"): diff --git a/hatch_cpp/tests/test_project_pyodide/cpp/extension.cpp b/hatch_cpp/tests/test_project_pyodide/cpp/extension.cpp new file mode 100644 index 0000000..04b97c4 --- /dev/null +++ b/hatch_cpp/tests/test_project_pyodide/cpp/extension.cpp @@ -0,0 +1,9 @@ +#include + +int answer() { + return 42; +} + +PYBIND11_MODULE(extension, module) { + module.def("answer", &answer); +} diff --git a/hatch_cpp/tests/test_project_pyodide/pyodide_project/__init__.py b/hatch_cpp/tests/test_project_pyodide/pyodide_project/__init__.py new file mode 100644 index 0000000..cd0c721 --- /dev/null +++ b/hatch_cpp/tests/test_project_pyodide/pyodide_project/__init__.py @@ -0,0 +1,3 @@ +from .extension import answer + +__all__ = ("answer",) diff --git a/hatch_cpp/tests/test_project_pyodide/pyproject.toml b/hatch_cpp/tests/test_project_pyodide/pyproject.toml new file mode 100644 index 0000000..d2f35e4 --- /dev/null +++ b/hatch_cpp/tests/test_project_pyodide/pyproject.toml @@ -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'" diff --git a/hatch_cpp/tests/test_structs.py b/hatch_cpp/tests/test_structs.py index fa22880..456c7ae 100644 --- a/hatch_cpp/tests/test_structs.py +++ b/hatch_cpp/tests/test_structs.py @@ -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 @@ -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", "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/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 == "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/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", "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/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() diff --git a/hatch_cpp/tests/test_vcpkg_ref.py b/hatch_cpp/tests/test_vcpkg_ref.py index 897cf0d..2cfecdc 100644 --- a/hatch_cpp/tests/test_vcpkg_ref.py +++ b/hatch_cpp/tests/test_vcpkg_ref.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace from hatch_cpp.toolchains.vcpkg import ( HatchCppVcpkgConfiguration, @@ -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.""" @@ -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") diff --git a/hatch_cpp/toolchains/common.py b/hatch_cpp/toolchains/common.py index 1ddac0b..ba455a7 100644 --- a/hatch_cpp/toolchains/common.py +++ b/hatch_cpp/toolchains/common.py @@ -4,7 +4,7 @@ from pathlib import Path from re import match from shutil import which -from sys import base_exec_prefix, exec_prefix, executable, platform as sys_platform +from sys import base_exec_prefix, exec_prefix, executable, platform as sys_platform, version_info from sysconfig import get_config_var, get_path from typing import Any, Literal @@ -29,11 +29,12 @@ Toolchain = Literal["vcpkg", "cmake", "vanilla"] Language = Literal["c", "c++"] Binding = Literal["cpython", "pybind11", "nanobind", "generic"] -Platform = Literal["linux", "darwin", "win32"] +Platform = Literal["linux", "darwin", "win32", "emscripten"] PlatformDefaults = { "linux": {"CC": "gcc", "CXX": "g++", "LD": "ld"}, "darwin": {"CC": "clang", "CXX": "clang++", "LD": "ld"}, "win32": {"CC": "cl", "CXX": "cl", "LD": "link"}, + "emscripten": {"CC": "emcc", "CXX": "em++", "LD": "wasm-ld"}, } @@ -100,6 +101,10 @@ def check_py_limited_api(cls, value: Any) -> Any: return value def get_qualified_name(self, platform): + if platform == "emscripten": + if self.binding == "generic": + return f"{self.name}.wasm" + return f"{self.name}.cpython-{version_info.major}{version_info.minor}-wasm32-emscripten.so" if self.binding == "cpython" and not self.py_limited_api: suffix = get_config_var("EXT_SUFFIX") return f"{self.name}{suffix}" @@ -242,9 +247,10 @@ class HatchCppPlatform(BaseModel): @staticmethod def default() -> HatchCppPlatform: - CC = environ.get("CC", PlatformDefaults[sys_platform]["CC"]) - CXX = environ.get("CXX", PlatformDefaults[sys_platform]["CXX"]) - LD = environ.get("LD", PlatformDefaults[sys_platform]["LD"]) + platform = "emscripten" if environ.get("PYODIDE_ABI_VERSION") else sys_platform + CC = environ.get("CC", PlatformDefaults[platform]["CC"]) + CXX = environ.get("CXX", PlatformDefaults[platform]["CXX"]) + LD = environ.get("LD", PlatformDefaults[platform]["LD"]) if "gcc" in CC and "g++" in CXX: toolchain = "gcc" elif "clang" in CC and "clang++" in CXX: @@ -252,11 +258,11 @@ def default() -> HatchCppPlatform: elif "cl" in CC and "cl" in CXX: toolchain = "msvc" # Fallback to platform defaults - elif sys_platform == "linux": + elif platform == "linux": toolchain = "gcc" - elif sys_platform == "darwin": + elif platform in ("darwin", "emscripten"): toolchain = "clang" - elif sys_platform == "win32": + elif platform == "win32": toolchain = "msvc" else: toolchain = "gcc" @@ -267,13 +273,13 @@ def default() -> HatchCppPlatform: # LD = which("ld.mold") # elif which("ld.lld"): # LD = which("ld.lld") - return HatchCppPlatform(cc=CC, cxx=CXX, ld=LD, platform=sys_platform, toolchain=toolchain) + return HatchCppPlatform(cc=CC, cxx=CXX, ld=LD, platform=platform, toolchain=toolchain) @model_validator(mode="wrap") @classmethod def validate_model(cls, data, handler): model = handler(data) - if which("ccache") and not model.disable_ccache and model.toolchain in ["gcc", "clang"]: + if which("ccache") and model.platform != "emscripten" and not model.disable_ccache and model.toolchain in ["gcc", "clang"]: if not model.cc.startswith("ccache "): model.cc = f"ccache {model.cc}" if not model.cxx.startswith("ccache "): @@ -324,7 +330,8 @@ def get_compile_flags(self, library: HatchCppLibrary, build_type: BuildType = "r # Toolchain-specific flags if self.toolchain == "gcc": flags += " " + " ".join(f"-I{d}" for d in effective_include_dirs) - flags += " -fPIC" + if self.platform != "emscripten": + flags += " -fPIC" flags += " " + " ".join(effective_compile_args) flags += " " + " ".join(f"-D{macro}" for macro in effective_define_macros) flags += " " + " ".join(f"-U{macro}" for macro in effective_undef_macros) @@ -332,7 +339,8 @@ def get_compile_flags(self, library: HatchCppLibrary, build_type: BuildType = "r flags += f" -std={library.std}" elif self.toolchain == "clang": flags += " ".join(f"-I{d}" for d in effective_include_dirs) - flags += " -fPIC" + if self.platform != "emscripten": + flags += " -fPIC" flags += " " + " ".join(effective_compile_args) flags += " " + " ".join(f"-D{macro}" for macro in effective_define_macros) flags += " " + " ".join(f"-U{macro}" for macro in effective_undef_macros) diff --git a/hatch_cpp/toolchains/vcpkg.py b/hatch_cpp/toolchains/vcpkg.py index 464464d..17945de 100644 --- a/hatch_cpp/toolchains/vcpkg.py +++ b/hatch_cpp/toolchains/vcpkg.py @@ -29,6 +29,7 @@ "arm64-uwp", "arm64-windows", "arm64-windows-static-md", + "wasm32-emscripten", ] VcpkgPlatformDefaults = { ("linux", "x86_64"): "x64-linux", @@ -38,6 +39,7 @@ ("win32", "x86_64"): "x64-windows-static-md", ("win32", "AMD64"): "x64-windows-static-md", ("win32", "arm64"): "arm64-windows-static-md", + ("emscripten", "wasm32"): "wasm32-emscripten", } @@ -122,9 +124,11 @@ def generate(self, config): commands = [] if self.vcpkg_triplet is None: - self.vcpkg_triplet = VcpkgPlatformDefaults.get((sys_platform, platform_machine())) + platform = config.platform.platform if config is not None else sys_platform + machine = "wasm32" if platform == "emscripten" else platform_machine() + self.vcpkg_triplet = VcpkgPlatformDefaults.get((platform, machine)) if self.vcpkg_triplet is None: - raise ValueError(f"Could not determine vcpkg triplet for platform {sys_platform} and architecture {platform_machine()}") + raise ValueError(f"Could not determine vcpkg triplet for platform {platform} and architecture {machine}") if self.vcpkg and Path(self.vcpkg).exists(): vcpkg_root = Path(self.vcpkg_root)