monty_types/exceptions.rs
1//! Public exception types: [`ExcType`], [`MontyException`] and its
2//! traceback/payload components ([`StackFrame`], [`CodeLoc`], [`ExcData`]).
3
4use std::{
5 error,
6 fmt::{self, Write},
7 mem, str,
8 sync::Arc,
9};
10
11use serde::{Deserialize, Serialize};
12use strum::{Display, EnumString, IntoStaticStr, VariantNames};
13
14use crate::format::StringRepr;
15
16/// Public representation of a Monty exception.
17#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
18pub struct MontyException {
19 /// The exception type raised
20 exc_type: ExcType,
21 /// Optional exception message explaining what went wrong
22 message: Option<String>,
23 /// Stack trace of the exception, first is the outermost frame shown first in the traceback
24 traceback: Vec<StackFrame>,
25 /// Structured payload for exception types that carry more than a message.
26 data: ExcData,
27}
28
29/// Number of identical consecutive frames to show before collapsing.
30///
31/// CPython shows 3 identical frames, then "[Previous line repeated N more times]".
32const REPEAT_FRAMES_SHOWN: usize = 3;
33
34/// Display implementation for MontyException should exactly match python traceback format.
35impl fmt::Display for MontyException {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 // Print the traceback header if we have frames
38 if !self.traceback.is_empty() {
39 writeln!(f, "Traceback (most recent call last):")?;
40 }
41
42 // Print frames, collapsing consecutive identical frames like CPython does
43 let mut i = 0;
44 while i < self.traceback.len() {
45 let frame = &self.traceback[i];
46
47 // Count consecutive identical frames
48 let mut repeat_count = 1;
49 while i + repeat_count < self.traceback.len()
50 && frames_are_identical(frame, &self.traceback[i + repeat_count])
51 {
52 repeat_count += 1;
53 }
54
55 if repeat_count > REPEAT_FRAMES_SHOWN {
56 // Show first REPEAT_FRAMES_SHOWN frames, then collapse the rest
57 for j in 0..REPEAT_FRAMES_SHOWN {
58 write!(f, "{}", self.traceback[i + j])?;
59 }
60 let collapsed = repeat_count - REPEAT_FRAMES_SHOWN;
61 writeln!(f, " [Previous line repeated {collapsed} more times]")?;
62 i += repeat_count;
63 } else {
64 // Show all frames in this group
65 for j in 0..repeat_count {
66 write!(f, "{}", self.traceback[i + j])?;
67 }
68 i += repeat_count;
69 }
70 }
71
72 if let Some(msg) = &self.message {
73 write!(f, "{}: {}", self.exc_type, msg)
74 } else {
75 write!(f, "{}", self.exc_type)
76 }
77 }
78}
79
80impl error::Error for MontyException {}
81
82impl MontyException {
83 /// Create a new MontyException with the given exception type and message.
84 ///
85 /// You can't provide a traceback here, it's send when raising the exception.
86 #[must_use]
87 pub fn new(exc_type: ExcType, message: Option<String>) -> Self {
88 Self {
89 exc_type,
90 message,
91 traceback: vec![],
92 data: ExcData::None,
93 }
94 }
95
96 /// Creates an exception with an explicit traceback.
97 ///
98 /// Most callers should use [`MontyException::new`] — the traceback is
99 /// normally attached when the exception is raised. This constructor
100 /// exists for boundaries that *reconstruct* an exception that was raised
101 /// elsewhere (e.g. deserializing one received from a `monty subprocess`
102 /// worker) and must preserve its original frames.
103 #[must_use]
104 pub fn with_traceback(exc_type: ExcType, message: Option<String>, traceback: Vec<StackFrame>) -> Self {
105 Self {
106 exc_type,
107 message,
108 traceback,
109 data: ExcData::None,
110 }
111 }
112
113 /// Attaches a structured payload — see [`ExcData`]. Public for
114 /// boundaries that reconstruct an exception raised elsewhere (like
115 /// [`MontyException::with_traceback`]); in-process raises attach the
116 /// payload at the raise site instead.
117 #[must_use]
118 pub fn with_data(mut self, data: ExcData) -> Self {
119 self.data = data;
120 self
121 }
122
123 /// The structured payload, [`ExcData::None`] for most exceptions.
124 #[must_use]
125 pub fn data(&self) -> &ExcData {
126 &self.data
127 }
128
129 /// Structured `UnicodeDecodeError`/`UnicodeEncodeError` fields, present
130 /// only for unicode errors raised by codec operations on objects no
131 /// larger than [`UnicodeErrorData::MAX_OBJECT_LEN`].
132 #[must_use]
133 pub fn unicode_data(&self) -> Option<&UnicodeErrorData> {
134 self.data.unicode()
135 }
136
137 /// Structured `json.JSONDecodeError` fields, present only for decode
138 /// errors raised by `json.loads` (not for manually raised exceptions).
139 #[must_use]
140 pub fn json_data(&self) -> Option<&JsonErrorData> {
141 self.data.json()
142 }
143
144 /// Removes and returns the structured payload, for consumers (like the
145 /// Python bindings) that rebuild the native exception and want the
146 /// payload by value without cloning it.
147 #[must_use]
148 pub fn take_data(&mut self) -> ExcData {
149 mem::take(&mut self.data)
150 }
151
152 /// Appends frames to this exception's traceback.
153 pub fn add_traceback(&mut self, traceback: impl IntoIterator<Item = StackFrame>) {
154 self.traceback.extend(traceback);
155 }
156
157 /// Shorthand for a traceback-free `RuntimeError` wrapping `err`'s display
158 /// output — used at host boundaries (input conversion, REPL feeds) where
159 /// no sandbox stack frames exist.
160 #[must_use]
161 pub fn runtime_error(err: impl fmt::Display) -> Self {
162 Self {
163 exc_type: ExcType::RuntimeError,
164 message: Some(err.to_string()),
165 traceback: vec![],
166 data: ExcData::None,
167 }
168 }
169
170 /// The exception type raised.
171 #[must_use]
172 pub fn exc_type(&self) -> ExcType {
173 self.exc_type
174 }
175
176 /// Optional exception message explaining what went wrong.
177 ///
178 /// Equivalent of python's `exc.args[0]`
179 #[must_use]
180 pub fn message(&self) -> Option<&str> {
181 self.message.as_deref()
182 }
183
184 /// Optional exception message explaining what went wrong.
185 ///
186 /// This takes ownership of the MontyException and returns an owned String.
187 ///
188 /// Equivalent of python's `exc.args[0]`
189 #[must_use]
190 pub fn into_message(self) -> Option<String> {
191 self.message
192 }
193
194 /// Stack trace of the exception, first is the outermost frame shown first in the traceback
195 #[must_use]
196 pub fn traceback(&self) -> &[StackFrame] {
197 &self.traceback
198 }
199
200 /// Returns a compact summary of the exception.
201 ///
202 /// Format: `ExceptionType: message` (e.g., `NotImplementedError: feature not supported`)
203 /// If there's no message, just returns the exception type name.
204 #[must_use]
205 pub fn summary(&self) -> String {
206 if let Some(msg) = &self.message {
207 format!("{}: {}", self.exc_type, msg)
208 } else {
209 self.exc_type.to_string()
210 }
211 }
212
213 /// Returns the exception formatted as Python's repr() would display it.
214 ///
215 /// Format: `ExceptionType('message')` (e.g., `ValueError('invalid value')`)
216 /// Uses appropriate quoting for messages containing quotes.
217 #[must_use]
218 pub fn py_repr(&self) -> String {
219 let type_str: &'static str = self.exc_type.into();
220 if let Some(msg) = &self.message {
221 format!("{}({})", type_str, StringRepr(msg))
222 } else {
223 format!("{type_str}()")
224 }
225 }
226}
227
228/// Check if two stack frames are identical for the purpose of collapsing repeated frames.
229///
230/// Two frames are identical if they have the same filename, line number, and function name.
231fn frames_are_identical(a: &StackFrame, b: &StackFrame) -> bool {
232 a.filename == b.filename && a.start.line == b.start.line && a.frame_name == b.frame_name
233}
234
235/// Python exception types supported by the interpreter.
236///
237/// Uses strum derives for automatic `Display`, `FromStr`, and `Into<&'static str>` implementations.
238/// The string representation matches the variant name exactly (e.g., `ValueError` -> "ValueError"),
239/// except where a `serialize` attribute gives a dotted name to disambiguate a stdlib subclass from
240/// its builtin parent (`binascii.Error` from `ValueError`, say).
241///
242/// `ExcType::VARIANTS` is the canonical list of those names, and the host bridges mirror it:
243/// `PYTHON_EXC_NAMES` in `crates/monty-js/ts/errors.ts` and the `ExcType` literal in
244/// `pydantic_monty/__init__.py`. Both are hand-written, so `monty-proto`'s `exc_type_bridges` test
245/// reads them and pins them here — adding a variant without wiring the bridges fails that test.
246#[derive(
247 Debug,
248 Clone,
249 Copy,
250 Default,
251 PartialEq,
252 Eq,
253 Hash,
254 Display,
255 EnumString,
256 IntoStaticStr,
257 VariantNames,
258 Serialize,
259 Deserialize,
260)]
261pub enum ExcType {
262 /// primary exception class - matches any exception in isinstance checks.
263 ///
264 /// Also the `Default` — required so `Type` (which embeds an `ExcType` in
265 /// its `Exception` variant) can derive `strum::EnumIter`.
266 #[default]
267 Exception,
268
269 /// System exit exceptions
270 BaseException,
271 SystemExit,
272 KeyboardInterrupt,
273
274 // --- ArithmeticError hierarchy ---
275 /// Intermediate class for arithmetic errors.
276 ArithmeticError,
277 /// Subclass of ArithmeticError.
278 OverflowError,
279 /// Subclass of ArithmeticError.
280 ZeroDivisionError,
281
282 // --- LookupError hierarchy ---
283 /// Intermediate class for lookup errors.
284 LookupError,
285 /// Subclass of LookupError.
286 IndexError,
287 /// Subclass of LookupError.
288 KeyError,
289
290 // --- RuntimeError hierarchy ---
291 /// Intermediate class for runtime errors.
292 RuntimeError,
293 /// Subclass of RuntimeError.
294 NotImplementedError,
295 /// Subclass of RuntimeError.
296 RecursionError,
297
298 // --- AttributeError hierarchy ---
299 AttributeError,
300 /// Subclass of AttributeError (from dataclasses module).
301 FrozenInstanceError,
302
303 // --- NameError hierarchy ---
304 NameError,
305 /// Subclass of NameError - for accessing local variable before assignment.
306 UnboundLocalError,
307
308 // --- ValueError hierarchy ---
309 ValueError,
310 /// Subclass of ValueError - for encoding/decoding errors.
311 UnicodeDecodeError,
312 /// Subclass of ValueError - for encoding errors (e.g. `str.encode('ascii')`
313 /// on a string containing non-ASCII characters).
314 UnicodeEncodeError,
315 /// Subclass of ValueError for invalid JSON syntax in `json.loads()`.
316 #[strum(serialize = "json.JSONDecodeError")]
317 JsonDecodeError,
318 /// Subclass of ValueError for invalid TOML in `tomllib.loads()` / `tomllib.load()`.
319 #[strum(serialize = "tomllib.TOMLDecodeError")]
320 TomlDecodeError,
321
322 // --- ImportError hierarchy ---
323 /// Import-related errors (module not found, name not in module).
324 ImportError,
325 /// Subclass of ImportError - for when a module cannot be found.
326 ModuleNotFoundError,
327
328 // --- OSError hierarchy ---
329 /// OS-related errors (file not found, permission denied, etc.)
330 OSError,
331 /// Subclass of OSError - for when a file or directory cannot be found.
332 FileNotFoundError,
333 /// Subclass of OSError - for when a file already exists.
334 FileExistsError,
335 /// Subclass of OSError - for when a path is a directory but a file was expected.
336 IsADirectoryError,
337 /// Subclass of OSError - for when a path is not a directory but one was expected.
338 NotADirectoryError,
339 /// Subclass of OSError - for when an operation is not permitted (e.g., writing
340 /// to a read-only mount, or attempting to access a path outside a mounted directory).
341 PermissionError,
342 /// `io.UnsupportedOperation` - raised by file objects when a requested
343 /// operation isn't allowed by the open mode (e.g. `read()` on `'w'`).
344 ///
345 /// In CPython this inherits from both `OSError` and `ValueError`. Monty's
346 /// `ExcType` enum models single parents, but [`Self::is_subclass_of`]
347 /// matches `UnsupportedOperation` against both `OSError` and `ValueError`
348 /// so `except ValueError:` and `except OSError:` both catch it as in
349 /// CPython.
350 #[strum(serialize = "io.UnsupportedOperation")]
351 UnsupportedOperation,
352 /// Subclass of OSError since Python 3.3 (PEP 3151).
353 TimeoutError,
354
355 // --- Standalone exception types ---
356 AssertionError,
357 MemoryError,
358 StopIteration,
359 SyntaxError,
360 TypeError,
361
362 // --- Module-specific exception types ---
363
364 // --- re module ---
365 /// `re.PatternError` - raised for invalid regex patterns or unsupported regex features.
366 ///
367 /// # Behavior Note
368 ///
369 /// Limited to monty's exception type, `PatternError` does not provide `pattern`, `pos`,
370 /// `lineno` and `colno` attributes.
371 ///
372 /// As per CPython's implementation, it would be hard to convert `fancy-regex`'s error
373 /// representations into the required attributes.
374 #[strum(serialize = "re.PatternError")]
375 RePatternError,
376
377 // --- binascii module ---
378 /// `binascii.Error` - raised by the `base64` codecs for malformed input.
379 ///
380 /// A `ValueError` subclass in CPython, so `except ValueError:` catches it.
381 #[strum(serialize = "binascii.Error")]
382 BinasciiError,
383
384 /// `binascii.Incomplete` - a direct `Exception` subclass, not a `ValueError`.
385 ///
386 /// Nothing raises it: the `a2b_hqx` family it belonged to left CPython in
387 /// 3.11, so the class survives only for `except binascii.Incomplete:` in
388 /// older code. Monty exposes it for the same reason.
389 #[strum(serialize = "binascii.Incomplete")]
390 BinasciiIncomplete,
391
392 // --- csv module ---
393 /// `csv.Error` - raised by the `csv` readers and writers for malformed data
394 /// and unknown dialect names.
395 ///
396 /// A direct `Exception` subclass, named `_csv.Error` as in CPython.
397 #[strum(serialize = "_csv.Error")]
398 CsvError,
399
400 // --- decimal module ---
401 // CPython's decimal signals form a multiple-inheritance hierarchy under
402 // `ArithmeticError`; [`Self::decimal_ancestors`] lists each one's bases.
403 /// `decimal.DecimalException`, the base of every decimal signal.
404 #[strum(serialize = "decimal.DecimalException")]
405 DecimalException,
406 /// `decimal.Clamped`: an exponent was changed to fit the representation.
407 #[strum(serialize = "decimal.Clamped")]
408 DecimalClamped,
409 /// `decimal.InvalidOperation`: an operation with no meaningful result.
410 #[strum(serialize = "decimal.InvalidOperation")]
411 DecimalInvalidOperation,
412 /// `decimal.ConversionSyntax`: a malformed numeric string.
413 #[strum(serialize = "decimal.ConversionSyntax")]
414 DecimalConversionSyntax,
415 /// `decimal.DivisionByZero`: a finite non-zero dividend divided by zero.
416 #[strum(serialize = "decimal.DivisionByZero")]
417 DecimalDivisionByZero,
418 /// `decimal.DivisionImpossible`: an integer quotient too long for the precision.
419 #[strum(serialize = "decimal.DivisionImpossible")]
420 DecimalDivisionImpossible,
421 /// `decimal.DivisionUndefined`: zero divided by zero.
422 #[strum(serialize = "decimal.DivisionUndefined")]
423 DecimalDivisionUndefined,
424 /// `decimal.Inexact`: a result was rounded and lost non-zero digits.
425 #[strum(serialize = "decimal.Inexact")]
426 DecimalInexact,
427 /// `decimal.InvalidContext`: an invalid context setting was detected.
428 #[strum(serialize = "decimal.InvalidContext")]
429 DecimalInvalidContext,
430 /// `decimal.Rounded`: a result was rounded, perhaps without losing digits.
431 #[strum(serialize = "decimal.Rounded")]
432 DecimalRounded,
433 /// `decimal.Subnormal`: a result's exponent fell below `Emin`.
434 #[strum(serialize = "decimal.Subnormal")]
435 DecimalSubnormal,
436 /// `decimal.Overflow`: a result's exponent exceeded `Emax`.
437 #[strum(serialize = "decimal.Overflow")]
438 DecimalOverflow,
439 /// `decimal.Underflow`: a subnormal result was rounded.
440 #[strum(serialize = "decimal.Underflow")]
441 DecimalUnderflow,
442 /// `decimal.FloatOperation`: floats and Decimals were mixed.
443 #[strum(serialize = "decimal.FloatOperation")]
444 DecimalFloatOperation,
445}
446impl ExcType {
447 /// Checks if this exception type is a subclass of another exception type.
448 ///
449 /// Implements Python's exception hierarchy for try/except matching:
450 /// - `Exception` is the base class for all standard exceptions
451 /// - `LookupError` is the base for `KeyError` and `IndexError`
452 /// - `ArithmeticError` is the base for `ZeroDivisionError` and `OverflowError`
453 /// - `RuntimeError` is the base for `RecursionError` and `NotImplementedError`
454 ///
455 /// Returns true if `self` would be caught by `except handler_type:`.
456 #[must_use]
457 pub fn is_subclass_of(self, handler_type: Self) -> bool {
458 if self == handler_type || self.decimal_ancestors().contains(&handler_type) {
459 return true;
460 }
461 match handler_type {
462 // BaseException catches all exceptions
463 Self::BaseException => true,
464 // Exception catches everything except BaseException, and direct subclasses: KeyboardInterrupt, SystemExit
465 Self::Exception => !matches!(self, Self::BaseException | Self::KeyboardInterrupt | Self::SystemExit),
466 // LookupError catches KeyError and IndexError
467 Self::LookupError => matches!(self, Self::KeyError | Self::IndexError),
468 // ArithmeticError catches ZeroDivisionError and OverflowError
469 Self::ArithmeticError => matches!(self, Self::ZeroDivisionError | Self::OverflowError),
470 // RuntimeError catches RecursionError and NotImplementedError
471 Self::RuntimeError => matches!(self, Self::RecursionError | Self::NotImplementedError),
472 // AttributeError catches FrozenInstanceError
473 Self::AttributeError => matches!(self, Self::FrozenInstanceError),
474 // NameError catches UnboundLocalError
475 Self::NameError => matches!(self, Self::UnboundLocalError),
476 // io.UnsupportedOperation is here because CPython gives it dual
477 // OSError + ValueError parentage
478 Self::ValueError => matches!(
479 self,
480 Self::UnicodeDecodeError
481 | Self::UnicodeEncodeError
482 | Self::JsonDecodeError
483 | Self::TomlDecodeError
484 | Self::BinasciiError
485 | Self::UnsupportedOperation
486 ),
487 // ImportError catches ModuleNotFoundError
488 Self::ImportError => matches!(self, Self::ModuleNotFoundError),
489 // OSError catches FileNotFoundError, FileExistsError, IsADirectoryError,
490 // NotADirectoryError, PermissionError, io.UnsupportedOperation, and
491 // TimeoutError (an OSError subclass since Python 3.3)
492 Self::OSError => matches!(
493 self,
494 Self::FileNotFoundError
495 | Self::FileExistsError
496 | Self::IsADirectoryError
497 | Self::NotADirectoryError
498 | Self::PermissionError
499 | Self::UnsupportedOperation
500 | Self::TimeoutError
501 ),
502 // All other types only match exactly (handled by self == handler_type above)
503 _ => false,
504 }
505 }
506
507 /// Every base of a `decimal` exception below `Exception`, in CPython's MRO order;
508 /// empty for every other type.
509 ///
510 /// The decimal signals inherit from several classes at once (`DivisionByZero` is
511 /// also a `ZeroDivisionError`, `Underflow` is `Inexact`, `Rounded` and `Subnormal`).
512 #[must_use]
513 pub fn decimal_ancestors(self) -> &'static [Self] {
514 const BASE: &[ExcType] = &[ExcType::DecimalException, ExcType::ArithmeticError];
515 const INVALID: &[ExcType] = &[
516 ExcType::DecimalInvalidOperation,
517 ExcType::DecimalException,
518 ExcType::ArithmeticError,
519 ];
520 match self {
521 Self::DecimalException => &[Self::ArithmeticError],
522 Self::DecimalClamped
523 | Self::DecimalInvalidOperation
524 | Self::DecimalInexact
525 | Self::DecimalRounded
526 | Self::DecimalSubnormal => BASE,
527 Self::DecimalConversionSyntax | Self::DecimalDivisionImpossible | Self::DecimalInvalidContext => INVALID,
528 Self::DecimalDivisionUndefined => &[
529 Self::DecimalInvalidOperation,
530 Self::DecimalException,
531 Self::ZeroDivisionError,
532 Self::ArithmeticError,
533 ],
534 Self::DecimalDivisionByZero => &[Self::DecimalException, Self::ZeroDivisionError, Self::ArithmeticError],
535 Self::DecimalOverflow => &[
536 Self::DecimalInexact,
537 Self::DecimalRounded,
538 Self::DecimalException,
539 Self::ArithmeticError,
540 ],
541 Self::DecimalUnderflow => &[
542 Self::DecimalInexact,
543 Self::DecimalRounded,
544 Self::DecimalSubnormal,
545 Self::DecimalException,
546 Self::ArithmeticError,
547 ],
548 Self::DecimalFloatOperation => &[Self::DecimalException, Self::ArithmeticError, Self::TypeError],
549 _ => &[],
550 }
551 }
552}
553
554/// Structured payload attached to exception types whose CPython counterparts
555/// carry more than a message. Currently unicode and json decode errors have
556/// one; the enum leaves room for future variants (e.g. `OSError`'s
557/// `errno`/`filename`) without another field on every exception.
558#[derive(Debug, Clone, Default, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
559pub enum ExcData {
560 /// No structured payload — every exception type without a variant below.
561 #[default]
562 None,
563 /// `UnicodeDecodeError` / `UnicodeEncodeError` constructor fields.
564 /// Boxed to keep the common `None` case (and every exception embedding
565 /// this enum) small.
566 Unicode(Box<UnicodeErrorData>),
567 /// `json.JSONDecodeError` attribute fields. Boxed like
568 /// [`ExcData::Unicode`] to keep the enum small.
569 Json(Box<JsonErrorData>),
570}
571
572impl ExcData {
573 /// The unicode-error fields, if this is [`ExcData::Unicode`].
574 #[must_use]
575 pub fn unicode(&self) -> Option<&UnicodeErrorData> {
576 match self {
577 Self::Unicode(data) => Some(data),
578 _ => None,
579 }
580 }
581
582 /// The json-error fields, if this is [`ExcData::Json`].
583 #[must_use]
584 pub fn json(&self) -> Option<&JsonErrorData> {
585 match self {
586 Self::Json(data) => Some(data),
587 _ => None,
588 }
589 }
590}
591
592/// Structured fields of a `UnicodeDecodeError` / `UnicodeEncodeError`,
593/// mirroring CPython's `encoding` / `object` / `start` / `end` / `reason`
594/// exception attributes.
595///
596/// Monty exceptions are otherwise message-only; unicode errors additionally
597/// carry these fields so host bindings (e.g. `pydantic_monty`) can construct
598/// real `UnicodeDecodeError` / `UnicodeEncodeError` instances instead of
599/// falling back to a plain `ValueError`. The payload is omitted when the
600/// offending object is larger than [`UnicodeErrorData::MAX_OBJECT_LEN`] —
601/// exceptions can be stored and copied outside the sandbox's resource
602/// tracker, so an unbounded payload would let huge inputs evade memory
603/// limits. Sandboxed code never sees these fields (in-sandbox exceptions
604/// expose only `args`).
605#[derive(Debug, Clone, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
606pub struct UnicodeErrorData {
607 /// The codec name as CPython reports it, e.g. `"utf-8"`, `"ascii"`.
608 pub encoding: String,
609 /// The full input that failed to encode/decode (`str` for encode errors,
610 /// `bytes` for decode errors), matching CPython's `exc.object`.
611 pub object: UnicodeErrorObject,
612 /// Start of the failing range: a character index for encode errors, a
613 /// byte offset for decode errors.
614 pub start: usize,
615 /// Exclusive end of the failing range, in the same units as `start`.
616 pub end: usize,
617 /// CPython's reason wording, e.g. `"ordinal not in range(128)"`.
618 pub reason: String,
619}
620
621/// The `object` attribute of a unicode error: the input being converted.
622#[derive(Debug, Clone, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
623pub enum UnicodeErrorObject {
624 /// A decode error's input `bytes`.
625 Bytes(#[serde(with = "serde_bytes")] Vec<u8>),
626 /// An encode error's input `str`.
627 Str(String),
628}
629
630impl UnicodeErrorData {
631 /// Payload size cap: unicode errors on objects larger than this carry no
632 /// structured data (hosts fall back to the message-only `ValueError`).
633 /// Exception payloads are copied into the host once they escape the worker,
634 /// so the cap bounds how much host memory a single raise can pin.
635 pub const MAX_OBJECT_LEN: usize = 64 * 1024;
636
637 /// Builds the payload for an encode error on `object`, or
638 /// [`ExcData::None`] when `object` exceeds [`Self::MAX_OBJECT_LEN`].
639 #[must_use]
640 pub fn encode(encoding: &str, object: &str, start: usize, end: usize, reason: &str) -> ExcData {
641 if object.len() <= Self::MAX_OBJECT_LEN {
642 ExcData::Unicode(Box::new(Self {
643 encoding: encoding.to_owned(),
644 object: UnicodeErrorObject::Str(object.to_owned()),
645 start,
646 end,
647 reason: reason.to_owned(),
648 }))
649 } else {
650 ExcData::None
651 }
652 }
653
654 /// Builds the payload for a decode error on `object`, or
655 /// [`ExcData::None`] when `object` exceeds [`Self::MAX_OBJECT_LEN`].
656 /// Public so `monty-fs` can build the payload for text-mode file reads.
657 #[must_use]
658 pub fn decode(encoding: &str, object: &[u8], start: usize, end: usize, reason: &str) -> ExcData {
659 if object.len() <= Self::MAX_OBJECT_LEN {
660 ExcData::Unicode(Box::new(Self {
661 encoding: encoding.to_owned(),
662 object: UnicodeErrorObject::Bytes(object.to_vec()),
663 start,
664 end,
665 reason: reason.to_owned(),
666 }))
667 } else {
668 ExcData::None
669 }
670 }
671}
672
673/// Structured fields of a `json.JSONDecodeError`, mirroring CPython's `msg` /
674/// `doc` / `pos` / `lineno` / `colno` exception attributes. `tomllib.TOMLDecodeError`
675/// defines the same five attributes, so it carries this payload too.
676///
677/// As with [`UnicodeErrorData`], the payload exists so host bindings can
678/// construct a real `json.JSONDecodeError` instead of falling back to a plain
679/// `ValueError`; sandboxed code never sees these fields. `lineno`/`colno` are
680/// carried explicitly rather than recomputed from `doc` because `doc` may be
681/// absent (see [`JsonErrorData::MAX_DOC_LEN`]).
682#[derive(Debug, Clone, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
683pub struct JsonErrorData {
684 /// The bare error message, without the `: line N column M (char K)`
685 /// suffix the formatted exception message carries.
686 pub msg: String,
687 /// The document being parsed, matching CPython's `exc.doc`. `None` when
688 /// the document exceeds [`JsonErrorData::MAX_DOC_LEN`] or is not valid
689 /// UTF-8 (`json.loads` on `bytes` input).
690 pub doc: Option<String>,
691 /// Character index of the error in `doc`, matching CPython's `exc.pos`.
692 pub pos: usize,
693 /// 1-based line of the error, matching CPython's `exc.lineno`.
694 pub lineno: usize,
695 /// 1-based column of the error, matching CPython's `exc.colno`.
696 pub colno: usize,
697}
698
699impl JsonErrorData {
700 /// Document size cap, mirroring [`UnicodeErrorData::MAX_OBJECT_LEN`]:
701 /// exception payloads are copied into the host once they escape the worker,
702 /// so `doc` is dropped (not truncated — a partial
703 /// document would misplace `pos`) for larger inputs.
704 pub const MAX_DOC_LEN: usize = 64 * 1024;
705
706 /// Builds the payload for a decode error on `doc`, omitting the document
707 /// when it exceeds [`Self::MAX_DOC_LEN`] or is not valid UTF-8.
708 #[must_use]
709 pub fn build(msg: &str, doc: &[u8], pos: usize, lineno: usize, colno: usize) -> ExcData {
710 let doc = if doc.len() <= Self::MAX_DOC_LEN {
711 str::from_utf8(doc).ok().map(ToOwned::to_owned)
712 } else {
713 None
714 };
715 ExcData::Json(Box::new(Self {
716 msg: msg.to_owned(),
717 doc,
718 pos,
719 lineno,
720 colno,
721 }))
722 }
723}
724
725/// A single frame in a Python traceback.
726///
727/// Contains all the information needed to display a traceback line:
728/// the file location, function name, and optional source code preview.
729///
730/// # Caret Markers
731///
732/// Monty uses only `~` characters for caret markers in tracebacks, unlike CPython 3.11+
733/// which uses `~` for the function name and `^` for arguments (e.g., `~~~~~~~~~~~^^^^^^^^^^^`).
734/// This simplification is intentional - Monty marks the entire expression span uniformly.
735#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
736pub struct StackFrame {
737 /// The filename where the code is located.
738 pub filename: String,
739 /// Start position in the source code.
740 pub start: CodeLoc,
741 /// End position in the source code.
742 pub end: CodeLoc,
743 /// The name of the frame (function name, or None for module-level code).
744 pub frame_name: Option<String>,
745 /// The source code line for preview in the traceback.
746 ///
747 /// Stored as `Arc<str>` rather than `String` so that consecutive frames
748 /// referencing the same source line — typical of recursion and tight
749 /// helper-function loops — share a single allocation. Without sharing, a
750 /// 1000-deep recursive call into code on a long line would clone the
751 /// entire line into each frame and amplify memory usage by the call
752 /// depth. Serialization roundtrips lose the sharing (each frame gets
753 /// its own `Arc`), but that is bounded by the wire size of the
754 /// traceback so does not regress the amplification.
755 pub preview_line: Option<Arc<str>>,
756 /// Whether to hide the caret marker in the traceback for this frame.
757 ///
758 /// Set to `true` for:
759 /// - `raise` statements (CPython doesn't show carets for raise)
760 /// - `AttributeError` on attribute access (CPython doesn't show carets for these)
761 pub hide_caret: bool,
762 /// Whether to hide the `, in <name>` part of the frame line.
763 ///
764 /// Set to `true` for `SyntaxError` where CPython doesn't show the frame name.
765 /// CPython's SyntaxError format: ` File "...", line N`
766 /// vs runtime error format: ` File "...", line N, in <module>`
767 pub hide_frame_name: bool,
768}
769
770impl fmt::Display for StackFrame {
771 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
772 // SyntaxError format: ` File "...", line N`
773 // Runtime error format: ` File "...", line N, in <module>`
774 if self.hide_frame_name {
775 write!(f, r#" File "{}", line {}"#, self.filename, self.start.line)?;
776 } else {
777 write!(f, r#" File "{}", line {}, in "#, self.filename, self.start.line)?;
778 if let Some(frame_name) = &self.frame_name {
779 f.write_str(frame_name)?;
780 } else {
781 f.write_str("<module>")?;
782 }
783 }
784
785 if let Some(line) = &self.preview_line {
786 if self.start.line != self.end.line {
787 // Multi-line statement range: `preview_line` holds a
788 // pre-rendered, dedented block (see `SourceMap::multiline_preview`).
789 // CPython prints each line at the 4-space frame indent with no
790 // caret markers.
791 f.write_char('\n')?;
792 for block_line in line.lines() {
793 writeln!(f, " {block_line}")?;
794 }
795 return Ok(());
796 }
797 // Strip leading whitespace like CPython does
798 let trimmed = line.trim_start();
799 writeln!(f, "\n {trimmed}")?;
800
801 // Hide caret for raise statements, AttributeError, etc.
802 if !self.hide_caret {
803 let leading_spaces = line.len() - trimmed.len();
804 // Calculate caret position relative to the trimmed line
805 // Column is 1-indexed, so subtract 1, then subtract leading spaces we stripped
806 let caret_start = if self.start.column as usize > leading_spaces {
807 4 + self.start.column as usize - leading_spaces - 1
808 } else {
809 4
810 };
811 f.write_str(&" ".repeat(caret_start))?;
812 // Always render at least one caret, even for zero-length ranges
813 // (e.g. a SyntaxError pointing just past the end of a truncated token).
814 let caret_len = (self.end.column - self.start.column).max(1) as usize;
815 writeln!(f, "{}", "~".repeat(caret_len))?;
816 }
817 } else {
818 f.write_char('\n')?;
819 }
820 Ok(())
821 }
822}
823
824/// A line and column position in source code.
825///
826/// Uses 1-based indexing for both line and column to match Python's conventions.
827///
828/// `u32` matches `ruff_text_size::TextSize`, which underpins all source ranges
829/// returned by the parser, so conversions between the two are zero-cost.
830#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
831pub struct CodeLoc {
832 /// Line number (1-based).
833 #[serde(rename = "L")]
834 pub line: u32,
835 /// Column number (1-based), counted in characters (not bytes).
836 #[serde(rename = "C")]
837 pub column: u32,
838}
839
840impl Default for CodeLoc {
841 fn default() -> Self {
842 Self { line: 1, column: 1 }
843 }
844}
845
846impl CodeLoc {
847 /// Creates a new CodeLoc from 0-based values.
848 ///
849 /// Lines and columns numbers are 1-indexed for display, hence `+ 1`.
850 /// Saturates at `u32::MAX` rather than panicking — overflow here is
851 /// already unreachable for any source ruff will accept (it caps source
852 /// size at 4 GiB), and saturation keeps the parser panic-free even if
853 /// that ever changes.
854 #[must_use]
855 pub fn new(line: u32, column: u32) -> Self {
856 Self {
857 line: line.saturating_add(1),
858 column: column.saturating_add(1),
859 }
860 }
861}
862
863/// The source range of the expression that suspended execution, carried by
864/// every suspension.
865///
866/// `start` and `end` are UTF-8 byte offsets into the named source, `end`
867/// exclusive: slice the source's bytes (in Python, `source.encode()[start:end]`)
868/// rather than indexing the string. A peer that sends no position reads as
869/// [`Self::unknown`].
870#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
871pub struct SourceRange {
872 /// The source the range indexes, named as a traceback frame names it: an
873 /// in-process one-shot run's script name, `<python-input-N>` for a feed,
874 /// `<string>` in `eval()` / `exec()`.
875 pub filename: String,
876 /// Byte offset where the expression starts.
877 pub start: u32,
878 /// Byte offset where the expression ends (exclusive).
879 pub end: u32,
880}
881
882impl SourceRange {
883 /// Longest `filename` a range carries, in bytes, so a peer cannot make hosts copy a huge name.
884 pub const MAX_FILENAME_LEN: usize = 256;
885
886 /// Builds a range, cutting `filename` on a char boundary.
887 #[must_use]
888 pub fn new(filename: &str, start: u32, end: u32) -> Self {
889 let mut len = filename.len().min(Self::MAX_FILENAME_LEN);
890 while !filename.is_char_boundary(len) {
891 len -= 1;
892 }
893 Self {
894 filename: filename[..len].to_owned(),
895 start,
896 end,
897 }
898 }
899
900 /// The range a host reports when its peer sent no position: an empty
901 /// filename and an empty range at offset zero.
902 #[must_use]
903 pub fn unknown() -> Self {
904 Self {
905 filename: String::new(),
906 start: 0,
907 end: 0,
908 }
909 }
910}
911
912/// Formats the message for a `UnicodeDecodeError` covering the byte range
913/// `start..end`: CPython's single-byte form (`byte 0x{first_byte:02x} in
914/// position {start}`) when the range is one byte, otherwise the range form
915/// (`bytes in position {start}-{end - 1}`).
916///
917/// A free function (rather than folded into `ExcType::unicode_decode_error`),
918/// public and re-exported at the crate root, so `monty-fs` can produce the
919/// identical wording when converting a `MountError::InvalidUtf8` from a
920/// text-mode file read into an exception.
921#[must_use]
922pub fn unicode_decode_error_msg(codec: &str, first_byte: u8, start: usize, end: usize, reason: &str) -> String {
923 // Callers must pass a non-empty range; checked in debug builds only so a
924 // wrong caller can't panic the VM in release (it gets a garbled message
925 // position instead, which is harmless).
926 debug_assert!(
927 end > start,
928 "unicode_decode_error_msg: end ({end}) must be > start ({start})"
929 );
930 if end - start == 1 {
931 format!("'{codec}' codec can't decode byte 0x{first_byte:02x} in position {start}: {reason}")
932 } else {
933 let last = end - 1;
934 format!("'{codec}' codec can't decode bytes in position {start}-{last}: {reason}")
935 }
936}