ExMonty (ExMonty v0.4.0)

Copy Markdown View Source

Elixir wrapper for Monty, a minimal secure Python interpreter written in Rust.

ExMonty provides safe execution of Python code from Elixir with:

  • Microsecond startup — no Python runtime needed
  • Interactive execution — code pauses at external function calls, hands control to Elixir, and resumes with results
  • Resource limits — control memory, CPU time, and recursion depth
  • Full type mapping — Python types map naturally to Elixir types

Quick Start

# Simple evaluation
{:ok, result, output} = ExMonty.eval("2 + 2")
# result = 4, output = ""

# With inputs
{:ok, runner} = ExMonty.compile("result = x + y", inputs: ["x", "y"])
{:ok, result, output} = ExMonty.run(runner, %{"x" => 10, "y" => 20})
# result = 30

Interactive Execution

{:ok, runner} = ExMonty.compile("result = fetch(url)", inputs: ["url"])

{:ok, progress} = ExMonty.start(runner, %{"url" => "https://example.com"})

case progress do
  {:name_lookup, name, snapshot, _output} ->
    # Provide a function object for the undefined name
    {:ok, next} = ExMonty.resume(snapshot, {:ok, {:function, name}})

  {:function_call, call, snapshot, _output} ->
    response = do_fetch(call.name, call.args)
    {:ok, next} = ExMonty.resume(snapshot, {:ok, response})

  {:method_call, call, snapshot, _output} ->
    # Dataclass method call — call.args[0] is the instance
    result = handle_method(call.name, call.args, call.kwargs)
    {:ok, next} = ExMonty.resume(snapshot, result)

  {:complete, value, _output} ->
    value
end

See ExMonty.Sandbox for a high-level handler that automates the interactive loop.

Summary

Functions

Compiles Python code into a reusable runner.

Serializes a runner to a binary for storage or transfer.

Serializes a future snapshot to a binary.

Serializes a snapshot to a binary for storage or transfer.

Compiles and runs Python code in one call.

Deserializes a future snapshot from a binary.

Deserializes a runner from a binary.

Deserializes a snapshot from a binary.

Returns the list of pending call IDs from a future snapshot.

Resumes interactive execution from a snapshot with a result value.

Resumes interactive execution from a future snapshot with results for pending calls.

Mount-aware variant of resume_futures/2. After resolving futures, any subsequent OS calls are intercepted by the mount table — closes the gap where futures resumption would otherwise bypass mount routing.

Mount-aware variant of resume/2. Pass :no_handler as the result for an :os_call snapshot to delegate to upstream's OsFunction::on_no_handler semantics (PermissionError for FS, RuntimeError for non-FS).

Runs a compiled runner to completion with the given inputs.

Starts interactive execution of a compiled runner.

Mount-aware variant of start/3. Filesystem operations matching a mount in the leased mount table are intercepted in Rust without surfacing to Elixir. Unmounted FS ops and non-FS ops surface as :os_call progress for fallback dispatch.

Types

error_reason()

@type error_reason() :: term()

future_snapshot()

@type future_snapshot() :: reference()

limits()

@type limits() :: %{
  optional(:max_allocations) => non_neg_integer(),
  optional(:max_duration_secs) => float(),
  optional(:max_memory) => non_neg_integer(),
  optional(:gc_interval) => non_neg_integer(),
  optional(:max_recursion_depth) => non_neg_integer()
}

progress()

@type progress() ::
  {:function_call, ExMonty.FunctionCall.t(), snapshot(), String.t()}
  | {:method_call, ExMonty.FunctionCall.t(), snapshot(), String.t()}
  | {:os_call, ExMonty.OsCall.t(), snapshot(), String.t()}
  | {:name_lookup, String.t(), snapshot(), String.t()}
  | {:resolve_futures, future_snapshot(), String.t()}
  | {:complete, term(), String.t()}

runner()

@type runner() :: reference()

snapshot()

@type snapshot() :: reference()

Functions

compile(code, opts \\ [])

