Usage with setup.py

While pyproject.toml-based configuration will be enough for most projects, sometimes you may need to use custom logic and imperative programming during the build. For those scenarios, setuptools also allows you to specify project configuration via setup.py in addition to pyproject.toml.

The following is a very basic tutorial that shows how to use setuptools-rust in your setup.py.

Basic implementation files

Let’s start by assuming that you already have a bunch of Python and Rust files[1] that you would like to package for distribution in PyPI inside of a project directory named hello-world-setuppy[2][3]:

hello-world-setuppy
├── Cargo.lock
├── Cargo.toml
├── python
│   └── hello_world
│       └── __init__.py
└── rust
    └── lib.rs
from ._lib import sum_as_string

__all__ = ["sum_as_string"]

# It is a common practice in Python packaging to keep the extension modules
# private and use Pure Python modules to wrap them.
# This allows you to have a very fine control over the public API.
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. The name of this function must match
/// the `lib.name` setting in the `Cargo.toml`, else Python will not be able to
/// import the module.
#[pymodule]
fn _lib(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_as_string, m)?)?;
    Ok(())
}
[package]
name = "hello-world-setuppy"
version = "0.1.0"
edition = "2021"

[dependencies]
pyo3 = "0.21.0"

[lib]
# See https://github.com/PyO3/pyo3 for details
name = "_lib"  # private module to be nested into Python package
path = "rust/lib.rs"
crate-type = ["cdylib"]

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

Adding files to support packaging

Now we start by adding a pyproject.toml which tells anyone that wants to use our project to use setuptools and setuptools-rust to build it:

[build-system]
requires = ["setuptools", "wheel", "setuptools-rust"]
build-backend = "setuptools.build_meta"

… and a setup.py configuration file that tells Setuptools how to build the Rust extensions using our Cargo.toml and setuptools-rust:

from setuptools import find_packages, setup

from setuptools_rust import Binding, RustExtension

setup(
    name="hello-world",
    version="1.0",
    packages=find_packages(where="python"),
    package_dir={"": "python"},
    rust_extensions=[
        RustExtension(
            "hello_world._lib",
            # ^-- The last part of the name (e.g. "_lib") has to match lib.name
            #     in Cargo.toml and the function name in the `.rs` file,
            #     but you can add a prefix to nest it inside of a Python package.
            path="Cargo.toml",  # Default value, can be omitted
            binding=Binding.PyO3,  # Default value, can be omitted
        )
    ],
    # rust extensions are not zip safe, just like C-extensions.
    # But `zip_safe=False` is an obsolete config that does not affect how `pip`
    # or `importlib.{resources,metadata}` handle the package.
)
# See reference for RustExtension in https://setuptools-rust.readthedocs.io/en/latest/reference.html

For a complete reference of the options supported by the RustExtension class, see the API reference.

We also add a MANIFEST.in file to control which files we want in the source distribution[4]:

include Cargo.toml
recursive-include rust *
recursive-include python *

Testing the extension

With these files in place, you can install the project in a virtual environment for testing and making sure everything is working correctly:

# cd hello-world-setuppy
python3 -m venv .venv
source .venv/bin/activate  # on Linux or macOS
.venv\Scripts\activate     # on Windows
python -m pip install -e .
python -c 'import hello_world; print(hello_world.sum_as_string(5, 7))'  # => 12
# ... better write some tests with pytest ...

Next steps and final remarks

  • When you are ready to distribute your project, have a look on the notes in the documentation about building wheels.

  • You can also use a RustBin object (instead of a RustExtension), if you want to distribute a binary executable written in Rust (instead of a library that can be imported by the Python runtime). Note however that distributing both library and executable (or multiple executables), may significantly increase the size of the wheel file distributed by the package index and therefore increase build, download and installation times. Another approach is to use a Python entry-point that calls the Rust implementation (exposed via PyO3 bindings). See the hello-world example for more insights.

  • If want to include both RustBin and RustExtension same macOS wheel, you might have to manually add an extra build.rs file, see PyO3/setuptools-rust#351 for more information about the workaround.

  • Since the adoption of PEP 517, running python setup.py ... directly as a CLI tool is considered deprecated. Nevertheless, setup.py can be safely used as a configuration file (the same way conftest.py is used by pytest or noxfile.py is used by nox). There is a different mindset that comes with this change, though: for example, it does not make sense to use sys.exit(0) in a setup.py file or use a overarching try...except... block to re-run a failed build with different parameters.