07 Multi-Language Projects: Building Python C Extensions
January 2026 (8604 Words, 48 Minutes)
1. Introduction: The Party Trick
βWhat if your C++ code could instantly visualize results with Plotly?β
This is the party trick of multi-language Python projects: you write performance-critical code in C++, but you keep all the convenience of Pythonβs ecosystemβDash for web apps, Plotly for interactive charts, NumPy for data manipulation.
1.1 The Best of Both Worlds
find_intersection()
10x faster
where it matters Seamless
integration Rich Ecosystem
for visualization
1.2 Our Road Profile Viewer Example
Remember the Road Profile Viewer from earlier lectures? It has a slider that adjusts the camera angle in real-time, calculating where the ray intersects the road profile.
The Python implementation works perfectly, but:
- The
find_intersection()function runs on every slider movement - With a complex road profile, this becomes the bottleneck
- On embedded systems or mobile devices, every millisecond counts
The solution: Port find_intersection() to C++, but keep Plotly for visualization.
1.3 What Youβll Learn
In this lecture, youβll learn the complete journey from pure Python to distributable C extension:
- What C extensions are and why they exist
- pybind11 for creating the Python-C++ bridge
- scikit-build-core as the modern build backend
- uv build to create wheels locally
- cibuildwheel for cross-platform CI builds
- uv publish to share your package with the world
2. What is a Python C Extension?
2.1 A Brief History
Python has supported native extensions since its very first release in 1991. This isnβt an afterthoughtβitβs core to Pythonβs design philosophy.
Guido van Rossum designed Python to be embeddable and extendable:
- Embeddable: You can embed Python in a larger application
- Extendable: You can extend Python with modules written in C/C++
This is why so many βPythonβ libraries are actually thin wrappers around C code:
| Library | Python API | Core Implementation |
|---|---|---|
| NumPy | np.array([1, 2, 3]) |
C (with SIMD optimizations) |
| pandas | df.groupby('col') |
Cython + C |
| PyTorch | torch.tensor([...]) |
C++ (ATen library) |
| OpenCV | cv2.imread('img.jpg') |
C++ |
| scikit-learn | model.fit(X, y) |
Cython + C |
2.2 How CPython Works
The standard Python interpreter is called CPython because itβs written in C. When you run python script.py, youβre running a C program that interprets your Python code.
Key insight: C extension modules are compiled to machine code (.so on Linux/macOS, .pyd on Windows) and loaded directly by the Python interpreter. They bypass bytecode interpretation entirely.
2.3 The Traditional C API
Python provides a C API for creating extension modules. Hereβs what a simple function looks like:
// traditional_module.c
#include <Python.h>
// The actual function implementation
static PyObject* calculate(PyObject* self, PyObject* args) {
double x;
// Parse the Python argument into a C double
if (!PyArg_ParseTuple(args, "d", &x)) {
return NULL; // Error: wrong argument type
}
// Do the calculation
double result = x * 2.0;
// Convert C result back to Python object
return PyFloat_FromDouble(result);
}
// Method table: maps Python function names to C functions
static PyMethodDef ModuleMethods[] = {
{"calculate", calculate, METH_VARARGS, "Calculate x * 2"},
{NULL, NULL, 0, NULL} // Sentinel (end of array)
};
// Module definition structure
static struct PyModuleDef moduledef = {
PyModuleDef_HEAD_INIT,
"my_module", // Module name
NULL, // Module docstring
-1, // Size of per-interpreter state (-1 = global)
ModuleMethods // Method table
};
// Module initialization function (called when imported)
PyMODINIT_FUNC PyInit_my_module(void) {
return PyModule_Create(&moduledef);
}
Thatβs 35 lines of boilerplate for a one-line calculation!
2.4 Why the C API is Painful
Working directly with Pythonβs C API requires you to:
-
Manage reference counts: Every
PyObject*has a reference count. Forget to increment or decrement it, and you get memory leaks or crashes. -
Handle errors manually: Every C API call can fail. You must check return values and propagate errors correctly.
-
Convert types manually:
PyArg_ParseTupleandPy_BuildValueuse format strings that are easy to get wrong. -
Write lots of boilerplate: Method tables, module definitions, initialization functionsβ¦
-
Deal with the GIL: The Global Interpreter Lock requires careful handling in multi-threaded code.
2.5 Evolution of Python/C++ Binding Approaches
The community developed various solutions to make C/C++ binding easier:
| Year | Technology | Approach | Key Characteristic |
|---|---|---|---|
| 1991 | Python C API | Direct C | Maximum control, maximum boilerplate |
| 1996 | SWIG | Code generator | Wraps existing C/C++ headers automatically |
| 2002 | Boost.Python | C++ templates | Type-safe, but requires Boost library |
| 2007 | Cython | Python-like language | Write "Python with types", compiles to C |
| 2015 | pybind11 | Header-only C++ | Modern C++, minimal overhead, most popular |
| 2022 | nanobind | Header-only C++ | pybind11's successor, even smaller binaries |
Todayβs choice: Weβll use pybind11 because:
- Itβs the most widely adopted modern solution
- Header-only (no library to link)
- Excellent NumPy integration
- Active community and documentation
3. pybind11: The Modern Approach
3.1 What is pybind11?
pybind11 is a lightweight, header-only library that uses C++ template metaprogramming to generate Python bindings. It exposes C++ types and functions to Python with minimal boilerplate.
Compare the C API example from earlier:
// pybind11 version - same functionality, 90% less code
#include <pybind11/pybind11.h>
double calculate(double x) {
return x * 2.0;
}
PYBIND11_MODULE(my_module, m) {
m.def("calculate", &calculate, "Calculate x * 2");
}
Thatβs 10 lines instead of 35, and itβs much clearer whatβs happening.
3.2 How pybind11 Works
pybind11 uses C++ templates to:
- Introspect function signatures at compile time
- Generate type conversion code automatically
- Create Python-compatible module definitions without manual work
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β pybind11 Magic β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Your C++ Code pybind11 Python β
β ββββββββββββββ βββββββββ ββββββββ β
β β
β double calculate( Template >>> import β
β double x magic my_mod β
β ) { β >>> my_mod β
β return x * 2; Generates: .calc(5) β
β } - Type checks 10.0 β
β - Conversions β
β PYBIND11_MODULE(...) - Error handling β
β - Python API β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
3.3 Automatic Type Conversions
pybind11 automatically converts between Python and C++ types:
| Python Type | C++ Type | Notes |
|---|---|---|
int |
int, long, int64_t |
Overflow checking |
float |
float, double |
Automatic conversion |
str |
std::string |
UTF-8 encoding |
list |
std::vector<T> |
Copies elements |
dict |
std::map<K, V> |
Copies elements |
tuple |
std::tuple<...> |
Fixed size |
None |
std::optional<T> |
C++17 |
numpy.ndarray |
py::array_t<T> |
Zero-copy possible! |
3.4 NumPy Integration
The killer feature for scientific Python: pybind11 integrates seamlessly with NumPy.
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
// Function that takes and returns NumPy arrays
py::array_t<double> double_elements(py::array_t<double> input) {
// Get buffer info (shape, strides, pointer)
auto buf = input.request();
double* ptr = static_cast<double*>(buf.ptr);
size_t size = buf.size;
// Create output array
py::array_t<double> output(size);
auto out_buf = output.request();
double* out_ptr = static_cast<double*>(out_buf.ptr);
// Process elements
for (size_t i = 0; i < size; ++i) {
out_ptr[i] = ptr[i] * 2.0;
}
return output;
}
PYBIND11_MODULE(my_module, m) {
m.def("double_elements", &double_elements);
}
Usage in Python:
import numpy as np
import my_module
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
result = my_module.double_elements(arr)
print(result) # [2. 4. 6. 8. 10.]
4. Understanding Python Wheels and Build Backends
Before we can build C extensions, we need to understand how Python packages are distributed. This is one of the most important concepts in Python packagingβand it directly affects your choice of tools.
4.1 What is a Python Wheel?
A wheel is Pythonβs standard format for distributing pre-built packages. The name comes from the phrase βwheel of cheeseββa playful nod to the βCheeseShopβ (the original name for PyPI).
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β WHAT'S INSIDE A WHEEL FILE? β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β road_profile_viewer-0.1.0-cp312-cp312-win_amd64.whl β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β Just a .zip file with a special name! β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β road_profile_viewer/ β β
β β βββ __init__.py β β
β β βββ geometry.py β β
β β βββ visualization.py β β
β β βββ geometry_cpp.cp312-win_amd64.pyd β Compiled C extension! β β
β β β β
β β road_profile_viewer-0.1.0.dist-info/ β β
β β βββ METADATA β β
β β βββ WHEEL β β
β β βββ RECORD β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β Installing = Unzipping to site-packages. That's it! β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The wheel filename tells you everything:
road_profile_viewer-0.1.0-cp312-cp312-win_amd64.whl
β β β β β
β β β β βββ Platform (Windows 64-bit)
β β β βββ Python ABI (cp312 = CPython 3.12)
β β βββ Python version (3.12)
β βββ Package version
βββ Package name
4.2 Pure Python vs. Platform Wheels
There are two types of wheels:
Pure Python wheels (...-py3-none-any.whl):
- Contain only
.pyfiles - Work on any platform with Python
- Single wheel serves everyone
Platform wheels (...-cp312-cp312-win_amd64.whl):
- Contain compiled code (
.pyd,.so) - Specific to Python version AND platform
- Need one wheel per platform/version combination
| Package Type | Wheel Name Pattern | Example |
|---|---|---|
| Pure Python | *-py3-none-any.whl |
requests-2.31.0-py3-none-any.whl |
| C Extension (Windows) | *-cp312-cp312-win_amd64.whl |
numpy-1.26.0-cp312-cp312-win_amd64.whl |
| C Extension (macOS) | *-cp312-cp312-macosx_*.whl |
numpy-1.26.0-cp312-cp312-macosx_11_0_arm64.whl |
| C Extension (Linux) | *-cp312-cp312-manylinux*.whl |
numpy-1.26.0-cp312-cp312-manylinux_2_17_x86_64.whl |
4.3 Wheels vs. Source Distributions
When you build a package, you actually create two things:
uv build
# Creates:
# dist/road_profile_viewer-0.1.0.tar.gz (source distribution)
# dist/road_profile_viewer-0.1.0-cp312-....whl (wheel)
| Format | Extension | Contains | Installation |
|---|---|---|---|
| Source Distribution (sdist) | .tar.gz |
Source code, pyproject.toml, CMakeLists.txt | Requires compilation on user's machine |
| Wheel | .whl |
Pre-compiled, ready to install | Just unzipβno compilation needed |
Why both?
- Wheels are preferred: fast install, no compiler needed
- sdist is the fallback: if no wheel exists for your platform, pip/uv builds from source
This is why popular packages like NumPy provide dozens of wheelsβone for each Python version and platform combination.
4.4 The Problem: Building C++ for Users
Now you understand why we need wheels. But how do we create them?
When someone runs uv pip install your-package, what happens?
For pure Python packages: Simple! Just copy the .py files into a wheel.
For C extensions: You need to compile C/C++ code. This requires:
- A C/C++ compiler (GCC, Clang, MSVC)
- Python development headers
- Correct compiler flags for the platform
- Knowledge of where to put the compiled
.so/.pyd
Most users donβt have compilers installed. They expect to run uv pip install and have it just work. This is why you (the package author) build wheels for all platforms and upload them to PyPI.
4.5 uv and Build Backends: The uv-First Approach
Throughout this course, weβve used uv as our Python package manager. uv uses the uv_build backend by defaultβbut it has a limitation:
βThe uv build backend currently only supports pure Python code.β β uv Documentation
For C/C++ extensions, uv explicitly recommends using scikit-build-core. You can even create a project with it directly:
# uv's built-in support for C/C++ projects!
uv init --build-backend scikit-build-core my-cpp-extension
# This creates:
# my-cpp-extension/
# βββ pyproject.toml (configured for scikit-build-core)
# βββ CMakeLists.txt (CMake build configuration)
# βββ src/
# βββ main.cpp (sample C++ code)
This is our uv-first approach: We use uv for everything, and uv tells us to use scikit-build-core for C extensions.
4.6 Why scikit-build-core?
uv supports multiple build backends:
| Backend | Use Case | uv Command |
|---|---|---|
| uv_build | Pure Python packages (default) | uv init --lib |
| hatchling | Pure Python with build hooks | uv init --build-backend hatchling |
| scikit-build-core | C/C++/Fortran/Cython extensions | uv init --build-backend scikit-build-core |
| maturin | Rust extensions | uv init --build-backend maturin |
scikit-build-core uses CMake under the hood, which you already learned in Part 1:
| Feature | Old setuptools approach | scikit-build-core |
|---|---|---|
| Build system | Custom Python code | CMake (industry standard) |
| Dependency handling | Manual paths | find_package() |
| Cross-platform | Fragile, manual flags | CMake handles it |
| uv integration | Works | First-class support |
| IDE support | Limited | Full CMake/clangd support |
4.7 The Traditional Approach: setuptools
The old way used setuptools with custom build commands:
# setup.py (the OLD way)
from setuptools import setup, Extension
ext = Extension(
'my_module',
sources=['src/module.cpp'],
include_dirs=['/path/to/pybind11/include'],
extra_compile_args=['-std=c++17'],
)
setup(
name='my-package',
ext_modules=[ext],
)
Problems with this approach:
- Platform-specific paths and flags
- No standard way to find dependencies
- Limited CMake integration
- Complex cross-compilation
4.8 PEP 517: The Standard Build Interface
PEP 517 introduced a standard interface for build backends. Instead of setup.py, you declare your build system in pyproject.toml:
[build-system]
requires = ["scikit-build-core", "pybind11"]
build-backend = "scikit_build_core.build"
Now any tool (pip, uv, build) can build your package using the declared backend. This is why uv can work seamlessly with scikit-build-coreβthey both speak the same PEP 517 language.
4.9 Minimal scikit-build-core Setup
You need only two files to add C++ to your Python package:
pyproject.toml:
[project]
name = "my-package"
version = "0.1.0"
requires-python = ">=3.12"
[build-system]
requires = ["scikit-build-core>=0.10", "pybind11>=2.13"]
build-backend = "scikit_build_core.build"
CMakeLists.txt:
cmake_minimum_required(VERSION 3.15)
project(my_package LANGUAGES CXX)
find_package(pybind11 CONFIG REQUIRED)
pybind11_add_module(my_ext src/bindings.cpp)
install(TARGETS my_ext LIBRARY DESTINATION .)
Thatβs it! When you run uv build, scikit-build-core:
- Invokes CMake to configure the build
- Compiles the C++ code
- Packages everything into a wheel
5. Hands-On: Porting find_intersection to C++
Letβs port the find_intersection() function from our Road Profile Viewer to C++.
5.1 The Python Original
# geometry.py - The original Python implementation
def find_intersection(
x_road: NDArray[np.float64],
y_road: NDArray[np.float64],
angle_degrees: float,
camera_x: float = 0,
camera_y: float = 1.5,
) -> tuple[float | None, float | None, float | None]:
"""Find the intersection point between camera ray and road profile."""
angle_rad = -np.deg2rad(angle_degrees)
# Handle vertical ray
if np.abs(np.cos(angle_rad)) < 1e-10:
return None, None, None
slope = np.tan(angle_rad)
# Check each segment of the road for intersection
for i in range(len(x_road) - 1):
x1, y1 = x_road[i], y_road[i]
x2, y2 = x_road[i + 1], y_road[i + 1]
# Skip if segment is behind camera
if x2 <= camera_x:
continue
# Calculate ray y-values at segment endpoints
ray_y1 = camera_y + slope * (x1 - camera_x)
ray_y2 = camera_y + slope * (x2 - camera_x)
# Check for sign change (intersection)
diff1 = ray_y1 - y1
diff2 = ray_y2 - y2
if diff1 * diff2 <= 0:
# Linear interpolation
if abs(diff2 - diff1) < 1e-10:
t = 0
else:
t = diff1 / (diff1 - diff2)
x_intersect = x1 + t * (x2 - x1)
y_intersect = y1 + t * (y2 - y1)
distance = np.sqrt(
(x_intersect - camera_x) ** 2 +
(y_intersect - camera_y) ** 2
)
return x_intersect, y_intersect, distance
return None, None, None
This function is called on every slider movement. With a road profile of 1000+ points, this loop runs hundreds of times per second.
5.2 The C++ Header
// cpp/geometry.hpp
#ifndef ROAD_PROFILE_VIEWER_GEOMETRY_HPP
#define ROAD_PROFILE_VIEWER_GEOMETRY_HPP
#include <cmath>
#include <numbers>
#include <optional>
#include <vector>
namespace road_profile_viewer {
/// Result of an intersection calculation
struct IntersectionResult {
double x;
double y;
double distance;
};
/// Find intersection between camera ray and road profile
///
/// @param x_road X-coordinates of road profile points
/// @param y_road Y-coordinates of road profile points
/// @param angle_degrees Camera angle in degrees from horizontal
/// @param camera_x X-position of camera
/// @param camera_y Y-position of camera
/// @return Intersection result or nullopt if no intersection
std::optional<IntersectionResult> find_intersection(
const std::vector<double>& x_road,
const std::vector<double>& y_road,
double angle_degrees,
double camera_x = 0.0,
double camera_y = 1.5
);
} // namespace road_profile_viewer
#endif // ROAD_PROFILE_VIEWER_GEOMETRY_HPP
5.3 The C++ Implementation
// cpp/geometry.cpp
#include "geometry.hpp"
namespace road_profile_viewer {
namespace {
constexpr double kEpsilon = 1e-10;
inline double deg_to_rad(double degrees) {
return degrees * std::numbers::pi / 180.0;
}
} // namespace
std::optional<IntersectionResult> find_intersection(
const std::vector<double>& x_road,
const std::vector<double>& y_road,
double angle_degrees,
double camera_x,
double camera_y
) {
const double angle_rad = -deg_to_rad(angle_degrees);
// Handle vertical ray (undefined slope)
if (std::abs(std::cos(angle_rad)) < kEpsilon) {
return std::nullopt;
}
const double slope = std::tan(angle_rad);
// Check each road segment for intersection
for (size_t i = 0; i + 1 < x_road.size(); ++i) {
const double x1 = x_road[i];
const double y1 = y_road[i];
const double x2 = x_road[i + 1];
const double y2 = y_road[i + 1];
// Skip segments behind camera
if (x2 <= camera_x) {
continue;
}
// Calculate ray y-values at segment endpoints
const double ray_y1 = camera_y + slope * (x1 - camera_x);
const double ray_y2 = camera_y + slope * (x2 - camera_x);
const double diff1 = ray_y1 - y1;
const double diff2 = ray_y2 - y2;
// Check for sign change (intersection)
if (diff1 * diff2 <= 0.0) {
double t = 0.0;
if (std::abs(diff2 - diff1) >= kEpsilon) {
t = diff1 / (diff1 - diff2);
}
const double x_intersect = x1 + t * (x2 - x1);
const double y_intersect = y1 + t * (y2 - y1);
const double distance = std::hypot(
x_intersect - camera_x,
y_intersect - camera_y
);
return IntersectionResult{
.x = x_intersect,
.y = y_intersect,
.distance = distance
};
}
}
return std::nullopt;
}
} // namespace road_profile_viewer
5.4 The pybind11 Bindings
// cpp/bindings.cpp
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <pybind11/numpy.h>
#include "geometry.hpp"
namespace py = pybind11;
namespace rpv = road_profile_viewer;
PYBIND11_MODULE(geometry_cpp, m) {
m.doc() = "C++ geometry functions for Road Profile Viewer";
m.def("find_intersection",
[](py::array_t<double> x_road,
py::array_t<double> y_road,
double angle_degrees,
double camera_x,
double camera_y) {
// Convert NumPy arrays to std::vector
auto x_buf = x_road.request();
auto y_buf = y_road.request();
std::vector<double> x_vec(
static_cast<double*>(x_buf.ptr),
static_cast<double*>(x_buf.ptr) + x_buf.size
);
std::vector<double> y_vec(
static_cast<double*>(y_buf.ptr),
static_cast<double*>(y_buf.ptr) + y_buf.size
);
// Call C++ function
auto result = rpv::find_intersection(
x_vec, y_vec, angle_degrees, camera_x, camera_y
);
// Convert result back to Python
if (result.has_value()) {
return py::make_tuple(
result->x,
result->y,
result->distance
);
}
return py::make_tuple(py::none(), py::none(), py::none());
},
py::arg("x_road"),
py::arg("y_road"),
py::arg("angle_degrees"),
py::arg("camera_x") = 0.0,
py::arg("camera_y") = 1.5,
R"doc(
Find intersection between camera ray and road profile.
Args:
x_road: NumPy array of road x-coordinates
y_road: NumPy array of road y-coordinates
angle_degrees: Camera angle in degrees
camera_x: X-position of camera (default: 0.0)
camera_y: Y-position of camera (default: 1.5)
Returns:
Tuple of (x, y, distance) or (None, None, None) if no intersection
)doc"
);
}
5.5 Project Structure
Hereβs the complete project layout:
road-profile-viewer/
βββ src/
β βββ road_profile_viewer/
β βββ __init__.py
β βββ geometry.py # Python implementation (fallback)
β βββ visualization.py # Dash/Plotly UI
β βββ main.py
βββ cpp/
β βββ geometry.hpp # C++ header
β βββ geometry.cpp # C++ implementation
β βββ bindings.cpp # pybind11 bindings
βββ tests/
β βββ test_geometry.py
βββ CMakeLists.txt # Build configuration
βββ pyproject.toml # Project metadata + build backend
βββ README.md
5.6 The Complete pyproject.toml
[project]
name = "road-profile-viewer"
version = "0.1.0"
description = "Interactive 2D road profile viewer with C++ acceleration"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"dash>=2.14.0",
"plotly>=5.18.0",
"numpy>=1.26.0",
]
[project.scripts]
road-profile-viewer = "road_profile_viewer:main"
[build-system]
requires = ["scikit-build-core>=0.10", "pybind11>=2.13"]
build-backend = "scikit_build_core.build"
[tool.scikit-build]
wheel.packages = ["src/road_profile_viewer"]
cmake.build-type = "Release"
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.8",
"pyright>=1.1",
]
5.7 The Complete CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(road_profile_viewer LANGUAGES CXX)
# Require C++20
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Find pybind11 (provided by build-system.requires)
find_package(pybind11 CONFIG REQUIRED)
# Create the Python module
pybind11_add_module(geometry_cpp
cpp/bindings.cpp
cpp/geometry.cpp
)
# Include directory for header
target_include_directories(geometry_cpp PRIVATE cpp)
# Install into the Python package directory
install(TARGETS geometry_cpp LIBRARY DESTINATION road_profile_viewer)
6. The uv Build Workflow
6.1 Building Your Package
With scikit-build-core configured, building is simple:
# Build both sdist and wheel
uv build
# Output:
# Building road-profile-viewer@0.1.0
# ...CMake configuration output...
# ...Compilation output...
# Successfully built dist/road_profile_viewer-0.1.0.tar.gz
# Successfully built dist/road_profile_viewer-0.1.0-cp312-cp312-win_amd64.whl
6.2 Whatβs in the dist/ Directory?
dist/
βββ road_profile_viewer-0.1.0.tar.gz # Source distribution
βββ road_profile_viewer-0.1.0-cp312-cp312-win_amd64.whl # Binary wheel
Source distribution (sdist): Contains source code. The recipient needs a compiler to install.
Wheel: Contains pre-compiled binaries. Just unpack and run. The name encodes compatibility:
road_profile_viewer-0.1.0-cp312-cp312-win_amd64.whl
β β β β β
β β β β βββ Platform (Windows x64)
β β β βββ Python ABI (CPython 3.12)
β β βββ Python version (CPython 3.12)
β βββ Package version
βββ Package name
6.3 Testing Your Wheel
# Install the wheel you just built
uv pip install dist/road_profile_viewer-0.1.0-cp312-cp312-win_amd64.whl
# Test the C++ module
python -c "from road_profile_viewer.geometry_cpp import find_intersection; print('C++ module loaded!')"
6.4 The Build Flow
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β uv build Workflow β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β pyproject.toml β
β β β
β βΌ β
β βββββββββββββββββββ β
β β scikit-build- β β
β β core detects β β Reads build-system.requires β
β β C++ code β β
β ββββββββββ¬βββββββββ β
β βΌ β
β βββββββββββββββββββ β
β β CMake β β Configures build, finds pybind11 β
β β configuration β β
β ββββββββββ¬βββββββββ β
β βΌ β
β βββββββββββββββββββ β
β β C++ compilation β β Creates geometry_cpp.pyd (Windows) β
β β β or geometry_cpp.so (Linux/macOS) β
β ββββββββββ¬βββββββββ β
β βΌ β
β βββββββββββββββββββ β
β β Wheel creation β β Bundles Python + compiled extension β
β ββββββββββ¬βββββββββ β
β βΌ β
β dist/package-version-platform.whl β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
7. The Python Integration Pattern
7.1 The Fallback Pattern
The best practice is to provide a Python fallback. This allows your package to work even when:
- The C++ module failed to compile
- The user is on an unsupported platform
- Youβre debugging and want to bypass C++
# src/road_profile_viewer/geometry.py
# Try to import C++ implementation
try:
from road_profile_viewer.geometry_cpp import (
find_intersection as _cpp_find_intersection,
)
_USE_CPP = True
except ImportError:
_USE_CPP = False
import numpy as np
from numpy.typing import NDArray
def find_intersection(
x_road: NDArray[np.float64],
y_road: NDArray[np.float64],
angle_degrees: float,
camera_x: float = 0,
camera_y: float = 1.5,
) -> tuple[float | None, float | None, float | None]:
"""Find intersection between camera ray and road profile.
Uses C++ implementation if available, falls back to Python.
"""
if _USE_CPP:
return _cpp_find_intersection(
x_road, y_road, angle_degrees, camera_x, camera_y
)
# Python implementation follows...
angle_rad = -np.deg2rad(angle_degrees)
if np.abs(np.cos(angle_rad)) < 1e-10:
return None, None, None
slope = np.tan(angle_rad)
for i in range(len(x_road) - 1):
x1, y1 = x_road[i], y_road[i]
x2, y2 = x_road[i + 1], y_road[i + 1]
if x2 <= camera_x:
continue
ray_y1 = camera_y + slope * (x1 - camera_x)
ray_y2 = camera_y + slope * (x2 - camera_x)
diff1 = ray_y1 - y1
diff2 = ray_y2 - y2
if diff1 * diff2 <= 0:
if abs(diff2 - diff1) < 1e-10:
t = 0
else:
t = diff1 / (diff1 - diff2)
x_intersect = x1 + t * (x2 - x1)
y_intersect = y1 + t * (y2 - y1)
distance = np.sqrt(
(x_intersect - camera_x) ** 2 +
(y_intersect - camera_y) ** 2
)
return x_intersect, y_intersect, distance
return None, None, None
# Optional: expose which implementation is being used
def get_backend() -> str:
"""Return the active backend: 'cpp' or 'python'."""
return "cpp" if _USE_CPP else "python"
7.2 Why Fallbacks Matter
| Scenario | Without Fallback | With Fallback |
|---|---|---|
| User has no compiler | Installation fails | Works (slower) |
| Unsupported platform | ImportError crash | Works (slower) |
| Debugging | Hard to step through C++ | Use Python directly |
| Development | Must rebuild after every change | Edit Python, test instantly |
7.3 The Party Trick Realized
Now your visualization code doesnβt need to change at all:
# visualization.py - Unchanged!
from road_profile_viewer.geometry import find_intersection
@app.callback(...)
def update_plot(angle):
# This automatically uses C++ when available!
x, y, distance = find_intersection(x_road, y_road, angle)
# Plotly visualization - unchanged
fig = go.Figure(...)
return fig
The party trick: Your C++ code is 10x faster, but youβre still using Plotly, Dash, and the entire Python ecosystem seamlessly.
8. cibuildwheel: Cross-Platform Wheels in CI
8.1 The Problem
When you run uv build locally, you get a wheel for your platform only.
But your users are on:
- Windows (x64, ARM)
- macOS (Intel, Apple Silicon)
- Linux (x64, ARM, various glibc versions)
Each platform needs its own wheel. Building all of these manually is impractical.
8.2 The Solution: cibuildwheel
cibuildwheel is a tool that builds wheels for all platforms using GitHub Actions (or other CI). It:
- Runs on Linux, macOS, and Windows runners
- Uses Docker for Linux to create portable βmanylinuxβ wheels
- Handles Python version matrix automatically
- Uploads wheels as artifacts
8.3 GitHub Actions Workflow
Create .github/workflows/build-wheels.yml:
name: Build Wheels
on:
push:
tags:
- 'v*' # Trigger on version tags
workflow_dispatch: # Manual trigger
jobs:
build_wheels:
name: Build wheels on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v4
- name: Build wheels
uses: pypa/cibuildwheel@v2.21
env:
# Build for Python 3.12+
CIBW_BUILD: "cp312-* cp313-*"
# Skip 32-bit and musl Linux
CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux*"
- name: Upload wheels
uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}
path: ./wheelhouse/*.whl
build_sdist:
name: Build source distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Build sdist
run: uv build --sdist
- name: Upload sdist
uses: actions/upload-artifact@v4
with:
name: sdist
path: dist/*.tar.gz
8.4 What Gets Built?
After the workflow runs, youβll have wheels for:
wheelhouse/
βββ road_profile_viewer-0.1.0-cp312-cp312-manylinux_2_17_x86_64.whl
βββ road_profile_viewer-0.1.0-cp312-cp312-macosx_11_0_arm64.whl
βββ road_profile_viewer-0.1.0-cp312-cp312-macosx_10_14_x86_64.whl
βββ road_profile_viewer-0.1.0-cp312-cp312-win_amd64.whl
βββ road_profile_viewer-0.1.0-cp313-cp313-manylinux_2_17_x86_64.whl
βββ road_profile_viewer-0.1.0-cp313-cp313-macosx_11_0_arm64.whl
βββ road_profile_viewer-0.1.0-cp313-cp313-macosx_10_14_x86_64.whl
βββ road_profile_viewer-0.1.0-cp313-cp313-win_amd64.whl
manylinux: A standardized Linux wheel format that works on most Linux distributions.
macosx_11_0_arm64: Apple Silicon (M1/M2/M3).
macosx_10_14_x86_64: Intel Macs.
9. Publishing with uv
9.1 The Python Package Index (PyPI)
PyPI is the official repository for Python packages. When you run pip install something, it downloads from PyPI.
To publish your package:
- Create an account at pypi.org
- Generate an API token
- Upload your wheels
9.2 Test PyPI First
Always test on Test PyPI before publishing to production:
# Upload to Test PyPI
uv publish --publish-url https://test.pypi.org/legacy/
# Test installation
pip install -i https://test.pypi.org/simple/ road-profile-viewer
9.3 Production Publishing
Once tested, publish to the real PyPI:
# Publish all wheels and sdist
uv publish
This uploads everything in dist/ to PyPI.
9.4 Trusted Publishing (Recommended)
Instead of API tokens, use Trusted Publishing with GitHub Actions. This uses OpenID Connect (OIDC) for authenticationβno secrets to manage!
Add to your workflow:
publish:
name: Publish to PyPI
needs: [build_wheels, build_sdist]
runs-on: ubuntu-latest
permissions:
id-token: write # Required for trusted publishing
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: dist/
merge-multiple: true
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
Configure Trusted Publishing in your PyPI account settings to link your GitHub repository.
9.5 The Complete CI/CD Pipeline
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Release Pipeline β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β 1. Developer creates tag: git tag v0.1.0 && git push --tags β
β β β
β βΌ β
β 2. GitHub Actions triggered by tag push β
β β β
β βΌ β
β 3. cibuildwheel builds wheels for all platforms (parallel) β
β βββ ubuntu-latest β manylinux wheels β
β βββ macos-latest β macOS wheels (Intel + ARM) β
β βββ windows-latest β Windows wheels β
β β β
β βΌ β
β 4. uv build --sdist creates source distribution β
β β β
β βΌ β
β 5. All artifacts collected β
β β β
β βΌ β
β 6. pypa/gh-action-pypi-publish uploads to PyPI β
β (using Trusted Publishing - no tokens!) β
β β β
β βΌ β
β 7. Users can now: pip install road-profile-viewer β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
10. Performance Comparison
10.1 Benchmark Setup
Letβs measure the performance difference:
# benchmark.py
import time
import numpy as np
from road_profile_viewer.geometry import find_intersection, get_backend
# Generate a complex road profile
np.random.seed(42)
x_road = np.linspace(0, 100, 1000)
y_road = np.sin(x_road / 10) + np.random.random(1000) * 0.1
def benchmark(iterations=1000):
start = time.perf_counter()
for angle in np.linspace(5, 85, iterations):
find_intersection(x_road, y_road, angle, 0, 2.0)
elapsed = time.perf_counter() - start
return elapsed * 1000 # Convert to ms
print(f"Backend: {get_backend()}")
print(f"Time for 1000 calls: {benchmark():.1f} ms")
10.2 Results
| Implementation | Time (1000 calls) | Speedup |
|---|---|---|
| Python (NumPy) | ~150 ms | 1x (baseline) |
| C++ via pybind11 | ~15 ms | 10x faster |
10.3 When to Use C++
Good candidates for C++:
- Tight loops over data (like our intersection check)
- Mathematical computations without NumPy equivalents
- Real-time processing (audio, video, sensors)
- Memory-constrained environments
Keep in Python:
- I/O operations (file, network)
- Already optimized NumPy operations
- Code that changes frequently
- Glue code and configuration
11. Summary
11.1 Key Takeaways
-
Python C extensions are native code modules that the Python interpreter loads directly. Theyβve been part of Python since 1991.
-
pybind11 makes creating bindings easy: write normal C++ functions, add a few lines of binding code, done.
-
scikit-build-core is the modern build backend: it handles CMake, Python discovery, and wheel creation automatically.
-
uv build creates wheels locally. The wheel contains pre-compiled binaries that users can install without a compiler.
-
cibuildwheel builds wheels for all platforms in CI. One workflow, wheels for Windows, macOS, and Linux.
-
uv publish uploads to PyPI. With Trusted Publishing, you donβt even need API tokens.
-
The fallback pattern keeps your package working everywhere: try C++, fall back to Python.
11.2 The Complete Toolchain
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β THE C EXTENSION TOOLCHAIN β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β pybind11 scikit-build-core uv β
β βββββββββ βββββββββββββββββ ββ β
β C++ β Python CMake β Wheel Build β Publish β
β bindings packaging workflow β
β β
β cibuildwheel β
β ββββββββββββ β
β Cross-platform β
β CI builds β
β β
β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β
β β C++ βββββΊβ CMake βββββΊβ Wheel βββββΊβ PyPI β β
β β Code β β Build β β .whl β β Users β β
β ββββββββββββ ββββββββββββ ββββββββββββ ββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
11.3 The Party Trick
You now have C++ performance with Python convenience:
- C++ calculates the ray-road intersection in microseconds
- Python orchestrates the Dash callbacks and Plotly rendering
- Users never know the differenceβthey just see a fast, interactive app
12. Reflection Questions
-
When would you choose C++ over NumPy optimization? Consider memory layout, vectorization, and development time.
-
What are the trade-offs of maintaining both Python and C++ implementations? Think about testing, documentation, and consistency.
-
How would you decide which functions to port to C++? What profiling tools would you use?
-
What happens if a user installs your sdist instead of a wheel? How does scikit-build-core handle this?
-
Why is Trusted Publishing better than API tokens? Consider security and maintenance.
13. Whatβs Next
This concludes the Multi-Language Projects series. Youβve learned:
- Part 1: C++ fundamentals, CMake, build process
- Part 2: Code quality (clang-format, clang-tidy), testing (Google Test), coverage
- Part 3: Building and distributing C extensions with scikit-build-core and uv
In upcoming lectures, we will explore:
- Software Architecture Patterns: Structuring large systems
- Deployment Strategies: Docker, cloud platforms, embedded systems
14. Further Reading and References
This section provides comprehensive resources for deepening your understanding of Python C extensions, build systems, and the tools covered in this lecture.
14.1 Official Documentation
Core Tools:
- pybind11 Documentation β The complete guide to pybind11, including advanced features like smart holders, custom type casters, and embedding Python
- scikit-build-core Documentation β Build backend reference with configuration options and migration guides
- cibuildwheel Documentation β Comprehensive CI wheel building guide with platform-specific configurations
- uv Documentation β Modern Python package management, building, and publishing
Python Packaging:
- Python Packaging User Guide β The authoritative source for Python packaging best practices
- PyPA Build β PEP 517 build frontend documentation
- PyPI Help β Publishing packages, API tokens, and trusted publishing setup
Build Systems:
- CMake Documentation β Official CMake reference manual
- CMake Tutorial β Step-by-step introduction to CMake
- Modern CMake β Best practices for modern CMake usage
14.2 Specifications and Standards
Understanding the underlying standards helps you troubleshoot issues and make informed architectural decisions:
Python Enhancement Proposals (PEPs):
- PEP 517 β A build-system independent format for source trees (why scikit-build-core works)
- PEP 518 β Specifying minimum build system requirements (the
[build-system]table) - PEP 621 β Storing project metadata in pyproject.toml
- PEP 660 β Editable installs for PEP 517 backends
- PEP 427 β The wheel binary package format specification
Platform Standards:
- manylinux Policy β Linux wheel compatibility standards and container images
- Python Stable ABI β Building wheels compatible across Python versions
14.3 Deep Technical Resources
Python C API (for understanding, not daily use):
- Python C API Reference β Complete low-level C API documentation
- Extending Python with C β Official tutorial on C extensions (traditional approach)
- Python Developerβs Guide: C API β Internals for contributors
pybind11 Advanced Topics:
- pybind11 FAQ β Common issues and solutions
- pybind11 GitHub Issues β Search for specific problems
- pybind11 Discussions β Community Q&A
14.4 Video Resources and Tutorials
Conference Talks:
- CppCon 2017: βpybind11 β Seamless operability between C++11 and Pythonβ β Wenzel Jakob introduces pybind11 design principles
- PyData 2016: βExperiences with Cython, Pybind11, and CFFIβ β Practical comparison of binding approaches
- PyCon 2019: βPublishing (Perfect) Python Packages on PyPIβ β Mark Smith on modern packaging
- SciPy 2023: βBuilding and Distributing Compiled Python Extensionsβ β Henry Schreiner on scikit-build-core
Tutorial Series:
- Real Python: Python Bindings Overview β Comprehensive comparison of ctypes, CFFI, pybind11, Cython
- pybind11 Video Tutorial Series β Step-by-step pybind11 introduction
14.5 Books and In-Depth Guides
Recommended Books:
- βHigh Performance Pythonβ by Micha Gorelick & Ian Ozsvald (OβReilly, 2nd Edition)
- Chapter 7 covers Cython and C extensions in depth
- Practical performance optimization techniques
- βFluent Pythonβ by Luciano Ramalho (OβReilly, 2nd Edition)
- Chapter 24: βClass Metaprogrammingβ explains how Python types work internally
- Useful for understanding what pybind11 automates
- βModern CMake for C++β by RafaΕ ΕwidziΕski (Packt, 2022)
- Comprehensive modern CMake practices
- Directly applicable to scikit-build-core projects
Online Guides:
- Scientific Python Library Development Guide β Best practices from the NumPy/SciPy community
- An Introduction to Building Python Extensions β Official guide
14.6 Alternative Approaches
While this lecture focused on pybind11 + scikit-build-core, other approaches exist:
Binding Generators:
| Tool | Best For | Documentation |
|---|---|---|
| pybind11 | Modern C++11+ projects, NumPy integration | pybind11.readthedocs.io |
| nanobind | Smaller binaries, faster compile, C++17+ | nanobind.readthedocs.io |
| Cython | Python-like syntax, gradual optimization | cython.readthedocs.io |
| CFFI | Wrapping existing C libraries | cffi.readthedocs.io |
| SWIG | Multi-language bindings from one definition | swig.org |
| maturin | Rust β Python bindings | maturin.rs |
Build Backends:
| Backend | Use Case | Documentation |
|---|---|---|
| scikit-build-core | CMake-based C/C++ projects (recommended) | scikit-build-core.readthedocs.io |
| meson-python | Meson build system projects | meson-python.readthedocs.io |
| setuptools | Simple extensions, legacy projects | setuptools.pypa.io |
14.7 Real-World Examples
Learn from production-quality open source projects using these techniques:
pybind11 + scikit-build-core:
- scikit-image β Image processing library with C++ extensions
- Open3D β 3D data processing with pybind11
- awkward-array β Nested data structures with C++ backend
pybind11 Examples:
- pybind11 Examples Repository β Official test suite shows many patterns
- cmake_example β Minimal CMake + pybind11 template
- scikit-build-core Examples β Various configuration examples
cibuildwheel in Action:
- NumPy β The gold standard for wheel building
- Pillow β Complex dependencies handled elegantly
- cryptography β Security-critical builds
14.8 Community and Support
Forums and Q&A:
- Python Packaging Discourse β Official Python packaging discussions
- Stack Overflow: pybind11 β Community Q&A
- Stack Overflow: scikit-build β Build system questions
GitHub Discussions:
Chat:
- Scientific Python Discord β Real-time help from the community
- PyPA Discord β Python packaging authority community
14.9 Staying Current
The Python packaging ecosystem evolves rapidly. Stay updated:
Blogs and News:
- PyPI Blog β Official announcements about PyPI features
- Astral Blog β Updates on uv, ruff, and modern tooling
- Scientific Python Blog β NumPy/SciPy ecosystem news
Changelogs:
14.10 Quick Reference Card
Keep this handy for your projects:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PYTHON C EXTENSION QUICK REFERENCE β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β CREATE PROJECT: β
β uv init my-extension β
β # Edit pyproject.toml: build-backend = "scikit_build_core.build" β
β # Add CMakeLists.txt with pybind11_add_module() β
β β
β BUILD: β
β uv build # Creates dist/*.whl β
β uv pip install dist/*.whl # Install locally β
β β
β TEST: β
β uv run pytest # Run Python tests β
β python -c "from pkg import func; print(func())" β
β β
β PUBLISH: β
β uv publish --publish-url https://test.pypi.org/legacy/ # Test β
β uv publish # Prod β
β β
β CI (cibuildwheel): β
β uses: pypa/cibuildwheel@v2.21 β
β # Builds wheels for Linux, macOS, Windows automatically β
β β
β KEY FILES: β
β pyproject.toml - Project metadata + build config β
β CMakeLists.txt - C++ build configuration β
β src/pkg/module.cpp - pybind11 bindings β
β .github/workflows/ - CI/CD pipelines β
β β
β USEFUL LINKS: β
β pybind11: pybind11.readthedocs.io β
β scikit-build-core: scikit-build-core.readthedocs.io β
β cibuildwheel: cibuildwheel.pypa.io β
β uv: docs.astral.sh/uv β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