@spec compile(
  String.t(),
  keyword()
) :: {:ok, runner()} | {:error, error_reason()}

Compiles Python code into a reusable runner.

The runner can be executed multiple times with different inputs via run/3 or start/3.

External function names no longer need to be declared upfront — they are auto-detected at runtime via name lookup. When the code references an undefined name, execution pauses with a {:name_lookup, name, snapshot, output} progress tuple, allowing the host to provide a value or function.

Options

  • :inputs - list of input variable names (default: [])
  • :script_name - name for the script in tracebacks (default: "main.py")

Examples

{:ok, runner} = ExMonty.compile("result = x * 2", inputs: ["x"])

{:ok, runner} = ExMonty.compile("result = fetch(url)", inputs: ["url"])

dump(runner)

@spec dump(runner()) :: {:ok, binary()} | {:error, term()}

Serializes a runner to a binary for storage or transfer.

Examples

{:ok, runner} = ExMonty.compile("result = x + 1", inputs: ["x"])
{:ok, binary} = ExMonty.dump(runner)
{:ok, restored} = ExMonty.load_runner(binary)

dump_future_snapshot(futures)

@spec dump_future_snapshot(future_snapshot()) :: {:ok, binary()} | {:error, term()}

Serializes a future snapshot to a binary.

Note: This consumes the future snapshot.

dump_snapshot(snapshot)

@spec dump_snapshot(snapshot()) :: {:ok, binary()} | {:error, term()}

Serializes a snapshot to a binary for storage or transfer.

Note: This consumes the snapshot — it cannot be used for resumption after dumping.

eval(code, opts \\ [])

@spec eval(
  String.t(),
  keyword()
) :: {:ok, term(), String.t()} | {:error, error_reason()}

Compiles and runs Python code in one call.

Convenience function that combines compile/2 and run/3.

Options

  • :inputs - map of input variable names to values (default: %{})
  • :limits - resource limits map (default: nil)
  • :script_name - script name for tracebacks (default: "main.py")

Examples

{:ok, 4, ""} = ExMonty.eval("result = 2 + 2")

{:ok, 30, ""} = ExMonty.eval("result = x + y",
  inputs: %{"x" => 10, "y" => 20}
)

load_future_snapshot(binary)

@spec load_future_snapshot(binary()) :: {:ok, future_snapshot()} | {:error, term()}

Deserializes a future snapshot from a binary.

load_runner(binary)

@spec load_runner(binary()) :: {:ok, runner()} | {:error, term()}

Deserializes a runner from a binary.

Examples

{:ok, runner} = ExMonty.load_runner(binary)

load_snapshot(binary)

@spec load_snapshot(binary()) :: {:ok, snapshot()} | {:error, term()}

Deserializes a snapshot from a binary.

pending_call_ids(futures)

@spec pending_call_ids(future_snapshot()) :: [non_neg_integer()]

Returns the list of pending call IDs from a future snapshot.

Examples

ids = ExMonty.pending_call_ids(futures)
# [1, 2, 3]

resume(snapshot, result)

@spec resume(snapshot(), {:ok, term()} | {:error, atom(), String.t()} | :undefined) ::
  {:ok, progress()} | {:error, error_reason()}

Resumes interactive execution from a snapshot with a result value.

For :function_call, :method_call, and :os_call snapshots, the result should be {:ok, value} for successful returns or {:error, type, message} for errors.

For :name_lookup snapshots, the result should be:

  • {:ok, {:function, name}} — provide a callable function object
  • {:ok, value} — provide any value for the name
  • :undefined — raise NameError in Python

Examples

# Function call result
{:ok, next_progress} = ExMonty.resume(snapshot, {:ok, "response body"})
{:ok, next_progress} = ExMonty.resume(snapshot, {:error, :runtime_error, "fetch failed"})

# Name lookup result
{:ok, next_progress} = ExMonty.resume(snapshot, {:ok, {:function, "my_func"}})
{:ok, next_progress} = ExMonty.resume(snapshot, :undefined)

