Skip to main content

outl_exec/runtimes/
python.rs

1//! `python` runtime — Python via [RustPython](https://rustpython.github.io).
2//!
3//! RustPython is a pure-Rust Python 3 interpreter. It supports a
4//! substantial subset of CPython 3 — enough for arithmetic, list/dict
5//! comprehensions, string methods, f-strings, regex, json. It is not
6//! CPython: numpy / pandas / native extensions don't run here.
7//!
8//! Output capture strategy: instead of redirecting `sys.stdout` (which
9//! requires the full stdlib), we prepend a tiny Python prelude that
10//! shadows the global `print` with a function that appends to a list.
11//! After running, we read that list out of the scope and join it.
12//! Works without `stdio` / `freeze-stdlib` features, keeping the
13//! binary small.
14//!
15//! Gated behind the `lang-python` feature.
16
17use std::time::Instant;
18
19use rustpython_vm::{compiler, scope::Scope, Interpreter, PyObjectRef, Settings, VirtualMachine};
20
21use crate::runtime::{ExecContext, ExecError, ExecOutput, ExitStatus, OutputFormat, Runtime};
22
23/// RustPython-backed runtime.
24pub struct PythonRuntime;
25
26const PRELUDE: &str = r#"
27__outl_out = []
28def print(*args, sep=' ', end='\n', **_kw):
29    __outl_out.append(sep.join(str(a) for a in args) + end)
30"#;
31
32impl Runtime for PythonRuntime {
33    fn language(&self) -> &'static str {
34        "python"
35    }
36
37    fn execute(&self, source: &str, _ctx: &ExecContext) -> Result<ExecOutput, ExecError> {
38        let start = Instant::now();
39        let interpreter = Interpreter::without_stdlib(Settings::default());
40
41        let outcome = interpreter.enter(|vm| -> Result<String, PyErrString> {
42            let scope = vm.new_scope_with_builtins();
43
44            run_code(vm, &scope, PRELUDE, "<prelude>")?;
45            run_code(vm, &scope, source, "<block>")?;
46
47            // Pull `__outl_out` out of the scope and join.
48            let key = vm.ctx.new_str("__outl_out");
49            let captured: PyObjectRef = scope
50                .globals
51                .get_item(&*key, vm)
52                .map_err(|e| pyerr_string(vm, e))?;
53
54            // It's a list of strings — len + getitem.
55            let len = vm
56                .call_method(&captured, "__len__", ())
57                .and_then(|v| v.try_int(vm).map(|i| i.as_bigint().clone()));
58            let len = match len {
59                Ok(n) => n.to_string().parse::<usize>().unwrap_or(0),
60                Err(_) => 0,
61            };
62
63            let mut buf = String::new();
64            for i in 0..len {
65                let idx: PyObjectRef = vm.ctx.new_int(i).into();
66                let item = vm
67                    .call_method(&captured, "__getitem__", (idx,))
68                    .map_err(|e| pyerr_string(vm, e))?;
69                let s = item.str(vm).map_err(|e| pyerr_string(vm, e))?;
70                // Surface non-UTF-8 conversion failures instead of
71                // silently dropping bytes from stdout. Python 3 strs
72                // are notionally UTF-8, but a buggy/native extension
73                // could feed in something we can't decode — better to
74                // fail loudly than to ship truncated output.
75                let chunk = s
76                    .to_str()
77                    .ok_or_else(|| PyErrString("python stdout has non-UTF-8 bytes".into()))?;
78                buf.push_str(chunk);
79            }
80            Ok(buf)
81        });
82
83        match outcome {
84            Ok(stdout) => Ok(ExecOutput {
85                stdout,
86                stderr: String::new(),
87                duration: start.elapsed(),
88                exit: ExitStatus::Ok,
89                format: OutputFormat::Text,
90            }),
91            Err(stderr) => Ok(ExecOutput {
92                stdout: String::new(),
93                stderr: stderr.0,
94                duration: start.elapsed(),
95                exit: ExitStatus::Trap("python-error".into()),
96                format: OutputFormat::Text,
97            }),
98        }
99    }
100}
101
102struct PyErrString(String);
103
104fn run_code(
105    vm: &VirtualMachine,
106    scope: &Scope,
107    source: &str,
108    label: &str,
109) -> Result<(), PyErrString> {
110    let code = vm
111        .compile(source, compiler::Mode::Exec, label.to_string())
112        .map_err(|e| PyErrString(format!("{e}")))?;
113    vm.run_code_obj(code, scope.clone())
114        .map_err(|e| pyerr_string(vm, e))?;
115    Ok(())
116}
117
118fn pyerr_string(
119    vm: &VirtualMachine,
120    e: rustpython_vm::PyRef<rustpython_vm::builtins::PyBaseException>,
121) -> PyErrString {
122    // `write_exception` requires a writer that implements RustPython's
123    // own `py_io::Write` — `String` does, `Vec<u8>` doesn't.
124    let mut buf = String::new();
125    let _ = vm.write_exception(&mut buf, &e);
126    PyErrString(buf)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn run(src: &str) -> String {
134        PythonRuntime
135            .execute(src, &ExecContext::default())
136            .unwrap()
137            .stdout
138    }
139
140    #[test]
141    fn print_writes_stdout() {
142        assert_eq!(run("print(1 + 2)"), "3\n");
143    }
144
145    #[test]
146    fn list_comprehension() {
147        assert_eq!(run("print([x*x for x in range(4)])"), "[0, 1, 4, 9]\n");
148    }
149
150    #[test]
151    fn fstrings_and_dicts() {
152        let out = run(r#"d={'a':1}; print(f"d={d}")"#);
153        assert!(out.contains("d={'a': 1}"));
154    }
155
156    #[test]
157    fn print_sep_end_kwargs() {
158        assert_eq!(run("print('a','b',sep='-',end='!')"), "a-b!");
159    }
160
161    #[test]
162    fn syntax_error_returns_trap() {
163        let out = PythonRuntime
164            .execute("def (", &ExecContext::default())
165            .unwrap();
166        assert!(matches!(out.exit, ExitStatus::Trap(_)));
167    }
168
169    #[test]
170    fn runtime_exception_returns_trap_with_traceback() {
171        let out = PythonRuntime
172            .execute("raise ValueError('boom')", &ExecContext::default())
173            .unwrap();
174        assert!(matches!(out.exit, ExitStatus::Trap(_)));
175        assert!(out.stderr.contains("ValueError"));
176    }
177}