From 9074f0a49cab1d348615cfe0ebd2e3c1c306b148 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:54:13 -0500 Subject: [PATCH 1/3] Enable the C ABI library option Regenerate from the base template with add_c_abi. The sources split into cpp/python-template-cpp, which builds libpython_template_cpp with a C surface, and cpp/python, the pybind11 extension that links it. cpp/CMakeLists.txt builds and installs the library without Python. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- .copier-answers.yaml | 3 +- README.md | 23 ++++ cpp/CMakeLists.txt | 46 ++++++++ cpp/cmake/python_template_cpp.pc.in | 9 ++ cpp/cmake/python_template_cppConfig.cmake.in | 4 + cpp/python-template-cpp/c_api.cpp | 7 ++ cpp/python-template-cpp/example.cpp | 7 ++ cpp/python-template-cpp/example.hpp | 7 ++ cpp/python-template-cpp/extension.cpp | 3 - cpp/python-template-cpp/extension.hpp | 12 -- cpp/python/extension.cpp | 9 ++ pyproject.toml | 39 ++++++- python_template_cpp/__init__.py | 24 ++++ .../python_template_cpp/python_template_cpp.h | 28 +++++ python_template_cpp/tests/test_all.py | 104 +++++++++++++++++- 15 files changed, 300 insertions(+), 25 deletions(-) create mode 100644 cpp/CMakeLists.txt create mode 100644 cpp/cmake/python_template_cpp.pc.in create mode 100644 cpp/cmake/python_template_cppConfig.cmake.in create mode 100644 cpp/python-template-cpp/c_api.cpp create mode 100644 cpp/python-template-cpp/example.cpp create mode 100644 cpp/python-template-cpp/example.hpp delete mode 100644 cpp/python-template-cpp/extension.cpp delete mode 100644 cpp/python-template-cpp/extension.hpp create mode 100644 cpp/python/extension.cpp create mode 100644 python_template_cpp/include/python_template_cpp/python_template_cpp.h diff --git a/.copier-answers.yaml b/.copier-answers.yaml index 144eda8..a72b04b 100644 --- a/.copier-answers.yaml +++ b/.copier-answers.yaml @@ -1,6 +1,7 @@ # Changes here will be overwritten by Copier -_commit: 02f55bd +_commit: c40b5eb _src_path: https://github.com/python-project-templates/base.git +add_c_abi: true add_docs: true add_extension: cpp add_pyodide: true diff --git a/README.md b/README.md index b769c1f..859daaa 100644 --- a/README.md +++ b/README.md @@ -9,5 +9,28 @@ A C++-Python project template ## Overview +## C ABI + +`cpp/python-template-cpp` builds `libpython_template_cpp`, a C ABI over the C++ code, declared in +`python_template_cpp/include/python_template_cpp/python_template_cpp.h`. Wheels ship the library under `python_template_cpp/lib` and +the header under `python_template_cpp/include/python_template_cpp`, and the pybind11 extension in `cpp/python` links +against the library. The tests compile a C program against the installed package. Other +projects build against the installed wheel with `python_template_cpp.get_include()`, +`get_library_dirs()`, `get_libraries()`, and `get_prefix()`. + +`HATCH_CPP_LIBRARY_SOURCE` chooses where the library comes from at build time: +`bundled` (the default, for wheels), `external` (a prebuilt library found +through `HATCH_CPP_LIBRARY_ROOT`, pkg-config, or the conda prefix), or `direct` +(compile the library sources into the extension). + +`cpp/CMakeLists.txt` builds and installs the library without Python, with a CMake +package config and a pkg-config file, for conda, vcpkg, and system packages: + +```bash +cmake -S cpp -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build +cmake --install build --prefix /path/to/prefix +``` + > [!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/cpp/CMakeLists.txt b/cpp/CMakeLists.txt new file mode 100644 index 0000000..c69514c --- /dev/null +++ b/cpp/CMakeLists.txt @@ -0,0 +1,46 @@ +# Builds and installs libpython_template_cpp without Python, for conda, vcpkg, and system packages. +cmake_minimum_required(VERSION 3.20) +project(python_template_cpp VERSION 0.1.0 LANGUAGES CXX) + +include(CMakePackageConfigHelpers) + +add_library(python_template_cpp SHARED python-template-cpp/c_api.cpp python-template-cpp/example.cpp) +add_library(python_template_cpp::python_template_cpp ALIAS python_template_cpp) +target_compile_features(python_template_cpp PRIVATE cxx_std_17) +target_compile_definitions(python_template_cpp PRIVATE PYTHON_TEMPLATE_CPP_BUILD) +target_include_directories( + python_template_cpp + PUBLIC $ $ + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} +) +# SOVERSION is PYTHON_TEMPLATE_CPP_ABI_VERSION. +set_target_properties( + python_template_cpp + PROPERTIES VERSION ${PROJECT_VERSION} + SOVERSION 1 + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON + INSTALL_NAME_DIR "@rpath" +) + +install( + TARGETS python_template_cpp + EXPORT python_template_cppTargets + RUNTIME DESTINATION bin + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib +) +install(DIRECTORY ../python_template_cpp/include/ DESTINATION include) +install(EXPORT python_template_cppTargets NAMESPACE python_template_cpp:: DESTINATION lib/cmake/python_template_cpp) + +configure_package_config_file( + cmake/python_template_cppConfig.cmake.in ${CMAKE_CURRENT_BINARY_DIR}/python_template_cppConfig.cmake + INSTALL_DESTINATION lib/cmake/python_template_cpp +) +write_basic_package_version_file(${CMAKE_CURRENT_BINARY_DIR}/python_template_cppConfigVersion.cmake COMPATIBILITY SameMinorVersion) +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/python_template_cppConfig.cmake ${CMAKE_CURRENT_BINARY_DIR}/python_template_cppConfigVersion.cmake + DESTINATION lib/cmake/python_template_cpp +) + +configure_file(cmake/python_template_cpp.pc.in ${CMAKE_CURRENT_BINARY_DIR}/python_template_cpp.pc @ONLY) +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/python_template_cpp.pc DESTINATION lib/pkgconfig) diff --git a/cpp/cmake/python_template_cpp.pc.in b/cpp/cmake/python_template_cpp.pc.in new file mode 100644 index 0000000..a72c35a --- /dev/null +++ b/cpp/cmake/python_template_cpp.pc.in @@ -0,0 +1,9 @@ +prefix=${pcfiledir}/../.. +libdir=${prefix}/lib +includedir=${prefix}/include + +Name: python_template_cpp +Description: A C++-Python project template +Version: @PROJECT_VERSION@ +Libs: -L${libdir} -lpython_template_cpp +Cflags: -I${includedir} diff --git a/cpp/cmake/python_template_cppConfig.cmake.in b/cpp/cmake/python_template_cppConfig.cmake.in new file mode 100644 index 0000000..3fa2911 --- /dev/null +++ b/cpp/cmake/python_template_cppConfig.cmake.in @@ -0,0 +1,4 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/python_template_cppTargets.cmake") +check_required_components(python_template_cpp) diff --git a/cpp/python-template-cpp/c_api.cpp b/cpp/python-template-cpp/c_api.cpp new file mode 100644 index 0000000..58ef7f1 --- /dev/null +++ b/cpp/python-template-cpp/c_api.cpp @@ -0,0 +1,7 @@ +#include "python_template_cpp/python_template_cpp.h" + +#include "python-template-cpp/example.hpp" + +uint32_t python_template_cpp_abi_version(void) { return PYTHON_TEMPLATE_CPP_ABI_VERSION; } + +int32_t python_template_cpp_add(int32_t i, int32_t j) { return python_template_cpp::add(i, j); } diff --git a/cpp/python-template-cpp/example.cpp b/cpp/python-template-cpp/example.cpp new file mode 100644 index 0000000..328f0db --- /dev/null +++ b/cpp/python-template-cpp/example.cpp @@ -0,0 +1,7 @@ +#include "python-template-cpp/example.hpp" + +namespace python_template_cpp { + +int add(int i, int j) { return i + j; } + +} diff --git a/cpp/python-template-cpp/example.hpp b/cpp/python-template-cpp/example.hpp new file mode 100644 index 0000000..70f3503 --- /dev/null +++ b/cpp/python-template-cpp/example.hpp @@ -0,0 +1,7 @@ +#pragma once + +namespace python_template_cpp { + +int add(int i, int j); + +} diff --git a/cpp/python-template-cpp/extension.cpp b/cpp/python-template-cpp/extension.cpp deleted file mode 100644 index 77e12c8..0000000 --- a/cpp/python-template-cpp/extension.cpp +++ /dev/null @@ -1,3 +0,0 @@ -#include "python-template-cpp/extension.hpp" - -int add(int i, int j) { return i + j; } diff --git a/cpp/python-template-cpp/extension.hpp b/cpp/python-template-cpp/extension.hpp deleted file mode 100644 index 2ae782a..0000000 --- a/cpp/python-template-cpp/extension.hpp +++ /dev/null @@ -1,12 +0,0 @@ -#pragma once -#include - -namespace py = pybind11; - -int add(int i, int j); - -PYBIND11_MODULE(extension, m) { - m.doc() = "pybind11 example plugin"; - - m.def("add", &add, "A function that adds two numbers"); -} diff --git a/cpp/python/extension.cpp b/cpp/python/extension.cpp new file mode 100644 index 0000000..e587613 --- /dev/null +++ b/cpp/python/extension.cpp @@ -0,0 +1,9 @@ +#include + +#include "python_template_cpp/python_template_cpp.h" + +PYBIND11_MODULE(extension, m) { + m.doc() = "pybind11 example plugin"; + + m.def("add", &python_template_cpp_add, "A function that adds two numbers"); +} diff --git a/pyproject.toml b/pyproject.toml index cf7ca2d..a6a8d76 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [build-system] requires = [ "hatchling", - "hatch-cpp>=0.5.0", + "hatch-cpp>=0.6.0", "pybind11", ] build-backend = "hatchling.build" @@ -39,7 +39,7 @@ develop = [ "check-dist", "cibuildwheel", "codespell", - "hatch-cpp>=0.5.0", + "hatch-cpp>=0.6.0", "hatchling", "mdformat", "mdformat-myst", @@ -76,6 +76,11 @@ filename = "pyproject.toml" search = 'version = "{current_version}"' replace = 'version = "{new_version}"' +[[tool.bumpversion.files]] +filename = "cpp/CMakeLists.txt" +search = "VERSION {current_version}" +replace = "VERSION {new_version}" + [tool.cibuildwheel] build = "cp311-* cp312-* cp313-*" test-command = "pytest -vvv {project}/python_template_cpp/tests" @@ -85,10 +90,13 @@ test-extras = "develop" manylinux-aarch64-image = "manylinux_2_28" manylinux-x86_64-image = "manylinux_2_28" skip = "*i686* *musllinux*" +# Keep the bundled C ABI library where the extension's rpath expects it. +repair-wheel-command = "auditwheel repair --exclude 'libpython_template_cpp.so' -w {dest_dir} {wheel}" [tool.cibuildwheel.macos] environment = {MACOSX_DEPLOYMENT_TARGET="11.0"} archs = "arm64" +repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} --exclude libpython_template_cpp.dylib -w {dest_dir} -v {wheel}" [tool.cibuildwheel.windows] archs = "AMD64" @@ -98,6 +106,8 @@ skip = "*win32 *arm64" test-command = "python -m pytest -vvv --pyargs python_template_cpp.tests" test-extras = [] test-requires = ["pytest"] +# Emscripten has no shared libraries to split; compile the library in. +environment = {HATCH_CPP_LIBRARY_SOURCE="direct"} [tool.coverage.run] branch = true @@ -119,9 +129,27 @@ src = "/" [tool.hatch.build.hooks.hatch-cpp] verbose = true -libraries = [ - {name = "python_template_cpp/extension", sources = ["cpp/python-template-cpp/extension.cpp"], include-dirs = ["cpp"], binding="pybind11"} -] + +# libpython_template_cpp: the C ABI library, shipped under python_template_cpp/lib. Its header is in python_template_cpp/include. +[[tool.hatch.build.hooks.hatch-cpp.libraries]] +name = "python_template_cpp/lib/libpython_template_cpp" +sources = ["cpp/python-template-cpp/c_api.cpp", "cpp/python-template-cpp/example.cpp"] +include-dirs = ["cpp", "python_template_cpp/include"] +binding = "generic" +define-macros = ["PYTHON_TEMPLATE_CPP_BUILD"] +extra-compile-args-linux = ["-fvisibility=hidden"] +extra-compile-args-darwin = ["-fvisibility=hidden"] +expected-headers = ["python_template_cpp/include/python_template_cpp/python_template_cpp.h"] +header-symbol-prefix = "python_template_cpp_" +expected-abi-strings = ["PYTHON_TEMPLATE_CPP_ABI_VERSION 1"] + +# The pybind11 extension, linked against the library by default (library-source = bundled). +[[tool.hatch.build.hooks.hatch-cpp.libraries]] +name = "python_template_cpp/extension" +sources = ["cpp/python/extension.cpp"] +include-dirs = ["python_template_cpp/include"] +binding = "pybind11" +depends = ["python_template_cpp/lib/libpython_template_cpp"] [tool.hatch.build.targets.sdist] exclude = ["*.dll", "*.dylib", "*.so", "*.pyd"] @@ -140,6 +168,7 @@ artifacts = [ "python_template_cpp/*.dll", "python_template_cpp/*.dylib", "python_template_cpp/*.so", + "python_template_cpp/lib/*", ] packages = [ "python_template_cpp", diff --git a/python_template_cpp/__init__.py b/python_template_cpp/__init__.py index f661b47..4a7aaac 100644 --- a/python_template_cpp/__init__.py +++ b/python_template_cpp/__init__.py @@ -1,6 +1,10 @@ import os import os.path +if os.name == "nt" and os.path.isdir(os.path.join(os.path.dirname(__file__), "lib")): + # Windows has no rpath, so the extension finds libpython_template_cpp.dll through this directory. + _DLL_DIRECTORY = os.add_dll_directory(os.path.join(os.path.dirname(__file__), "lib")) + from .extension import * __version__ = "0.1.0" @@ -16,3 +20,23 @@ def bin_path(): def lib_path(): return os.path.abspath(os.path.join(os.path.dirname(__file__), "lib")) + + +def get_prefix() -> str: + """The installed package directory, which holds ``include`` and ``lib`` for libpython_template_cpp.""" + return os.path.abspath(os.path.dirname(__file__)) + + +def get_include() -> str: + """The directory holding ``python_template_cpp/python_template_cpp.h``.""" + return include_path() + + +def get_library_dirs() -> list[str]: + """Directories holding the bundled libpython_template_cpp shared library.""" + return [lib_path()] + + +def get_libraries() -> list[str]: + """Link names for libpython_template_cpp, for ``-l`` or ``target_link_libraries``.""" + return ["libpython_template_cpp" if os.name == "nt" else "python_template_cpp"] diff --git a/python_template_cpp/include/python_template_cpp/python_template_cpp.h b/python_template_cpp/include/python_template_cpp/python_template_cpp.h new file mode 100644 index 0000000..13fa21f --- /dev/null +++ b/python_template_cpp/include/python_template_cpp/python_template_cpp.h @@ -0,0 +1,28 @@ +/* C ABI of libpython_template_cpp. Every exported function is declared here. */ +#pragma once + +#include + +#define PYTHON_TEMPLATE_CPP_ABI_VERSION 1 + +#if defined(_WIN32) && defined(PYTHON_TEMPLATE_CPP_BUILD) +#define PYTHON_TEMPLATE_CPP_API __declspec(dllexport) +#elif defined(_WIN32) +#define PYTHON_TEMPLATE_CPP_API __declspec(dllimport) +#else +#define PYTHON_TEMPLATE_CPP_API __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* The ABI version the library was built with. Compare it with PYTHON_TEMPLATE_CPP_ABI_VERSION. */ +PYTHON_TEMPLATE_CPP_API uint32_t python_template_cpp_abi_version(void); + +/* Adds two numbers. */ +PYTHON_TEMPLATE_CPP_API int32_t python_template_cpp_add(int32_t i, int32_t j); + +#ifdef __cplusplus +} +#endif diff --git a/python_template_cpp/tests/test_all.py b/python_template_cpp/tests/test_all.py index f1cce41..469cbaa 100644 --- a/python_template_cpp/tests/test_all.py +++ b/python_template_cpp/tests/test_all.py @@ -1,6 +1,102 @@ -from python_template_cpp import add +import os +from ctypes import CDLL, c_uint32 +from pathlib import Path +from shutil import which +from subprocess import run +from sys import platform +import pytest -class TestAll: - def test_all(self): - assert 3 == add(1, 2) +from python_template_cpp import add, get_include, get_libraries, get_library_dirs + +C_CONSUMER = """ +#include +#include + +int main(void) { + printf("%u %d", python_template_cpp_abi_version(), python_template_cpp_add(1, 2)); + return 0; +} +""" + +CMAKE_CONSUMER = """ +cmake_minimum_required(VERSION 3.20) +project(consumer LANGUAGES C) +find_package(python_template_cpp CONFIG REQUIRED) +add_executable(consumer consumer.c) +target_link_libraries(consumer PRIVATE python_template_cpp::python_template_cpp) +""" + + +def _library_filename(name: str) -> str: + return f"{name}.dll" if platform == "win32" else f"lib{name}.dylib" if platform == "darwin" else f"lib{name}.so" + + +def _cmake_build(cmake: str, source: Path, build: Path, option: str) -> None: + run([cmake, "-S", str(source), "-B", str(build), option, "-DCMAKE_BUILD_TYPE=Release"], check=True) + run([cmake, "--build", str(build), "--config", "Release"], check=True) + + +def test_all(): + assert 3 == add(1, 2) + + +def test_c_library_and_header_are_installed(): + if platform == "emscripten": + pytest.skip("Pyodide wheels compile the library in") + header = Path(get_include()) / "python_template_cpp" / "python_template_cpp.h" + assert "#define PYTHON_TEMPLATE_CPP_ABI_VERSION 1" in header.read_text() + (name,) = get_libraries() + library = CDLL(str(Path(get_library_dirs()[0]) / _library_filename(name))) + library.python_template_cpp_abi_version.restype = c_uint32 + assert library.python_template_cpp_abi_version() == 1 + + +def test_c_consumer_builds_against_the_installed_package(tmp_path): + if platform == "emscripten": + pytest.skip("Pyodide wheels compile the library in") + compiler = next((path for path in map(which, ("cc", "gcc", "clang", "cl")) if path), None) + if compiler is None: + pytest.skip("no C compiler on PATH") + (library,) = get_libraries() + (library_dir,) = get_library_dirs() + source = tmp_path / "consumer.c" + source.write_text(C_CONSUMER) + executable = tmp_path / ("consumer.exe" if platform == "win32" else "consumer") + env = dict(os.environ) + if Path(compiler).stem.lower() == "cl": + command = [compiler, "/nologo", f"/I{get_include()}", str(source), f"/Fe:{executable}", "/link", f"/LIBPATH:{library_dir}", f"{library}.lib"] + env["PATH"] = library_dir + os.pathsep + env["PATH"] + else: + command = [ + compiler, + f"-I{get_include()}", + str(source), + f"-L{library_dir}", + f"-l{library}", + f"-Wl,-rpath,{library_dir}", + "-o", + str(executable), + ] + run(command, check=True, cwd=tmp_path) + assert run([str(executable)], check=True, capture_output=True, text=True, env=env).stdout == "1 3" + + +def test_cmake_project_installs_a_package_that_find_package_locates(tmp_path): + cmake = which("cmake") + project = Path(__file__).resolve().parents[2] / "cpp" + if cmake is None or not (project / "CMakeLists.txt").is_file(): + pytest.skip("needs cmake and the source tree") + prefix = tmp_path / "prefix" + _cmake_build(cmake, project, tmp_path / "build", f"-DCMAKE_INSTALL_PREFIX={prefix}") + run([cmake, "--install", str(tmp_path / "build"), "--config", "Release"], check=True) + assert (prefix / "lib" / "pkgconfig" / "python_template_cpp.pc").is_file() + + consumer = tmp_path / "consumer" + consumer.mkdir() + (consumer / "CMakeLists.txt").write_text(CMAKE_CONSUMER) + (consumer / "consumer.c").write_text(C_CONSUMER) + _cmake_build(cmake, consumer, consumer / "build", f"-DCMAKE_PREFIX_PATH={prefix}") + (executable,) = [path for path in (consumer / "build").rglob("consumer*") if path.name in ("consumer", "consumer.exe") and path.is_file()] + env = {**os.environ, "PATH": str(prefix / "bin") + os.pathsep + os.environ["PATH"]} + assert run([str(executable)], check=True, capture_output=True, text=True, env=env).stdout == "1 3" From e31bd8fe04aa23b7752cb9bb59dab6f9cb2ee683 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:06:36 -0500 Subject: [PATCH 2/3] Set PATH for the Windows consumer program with any compiler Windows runners find MinGW gcc before cl, and the program needs the library directory on PATH either way. The same change is in python-project-templates/base#223. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- python_template_cpp/tests/test_all.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/python_template_cpp/tests/test_all.py b/python_template_cpp/tests/test_all.py index 469cbaa..625ac67 100644 --- a/python_template_cpp/tests/test_all.py +++ b/python_template_cpp/tests/test_all.py @@ -64,9 +64,11 @@ def test_c_consumer_builds_against_the_installed_package(tmp_path): source.write_text(C_CONSUMER) executable = tmp_path / ("consumer.exe" if platform == "win32" else "consumer") env = dict(os.environ) + if platform == "win32": + # Windows has no rpath, so the program finds the DLL through PATH whichever compiler built it. + env["PATH"] = library_dir + os.pathsep + env["PATH"] if Path(compiler).stem.lower() == "cl": command = [compiler, "/nologo", f"/I{get_include()}", str(source), f"/Fe:{executable}", "/link", f"/LIBPATH:{library_dir}", f"{library}.lib"] - env["PATH"] = library_dir + os.pathsep + env["PATH"] else: command = [ compiler, From 92f7f3e3d9f44ae84bd3b910ca741748964045b1 Mon Sep 17 00:00:00 2001 From: Tim Paine <3105306+timkpaine@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:11:57 -0500 Subject: [PATCH 3/3] Exclude the C ABI library from delvewheel repair cibuildwheel runs delvewheel on Windows by default, and it cannot find the DLL the wheel already ships. The same change is in python-project-templates/base#223. Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com> --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index a6a8d76..8be6ffd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,8 @@ repair-wheel-command = "delocate-wheel --require-archs {delocate_archs} --exclud [tool.cibuildwheel.windows] archs = "AMD64" skip = "*win32 *arm64" +# The C ABI library ships inside the wheel; delvewheel must not look for it on PATH. +repair-wheel-command = "delvewheel repair --exclude libpython_template_cpp.dll -w {dest_dir} {wheel}" [tool.cibuildwheel.pyodide] test-command = "python -m pytest -vvv --pyargs python_template_cpp.tests"