diff --git a/.github/workflows/pip.yml b/.github/workflows/pip.yml index 18d8edd..8db578d 100644 --- a/.github/workflows/pip.yml +++ b/.github/workflows/pip.yml @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index 29f2c4e..6ab71bc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build *.egg-info .vscode .vs +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..fdcfcfd --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index af8ce44..7965a75 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 @@ -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 @@ -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 $ + 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}) diff --git a/README.md b/README.md index fc81f0a..d92b758 100644 --- a/README.md +++ b/README.md @@ -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 ----------- diff --git a/pyproject.toml b/pyproject.toml index e49c78d..2b4221c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [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] @@ -7,7 +7,8 @@ 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" }, ] @@ -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 @@ -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 @@ -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" diff --git a/src/nanobind_example/__init__.py b/src/nanobind_example/__init__.py index fe8a74c..34941c7 100644 --- a/src/nanobind_example/__init__.py +++ b/src/nanobind_example/__init__.py @@ -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__, +) diff --git a/src/nanobind_example_ext.cpp b/src/nanobind_example_ext.cpp index 6d30dbf..01358d6 100644 --- a/src/nanobind_example_ext.cpp +++ b/src/nanobind_example_ext.cpp @@ -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.)"); } diff --git a/tests/test_basic.py b/tests/test_basic.py index 9c670fc..61aabff 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,4 +1,5 @@ import nanobind_example as m + def test_add(): assert m.add(1, 2) == 3 diff --git a/typecheck_smoke.py b/typecheck_smoke.py new file mode 100644 index 0000000..e9abcab --- /dev/null +++ b/typecheck_smoke.py @@ -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) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..29ed645 --- /dev/null +++ b/uv.lock @@ -0,0 +1,79 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nanobind-example" +version = "0.0.1" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "pytest" }] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +]