How Monty Captures stdout and stderr from Executed Python Code
Monty captures output by replacing Python's standard stream objects with lightweight marker values and routing all print() calls through a configurable PrintWriter enum that supports in-memory collection, custom callbacks, or real stdout.
When executing untrusted Python code in the pydantic/monty runtime, controlling where output goes is critical for security and testing. Unlike CPython, Monty does not expose real OS file handles for sys.stdout or sys.stderr. Instead, it implements a sandbox-safe redirection system using placeholder markers and the PrintWriter abstraction to capture, suppress, or process output during execution.
The Architecture Behind Monty's Output Capture
Monty's approach separates the Python-facing API from the actual I/O implementation through two key layers.
The sys.stdout and sys.stderr Markers
In src/modules/sys.rs (lines 31-42), Monty creates Value::Marker objects for the standard streams. These markers use StaticStrings::Stdout and StaticStrings::Stderr as identifiers and display representations like <stdout> and <stderr>. Crucially, these markers carry no I/O logic and hold no file descriptors—they exist solely so Python code can reference sys.stdout and sys.stderr without accessing the underlying operating system streams.
The PrintWriter Abstraction
The actual output sink lives in src/io.rs (lines 16-65) as the PrintWriter enum. This abstraction provides four variants that determine where print() output ends up:
Disabled: Suppresses all output entirelyStdout: Writes to the real process stdout viaprint!Collect(String): Buffers output in a growable in-memory stringCallback(&mut dyn PrintWriterCallback): Delegates to custom user-defined logic
Each variant implements stdout_write and stdout_push methods that the builtin print invokes to emit text.
How the print Builtin Routes Output
When Python code calls print(), the interpreter executes the builtin defined in src/builtins/print.rs (lines 36-55). This builtin_print function receives a mutable reference to the current PrintWriter and iterates through arguments, calling print.stdout_write for each value and print.stdout_push for the final newline or custom end parameter.
The execution flow works as follows:
- Python
print("hello")triggers the builtin builtin_printcallsprint.stdout_write("hello")on the activePrintWriter- The variant determines the destination:
Collect(buf)appends to the internal string buffer viabuf.push_str(&output)Callback(cb)invokes the custom handler'sstdout_writemethodStdoutusesprint!("{output}")to write to the host process
- After all arguments,
stdout_push('\n')completes the line
Because sys.stdout is just a marker, it never influences where print writes—the host program controls output solely via the PrintWriter supplied to the runner.
Three Methods to Capture stdout in Monty
Monty provides flexible patterns for capturing output depending on whether you're using the Rust API directly or the Python bindings.
Capture Output In-Memory with PrintWriter::Collect
For unit tests or sandboxed execution, use the Collect variant to buffer output without touching the filesystem or real stdout.
use monty::{MontyRun, PrintWriter};
let code = r#"
print("first")
print("second", end="!")
"#;
let runner = MontyRun::new(
code.to_owned(),
"example.py",
vec![], // no input variables
vec![], // no external functions
).unwrap();
let mut collector = PrintWriter::Collect(String::new());
let result = runner.run(vec![], monty::resource::NoLimitTracker, &mut collector).unwrap();
let captured = collector.collected_output().unwrap();
assert_eq!(captured, "first\nsecond!");
The collected_output() method retrieves the buffered string after execution completes. This approach is defined in src/io.rs where the Collect variant implements the stdout_write method to push content into the internal String buffer.
Capture Output from Python with run_collect
When using the pydantic_monty Python package, the wrapper provides a convenience method that handles the PrintWriter::Collect setup automatically.
import pydantic_monty
code = """
print("hello")
print("world", end="?")
"""
runner = pydantic_monty.MontyRun(
code=code,
script_name="example.py",
input_names=[],
external_functions=[],
)
# `collect` flag tells the Python wrapper to use PrintWriter::Collect
result, stdout = runner.run_collect([])
assert stdout == "hello\nworld?"
The run_collect method returns a tuple of the execution result and the captured stdout string, creating the Collect writer internally.
Redirect Output to a Custom Callback
For integration with logging frameworks or real-time processing, implement the PrintWriterCallback trait and use the Callback variant.
use monty::{MontyRun, PrintWriter, PrintWriterCallback};
use std::borrow::Cow;
struct Logger;
impl PrintWriterCallback for Logger {
fn stdout_write(&mut self, output: Cow<'_, str>) -> Result<(), monty::exception_public::MontyException> {
log::info!("Monty stdout: {}", output);
Ok(())
}
fn stdout_push(&mut self, end: char) -> Result<(), monty::exception_public::MontyException> {
log::info!("Monty stdout end: {}", end);
Ok(())
}
}
let runner = MontyRun::new("print('hey')".into(), "log.py", vec![], vec![]).unwrap();
let mut cb = PrintWriter::Callback(&mut Logger);
let _ = runner.run(vec![], monty::resource::NoLimitTracker, &mut cb);
This approach delegates each write operation to your custom logic, enabling integration with structured logging systems or streaming pipelines while maintaining the sandbox boundary.
Summary
- Monty replaces
sys.stdoutandsys.stderrwith lightweight marker objects (Value::Marker) that have no I/O capabilities, defined insrc/modules/sys.rs. - All output flows through the
PrintWriterenum insrc/io.rs, which supports disabled, real stdout, in-memory collection, and custom callback variants. - The
printbuiltin insrc/builtins/print.rsdelegates to the activePrintWriter'sstdout_writeandstdout_pushmethods rather than using file descriptors. - To capture output, supply
PrintWriter::CollecttoMontyRun::run, use therun_collectconvenience method in Python, or implementPrintWriterCallbackfor custom handling.
Frequently Asked Questions
Does Monty use real file descriptors for stdout and stderr?
No. Monty does not expose OS file handles to the Python runtime. According to the source code in src/modules/sys.rs, sys.stdout and sys.stderr are implemented as Value::Marker objects that serve as placeholders without any underlying file descriptor or I/O logic.
How do I capture stderr separately from stdout?
The PrintWriter abstraction primarily handles stdout output from the print() builtin. While sys.stderr exists as a marker object in src/modules/sys.rs, capturing it separately would require intercepting writes to that marker similarly to how PrintWriter handles stdout, potentially by customizing the runtime to handle stderr markers through a separate callback or collection mechanism.
Can I redirect output to a logging framework like tracing or log?
Yes. Implement the PrintWriterCallback trait and wrap your logging framework's macros within the stdout_write and stdout_push methods. Then pass PrintWriter::Callback(&mut your_logger) to MontyRun::run to redirect all print output through your logging pipeline.
Is the output capture thread-safe?
The PrintWriter implementations shown in the examples are not inherently thread-safe for concurrent access from multiple execution contexts. For thread-safe capture, you should wrap the collector in a synchronization primitive such as Arc<Mutex<String>> for the Collect variant, or ensure that each thread uses its own MontyRun instance with isolated PrintWriter instances.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →