monty_types/io.rs
1//! Print-output plumbing: [`PrintStream`], [`PrintWriter`] and the
2//! [`PrintWriterCallback`] trait used by hosts to capture `print()` output.
3
4use std::borrow::Cow;
5
6use crate::{
7 exceptions::{ExcType, MontyException},
8 resource::ResourceError,
9};
10
11/// Default cap for [`PrintWriter::CollectString`] / [`PrintWriter::CollectStreams`]
12/// and the matching Python collectors.
13///
14/// Host-side print buffers sit outside [`crate::ResourceLimits::max_memory`];
15/// without a cap, a print loop can OOM the host while sandbox limits stay green.
16/// Pass `max_bytes: None` to opt out on trusted hosts.
17pub const DEFAULT_MAX_PRINT_COLLECT_BYTES: usize = 10 * 1024 * 1024;
18
19/// Host bytes charged for each retained `(stream, text)` entry beyond its text.
20///
21/// A retained entry costs about 32 bytes in the vector plus its `String`'s own
22/// allocation, none of which is text. Charging only the text would let a run
23/// that starts a fresh entry per byte — one that alternates between the two
24/// streams — occupy roughly 64x the host memory the cap accounts for.
25pub const COLLECT_STREAMS_ENTRY_OVERHEAD: usize = 64;
26
27/// Identifies the output stream for a single print fragment.
28///
29/// `print()` writes to `Stdout` unless it is given `file=sys.stderr`, which is
30/// the only way sandboxed code can reach `Stderr`.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PrintStream {
33 /// Standard output, the default for a `print()` call with no `file=`.
34 Stdout,
35 /// Standard error, reached by `print(..., file=sys.stderr)`.
36 Stderr,
37}
38
39/// Output handler for the `print()` builtin function.
40///
41/// Provides common output modes as enum variants to avoid trait object overhead
42/// in the typical cases (stdout, disabled, collect). For custom output handling,
43/// use the `Callback` variant with a [`PrintWriterCallback`] implementation.
44///
45/// # Variants
46/// - `Disabled` — silently discards all output (useful for benchmarking or suppressing output).
47/// - `Stdout` — writes to standard output (the default behavior).
48/// - `CollectString` — accumulates output into a target `String` for programmatic access.
49/// No stream labels are preserved; every fragment is appended in the order it was emitted.
50/// The `Option<usize>` is an optional byte cap (`None` = unlimited); constructors
51/// [`collect_string`](Self::collect_string) / default Python collectors use
52/// [`DEFAULT_MAX_PRINT_COLLECT_BYTES`].
53/// - `CollectStreams` — accumulates output as `(stream, text)` pairs, merging consecutive
54/// same-stream fragments into one tuple. Each write to the same stream extends the
55/// trailing entry rather than producing a new one; a new tuple is only pushed when
56/// the stream changes. Same optional byte cap as `CollectString`.
57/// - `Callback` — delegates to a user-provided [`PrintWriterCallback`] implementation.
58pub enum PrintWriter<'a> {
59 /// Silently discard all output.
60 Disabled,
61 /// Write to standard output.
62 Stdout,
63 /// Collect all output into a single `String`, in emit order, with no stream labels.
64 ///
65 /// Second field: max collected bytes (`None` = unlimited). Exceeding raises
66 /// `MemoryError` with the same message as [`ResourceError::Memory`].
67 CollectString(&'a mut String, Option<usize>),
68 /// Collect all output as `(stream, text)` entries.
69 ///
70 /// The builtin `print()` implementation calls `write` for each argument and
71 /// `push` for each separator/terminator. To avoid one entry per fragment,
72 /// [`CollectedStreams`] appends to the trailing entry when it already
73 /// matches the current stream; a new entry is only pushed when the stream
74 /// changes. So a run that stays on one stream collects a single entry
75 /// however many fragments it wrote, and one that alternates collects one
76 /// per run.
77 ///
78 /// Second field: the cap the collector's charge is checked against (`None`
79 /// = unlimited), which counts [`COLLECT_STREAMS_ENTRY_OVERHEAD`] per entry
80 /// as well as the text.
81 CollectStreams(&'a mut CollectedStreams, Option<usize>),
82 /// Delegate to a custom callback.
83 Callback(&'a mut dyn PrintWriterCallback),
84}
85
86impl PrintWriter<'_> {
87 /// Collect into `buf` with the default [`DEFAULT_MAX_PRINT_COLLECT_BYTES`] cap.
88 pub fn collect_string(buf: &mut String) -> PrintWriter<'_> {
89 PrintWriter::CollectString(buf, Some(DEFAULT_MAX_PRINT_COLLECT_BYTES))
90 }
91
92 /// Collect into `buf` with the default [`DEFAULT_MAX_PRINT_COLLECT_BYTES`] cap.
93 pub fn collect_streams(buf: &mut CollectedStreams) -> PrintWriter<'_> {
94 PrintWriter::CollectStreams(buf, Some(DEFAULT_MAX_PRINT_COLLECT_BYTES))
95 }
96
97 /// Creates a new [`PrintWriter`] that reborrows the same underlying target.
98 ///
99 /// This is useful in iterative execution (`start`/`resume` loops) where each
100 /// step takes [`PrintWriter`] by value but you want all steps to write to the
101 /// same output target. The original writer remains valid after the reborrowed
102 /// copy is dropped.
103 pub fn reborrow(&mut self) -> PrintWriter<'_> {
104 match self {
105 Self::Disabled => PrintWriter::Disabled,
106 Self::Stdout => PrintWriter::Stdout,
107 Self::CollectString(buf, max) => PrintWriter::CollectString(buf, *max),
108 Self::CollectStreams(buf, max) => PrintWriter::CollectStreams(buf, *max),
109 Self::Callback(cb) => PrintWriter::Callback(&mut **cb),
110 }
111 }
112
113 /// Called once for each formatted argument passed to `print()`.
114 ///
115 /// This method writes only the given argument's text, without adding
116 /// separators or a trailing newline. Separators (spaces) and the final
117 /// terminator (newline) are emitted via [`push`](Self::push).
118 ///
119 /// `CollectString` keeps no stream labels, so a run that prints to both
120 /// streams interleaves them in one buffer. Use `CollectStreams` to tell
121 /// them apart.
122 pub fn write(&mut self, stream: PrintStream, output: Cow<'_, str>) -> Result<(), MontyException> {
123 match self {
124 Self::Disabled => Ok(()),
125 Self::Stdout => {
126 match stream {
127 PrintStream::Stdout => print!("{output}"),
128 PrintStream::Stderr => eprint!("{output}"),
129 }
130 Ok(())
131 }
132 Self::CollectString(buf, max_bytes) => {
133 check_print_collect_limit(buf.len(), output.len(), *max_bytes)?;
134 buf.push_str(&output);
135 Ok(())
136 }
137 Self::CollectStreams(buf, max_bytes) => buf.push_str(stream, &output, *max_bytes),
138 Self::Callback(cb) => match stream {
139 PrintStream::Stdout => cb.stdout_write(output),
140 PrintStream::Stderr => cb.stderr_write(output),
141 },
142 }
143 }
144
145 /// Appends a single character to the given stream.
146 ///
147 /// Generally called to add spaces (separators) and newlines (terminators)
148 /// within print output.
149 pub fn push(&mut self, stream: PrintStream, end: char) -> Result<(), MontyException> {
150 match self {
151 Self::Disabled => Ok(()),
152 Self::Stdout => {
153 match stream {
154 PrintStream::Stdout => print!("{end}"),
155 PrintStream::Stderr => eprint!("{end}"),
156 }
157 Ok(())
158 }
159 Self::CollectString(buf, max_bytes) => {
160 check_print_collect_limit(buf.len(), end.len_utf8(), *max_bytes)?;
161 buf.push(end);
162 Ok(())
163 }
164 Self::CollectStreams(buf, max_bytes) => buf.push_char(stream, end, *max_bytes),
165 Self::Callback(cb) => match stream {
166 PrintStream::Stdout => cb.stdout_push(end),
167 PrintStream::Stderr => cb.stderr_push(end),
168 },
169 }
170 }
171
172 /// [`write`](Self::write) to [`PrintStream::Stdout`].
173 pub fn stdout_write(&mut self, output: Cow<'_, str>) -> Result<(), MontyException> {
174 self.write(PrintStream::Stdout, output)
175 }
176
177 /// [`push`](Self::push) to [`PrintStream::Stdout`].
178 pub fn stdout_push(&mut self, end: char) -> Result<(), MontyException> {
179 self.push(PrintStream::Stdout, end)
180 }
181
182 /// Whether this writer wants [`poll_flush`](Self::poll_flush) called at all.
183 ///
184 /// Only `Callback` can buffer, so the VM hoists this out of its dispatch
185 /// loop and skips the poll (and its clock read) entirely for every other
186 /// variant.
187 #[must_use]
188 pub fn wants_poll(&self) -> bool {
189 matches!(self, Self::Callback(_))
190 }
191
192 /// Forwards the VM's periodic checkpoint to a buffering callback.
193 pub fn poll_flush(&mut self) -> Result<(), MontyException> {
194 match self {
195 Self::Callback(cb) => cb.poll_flush(),
196 _ => Ok(()),
197 }
198 }
199}
200
201/// The buffer behind [`PrintWriter::CollectStreams`]: `(stream, text)` entries
202/// plus the running charge their cap is checked against.
203///
204/// The charge is carried rather than re-derived because the cap is checked on
205/// every fragment: summing the entries each time is O(entries), which a run
206/// alternating between the streams turns into quadratic work, since each switch
207/// starts a new entry. `pydantic_monty`'s collector keeps the same running
208/// charge for the same reason.
209#[derive(Debug, Default)]
210pub struct CollectedStreams {
211 entries: Vec<(PrintStream, String)>,
212 /// Text bytes plus [`COLLECT_STREAMS_ENTRY_OVERHEAD`] per entry, kept in
213 /// step with every append.
214 charged_bytes: usize,
215}
216
217impl CollectedStreams {
218 /// The collected fragments, in the order the sandbox produced them.
219 #[must_use]
220 pub fn entries(&self) -> &[(PrintStream, String)] {
221 &self.entries
222 }
223
224 /// Takes the collected fragments, leaving the buffer empty.
225 #[must_use]
226 pub fn into_entries(self) -> Vec<(PrintStream, String)> {
227 self.entries
228 }
229
230 /// Appends a string fragment, merging into the trailing entry when the
231 /// stream matches.
232 fn push_str(&mut self, stream: PrintStream, text: &str, max_bytes: Option<usize>) -> Result<(), MontyException> {
233 let starts_entry = self.starts_entry(stream);
234 self.charge(text.len(), starts_entry, max_bytes)?;
235 match self.entries.last_mut() {
236 Some((s, existing)) if *s == stream => existing.push_str(text),
237 _ => self.entries.push((stream, text.to_owned())),
238 }
239 Ok(())
240 }
241
242 /// Appends a single character, merging into the trailing entry when the
243 /// stream matches.
244 fn push_char(&mut self, stream: PrintStream, ch: char, max_bytes: Option<usize>) -> Result<(), MontyException> {
245 let starts_entry = self.starts_entry(stream);
246 self.charge(ch.len_utf8(), starts_entry, max_bytes)?;
247 match self.entries.last_mut() {
248 Some((s, existing)) if *s == stream => existing.push(ch),
249 _ => self.entries.push((stream, String::from(ch))),
250 }
251 Ok(())
252 }
253
254 /// Whether a fragment on `stream` would start an entry rather than extend
255 /// the trailing one.
256 fn starts_entry(&self, stream: PrintStream) -> bool {
257 !matches!(self.entries.last(), Some((s, _)) if *s == stream)
258 }
259
260 /// Checks a fragment's `add` text bytes against the cap and books them,
261 /// with [`COLLECT_STREAMS_ENTRY_OVERHEAD`] on top when it starts an entry.
262 /// The charge is left untouched when the cap refuses the fragment.
263 fn charge(&mut self, add: usize, starts_entry: bool, max_bytes: Option<usize>) -> Result<(), MontyException> {
264 let add = if starts_entry {
265 add.saturating_add(COLLECT_STREAMS_ENTRY_OVERHEAD)
266 } else {
267 add
268 };
269 check_print_collect_limit(self.charged_bytes, add, max_bytes)?;
270 self.charged_bytes = self.charged_bytes.saturating_add(add);
271 Ok(())
272 }
273}
274
275/// Rejects a collect-buffer growth that would exceed `max_bytes`.
276///
277/// `None` means unlimited. On overflow, returns the same `MemoryError` message
278/// as [`ResourceError::Memory`] so hosts see one familiar limit string.
279pub fn check_print_collect_limit(
280 current_len: usize,
281 add: usize,
282 max_bytes: Option<usize>,
283) -> Result<(), MontyException> {
284 let Some(limit) = max_bytes else {
285 return Ok(());
286 };
287 let used = current_len.saturating_add(add);
288 if used > limit {
289 Err(MontyException::new(
290 ExcType::MemoryError,
291 Some(ResourceError::Memory { limit, used }.to_string()),
292 ))
293 } else {
294 Ok(())
295 }
296}
297
298/// Trait for custom output handling from the `print()` builtin function.
299///
300/// Implement this trait and pass it via [`PrintWriter::Callback`] to capture
301/// or redirect print output from sandboxed Python code.
302pub trait PrintWriterCallback {
303 /// Called once for each formatted argument passed to `print()`.
304 ///
305 /// This method is responsible for writing only the given argument's text, and must
306 /// not add separators or a trailing newline. Separators (such as spaces) and the
307 /// final terminator (such as a newline) are emitted via [`stdout_push`](Self::stdout_push).
308 ///
309 /// # Arguments
310 /// * `output` - The formatted output string for a single argument (without
311 /// separators or trailing newline).
312 fn stdout_write(&mut self, output: Cow<'_, str>) -> Result<(), MontyException>;
313
314 /// Add a single character to stdout.
315 ///
316 /// Generally called to add spaces and newlines within print output.
317 ///
318 /// # Arguments
319 /// * `end` - The character to print after the formatted output.
320 fn stdout_push(&mut self, end: char) -> Result<(), MontyException>;
321
322 /// Called for each formatted argument of a `print(..., file=sys.stderr)`.
323 ///
324 /// Defaults to [`stdout_write`](Self::stdout_write), so a host written
325 /// before stderr existed still receives the text instead of losing it.
326 fn stderr_write(&mut self, output: Cow<'_, str>) -> Result<(), MontyException> {
327 self.stdout_write(output)
328 }
329
330 /// Adds a single character to stderr. Defaults to
331 /// [`stdout_push`](Self::stdout_push), as [`stderr_write`](Self::stderr_write) does.
332 fn stderr_push(&mut self, end: char) -> Result<(), MontyException> {
333 self.stdout_push(end)
334 }
335
336 /// Gives a buffering implementation a chance to release what it holds.
337 ///
338 /// The VM calls this from its periodic dispatch checkpoint, so a callback
339 /// that batches writes can bound how long output sits unsent while the
340 /// program computes without printing. Implementations that write straight
341 /// through do nothing here.
342 fn poll_flush(&mut self) -> Result<(), MontyException> {
343 Ok(())
344 }
345}