Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion .github/workflows/pip.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
fail-fast: false
matrix:
platform: [windows-latest, macos-latest, ubuntu-latest]
python-version: ["3.8", "3.12"]
python-version: ["3.12"]

steps:
- uses: actions/checkout@v5
Expand All @@ -36,3 +36,55 @@ jobs:

- name: Test
run: python -m pytest

typecheck:
name: Type checking
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v5

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Build package
run: uv sync

- name: Type check with ty
run: uvx ty check

- name: Type check with pyright
run: uvx pyright

- name: Type check with mypy
run: uv run --with mypy mypy .

- name: Verify type stubs work (ty)
run: |
# This should fail - if it doesn't, stubs aren't working
if uvx ty check typecheck_smoke.py 2>&1 | grep -q "Found 2 diagnostics"; then
echo "✓ ty correctly detected type errors in smoke test"
else
echo "✗ ty did not detect expected type errors"
exit 1
fi

- name: Verify type stubs work (pyright)
run: |
# This should fail - if it doesn't, stubs aren't working
if uvx pyright typecheck_smoke.py 2>&1 | grep -q "2 errors"; then
echo "✓ pyright correctly detected type errors in smoke test"
else
echo "✗ pyright did not detect expected type errors"
exit 1
fi

- name: Verify type stubs work (mypy)
run: |
# This should fail - if it doesn't, stubs aren't working
if uv run --with mypy mypy typecheck_smoke.py 2>&1 | grep -q 'Argument 1 to "add" has incompatible type'; then
echo "✓ mypy correctly detected type errors in smoke test"
else
echo "✗ mypy did not detect expected type errors"
exit 1
fi
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ build
*.egg-info
.vscode
.vs
.venv
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12
82 changes: 66 additions & 16 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
cmake_minimum_required(VERSION 3.15...3.26)

if(CMAKE_SOURCE_DIR STREQUAL CMAKE_BINARY_DIR)
message(FATAL_ERROR "In-tree builds are not supported. Run CMake from a separate directory: cmake -B build")
endif()

project(nanobind_example LANGUAGES CXX)

# Try to import all Python components potentially needed by nanobind
find_package(Python 3.12
REQUIRED COMPONENTS Interpreter Development.Module
OPTIONAL_COMPONENTS Development.SABIModule
)

if (NOT SKBUILD)
message(WARNING "\
This CMake file is meant to be executed using 'scikit-build'. Running
Expand All @@ -17,26 +27,51 @@ if (NOT SKBUILD)
in your environment once and use the following command that avoids
a costly creation of a new virtual environment at every compilation:
=====================================================================
$ pip install nanobind scikit-build-core[pyproject]
$ pip install --no-build-isolation -ve .
$ uv sync
=====================================================================
You may optionally add -Ceditable.rebuild=true to auto-rebuild when
the package is imported. Otherwise, you need to re-run the above
after editing C++ files.")
endif()
When using uv run, the package is already rebuilt automatically if
it detects sources listed in cache-keys are updated.")

# Try to import all Python components potentially needed by nanobind
find_package(Python 3.8
REQUIRED COMPONENTS Interpreter Development.Module
OPTIONAL_COMPONENTS Development.SABIModule)
# NOTE: We allow fetching nanobind directly here for development this allows
# `cmake -B build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON` to work as
# expected and get proper language server support in editors like VSCode.

# Import nanobind through CMake's find_package mechanism
find_package(nanobind CONFIG REQUIRED)
# Extract the minimum nanobind version from pyproject.toml
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/pyproject.toml" PYPROJECT_TOML_CONTENT)
if(PYPROJECT_TOML_CONTENT MATCHES "\"nanobind *[>=]= *([^\"]+)\"")
set(NANOBIND_TAG "v${CMAKE_MATCH_1}")
else()
message(FATAL_ERROR "Could not find nanobind version in pyproject.toml")
endif()

# Fix warnings about DOWNLOAD_EXTRACT_TIMESTAMP
if(POLICY CMP0135)
cmake_policy(SET CMP0135 NEW)
endif()
include(FetchContent)
message(STATUS "Fetching nanobind (${NANOBIND_TAG})...")
FetchContent_Declare(nanobind
GIT_REPOSITORY
"https://github.com/wjakob/nanobind"
GIT_TAG
${NANOBIND_TAG}
)
FetchContent_MakeAvailable(nanobind)
else()
# Use the nanobind provided by scikit-build-core
find_package(nanobind REQUIRED)
endif()

set(PYTHON_MODULE nanobind_example)
set(EXTENSION_MODULE nanobind_example_ext)
set(EXTENSION_SOURCES
src/nanobind_example_ext.cpp
)

# We are now ready to compile the actual extension module
nanobind_add_module(
# Name of the extension
nanobind_example_ext
${EXTENSION_MODULE}

# Target the stable ABI for Python 3.12+, which reduces
# the number of binary wheels that must be built. This
Expand All @@ -52,8 +87,23 @@ nanobind_add_module(
NB_STATIC

# Source code goes here
src/nanobind_example_ext.cpp
${EXTENSION_SOURCES}
)

# Generate type stubs for the extension module
nanobind_add_stub(
${EXTENSION_MODULE}_stub
MODULE ${EXTENSION_MODULE}
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/${EXTENSION_MODULE}.pyi
PYTHON_PATH $<TARGET_FILE_DIR:${EXTENSION_MODULE}>
DEPENDS ${EXTENSION_MODULE}
)

# Install directive for scikit-build-core
install(TARGETS nanobind_example_ext LIBRARY DESTINATION nanobind_example)
# Install the extension module and type stubs
install(TARGETS ${EXTENSION_MODULE} LIBRARY DESTINATION ${PYTHON_MODULE})
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/py.typed" "")
install(FILES "${CMAKE_CURRENT_BINARY_DIR}/py.typed" DESTINATION ${PYTHON_MODULE})
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${EXTENSION_MODULE}.pyi DESTINATION ${PYTHON_MODULE})

# Install the Python package files
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/src/${PYTHON_MODULE}/__init__.py DESTINATION ${PYTHON_MODULE})
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,65 @@ interactive Python session):
3
```

Development
-----------

This project uses [uv](https://docs.astral.sh/uv/) for development.

### Building

```bash
# Build native project (installs in editable mode)
uv sync

