# How Monty Captures stdout and stderr from Executed Python Code

> Discover how Monty captures stdout and stderr from executed Python code by replacing standard stream objects and using a configurable PrintWriter for flexible output handling.

- Repository: [Pydantic/monty](https://github.com/pydantic/monty)
- Tags: internals
- Published: 2026-02-16

---

**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`](https://github.com/pydantic/monty/blob/main/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`](https://github.com/pydantic/monty/blob/main/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`](https://github.com/pydantic/monty/blob/main/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.

```rust
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`](https://github.com/pydantic/monty/blob/main/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.

```python
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.

```rust
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`](https://github.com/pydantic/monty/blob/main/src/modules/sys.rs).
- All output flows through the **`PrintWriter`** enum in [`src/io.rs`](https://github.com/pydantic/monty/blob/main/src/io.rs), which supports disabled, real stdout, in-memory collection, and custom callback variants.
- The **`print`** builtin in [`src/builtins/print.rs`](https://github.com/pydantic/monty/blob/main/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`](https://github.com/pydantic/monty/blob/main/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`](https://github.com/pydantic/monty/blob/main/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.