This repository is a practical template for building a Python package with C++ extensions powered by nanobind.
It demonstrates how to:
- organize a Python package using the src layout
- configure packaging and native builds with pyproject.toml and scikit-build-core
- manage dependencies and developer workflows with pixi
- structure tests (unit, integration, end-to-end)
- use Ruff for linting and formatting
- expose C++ code to Python with nanobind
Use this repository as a starting point for hybrid Python/C++ packages where you need:
- clean project structure
- reproducible environments
- native extension builds through CMake
- testing and code-quality foundations
The project follows the src layout, where importable Python code lives in src/bind_template.
bind_template/
├── CMakeLists.txt
├── pixi.toml
├── pyproject.toml
├── src/
│ ├── bind_template/
│ │ ├── __init__.py
│ │ ├── example.py
│ │ ├── ndarray/
│ │ └── scalar/
│ └── cpp/
│ ├── bindings/
│ │ └── bind_process_scalar.cpp
│ └── lib/
│ ├── process_scalar.cpp
│ ├── process_scalar.h
│ └── process_ndarray.h
└── tests/
├── unit/
├── integration/
└── e2e/
Why this layout is useful:
- it keeps Python and C++ sources clearly separated
- it keeps test and source clearly separated
Pixi is a package manager that simplifies dependency management for different development scenarios. pixi.toml defines dependencies and workflow commands using features and environments.
-
Isolation: Keep development tools (like ruff, pytest) separate from production runtime dependencies. This avoids bloating your installed package with unnecessary build tooling.
-
Reproducibility: The pixi.lock file pins all transitive dependencies to exact versions. Every team member, CI/CD pipeline, and deployment gets identical environments.
-
Workflow automation: Define named tasks in pixi.toml and run them in the correct environment context. No need to remember complex shell command chains.
-
Multiple use cases: Switch between environments for different work without reinstalling.
pixi.toml organizes dependencies as features, then combines them into named environments:
Features defined in the project:
- runtime feature: python, nanobind, numpy, scipy, pip, ipython
- test feature: pytest
- lint feature: ruff
NOTE:
pixi add cmakecan returns an error (Filename too long (os error 36)) depending on the file system.if you don't have cmake installed, try with pixi else use system installation
Tasks are defined per-feature:
[feature.test.tasks]
install = "pip install -e . "
tests_unit = "pytest -v tests/unit"Environments combine features:
[environments]
default = { features = ["runtime"] }
quality = { features = ["runtime", "test", "lint"] }Pattern 1: Daily development (minimal environment)
pixi shell
# You now have: python, nanobind, numpy, scipy, pip, ipython
# NOT pytest or ruff—keep your environment lean
pip install -e .
python -c "from bind_template.scalar.process_scalar import ProcessScalar; print(ProcessScalar().add(1, 2))"Pattern 2: Testing and code review (full development environment)
# Run predefined tasks in the quality environment
pixi run -e quality install
pixi run -e quality tests_unit
# Or interactively work in quality environment
pixi shell -e quality
ruff check .
ruff format .
pytest -v tests/unit- For solo developers: One command to switch tooling context (pixi shell -e quality).
- For teams: Onboarding is trivial—pixi shell and you're ready to code.
- For CI/CD: Deterministic builds thanks to pixi.lock; no "works on my machine" surprises.
- For users: Smaller install footprints; production never includes dev tools.
The file pyproject.toml defines both package metadata and the native build backend.
- build backend: scikit-build-core
- C++ binding framework: nanobind
- package metadata: project name, version, authors, Python requirement
At build time:
- scikit-build-core drives CMake.
- CMake compiles the nanobind extension module.
- The compiled shared library is installed into the Python package path.
This project currently builds the process_scalar extension and installs it into bind_template/scalar.
# install the package in editable mode
pip install -e .
# uninstall the package
pip uninstall bind_templateDistribution
If the package is public, you can install with url of repository with prefix git+
$ pip install git+https://github.com/luckyjim/bind_template.git
Collecting git+https://github.com/luckyjim/bind_template.git
Cloning https://github.com/luckyjim/bind_template.git
...
Successfully built bind-template
Installing collected packages: bind-template
Successfully installed bind-template-0.0.1The repository is organized around three test levels:
- unit tests: validate isolated functions/classes (fast feedback)
- integration tests: validate interactions between components
- end-to-end tests: validate complete user-facing workflows
If you are interested in software testing, you can consult the International Software Testing Qualifications Board (ISTQB) courses, like Certified Tester Foundation Level as well as the definitions of terms related to software testing.
Current automated examples include unit tests for the C++-backed ProcessScalar class.
Pytest basics used in this project:
- test module names should use the test_ prefix (for example: test_ProcessScalar.py)
- test function names should use the test_ prefix
- use assert for scalar values and simple conditions
- use numpy.allclose() when comparing two floating-point arrays
Example:
import numpy as np
def test_addition():
assert 1 + 2 == 3
def test_array_values():
expected = np.array([0.1, 0.2, 0.3])
actual = np.array([0.10000001, 0.19999999, 0.30000002])
assert np.allclose(actual, expected)Consult the pytest doc to discover all amazing possibility of this library.
Ruff is the primary code-quality tool in this project.
- linter: detects style and correctness issues
- formatter: enforces a consistent code style
- auto-fix: can automatically resolve a subset of issues
Typical commands:
ruff check .
ruff format .nanobind allows calling C++ code from Python.
- C++ implementation files are in src/cpp/lib
- nanobind binding code is in src/cpp/bindings
Three key points in CMakeLists.txt:
- Find nanobind:
find_package(nanobind CONFIG REQUIRED)- Build the extension module:
nanobind_add_module(process_scalar
STABLE_ABI
src/cpp/bindings/bind_process_scalar.cpp
src/cpp/lib/process_scalar.cpp
)- Install the shared library into the Python package:
install(TARGETS process_scalar LIBRARY DESTINATION bind_template/scalar)The generated shared object (.so on Linux) is then importable from Python as a regular module.
This code defines a process_scalar module exposing one class, ProcessScalar.
#include <nanobind/nanobind.h>
#include "process_scalar.h"
namespace nb = nanobind;
NB_MODULE(process_scalar, m)
{
m.doc() = "A C++ class binding by nanobind";
nb::class_<ProcessScalar>(m, "ProcessScalar","A simple class from C++")
.def(nb::init<>())
.def("add", &ProcessScalar::add)
.def("multiply", &ProcessScalar::multiply);
}- NB_MODULE(process_scalar, m) Defines the Python extension module.
- m.doc() = "A C++ class binding by nanobind"; Sets the module-level docstring.
- nb::class_(m, ...) Declares a Python-visible class backed by the C++ ProcessScalar type.
- .def("add", &ProcessScalar::add) Binds a Python method name to a C++ member function.
The complete nanobind documentation is available here.
Development/debug mode
In the default pixi environment, you can run CMake directly:
$ cmake -S . -B build -Dnanobind_DIR=$(python -m nanobind --cmake_dir)
$ cmake --build build
$ cmake --build build --target cleanInstallation
Installation is performed with pip install . thanks to the build system configured in pyproject.toml:
[build-system]
requires = ["scikit-build-core >=0.4.3", "nanobind >=1.3.2"]
build-backend = "scikit_build_core.build"from bind_template.scalar.process_scalar import ProcessScalar
obj = ProcessScalar()
print(obj.add(1, 2))
print(obj.multiply(3, 4))Expected output:
3
12
File exo.md contents two kinds of exercice :
- pixi for quality tool
- use of nanobind
Correction in branches
- pixi_solution
- ndarray_solution

