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..8be6ffd 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,19 +90,26 @@ 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" 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" 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 +131,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 +170,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..625ac67 100644 --- a/python_template_cpp/tests/test_all.py +++ b/python_template_cpp/tests/test_all.py @@ -1,6 +1,104 @@ -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 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"] + 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"