# nanobind + scikit-build-core + cibuildwheel Best Practices

> Research for pixmask: C++ library with Python bindings targeting image sanitization.
> All patterns verified against wjakob/nanobind_example (canonical reference) and official docs as of March 2026.

---

## 1. Project Structure

Canonical layout matching wjakob/nanobind_example:

```
pixmask/
├── CMakeLists.txt
├── pyproject.toml
├── src/
│   ├── pixmask_ext.cpp          # NB_MODULE entry point
│   └── pixmask/                 # Python package
│       ├── __init__.py
│       ├── __init__.pyi         # hand-written or generated stub
│       └── py.typed             # PEP 561 marker (generated by CMake)
├── src/cpp/
│   ├── include/
│   │   └── fsq/                 # existing headers
│   └── bindings/
│       └── module.cpp           # existing binding file
├── tests/
│   ├── test_api.py              # pytest: Python API surface
│   └── cpp/
│       └── test_filters.cpp     # Catch2: pure C++ unit tests
├── .github/
│   └── workflows/
│       └── wheels.yml
└── research/
```

Key decisions:
- Single `NB_MODULE` entry point; all submodules registered from one `.cpp` file.
- `src/` layout prevents accidental import of unbuilt package from repo root.
- `py.typed` generated at CMake install time enables downstream type checking.

---

## 2. nanobind CMakeLists.txt

### Minimal complete example

```cmake
cmake_minimum_required(VERSION 3.15...3.27)

project(${SKBUILD_PROJECT_NAME} LANGUAGES CXX)

# Guard against direct cmake invocation
if(NOT SKBUILD)
  message(WARNING
    "This CMake file is meant to be used via scikit-build-core.\n"
    "For development: pip install --no-build-isolation -ve .")
endif()

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Python: SABIModule is optional (only used with STABLE_ABI)
find_package(Python 3.9
  REQUIRED COMPONENTS Interpreter Development.Module
  OPTIONAL_COMPONENTS Development.SABIModule)

# Locate nanobind from pip-installed package
find_package(nanobind CONFIG REQUIRED)

# --- Core C++ library (header-only or compiled) ---
# If pixmask has compiled C++ sources, build a static lib to link against:
add_library(pixmask_core STATIC
  src/cpp/filters/median.cpp
  src/cpp/filters/gaussian.cpp
)
target_include_directories(pixmask_core PUBLIC src/cpp/include)
target_compile_options(pixmask_core PRIVATE -O3)

# --- Python extension module ---
nanobind_add_module(
  pixmask_ext        # extension name: imported as `pixmask._pixmask_ext`

  STABLE_ABI         # target Python stable ABI (3.12+); no-op on older pythons
  NB_STATIC          # link libnanobind statically into this .so (simpler redistribution)
                     # Use NB_SHARED if building multiple extensions in one package
  LTO                # link-time optimization in release builds
  NB_DOMAIN pixmask  # restrict inter-extension type visibility to "pixmask" namespace

  src/pixmask_ext.cpp
)

target_include_directories(pixmask_ext PRIVATE src/cpp/include)
target_link_libraries(pixmask_ext PRIVATE pixmask_core)

# Install the extension into the Python package directory
install(TARGETS pixmask_ext LIBRARY DESTINATION pixmask)

# --- Type stubs (generated at install time) ---
# INSTALL_TIME avoids trying to import the uninstalled extension during build
nanobind_add_stub(
  pixmask_ext_stub
  INSTALL_TIME
  MODULE pixmask_ext
  OUTPUT pixmask_ext.pyi
  PYTHON_PATH "."          # relative to CMAKE_INSTALL_PREFIX at install time
  MARKER_FILE py.typed     # generate PEP 561 py.typed marker
)
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/py.typed" DESTINATION pixmask)
```

### nanobind_add_module flags reference