resume_futures(futures, results)

@spec resume_futures(future_snapshot(), [{non_neg_integer(), term()}]) ::
  {:ok, progress()} | {:error, error_reason()}

Resumes interactive execution from a future snapshot with results for pending calls.

Each result is a {call_id, {:ok, value}} or {call_id, {:error, type, message}} tuple.

Examples

ids = ExMonty.pending_call_ids(futures)
results = Enum.map(ids, fn id -> {id, {:ok, compute(id)}} end)
{:ok, next_progress} = ExMonty.resume_futures(futures, results)

resume_futures_with_mounts(futures, results, lease)

@spec resume_futures_with_mounts(
  future_snapshot(),
  [{non_neg_integer(), term()}],
  ExMonty.Mount.Lease.t()
) :: {:ok, progress()} | {:error, error_reason()}

Mount-aware variant of resume_futures/2. After resolving futures, any subsequent OS calls are intercepted by the mount table — closes the gap where futures resumption would otherwise bypass mount routing.

resume_with_mounts(snapshot, result, lease)

@spec resume_with_mounts(snapshot(), term(), ExMonty.Mount.Lease.t()) ::
  {:ok, progress()} | {:error, error_reason()}

Mount-aware variant of resume/2. Pass :no_handler as the result for an :os_call snapshot to delegate to upstream's OsFunction::on_no_handler semantics (PermissionError for FS, RuntimeError for non-FS).

run(runner, inputs \\ %{}, opts \\ [])

@spec run(runner(), map(), keyword()) ::
  {:ok, term(), String.t()} | {:error, error_reason()}

Runs a compiled runner to completion with the given inputs.

Returns the result value and any captured print output.

Options

  • :limits - resource limits map (default: nil for default limits)

Examples

{:ok, runner} = ExMonty.compile("result = x + y", inputs: ["x", "y"])
{:ok, result, output} = ExMonty.run(runner, %{"x" => 1, "y" => 2})
# result = 3, output = ""

start(runner, inputs \\ %{}, opts \\ [])

@spec start(runner(), map(), keyword()) ::
  {:ok, progress()} | {:error, error_reason()}

Starts interactive execution of a compiled runner.

Returns a progress tuple that indicates the current state of execution. Use pattern matching to handle function calls, OS calls, futures, or completion.

Options

  • :limits - resource limits map (default: nil)

Progress Values

  • {:name_lookup, name, snapshot, output} — paused at unresolved name lookup. Resume with {:ok, {:function, name}} to provide a callable, {:ok, value} for a constant, or :undefined to raise NameError.
  • {:function_call, %ExMonty.FunctionCall{}, snapshot, output} — paused at external function call
  • {:method_call, %ExMonty.FunctionCall{}, snapshot, output} — paused at dataclass method call (first arg is the dataclass instance)
  • {:os_call, %ExMonty.OsCall{}, snapshot, output} — paused at OS/filesystem operation
  • {:resolve_futures, future_snapshot, output} — paused waiting for async futures
  • {:complete, value, output} — execution finished

Examples

{:ok, runner} = ExMonty.compile("result = fetch(url)", inputs: ["url"])

{:ok, {:name_lookup, "fetch", snapshot, _output}} =
  ExMonty.start(runner, %{"url" => "https://example.com"})

# Provide the function, then handle the actual call
{:ok, {:function_call, call, snapshot2, _}} =
  ExMonty.resume(snapshot, {:ok, {:function, "fetch"}})

start_with_mounts(runner, lease, inputs \\ %{}, opts \\ [])

@spec start_with_mounts(runner(), ExMonty.Mount.Lease.t(), map(), keyword()) ::
  {:ok, progress()} | {:error, error_reason()}

Mount-aware variant of start/3. Filesystem operations matching a mount in the leased mount table are intercepted in Rust without surfacing to Elixir. Unmounted FS ops and non-FS ops surface as :os_call progress for fallback dispatch.

Most users invoke this transparently via ExMonty.Sandbox.run/2 with the :mounts option.