I use libfranka: C++ library for Franka Robotics research robots every day to control the Franka Research 3 robot in our lab. While it’s a powerful C++ library, my team and I wanted to create a Python binding for a few key reasons
- Simplicity: we love how straightforward and easy Python is to use.
- Integration: we wanted to make it easier to work with other Python libraries like cuRobo: CUDA Accelerated Robot Library.
- Customization: we have our own opinionated ways of controlling the robot.
Anyway, I decided to build it. Here’s a breakdown of how I did it.
Big Picture
Let’s called our Python binding frankz
, and here’s a high-level look at the components involved:
📌submodule
The frankarobotics/libfranka
is a git submodule of my project. I’ll explain the key benefit later.
📌nanobind The nanobind the tool I use to create C++ to Python connection.
📌uv I use uv to manage the majority of dependencies.
Remark
Not only the Python dependencies but also the dev dependencies.
📌auditwheel A utility that makes our final Python package portable, so it can be installed on other machines easily.
Setup environment with uv
Let’s first create a dedicated Python environment.
uv venv --python 3.10 #specify the python version you wanna target
source .venv/bin/activate
uv init
uv add --dev nanobind auditwheel
After these steps, uv
will create a pyproject.toml
file that looks something like this:
[project]
name = "frankz"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
[dependency-groups]
dev = [
"auditwheel>=6.4.2",
"nanobind>=2.9.2",
]
TipThink of the
uv add --dev <package>
as thenpm install -D <package>
. It adds a tool that helps you develop the project but is not a dependency of your package.
Using nanobind
with CMake
Now, we need to tell our build system, CMake, how to use nanobind
. The main challenge is letting CMake know where nanobind
is installed inside our virtual environment. We can do this by asking Python for the path via execute_process()
.
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/deps/libfranka)
# Dependency: nanobind
find_package(Python 3.11 COMPONENTS Interpreter ${DEV_MODULE})
execute_process(
COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_VARIABLE nanobind_ROOT
)
find_package(nanobind CONFIG REQUIRED)
# Create Python module
nanobind_add_module(frankz
src/frankz/source.cpp
)
# Set default build type to Release if not specified
if (NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Choose the type of build." FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS "Debug" "Release" "MinSizeRel" "RelWithDebInfo")
endif()
target_link_libraries(frankz PRIVATE
Franka::Franka
)
target_include_directories(frankz PRIVATE
include
)
install(TARGETS frankz DESTINATION frankz)
For this to work, you also need to configure pyproject.toml
to use scikit-build-core
, which connects uv
and CMake
.
[build-system]
requires = ["nanobind", "scikit-build-core >=0.4.3"]
build-backend = "scikit_build_core.build"
Remark
See Setting up a build system for more.
This setup ensures that when you build your project, nanobind
is available and CMake knows how to use it to compile the Python module.
Why use libfranka
as a submodule?
A key design choice for this project was to include libfranka
’s source code directly as a git submodule instead of relying on a system-wide installation.
If our Python wrapper depended on a system-installed version of libfranka
, it would be tightly coupled to whatever version happens to be on that machine. This can easily lead to version conflicts and makes our package fragile.
By using a submodule, we bundle a specific version of libfranka
’s source code with our project. This gives us complete control. We can guarantee that frankz
version 0.16.0
is always built with libfranka
version 0.16.0
.
mkdir deps
git submodule add https://github.com/frankarobotics/libfranka.git deps/libfranka
cd deps/libfranka
git checkout 0.16.0
cd ../..
git add deps/libfranka
This approach makes our project self-contained and far more reliable.
Handling libfranka
’s dependencies
Even though we’re building libfranka
from source, it still has its own dependencies that need to be installed on our build machine. In light of README.md
,
sudo apt-get update
sudo apt-get install -y build-essential cmake git libpoco-dev libeigen3-dev libfmt-dev
sudo apt-get install -y lsb-release curl
sudo mkdir -p /etc/apt/keyrings
curl -fsSL http://robotpkg.openrobots.org/packages/debian/robotpkg.asc | sudo tee /etc/apt/keyrings/robotpkg.asc
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/robotpkg.asc] http://robotpkg.openrobots.org/packages/debian/pub $(lsb_release -cs) robotpkg" | sudo tee /etc/apt/sources.list.d/robotpkg.list
sudo apt-get update
sudo apt-get install -y robotpkg-pinocchio
we know that the dependencies of libfranka
were
- poco
- eigen3
- fmt
- pinocchio (installed in
/opt/openrobots
)
Most of these libraries are installed in standard system paths like /usr/local
, /usr/
, /opt/
, but pinocchio
is installed in a custom location(/opt/openrobots/)
. We need to tell CMake where to find it.
In the libfranka
’s README.md
, we know that the libfranka
was built via
cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=/opt/openrobots/lib/cmake -DBUILD_TESTS=OFF ..
make
Therefore, we need to do something similar in our CMakeLists.txt
:
list(APPEND CMAKE_PREFIX_PATH
"/opt/openrobots"
)
cmake_minimum_required(VERSION 3.15...3.27)
project(frankz VERSION 0.16.0 LANGUAGES CXX)
option(BUILD_EXAMPLES "Build libfranka examples" OFF)
option(BUILD_TESTS "Build libfranka tests" OFF)
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/deps/libfranka)
This way, when CMake compiles our bundled libfranka
, it knows where to find all the necessary libraries.
A useful tool: ldd
and RPATH
During development, you might find that your compiled library builds successfully but fails at runtime because it can’t find a dependency like pinocchio
. The ldd
command is incredibly useful for debugging these kinds of linking issue.
For example, if you omit the CMAKE_BUILD_WITH_INSTALL_RPATH
, the ldd
will show that libpinocchio
is “not found”:
$ ldd frankz.cpython-311-x86_64-linux-gnu.so
libpinocchio_parsers.so.3.4.0 => not found
liburdfdom_world.so.4.0 => /lib/x86_64-linux-gnu/liburdfdom_world.so.4.0 (0x00007e5aa444b000)
/lib64/ld-linux-x86-64.so.2 (0x00007e5aa4e58000)
libpinocchio_default.so.3.4.0 => not found
To fix this, we need to embed the path to /opt/openrobots/lib
into our compiled library. We do this by setting CMAKE_INSTALL_RPATH
in our CMakeLists.txt
.
set(CMAKE_INSTALL_RPATH "$ORIGIN/../lib;/opt/openrobots/lib")
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
After rebuilding with this setting, ldd
confirms that the library knows exactly where to find pinocchio
at runtime:
$ ldd frankz.cpython-311-x86_64-linux-gnu.so
libpinocchio_parsers.so.3.4.0 => /opt/openrobots/lib/libpinocchio_parsers.so.3.4.0 (0x000075d6e5fb7000)
liburdfdom_world.so.4.0 => /lib/x86_64-linux-gnu/liburdfdom_world.so.4.0 (0x00007e5aa444b000)
/lib64/ld-linux-x86-64.so.2 (0x00007e5aa4e58000)
libpinocchio_default.so.3.4.0 => /opt/openrobots/lib/libpinocchio_default.so.3.4.0 (0x000075d6e5a94000)
This solves the problem on our build machine, but what about other users?
Make a portable wheel with auditwheel
Our current package requires users to have pinocchio
and other libraries installed in specific system directories. This isn’t very user-friendly. Python users expect to install a package with a single command without worrying about C++ dependencies.
This’s where the pypa/auditwheel: Auditing and relabeling cross-distribution Linux wheels. comes in. It’s a tool that takes our platform-specific wheel and turns it into a portable one by bundling all the required C++ libraries inside it.
After building the wheel, run this command:
auditwheel repair dist/frankz-0.15.3-cp311-cp311-linux_x86_64.whl
This creates a new wheel with a name like frankz-0.15.3-cp311-cp311-manylinux_2_39_x86_64.whl
. The **-manylinux_**.whl
means it’s now portable.
If you run ldd
on the library, you’ll see that it no longer depends on system paths. Instead, it finds it dependencies right inside the Python environment, making it truly self-contained.
$ ldd frankz.cpython-311-x86_64-linux-gnu.so
libpinocchio_parsers-a4fa2716.so.3.4.0 => /home/hex/temp/test_frankz/.venv/lib/python3.11/site-packages/frankz/../lib/../frankz.libs/libpinocchio_parsers-a4fa2716.so.3.4.0 (0x000074cee7faf000)
liburdfdom_world-f92371d8.so.4.0 => /home/hex/temp/test_frankz/.venv/lib/python3.11/site-packages/frankz/../lib/../frankz.libs/liburdfdom_world-f92371d8.so.4.0 (0x000074cee7f8d000)
libpinocchio_default-4d0f86a8.so.3.4.0 => /home/hex/temp/test_frankz/.venv/lib/python3.11/site-packages/frankz/../lib/../frankz.libs/libpinocchio_default-4d0f86a8.so.3.4.0 (0x000074cee7a69000)
Now you have a professional, portable Python package that’s ready to be published for everyone to use.