| Flag | Effect |
|------|--------|
| `STABLE_ABI` | Builds `.abi3.so` for CPython 3.12+; wheel is reusable across minor versions |
| `NB_STATIC` | Links `libnanobind` statically into the `.so`; default; good for single-extension packages |
| `NB_SHARED` | Links `libnanobind` as a shared lib; use when package has multiple extensions to save space |
| `LTO` | Link-time optimization in Release builds; reduces binary size and improves inlining |
| `NOMINSIZE` | Disable `-Os` size optimization; use when profiling shows it hurts perf |
| `NB_DOMAIN <name>` | Restrict type sharing to named domain; prevents type leakage between unrelated packages |
| `FREE_THREADED` | Target Python 3.13+ free-threaded (GIL-less) builds; cannot combine with STABLE_ABI |
| `NOSTRIP` | Keep debug symbols; useful for profiling builds |
| `MUSL_DYNAMIC_LIBCPP` | Prevent static libstdc++/libgcc on musllinux; needed for some distros |

---

## 3. C++ Bindings: Class and Function Exposure

### Module entry point (`src/pixmask_ext.cpp`)

```cpp
#include <nanobind/nanobind.h>
#include <nanobind/ndarray.h>
#include <nanobind/stl/string.h>
#include <nanobind/stl/vector.h>
#include <nanobind/stl/optional.h>

#include "fsq/pipeline.hpp"
#include "fsq/filters/median.hpp"

namespace nb = nanobind;
using namespace nb::literals;

// Forward declarations
void bind_filters(nb::module_& m);
void bind_pipeline(nb::module_& m);

NB_MODULE(pixmask_ext, m) {
    m.doc() = "pixmask C++ extension: fast image sanitization";

    // Submodules
    auto m_filters = m.def_submodule("filters", "Image filter operations");
    auto m_quant   = m.def_submodule("quant",   "Quantization utilities");

    bind_filters(m_filters);
    bind_pipeline(m);
}
```

### Exposing a C++ class

```cpp
// In bind_filters.cpp
void bind_filters(nb::module_& m) {
    nb::class_<fsq::MedianFilter>(m, "MedianFilter")
        .def(nb::init<int>(), "kernel_size"_a = 3,
             "Construct median filter with given kernel size.")
        .def("apply",
             [](const fsq::MedianFilter& f,
                nb::ndarray<uint8_t, nb::shape<-1,-1,3>,
                            nb::device::cpu, nb::c_contig> img) {
                 // zero-copy: img.data() is the numpy buffer
                 return f.apply_view(img.data(),
                                     img.shape(0), img.shape(1));
             },
             "image"_a,
             "Apply filter. Accepts HxWx3 uint8 numpy array. Returns new array.")
        .def_prop_ro("kernel_size", &fsq::MedianFilter::kernel_size);
}
```

---

## 4. Zero-Copy NumPy Array Interop (`nb::ndarray`)

### Accepting arrays (input)

```cpp
#include <nanobind/ndarray.h>

// Constrained: must be C-contiguous HxWx3 uint8 on CPU
void process_rgb(
    nb::ndarray<uint8_t,
                nb::shape<-1, -1, 3>,   // -1 = any size
                nb::device::cpu,
                nb::c_contig> img)       // enforce row-major
{
    size_t H = img.shape(0), W = img.shape(1);
    uint8_t* data = img.data();          // direct pointer, zero-copy
    // ... modify in-place or read
}

// Unconstrained: any array, inspect at runtime
void process_any(nb::ndarray<> img) {
    if (img.dtype() != nb::dtype<uint8_t>())
        throw std::runtime_error("expected uint8");
}
```

### Returning arrays (zero-copy from C++ heap)

