diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fff247..8513b62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,19 +45,20 @@ jobs: - uses: astral-sh/setup-uv@v8.0.0 - run: uvx nox -s ${{ matrix.session }} - free-threading: + cibuildwheel: runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false matrix: runs-on: [ubuntu-latest, windows-latest, macos-14] - name: cibw on ${{ matrix.runs-on }} + package: [hello-free-threading, core-nanobind-shared] + name: cibw ${{ matrix.package }} on ${{ matrix.runs-on }} steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v8.0.0 - uses: pypa/cibuildwheel@v2.21 with: - package-dir: projects/hello-free-threading + package-dir: projects/${{ matrix.package }} env: CIBW_PRERELEASE_PYTHONS: True diff --git a/AGENTS.md b/AGENTS.md index 232700a..5da74d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,10 +9,10 @@ A collection of small, self-contained example packages under `projects/`, each d Projects fall into distinct families; know which one you're editing before changing build config: - **Legacy `scikit-build`** (`build-backend = "setuptools.build_meta"`, has `setup.py`): `hello-pure`, `hello-cpp`, `hello-cython`, `hello-pybind11`, `pen2-cython`, `tower-of-babel`. Build requires include `scikit-build`, `cmake`, `ninja` in `[build-system].requires`. Metadata lives in `setup.py`. -- **Modern `scikit-build-core`** (`build-backend = "scikit_build_core.build"`, pyproject-only, no `setup.py`): `core-c-hello`, `core-pybind11-hello`, `hello-free-threading`, `pi-fortran`, `hello-cmake-package`. Metadata lives in `[project]`; configure via `[tool.scikit-build]`. +- **Modern `scikit-build-core`** (`build-backend = "scikit_build_core.build"`, pyproject-only, no `setup.py`): `core-c-hello`, `core-cython-hello`, `core-pybind11-hello`, `core-nanobind-shared`, `hello-free-threading`, `pi-fortran`, `hello-cmake-package`. Metadata lives in `[project]`; configure via `[tool.scikit-build]`. - **Hatchling + scikit-build-core plugin** (`build-backend = "hatchling.build"`): `hatchling-pybind11-hello`, using `[tool.hatch.build.targets.wheel.hooks.scikit-build]`. -Language bindings vary per project: pure Python, raw C/C++, pybind11, Cython, Fortran (`pi-fortran`). `tower-of-babel` is the broadest, exercising Boost + Cython static/shared linkage. +Language bindings vary per project: pure Python, raw C/C++, pybind11, nanobind (`core-nanobind-shared`, which also demonstrates linking to a shared library shipped in the wheel), Cython, Fortran (`pi-fortran`). `tower-of-babel` is the broadest, exercising Boost + Cython static/shared linkage. ## Commands @@ -32,7 +32,7 @@ Development is driven by nox (`noxfile.py`). Use `uvx nox` (uv is the assumed ru - `hello_list` (tested by `test`): the four `hello-*` setuptools projects, plus `hello-cmake-package` and `pi-fortran` (**non-Windows only**), plus `hello-free-threading` (**Python 3.13+ only**). - `long_hello_list` (built by `dist`): `hello_list` plus `pen2-cython`, `core-c-hello`, `core-pybind11-hello`, `hatchling-pybind11-hello`. -- `hello-free-threading` is wheel-built separately via cibuildwheel in CI (`free-threading` job), not through nox. +- `hello-free-threading` and `core-nanobind-shared` are also wheel-built and tested via cibuildwheel in CI (`cibuildwheel` job); `hello-free-threading` only through that job, not nox. A new project is invisible to CI until added to the appropriate list here. diff --git a/noxfile.py b/noxfile.py index 2c950bb..3efe108 100644 --- a/noxfile.py +++ b/noxfile.py @@ -11,6 +11,7 @@ "hello-pybind11", "hello-cython", "core-cython-hello", + "core-nanobind-shared", "hello-cmake-package", "pi-fortran", ] diff --git a/projects/core-nanobind-shared/CMakeLists.txt b/projects/core-nanobind-shared/CMakeLists.txt new file mode 100644 index 0000000..ff49650 --- /dev/null +++ b/projects/core-nanobind-shared/CMakeLists.txt @@ -0,0 +1,41 @@ +cmake_minimum_required(VERSION 3.15...3.30) + +project(${SKBUILD_PROJECT_NAME} VERSION ${SKBUILD_PROJECT_VERSION} LANGUAGES CXX) + +find_package( + Python + COMPONENTS Interpreter Development.Module + REQUIRED) +find_package(nanobind CONFIG REQUIRED) + +# An ordinary C++ shared library that knows nothing about Python. In a real +# project this could be a pre-existing library or an imported target. +add_library(say_hello SHARED cpp/hello.cpp) +target_include_directories(say_hello PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/cpp") +# MSVC exports nothing by default; real projects usually use +# generate_export_header() and explicit __declspec macros instead. +set_target_properties(say_hello PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) + +# The Python extension module, linked to the shared library like any other +# CMake consumer. +nanobind_add_module(_hello src/bindings.cpp) +target_link_libraries(_hello PRIVATE say_hello) + +# Both binaries are installed side by side into the "hello" package, so the +# extension must look next to itself for the library at import time. Windows +# has no RPATH; there the DLL is found because it sits next to the .pyd. +if(APPLE) + set_target_properties(_hello PROPERTIES INSTALL_RPATH "@loader_path") +elseif(UNIX) + set_target_properties(_hello PROPERTIES INSTALL_RPATH "$ORIGIN") +endif() + +# Destinations are relative to the root of the wheel (platlib). LIBRARY covers +# the extension and the .so/.dylib, RUNTIME covers the .dll on Windows, and +# the MSVC import library (ARCHIVE) is a build-time artifact that doesn't +# belong in the wheel. +install( + TARGETS _hello say_hello + LIBRARY DESTINATION hello + RUNTIME DESTINATION hello + ARCHIVE DESTINATION hello EXCLUDE_FROM_ALL) diff --git a/projects/core-nanobind-shared/README.md b/projects/core-nanobind-shared/README.md new file mode 100644 index 0000000..9d67a48 --- /dev/null +++ b/projects/core-nanobind-shared/README.md @@ -0,0 +1,40 @@ +# core-nanobind-shared + +A minimal [scikit-build-core](https://scikit-build-core.readthedocs.io) + +[nanobind](https://nanobind.readthedocs.io) project where the extension module +links against a plain C++ **shared** library that ships inside the wheel. +Requested in +[scikit-build-core#1034](https://github.com/scikit-build/scikit-build-core/issues/1034). + +## Layout + +- `cpp/`: an ordinary C++ shared library (`say_hello`) with no Python + knowledge. Stand-in for any existing `add_library(... SHARED ...)` target or + third-party library you build yourself. +- `src/bindings.cpp`: the nanobind module `_hello`, which just links to the + library with `target_link_libraries`. +- `src/hello/`: the pure Python package; scikit-build-core picks it up + automatically because it matches the project name. + +## The parts that trip people up + +- **Install with relative destinations.** `install(TARGETS ... DESTINATION + hello)` puts both binaries inside the `hello` package in the wheel. Never + use `${CMAKE_INSTALL_PREFIX}` in destinations; scikit-build-core installs + into a staging directory that becomes the wheel's platlib root. +- **RPATH.** At import time the extension must find the shared library next to + itself, so its `INSTALL_RPATH` is set to `$ORIGIN` (Linux) or `@loader_path` + (macOS). On macOS this works together with the default `@rpath/...` install + name CMake gives shared libraries. +- **Windows.** There is no RPATH; the DLL is found because CPython loads + extension modules with their own directory on the DLL search path, so + installing the DLL next to the `.pyd` is enough (`RUNTIME DESTINATION`). + The MSVC import library (`.lib`, the `ARCHIVE` artifact) is only needed at + build time and is excluded from the wheel. +- **No `SOVERSION`/`VERSION` on the library.** Versioned shared libraries are + installed as symlinks, but wheels cannot contain symlinks — you would ship + multiple full copies of the library. + +If you link against libraries you do *not* ship in the wheel, this RPATH setup +does not apply; use `auditwheel` (Linux) / `delocate` (macOS) / +`delvewheel` (Windows) to vendor them into repaired wheels instead. diff --git a/projects/core-nanobind-shared/cpp/hello.cpp b/projects/core-nanobind-shared/cpp/hello.cpp new file mode 100644 index 0000000..ae39c51 --- /dev/null +++ b/projects/core-nanobind-shared/cpp/hello.cpp @@ -0,0 +1,5 @@ +#include "hello.hpp" + +int add(int a, int b) { return a + b; } + +std::string greet(const std::string &name) { return "Hello, " + name + "!"; } diff --git a/projects/core-nanobind-shared/cpp/hello.hpp b/projects/core-nanobind-shared/cpp/hello.hpp new file mode 100644 index 0000000..7e3f840 --- /dev/null +++ b/projects/core-nanobind-shared/cpp/hello.hpp @@ -0,0 +1,6 @@ +#pragma once + +#include + +int add(int a, int b); +std::string greet(const std::string &name); diff --git a/projects/core-nanobind-shared/pyproject.toml b/projects/core-nanobind-shared/pyproject.toml new file mode 100644 index 0000000..6a2bfbd --- /dev/null +++ b/projects/core-nanobind-shared/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["scikit-build-core", "nanobind"] +build-backend = "scikit_build_core.build" + +[project] +name = "hello" +version = "1.2.3" +authors = [ + { name = "The scikit-build team" }, +] +description = "a minimal example package (nanobind + shared library version)" +requires-python = ">=3.9" +classifiers = [ + "License :: OSI Approved :: MIT License", +] + +# One version is enough here; the point is exercising the wheel-repair step +# (auditwheel/delocate) over the shipped shared library. +[tool.cibuildwheel] +build-frontend = "build[uv]" +build = "cp312-*" +archs = ["auto64"] +test-command = "pytest {package}" +test-requires = ["pytest"] diff --git a/projects/core-nanobind-shared/src/bindings.cpp b/projects/core-nanobind-shared/src/bindings.cpp new file mode 100644 index 0000000..9e8371e --- /dev/null +++ b/projects/core-nanobind-shared/src/bindings.cpp @@ -0,0 +1,12 @@ +#include +#include + +#include "hello.hpp" + +namespace nb = nanobind; + +NB_MODULE(_hello, m) { + m.doc() = "nanobind bindings for the say_hello shared library"; + m.def("add", &add, nb::arg("a"), nb::arg("b"), "Add two integers"); + m.def("greet", &greet, nb::arg("name"), "Greet somebody by name"); +} diff --git a/projects/core-nanobind-shared/src/hello/__init__.py b/projects/core-nanobind-shared/src/hello/__init__.py new file mode 100644 index 0000000..48c381a --- /dev/null +++ b/projects/core-nanobind-shared/src/hello/__init__.py @@ -0,0 +1,3 @@ +from ._hello import add, greet + +__all__ = ["add", "greet"] diff --git a/projects/core-nanobind-shared/tests/test_hello.py b/projects/core-nanobind-shared/tests/test_hello.py new file mode 100644 index 0000000..a6d1001 --- /dev/null +++ b/projects/core-nanobind-shared/tests/test_hello.py @@ -0,0 +1,9 @@ +import hello + + +def test_add(): + assert hello.add(2, 3) == 5 + + +def test_greet(): + assert hello.greet("World") == "Hello, World!"