The Rust programming language has gotten more prominent for writing compiled Python extensions. Currently, there is a bunch of boilerplate for wrapping writing up a Rust function and making it callable from Python. I enjoy exploring and prototyping code in Jupyter Notebooks, so I developed rustimport_jupyter to compile Rust code in Jupyter and have the compiled code available in Python! In this blog post, I will showcase a simple function, NumPy function, and Polar expression plugins. This blog post is runnable as a notebook on Google Colab.
Simple Rust Functions¶
rustimport_jupyter builds on top of rustimport to compile Python extensions written in Rust from Jupyter notebooks. After installing the rustimport_jupyter package from PyPI, we load the magic from within a Jupyter notebook:
%load_ext rustimport_jupyter
Next, we define a double function in Rust and prefixing the cell with the %%rustimport marker:
%%rustimport
use pyo3::prelude::*;
#[pyfunction]
fn double(x: i32) -> i32 {
    2 * x
}
The %%rustimport marker compiles the Rust code and imports the double function into the Jupyter notebook environment. This means, we can directly call it from Python!
double(34)
By default, %%rustimport is compiles without Rust optimizations. We can enable these optimizations by adding the --release flag:
%%rustimport --release
use pyo3::prelude::*;
#[pyfunction]
fn triple(x: i32) -> i32 {
    3 * x
}
triple(7)
NumPy in Rust¶
Rust's ecosystem contains many third party libraries that is useful for writing our custom functions. rustimport defines a custom //: comment syntax that we can use to pull in write our extensions. In this next example, we use PyO3/rust-numpy to define a NumPy function that computes a*x-y in Rust:
%%rustimport --release
//: [dependencies]
//: pyo3 = { version = "0.20", features = ["extension-module"] }
//: numpy = "0.20"
use pyo3::prelude::*;
use numpy::ndarray::{ArrayD, ArrayViewD};
use numpy::{IntoPyArray, PyArrayDyn, PyReadonlyArrayDyn};
fn axsy(a: f64, x: ArrayViewD<'_, f64>, y: ArrayViewD<'_, f64>) -> ArrayD<f64> {
    a * &x - &y
}
#[pyfunction]
#[pyo3(name = "axsy")]
fn axsy_py<'py>(
    py: Python<'py>,
    a: f64,
    x: PyReadonlyArrayDyn<'py, f64>,
    y: PyReadonlyArrayDyn<'py, f64>,
) -> &'py PyArrayDyn<f64> {
    let x = x.as_array();
    let y = y.as_array();
    let z = axsy(a, x, y);
    z.into_pyarray(py)
}
The pyo3(name = "axsy") Rust macro exports the compiled function as axsy in Python. We can now use axsy directly in Jupyter:
import numpy as np
a = 2.4
x = np.array([1.0, -3.0, 4.0], dtype=np.float64)
y = np.array([2.1, 1.0, 4.0], dtype=np.float64)
axsy(a, x, y)
Polars Expression Plugin¶
Recently, Polars added support for expression plugins to create user defined functions. With rustimport_jupyter, we can prototype quickly on an Polars expression directly in Jupyter! In this example, we compile a pig-laten expression as seen in Polar's user guide:
%%rustimport --module-path-variable=polars_pig_latin_module
//: [dependencies]
//: polars = { version = "*" }
//: pyo3 = { version = "*", features = ["extension-module"] }
//: pyo3-polars = { version = "0.9", features = ["derive"] }
//: serde = { version = "*", features = ["derive"] }
use pyo3::prelude::*;
use polars::prelude::*;
use pyo3_polars::derive::polars_expr;
use std::fmt::Write;
fn pig_latin_str(value: &str, output: &mut String) {
    if let Some(first_char) = value.chars().next() {
        write!(output, "{}{}ay", &value[1..], first_char).unwrap()
    }
}
#[polars_expr(output_type=Utf8)]
fn pig_latinnify(inputs: &[Series]) -> PolarsResult<Series> {
    let ca = inputs[0].utf8()?;
    let out: Utf8Chunked = ca.apply_to_buffer(pig_latin_str);
    Ok(out.into_series())
}
Note that we use --module-path-variable=polars_pig_latin_module, which saves the compiled module path as polars_pig_latin_module. With polars_pig_latin_module defined, we configure a language namespace for the Polars DataFrame:
import polars as pl
@pl.api.register_expr_namespace("language")
class Language:
    def __init__(self, expr: pl.Expr):
        self._expr = expr
    def pig_latinnify(self) -> pl.Expr:
        return self._expr.register_plugin(
            lib=polars_pig_latin_module,
            symbol="pig_latinnify",
            is_elementwise=True,
        )
With the language namepsace defined, we can now use it with Polars:
df = pl.DataFrame(
    {
        "convert": ["pig", "latin", "is", "silly"],
    }
)
out = df.with_columns(
    pig_latin=pl.col("convert").language.pig_latinnify(),
)
print(out)
Conclusion¶
For those who like prototyping and exploring in Jupyter notebooks, rustimport_jupyter enables you to explore the Rust ecosystem while easily connecting it to your Python code. You can try out the library by installing it with: pip install rustimport_jupyter 🚀!