This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
DON'T COMMIT UNLESS EXPLICITLY ASKED TO DO SO BY THE USER! Previous commit requests do not matter - don't commit unless you've just been explicitly asked to do so.
Monty is a sandboxed Python interpreter written in Rust. It parses Python code using Ruff's ruff_python_parser but implements its own runtime execution model for safety and performance. It supports a subset of Python features.
Project goals:
- Safety: Execute untrusted Python code safely without FFI or C dependencies, instead sandbox will call back to host to run foreign/external functions.
- Performance: Fast execution through compile-time optimizations and efficient memory layout
- Simplicity: Clean, understandable implementation focused on a Python subset
- Snapshotting and iteration: Plan is to allow code to be iteratively executed and snapshotted at each function call
- Cross-platform: Runs on Linux, macOS, and Windows (and any other OS that can run Rust)
- Targets the latest stable version of Python, currently Python 3.14
The public data types (the MontyGraph/MontyNode value graph and the MontyObject/CallArgs/NamedValues
built on it, MontyException/ExcType, OsFunctionCall +
its arg structs, ResourceLimits/ResourceTracker, PrintStream/PrintWriter,
CompileOptions, ExtFunctionResult, FileMode, ...) live in crates/monty-types,
which depends on no other monty crate except the monty-macros derives. monty
depends on monty-types but does not blanket re-export it — only a few types
are re-exported inline where they appear in monty's public API (e.g.
run::CompileOptions, run_progress::{ExtFunctionResult, NameLookupResult}).
Code needing MontyObject, MontyException, OsFunctionCall, etc. must
depend on monty-types directly.
Host-side crates (monty-fs, monty-pool, monty-proto without its worker
feature, monty-python, monty-js) MUST depend on monty-types, NOT monty —
this keeps the interpreter out of their binaries. Only the worker side
(monty-runtime, monty-wasm-runtime, monty-proto with worker) links the
interpreter. Don't add a monty dependency to a host-side crate; if it needs a
type, that type belongs in monty-types.
Interpreter-coupled methods on these types live in monty as pub(crate)
extension traits (ExcTypeExt, MontyObjectExt, MontyGraphExt, CallArgsExt,
MontyTypeExt, StackFrameExt, FileModeExt, BuiltinsFunctionsExt,
ExtFunctionResultExt) — import the trait to call e.g. ExcType::type_error(...) or
MontyObject::export(value, vm).
object_bridge::GraphExporter builds one MontyGraph per outgoing message, so a sub-object shared in the sandbox
crosses once; MontyGraphExt::to_values converts an incoming graph back into interpreter values.
MontyObject is one owned value, a graph plus its root node: hosts build inputs with it and read results through its
ObjectRef accessors. These types, along with CallArgs and NamedValues, have private fields; carrier builders accept
MontyObject values rather than exposing node IDs.
Graph types (MontyGraph, MontyNode, NodeId, ClassTypeNode, GraphError), PushValue, raw node inspection and
borrowed/owned storage access are exported only through monty_types::unstable.
These representation APIs carry no API compatibility guarantee; prefer value constructors and accessors elsewhere.
The unstable object_from_graph, call_args_from_parts and named_values_from_parts constructors check the roots.
Monty must work identically on Linux, macOS, and Windows. Within the Monty sandbox,
paths always use POSIX/Linux-style forward slashes (/) regardless of the host OS.
The MountTable handles translating between virtual POSIX paths and host-native paths.
Key rules:
- Virtual paths are always POSIX-style (
/mnt/data/file.txt), never Windows-style - Host paths use
std::path::Path/PathBufwhich handles OS differences automatically - Avoid
#[cfg(unix)]-only code in the main crate — all features must work on all platforms. The one exception is the CPU execution clock, which is Linux-only so that it needs nounsafe. - Tests in
crates/*/tests/should be cross-platform; use helper functions for OS-specific APIs like symlink creation (seesymlink_file/symlink_dirincrates/monty-fs/tests/common/mod.rs, shared viamod common;— eachtests/*.rsis its own crate, so helpers used by more than one belong there) - CI runs
cargo test -p monty --features memory-model-checksandcargo test -p monty-fson Linux, macOS, and Windows
It's ABSOLUTELY CRITICAL that there's no way for code run in a Monty sandbox to access the host filesystem, or environment or to in any way "escape the sandbox".
Monty will be used to run untrusted, potentially malicious code.
Make sure there's no risk of this, either in the implementation, or in the public API that makes it more like that a developer using the pydantic_monty package might make such a mistake.
Possible security risks to consider:
- filesystem access
- path traversal to access files the users did not intend to expose to the monty sandbox
- memory errors - use of unsafe memory operations
- excessive memory usage - evading monty's resource limits
- infinite loops - evading monty's resource limits
- network access - sockets, HTTP requests
- subprocess/shell execution - os.system, subprocess, etc.
- import system abuse - importing modules with side effects or accessing
__import__ - external function/callback misuse - callbacks run in host environment
- deserialization attacks - worker frames are untrusted; snapshots follow the trust contract below
- regex/string DoS - catastrophic backtracking or operations bypassing limits
- information leakage via timing or error messages
- Python/Javascript/Rust APIs that accidentally allow developers to expose their host to monty code
Snapshots and direct serde-serialized interpreter state must be unmodified output from a trusted, compatible Monty producer. The caller is responsible for establishing provenance and integrity before loading; the interpreter does not authenticate snapshots or fully validate their contents. Invalid snapshots have no correctness or availability guarantees: loading or using them may panic, abort, hang, or produce incorrect results, but MUST NOT cause undefined behaviour. Successful deserialization is not proof of validity. Genuine snapshots produced while running untrusted Python remain supported.
Do not add semantic validation or graceful error paths solely for tampered snapshots. Keep memory-safety checks (including rejecting transient Gray/White GC states), reconstruction needed by valid snapshots, format/version checks, and transport compatibility checks. Worker frames, host values, callbacks and filesystem mounts remain untrusted boundaries independently of snapshot trust.
The MountTable allows mounting real host directories into the sandbox at virtual paths,
with configurable access modes (ReadWrite, ReadOnly, OverlayMemory).
Mounts are HOST-side code: the monty interpreter crate performs no filesystem
I/O and does not depend on monty-fs. Sandboxed code suspends with an
OsFunctionCall, which a host holding a MountTable (the pool parent, the CLI,
bindings) services via MountTable::handle_os_call.
CRITICAL SECURITY INVARIANT: The monty runtime MUST NEVER read, write, or obtain any information about any file or directory outside the specific directory that is mounted. This is enforced by:
- A
cap_std::fs::Dirdescriptor opened once at mount time, which every operation runs relative to — so.., symlinks and intermediate directories swapped mid-operation cannot reach out - Virtual-space normalization that prevents
..escape in the sandbox namespace ResolveandAbsolutereturning virtual paths, never host paths- Null byte rejection in all paths
Path confinement is structural, not a check: Mount::dir (in
crates/monty-fs/src/mount_table.rs) is the boundary; path_security.rs is
now only path policy. The cost is that an absolute symlink target is never
followed, even inside the mount (see limitations/filesystem.md) — do not
"fix" that by comparing against the mount's host path, which restores the
check-then-use this removes.
Changes to mount_table.rs or path_security.rs require careful security
review. The crates/monty/src/heap/ module and the mount boundary are the most security-critical
code in the codebase.
A monty process can never be made fully crash-proof against memory errors (stack overflow aborts, allocator aborts), so monty can run as isolated worker subprocesses:
crates/monty-proto— the wire protocol: a protobuf schema (proto/monty/v1/monty.proto), checked-in prost-generated code (regenerate withmake generate-proto; CI enforces sync viamake check-proto), 4-byte LE length-prefixed framing, and fallible conversions between wire types andMontyException/etc. Values cross as one flat post-orderMontyGraphper message (monty.v1.Arena): every child index is lower than its holder's, an index used twice is a shared object, and the message names its roots by index, so a shared sub-object crosses once and the wire imposes no nesting limit. prostextern_pathmaps the message ontoWireArena(src/wire.rs), a hand-writtenprost::Messageimpl that encodes borrowedMontyNodes without cloning. Decoding parses one generated protobuf node at a time, then validates and converts it into the domain arena; temporary buffers and conversions share the frame budget.wire/references.rsmaps index, pair and named-tuple messages onto domain reference buffers so node conversion can transfer them without allocating or copying.tests/differential.rsproves it byte-compatible against a fully prost-generated oracle (tests/oracle/, regenerated and CI-checked together with the main codegen). Parents must treat frames from a (possibly compromised) child as untrusted — wire decoding and proto→Rust conversions validate everything and never panic. Generated decoders usebudgeted_prostviaprost_path: generated vectors and byte buffers useBudgetVec, whose decode growth is fallible and shares a cumulative per-frame allocation budget with hand-written boxed payloads. Decode throughdecode_frameorFrameReader::read, which scope the budget; decoding these protocol types cannot allocate payload storage outside a frame. Integration tests enable the internaltest-utilfeature for smaller budgets and accounting checks. New allocation forms must extend the adapter and its tests; codegen rejects unsupported maps, groups, generated boxes,Bytesfields and repeated enums (prost's enum accessors require infalliblepush).monty-protodepends only onmonty-typesby default; itsworkerfeature (enabled bymonty-runtime/monty-wasm-runtime) pulls in the fullmontyinterpreter for the child-sideworkerstate machine. Itspythonfeature (monty_proto::python, PyO3 value conversion) servesmonty-pythonand alsomonty-cpython, an embedded-CPython worker in the privatemonty-privaterepository, so keep shared conversion there and host-only binding logic inmonty-python.monty-private's crates (monty-cpython,monty-relay,monty-server, ...) also consume the wire protocol: check them before changingmonty_proto::python's API or theOsCallarms, whichmonty-relayscans by field number.monty subprocess(incrates/monty-runtime/src/subprocess.rs) — the child: reads framed requests on stdin, writes framed events on stdout, serving one REPL session per checkout. Strict alternation: one request in, zero or more streamedPrintevents out, then exactly one turn-ending event.PoolConfig::execution_clockselects wall time (default) or native thread CPU time for all local sessions. A WebSocket server selects its own clock and reports it to clients; session limits cannot override it. CPU mode needs Linux workers, so it needs nounsafe:Pool::newrejects it for subprocess pools elsewhere and the worker refuses it. WebSocket clients on any platform can adopt a Linux server's CPU clock. Local subprocess pools supervise CPU with the child's OS process clock. CPU mode requires a finitePoolConfig::request_timeout; WebSocket clients do not supervise CPU locally. WebAssembly workers accept wall mode only. Snapshots retain their clock; the worker rejects a load configured with another clock.crates/monty-pool— the parent: an async (tokio) elastic pool of workers with crash detection/replacement and a hard per-turn timeout. Frame reads are cancel-safe (partial-frame state lives in the worker, no pump task), and turn deadlines are tokio timers rather than a watchdog thread.crates/monty-alloc— the#[global_allocator]both workers run under: it exposes live-byte usage for the interpreter's soft-limit checks and enforces the hard ceiling selected fromChild::session_budgetafter every request. Crossing the hard limit ends the process rather than letting Rust abort. Itsexit-codefeature picks how:monty-runtimeenables it and exits withOOM_EXIT_CODEfor the pool to classify,monty-wasm-runtimeleaves it off and traps, having no exit status to offer. Only a binary or a wasm module may declare a global allocator, so the crate provides the type and each declares its own. Direct interpreter use must install and arm this allocator before configuringmax_memory.pydantic_monty.Monty/pydantic_monty.AsyncMonty— the ONLY Python execution surface (there is no in-process Python API): sync and async pools of workers (with Monty() as pool: with pool.checkout() as session: session.feed_run(...), and theasync with/await feed_runequivalents).
The contract for crash detection: a child that exits or EOFs without a
FatalError event crashed hard; the parent discards it and replaces it.
Pool and protocol design belong in their crate READMEs; host-API contracts belong in the relevant
docs/ topic and binding reference. Value-conversion divergences belong in limitations/host-values.md.
Monty is implemented as a bytecode VM, same as CPython.
Opcodes serialize as a single byte, so the Opcode enum (crates/monty/src/bytecode/op.rs)
is hard-capped at 256 variants and roughly half are already taken. Use slots sparingly:
prefer a flags/operand encoding on one opcode (e.g. Assert/FormatValue) over a family
of near-identical opcodes, unless the instruction is hot enough that decoding the
discriminating operand would cost measurable dispatch time.
Each Code (crates/monty/src/bytecode/code.rs) owns its bytecode, constants and metadata.
A CallFrame borrows the Code and caches its bytecode slice so instruction fetches avoid an extra dereference.
Its usize instruction pointer, source locations and exception handlers all use body-relative offsets.
Committed functions have stable addresses and their code is immutable, so runtime compilation can publish new
functions without invalidating active frames.
Module code is held in an Arc<Code> so runner clones share it; frames borrow it from the running Program.
eval() / exec() bodies are stored as functions in Interns.
Snapshots store function IDs and offsets, rebuilding code borrows on restore.
Dumps (crates/monty/src/dump_format.rs) are CBOR through serde: derived structs are maps keyed by field name and
enums by variant name (a #[serde(transparent)] newtype or a hand-written impl chooses its own shape and must keep
it). Adding a field (#[serde(default)]), removing one, or inserting a variant anywhere keeps older dumps
loading, while tuple structs and tuple variants are positional and may only grow at the end; the names are the
persistence contract, so renaming a serialized field or variant needs #[serde(alias = "old")], and #[serde(deny_unknown_fields)] must never go on a dumped type.
Persisted Vec<u8> / [u8; N] data takes #[serde(with = "serde_bytes")] so it is written as one byte string.
Types written many times (Value, HeapEntry, CcColor, the hot HeapData variants, List, Tuple, Dict,
DictEntry, SetEntry, CodeRange, LocationEntry, CodeLoc, Instance, Closure, FunctionDefaults,
SerializedFrame) or once per function (Function, Signature, Identifier, NameScope, Code, ExceptionEntry)
rename every field and variant to one upper-case letter, since key strings otherwise dominate the dump; a new field
or variant on them takes an unused letter.
DUMP_VERSION still bumps when the meaning of stored data changes: opcodes, BuiltinsFunctions order (its
discriminants are bytecode operands), CmpOperator values, the compiler's constant layout, how a key hashes (dict and
set entries persist their hash, which is why keys without a heap identity hash by name or a persisted id such as a
FunctionId, never by discriminant), a semantic change to a stored value, or a sha1 / sha2 crate upgrade that
changes their SerializableState layout (hashlib's SHA-1 and SHA-2 objects persist it verbatim). crates/monty/tests/dump_compat.rs loads a checked-in fixture written at the current version; a
change that breaks it decides between an alias, a default, or a bump plus UPDATE_DUMP_FIXTURE=1 to regenerate.
Frozen function code is also persisted: Rust calls its captured entry signature, and that code calls private native helpers.
Incompatible changes to either calling convention require compatibility handling or a version bump, including changes
to argument order or meaning.
Compatible source-body changes do not require a bump; restored sessions retain their captured implementations.
When the old data still holds everything the new meaning needs, bump DUMP_VERSION but keep
MIN_SUPPORTED_DUMP_VERSION, and convert in one of two places so the live heap never carries a legacy form:
a #[serde(from = "Dumped…")] shim when one value's stored shape changed (see TableCursor), or an upgrade in
dump_upgrade.rs, which Dump::load runs over an older dump's heap and interns; its module docs list the rules.
The first upgrade is modules/dataclasses/legacy.rs (version 13), tested against the dataclasses_v13*.bin fixtures
the 1.1.0 release wrote, which must never be regenerated.
The VM holds &Interns, never &mut Interns.
Committed strings, literals and functions have stable addresses in append-only storage.
For REPL feeds and runtime compilation, CompileInterns owns pending entries and deduplicates strings against both tables.
Fresh programs use CompileInterns::direct(&mut Interns) and discard the entire interner if compilation fails.
Do not use direct mode for an existing session: only the overlay supports rejection without retaining products.
New IDs start at the committed table lengths, so bytecode uses final IDs without relocation.
An active overlay blocks runtime interning and other compilations from consuming those IDs.
Compiled function bodies remain owned by the overlay until publication.
Only an admitted snippet publishes its intern entries and code; dropping a rejected overlay frees its products.
For eval() / exec(), reserve the frame's recursion level before publication, then construct its frame from the
committed code.
No fallible operation or Python execution may intervene between admission and installing the frame.
Preparation's provisional global slots are restored on rejection.
Never roll back a snippet after execution starts: its definitions may already be reachable from globals.
Snippet source IDs occupy a separate range from canonical string IDs.
They display as <string> without allowing duplicate entries in the string-deduplication maps.
Resolve filenames with Interns::get_filename; ordinary get_str only accepts canonical string IDs.
All heap-allocated Python objects (lists, dicts, strings, etc.) are stored in a paged arena (Heap). The HeapReader API provides compile-time safe access to heap data. This is the primary mechanism for reading and mutating heap objects throughout the codebase.
The crates/monty/src/heap/ module is a critical safety boundary. Its mod.rs, stable_heap.rs and free_list.rs contain unsafe code that underpins the soundness of the entire HeapReader/HeapRead system (pointer arithmetic, UnsafeCell access, reader-count invariants).
Do NOT modify files under crates/monty/src/heap/ without explicit user approval.
Changes to this module require careful review of the safety invariants documented in the code comments.
HeapReader<'a>— A scoped borrow created exclusively byHeapReader::with. Itsfor<'a>closure gives handles an invariant brand that cannot escape the closure.HeapObjectRead<'a, T>— A typed heap-object guard holding its identity and reader count.heap.read(id)returns aHeapReadOutput<'a>variant containing one.HeapReadMut<'a, T>— A mutable-access capability exposed only by shared reference from object guards andprotect_mutwrappers. Itsget_mut(&self, &mut HeapReader)serializes payload access through the reader.HeapRead<'a, T>— A read-only capability exposed by shared dereference from mutable capabilities andprotectwrappers. Capabilities must never be extracted, cloned or exposed viaDerefMut: swapping them would detach their pointers from their owners.HeapView<'a, T>— Shared payload access for generic helpers. Slice and box projections resolve their current storage on each access rather than cache pointers invalidated by parent mutation.
// Scoped heap access.
// The second argument allows for extra data to be
// passed into the closure, will be rebranded as
// `&'a mut ...` to match the `'a` lifetime of the
// `HeapRead` handle, so the closure can have additional
// context while still having the `for <'a>` safety guarantee.
HeapReader::with(heap, &mut (), |heap, ()| {
let output = heap.read(some_id); // returns HeapReadOutput<'a>
match output {
HeapReadOutput::List(list) => {
let items = list.get(heap); // &List, borrows heap immutably
let items_mut = list.get_mut(heap); // &mut List, borrows heap mutably
}
_ => { /* ... */ }
}
})Key borrowing rules:
get(&self, &HeapReader)→&T— immutable access, prevents heap mutation while reference livesget_mut(&self, &mut HeapReader)→&mut T— mutable access throughHeapReadMut, exclusive via the reader- Both accessors borrow the capability and reader for the returned reference's lifetime, retaining the owner or protected source
- Multiple capability aliases can coexist; their payload borrows cannot conflict
dec_ref()panics if any reader is active — prevents use-after-free
Read-only helpers live on HeapRead; mutation helpers live on HeapReadMut and take &self.
PyTrait remains implemented once per type on HeapObjectRead, which also supplies Python identity:
// Methods on a heap type
impl<'h> HeapReadMut<'h, List> {
pub fn append(&self, vm: &mut VM<'h>, item: Value) -> RunResult<()> {
self.get_mut(vm.heap).items.push(item);
Ok(())
}
}
// PyTrait implementation
impl<'h> PyTrait<'h> for HeapObjectRead<'h, List> {
fn py_type(&self, vm: &VM<'h>) -> Type { Type::List }
fn py_len(&self, vm: &VM<'h>) -> Option<usize> {
Some(self.get(vm.heap).items.len())
}
// ...
}All types that implement DropWithContext<C> hold heap (and possibly VM-side) references and must be cleaned up correctly on every code path — not just the happy path, but also early returns via ?, continue, conditional branches, etc. A missed drop_with on any branch leaks reference counts.
DropWithContext<C> is generic over the cleanup context C — whatever borrow the caller has on hand: a Heap, a HeapReader, the VM, or the json Encoder. The bound on each impl states the capability the value needs: heap-only values bound C by ContainsHeap (one impl then covers all four contexts), while values holding a RecursionToken (the container iterators) bound C by ContainsVM — satisfied only by VM/Encoder, since the recursion counter is unreachable through a bare heap. The same drop_with / DropGuard / defer_drop! machinery serves both. There are three mechanisms for ensuring cleanup, listed in order of preference:
The simplest and safest approach. Use defer_drop! (or defer_drop_mut! when mutable access to the value is needed) to bind a value into a guard that automatically drops it when scope exits — whether that's normal completion, early return via ?, continue, or any other branch. The macro rebinds the value and heap variables as borrows from the guard, so you keep using them by name as before:
let value = self.pop();
defer_drop!(value, heap); // value is now &Value, heap is now &mut Heap
let result = value.py_repr(heap)?; // guard handles cleanup on all pathsBeyond safety, defer_drop! is often much more concise than inserting drop_with calls in every branch of complex control flow.
defer_drop! gives you an immutable reference to the value. Use defer_drop_mut! when you need a mutable reference (e.g. iterators, values you may swap):
let iter = vm.heap.get_iter(iter_ref);
defer_drop_mut!(iter, vm);
while let Some(item) = iter.for_next(vm)? { ... }Limitation: because the macro rebinds the context, it cannot be used inside &mut self methods on the VM where self owns the heap — first assign let this = self; and pass this instead.
Use DropGuard directly when defer_drop! is too restrictive — specifically when you need to conditionally extract the value instead of dropping it. DropGuard provides into_inner() and into_parts() to reclaim ownership, while its Drop impl still guarantees cleanup on all other paths.
Do not use DropGuard when the value is never moved back out of it. A guard used only through as_parts() or as_parts_mut() must be replaced with defer_drop! or defer_drop_mut!; explicit guards are reserved for code that later calls into_inner() or into_parts(). This keeps the ownership intent visible and avoids unnecessary guard bookkeeping:
// DropGuard needed here because on success we push lhs back onto the stack
// instead of dropping it
let mut lhs_guard = DropGuard::new(self.pop(), self);
let (lhs, this) = lhs_guard.as_parts_mut();
if lhs.py_iadd(rhs, this.heap)? {
let (lhs, this) = lhs_guard.into_parts(); // reclaim lhs, don't drop
this.push(lhs);
return Ok(());
}
// otherwise lhs_guard drops lhs automatically at scope exitFor very simple cases with a single linear code path and no branching between acquiring and releasing the value, a direct drop_with call is acceptable as long as it produces more concise code than defer_drop!:
let iter = self.pop();
iter.drop_with(self); // single path, no branchingdrop_with should be used only when it is genuinely simpler than defer_drop! or DropGuard. The latter two are safer and more maintainable, especially in complex control flow. Multiple manual cleanup calls for the same owned value are a poor substitute for a guard.
Do not use drop_with if any of the following are true:
- The same value has
drop_withcalled in multiple places (e.g. a loop withcontinueor?in the middle). This impliesdefer_drop!orDropGuardwill be easier to read. - The explicit call to
drop_withproduces more lines of code thandefer_drop!orDropGuardwould. The latter often avoid rightward drift and make the cleanup logic easier. - The value is part of a container (e.g.
Vec<Value>). Ideally the container itself implementsDropWithContextand sodefer_droporDropGuardcan be used on the whole container. Consider if aDropWithContextimplementation for the container might be missing.
Add use_phf only to EnumString enums with more than ~100 variants, or ones that will clearly grow past that.
Its SipHash costs ~11 ns per lookup however small the enum, while strum's default match costs 3–6 ns on enums of
40–80 variants, and less still on misses whose length no variant shares.
At ~970 variants (StaticStrings), match costs ~100 ns and PHF wins about 7×.
Any code that builds a String whose final size is not already bounded by an existing input must use StringBuilder (in crates/monty/src/string_builder.rs) rather than String::with_capacity(...).push(...). A loop-built string can otherwise jump past both allocator limits before an execution checkpoint — this is exactly the class of bug that hit str.expandtabs (huge tabsize amplifying a single tab into a multi-gigabyte allocation).
StringBuilder preflights capacity growth against allocator-backed usage. The in-progress buffer is itself visible to the allocator, so nested builders share the same real-byte budget. Growth is amortized via 2× doubling:
// Bounded size known up front (padding to a given width):
let mut builder = StringBuilder::with_capacity(width * fillchar.len_utf8(), &vm.heap.tracker)?;
builder.push_str(s)?;
for _ in 0..pad { builder.push(fillchar)?; }
builder.finish(vm.heap)
// Size not bounded up front (e.g. attacker-controlled multiplier):
let mut builder = StringBuilder::new(&vm.heap.tracker);
for c in input.chars() { builder.push(c)?; }
builder.finish(vm.heap)StringBuilder also implements fmt::Write, so write!(builder, ...), format_args!, and the existing py_repr_fmt(f, ...) machinery work against a tracker-protected buffer. fmt::Error is payload-free, so any ResourceError raised by a write is stashed on the builder and surfaced by finish(heap) — callers using write! don't need to thread the tracker error themselves.
When the input is already bounded (e.g. s.to_lowercase(), slicing, to_owned() of an existing tracked string), passing a plain String / &str to allocate_string is fine — the result is bounded by a known multiple of an already-tracked input, so no amplification is possible.
max_memory is a soft limit: the VM polls allocator-backed usage every 255
instructions (check_memory_time), and everything pathological is caught by the hard
limits — the allocator's hard ceiling (soft + headroom, worker exits with
OOM_EXIT_CODE and the pool replaces it) and the pool's turn timeout. Soft
checks exist ONLY to turn common overshoots into a graceful MemoryError
that keeps the session alive; they are not a safety boundary, so do not
sprinkle them everywhere — every check is code noise and hot-path cost.
Add a check only where ordinary code commonly allocates a multi-MiB burst inside a single builtin call (i.e. before the next instruction checkpoint):
- Known-size bulk allocation: one up-front
tracker.check_allocation(n * VALUE_SIZE)(container clone/copy, e.g.clone_all_items,list_copy) orcheck_repeat_size-style estimate (resource_checks.rs). - Iterator collection:
collect_python_iterator/checked_preallocation_hintalready handle it; for push-loops that bypass them, a one-shot size-hint preflight (seedeque_extend) — never a per-item poll. - Unbounded/amplifying string building:
StringBuilder(above). - Incremental growth of a buffer of interpreter values:
tracker.check_growthbefore the push (List::append,parse_json_array,re_pattern's match loops) — it only reaches the tracker at a capacity boundary. It models one push; a bulk reservation wantscheck_allocationsized for the whole result.
Do NOT add per-iteration check_time() polls to Rust-side loops for memory's
sake, and do NOT preflight results bounded by a constant multiple of an
already-tracked input (path joins) — rare oversized cases there are the hard
limit's job. The exception is a loop allocating per item, where a preflight on
the result buffer cannot see what the items themselves cost:
tracker.check_memory_time_every(i) is the amortized poll for that, as in
parse_json_array and re_pattern's match and split loops. *args, parsed
JSON arrays and re.findall with at most one capture group are preflighted now
— each killed the worker on ordinary code while listed as bounded-by-input
above; a wider findall and re.finditer allocate per match between the checks
and are still open. Test each graceful path in
large_allocations_are_rejected_before_the_hard_limit
(crates/monty-runtime/tests/subprocess.rs) — the interpreter's own tests
never arm the allocator, so only subprocess tests exercise max_memory.
IMPORTANT: before running cargo build or cargo run, it is likely necessary to run make install-py to ensure that the Python virtual environment is available for build.
Instead use the following make commands:
make install-py Install python dependencies
make install-js Install JS package dependencies
make install Install the package, dependencies, and pre-commit for local development
make dev-py Install the python package for development
make build-js Build the JS package (compile TypeScript)
make lint-js Lint JS code with oxlint
make test-js Test the JS package (builds the monty binary the workers run)
make dev-py-release Install the python package for development with a release build
make build-wasm Build the WASI 0.2 worker component (requires the wasm32-wasip1 target)
make check-wasm-types Verify checked-in component declarations match the WIT interface
make test-wasm Test the wasm worker component from Node, with no browser
make test-browser Build and test the wasm worker path in headless Chromium
make dev-py-pgo Install the Python package with a PGO-optimized Monty runtime
make format-rs Format Rust code with fmt
make format-py Format Python code - WARNING be careful about this command as it may modify code and break tests silently!
make format-js Format JS code with prettier
make format-md Format markdown with mdformat (tables, mkdocs admonitions, frontmatter)
make format Format Rust code, this does not format Python code as we have to be careful with that
make lint-rs Lint Rust code with clippy and import checks
make clippy-fix Fix Rust code with clippy
make generate-proto Regenerate monty-proto's checked-in code from the .proto schema
make check-proto Verify monty-proto's checked-in code matches the .proto schema
make check-publish Package and verify-build every publishable crate as `cargo publish` would, without uploading
make generate-api-docs Generate the Rust API reference into docs/api/rust/ (gitignored) from rustdoc JSON
make docs-dev Preview this checkout in a sibling pydantic/unified-docs checkout (../unified-docs)
make lint-py Lint Python code with ruff
make lint-md Check markdown formatting with mdformat
make lint Lint the code with ruff, clippy and mdformat
make test-no-features Run rust tests without any features enabled
make test-memory-model-checks Run rust tests with memory-model-checks enabled - THIS IS EXTREMELY SLOW, SHOULD MOSTLY BE RUN IN CI OR IF ABSOLUTELY NECESSARY
make test-ref-count-return Run rust tests with ref-count-return enabled
make test-cases Run tests cases only
make test-type-checking Run rust tests on monty-type-checking
make pytest Run Python tests with pytest
make test-py Build the python package (debug profile) and run tests
make test-docs Test docs examples only
make test Run rust tests
make testcov Run Rust tests with coverage, print table, and generate HTML report
make complete-tests Fill in incomplete test expectations using CPython
make generate-unicode-type Regenerate the str character-property tables from the current CPython
make check-unicode-type Verify the checked-in str character-property tables match the current CPython
make update-typeshed Update vendored typeshed from upstream
make bench Run benchmarks
make bench-pool Run subprocess pool benchmarks (spawn, checkout, wire round-trips)
make dev-bench Run benchmarks to test with dev profile
make profile Profile the code with pprof and generate flamegraphs
make type-sizes Write type sizes for the crate to ./type-sizes.txt (requires nightly and top-type-sizes)
make main run linting and the most important tests
make help Show this help (usage: make help)Use the /python-playground skill to check cpython and monty behavior.
See RELEASING.md for the release process.
It's important that exceptions raised/returned by this library match those raised by Python.
Wherever you see an Exception with a repeated message, create a dedicated method to create that exception crates/monty/src/exception_private.rs.
When writing exception messages, always check crates/monty/src/exception_private.rs for existing methods to generate that message.
Whenever you add or modify a Rust-side function, method, type
constructor, or OsFunction handler that takes anything beyond the
trivial 0/1/2-positional shapes already covered by
ArgValues::check_zero_args / get_one_arg / get_two_args /
get_zero_one_arg / into_pos_only, you MUST use
#[derive(FromArgs)] (re-exported as monty::args::FromArgs).
Hand-written args.into_parts() loops are not acceptable for any
signature that has multiple positionals with defaults, keyword
arguments, *args, or **kwargs — they are a known source of
reference-count leaks, divergent error messages, and duplicated
boilerplate. FromArgs emits a static param spec driven by the runtime
binder (crates/monty/src/args/bind_native.rs), which handles dispatch,
conflict detection, default handling, and refcount cleanup mechanically.
Pick style = def | clinic | c | c_named | unpack by the CPython parser
family the target function uses — see
crates/monty-macros/README.md for the
family table and the full attribute surface (style, at_most_total,
bad_arg[_named], pos_only, kw_only, varargs, varkwargs,
default, static_string, …) and how to extend the macro or add new
FromValue impls.
If a callsite needs custom per-argument coercion (e.g. value_to_float
for math, a TimeDelta type check, a bytes-or-str union), declare
the field as Value and run the coercion in the function body after
the from_args call — the macro still handles the parsing, your code
just adds the final validation step.
Avoid local imports, unless there's a very good reason, all imports should be at the top of the file.
Avoid fn my_func<T: MyTrait>(..., param: T) style function definitions, STRONGLY prefer fn my_func(param: impl MyTrait) syntax since changes are more localized. This includes in trait definitions and implementations.
Also avoid using functions and structs via a path like std::borrow::Cow::Owned(...), instead import Cow globally with use std::borrow::Cow;.
STRONGLY prefer expression-oriented style: use if/match as expressions with a trailing (tail) expression rather than early return with a guard clause. E.g. prefer
if cond { a } else { b }over
if cond {
return a;
}
bThis applies to function bodies and block expressions alike. Only use early return when it genuinely simplifies control flow (e.g. several guard clauses at the top of a function).
This applies even more strongly to long if cond { ... } else if cond2 { ... } ... else { ... } chains — keep them as a single expression yielding a value, rather than scattering return statements through each branch.
NEVER use allow() in rust lint markers, instead use expect() so any unnecessary markers are removed. E.g. use
#[expect(clippy::too_many_arguments)]NOT!
#[allow(clippy::too_many_arguments)]IMPORTANT: every struct, enum and function should have a concise docstring to explain what it does and why; and any considerations or potential foot-guns of using that type.
The only exception is trait implementation methods where a docstring is not necessary if the method is self-explanatory.
It's important that docstrings cover the motivation and primary usage patterns of code, not just the simple "what it does".
Similarly, you should add comments to code, especially if the code is complex or esoteric.
Comments and field docstrings should almost never be more than 3 lines, mostly 1 line. Function and struct docstrings should be concise, generally <= 5 lines.
Only add examples to docstrings of public functions and structs, examples should be <=8 lines, if the example is more, remove it.
If you add example code to docstrings, it must be run in tests. NEVER add examples that are ignored.
If you encounter a comment or docstring that's out of date - you MUST update it to be correct.
Similarly, if you encounter code that has no docstrings or comments, or they are minimal, you should add more detail.
Always use single back-ticks in python docstrings - they should be markdown, not rst!
NOTE: COMMENTS AND DOCSTRINGS ARE EXTREMELY IMPORTANT TO THE LONG TERM HEALTH OF THE PROJECT.
NOTE: COMMENTS AND DOCSTRINGS SHOULD BE CONCISE - EXCESSIVELY VERBOSE DOCSTRINGS MAKE THE CODE HARDER TO READ AND MAINTAIN!
Do NOT write tests within modules unless explicitly prompted to do so.
Tests should live in the relevant tests/ directory.
Commands:
# Build the project
cargo build
# Run tests
cargo test -p monty
# Run crates/monty/test_cases tests only
make test-cases
# Run a specific test
cargo test -p monty --test TEST str__ops
cargo run -p monty-datatest str__ops
# Run the interpreter on a Python file
cargo run -- <file.py>The memory-model-checks feature (make test-memory-model-checks, or
--features memory-model-checks on the commands above) is VERY SLOW — it is
run in CI, so do NOT enable it by default. Only reach for it when a change
specifically touches refcount/heap/GC behavior (e.g. new opcodes that retain
values, drop_with paths, cycle collection) and then run just the relevant
test binary, e.g. cargo test -p monty --test TEST --features memory-model-checks.
See more test commands above.
Read Makefile for other useful commands.
You can use the ./playground directory (excluded from git, create with mkdir -p playground) to write files
when you want to experiment by running a file with cpython or monty, e.g.:
python3 playground/test.pyto run the file with cpythoncargo run -- playground/test.pyto run the file with monty
DO NOT use /tmp or pipe code to the interpreter, or use python3 -c ... as it requires extra permissions and can slow you down!
More details in the "python-playground" skill.
Most functionality should be tested via python files in the crates/monty/test_cases directory.
DO NOT create many small test files. This would be unmaintainable.
ALWAYS consolidate related tests into single files using multiple assert statements. Follow crates/monty/test_cases/fstring__all.py as the gold standard pattern:
# === Section name ===
# brief comment if needed
assert condition
assert another_condition
# === Next section ===
x = setup_value
assert x == expectedDo NOT add messages to assert statements — Monty's assert message annotations
(see limitations/assert.md) already show the failing values, so a hand-written
message is clutter. The ONE exception: tests whose failure would show nothing,
i.e. assert False sentinels in try/except blocks (assert False, 'expected TypeError') and tests that evaluate to a bare bool (not expressions, chained
comparisons, boolean ops) — there a message is required since introspection
shows nothing.
Do NOT Write tests like assert 'thing' in msg it's lazy and inexact unless explicitly told to do so, instead write tests like assert msg == 'expected message' to ensure clarity and accuracy and most importantly, to identify differences between Monty and CPython.
Only create a separate test file when you MUST use one of these special expectation formats:
"""TRACEBACK:..."""- Test expects an exception with full traceback (PREFERRED for error tests)# Raise=Exception('message')- Test expects an exception without traceback verification - NOT RECOMMENDED, useTRACEBACKinstead# ref-counts={...}- Test checks reference counts (special mode)- you're writing tests for a different behavior or section of the language
For everything else, add asserts to an existing test file or create ONE consolidated file for the feature.
Name files by feature, not by micro-variant:
- ✅
str__ops.py- all string operations (add, iadd, len, etc.) - ✅
list__methods.py- all list method tests - ❌
str__add_basic.py,str__add_empty.py,str__add_multiple.py- TOO GRANULAR
Only use these when assert won't work (on last line of file):
# Return=value- Checkrepr()output (prefer assert instead)# Return.str=value- Checkstr()output (prefer assert instead)# Return.type=typename- Checktype()output (prefer assert instead)# Raise=Exception('message')- Expect exception without traceback (REQUIRES separate file)"""TRACEBACK:..."""- Expect exception with full traceback (PREFERRED over# Raise=)# ref-counts={...}- Check reference counts (REQUIRES separate file)- No expectation comment - Assert-based test (PREFERRED)
Do NOT use # Return= when you could use assert instead
For tests that expect exceptions, prefer traceback tests over # Raise= or try / except because they verify:
- The full traceback with all stack frames
- Correct line numbers for each frame
- Function names in the traceback
- The caret markers (
~) pointing to the error location
Traceback test format - add a triple-quoted string at the end of the file starting with \nTRACEBACK::
def foo():
raise ValueError('oops')
foo()
"""
TRACEBACK:
Traceback (most recent call last):
File "my_test.py", line 4, in <module>
foo()
~~~~~
File "my_test.py", line 2, in foo
raise ValueError('oops')
ValueError: oops
"""Key points:
- The filename in the traceback should match the test file name (just the basename, not the full path)
- Use
~for caret markers (the test runner normalizes CPython's^to~) - The
<module>frame name is used for top-level code - Tests run against both Monty and CPython, so the traceback must match both
If you don't care about the traceback or it intentionally differs from cpython (e.g. for json) and you want to test
multiple cases in the same file, use this style
try:
...
assert False, 'expected <task> to fail'
except <ErrorType> as exc:
assert str(exc) = '<expected exception message>'IMPORTANT: don't just check that an exception is raised, you should always check the exception message.
IMPORTANT: DON'T BE LAZY. If the exception differs between cpython and Monty, either fix the exception message, or stop and report the problem!
Only use # Raise= when you only care about the exception type/message and not the traceback and you can't use a try/except block.
Configure a case with a # /// monty header: a PEP 723 comment block of TOML,
on the first lines of the file.
monty_fixture::Header (crates/monty-fixture) parses it for both the datatest harness and monty-bench.
Unknown keys, wrong types and malformed blocks are errors, and every key defaults to off:
# /// monty
# call-external = true
# run-async = true
# ///call-external = trueto support calling external functionsrun-async = trueto support running async codemount-fs = trueto mount a fixture directory:/mntfor Monty, a real temp dir for CPython,rootin both; modules written there import on both sides (it is first on CPython'ssys.path; write every file before the first import)skip-cpython-windows = trueto skip the CPython side on Windows (POSIX path semantics)cpython-main-module = trueto set__name__ = '__main__'on CPython onlygc-interval = <N>to override the GC intervaltimezone = 'Europe/London'to run the case in an IANA zone rather than the UTC default: it setsOsPolicy::timezonefor Monty andTZfor CPython, soastimezone(), a naivetimestamp(),%Z/%zand thetimezone constants can be compared. The CPython side is skipped on Windows, which has notime.tzset. Without the key both sides run in UTC — except on Windows, where the harness cannot move CPython, so a case asserting zone values needsskip-cpython-windows(seedatetime__zone_default.py).[bench]table: options forbench__*.pycases andmonty-benchscripts (seecrates/monty-bench/benches/main.rs)
NEVER MARK TESTS AS XFAIL UNDER ANY CIRCUMSTANCES!!! INSTEAD FIX THE BEHAVIOR SO THAT THE TEST PASSES.
Never mark tests as:
xfail = ['cpython']- Test is required to fail on CPythonxfail = ['monty']- Test is required to fail on Monty
NEVER MARK TESTS AS XFAIL UNDER ANY CIRCUMSTANCES!!! INSTEAD FIX THE BEHAVIOR SO THAT THE TEST PASSES.
The header shifts line numbers, so TRACEBACK expectations count its lines.
- Prefer single quotes for strings in Python tests
- Do NOT add
# noqaor# pyright: ignorecomments to test code, instead add the failing code topyproject.toml - The ONLY exception is
awaitexpressions outside of async functions, where you should add# pyright: ignore - Run
make lint-pyafter adding tests - Use
make complete-teststo fill in blank expectations - Regression tests run via
datatest-stableharness incrates/monty-datatest/src/main.rs, usemake test-casesto run them - The CPython side of each case runs under a 30s watchdog (
CpythonWatchdogin the harness): a hanging case fails on its own instead of stalling the whole run, and one stuck in C code aborts the run naming the test
In crates/*/tests/*.rs (but not crates/monty/test_cases/), use insta assert_snapshot! for multi-line strings, serialized output, error messages otherwise fuzz-checked via .contains(...), and any fixture currently compared via a hand-rolled UPDATE_EXPECT helper (use external snapshots under tests/snapshots/).
Keep assert_eq! for scalars, enums, and structural values (MontyObject, Vec, etc.), and for principled membership checks like vec.contains(...).
Workflow: write assert_snapshot!(value, @"");, then cargo insta test --accept to populate (plain INSTA_UPDATE=always does not update inline @"..." snapshots — you need the cargo insta subcommand, installed via cargo install cargo-insta). Add insta = { workspace = true } to [dev-dependencies] when introducing it to a new crate.
Three PyPI distributions are built from this repo:
pydantic-monty-client(crates/monty-python/, Cargo packagepydantic-monty-client) — the PyO3 bindings, i.e. thepydantic_montymodule. It deliberately does not depend on the runtime, so it can be installed where themontybinary comes from a base image or system package.pydantic-monty-runtime(crates/monty-runtime/) — themontyworker binary.pydantic-monty(packages/pydantic-monty/) — a hatchling metapackage with no code, exactly pinning the other two. This is what users install. Its version and both pins are rewritten from the Cargo workspace version bycrates/monty-python/build.rs; never edit them by hand.
Execution always happens in monty worker subprocesses — there is no in-process execution API.
The surface is Monty (sync pool) and AsyncMonty (async pool), each with
pool.checkout(...) sessions driven by feed_run (a coroutine on async sessions).
crates/monty-python/src/- Rust source for PyO3 bindingscrates/monty-python/python/pydantic_monty/_monty.pyi- Type stubs for the Python modulecrates/monty-python/tests/- Python tests using pytestcrates/monty-python/README.md- thepydantic-monty-clientreadme (binary resolution); the full user-facing docs live inpackages/pydantic-monty/README.md
Dependencies needed for python testing are installed in crates/monty-python/pyproject.toml.
To install these dependencies, use uv sync --all-packages --only-dev.
# Build the Python package for development (required before running tests)
make dev-py
# Run Python tests
make test-py
# Or run pytest directly (after dev-py)
uv run pytest
# Run a specific test file
uv run pytest crates/monty-python/tests/test_basic.py
# Run a specific test
uv run pytest crates/monty-python/tests/test_basic.py::test_simple_expressionCheck and follow the style of other python tests.
Make sure you put tests in the correct file.
DO NOT use python/pytest tests for monty core functionality! When testing core functionality, add tests to crates/monty/test_cases/ or crates/monty/tests/. Only use python/pytest tests for pydantic_monty functionality testing.
NEVER use class-based tests. All tests should be simple functions.
Use @pytest.mark.parametrize whenever testing multiple similar cases.
Use snapshot from inline-snapshot for all test asserts.
NEVER do the lazy assert '...' in ... instead always do assert value == snapshot(),
then run the test and inline-snapshot will fill in the missing value in the snapshot() call.
Use pytest.raises for expected exceptions, like this
with pytest.raises(ValueError) as exc_info:
session.feed_run(code, print_callback=callback)
assert exc_info.value.args[0] == snapshot('stopped at 3')Heap-allocated values (Value::Ref) use manual reference counting. Key rules:
- Cloning: Use
clone_with_heap(heap)which increments refcounts forRefvariants. - Dropping: Call
drop_with(ctx)(the [DropWithContext] method) when discarding aValuethat may be aRef.
Container types (List, Tuple, Dict) also have clone_with_heap() methods.
HeapId does not encode whether a reference is owned or borrowed. Locally owned IDs should typically be wrapped in Value::Ref immediately so defer_drop! and DropGuard can manage cleanup; a local raw HeapId should otherwise be presumed borrowed.
Owned HeapId fields remain the preferred representation where a structure needs the raw ID, such as ListIterator::list. Such fields must be documented as owned and cleaned up exactly once:
- Heap-stored
HeapItemimplementations must push every owned ID frompy_dec_ref_ids; this is preferred to callingHeap::dec_refdirectly because destruction uses the heap's iterative cleanup stack. - Non-
HeapItemowners should release owned IDs through theirDropWithContextimplementation, where a directdec_refis acceptable. - Direct
dec_refin ordinary control flow is discouraged. As withdrop_with, never scatter cleanup for the same owned reference across branches; use an owningValueand a guard instead.
Raw ownership is also acceptable when immediately transferred into a documented owned field or across an API whose contract explicitly transfers ownership.
Mutability of the heap parameter is asymmetric — do not assume the two methods take the same kind of borrow:
clone_with_heaptakes&impl ContainsHeap(immutable). The refcount field lives behind interior mutability, soinc_refis&selfonHeap. This means you can callclone_with_heapwhile other immutable borrows of the heap (e.g. aHeapReadhandle obtained via.get(heap)) are still live.Heap::allocateis also&selfbecause entry storage is behind interior mutability. New heap entries can be created without a&mut Heap.drop_withtakes&mut C(the cleanup context —Heap/HeapReader/VM/Encoder), because dropping may free entries and run destructors, which mutates the heap.
If you find yourself fighting the borrow checker around clone_with_heap or allocate, the fix is almost never &mut — it is more likely that you are passing the wrong receiver (e.g. vm instead of vm.heap) or holding a &mut borrow elsewhere that should be &.
Reference counting alone cannot reclaim cycles. Monty uses Bacon–Rajan trial deletion
(Heap::collect_cycles in crates/monty/src/heap/mod.rs).
Resource limits: When a memory or time limit is exceeded, execution terminates with a ResourceError. No guarantees are made about the state of the heap or reference counts after a resource limit is exceeded. The heap may contain orphaned objects with incorrect refcounts. This is acceptable because resource exhaustion is a terminal error - the execution context should be discarded.
Public API documentation belongs in the JavaScript guide and its linked concept pages. The package README is an npm introduction, not a second reference.
src/implements the native napi binding overmonty-pool;ts/pool.tswraps it.ts/shared.tsdefines common public exports;ts/session.tsdrives both native and WASM transports.ts/worker/implements WASM pooling, channels and value conversion. These are internal, not package exports.index.tsis the browser/default entry;index.node.tsselects Node worker threads. No in-process fallback.crates/monty-wasm-runtimewraps the shared Rust protocol child as a WIT component with flat value arenas.make build-wasmbuildswasm32-wasip1, applies Jco's reactor adapter, and regeneratests/worker/component/. Never edit those generated declarations manually.native-addon.jsandnative-addon.generated.d.tsare generated bynpm run build:napi. Platform packages contain both the.nodeaddon and themontybinary.
Use make test-js, make test-wasm and make test-browser for native Node, WASM Node and Chromium respectively.
Vitest runs __test__/ against public entries in dist/; make test-js sets MONTY_BIN to the debug worker build.
Use setupPool from __test__/helpers.ts and put portable behavior in the shared specs, not duplicate WASM files.
Gate only backend-specific capabilities; reserve node_* / wasm_* specs for those.
make lint-js runs oxlint and test type-checking; make format-js runs Prettier.
See the documentation rules below for executable examples.
Monty has four hand-maintained documentation surfaces. They serve different readers and none is generated from another, so a change that updates one and not the others leaves the project describing behaviour it no longer has.
| Surface | Reader | Contains |
|---|---|---|
README.md |
GitHub, PyPI, npm landing page | A short pitch with the latency numbers, install commands, one example, links into docs/, plus badges, community bindings and the Pydantic Stack footer; no can/cannot lists, quickstarts or alternatives |
docs/ |
the docs site (pydantic.dev/docs/monty), nav in mkdocs.yml |
The landing page (index.md: numbers, why, example), commercial support (server.md), getting started per language (install then examples), concepts (security model, host functions, resource limits, filesystem, snapshots, type checking), reference (comparison to alternatives, examples, CLI), limitations (the subset on limitations/index.md, then one page per feature) |
limitations/ (a symlink to docs/limitations/, published as the site's Limitations section) |
agents and contributors chasing a specific behaviour | index.md gives the shape of the subset; the other pages are the exhaustive per-feature record of CPython divergences (see the section below) |
crates/*/README.md |
crates.io, and PyPI/npm for the binding crates | Per-crate API documentation; monty-python/README.md is the Python binding reference, while monty-js/README.md is an npm introduction linking to the JavaScript guide |
docs/ does not duplicate limitations/. docs/ describes the shape of what Monty
implements and links out; limitations/ owns every divergence. A divergence written into
a docs/ page instead of limitations/ is a defect — move it and link.
- A CPython divergence —
limitations/<file>.md, per the mandatory rule below. - The subset changes shape (a stdlib module becomes importable, a parse-time
rejection lands or is lifted, a language feature ships) — also
docs/limitations/index.md. - Python binding API (
crates/monty-python/) — the_monty.pyidocstrings,crates/monty-python/README.md, and thedocs/page that covers the feature. - JavaScript binding API (
crates/monty-js/) — TypeScript declarations and the relevantdocs/topic;docs/quickstart/javascript.mdindexes these. Update the package README only when its introduction changes. - Rust API — the owning crate's README and
docs/quickstart/rust.md. - Resource limits, mount options, or a sandbox invariant —
docs/resource-limits.md,docs/filesystem.md,docs/security.mdrespectively, pluslimitations/. - CLI flags (
crates/monty-runtime/src/main.rs) —crates/monty-runtime/README.mdanddocs/cli.md.
These facts are stated in more than one place on purpose, because a reader needs them where they are. Change one and you must change all of them:
- The importable stdlib module list —
limitations/modules.md(authoritative),docs/limitations/index.md. - The start-latency numbers —
ROWSinscripts/startup_latency_chart.py(which rendersdocs/img/startup-latency.svg),docs/index.md,docs/alternatives.md,README.md. Re-measure withscripts/startup_performance.py, then update all four. - Default resource limits (1000 recursion frames, 1000 suspensions, 10 MiB
print collectors, 1s duration grace) —
limitations/resource_limits.md,docs/resource-limits.md, and the binding docstrings. The per-mount memory default is the exception: its value is stated only inmonty_fs::DEFAULT_MEMORY_USAGE_LIMIT, and everything else refers to that name. OsPolicydefaults (system clock, UTC zone, host-serviced sleeps capped at 10 s, a zeroprocess_time, OS entropy forrandom) andcall_host—limitations/time.md,limitations/datetime.md,limitations/random.md,docs/security.md(the clock / entropy / waiting),docs/cli.mdandcrates/monty-runtime/README.md(--max-sleep,--max-total-sleep), thecheckout()docstrings in_monty.pyiandcrates/monty-js/ts/pool.ts.- The default
max_checkouts_per_worker(1000) —monty_pool::DEFAULT_MAX_CHECKOUTS_PER_WORKER(authoritative; compile- and test-time checks pin the JS constant and Pythontext_signatures to it),_monty.pyi,crates/monty-js/ts/options.ts,crates/monty-js/ts/pool.ts, themonty-poolREADME,docs/resource-limits.md,docs/security.mdanddocs/quickstart/. - Mount modes and their defaults —
limitations/filesystem.md,docs/filesystem.md, theMountDirdocstrings in_monty.pyiandcrates/monty-js/ts/mount.ts.
Monty implements a limited subset of CPython.
Its behaviour should match CPython 3.14 except where documented in limitations.
The list of stdlib modules in docs/limitations/index.md must be updated if a new standard library module is implemented.
make test-docschecks every Python snippet indocs/,README.md,packages/pydantic-monty/README.md, andcrates/monty-python/README.md. It executes each snippet unless marked```python test="skip"; skipped snippets are still ruff-linted. This includesdocs/limitations/, whose snippets are all sandbox-side and so alltest="skip".make test-jsruns every```tssnippet indocs/,README.mdandcrates/monty-js/README.mdas its own module (crates/monty-js/__test__/node_docs.spec.ts: type-checked with tsc, then imported). A snippet must be self-contained — its own imports,await using pool = await Monty.create(), no leaked pool, noprocess.exit— and```ts test="skip"skips running it but not type-checking it. Printed output is not compared.- Every example outside
docs/index.mdanddocs/quickstart/is a=== "Python"/=== "TypeScript"tab pair, Python first; both runners find fences inside tabs. Rust examples stay ondocs/quickstart/rust.md, the only pagemonty-doctestcompiles: rustdoc ignores a fence indented inside a tab. - Sandbox-side Python (code fed to Monty, not host code) belongs inside a host snippet as
a string, or in a
test="skip"block. It must never be a runnable top-level block — CPython would execute it. - Link the first mention of a
pydantic_montytype, method or attribute in each section to its API page with the autorefs form[Monty][pydantic_monty.Monty]/[MountDir.close()][pydantic_monty.MountDir.close]; mkdocs-autorefs resolves it locally and the pydantic.dev pipeline resolves it from the::: pydantic_montyblocks underdocs/api/python/, somake docsfails on a name neither page documents (add it to a:::block first);.mdformat.tomlsetsignore_missing_referencessomake format-mdleaves the syntax alone. Rust items have no symbol map: link them explicitly as[Pool](api/rust/monty-pool.md#pool)(the anchor is the lowercased top-level item name; methods, fields and variants share their parent's anchor). JavaScript types have no API pages, so leave them as plain code spans. - New pages go in the
mkdocs.ymlnav:; the nav is what orders the docs site. That includes a newlimitations/file, which goes in the flat Limitations section (nested groups would change the page URLs and break the relative links between limitations pages). - Prose style follows
.agents/skills/writing-style: one sentence per line, claims traceable to source, no hype. Do not state a behaviour you have not read in the code, the tests orlimitations/. make format-mdnormalises every tracked markdown file except the crate READMEs, which rustdoc embeds and clippy lints for two-space list continuations, with mdformat (table alignment, four-space list continuations, admonitions and frontmatter kept; style in.mdformat.toml); pre-commit runs it, andmake lintchecks it withmake lint-md. It never rewraps prose.
make test-docsapplies the Python checks above and compiles Rust snippets indocs/quickstart/rust.md.make test-jsapplies the TypeScript check.make docsbuilds the site with--strict, which fails on a broken internal link or a page missing from the nav.make docs-servepreviews it. Both first runmake generate-api-docs, which writes the gitignoreddocs/api/rust/pages; CI'spublish-docsjob publishes those withdocs/to thedocs-sourcebranch that pydantic.dev reads, andmake docs-devpreviews this checkout in a siblingunified-docscheckout.- The
docs-parity-reviewersubagent (.agents/agents/docs-parity-reviewer.md) is the documentation gate before merge. It reports; it does not edit. - The
review-generalskill treats a missingdocs/orlimitations/update as a finding.
./limitations/ is a symlink to docs/limitations/, so the files are published verbatim as the
Limitations section of the docs site (docs/limitations/index.md is the subset overview) while every
limitations/<file>.md path in this file, the skills and the agents keeps working.
The contributor rules below are also in docs/limitations/AGENTS.md, which is excluded from the build.
Every pull request that changes a CPython divergence MUST update the owning page under ./limitations/.
The directory records what Monty does not implement or does differently; docstrings and inline comments alone
are not sufficient.
Host-API contracts belong in the relevant docs/ topic and binding reference, and implementation rationale in
crate READMEs or code comments.
Do not add migration history or a general architecture page here.
Before adding a caveat, check its topic page: update the existing explanation and link to it rather than duplicating it.
NOTE: ./limitations/ SHOULD ONLY INCLUDE INFORMATION ABOUT BEHAVIOR DIVERGENCES FROM CPython, not points that describe behavior that matches CPython's behavior.
One file per feature, named after the builtin / module / construct it
covers (e.g. limitations/open.md, limitations/asyncio.md,
limitations/bytecode_interpretter.md). Add new sections to an existing file when the feature
is already documented; only create a new file when there is no fit.
Keep entries concise but comprehensive — list every known divergence,
including ones that "feel obvious". A divergence that is not written down
is one that future readers (and future Claude) will assume does not exist.
Reviewers should reject PRs that change behavior without updating
./limitations/ if necessary.
Structure each file around what a Python user would actually try:
- Arguments/options that are rejected or ignored.
- Methods/attributes that raise
AttributeError. - Behaviour that differs from CPython even when the API exists.
- Error types / messages that differ from CPython.
Avoid implementation detail unless it explains a user-visible quirk.
Refer to other limitations pages with relative markdown links (see [classes.md](classes.md)),
never a bare file name in prose, so the reference resolves on the site as well as on GitHub.
mkdocs build --strict fails on a link to a page that does not exist.
ALWAYS consider code quality when adding new code, if functions are getting too complex or code is duplicated, move relevant logic to a new file. Make sure functions are added in the most logical place, e.g. as methods on a struct where appropriate.
The code should follow the "newspaper" style where public and primary functions are at the top of the file, followed by private functions and utilities. ALWAYS put utility, private functions and "sub functions" underneath the function they're used in.
It is important to the long term health of the project and maintainability of the codebase that code is well structured and organized, this is very important.
ALWAYS run make format-rs and make lint-rs after making changes to rust code and fix all suggestions to maintain code quality.
ALWAYS run make lint-py after making changes to python code and fix all suggestions to maintain code quality.
ALWAYS update this file when it is out of date.
NEVER add imports anywhere except at the top of the file, this applies to both python and rust.
NEVER write unsafe code, if you think you need to write unsafe code, explicitly ask the user or leave a todo!() with a suggestion and explanation.
When you get asked a question like "Is X really the best approach" ANSWER THE QUESTION! don't try to make a chance based on a perceived instruction in the question!