Skip to main content

monty_types/
object.rs

1//! [`MontyObject`] — the owned, heap-free representation of a Python value
2//! at the host boundary — plus [`MontyType`] and the datetime value types.
3
4use std::{
5    borrow::Cow,
6    error::Error,
7    fmt::{self, Write},
8    hash::{Hash, Hasher},
9    mem, slice,
10    vec::IntoIter,
11};
12
13use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeDelta as ChronoTimeDelta};
14use num_bigint::BigInt;
15use num_traits::{ToPrimitive, Zero};
16
17use crate::{
18    builtins::BuiltinsFunctions,
19    exceptions::ExcType,
20    file_mode::FileMode,
21    format::{FormatFloat, StringRepr, bytes_repr_fmt, format_offset_timedelta_repr, string_repr_fmt},
22    resource::ResourceError,
23};
24
25/// An owned Python value exchanged between Monty and its host.
26///
27/// Construct `MontyObject` values to provide globals, external-function
28/// results, and other inputs to sandboxed code. Execution results and values
29/// passed to host callbacks use the same representation.
30///
31/// Most common Python values have a direct variant, including nested
32/// collections and datetime values. [`Repr`](Self::Repr) and
33/// [`Cycle`](Self::Cycle) can only appear in output because they cannot be
34/// reconstructed as executable Python values. [`Exception`](Self::Exception)
35/// can be used both to raise an exception and to represent one returned by
36/// execution.
37///
38/// Collections are owned snapshots: modifying a returned `MontyObject` does
39/// not modify the corresponding value in a running session.
40///
41/// # Hashability
42///
43/// Only immutable variants implement `Hash`, including the datetime family
44/// (`Date`, `DateTime`, `TimeDelta`, `TimeZone`). Attempting to hash mutable
45/// variants (`List`, `Dict`) will panic.
46///
47/// # Serialization
48///
49/// The derived `Serialize` / `Deserialize` impls use an externally tagged
50/// format (`{"Int": 42}`, `{"String": "hi"}`, ...). This is what `postcard`
51/// and `serde_json::to_string(&obj)` produce. It is lossless and designed
52/// for snapshots and binary transport, not for human-facing JSON.
53#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
54pub enum MontyObject {
55    /// Python's `Ellipsis` singleton (`...`).
56    Ellipsis,
57    /// Python's `NotImplemented` singleton.
58    NotImplemented,
59    /// Python's `None` singleton.
60    None,
61    /// Python boolean (`True` or `False`).
62    Bool(bool),
63    /// Python integer (64-bit signed).
64    Int(i64),
65    /// Python arbitrary-precision integer (larger than i64).
66    BigInt(BigInt),
67    /// Python float (64-bit IEEE 754).
68    Float(f64),
69    /// Python string (UTF-8).
70    String(String),
71    /// Python bytes object.
72    Bytes(Vec<u8>),
73    /// Python list (mutable sequence).
74    List(Vec<Self>),
75    /// Python tuple (immutable sequence).
76    Tuple(Vec<Self>),
77    /// Python named tuple (immutable sequence with named fields).
78    ///
79    /// Named tuples behave like tuples but also support attribute access by field name.
80    /// The type_name is used in repr (e.g., "os.stat_result"), and field_names provides
81    /// the attribute names for each position.
82    NamedTuple {
83        /// Type name for repr (e.g., "os.stat_result").
84        type_name: String,
85        /// Field names in order.
86        field_names: Vec<String>,
87        /// Values in order (same length as field_names).
88        values: Vec<Self>,
89    },
90    /// Python dictionary (insertion-ordered mapping).
91    Dict(DictPairs),
92    /// Python set (mutable, unordered collection of unique elements).
93    Set(Vec<Self>),
94    /// Python frozenset (immutable, unordered collection of unique elements).
95    FrozenSet(Vec<Self>),
96    /// Python `datetime.date`.
97    Date(MontyDate),
98    /// Python `datetime.datetime`.
99    DateTime(MontyDateTime),
100    /// Python `datetime.timedelta`.
101    TimeDelta(MontyTimeDelta),
102    /// Python `datetime.timezone` fixed-offset timezone.
103    TimeZone(MontyTimeZone),
104    /// Python exception with type and optional message argument.
105    Exception {
106        /// The exception type (e.g., `ValueError`, `TypeError`).
107        exc_type: ExcType,
108        /// Optional string argument passed to the exception constructor.
109        arg: Option<String>,
110    },
111    /// A Python type object (e.g., `int`, `str`, `list`).
112    ///
113    /// Returned by the `type()` builtin and can be compared with other types.
114    Type(MontyType),
115    BuiltinFunction(BuiltinsFunctions),
116    /// Python `pathlib.Path` object (or technically a `PurePosixPath`).
117    ///
118    /// Represents a filesystem path. Can be used both as input (from host) and output.
119    Path(String),
120    /// An open file object (the result of `open()`).
121    FileHandle(MontyFileHandle),
122    /// A dataclass instance with class name, field names, attributes, and mutability.
123    ///
124    /// Method calls are detected lazily at runtime: when `call_attr` is invoked
125    /// on a dataclass and the attribute name is not found in `attrs`, it is
126    /// dispatched as a `MethodCall` to the host (provided the name is public).
127    Dataclass {
128        /// The class name (e.g., "Point", "User").
129        name: String,
130        /// Identifier of the type, from `id(type(dc))` in python.
131        type_id: u64,
132        /// Declared field names in definition order (for repr).
133        field_names: Vec<String>,
134        /// All attribute name -> value mapping (includes fields and extra attrs).
135        attrs: DictPairs,
136        /// Whether this dataclass instance is immutable.
137        frozen: bool,
138    },
139    /// An external function provided by the host.
140    ///
141    /// Returned by the host in response to a `NameLookup` to provide a callable
142    /// that the VM can invoke. When called, the VM yields `FunctionCall` to the host.
143    Function {
144        /// The function name (used for repr, error messages, and function call identification).
145        name: String,
146        /// Optional docstring for the function.
147        docstring: Option<String>,
148    },
149    /// Fallback for values that cannot be represented as other variants.
150    ///
151    /// Contains the `repr()` string of the original value.
152    ///
153    /// This is output-only and cannot be used as an input to the interpreter.
154    Repr(String),
155    /// Represents a cycle detected during Value-to-MontyObject conversion.
156    ///
157    /// When converting cyclic structures (e.g., `a = []; a.append(a)`), this variant
158    /// is used to break the infinite recursion. Contains an opaque identity token
159    /// (the raw heap index of the object the cycle points back to — meaningful only
160    /// for equality, and only within the result that produced it) and the
161    /// type-specific placeholder string (e.g., `"[...]"` for lists, `"{...}"` for
162    /// dicts). Two `Cycle` values compare equal if they refer to the same object.
163    ///
164    /// This is output-only and cannot be used as an input to the interpreter.
165    Cycle(usize, String),
166}
167
168impl fmt::Display for MontyObject {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        match self {
171            Self::String(s) => f.write_str(s),
172            Self::Cycle(_, placeholder) => f.write_str(placeholder),
173            Self::Type(t) => write!(f, "<class '{t}'>"),
174            Self::Function { name, .. } => write!(f, "<function '{name}' external>"),
175            _ => self.repr_fmt(f),
176        }
177    }
178}
179
180impl MontyObject {
181    /// Creates a new `MontyObject` from something that can be converted into a `DictPairs`.
182    pub fn dict(dict: impl Into<DictPairs>) -> Self {
183        Self::Dict(dict.into())
184    }
185
186    /// Resolves a builtin function by its Python name (e.g. `"len"`).
187    ///
188    /// The `BuiltinsFunctions` enum inside [`MontyObject::BuiltinFunction`] is
189    /// crate-private, so boundaries that serialize a builtin function by name
190    /// (e.g. the subprocess wire protocol) use this to reconstruct the variant.
191    /// The name matches the variant's `Display` output.
192    #[must_use]
193    pub fn builtin_function_from_name(name: &str) -> Option<Self> {
194        name.parse::<BuiltinsFunctions>().ok().map(Self::BuiltinFunction)
195    }
196
197    /// Shallow host footprint of a freshly decoded `obj`: the fixed [`MontyObject`]
198    /// size plus any leaf payload it owns *directly* (string/bytes/bigint bytes, and
199    /// the `Vec<String>` field names of structured values, which aren't themselves
200    /// `MontyObject`s and would otherwise be uncharged). Container elements are
201    /// excluded — each charges its own size via `monty-proto`'s `decode_field`, so a list charges
202    /// 88 bytes here.
203    pub fn host_size(&self) -> usize {
204        /// Fixed size of one `MontyObject` (88 bytes today) — the per-element cost
205        /// that makes cheap wire elements amplify on the host.
206        const BASE: usize = size_of::<MontyObject>();
207        /// `String` header counted per owned metadata string; content dominates.
208        const STR_OVERHEAD: usize = size_of::<String>();
209
210        let names_len = |names: &[String]| -> usize { names.iter().map(|s| STR_OVERHEAD + s.len()).sum() };
211
212        let payload = match self {
213            Self::String(s) | Self::Path(s) | Self::Repr(s) => s.len(),
214            Self::Cycle(_, placeholder) => placeholder.len(),
215            Self::Bytes(b) => b.len(),
216            // Saturate rather than truncate on a 32-bit `usize`: an over-large
217            // estimate only trips the budget sooner, which is the safe direction.
218            Self::BigInt(bi) => usize::try_from(bi.bits().div_ceil(8)).unwrap_or(usize::MAX),
219            Self::Exception { arg, .. } => arg.as_ref().map_or(0, String::len),
220            Self::FileHandle(fh) => fh.path.len(),
221            Self::Function { name, docstring } => name.len() + docstring.as_ref().map_or(0, String::len),
222            Self::NamedTuple {
223                type_name, field_names, ..
224            } => type_name.len() + names_len(field_names),
225            Self::Dataclass { name, field_names, .. } => name.len() + names_len(field_names),
226            // A `Type::Instance` carries the resolved class name as an owned leaf
227            // `String` (the other `MontyType`s are payload-free), so charge it here
228            // like the `String`/`Function`/... names above.
229            Self::Type(MontyType::Instance(name)) => name.len(),
230            _ => 0,
231        };
232        BASE + payload
233    }
234
235    /// Returns the Python `repr()` string for this value.
236    ///
237    /// # Panics
238    /// Could panic if out of memory.
239    #[must_use]
240    pub fn py_repr(&self) -> String {
241        let mut s = String::new();
242        self.repr_fmt(&mut s).expect("Unable to format repr display value");
243        s
244    }
245
246    fn repr_fmt(&self, f: &mut impl Write) -> fmt::Result {
247        match self {
248            Self::Ellipsis => f.write_str("Ellipsis"),
249            Self::NotImplemented => f.write_str("NotImplemented"),
250            Self::None => f.write_str("None"),
251            Self::Bool(true) => f.write_str("True"),
252            Self::Bool(false) => f.write_str("False"),
253            Self::Int(v) => write!(f, "{v}"),
254            Self::BigInt(v) => write!(f, "{v}"),
255            Self::Float(v) => write!(f, "{}", FormatFloat(*v)),
256            Self::String(s) => string_repr_fmt(s, f),
257            Self::Bytes(b) => bytes_repr_fmt(b, f),
258            Self::List(l) => {
259                f.write_char('[')?;
260                let mut iter = l.iter();
261                if let Some(first) = iter.next() {
262                    first.repr_fmt(f)?;
263                    for item in iter {
264                        f.write_str(", ")?;
265                        item.repr_fmt(f)?;
266                    }
267                }
268                f.write_char(']')
269            }
270            Self::Tuple(t) => {
271                f.write_char('(')?;
272                let mut iter = t.iter();
273                if let Some(first) = iter.next() {
274                    first.repr_fmt(f)?;
275                    for item in iter {
276                        f.write_str(", ")?;
277                        item.repr_fmt(f)?;
278                    }
279                }
280                f.write_char(')')
281            }
282            Self::NamedTuple {
283                type_name,
284                field_names,
285                values,
286            } => {
287                // Format: type_name(field1=value1, field2=value2, ...)
288                f.write_str(type_name)?;
289                f.write_char('(')?;
290                let mut first = true;
291                for (name, value) in field_names.iter().zip(values) {
292                    if !first {
293                        f.write_str(", ")?;
294                    }
295                    first = false;
296                    f.write_str(name)?;
297                    f.write_char('=')?;
298                    value.repr_fmt(f)?;
299                }
300                f.write_char(')')
301            }
302            Self::Dict(d) => {
303                f.write_char('{')?;
304                let mut iter = d.iter();
305                if let Some((k, v)) = iter.next() {
306                    k.repr_fmt(f)?;
307                    f.write_str(": ")?;
308                    v.repr_fmt(f)?;
309                    for (k, v) in iter {
310                        f.write_str(", ")?;
311                        k.repr_fmt(f)?;
312                        f.write_str(": ")?;
313                        v.repr_fmt(f)?;
314                    }
315                }
316                f.write_char('}')
317            }
318            Self::Set(s) => {
319                if s.is_empty() {
320                    f.write_str("set()")
321                } else {
322                    f.write_char('{')?;
323                    let mut iter = s.iter();
324                    if let Some(first) = iter.next() {
325                        first.repr_fmt(f)?;
326                        for item in iter {
327                            f.write_str(", ")?;
328                            item.repr_fmt(f)?;
329                        }
330                    }
331                    f.write_char('}')
332                }
333            }
334            Self::FrozenSet(fs) => {
335                f.write_str("frozenset(")?;
336                if !fs.is_empty() {
337                    f.write_char('{')?;
338                    let mut iter = fs.iter();
339                    if let Some(first) = iter.next() {
340                        first.repr_fmt(f)?;
341                        for item in iter {
342                            f.write_str(", ")?;
343                            item.repr_fmt(f)?;
344                        }
345                    }
346                    f.write_char('}')?;
347                }
348                f.write_char(')')
349            }
350            Self::Date(date) => write!(f, "datetime.date({}, {}, {})", date.year, date.month, date.day),
351            Self::DateTime(datetime) => {
352                write!(
353                    f,
354                    "datetime.datetime({}, {}, {}, {}, {}",
355                    datetime.year, datetime.month, datetime.day, datetime.hour, datetime.minute
356                )?;
357                if datetime.second != 0 || datetime.microsecond != 0 {
358                    write!(f, ", {}", datetime.second)?;
359                }
360                if datetime.microsecond != 0 {
361                    write!(f, ", {}", datetime.microsecond)?;
362                }
363                if let Some(offset) = datetime.offset_seconds {
364                    if offset == 0 && datetime.timezone_name.is_none() {
365                        f.write_str(", tzinfo=datetime.timezone.utc")?;
366                    } else {
367                        let timedelta_repr = format_offset_timedelta_repr(offset);
368                        write!(f, ", tzinfo=datetime.timezone({timedelta_repr}")?;
369                        if let Some(name) = &datetime.timezone_name {
370                            write!(f, ", {}", StringRepr(name))?;
371                        }
372                        f.write_char(')')?;
373                    }
374                }
375                f.write_char(')')
376            }
377            Self::TimeDelta(delta) => {
378                if delta.days == 0 && delta.seconds == 0 && delta.microseconds == 0 {
379                    return f.write_str("datetime.timedelta(0)");
380                }
381                f.write_str("datetime.timedelta(")?;
382                let mut first = true;
383                if delta.days != 0 {
384                    write!(f, "days={}", delta.days)?;
385                    first = false;
386                }
387                if delta.seconds != 0 {
388                    if !first {
389                        f.write_str(", ")?;
390                    }
391                    write!(f, "seconds={}", delta.seconds)?;
392                    first = false;
393                }
394                if delta.microseconds != 0 {
395                    if !first {
396                        f.write_str(", ")?;
397                    }
398                    write!(f, "microseconds={}", delta.microseconds)?;
399                }
400                f.write_char(')')
401            }
402            Self::TimeZone(tz) => {
403                if tz.offset_seconds == 0 && tz.name.is_none() {
404                    return f.write_str("datetime.timezone.utc");
405                }
406                let timedelta_repr = format_offset_timedelta_repr(tz.offset_seconds);
407                write!(f, "datetime.timezone({timedelta_repr}")?;
408                if let Some(name) = &tz.name {
409                    write!(f, ", {}", StringRepr(name))?;
410                }
411                f.write_char(')')
412            }
413            Self::Exception { exc_type, arg } => {
414                let type_str: &'static str = exc_type.into();
415                write!(f, "{type_str}(")?;
416
417                if let Some(arg) = &arg {
418                    string_repr_fmt(arg, f)?;
419                }
420                f.write_char(')')
421            }
422            Self::Dataclass {
423                name,
424                field_names,
425                attrs,
426                ..
427            } => {
428                // Format: ClassName(field1=value1, field2=value2, ...)
429                // Only declared fields are shown, not extra attributes
430                f.write_str(name)?;
431                f.write_char('(')?;
432                let mut first = true;
433                for field_name in field_names {
434                    if !first {
435                        f.write_str(", ")?;
436                    }
437                    first = false;
438                    f.write_str(field_name)?;
439                    f.write_char('=')?;
440                    // Look up value in attrs
441                    let key = Self::String(field_name.clone());
442                    if let Some(value) = attrs.iter().find(|(k, _)| k == &key).map(|(_, v)| v) {
443                        value.repr_fmt(f)?;
444                    } else {
445                        f.write_str("<?>")?;
446                    }
447                }
448                f.write_char(')')
449            }
450            Self::Path(p) => write!(f, "PosixPath('{p}')"),
451            Self::FileHandle(handle) => write!(f, "{handle}"),
452            Self::Type(t) => write!(f, "<class '{t}'>"),
453            Self::BuiltinFunction(func) => write!(f, "<built-in function {func}>"),
454            Self::Function { name, .. } => write!(f, "<function '{name}' external>"),
455            Self::Repr(s) => write!(f, "Repr({})", StringRepr(s)),
456            Self::Cycle(_, placeholder) => f.write_str(placeholder),
457        }
458    }
459
460    /// Returns `true` if this value is "truthy" according to Python's truth testing rules.
461    ///
462    /// In Python, the following values are considered falsy:
463    /// - `None` and `Ellipsis`
464    /// - `False`
465    /// - Zero numeric values (`0`, `0.0`)
466    /// - Empty sequences and collections (`""`, `b""`, `[]`, `()`, `{}`)
467    ///
468    /// All other values are truthy, including `Exception` and `Repr` variants.
469    #[must_use]
470    pub fn is_truthy(&self) -> bool {
471        match self {
472            Self::None => false,
473            Self::Ellipsis | Self::NotImplemented => true,
474            Self::Bool(b) => *b,
475            Self::Int(i) => *i != 0,
476            Self::BigInt(bi) => !bi.is_zero(),
477            Self::Float(f) => *f != 0.0,
478            Self::String(s) => !s.is_empty(),
479            Self::Bytes(b) => !b.is_empty(),
480            Self::List(l) => !l.is_empty(),
481            Self::Tuple(t) => !t.is_empty(),
482            Self::NamedTuple { values, .. } => !values.is_empty(),
483            Self::Dict(d) => !d.is_empty(),
484            Self::Set(s) => !s.is_empty(),
485            Self::FrozenSet(fs) => !fs.is_empty(),
486            Self::Date(_) => true,
487            Self::DateTime(_) => true,
488            Self::TimeDelta(delta) => delta.days != 0 || delta.seconds != 0 || delta.microseconds != 0,
489            Self::TimeZone(_) => true,
490            Self::Exception { .. } => true,
491            Self::Path(_) => true,           // Path instances are always truthy
492            Self::FileHandle { .. } => true, // File objects are always truthy
493            Self::Dataclass { .. } => true,  // Dataclass instances are always truthy
494            Self::Type(_) | Self::BuiltinFunction(_) | Self::Function { .. } | Self::Repr(_) | Self::Cycle(_, _) => {
495                true
496            }
497        }
498    }
499
500    /// Returns the Python type name for this value (e.g., `"int"`, `"str"`, `"list"`).
501    ///
502    /// These are the same names returned by Python's `type(x).__name__`.
503    #[must_use]
504    pub fn type_name(&self) -> &'static str {
505        match self {
506            Self::None => "NoneType",
507            Self::Ellipsis => "ellipsis",
508            Self::NotImplemented => "NotImplementedType",
509            Self::Bool(_) => "bool",
510            Self::Int(_) | Self::BigInt(_) => "int",
511            Self::Float(_) => "float",
512            Self::String(_) => "str",
513            Self::Bytes(_) => "bytes",
514            Self::List(_) => "list",
515            Self::Tuple(_) => "tuple",
516            Self::NamedTuple { .. } => "namedtuple",
517            Self::Dict(_) => "dict",
518            Self::Set(_) => "set",
519            Self::FrozenSet(_) => "frozenset",
520            Self::Date(_) => "date",
521            Self::DateTime(_) => "datetime",
522            Self::TimeDelta(_) => "timedelta",
523            Self::TimeZone(_) => "timezone",
524            Self::Exception { .. } => "Exception",
525            Self::Path(_) => "PosixPath",
526            Self::FileHandle(handle) => handle.mode.type_name(),
527            Self::Dataclass { .. } => "dataclass",
528            Self::Type(_) => "type",
529            Self::BuiltinFunction(_) => "builtin_function_or_method",
530            Self::Function { .. } => "function",
531            Self::Repr(_) => "repr",
532            Self::Cycle(_, _) => "cycle",
533        }
534    }
535}
536
537impl Hash for MontyObject {
538    fn hash<H: Hasher>(&self, state: &mut H) {
539        // Hash the discriminant first (but Int and BigInt share discriminant for consistency)
540        match self {
541            Self::Int(_) | Self::BigInt(_) => {
542                // Use Int discriminant for both to maintain hash consistency
543                mem::discriminant(&Self::Int(0)).hash(state);
544            }
545            _ => mem::discriminant(self).hash(state),
546        }
547
548        match self {
549            Self::Ellipsis | Self::NotImplemented | Self::None => {}
550            Self::Bool(bool) => bool.hash(state),
551            Self::Int(i) => i.hash(state),
552            Self::BigInt(bi) => {
553                // For hash consistency, if BigInt fits in i64, hash as i64
554                if let Ok(i) = i64::try_from(bi) {
555                    i.hash(state);
556                } else {
557                    // For large BigInts, hash the signed bytes
558                    bi.to_signed_bytes_le().hash(state);
559                }
560            }
561            Self::Float(f) => f.to_bits().hash(state),
562            Self::String(string) => string.hash(state),
563            Self::Bytes(bytes) => bytes.hash(state),
564            Self::Date(date) => date.hash(state),
565            Self::DateTime(datetime) => datetime.hash(state),
566            Self::TimeDelta(delta) => delta.hash(state),
567            Self::TimeZone(timezone) => timezone.hash(state),
568            Self::Path(path) => path.hash(state),
569            Self::FileHandle(MontyFileHandle { path, mode, position }) => {
570                path.hash(state);
571                mode.as_str().hash(state);
572                position.hash(state);
573            }
574            Self::Type(t) => t.name().hash(state),
575            Self::Cycle(_, _) => panic!("cycle values are not hashable"),
576            _ => panic!("{} python values are not hashable", self.type_name()),
577        }
578    }
579}
580
581impl PartialEq for MontyObject {
582    fn eq(&self, other: &Self) -> bool {
583        match (self, other) {
584            (Self::Ellipsis, Self::Ellipsis) => true,
585            (Self::NotImplemented, Self::NotImplemented) => true,
586            (Self::None, Self::None) => true,
587            (Self::Bool(a), Self::Bool(b)) => a == b,
588            (Self::Int(a), Self::Int(b)) => a == b,
589            (Self::BigInt(a), Self::BigInt(b)) => a == b,
590            // Cross-compare Int and BigInt without allocating a temporary BigInt.
591            (Self::Int(a), Self::BigInt(b)) | (Self::BigInt(b), Self::Int(a)) => b.to_i64() == Some(*a),
592            // Use to_bits() for float comparison to be consistent with Hash
593            (Self::Float(a), Self::Float(b)) => a.to_bits() == b.to_bits(),
594            (Self::String(a), Self::String(b)) => a == b,
595            (Self::Bytes(a), Self::Bytes(b)) => a == b,
596            (Self::List(a), Self::List(b)) => a == b,
597            (Self::Tuple(a), Self::Tuple(b)) => a == b,
598            (Self::Date(a), Self::Date(b)) => a == b,
599            (Self::DateTime(a), Self::DateTime(b)) => a == b,
600            (Self::TimeDelta(a), Self::TimeDelta(b)) => a == b,
601            (Self::TimeZone(a), Self::TimeZone(b)) => a == b,
602            (
603                Self::NamedTuple {
604                    type_name: a_type,
605                    field_names: a_fields,
606                    values: a_values,
607                },
608                Self::NamedTuple {
609                    type_name: b_type,
610                    field_names: b_fields,
611                    values: b_values,
612                },
613            ) => a_type == b_type && a_fields == b_fields && a_values == b_values,
614            // NamedTuple can compare with Tuple by values only (matching Python semantics)
615            (Self::NamedTuple { values, .. }, Self::Tuple(t)) | (Self::Tuple(t), Self::NamedTuple { values, .. }) => {
616                values == t
617            }
618            (Self::Dict(a), Self::Dict(b)) => a == b,
619            (Self::Set(a), Self::Set(b)) => a == b,
620            (Self::FrozenSet(a), Self::FrozenSet(b)) => a == b,
621            (
622                Self::Exception {
623                    exc_type: a_type,
624                    arg: a_arg,
625                },
626                Self::Exception {
627                    exc_type: b_type,
628                    arg: b_arg,
629                },
630            ) => a_type == b_type && a_arg == b_arg,
631            (
632                Self::Dataclass {
633                    name: a_name,
634                    type_id: a_type_id,
635                    field_names: a_field_names,
636                    attrs: a_attrs,
637                    frozen: a_frozen,
638                },
639                Self::Dataclass {
640                    name: b_name,
641                    type_id: b_type_id,
642                    field_names: b_field_names,
643                    attrs: b_attrs,
644                    frozen: b_frozen,
645                },
646            ) => {
647                a_name == b_name
648                    && a_type_id == b_type_id
649                    && a_field_names == b_field_names
650                    && a_attrs == b_attrs
651                    && a_frozen == b_frozen
652            }
653            (Self::Path(a), Self::Path(b)) => a == b,
654            (
655                Self::FileHandle(MontyFileHandle {
656                    path: a_path,
657                    mode: a_mode,
658                    position: a_pos,
659                }),
660                Self::FileHandle(MontyFileHandle {
661                    path: b_path,
662                    mode: b_mode,
663                    position: b_pos,
664                }),
665            ) => a_path == b_path && a_mode == b_mode && a_pos == b_pos,
666            (
667                Self::Function {
668                    name: a_name,
669                    docstring: a_doc,
670                },
671                Self::Function {
672                    name: b_name,
673                    docstring: b_doc,
674                },
675            ) => a_name == b_name && a_doc == b_doc,
676            (Self::Repr(a), Self::Repr(b)) => a == b,
677            (Self::Cycle(a, _), Self::Cycle(b, _)) => a == b,
678            (Self::Type(a), Self::Type(b)) => a == b,
679            // matches Python, where builtins are singletons: `len == len` is True
680            (Self::BuiltinFunction(a), Self::BuiltinFunction(b)) => a == b,
681            _ => false,
682        }
683    }
684}
685
686impl Eq for MontyObject {}
687
688impl AsRef<Self> for MontyObject {
689    fn as_ref(&self) -> &Self {
690        self
691    }
692}
693
694/// The Python type of a value at the host boundary — the public mirror of the
695/// internal runtime `Type` enum.
696///
697/// Where the runtime `Type::Instance` carries a transient heap id, the public
698/// [`MontyType::Instance`] carries the *resolved class name* as an owned
699/// `String`, so a `MontyType` is always self-contained: it can be serialized,
700/// sent over the subprocess wire protocol, and displayed without heap access.
701///
702/// `Instance` is output-only: a class binding cannot be reconstructed from a
703/// name, so passing `MontyType::Instance` as an *input* is rejected with an
704/// [`InvalidInputError`] (see [`MontyObject`] input conversion).
705#[derive(
706    Debug,
707    Clone,
708    PartialEq,
709    Eq,
710    serde::Serialize,
711    serde::Deserialize,
712    strum::EnumIter,
713    strum::EnumString,
714    strum::IntoStaticStr,
715)]
716#[strum(serialize_all = "lowercase")]
717pub enum MontyType {
718    Ellipsis,
719    Type,
720    #[strum(serialize = "NoneType")]
721    NoneType,
722    Bool,
723    Int,
724    Float,
725    Range,
726    Slice,
727    Date,
728    #[strum(serialize = "datetime.datetime")]
729    DateTime,
730    TimeDelta,
731    TimeZone,
732    Str,
733    Bytes,
734    List,
735    /// `collections.deque`. Qualified like `datetime.datetime` so the
736    /// host-boundary name matches the runtime `Type::Deque` (`collections.deque`)
737    /// rather than a bare `deque`.
738    #[strum(serialize = "collections.deque")]
739    Deque,
740    #[strum(serialize = "list_iterator")]
741    ListIterator,
742    #[strum(serialize = "callable_iterator")]
743    CallableIterator,
744    Tuple,
745    NamedTuple,
746    Dict,
747    #[strum(serialize = "dict_keys")]
748    DictKeys,
749    #[strum(serialize = "dict_items")]
750    DictItems,
751    #[strum(serialize = "dict_values")]
752    DictValues,
753    Set,
754    FrozenSet,
755    Dataclass,
756    /// An instance of a sandbox-defined class (`class Foo: ...`), carrying the
757    /// resolved class name (e.g. `"Foo"`). Output-only — rejected as an input.
758    ///
759    /// `#[strum(disabled)]`: excluded from `EnumIter` (no meaningful default
760    /// name; the name round-trip tests iterate the nameable variants only).
761    #[strum(disabled)]
762    Instance(String),
763    /// Exception types render/parse via `ExcType`'s own strum name
764    /// (`"ValueError"`, `"json.JSONDecodeError"`, ...), so this variant is
765    /// `#[strum(disabled)]`: [`name`](Self::name) and
766    /// [`from_type_name`](Self::from_type_name) peel `Exception` off
767    /// explicitly.
768    #[strum(disabled)]
769    Exception(ExcType),
770    Function,
771    #[strum(serialize = "builtin_function_or_method")]
772    BuiltinFunction,
773    Cell,
774    Iterator,
775    Coroutine,
776    Module,
777    #[strum(serialize = "_io.TextIOWrapper")]
778    TextIOWrapper,
779    #[strum(serialize = "_io.BufferedReader")]
780    BufferedReader,
781    #[strum(serialize = "_io.BufferedWriter")]
782    BufferedWriter,
783    #[strum(serialize = "_io.BufferedRandom")]
784    BufferedRandom,
785    #[strum(serialize = "typing._SpecialForm")]
786    SpecialForm,
787    #[strum(serialize = "PosixPath")]
788    Path,
789    Property,
790    #[strum(serialize = "re.Pattern")]
791    RePattern,
792    #[strum(serialize = "re.Match")]
793    ReMatch,
794    // Serialized enum variants are append-only to preserve postcard discriminants.
795    #[strum(serialize = "tuple_iterator")]
796    TupleIterator,
797    #[strum(serialize = "str_ascii_iterator")]
798    StrAsciiIterator,
799    #[strum(serialize = "str_iterator")]
800    StrIterator,
801    #[strum(serialize = "bytes_iterator")]
802    BytesIterator,
803    #[strum(serialize = "range_iterator")]
804    RangeIterator,
805    #[strum(serialize = "dict_keyiterator")]
806    DictKeyIterator,
807    #[strum(serialize = "dict_itemiterator")]
808    DictItemIterator,
809    #[strum(serialize = "dict_valueiterator")]
810    DictValueIterator,
811    #[strum(serialize = "set_iterator")]
812    SetIterator,
813    #[strum(serialize = "itertools.count")]
814    ItertoolsCount,
815    #[strum(serialize = "itertools.repeat")]
816    ItertoolsRepeat,
817    /// A `dataclasses.Field` describing one field of a sandbox `@dataclass`,
818    /// as found in a class's `__dataclass_fields__`.
819    #[strum(serialize = "Field")]
820    Field,
821    #[strum(serialize = "itertools.pairwise")]
822    ItertoolsPairwise,
823    #[strum(serialize = "itertools.compress")]
824    ItertoolsCompress,
825    #[strum(serialize = "itertools.islice")]
826    ItertoolsIslice,
827    #[strum(serialize = "itertools.chain")]
828    ItertoolsChain,
829    #[strum(serialize = "itertools.cycle")]
830    ItertoolsCycle,
831    #[strum(serialize = "NotImplementedType")]
832    NotImplementedType,
833}
834
835impl fmt::Display for MontyType {
836    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
837        f.write_str(self.name())
838    }
839}
840
841impl MontyType {
842    /// The Python-visible name of this type (`"int"`, `"datetime.datetime"`,
843    /// `"ValueError"`, or the class name for [`Instance`](Self::Instance)).
844    #[must_use]
845    pub fn name(&self) -> &str {
846        match self {
847            Self::Instance(name) => name,
848            Self::Exception(exc_type) => (*exc_type).into(),
849            // Every remaining variant is named by strum's `IntoStaticStr`
850            // (`Exception`/`Instance` are peeled off above).
851            other => other.into(),
852        }
853    }
854
855    /// Parses a name produced by [`Display`](fmt::Display)/[`name`](Self::name)
856    /// back to the `MontyType` — the wire-protocol decode path for builtin
857    /// type names. Never yields [`Instance`](Self::Instance) (`"object"` and
858    /// class names return `None`); the wire carries instance types in a
859    /// dedicated field instead.
860    ///
861    /// `EnumString` parses via the same strum `serialize` attributes that
862    /// `IntoStaticStr` renders with, so the two stay in lockstep by
863    /// construction. Exception types display as their exception name
864    /// ("ValueError", "json.JSONDecodeError", ...) — fall back to the
865    /// `ExcType` parser.
866    #[must_use]
867    pub fn from_type_name(name: &str) -> Option<Self> {
868        name.parse::<Self>()
869            .ok()
870            .or_else(|| name.parse::<ExcType>().ok().map(Self::Exception))
871    }
872}
873
874/// A Python `datetime.date` value with year, month, and day components.
875#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
876pub struct MontyDate {
877    /// Gregorian year in range 1..=9999.
878    pub year: i32,
879    /// Month component in range 1..=12.
880    pub month: u8,
881    /// Day component valid for the given month/year.
882    pub day: u8,
883}
884
885/// A Python `datetime.datetime` value with date, time, and optional timezone components.
886#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
887pub struct MontyDateTime {
888    /// Gregorian year in range 1..=9999.
889    pub year: i32,
890    /// Month component in range 1..=12.
891    pub month: u8,
892    /// Day component valid for the given month/year.
893    pub day: u8,
894    /// Hour in range 0..=23.
895    pub hour: u8,
896    /// Minute in range 0..=59.
897    pub minute: u8,
898    /// Second in range 0..=59.
899    pub second: u8,
900    /// Microsecond in range 0..=999_999.
901    pub microsecond: u32,
902    /// Fixed offset seconds for aware datetimes, or `None` for naive values.
903    pub offset_seconds: Option<i32>,
904    /// Optional explicit timezone name for aware datetimes.
905    ///
906    /// Must be `None` when `offset_seconds` is `None`.
907    pub timezone_name: Option<String>,
908}
909
910/// A Python `datetime.timedelta` value representing a duration.
911#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
912pub struct MontyTimeDelta {
913    /// Day component.
914    pub days: i32,
915    /// Seconds component in normalized range 0..86400.
916    pub seconds: i32,
917    /// Microseconds component in normalized range 0..1_000_000.
918    pub microseconds: i32,
919}
920
921/// A Python `datetime.timezone` fixed-offset timezone.
922#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
923pub struct MontyTimeZone {
924    /// Fixed UTC offset in seconds.
925    pub offset_seconds: i32,
926    /// Optional display name.
927    pub name: Option<String>,
928}
929
930impl PartialEq for MontyDateTime {
931    fn eq(&self, other: &Self) -> bool {
932        let self_aware = self.offset_seconds.is_some();
933        let other_aware = other.offset_seconds.is_some();
934        if self_aware != other_aware {
935            return false;
936        }
937
938        if self_aware {
939            return monty_datetime_utc_micros(self)
940                .zip(monty_datetime_utc_micros(other))
941                .is_some_and(|(lhs, rhs)| lhs == rhs)
942                || monty_datetime_raw_eq(self, other);
943        }
944
945        monty_datetime_local_micros(self)
946            .zip(monty_datetime_local_micros(other))
947            .is_some_and(|(lhs, rhs)| lhs == rhs)
948            || monty_datetime_raw_eq(self, other)
949    }
950}
951
952impl Eq for MontyDateTime {}
953
954impl Hash for MontyDateTime {
955    fn hash<H: Hasher>(&self, state: &mut H) {
956        if self.offset_seconds.is_some()
957            && let Some(utc_micros) = monty_datetime_utc_micros(self)
958        {
959            utc_micros.hash(state);
960            return;
961        }
962        if let Some(local_micros) = monty_datetime_local_micros(self) {
963            local_micros.hash(state);
964            return;
965        }
966
967        // Invalid carrier values should still hash deterministically instead of panicking.
968        self.year.hash(state);
969        self.month.hash(state);
970        self.day.hash(state);
971        self.hour.hash(state);
972        self.minute.hash(state);
973        self.second.hash(state);
974        self.microsecond.hash(state);
975        self.offset_seconds.hash(state);
976        self.timezone_name.hash(state);
977    }
978}
979
980impl PartialEq for MontyTimeZone {
981    fn eq(&self, other: &Self) -> bool {
982        self.offset_seconds == other.offset_seconds
983    }
984}
985
986impl Eq for MontyTimeZone {}
987
988impl Hash for MontyTimeZone {
989    fn hash<H: Hasher>(&self, state: &mut H) {
990        self.offset_seconds.hash(state);
991    }
992}
993
994/// Error returned when a `MontyObject` cannot be converted to the requested Rust type.
995///
996/// This error is returned by the `TryFrom` implementations when attempting to extract
997/// a specific type from a `MontyObject` that holds a different variant.
998#[derive(Debug)]
999pub struct ConversionError {
1000    /// The type name that was expected (e.g., "int", "str").
1001    pub expected: &'static str,
1002    /// The actual type name of the `MontyObject` (e.g., "list", "NoneType").
1003    pub actual: &'static str,
1004}
1005
1006impl ConversionError {
1007    /// Creates a new `ConversionError` with the expected and actual type names.
1008    #[must_use]
1009    pub fn new(expected: &'static str, actual: &'static str) -> Self {
1010        Self { expected, actual }
1011    }
1012}
1013
1014impl fmt::Display for ConversionError {
1015    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1016        write!(f, "expected {}, got {}", self.expected, self.actual)
1017    }
1018}
1019
1020impl Error for ConversionError {}
1021
1022/// Error returned when a `MontyObject` cannot be used as an input to code execution.
1023///
1024/// This can occur when:
1025/// - A `MontyObject` variant (like `Repr`) is only valid as an output, not an input
1026/// - A resource limit is exceeded during conversion
1027#[derive(Debug, Clone)]
1028pub enum InvalidInputError {
1029    /// The input type is not valid for conversion to a runtime Value.
1030    /// Message explaining why the type is invalid.
1031    InvalidType(Cow<'static, str>),
1032    /// A resource limit was exceeded during conversion.
1033    Resource(ResourceError),
1034}
1035
1036impl InvalidInputError {
1037    /// Creates a new `InvalidInputError` for the given type name.
1038    #[must_use]
1039    pub fn invalid_type(msg: impl Into<Cow<'static, str>>) -> Self {
1040        Self::InvalidType(msg.into())
1041    }
1042}
1043
1044impl fmt::Display for InvalidInputError {
1045    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1046        match self {
1047            Self::InvalidType(msg) => write!(f, "{msg}"),
1048            Self::Resource(e) => write!(f, "{e}"),
1049        }
1050    }
1051}
1052
1053impl Error for InvalidInputError {}
1054
1055impl From<ResourceError> for InvalidInputError {
1056    fn from(err: ResourceError) -> Self {
1057        Self::Resource(err)
1058    }
1059}
1060
1061/// Attempts to convert a MontyObject to an i64 integer.
1062/// Returns an error if the object is not an Int variant.
1063impl TryFrom<&MontyObject> for i64 {
1064    type Error = ConversionError;
1065
1066    fn try_from(value: &MontyObject) -> Result<Self, Self::Error> {
1067        match value {
1068            MontyObject::Int(i) => Ok(*i),
1069            _ => Err(ConversionError::new("int", value.type_name())),
1070        }
1071    }
1072}
1073
1074/// Attempts to convert a MontyObject to an f64 float.
1075/// Returns an error if the object is not a Float or Int variant.
1076/// Int values are automatically converted to f64 to match python's behavior.
1077impl TryFrom<&MontyObject> for f64 {
1078    type Error = ConversionError;
1079
1080    fn try_from(value: &MontyObject) -> Result<Self, Self::Error> {
1081        match value {
1082            MontyObject::Float(f) => Ok(*f),
1083            MontyObject::Int(i) => Ok(*i as Self),
1084            _ => Err(ConversionError::new("float", value.type_name())),
1085        }
1086    }
1087}
1088
1089/// Attempts to convert a MontyObject to a String.
1090/// Returns an error if the object is not a heap-allocated Str variant.
1091impl TryFrom<&MontyObject> for String {
1092    type Error = ConversionError;
1093
1094    fn try_from(value: &MontyObject) -> Result<Self, Self::Error> {
1095        if let MontyObject::String(s) = value {
1096            Ok(s.clone())
1097        } else {
1098            Err(ConversionError::new("str", value.type_name()))
1099        }
1100    }
1101}
1102
1103/// Attempts to convert a `MontyObject` to a bool.
1104/// Returns an error if the object is not a True or False variant.
1105/// Note: This does NOT use Python's truthiness rules (use MontyObject::bool for that).
1106impl TryFrom<&MontyObject> for bool {
1107    type Error = ConversionError;
1108
1109    fn try_from(value: &MontyObject) -> Result<Self, Self::Error> {
1110        match value {
1111            MontyObject::Bool(b) => Ok(*b),
1112            _ => Err(ConversionError::new("bool", value.type_name())),
1113        }
1114    }
1115}
1116
1117/// A collection of key-value pairs representing Python dictionary contents.
1118///
1119/// Used internally by `MontyObject::Dict` to store dictionary entries while preserving
1120/// insertion order. Keys and values are both `MontyObject` instances.
1121#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1122pub struct DictPairs(Vec<(MontyObject, MontyObject)>);
1123
1124impl From<Vec<(MontyObject, MontyObject)>> for DictPairs {
1125    fn from(pairs: Vec<(MontyObject, MontyObject)>) -> Self {
1126        Self(pairs)
1127    }
1128}
1129
1130impl IntoIterator for DictPairs {
1131    type Item = (MontyObject, MontyObject);
1132    type IntoIter = IntoIter<Self::Item>;
1133
1134    fn into_iter(self) -> Self::IntoIter {
1135        self.0.into_iter()
1136    }
1137}
1138impl<'a> IntoIterator for &'a DictPairs {
1139    type Item = &'a (MontyObject, MontyObject);
1140    type IntoIter = slice::Iter<'a, (MontyObject, MontyObject)>;
1141
1142    fn into_iter(self) -> Self::IntoIter {
1143        self.0.iter()
1144    }
1145}
1146
1147impl FromIterator<(MontyObject, MontyObject)> for DictPairs {
1148    fn from_iter<T: IntoIterator<Item = (MontyObject, MontyObject)>>(iter: T) -> Self {
1149        Self(iter.into_iter().collect())
1150    }
1151}
1152
1153impl DictPairs {
1154    /// Number of (key, value) pairs held by this dict.
1155    #[must_use]
1156    pub fn len(&self) -> usize {
1157        self.0.len()
1158    }
1159
1160    /// Whether this dict has no pairs.
1161    #[must_use]
1162    pub fn is_empty(&self) -> bool {
1163        self.0.is_empty()
1164    }
1165
1166    fn iter(&self) -> impl Iterator<Item = &(MontyObject, MontyObject)> {
1167        self.0.iter()
1168    }
1169}
1170
1171/// An open file object (the result of `open()`).
1172///
1173/// This is the boundary representation of Monty's heap `OpenFile`
1174/// wrapper. It carries everything needed to service a file operation from a
1175/// host that holds no live OS handle: the virtual `path`, the `mode`, and
1176/// the byte `position` for seek-aware reads.
1177///
1178/// The host produces a `FileHandle` as the result of an
1179/// [`OsFunctionCall::Open`](crate::os::OsFunctionCall::Open) call; the
1180/// interpreter then builds its heap file wrapper from it. Conversely, a heap file
1181/// object passed as an argument to a `read`/`write` OS call is converted
1182/// back to a `FileHandle` so the host receives this state.
1183#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1184pub struct MontyFileHandle {
1185    /// The virtual (sandbox) path of the file. Never a host path.
1186    pub path: String,
1187    /// The parsed `open()` mode.
1188    pub mode: FileMode,
1189    /// Position for sized/line/seek operations: char index in text mode,
1190    /// byte index in binary mode. `0` for a freshly opened file.
1191    pub position: u64,
1192}
1193
1194impl fmt::Display for MontyFileHandle {
1195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1196        write!(
1197            f,
1198            "<{} name={} mode={}>",
1199            self.mode.file_type_name(),
1200            StringRepr(&self.path),
1201            StringRepr(self.mode.as_str())
1202        )
1203    }
1204}
1205
1206fn monty_datetime_local_micros(datetime: &MontyDateTime) -> Option<i64> {
1207    monty_datetime_naive(datetime).map(|naive| naive.and_utc().timestamp_micros())
1208}
1209
1210fn monty_datetime_raw_eq(a: &MontyDateTime, b: &MontyDateTime) -> bool {
1211    a.year == b.year
1212        && a.month == b.month
1213        && a.day == b.day
1214        && a.hour == b.hour
1215        && a.minute == b.minute
1216        && a.second == b.second
1217        && a.microsecond == b.microsecond
1218        && a.offset_seconds == b.offset_seconds
1219        && a.timezone_name == b.timezone_name
1220}
1221
1222fn monty_datetime_utc_micros(datetime: &MontyDateTime) -> Option<i64> {
1223    let offset_seconds = datetime.offset_seconds?;
1224    let offset_delta = ChronoTimeDelta::try_seconds(i64::from(offset_seconds))?;
1225    let utc = monty_datetime_naive(datetime)?.checked_sub_signed(offset_delta)?;
1226    Some(utc.and_utc().timestamp_micros())
1227}
1228
1229fn monty_datetime_naive(datetime: &MontyDateTime) -> Option<NaiveDateTime> {
1230    let date = NaiveDate::from_ymd_opt(datetime.year, u32::from(datetime.month), u32::from(datetime.day))?;
1231    let time = NaiveTime::from_hms_micro_opt(
1232        u32::from(datetime.hour),
1233        u32::from(datetime.minute),
1234        u32::from(datetime.second),
1235        datetime.microsecond,
1236    )?;
1237    Some(date.and_time(time))
1238}