```cpp
// Pattern 1: capsule owns the heap allocation
nb::ndarray<nb::numpy, uint8_t, nb::ndim<3>>
make_blank_image(size_t H, size_t W) {
    uint8_t* buf = new uint8_t[H * W * 3]();

    // capsule deleter runs when numpy array is GC'd
    nb::capsule owner(buf, [](void* p) noexcept {
        delete[] static_cast<uint8_t*>(p);
    });

    return nb::ndarray<nb::numpy, uint8_t, nb::ndim<3>>(
        buf, {H, W, 3}, owner);
}

// Pattern 2: reference existing C++ object memory
// Use rv_policy::reference_internal so Python keeps C++ object alive
nb::class_<ImageBuffer>(m, "ImageBuffer")
    .def("as_numpy",
         [](ImageBuffer& self) {
             return nb::ndarray<nb::numpy, uint8_t, nb::ndim<3>>(
                 self.data(), {self.height(), self.width(), 3});
         },
         nb::rv_policy::reference_internal);  // self is the owner
```

### Performance: array views for inner loops

```cpp
// Without view: every element access goes through virtual dispatch
// With view: compile-time-known dtype/ndim enables direct pointer arithmetic
auto v = img.view<uint8_t, nb::ndim<3>>();
for (size_t i = 0; i < v.shape(0); ++i)
    for (size_t j = 0; j < v.shape(1); ++j)
        for (size_t k = 0; k < v.shape(2); ++k)
            v(i, j, k) = process(v(i, j, k));
```

### Template parameter reference

| Parameter | Options |
|-----------|---------|
| Dtype | `uint8_t`, `float`, `double`, `int32_t`, `std::complex<float>`, ... |
| Shape | `nb::shape<H, W, C>` (use `-1` for dynamic), or `nb::ndim<N>` |
| Device | `nb::device::cpu`, `nb::device::cuda` |
| Order | `nb::c_contig` (row-major), `nb::f_contig` (col-major), `nb::any_contig` |
| Framework | `nb::numpy`, `nb::pytorch`, `nb::tensorflow`, `nb::jax`, `nb::cupy` |
| Constness | `const T` or `nb::ro` (accept any read-only type) |

---

## 5. Type Stub Generation

### Build-time stubs (when extension can be imported during build)

```cmake
nanobind_add_stub(
  pixmask_ext_stub
  MODULE pixmask_ext
  OUTPUT pixmask/pixmask_ext.pyi
  PYTHON_PATH $<TARGET_FILE_DIR:pixmask_ext>
  DEPENDS pixmask_ext
  MARKER_FILE pixmask/py.typed
  VERBOSE
)
```

### Install-time stubs (safer for scikit-build-core packaging)

```cmake
install(TARGETS pixmask_ext DESTINATION pixmask)

nanobind_add_stub(
  pixmask_ext_stub
  INSTALL_TIME                  # runs during `cmake --install`, not `cmake --build`
  MODULE pixmask_ext
  OUTPUT pixmask/pixmask_ext.pyi
  PYTHON_PATH "."               # relative to install prefix
  MARKER_FILE pixmask/py.typed
)
```

