Skip to main content

steelix

steelix transpiles Python into Rust code that calls libpython through PyO3.

It is meant to help move Python projects to Rust. You transpile the project once and get a Rust program that behaves like the original. Semantics and error messages stay the same, the standard library and third party packages are still imported from Python, and tracebacks still point at the Python source. You can deploy that program right away, then replace its functions with ordinary Rust one at a time, keeping it working after every step.

It is a bit like Nuitka, but the output is Rust instead of C, and the goal is readability rather than speed. Nuitka's C is meant for a compiler; steelix's Rust is meant to be read and edited by people, and becomes the starting point of the port. It is plain PyO3 plus a small runtime crate.

Example

import json


class Item:
    def __init__(self, name, price, qty=1):
        self.name = name
        self.price = price
        self.qty = qty

    def total(self):
        return self.price * self.qty


def load(text):
    try:
        rows = json.loads(text)
    except ValueError as e:
        print(f"bad input: {e}")
        return []
    return [Item(**row) for row in rows if row["qty"] > 0]


items = load('[{"name": "pen", "price": 2, "qty": 3}]')
print(sum(item.total() for item in items))

steelix transpile inventory.py -o out/ writes a Cargo crate. An excerpt of out/src/inventory.rs:

// inventory.py:14
pub fn load<'py>(m: &rt::Module<'py>, text: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
    let py = m.py();
    rt::frame!(m, "load", 14, |frame| {
        let rows = match (|| m.global("json")?.method1("loads", (text,)))() {
            Ok(value) => value,
            Err(err) if err.is_instance_of::<PyValueError>(py) => {
                let e = frame.catch(err);
                let _handling = rt::Handling::new(&e);
                rt::builtins::print(
                    py,
                    (rt::fstring(py, ("bad input: ", rt::format(&e, "")?))?,),
                )?;
                return Ok(PyList::empty(py).into_any());
            }
            Err(err) => return Err(err),
        };
        let list = PyList::empty(py);
        for row in rows.try_iter()? {
            let row = row?;
            if row.get_item("qty")?.gt(0)? {
                list.append(rt::call!(m.global("Item")?, **row)?)?;
            }
            py.check_signals()?;
        }
        Ok(list.into_any())
    })
}

pub mod item {
    use super::*;

    // inventory.py:5
    pub fn __init__<'py>(
        m: &rt::Module<'py>,
        slf: &Bound<'py, PyAny>,
        name: &Bound<'py, PyAny>,
        price: &Bound<'py, PyAny>,
        qty: &Bound<'py, PyAny>,
    ) -> PyResult<Bound<'py, PyAny>> {
        rt::frame!(m, "Item.__init__", 5, |_| {
            slf.set_attr("name", name)?;
            slf.set_attr("price", price)?;
            slf.set_attr("qty", qty)?;
            Ok(rt::none(m.py()))
        })
    }

    // inventory.py:10
    pub fn total<'py>(m: &rt::Module<'py>, slf: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyAny>> {
        rt::frame!(m, "Item.total", 10, |_| {
            slf.attr("price")?.mul(slf.attr("qty")?)
        })
    }
}

cargo build in out/ produces an executable that embeds libpython and prints 6, like python inventory.py. The class is a real Python class, load is a real Python function (inspect.signature, pickling and monkeypatching still work), and the generator expression is evaluated lazily as in CPython.

Installation

steelix needs Python 3.14 and a Rust toolchain.

pip install steelix
# or
uv tool install steelix

The generated crates depend on the steelix-runtime crate from crates.io.

Usage

steelix transpile app.py -o out/               # a crate for app.py and the project modules it imports
steelix transpile -m pkg --root src/ -o out/   # the same for `python -m pkg`
steelix build app.py                           # transpile, build and assemble a relocatable dist/app/
steelix test out/ -- tests/                    # run the project's pytest suite against the native modules
steelix coverage app.py                        # how much of the code becomes Rust
steelix attrs app.py --class Point             # who reads and writes the attributes of a class

When building a generated crate yourself, point PyO3 at the interpreter whose packages the program uses: PYO3_PYTHON=/path/to/venv/bin/python cargo build.

