Build and Run a C Project from Quarto Using Python

C
Quarto
Python
subprocess
Author

Galen Seilis

Published

July 30, 2024

In this post I share a way to compile and run a C file using Python, which in turn can be used to render the output of the C program in Quarto.

Python Script

The Python script I developed is similar to the one I made for Rust, except that it does two phases. First gcc -c file.c is run on each file in a target path. Then gcc -o program main.o file1.o ... is run to put together the final program with appropriate linking.

import subprocess
import os
import glob
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_c(project_dir):
    project_dir = os.path.abspath(project_dir)

    # Find all .c files in the project directory
    c_files = glob.glob(os.path.join(project_dir, '*.c'))
    if not c_files:
        raise FileNotFoundError("No C source files found in the project directory.")

    object_files = []

    # Compile each .c file into an object file
    for c_file in c_files:
        obj_file = os.path.splitext(c_file)[0] + '.o'
        try:
            compile_process = subprocess.run(
                ['gcc', '-c', c_file, '-o', obj_file],
                cwd=project_dir,
                check=True,
                capture_output=True,
                text=True
            )
            object_files.append(obj_file)
        except subprocess.CalledProcessError as e:
            print(f"Compilation Error for {c_file}: {e.stderr}")
            return

    # Determine the name of the executable (assuming the file with main is called main.c)
    exe_name = 'program'
    main_file = os.path.join(project_dir, 'main.c')
    if os.path.exists(main_file):
        exe_name = os.path.splitext(os.path.basename(main_file))[0]

    # Link all object files into a single executable
    try:
        link_process = subprocess.run(
            ['gcc', '-o', exe_name] + object_files,
            cwd=project_dir,
            check=True,
            capture_output=True,
            text=True
        )
    except subprocess.CalledProcessError as e:
        print(f"Linking Error: {e.stderr}")
        return

    # Find the compiled executable
    target_exe = os.path.join(project_dir, exe_name)

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

    # Set the executable permissions
    try:
        os.chmod(target_exe, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
    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__":
    output = compile_and_run_c('../posts/c-run-from-python/hello')
    print(output)

Let’s see it in action.

Single File Example

import sys
sys.path.insert(1, '../../scripts')

import run_c

print(run_c.compile_and_run_c('./hello/'))
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 the above example I am only compiling and running a single C file, but it is certainly possible to compile multiple files with a further change. For each .c file in the project path, I could run gcc -c file.c on each file. Then I could run gcc -o program main.o file1.o file2.o. This way I can link the object files together.

Linked Files Example

In this example I define a main.c source file, and a couple of helper<#>.c source files along with their header files.

// main.c
#include <stdio.h>
#include "helper1.h"
#include "helper2.h"

int main() {
    printf("Starting program...\n");
    helper1();
    helper2();
    printf("Program finished.\n");
    return 0;
}
// helper1.c
#include <stdio.h>
#include "helper1.h"

void helper1() {
    printf("Hello from helper1!\n");
}
// helper1.h
#ifndef HELPER1_H
#define HELPER1_H

void helper1();

#endif
// helper2.c
#include <stdio.h>
#include "helper2.h"

void helper2() {
    printf("Hello from helper2!\n");
}
// helper2.h
#ifndef HELPER2_H
#define HELPER2_H

void helper2();

#endif

Now we can similarly point run_c.compile_and_rune_c pointed at the path where these files exist.

import sys
sys.path.insert(1, '../../scripts')

import run_c

print(run_c.compile_and_run_c('./hello2/'))
Starting program...
Hello from helper1!
Hello from helper2!
Program finished.

Conclusions

As long as the linking and compiled options are kept simple, this script allows you to compile simple C langauge programs. This may be suitable for ensuring that C code examples for blogging actually work. Expanding into autotools and make files is the way to go for more complicated builds.

The same limitation as the corresponding script to run Rust code applies: if your qmd file does not change while Quarto’s setting is freeze: auto, the page will not be rerendered if the C code changes even if the Quarto document is not changed.