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 entirely
  • Stdout: Writes to the real process stdout via print!
  • Collect(String): Buffers output in a growable in-memory string
  • Callback(&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:

  1. Python print("hello") triggers the builtin
  2. builtin_print calls print.stdout_write("hello") on the active PrintWriter
  3. The variant determines the destination:
    • Collect(buf) appends to the internal string buffer via buf.push_str(&output)
    • Callback(cb) invokes the custom handler's stdout_write method
    • Stdout uses print!("{output}") to write to the host process
  4. 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.stdout and sys.stderr with lightweight marker objects (Value::Marker) that have no I/O capabilities, defined in src/modules/sys.rs.
  • All output flows through the PrintWriter enum in src/io.rs, which supports disabled, real stdout, in-memory collection, and custom callback variants.
  • The print builtin in src/builtins/print.rs delegates to the active PrintWriter's stdout_write and stdout_push methods rather than using file descriptors.
  • To capture output, supply PrintWriter::Collect to MontyRun::run, use the run_collect convenience method in Python, or implement PrintWriterCallback for 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →