Build and Run a Rust Project from Quarto Using Python

Rust
Quarto
Python
Cargo
rustc
Author

Galen Seilis

Published

July 29, 2024

In a previous post I used a Lua extension to compile a Rust file using the rustc compiler. I ran into multiple problems.

Using rustc rather than Cargo means that I miss out on a lot of the build tools, and it is also less conventional for Rust projects.

There were also issues with my plugin. It did not correctly turn off echo either locally to a code block, or to the global setting in the preamble of the Quarto file. It also did not put code on a new line. Further, I got feedback that this might not be supported. I also learned from a discussion answer on the Quarto GitHub discussion board that there are tools that might be better.

While other tools like evcxr look appealing, I have not looked into how to exactly integrate it with Quarto yet.

But there is a low-hanging fruit we can take advantage of here. We can certainly use the Python subprocess library to indirectly orchestrate building and running a rust project. It also allows us to capture the output as text and return that into a Jupyter notebook. So that’s exactly what I made:

"""Compile and run a Rust project with Cargo, returning its standard output.

The project is built in release mode with ``cargo build --release`` and the
resulting executable in ``target/release`` is run so its output can be
captured. This is the standalone version of the code that used to live in the
"Build and Run a Rust Project from Quarto Using Python" post.
"""

import subprocess
import os
import stat


def log_permissions(path):
    st = os.stat(path)
    permissions = stat.filemode(st.st_mode)
    print(f"Permissions for {path}: {permissions}")


def compile_and_run_rust(target_file):
    # Get the directory and the file name
    target_dir = os.path.dirname(target_file)
    target_name = os.path.basename(
        target_dir
    )  # Adjusted to get the correct target name

    # Ensure Cargo.toml exists in the target directory
    cargo_toml_path = os.path.join(target_dir, "Cargo.toml")
    if not os.path.exists(cargo_toml_path):
        raise FileNotFoundError("Cargo.toml not found in the target directory.")

    # Compile the Rust project
    try:
        build_process = subprocess.run(
            ["cargo", "build", "--release"],
            cwd=target_dir,
            check=True,
            capture_output=True,
            text=True,
        )
    except subprocess.CalledProcessError as e:
        print(f"Compilation Error: {e.stderr}")
        return

    # Find the compiled executable
    target_exe = os.path.join(target_dir, "target", "release", target_name)
    if os.name == "nt":
        target_exe += ".exe"

    if not os.path.exists(target_exe):
        raise FileNotFoundError("Compiled executable not found.")

    if os.name != "nt":
        try:
            os.chmod(
                target_exe,
                stat.S_IRWXU
                | stat.S_IRGRP
                | stat.S_IXGRP
                | stat.S_IROTH
                | stat.S_IXOTH,
            )
            os.chmod(
                os.path.dirname(target_exe), stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO
            )
        except PermissionError as e:
            print(f"Error setting permissions: {e}")
            return

    # Run the compiled executable and capture its output
    try:
        run_process = subprocess.run(
            [target_exe], check=True, capture_output=True, text=True
        )
        output = run_process.stdout
        return output
    except subprocess.CalledProcessError as e:
        print(f"Execution Error: {e.stderr}")
        return


# Example usage
if __name__ == "__main__":
    here = os.path.dirname(os.path.abspath(__file__))
    output = compile_and_run_rust(os.path.join(here, "..", "hello", "main.rs"))
    print(output)

Python Jupyter notebooks run Python in interactive mode, so it is slightly less convenient for importing Python files. Nonetheless this can be done by inserting our script into the path using the sys library. Once we have imported the run_rust file, we can call the compile_and_run_rust pointing to a Rust project path that is locally stored.

Let us start a Rust project called “hello”.

cargo init hello

I also added a loop with a println macro just so we can see how this approach handles keeping newline characters. Here is the Rust code in hello/src/main.rs.

fn main() {

    let mut count = 0;

    loop {
        count = count + 1;
        println!("{} Hello, world!", count);
        if count > 11 {
            break
        }
    }
}

The above Rust code should print a series of lines each starting with a number, with the numbers ranging from 1 to 12.

With all that setup, we can now try using the run_rust.compile_and_run_rust process caller.

"""Build and run the ``hello`` Rust project and print its output."""

import os

import run_rust

here = os.path.dirname(os.path.abspath(__file__))

print(run_rust.compile_and_run_rust(os.path.join(here, "..", "hello") + os.sep))
1 Hello, world!
2 Hello, world!
3 Hello, world!
4 Hello, world!
5 Hello, world!
6 Hello, world!
7 Hello, world!
8 Hello, world!
9 Hello, world!
10 Hello, world!
11 Hello, world!
12 Hello, world!

And there we have it! We can further try again, but with #| echo: false in the Python code block to turn off echo:

"""Build and run the ``hello`` Rust project and print its output."""

import os

import run_rust

here = os.path.dirname(os.path.abspath(__file__))

print(run_rust.compile_and_run_rust(os.path.join(here, "..", "hello") + os.sep))
1 Hello, world!
2 Hello, world!
3 Hello, world!
4 Hello, world!
5 Hello, world!
6 Hello, world!
7 Hello, world!
8 Hello, world!
9 Hello, world!
10 Hello, world!
11 Hello, world!
12 Hello, world!

In conclusion, this approach uses Python itself and Python Jupyter notebooks to compile, run, and display the printed output from a Rust program. It succeeds in preserving newline characters, and echo works locally. It also works globally for the whole file. The only remaining thing to watch out for in particular is that Quarto’s automatic freezing of posts will not detect if you have changed the Rust code; you may need to change your qmd file in some way.