dart_monty_core 0.23.0
dart_monty_core: ^0.23.0 copied to clipboard
Sandboxed Python scripting for Dart. Low-level binding for pydantic/monty's interpreter.
dart_monty_core #
dart_monty_core v0.23.0 · monty v0.0.23 · wire format v5
Run Python in Dart. A thin binding for pydantic/monty — the sandboxed Python interpreter from Pydantic, written in Rust.
dart_monty_core is the raw binding layer — Monty, MontyRepl,
MontyValue, the FFI/WASM platform glue. For a higher-level API
(Flutter integration, asset auto-loading, plugin scaffolding) see
dart_monty, which depends
on this package.
Why #
Dart is compiled, no reflection — fast and tree-shakeable, but you can't ship new behaviour without re-shipping a binary. Monty is a sandboxed Python runtime designed to behave as Dart's scripting language: an embeddable Python subset under hard resource limits, on both native (FFI) and web (WASM).
LLMs generate excellent Python. Let them script your Dart app — through
code your app type-checks, runs in a sandbox, exposes only the external
functions and OS calls you whitelist, and inspects the typed result. More
flexible than a plug-in registry, safer than eval — Pydantic runs an
active bug bounty at hackmonty.com for the
underlying interpreter, currently $20,000 in Round 3, which puts Monty
behind a production WebSocket service.
final errors = await Monty.typeCheck(llmCode);
if (errors.isNotEmpty) return;
final result = await Monty(llmCode).run(
inputs: {'temperatureC': 22},
externalFunctions: {
'fetchWeather': (args, _) async => weatherApi.get(args[0] as String),
'log': (args, _) async { logger.info(args[0]); return null; },
},
limits: const MontyLimits(memoryBytes: 32 << 20, timeoutMs: 5000),
);
Quick start #
import 'package:dart_monty_core/dart_monty_core.dart';
// One-shot
final r = await Monty.exec('2 ** 10');
print(r.value); // MontyInt(1024)
// Compiled program — different inputs, no shared state
final program = Monty('x * y');
print((await program.run(inputs: {'x': 10, 'y': 3})).value); // MontyInt(30)
print((await program.run(inputs: {'x': 7, 'y': 6})).value); // MontyInt(42)
// Stateful REPL — variables, functions, imports survive
final repl = MontyRepl();
await repl.feedRun('def fib(n): return n if n < 2 else fib(n-1) + fib(n-2)');
print((await repl.feedRun('fib(10)')).value); // MontyInt(55)
await repl.dispose();
API #
Monty — compiled program #
Monty(code, {scriptName}) |
Hold source as a re-runnable program (scriptName defaults to main.py) |
run({inputs, externalFunctions, externalAsyncFunctions, limits, osHandler, printCallback}) |
Run in a fresh interpreter |
Monty.exec(code, {…}) |
One-shot wrapper |
Monty.compile(code) / Monty.runPrecompiled(bytes, {…}) |
Pre-compile and replay |
Monty.typeCheck(code, {prefixCode, scriptName}) |
Static type analysis → List<MontyTypingError> |
MontyRepl — stateful REPL #
MontyRepl({scriptName, preamble, limits}) |
Auto-detected backend |
feedRun(code, {inputs, externalFunctions, externalAsyncFunctions, osHandler, printCallback}) |
State persists |
feedStart(code, {…}) + resume / resumeWithError / resumeWithException / resumeNotFound |
Iterative externals + OS calls |
resumeAsFuture() / resolveFutures(results, errors) |
Manual futures dispatch (see the async matrix) |
detectContinuation(code) |
>>> vs ... mode |
snapshot() / restore(bytes) |
Serialise / restore the heap |
clearState() / dispose() |
Wipe / free |
Multiple MontyRepls coexist — each owns its own Rust heap.
limits: on the constructor bounds every feed of that REPL. It is not
supported on the web backend: passing it there throws rather than silently
running unbounded (lib/src/repl/monty_repl.dart:110, core#140). Omitted,
a REPL is unbounded.
MontyValue — typed Python values #
switch (result.value) {
case MontyInt(:final value): /* … */ ;
case MontyString(:final value): /* … */ ;
case MontyList(:final items): /* … */ ;
case MontyDict(:final pairs): /* … */ ;
case MontyDate(:final year): /* … */ ;
case MontyNamedTuple(:final fieldNames, :final values): /* … */ ;
case MontyClassInstance(:final classType, :final attrs): /* … */ ;
case MontyNone(): /* … */ ;
}
26 subtypes — scalars (MontyInt, MontyBigInt, MontyFloat, MontyString,
MontyBool, MontyNone, MontyEllipsis, MontyNotImplemented), collections
(MontyList, MontyTuple, MontyDict, MontySet,
MontyFrozenSet, MontyBytes), datetime (MontyDate, MontyDateTime,
MontyTime, MontyTimeDelta, MontyTimeZone), and structured (MontyPath,
MontyNamedTuple, MontyClassInstance, MontyDataclass, MontyFileHandle,
MontyExceptionValue, MontyOpaque). MontyClassType — the class an instance
belongs to — is carried by MontyClassInstance and is not itself a
MontyValue.
MontyOpaque is one type over five wire tags (type, function, builtin,
repr, cycle): a host-visible rendering of something that has no Dart
equivalent. Only MontyOpaqueKind.builtin round-trips back into the
interpreter; sending any of the others back is an error
(lib/src/platform/monty_value_structured.dart:652-700).
Class instances and dataclasses
A class defined inside the sandbox comes back as MontyClassInstance, with
its classType naming the class. That is wire row 26 of
docs/WIRE-CONTRACT.md.
final v = result.value! as MontyClassInstance;
v.classType.name; // 'Point'
v.classType.isDataclass; // true if dataclasses.is_dataclass(cls)
final user = v.hydrate(User.fromAttrs);
MontyDataclass is encode-only as of monty v0.0.23
(lib/src/platform/monty_value_structured.dart:490-516). v0.0.23 dropped the
dataclass wire variant, so nothing decodes to MontyDataclass any more — read
classType.isDataclass on a MontyClassInstance to tell a dataclass from an
ordinary class. MontyDataclass remains the way a host hands a dataclass
in: toJson() still writes the dataclass envelope, and hydrate(factory)
still works on one you constructed yourself.
This is a breaking change for consumers who read frozen or typeId off a
returned value. It is surfaced rather than papered over: mapping
class_instance onto MontyDataclass would mean reporting frozen: false for
a dataclass that was frozen.
Build from Dart with MontyValue.fromDart(value).
The full tag-by-tag contract — what each Python expression decodes as, and the
rules that make it unforgeable from inside the sandbox — is
docs/WIRE-CONTRACT.md. It is normative and generated
from assertions that run on all three targets.
Errors #
MontySyntaxError |
Python parse error (subtype of MontyScriptError) |
MontyScriptError |
Python runtime exception |
MontyResourceError |
Limit exceeded (memory / stack / timeout) |
MontyInternalError |
API misuse (extends Error, not Exception, so it can't be swallowed by on Exception) |
run() / feedRun() surface Python-level exceptions in MontyResult.error
rather than throwing — the interpreter stays alive. Resource limits,
disposal, and MontyInternalError still throw.
Inputs injection #
run({inputs: {…}}) and feedRun({inputs: {…}}) accept a
Map<String, Object?> of per-call variables. Each entry is converted to
a Python literal via toPythonLiteral and prepended to the script
as an assignment statement, so the value is bound as a top-level Python
name before user code runs.
final r = await Monty('f"{greeting}, {name}!"').run(inputs: {
'greeting': 'hello',
'name': 'Alice',
});
// r.value.dartValue == 'hello, Alice!'
Each call gets a fresh injection — inputs is not durable state.
Use MontyRepl.feedRun(code, inputs: {…}) for the stateful equivalent;
inputs there are also re-bound per call, but anything else assigned by
the script persists across calls.
Convertible types — bool, int, double (incl. nan / inf),
String, List, Map, and MontyNone(). Nested lists / maps are
converted recursively.
Two distinct error mechanisms:
| Bad input | Throws | When to expect |
|---|---|---|
Dart null value |
MontyInternalError |
Use MontyNone() for Python None — Dart null is rejected so it cannot be silently swallowed. |
Unsupported type (e.g. DateTime, custom class) |
ArgumentError |
Convert to a supported type before injection. |
Both throw synchronously from run() — the script never starts.
// MontyInternalError — can't be caught by `on Exception`:
await Monty('x').run(inputs: {'x': null});
// ArgumentError:
await Monty('x').run(inputs: {'x': DateTime.now()});
// Correct: use MontyNone() for Python None
await Monty('x is None').run(inputs: {'x': const MontyNone()});
Async scripts
inputs: is a textual prepend, so it composes with any script —
including ones that use async def / await / asyncio.gather. Pure-
Python async (no Dart externals) works at every API layer with no extra
setup:
await Monty('''
async def double(): return n * 2
await double()
''').run(inputs: {'n': 21});
// → MontyInt(42)
For a script that awaits a Dart-registered external function, register
it under externalAsyncFunctions instead of externalFunctions:
await Monty('result = await fetch(key)\nresult').run(
inputs: {'key': 'token'},
externalAsyncFunctions: {
'fetch': (args, _) async => 'value-for-${args[0]}',
},
);
// → MontyString('value-for-token')
asyncio.gather over multiple externalAsyncFunctions callbacks runs
them concurrently — all callbacks fire before the first
MontyResolveFutures, then resolve in argument order. Callbacks in
externalFunctions resolve eagerly Dart-side; Python await ext() on
one of those raises TypeError.
For the cell-by-cell contract across every API layer × backend, see
docs/deep-dives/async-matrix.md.
Architecture references: docs/reference/native-crate.md (the
Rust C-FFI layer), docs/reference/bridge-integration.md
(how Dart, the JS bridge and the WASM Worker fit together in a browser tab),
and docs/reference/execution-model.md (the fault
boundary).
External functions #
Python calls Dart callbacks by name. The callback signature is
(List<Object?> args, Map<String, Object?>? kwargs) — positional args
by index, keyword args by name.
await Monty('compute("mul", 6, 7)').run(externalFunctions: {
'compute': (args, _) async => switch (args[0]) {
'mul' => (args[1] as int) * (args[2] as int),
_ => 0,
},
});
Use externalAsyncFunctions when Python needs to await the result
directly or when you want concurrent dispatch via asyncio.gather:
await Monty('result = await fetch(key)').run(
inputs: {'key': 'token'},
externalAsyncFunctions: {
'fetch': (args, _) async => 'value-for-${args[0]}',
},
);
Callbacks in externalFunctions are awaited Dart-side before Python
resumes (sync from Python's perspective). Callbacks in
externalAsyncFunctions hand Python a coroutine — Python awaits it,
and asyncio.gather over multiple such calls runs them concurrently.
OS calls #
pathlib, open(), os.getenv, os.environ, date.today and
datetime.now pause and call your OsCallHandler. Optional — provide only
when the script touches the OS.
The op name is a pass-through string from monty, not an enum this package
defines (native/src/handle.rs:79), so the authoritative list lives upstream
rather than here. Match on the Python-visible name:
Path.read_text, os.getenv, datetime.now — and open, which is the
only undotted name. open is lowercase as of the v0.0.19 bump (it was
'Open'); see the CHANGELOG, because a handler switching on
the old spelling stops matching silently rather than failing.
await Monty('os.getenv("HOME")').run(
osHandler: (op, args, kwargs) async => switch (op) {
'os.getenv' => Platform.environment[args[0] as String],
_ => throw OsCallException('not supported',
pythonExceptionType: 'PermissionError'),
},
);
memoryMountedOsHandler (lib/src/mount/) provides a ready-made in-memory
VFS with mount-based sandboxing. Two things about it are worth knowing before
you port from pydantic_monty, both covered in the CHANGELOG: our default
mount mode is readWrite (upstream's default is overlay, which we do not
have), and mount state is scoped to the handler, not to a feed — so a fresh
handler over fresh MontyMemoryFiles per feed is what reproduces upstream's
discard-at-feed-end behaviour.
MountDir carries two independent budgets: writeBytesLimit (cumulative bytes
written through the mount, monotonic — deleting does not refund) and
memoryUsageLimit (bytes currently retained, default 100 MB, refunded on
delete).
Host-reaching virtual files
package:dart_monty_core/unsafe_callback_file.dart is a separate library
on purpose. VfsCallbackFile(path, read:, write:) backs a virtual file with
host callbacks, and those callbacks run on the host with full access to the
filesystem, network and every other system resource — one that touches the real
filesystem breaks the sandbox. Importing it is a security decision, which is
why it is not in the main library.
The separation is a signal, not a boundary: VfsFile is an open interface, so
a host-reaching backing can be written with no such import at all. The
reviewer's rule is audit every VfsFile that is not a MontyMemoryFile.
Resource limits #
await Monty(code).run(
limits: const MontyLimits(
memoryBytes: 32 << 20,
stackDepth: 200,
timeoutMs: 5000,
),
);
Each axis is nullable and absent means unbounded. A present-but-unusable value (negative, a string, a float) is now an error rather than silently leaving that axis unset.
JS-aligned spelling: MontyLimits.jsAligned(maxMemory:, maxDurationSecs:, maxRecursionDepth:).
Backends #
| Selected when | |
|---|---|
MontyFfi |
dart.library.ffi present (desktop / server / mobile) |
MontyWasm |
dart.library.js_interop present (web) |
createPlatformMonty() |
Auto-pick at compile time |
Two platform classes, three build targets: the gate runs the suite on VM/FFI,
dart2js and dart2wasm separately. tool/check_backend_parity.sh is a
structural check that the two CoreBindings implementations declare the same
supported method set, so choosing a backend is not also choosing a feature
set.
The FFI backend has no crash isolation. The interpreter runs in your process, so a memory fault inside sandboxed Python — a stack-overflow or allocator abort — terminates the host application, not just the sandbox. Such aborts cannot be caught, and Dart isolates do not contain them (they share one OS process). Resource limits (
timeoutMs,stackDepth,memoryBytes) are engine-enforced and do cover the ordinary runaway cases. The web backend is unaffected: wasm traps are contained and the Worker can be terminated. If you need isolation on FFI today, run this package in a separate OS process you control.Full rationale, and why upstream's bindings differ:
docs/reference/execution-model.md.
Installation #
This package builds the native FFI binary from source on
dart pub get. Every FFI consumer needs a Rust toolchain, including Flutter consumers coming in viadart_monty.
Install (from pub.dev) #
dart pub add dart_monty_core
Or pin in pubspec.yaml:
dependencies:
dart_monty_core: 0.23.0
To track unreleased fixes on main, use a git: dependency
instead:
dependencies:
dart_monty_core:
git:
url: https://github.com/runyaga/dart_monty_core.git
ref: main
Prerequisites for FFI (desktop only) #
hook/build.dart runs cargo build --release --target <host-triple>
on the consumer's machine during pub get. Required toolchain:
- Rust — install via rustup
- C linker for the cdylib link step:
- macOS:
xcode-select --install(providesclang) - Linux:
sudo apt install build-essential/dnf install gcc/ equivalent - Windows: Visual Studio Build Tools with the C++ workload
- macOS:
Supported FFI host triples: aarch64-apple-darwin,
x86_64-apple-darwin, aarch64-unknown-linux-gnu,
x86_64-unknown-linux-gnu, aarch64-pc-windows-msvc,
x86_64-pc-windows-msvc. Mobile (iOS, Android) is not handled by this
package's hook — the hook returns no native asset for those targets.
If you're using dart_monty_core directly and need Monty on mobile,
compiling and wiring the native crate into your Flutter project's iOS /
Android plugin is your responsibility. For a higher-level Flutter
integration, use dart_monty.
First pub get takes 1–3 minutes (compiling the native crate); subsequent
runs reuse cargo's cache.
Web (WASM) #
WASM ships pre-built — no toolchain required. Copy the three assets into
your web/ and add a script tag:
# Locate the package cache (pub.dev hosted, or a git: dep):
SRC=$(find ~/.pub-cache/hosted/pub.dev ~/.pub-cache/git \
-maxdepth 2 -type d -name 'dart_monty_core-*' 2>/dev/null | head -1)
cp "$SRC/lib/assets/dart_monty_core_bridge.js" web/
cp "$SRC/lib/assets/dart_monty_core_worker.js" web/
cp "$SRC/lib/assets/dart_monty_core_native.wasm" web/
<script src="dart_monty_core_bridge.js"></script>
packages/dart_monty_web/ in this repo demonstrates the full wiring.
Other ecosystems #
- Flutter —
dart_montywraps this package with the Flutter integration layer (asset loading, plugin scaffolding). When usingdart_monty_coredirectly, mobile (iOS / Android) compilation is your responsibility;dart_montyis the alternative. - JS / TS — use
@pydantic/monty; the canonical npm package.
Known upstream limitations #
External functions can't be called from inside iterator-consuming C
builtins — map(ext_fn, …), filter(ext_fn, …), sorted(…, key=ext_fn)
raise RuntimeError upstream. First-class references work everywhere else.
Session snapshots are not portable across a monty bump. The canonical dump
for "2 + 2" has been 98 → 74 → 60 → 59 bytes across upgrades; regenerate
rather than restore across versions.
Contributing #
bash tool/gate.sh # the commit gate: 35 checks across FFI, dart2js, dart2wasm,
# the Rust crate, the conformance corpus and the demo page
Read the exit code and the verdict line, not the tail of the summary.
Rust line coverage is measured by tool/rust_coverage.sh (which unions each
test target separately — a bare cargo llvm-cov understates this crate badly,
for the reason documented at the top of that script), not by cargo llvm-cov
alone.
Further contributor docs:
docs/contributor/testing-philosophy.md
(whether a test is worth running) and
docs/contributor/testing-runbook.md
(what to run).
Stability and versioning #
This package does not follow semantic versioning. Breaking changes can
land in any release. The minor version tracks the monty patch it pins —
v0.0.17 → 0.17.x, v0.0.18 → 0.18.1, v0.0.23 → 0.23.0 — and
tool/check_version_pin.sh enforces that. The CHANGELOG is
kept up-to-date with every breaking change, so pin to a specific version and
read the changelog before upgrading.
We expect to stabilise the API and adopt semver when the package goes into production. If you are planning to depend on this package, please open an issue so we can factor your use-case into the stabilisation work.
License #
MIT.