Rust - Python binding - Setting up PyO3 crate with Maturin

When you want to make large computations, using Python may not be the best solution.

By building a Python module from Rust, we get the best of both worlds: Python productivity and Rust speed.

In this tutorial we'll cover the basic integration process using PyO3, a Rust crate, for the bindings and Maturin, a Python tool, for managing the dynamic library.

First of all

Github of this tutorial: https://github.com/badprog/badprog-rust-binding-python-with-pyo3

And versions used:

The Rust module part

Our project is split into subprojects.

So each subproject has its own Cargo.toml file.

But to keep things simple, we'll only create one subproject: classic_operations.

Our subproject is a Rust library.

It contains only one module: computation.rs.

This module has a struct Computation with 4 fields.

This struct is implemented and contains a constructor method new() as well as several other methods such as:

  • add()
  • sub()
  • div()
  • mul()

These methods are respectively for adding, subtracting, dividing and multiplying floats.

It also includes their getter methods in order to retrieve the values stored in the struct.

We included unit tests for each of them in the same module.

The Rust library and binding part

In order to generate our Rust bindings for Python, there are many ways to accomplish this goal.

For this tutorial, we're going to use the PyO3 crate to act as an API and wrap our Rust code into something that Python will be able to use as a standard Python module.

To help in this task, PyO3 brings procedural macros such as #[pyclass] or #[pymethods] to signal that the following struct or impl block is exposed as native Python class or methods.

For the #[pymodule] one, it tells the compiler that the following function is the module name that Python will have to import.

In our case this name is rust_classic_operations.

The Python part

To test our binding library, we'll create a simple Python file in order to verify that every Rust methods are callable from Python.

To import this module we have to use the name rust_classic_operations we have given it. 

Code 

/Cargo.toml

[workspace]
members = [
    "classic_operations",
]

resolver = "3"

 

/binding.py

#
import rust_classic_operations

# Comp intantiation
comp = rust_classic_operations.PyComputation()

# Add
comp.add(10, 50)
comp.add(5, 100)
comp.add(8, 40)

total_add = comp.get_val_add()
print(f"Total add: {total_add}")

# Sub
comp.sub(10, 50)
comp.sub(5, 100)
comp.sub(8, 40)

total_sub = comp.get_val_sub()
print(f"Total sub: {total_sub}")

# Div
comp.div(10, 50)
comp.div(5, 100)
comp.div(8, 40)

total_div = comp.get_val_div()
print(f"Total div: {total_div}")

# Mul
comp.mul(10, 50)
comp.mul(5, 100)
comp.mul(8, 40)

total_mul = comp.get_val_mul()
print(f"Total mul: {total_mul}")

 

/classic_operations/src/Cargo.toml

[package]
name = "classic_operations"
version = "0.1.0"
edition = "2024"

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

[dependencies]
pyo3 = { version = "0.27.1", features = ["extension-module"] }
 

/classic_operations/src/computation.rs

// badprog.com
pub struct Computation {
    val_add: f64,
    val_sub: f64,
    val_div: f64,
    val_mul: f64,
}

impl Computation {
    // ========================================================================
    // new()
    pub fn new() -> Self {
        Self {
            val_add: 0.0,
            val_sub: 0.0,
            val_div: 0.0,
            val_mul: 0.0,
        }
    }

    // ========================================================================
    // add()
    pub fn add(&mut self, left: f64, right: f64) -> f64 {
        let result = left + right;

        self.val_add += result;

        result
    }

    // ========================================================================
    // get_val_add()
    pub fn get_val_add(&self) -> f64 {
        self.val_add
    }

    // ========================================================================
    // sub()
    pub fn sub(&mut self, left: f64, right: f64) -> f64 {
        let result = left - right;

        self.val_sub += result;

        result
    }

    // ========================================================================
    // get_val_sub()
    pub fn get_val_sub(&self) -> f64 {
        self.val_sub
    }

    // ========================================================================
    // div()
    pub fn div(&mut self, left: f64, right: f64) -> f64 {
        let result = left / right;

        self.val_div += result;

        result
    }

    // ========================================================================
    // get_val_div()
    pub fn get_val_div(&self) -> f64 {
        self.val_div
    }

    // ========================================================================
    // mul()
    pub fn mul(&mut self, left: f64, right: f64) -> f64 {
        let result = left * right;

        self.val_mul += result;

        result
    }

    // ========================================================================
    // get_val_div()
    pub fn get_val_mul(&self) -> f64 {
        self.val_mul
    }
}

mod tests {
    // use super::*;
    // use crate::computation;

    #[test]
    fn test_computation_add() {
        let left = 10;
        let right = 5;
        let expected_value = 15;
        let mut comp = computation::Computation::new();
        let result = comp.add(left, right);
        // assert_eq
        assert_eq!(result, expected_value);
        assert_eq!(comp.get_val_add(), expected_value);
    }

    #[test]
    fn test_computation_sub() {
        let left = 10;
        let right = 5;
        let expected_value = 5;
        let mut comp = computation::Computation::new();
        let result = comp.sub(left, right);
        // assert_eq
        assert_eq!(result, expected_value);
        assert_eq!(comp.get_val_sub(), expected_value);
    }

    #[test]
    fn test_computation_div() {
        let left = 10;
        let right = 5;
        let expected_value = 2;
        let mut comp = computation::Computation::new();
        let result = comp.div(left, right);
        // assert_eq
        assert_eq!(result, expected_value);
        assert_eq!(comp.get_val_div(), expected_value);
    }

    #[test]
    fn test_computation_mul() {
        let left = 10;
        let right = 5;
        let expected_value = 50;
        let mut comp = computation::Computation::new();
        let result = comp.mul(left, right);
        // assert_eq
        assert_eq!(result, expected_value);
        assert_eq!(comp.get_val_mul(), expected_value);
    }
}

 

/classic_operations/src/lib.rs

pub mod computation;

use crate::computation::Computation;
use pyo3::prelude::*;

#[pyclass]
pub struct PyComputation {
    pub inner: computation::Computation,
}

#[pymethods]
impl PyComputation {
    #[new]
    pub fn new() -> Self {
        Self {
            inner: Computation::new(),
        }
    }

    // ========================================================================
    // add()
    pub fn add(&mut self, left: f64, right: f64) -> f64 {
        self.inner.add(left, right)
    }
    pub fn get_val_add(&self) -> f64 {
        self.inner.get_val_add()
    }

    // ========================================================================
    // sub()
    pub fn sub(&mut self, left: f64, right: f64) -> f64 {
        self.inner.sub(left, right)
    }
    pub fn get_val_sub(&self) -> f64 {
        self.inner.get_val_sub()
    }

    // ========================================================================
    // div()
    pub fn div(&mut self, left: f64, right: f64) -> f64 {
        self.inner.div(left, right)
    }
    pub fn get_val_div(&self) -> f64 {
        self.inner.get_val_div()
    }

    // ========================================================================
    // mul()
    pub fn mul(&mut self, left: f64, right: f64) -> f64 {
        self.inner.mul(left, right)
    }
    pub fn get_val_mul(&self) -> f64 {
        self.inner.get_val_mul()
    }
}

#[pymodule]
fn rust_classic_operations(
    _py: Python,
    m: &pyo3::Bound<'_, pyo3::types::PyModule>,
) -> pyo3::PyResult<()> {
    m.add_class::<PyComputation>()?;
    Ok(())
}

 

Generating the library

To generate the Rust binding library we're going to use Maturin.
It's a tool used to manage the compilation and the path where the dynamic library will be generated.

Before going further, let's create a virtual environment for Python:

python -m venv .venv1

Then, let's activate it:

. .venv1/bin/activate

With our Python setup ready, let's install the Maturin Python tool:

pip install maturin

Use it to generate the dynamic library then allowing Python to access it:

maturin develop -m classic_operations/Cargo.toml

Note here that we need to explicitely specify where is the Cargo.toml file to load because we use subprojects (potentially many Cargo.toml files). 

It's also possible to go to the subproject directly and type the command without specifying the path of Cargo.toml (just "maturin develop").

It's now time to check with our Python file if our library really works.

python binding.py 

You should see this output:

Total add: 213.0

Total sub: -167.0

Total div: 0.45

Total mul: 1320.0

If it's the case then great job, you did it cool

 

Add new comment

Plain text

  • No HTML tags allowed.
  • Lines and paragraphs break automatically.