Rust Extensions

This page explains how to add your own async Rust code and use it alongside rsloop in Python.

The idea is:

  • Python still uses rsloop as the event loop
  • your own PyO3 extension module exposes extra functions
  • those functions return Python awaitables backed by Rust futures

rsloop provides a small Rust helper module for this:

  • rsloop::rust_async::get_current_locals(...)
  • rsloop::rust_async::future_into_py(...)
  • rsloop::rust_async::future_into_py_with_locals(...)
  • rsloop::rust_async::local_future_into_py(...)
  • rsloop::rust_async::local_future_into_py_with_locals(...)
  • rsloop::rust_async::TaskLocals
  • rsloop::rust_async::into_future_with_locals(...)

When to use this

Use this pattern when:

  • you want to keep the main application in Python
  • you want some operations implemented in Rust
  • those Rust operations should be await-able from Python
  • you want them to run under the same active rsloop loop

This is not for modifying rsloop itself. It is for building a separate Rust extension crate that depends on rsloop.

What the developer builds

You usually create a second crate with:

  1. a Cargo.toml for your PyO3 extension
  2. a pyproject.toml for maturin
  3. Rust functions marked with #[pyfunction]
  4. a #[pymodule] that exports those functions

The important part is that each async Rust function should return a Python awaitable using rsloop::rust_async::future_into_py(...).

Minimal Rust example

use std::time::Duration;

use pyo3::prelude::*;

#[pyfunction]
fn sleep_and_tag(py: Python<'_>, label: String, delay_ms: u64) -> PyResult<Bound<'_, PyAny>> {
    rsloop::rust_async::future_into_py(py, async move {
        async_std::task::sleep(Duration::from_millis(delay_ms)).await;
        Ok(format!("rust finished: {label}"))
    })
}

#[pymodule(gil_used = false)]
fn my_rust_ext(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sleep_and_tag, m)?)?;
    Ok(())
}

In this example:

  • the Python caller gets an awaitable back immediately
  • the real work happens in the Rust future
  • the future is attached to the currently running Python event loop

Python side usage

From Python, the Rust function looks like a normal async operation:

import asyncio
import rsloop
import my_rust_ext


async def main() -> None:
    loop = asyncio.get_running_loop()
    print(type(loop).__name__)

    result = await my_rust_ext.sleep_and_tag("hello from python", 50)
    print(result)


rsloop.run(main())

This works because rsloop.run(...) creates the running loop first, and then the Rust extension captures that loop when future_into_py(...) is called.

Project setup

Your extension crate should depend on:

[dependencies]
async-std = "1"
pyo3 = { version = "0.28", features = ["extension-module"] }
rsloop = { version = "0.1.14" }

For a real package, replace the path dependency with the form that matches your project layout.

Your pyproject.toml should use maturin in the normal PyO3 way:

[build-system]
requires = ["maturin>=1.7,<2"]
build-backend = "maturin"

[tool.maturin]
module-name = "my_rust_ext"

How the bridge works

future_into_py(...) does two important things:

  1. It captures the current Python event loop and contextvars.
  2. It converts the Rust future into a Python awaitable.

That means your Rust future can be awaited from Python code that is already running on rsloop.

If you need lower-level control, use get_current_locals(...) and future_into_py_with_locals(...) directly. That is useful when you capture the loop/context once and reuse it across multiple Rust operations.

Calling Python awaitables from Rust

rsloop also re-exports into_future_with_locals(...).

Use that when your Rust future needs to await a Python awaitable:

use pyo3::prelude::*;

fn call_python_awaitable(
    py: Python<'_>,
    awaitable: Py<PyAny>,
) -> PyResult<Bound<'_, PyAny>> {
    let locals = rsloop::rust_async::get_current_locals(py)?;
    rsloop::rust_async::future_into_py_with_locals(py, locals.clone(), async move {
        let python_future = Python::attach(|py| {
            rsloop::rust_async::into_future_with_locals(&locals, awaitable.bind(py).clone())
        })?;
        let value = python_future.await?;
        Ok(value)
    })
}

That pattern is more advanced, but it is useful when Rust is coordinating both Rust futures and Python coroutines.

Full example in this repository

The repository includes a complete example in:

  • examples/rust/src/lib.rs
  • examples/rust/demo.py
  • examples/rust/README.md

If you want the shortest end-to-end check from the repository root, run:

uv run --with . --with ./examples/rust python examples/rust/demo.py

Practical advice

  • Keep the Rust extension separate from the rsloop crate itself.
  • Start with small #[pyfunction] wrappers that return one awaitable each.
  • Prefer returning normal Python-friendly values such as strings, integers, dictionaries, and lists.
  • Use future_into_py(...) first. Drop to the *_with_locals(...) variants only when you actually need them.
  • Make sure your Rust function is called while a Python event loop is already running.

If you call the helper outside a running event loop, capturing the current loop will fail, because there is no active Python loop to attach to.