steelix build bundles the Python runtime and the site-packages of the build environment next to the executable, so the result runs on a machine without Python (Linux, glibc at least as new as the build machine's).

Rewriting

Each generated function keeps its Python calling convention, so it can be rewritten on its own: keep the signature as an adapter, move the logic into plain Rust, convert the arguments at the boundary. The rest of the program, including Python code that calls the function, does not notice. examples/logsum shows a small CLI before and after rewriting some of its functions.

Python containers can stay as they are at first (a PyDict is fine in Rust code), so the first step only changes the logic, not the data model.

The project's own tests check each step. steelix test out/ -- tests/ builds the crate and runs python -m pytest tests/ with the generated executable acting as the Python interpreter: every import of a transpiled module gets the native code, wherever the project directory is, and the run fails if a transpiled module was imported from its Python source instead. pytest comes from the environment of the interpreter the crate is built with (--python, default $PYO3_PYTHON).

Classes are rewritten the same way, method by method, with the instance state left in __dict__. Turning a class into a #[pyclass] struct has to wait until every function that reads or writes its attributes is rewritten, and in real projects those are often spread over several modules. steelix attrs app.py lists, for every class, each attribute and the functions that read, write or delete it (file and line), the accesses whose receiver's class it can not tell but whose name matches, and dynamic access such as setattr with a computed name. steelix attrs app.py --pyclass pkg.mod.Point prints a draft #[pyclass] for one class, with fields, method stubs and a header listing what complicates the change (subclasses, metaclasses, decorators, class body logic, dynamic access, reassigned class attributes). The draft is advice, never part of the build.

--mode ext builds a PyO3 extension module instead of an executable, for publishing a transpiled library. Install a package as pkg/__init__.so so that python -m pkg works.

What is missing

  • Async generators (async def with yield), async comprehensions and a few rare uses of await and yield (in a lambda, in an except clause, types.coroutine) are kept as Python. Coroutines and generator functions are native: they become async fns whose .await points are the Python await and yield expressions, driven by a small runtime object that behaves like CPython's coroutine and generator.
  • OR patterns with captures in match statements (case [x] | {"x": x}:) and a few rarer constructs (custom metaclasses, exec/eval without a namespace, ...) are kept as Python. Other match statements are native: each case becomes an if let .. && .. chain over small runtime checks that behave like CPython's pattern matching, down to the order of len(), __eq__ and attribute lookups.
  • Executables for Windows and macOS are not supported yet, only Linux.
  • Free-threaded Python is not supported.

"Kept as Python" means the statement or function stays Python source inside the generated Rust and runs in the same module globals, so any program CPython runs can be transpiled. steelix transpile and steelix coverage report what was kept and why.

Status

Early, but tested against CPython: every test case, six command line tools of the standard library, the standard library json package and a small Scheme interpreter run with the same output and the same tracebacks as under CPython, and 154 standard library modules transpile into crates that compile without warnings.

License

BSD 3-Clause, see LICENSE.

Metadata

Release files for steelix 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for steelix 0.2.0
File Size Uploaded Provenance Actions
steelix-0.2.0.tar.gz 179.2 kB Without provenance Details for steelix-0.2.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for steelix 0.2.0
File Interpreter ABI Platform Actions
steelix-0.2.0-py3-none-any.whl Python 3 none any Details for steelix-0.2.0-py3-none-any.whl

Total release size: 382.0 kB

Release files / steelix-0.2.0.tar.gz

Download URL steelix-0.2.0.tar.gz
Size 179.2 kB
Tags Source
SHA-256 checksum
How to use checksums
a0705e425652e989e648c4dfa0345e1f54a4cfb7253d1f0e4cd6b534ec2b7dcc
BLAKE2b-256 checksum
How to use checksums
4f7c58ce7fa89bfa7d4f6fc96c4a6c5317fa1a45a6bf68184f09fe1296165d9e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.7 {"installer":{"name":"uv","version":"0.13.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":null,"id":"forky","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / steelix-0.2.0-py3-none-any.whl

Download URL steelix-0.2.0-py3-none-any.whl
Size 202.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
61838c55551a74acd5c66b241799f53a3db6e499a9aefe1c8b01e9c706077bf1
BLAKE2b-256 checksum
How to use checksums
75425f02e34d159c58b6944daa4b61ec207408c53808e956a9c1c95147ca1d01
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.13.7 {"installer":{"name":"uv","version":"0.13.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":null,"id":"forky","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Security for steelix 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page