# Rebuild native project from scratch (usually not necessary)
uv sync --reinstall

# Build and run tests
uv run pytest
```

When using `uv run` the native code will automatically be rebuilt if changes
are detected in the `cache-keys`.

### Local C++ Development

Generate `compile_commands.json` for IDE/LSP support (clangd, etc.):

```bash
cmake -B build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
```

### Type Checking

The project includes type stubs (`.pyi` files) generated automatically by
nanobind. A `py.typed` marker file is installed for
[PEP 561](https://peps.python.org/pep-0561/) compliance, so language servers
like Pylance, Pyright, and ty will provide completions and type checking.

To run type checking:

```bash
uvx ty check
uvx pyright
uv run --with mypy mypy .
```

### Package Structure

The extension module is built as `nanobind_example_ext` and re-exported through
`nanobind_example/__init__.py`. When adding new bindings:

1. Add the binding in `src/nanobind_example_ext.cpp`
2. Update `src/nanobind_example/__init__.py` to re-export the new symbols

Imports must be absolute:

```python
from nanobind_example import add # Correct
from nanobind_example_ext import add # Won't work
```

CI Examples
-----------

Expand Down
47 changes: 42 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
[build-system]
requires = ["scikit-build-core >=0.10", "nanobind >=1.3.2"]
requires = ["scikit-build-core >=0.11.6", "nanobind >=2.10.2"]
build-backend = "scikit_build_core.build"

[project]
name = "nanobind-example"
version = "0.0.1"
description = "An example minimal project that compiles bindings using nanobind and scikit-build"
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.12"
dependencies = []
authors = [
{ name = "Wenzel Jakob", email = "wenzel.jakob@epfl.ch" },
]
Expand All @@ -18,6 +19,10 @@ classifiers = [
[project.urls]
Homepage = "https://github.com/wjakob/nanobind_example"

[dependency-groups]
dev = [
"pytest",
]

[tool.scikit-build]
# Protect the configuration against future changes in scikit-build-core
Expand All @@ -29,6 +34,41 @@ build-dir = "build/{wheel_tag}"
# Build stable ABI wheels for CPython 3.12+
wheel.py-api = "cp312"

# This setup only works properly in redirect mode
editable.mode = "redirect"

[tool.uv]
cache-keys = [
{ file = "CMakeLists.txt" },
{ file = "src/**/*.cpp" },
{ file = "src/**/*.h" },
]

[tool.ty.environment]
# Don't include src/ in first-party search path so ty finds the installed
# package (with .pyi stubs) in .venv/lib/python3.12/site-packages/ instead
root = ["."]

[tool.ty.src]
exclude = ["typecheck_smoke.py"]

[tool.pyright]
include = ["tests"]
exclude = [
"build/**",
".venv/**",
"**/__pycache__",
]
extraPaths = []
venvPath = "."
venv = ".venv"

[tool.mypy]
exclude = [
"^build/",
"^typecheck_smoke\\.py$",
]

[tool.cibuildwheel]
# Necessary to see build output from the actual compilation
build-verbosity = 1
Expand All @@ -37,9 +77,6 @@ build-verbosity = 1
test-command = "pytest {project}/tests"
test-requires = "pytest"

# Don't test Python 3.8 wheels on macOS/arm64
test-skip="cp38-macosx_*:arm64"

# Needed for full C++17 support
[tool.cibuildwheel.macos.environment]
MACOSX_DEPLOYMENT_TARGET = "10.14"
7 changes: 6 additions & 1 deletion src/nanobind_example/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
from .nanobind_example_ext import add, __doc__
# NOTE: we use an absolute import here to get the type checking to work properly.
# Additionally we re-export the imported symbols to make ruff happy.
from nanobind_example.nanobind_example_ext import (
add as add,
__doc__ as __doc__,
)
6 changes: 4 additions & 2 deletions src/nanobind_example_ext.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ namespace nb = nanobind;

using namespace nb::literals;

static int add(int a, int b) { return a + b; }

NB_MODULE(nanobind_example_ext, m) {
m.doc() = "This is a \"hello world\" example with nanobind";
m.def("add", [](int a, int b) { return a + b; }, "a"_a, "b"_a);
m.doc() = "This is a \"hello world\" example with nanobind";
m.def("add", &add, "a"_a, "b"_a, R"(Add two integers together.)");
}
1 change: 1 addition & 0 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import nanobind_example as m


def test_add():
assert m.add(1, 2) == 3
10 changes: 10 additions & 0 deletions typecheck_smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# This file contains intentional type errors to verify that type stubs work.
# CI runs type checkers on this file and expects them to fail.
# Do not import this file in actual tests.

import nanobind_example as m


def bad_add_call(a: str, b: str) -> int:
"""Intentionally passing str to int parameters - should fail type checking."""
return m.add(a, b)
Loading