Python QuickStart
uv add pydantic-monty
Everything in pydantic_monty starts with a pool of worker subprocesses.
Execution never happens in your process: a Monty process can never be made fully crash-proof against memory errors
triggered by adversarial code, so the interpreter always runs in a worker that can crash without taking your process
down.
from pydantic_monty import Monty
with Monty() as pool:
with pool.checkout() as session:
print(session.feed_run('1 + 2'))
#> 3
Monty() configures the pool; the workers are spawned by with.
pool.checkout() dedicates one worker to one REPL session.
feed_run executes a snippet and returns the value of its trailing expression.
Session state — globals, functions, classes — persists across feed_run calls on the same checkout:
from pydantic_monty import Monty
with Monty() as pool:
with pool.checkout() as session:
session.feed_run('x = 40')
print(session.feed_run('x + 2'))
#> 42
When the with block on the session exits, the worker goes back to the pool and the session state is gone.
There are two ways to give the sandbox values from the host.
inputs binds values as globals eagerly, before the snippet runs.
Every entry is converted and bound once, whether or not the code uses it.
external_lookup resolves names lazily, when the code reads them.
A callable entry becomes a host function the sandbox can call; any other value is converted and
returned when the name is read; a name that is absent raises NameError inside the sandbox.
from pydantic_monty import Monty
with Monty() as pool:
with pool.checkout() as session:
result = session.feed_run(
'double(x) + y',
inputs={'x': 5, 'y': 1},
external_lookup={'double': lambda x: x * 2},
)
print(result)
#> 11
A name present in both is served by the eager inputs binding.
None, bool, int (arbitrary precision), float, str, bytes, list, tuple, dict, set, frozenset,
Ellipsis, NotImplemented, datetime.date, datetime.datetime, datetime.timedelta, datetime.timezone, named
tuples, dataclass instances, exception instances, and the type objects Monty models (int, str, datetime.date, …)
all convert in both directions.
Put callables in external_lookup, where they become host functions; a callable in inputs
binds only a reference the sandbox still resolves through external_lookup when it is called.
POSIX paths convert too — pathlib.PurePosixPath and pathlib.PosixPath, which is what Path() builds on Linux and
macOS.
They come back as PurePosixPath, and a PureWindowsPath / WindowsPath is rejected, because paths inside the sandbox
are always POSIX.
Anything else is rejected with MontyConversionError before it reaches the sandbox:
from decimal import Decimal
from pydantic_monty import Monty, MontyConversionError
with Monty() as pool:
with pool.checkout() as session:
try:
session.feed_run('v', inputs={'v': Decimal('1.5')})
except MontyConversionError as exc:
print(exc)
#> Cannot convert decimal.Decimal to Monty value
AsyncMonty is the asyncio counterpart.
Worker I/O runs off the event loop, and host functions may be coroutines:
import asyncio
from pydantic_monty import AsyncMonty
async def fetch(url: str) -> str:
await asyncio.sleep(0.01)
return f'contents of {url}'
async def main():
async with AsyncMonty() as pool:
async with pool.checkout() as session:
result = await session.feed_run(
"await fetch('https://example.com')",
external_lookup={'fetch': fetch},
)
print(result)
#> contents of https://example.com
asyncio.run(main())
There is no event loop inside the sandbox — the host is the loop.
Sandboxed async def and await work, and asyncio exposes exactly run and gather, the latter running host calls
concurrently.
asyncio.create_task, asyncio.sleep and everything else in the module do not exist.
See limitations/asyncio.md.
By default the sandbox’s print() goes to your process’s stdout and stderr.
Pass print_callback to intercept it:
from pydantic_monty import CollectString, Monty
with Monty() as pool:
with pool.checkout() as session:
collector = CollectString()
session.feed_run("print('from the sandbox')", print_callback=collector)
print(repr(collector.output))
#> 'from the sandbox\n'
CollectStreams collects (stream, text) tuples instead, so you can tell stdout from stderr.
Both cap collected output at 10 MiB by default; pass max_bytes=None to disable the cap.
That cap is separate from max_memory, and it is enforced in your process as the output
arrives, not by the worker.
Exceeding it fails the feed with MontyRuntimeError wrapping a MemoryError; call exc.exception() for the
MemoryError itself.
Sandboxed code cannot catch it, so a print() loop cannot swallow the cap.
A plain callable works too, receiving (stream, text):
from pydantic_monty import Monty
seen: list[tuple[str, str]] = []
with Monty() as pool:
with pool.checkout() as session:
session.feed_run(
"print('hello')",
print_callback=lambda stream, text: seen.append((stream, text)),
)
print(seen)
#> [('stdout', 'hello\n')]
Output arrives in chunks flushed at newline boundaries or once roughly 8 KiB accumulates, not one call per print().
Every Monty error subclasses MontyError:
| Exception | Raised when | Session survives |
|---|---|---|
MontySyntaxError | The snippet does not parse | yes |
MontyTypingError | Type checking rejected the snippet | yes |
MontyRuntimeError | The code raised at runtime | yes — but discard it after a resource limit |
MontyConversionError | A host value cannot cross the boundary | from inputs yes, from external_lookup no |
MontyCrashedError | The worker died, or hit request_timeout | no |
inputs are converted before the snippet runs, so a rejected value leaves the session untouched.
An external_lookup value is converted mid-execution, while the worker is suspended on the name read, so the checkout
is discarded and reusing it raises RuntimeError: this checkout has already been finished.
Check out again to retry.
A MontyRuntimeError carrying TimeoutError, or a MemoryError from the sandbox heap, is a resource
limit rather than ordinary sandbox code raising.
The pool leaves the checkout open, but the heap behind it is no longer trustworthy, so discard it rather than feeding it
again.
A spent max_duration_secs budget is cumulative, so later feeds re-raise TimeoutError anyway; after a max_memory
trip they may quietly succeed.
The print-collector cap is not one of these, though it looks identical from the outside: same MontyRuntimeError, same
MemoryError, same memory limit exceeded: ... message.
If you collect printed output at all — and the collectors are capped by default — you cannot tell the two apart from the
exception alone, and in the collector case nothing is wrong with the session.
See limitations/print.md.
MontySyntaxError and MontyRuntimeError carry a Monty traceback:
from pydantic_monty import Monty, MontyRuntimeError
code = """
def f():
raise ValueError('boom')
f()
"""
with Monty() as pool:
with pool.checkout() as session:
try:
session.feed_run(code)
except MontyRuntimeError as exc:
print(exc.display(format='type-msg'))
#> ValueError: boom
print([frame.function_name for frame in exc.traceback()])
#> ['<module>', 'f']
display() also takes 'traceback' (the default, a full CPython-style traceback) and 'msg'.
exc.exception() returns the inner exception as a native Python exception object.
MontyCrashedError is the one that loses the session.
The pool has already replaced the worker by the time you catch it, so retrying on a fresh checkout is safe:
from pydantic_monty import Monty, MontyCrashedError
hostile_code = '...'
with Monty() as pool:
with pool.checkout() as session:
try:
session.feed_run(hostile_code) # even a segfault is contained
except MontyCrashedError:
... # the worker died; the pool already replaced it
Both are configured per session, on checkout():
from pydantic_monty import Monty, MontyRuntimeError
with Monty(request_timeout=10) as pool:
with pool.checkout(limits={'max_duration_secs': 0.1}) as session:
try:
session.feed_run('while True:\n pass')
except MontyRuntimeError as exc:
print(exc.display(format='type-msg').split(':')[0])
#> TimeoutError
from pydantic_monty import Monty, MontyTypingError
with Monty() as pool:
with pool.checkout(type_check=True) as session:
try:
session.feed_run("x: int = 'not an int'")
except MontyTypingError as exc:
print('invalid-assignment' in exc.display())
#> True
See resource limits and type checking.
from pydantic_monty import Monty
pool = Monty(
binary_path=None, # explicit path to the `monty` worker binary
min_processes=1, # workers spawned eagerly and kept warm
max_processes=None, # cap on live workers; defaults to the CPU count
checkout_timeout=None, # seconds `checkout()` waits for a free worker
request_timeout=None, # hard per-turn deadline; kills the worker
max_checkouts_per_worker=None, # recycle a worker after N sessions
)
request_timeout is a per-turn host-side backstop: a worker that exceeds it is killed and the call raises
MontyCrashedError with timed_out=True.
It catches hangs the in-sandbox limits cannot see, because those are only checked at interpreter checkpoints.
A loop of quick host calls resets it each turn; set max_duration_secs as well.
AsyncMonty takes the same arguments.
- Host functions — the only way code in the sandbox reaches anything outside it.
- Filesystem access — mounts and the
oscallback. - Snapshots —
feed_start,dump()and resuming later. - The Python subset — what the sandbox can actually run.
Worked examples using Pydantic AI live in examples/.