Extensions with Rust

Extensions with Rust#

Rust has enjoyed fantastic synergy with Python; it’s one of the reasons Rust has been doing so well in the Python extension module landscape. There are two key projects: PyO3, which functions as a powerful pure Rust library for creating extensions (very much like pybind11 for C++), and Maturin, a very simple modern build system tied to Cargo (much like Scikit-build-core and CMake for C/C++/Fortran languages).

Getting started#

Creating a new project is easy. Install maturin (cargo install maturin, brew install maturin, pipx install maturin, etc). Then you can use the maturin command line tool to quickly make a new project:

$ maturin new rust_example

If you don’t provide a binding mechanism to use, it will ask you. It will default to a pure-Rust project; you can add flags to get a mixed Python and Rust project instead (most more advanced projects will have at least some Python parts). Check the flags with --help/-h.

Now, you should have a project like this:

rust_example
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs

Your Cargo.toml should look something like this:

[package]
name = "rust_example"
version = "0.1.0"
edition = "2021"

[lib]
name = "rust_example"
crate-type = ["cdylib"]

[dependencies]
pyo3 = {version = "0.21.1", features = ["abi3-py38"]}

The standard rust package stuff is at the top. The lib table has the library name (this must match the module name), and the crate-type, which must include cdylib to be importable in Python. If you want to access it from Rust too, you can add to this list.

The line that might look a little different vs. the template is the dependencies; we’ve ensured that we have a version of pyo3 new enough to use the new Bound, and we are compiling for the Limited ABI - which will make our compiles without needing Python (at least on Unix) and allow us to support all versions of Python newer than some minimum with a single binary. The cost is a few features will be disabled (less if the version is higher), and some optimizations will be disabled.

The pyproject.toml file should look pretty normal:

[build-system]
requires = ["maturin>=1.5,<2.0"]
build-backend = "maturin"

[project]
name = "rust_example"
requires-python = ">=3.9"
classifiers = [
    "Programming Language :: Rust",
    "Programming Language :: Python :: Implementation :: CPython",
    "Programming Language :: Python :: Implementation :: PyPy",
]
dynamic = ["version"]

[tool.maturin]
features = ["pyo3/extension-module"]

The main thing of note here is the tool.maturin.features field, which tells it you have a PyO3 module. Otherwise, this is pretty similar to other build backends. It it able to pull the version from the Cargo.toml file if you include "version" in the dynamic list.

Finally, we have the library itself. Here’s the simple template example, src/lib.rs:

use pyo3::prelude::*;

/// Formats the sum of two numbers as string.
#[pyfunction]
fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
    Ok((a + b).to_string())
}

/// A Python module implemented in Rust.
#[pymodule]
fn rust_example(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
    Ok(())
}

It starts with a use statement that pulls in the basics for using PyO3.

Then there’s a docstring on a function (notice the triple slashes) - PyO3 will actually be able to capture this for the Python docstring! You can’t natively do this in C++; you can write scripts that try to collect these, but Rust supports it natively.

The function is annotated with #[pyfunction], which will make it a function you can add to Python. You return a PyResult<...> if a function could “throw” an error in Python. Functions that don’t ever throw errors can just return normal Rust values that have known conversions. Otherwise, it’s pretty normal. (Actually, we don’t ever return a non-OK value in this example, so feel free to simplify this to just return String).

Now, we have a module. This is a function that sets up a module by taking a Bound PyModule and running .add_* functions on it to add (much like pybind11). Functions need to be given in the wrap_pyfunction! macro.

This is the classic interface; there’s a new interface based on Rust inline modules that is much nicer, as well. Here’s the new interface, currently (0.21) requires the experimental-declarative-modules (PyO3) feature:

/// A Python module implemented in Rust.
#[pymodule]
mod rust_example {
  use super::*;

  /// Formats the sum of two numbers as string.
  #[pyfunction]
  fn sum_as_string(a: usize, b: usize) -> PyResult<String> {
      Ok((a + b).to_string())
  }
}

Our square example would look like this:

use pyo3::prelude::*;

#[pymodule]
mod rust_example {
  use super::*;

  #[pyfunction]
  fn square(a: usize, b: usize) -> usize {
      a + b
  }
}

Building and running#

You can use all the usual Python tools (like pip, build, etc), but you can also build directly with maturin. In many cases, this will skip many of the Python calls altogether. The maturin build command will build a wheel. The maturin develop command will do an editable install (requires pip in the venv you are building in).