For INSTALL_TIME, do NOT set `DEPENDS` (the target isn't built yet at install scheduling time).

### Shipping stubs in the wheel

scikit-build-core picks up all `install()` directives. The `.pyi` and `py.typed` files installed to `pixmask/` are automatically included in the wheel if they fall under `wheel.install-dir`.

```toml
# pyproject.toml - ensure stubs are in wheel
[tool.scikit-build]
wheel.packages = ["src/pixmask"]
# or explicitly:
# wheel.install-dir = "pixmask"
```

---

## 6. Stable ABI for Python 3.12+

The stable ABI produces a single `.abi3.so` wheel suffix valid for all CPython 3.12, 3.13, 3.14+. This halves the number of wheel files to build.

**Requirements:**
1. `STABLE_ABI` flag in `nanobind_add_module()`
2. `Development.SABIModule` as OPTIONAL CMake component
3. `wheel.py-api = "cp312"` in pyproject.toml

```toml
[tool.scikit-build]
wheel.py-api = "cp312"   # builds abi3 wheel; ignored for PyPy/free-threaded
```

**Limitations:**
- No stable ABI for free-threaded Python 3.13+. `STABLE_ABI` is silently ignored when building for free-threaded.
- `STABLE_ABI` and `FREE_THREADED` can coexist in one CMakeLists.txt — CMake chooses at build time.

---

## 7. pyproject.toml (scikit-build-core)

### Static version (simplest)

```toml
[build-system]
requires = ["scikit-build-core>=0.10", "nanobind>=2.0.0"]
build-backend = "scikit_build_core.build"

[project]
name = "pixmask"
version = "0.1.0"
description = "Fast image sanitization for multimodal LLMs"
readme = "README.md"
requires-python = ">=3.9"
license = { file = "LICENSE" }
classifiers = [
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: C++",
]

[tool.scikit-build]
# Lock config format against future scikit-build-core changes
minimum-version = "build-system.requires"

# Per-wheel_tag build cache (avoids rebuilding when switching Python versions)
build-dir = "build/{wheel_tag}"

# Stable ABI wheels for CPython 3.12+ (one .abi3.so serves all future CPython versions)
wheel.py-api = "cp312"

# Explicit package listing (avoids accidentally picking up test files)
wheel.packages = ["src/pixmask"]
```

### Dynamic version via setuptools-scm

```toml
[build-system]
requires = [
    "scikit-build-core>=0.10",
    "nanobind>=2.0.0",
    "setuptools-scm>=8",
]
build-backend = "scikit_build_core.build"

[project]
name = "pixmask"
dynamic = ["version"]

[tool.scikit-build]
minimum-version = "build-system.requires"
build-dir = "build/{wheel_tag}"
wheel.py-api = "cp312"
# Tell scikit-build-core to use the setuptools-scm plugin for version
metadata.version.provider = "scikit_build_core.metadata.setuptools_scm"
# Include generated _version.py in sdist (needed when building from sdist without git)
sdist.include = ["src/pixmask/_version.py"]

[tool.setuptools_scm]
write_to = "src/pixmask/_version.py"
# Fallback when not in a git repo (e.g. CI building from sdist)
fallback_version = "0.0.0+unknown"
```

Then in `src/pixmask/__init__.py`:
```python
from pixmask._version import version as __version__
```

### Per-architecture CMake overrides (for SIMD)

```toml
[[tool.scikit-build.overrides]]
if.platform-machine = "^x86_64$"
cmake.args = ["-DPIXMASK_ARCH=x86_64"]

[[tool.scikit-build.overrides]]
if.platform-machine = "^aarch64$"
cmake.args = ["-DPIXMASK_ARCH=aarch64"]

[[tool.scikit-build.overrides]]
if.platform-machine = "^arm64$"   # macOS M-series reports arm64
cmake.args = ["-DPIXMASK_ARCH=arm64"]
```

In CMakeLists.txt, translate the arch variable to compiler flags:
```cmake
if(PIXMASK_ARCH STREQUAL "x86_64")
    target_compile_options(pixmask_core PRIVATE -mavx2 -mfma)
elseif(PIXMASK_ARCH MATCHES "aarch64|arm64")
    target_compile_options(pixmask_core PRIVATE -march=armv8-a+simd)
endif()
```

### cibuildwheel configuration (in same pyproject.toml)

```toml
[tool.cibuildwheel]
build-verbosity = 1
test-requires = ["pytest", "numpy"]
test-command = "pytest {project}/tests -x -q"

# macOS: full C++17 support requires 10.14+
[tool.cibuildwheel.macos.environment]
MACOSX_DEPLOYMENT_TARGET = "10.14"
```

---

## 8. GitHub Actions Workflow (cibuildwheel)

```yaml
# .github/workflows/wheels.yml
name: Wheels

on:
  workflow_dispatch:
  pull_request:
  push:
    branches: [main]
  release:
    types: [published]

jobs:
  build_sdist:
    name: Build SDist
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0          # needed for setuptools-scm version detection

      - name: Build SDist
        run: pipx run build --sdist

      - name: Validate metadata
        run: pipx run twine check dist/*

      - uses: actions/upload-artifact@v4
        with:
          name: dist-sdist
          path: dist/*.tar.gz

  build_wheels:
    name: Wheels on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-13, macos-latest, windows-latest]
        # macos-13 = Intel x86_64; macos-latest = Apple Silicon arm64

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # QEMU enables aarch64/ppc64le/s390x Linux builds via emulation
      - name: Set up QEMU
        if: runner.os == 'Linux'
        uses: docker/setup-qemu-action@v3
        with:
          platforms: all

      - uses: pypa/cibuildwheel@v2.22
        env:
          # Build aarch64 in addition to native x86_64 on Linux runners
          CIBW_ARCHS_LINUX: "auto aarch64"
          # macOS: auto detects the runner's native arch (arm64 or x86_64)
          CIBW_ARCHS_MACOS: "auto"
          # Windows: x86_64 only (arm64 Windows has poor ecosystem support)
          CIBW_ARCHS_WINDOWS: "auto64"
          # Skip PyPy (not a target) and 32-bit builds
          CIBW_SKIP: "pp* *-win32 *-manylinux_i686"

      - name: Verify no uncommitted changes
        run: git diff --exit-code
        shell: bash

      - uses: actions/upload-artifact@v4
        with:
          name: dist-${{ matrix.os }}
          path: wheelhouse/*.whl

  upload_to_pypi:
    name: Upload to PyPI
    needs: [build_wheels, build_sdist]
    runs-on: ubuntu-latest
    if: github.event_name == 'release' && github.event.action == 'published'
    environment:
      name: pypi
      url: https://pypi.org/p/pixmask

    permissions:
      id-token: write     # required for PyPI Trusted Publishing (no token needed)

    steps:
      - uses: actions/download-artifact@v4
        with:
          path: dist
          pattern: dist-*
          merge-multiple: true

      - uses: pypa/gh-action-pypi-publish@release/v1
        # No `password:` needed with Trusted Publishing configured on PyPI
```

### SIMD flags per arch via cibuildwheel overrides

```toml
# In pyproject.toml
[[tool.cibuildwheel.overrides]]
select = "*-manylinux_x86_64 *-musllinux_x86_64"
environment = { CMAKE_ARGS = "-DPIXMASK_ARCH=x86_64" }

[[tool.cibuildwheel.overrides]]
select = "*-manylinux_aarch64 *-musllinux_aarch64"
environment = { CMAKE_ARGS = "-DPIXMASK_ARCH=aarch64" }

[[tool.cibuildwheel.overrides]]
select = "*-macosx_arm64"
environment.CMAKE_ARGS = "-DPIXMASK_ARCH=arm64"
inherit.environment = "append"
```

---

## 9. Testing Infrastructure

### pytest for Python API (`tests/test_api.py`)

```python
import numpy as np
import pytest
import pixmask


@pytest.fixture
def rgb_image():
    """256x256 random uint8 HxWx3 image."""
    rng = np.random.default_rng(42)
    return rng.integers(0, 256, (256, 256, 3), dtype=np.uint8)


def test_median_filter_output_shape(rgb_image):
    result = pixmask.filters.MedianFilter(kernel_size=3).apply(rgb_image)
    assert result.shape == rgb_image.shape
    assert result.dtype == np.uint8


def test_zero_copy_no_extra_allocation(rgb_image):
    # Verify the returned array shares or is derived from input memory
    f = pixmask.filters.MedianFilter(kernel_size=1)  # identity at k=1
    result = f.apply(rgb_image)
    assert np.array_equal(result, rgb_image)


def test_rejects_wrong_dtype():
    bad = np.zeros((10, 10, 3), dtype=np.float32)
    with pytest.raises(TypeError):
        pixmask.filters.MedianFilter().apply(bad)
```

### Catch2 for C++ unit tests (`tests/cpp/test_filters.cpp`)

```cpp
#define CATCH_CONFIG_MAIN
#include <catch2/catch_test_macros.hpp>
#include "fsq/filters/median.hpp"

TEST_CASE("MedianFilter identity on constant image", "[median]") {
    std::vector<uint8_t> img(10 * 10 * 3, 128);
    fsq::MedianFilter f(3);
    auto result = f.apply(img.data(), 10, 10);
    REQUIRE(result == img);
}

TEST_CASE("MedianFilter rejects even kernel size", "[median]") {
    REQUIRE_THROWS_AS(fsq::MedianFilter(4), std::invalid_argument);
}
```

CMake for Catch2 tests:

```cmake
# In CMakeLists.txt (gated so it only runs in dev, not packaging)
if(BUILD_TESTING AND NOT SKBUILD)
    include(CTest)
    find_package(Catch2 3 REQUIRED)

    add_executable(test_filters tests/cpp/test_filters.cpp)
    target_link_libraries(test_filters PRIVATE pixmask_core Catch2::Catch2WithMain)

    include(Catch)
    catch_discover_tests(test_filters)
endif()
```

Install Catch2 for local dev:
```bash
vcpkg install catch2
# or
cmake -DCMAKE_TOOLCHAIN_FILE=... -DBUILD_TESTING=ON ..
```

### Running both test suites locally

```bash
# Python API tests (after pip install -e .)
pytest tests/ -x -q

# C++ unit tests
cmake -S . -B build -DBUILD_TESTING=ON -DSKBUILD=OFF
cmake --build build
ctest --test-dir build --output-on-failure
```

### CTest + pytest integration via cmake-pytest-module

For unified `ctest` run across C++ and Python tests, use `c4deszes/cmake-pytest-module`:

```cmake
# Discovers pytest tests and registers them with CTest
include(Pytest)
pytest_discover_tests(
  pytest_tests
  WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
)
```

---

## 10. Real-World Reference Projects

| Project | What to Study |
|---------|--------------|
| [wjakob/nanobind_example](https://github.com/wjakob/nanobind_example) | Canonical reference: pyproject.toml, CMakeLists.txt, wheels.yml |
| [Krande/nanobind-minimal](https://github.com/Krande/nanobind-minimal) | Minimal nanobind + scikit-build-core example |
| [faiss-wheels/faiss-wheels](https://github.com/faiss-wheels/faiss-wheels) | SIMD per-arch flags, complex cibuildwheel setup |
| [wjakob/nanobind](https://github.com/wjakob/nanobind) | Source of truth for CMake API; see `cmake/nanobind-config.cmake` |
| [scikit-build/scikit-build-core](https://github.com/scikit-build/scikit-build-core) | Override system, dynamic metadata plugins |

---

## 11. Gotchas and Risks

### nanobind

1. **NB_STATIC vs NB_SHARED**: With `NB_STATIC` (default), each `.so` embeds its own `libnanobind`. If pixmask exposes multiple extension modules (e.g., `_core.so` and `_gpu.so`), they cannot share C++ type registrations at runtime without `NB_SHARED` + `NB_DOMAIN`. For a single extension, `NB_STATIC` is always correct.

2. **INSTALL_TIME stubs and path resolution**: `PYTHON_PATH "."` in `nanobind_add_stub(INSTALL_TIME ...)` is relative to `CMAKE_INSTALL_PREFIX`. If scikit-build-core installs to an unusual prefix, stubs may not find the module. Test with `pip install --no-build-isolation -ve .` first.

3. **Overload resolution with ndarray**: nanobind makes a second pass with implicit conversions (contiguity, dtype) when no overload matches on the first pass. If two overloads differ only in constiguity, `.noconvert()` is needed to prevent incorrect resolution.

4. **FREE_THREADED + STABLE_ABI**: There is no stable ABI for free-threaded Python (as of 2026). `STABLE_ABI` is silently ignored for free-threaded builds. Track PEP 803 for future status.

### scikit-build-core

5. **`minimum-version = "build-system.requires"`**: This pins the config format version to the version in `requires`, so future scikit-build-core releases cannot silently break your config schema. Prefer this over a hardcoded version string.

6. **cmake.args override semantics**: `[[tool.scikit-build.overrides]]` with `cmake.args` **replaces** (not appends) the base `cmake.args`. If you need additive behavior, repeat all args in each override block.

7. **editable installs**: `pip install --no-build-isolation -ve .` is the fast development loop. Add `-Ceditable.rebuild=true` to auto-rebuild on import (requires scikit-build-core >= 0.5).

8. **setuptools-scm in CI**: The `build_sdist` job must use `fetch-depth: 0` in `actions/checkout` or setuptools-scm cannot read git tags. Without this, it produces version `0.0.0+unknown`.

### cibuildwheel

9. **QEMU emulation is slow**: aarch64 via QEMU on x86_64 runners takes 3-10x longer. For production CI, use native aarch64 runners (GitHub has `ubuntu-24.04-arm`) if build time matters:
   ```yaml
   matrix:
     os: [ubuntu-latest, ubuntu-24.04-arm, macos-13, macos-latest, windows-latest]
   ```
   Then set `CIBW_ARCHS: "auto"` (no QEMU needed).

10. **macOS deployment target**: Without `MACOSX_DEPLOYMENT_TARGET = "10.14"`, you may get linker warnings or missing C++17 stdlib symbols on older macOS. Set it explicitly.

11. **musllinux builds**: manylinux images use glibc; Alpine/Docker-based environments use musl. cibuildwheel builds `musllinux_1_2` wheels automatically alongside `manylinux`. If pixmask has SIMD code, `MUSL_DYNAMIC_LIBCPP` may be needed in CMake to avoid static libstdc++ issues.

12. **Wheel verification**: Always run `twine check dist/*` in the SDist job. cibuildwheel's `test-command` runs inside the container after installation — this is the only reliable way to catch missing symbols or bad rpath.

---

## 12. Recommended Adoption Path for pixmask

1. Replace empty `CMakeLists.txt` with the template from Section 2, wiring in existing `src/cpp/` sources.
2. Replace empty `pyproject.toml` with the static-version template from Section 7.
3. Wire `src/cpp/bindings/module.cpp` as the `NB_MODULE` entry, exposing types from `fsq/`.
4. Add `nb::ndarray` signatures for any filter that accepts/returns image data (Section 4).
5. Add `nanobind_add_stub(INSTALL_TIME ...)` for the `.pyi` stub.
6. Write pytest tests in `tests/` and Catch2 tests in `tests/cpp/`.
7. Replace empty `.github/workflows/wheels.yml` with the workflow from Section 8.
8. Switch to setuptools-scm dynamic versioning once the project tags its first release.

---

*Sources verified March 2026:*
- *nanobind CMake API: https://nanobind.readthedocs.io/en/latest/api_cmake.html*
- *nanobind ndarray: https://nanobind.readthedocs.io/en/latest/ndarray.html*
- *nanobind packaging: https://nanobind.readthedocs.io/en/latest/packaging.html*
- *nanobind stub generation: https://nanobind.readthedocs.io/en/latest/typing.html*
- *scikit-build-core configuration: https://scikit-build-core.readthedocs.io/en/latest/configuration/index.html*
- *scikit-build-core overrides: https://scikit-build-core.readthedocs.io/en/latest/configuration/overrides.html*
- *scikit-build-core dynamic metadata: https://scikit-build-core.readthedocs.io/en/latest/configuration/dynamic.html*
- *cibuildwheel options: https://cibuildwheel.pypa.io/en/stable/options/*
- *cibuildwheel FAQ (QEMU): https://cibuildwheel.pypa.io/en/stable/faq/*
- *wjakob/nanobind_example (canonical): https://github.com/wjakob/nanobind_example*
