dart_monty_core 0.23.0 copy "dart_monty_core: ^0.23.0" to clipboard
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

CI Pages codecov

Live Demo | GitHub

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 typesbool, 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 via dart_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 (provides clang)
    • Linux: sudo apt install build-essential / dnf install gcc / equivalent
    • Windows: Visual Studio Build Tools with the C++ workload

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 #

  • Flutterdart_monty wraps this package with the Flutter integration layer (asset loading, plugin scaffolding). When using dart_monty_core directly, mobile (iOS / Android) compilation is your responsibility; dart_monty is 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.

2
likes
150
points
163
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Sandboxed Python scripting for Dart. Low-level binding for pydantic/monty's interpreter.

Repository (GitHub)
View/report issues

Topics

#python #sandbox #interpreter #scripting

License

MIT (license)

Dependencies

code_assets, collection, ffi, hooks, meta

More

Packages that depend on dart_monty_core