Home

07 Multi-Language Projects: Building Python C Extensions

lecture cpp python pybind11 scikit-build-core c-extension cibuildwheel uv pypi wheels

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

🎭 The Multi-Language Party Trick
⚑
C++ Engine
find_intersection() 10x faster
β†’
πŸ”—
Python Bridge
pybind11 Type conversion
β†’
πŸ“Š
Plotly/Dash
Interactive UI Zero effort
Performance
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 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:

  1. What C extensions are and why they exist
  2. pybind11 for creating the Python-C++ bridge
  3. scikit-build-core as the modern build backend
  4. uv build to create wheels locally
  5. cibuildwheel for cross-platform CI builds
  6. 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:

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.

🐍 CPython Architecture
Your Python Code
β–Ό
Python Parser
Converts text to AST
β–Ό
Bytecode Compiler
Compiles AST to .pyc
β–Ό
Python VM
(Interpreter)
◄──►
C Extension
Module (.so)
Your C code!
β–Ό
Operating System / Hardware

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:

  1. Manage reference counts: Every PyObject* has a reference count. Forget to increment or decrement it, and you get memory leaks or crashes.

  2. Handle errors manually: Every C API call can fail. You must check return values and propagate errors correctly.

  3. Convert types manually: PyArg_ParseTuple and Py_BuildValue use format strings that are easy to get wrong.

  4. Write lots of boilerplate: Method tables, module definitions, initialization functions…

  5. 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:


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:

  1. Introspect function signatures at compile time
  2. Generate type conversion code automatically
  3. 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):

Platform wheels (...-cp312-cp312-win_amd64.whl):

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?

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:

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:

  1. Platform-specific paths and flags
  2. No standard way to find dependencies
  3. Limited CMake integration
  4. 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:

  1. Invokes CMake to configure the build
  2. Compiles the C++ code
  3. 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:

# 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:

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:

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:

  1. Create an account at pypi.org
  2. Generate an API token
  3. 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++:

Keep in Python:


11. Summary

11.1 Key Takeaways

  1. Python C extensions are native code modules that the Python interpreter loads directly. They’ve been part of Python since 1991.

  2. pybind11 makes creating bindings easy: write normal C++ functions, add a few lines of binding code, done.

  3. scikit-build-core is the modern build backend: it handles CMake, Python discovery, and wheel creation automatically.

  4. uv build creates wheels locally. The wheel contains pre-compiled binaries that users can install without a compiler.

  5. cibuildwheel builds wheels for all platforms in CI. One workflow, wheels for Windows, macOS, and Linux.

  6. uv publish uploads to PyPI. With Trusted Publishing, you don’t even need API tokens.

  7. 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:


12. Reflection Questions

  1. When would you choose C++ over NumPy optimization? Consider memory layout, vectorization, and development time.

  2. What are the trade-offs of maintaining both Python and C++ implementations? Think about testing, documentation, and consistency.

  3. How would you decide which functions to port to C++? What profiling tools would you use?

  4. What happens if a user installs your sdist instead of a wheel? How does scikit-build-core handle this?

  5. 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:

In upcoming lectures, we will explore:


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:

Python Packaging:

Build Systems:

14.2 Specifications and Standards

Understanding the underlying standards helps you troubleshoot issues and make informed architectural decisions:

Python Enhancement Proposals (PEPs):

Platform Standards:

14.3 Deep Technical Resources

Python C API (for understanding, not daily use):

pybind11 Advanced Topics:

14.4 Video Resources and Tutorials

Conference Talks:

Tutorial Series:

14.5 Books and In-Depth Guides

Recommended Books:

Online Guides:

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:

pybind11 Examples:

cibuildwheel in Action:

14.8 Community and Support

Forums and Q&A:

GitHub Discussions:

Chat:

14.9 Staying Current

The Python packaging ecosystem evolves rapidly. Stay updated:

Blogs and 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                             β”‚
β”‚                                                                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
© 2026 Dominik Mueller   β€’  Powered by Soopr   β€’  Theme  Moonwalk