5.5 KiB
Developer Guide – Python Bindings
This guide is aimed at contributors who want to extend or modify the Python bindings for uLib.
Repository Layout
ulib/
├── src/
│ └── Python/
│ ├── module.cpp # pybind11 module entry point
│ ├── core_bindings.cpp # uLib::Core bindings
│ ├── math_bindings.cpp # uLib::Math bindings
│ ├── math_filters_bindings.cpp# VoxImageFilter bindings
│ ├── CMakeLists.txt # builds uLib_python shared lib
│ ├── testing/ # Python unit tests
│ │ ├── pybind_test.py
│ │ ├── core_pybind_test.py
│ │ ├── math_pybind_test.py
│ │ └── math_filters_test.py
│ └── uLib/ # Python package (uLib_python.so lands here)
│ └── __init__.py
├── build_python.py # poetry build hook (calls CMake)
├── pyproject.toml # poetry metadata
└── condaenv.yml # conda/micromamba environment
Adding a New Binding
All bindings live in the four source files listed above. The module entry point module.cpp calls init_core(), init_math(), and init_math_filters() in order.
1. Pick (or create) the right binding file
| C++ header location | Binding file |
|---|---|
src/Core/ |
core_bindings.cpp |
src/Math/ (geometry, grids, VoxImage) |
math_bindings.cpp |
src/Math/VoxImageFilter*.hpp |
math_filters_bindings.cpp |
2. Add the #include directive
// math_bindings.cpp
#include "Math/MyNewClass.h"
3. Write the pybind11 binding inside the appropriate init_* function
void init_math(py::module_ &m) {
// ... existing bindings ...
py::class_<MyNewClass>(m, "MyNewClass")
.def(py::init<>())
.def("MyMethod", &MyNewClass::MyMethod)
.def("AnotherMethod", &MyNewClass::AnotherMethod,
py::arg("x"), py::arg("y") = 0.0f);
}
4. Rebuild only the Python target
cmake --build build --target uLib_python -j$(nproc)
5. Write a Python test
Add a new test class to the relevant test file (or create a new one under src/Python/testing/):
# src/Python/testing/math_pybind_test.py
class TestMyNewClass(unittest.TestCase):
def test_basic(self):
obj = uLib.Math.MyNewClass()
result = obj.MyMethod()
self.assertAlmostEqual(result, expected_value)
Register the test in src/Python/CMakeLists.txt if you add a new file:
add_test(NAME pybind_my_new
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/testing/my_new_test.py)
set_tests_properties(pybind_my_new PROPERTIES
ENVIRONMENT "PYTHONPATH=$<TARGET_FILE_DIR:uLib_python>:${PROJECT_SOURCE_DIR}/src/Python")
Build System Details
CMakeLists.txt (src/Python/)
pybind11_add_module compiles the shared library uLib_python and links it against the C++ static/shared libraries uLibCore and uLibMath. The install target copies the .so into the standard library directory.
pybind11_add_module(uLib_python
module.cpp core_bindings.cpp math_bindings.cpp math_filters_bindings.cpp)
target_link_libraries(uLib_python PRIVATE uLibCore uLibMath)
poetry / build_python.py
pyproject.toml declares build_python.py as the custom build hook. When poetry install or poetry build is invoked it:
- Calls
cmake <root> -DCMAKE_LIBRARY_OUTPUT_DIRECTORY=<pkg_dir> ...inbuild_python/. - Builds only the
uLib_pythontarget. - The resulting
.sois placed insidesrc/Python/uLib/so it is picked up by Poetry as a package data file.
The USE_CUDA environment variable gates CUDA support at build time:
USE_CUDA=ON poetry install # with CUDA
USE_CUDA=OFF poetry install # CPU only (default)
Running All Tests
# From the repository root, with PYTHONPATH set:
export PYTHONPATH="$(pwd)/build/src/Python:$(pwd)/src/Python"
python -m pytest src/Python/testing/ -v
Or through CMake's test runner (after building the full project):
cd build
ctest --output-on-failure -R pybind
Expected output (all passing):
Start 1: pybind_general
1/4 Test #1: pybind_general ............. Passed
Start 2: pybind_core
2/4 Test #2: pybind_core ................ Passed
Start 3: pybind_math
3/4 Test #3: pybind_math ................ Passed
Start 4: pybind_math_filters
4/4 Test #4: pybind_math_filters ........ Passed
Memory Management Notes
uLib::Vector<T> has explicit GPU memory management. When wrapping methods that return references to internal data, use py::return_value_policy::reference_internal to avoid dangling references:
.def("Data", &VoxImage<Voxel>::Data,
py::return_value_policy::reference_internal)
For objects held by std::unique_ptr without Python-side deletion, use py::nodelete:
py::class_<Abstract::VoxImageFilter,
std::unique_ptr<Abstract::VoxImageFilter, py::nodelete>>(m, "AbstractVoxImageFilter")