Current section
Files
Jump to
Current section
Files
lib/pyex.ex
defmodule Pyex do
@moduledoc """
A Python 3 interpreter written in Elixir.
Pyex lexes, parses, and evaluates Python source code entirely
within the BEAM -- no external runtime, no ports, and no Python
runtime dependency.
It is designed as a capabilities-based sandbox for running
LLM-generated compute safely: every I/O operation (network,
filesystem, database) is denied by default and must be
explicitly granted through `Pyex.Ctx` options.
## Quick start
Pyex.run!("sorted([3, 1, 2])")
# => [1, 2, 3]
{:ok, 42, _ctx} = Pyex.run("40 + 2")
## Public API
- `compile/1` -- parse source to AST (reusable)
- `run/2` -- execute source or AST, returns `{:ok, value, ctx}` or `{:error, error}`
- `run!/2` -- execute, returns value or raises
- `output/1` -- extract print output from a context
## Sandbox
All external access is configured through `Pyex.Ctx` options.
Python code can only reach what you explicitly grant:
Pyex.run(source,
env: %{"API_KEY" => "sk-..."},
timeout: 5_000,
modules: %{"mylib" => %{"greet" => {:builtin, fn [n] -> "hi \#{n}" end}}})
See `run/2` for the full list of options.
"""
alias Pyex.{Builtins, Ctx, Error, Lexer, Parser, Interpreter}
@doc """
Compiles a Python source string to an AST.
The lexer and parser are pure functions of the source text.
Callers that execute the same program repeatedly should
compile once and reuse the AST with `run/2`.
"""
@spec compile(String.t()) :: {:ok, Parser.ast_node()} | {:error, String.t()}
def compile(source) when is_binary(source) do
with {:ok, tokens} <- Lexer.tokenize(source) do
Parser.parse(tokens)
end
end
@doc """
Runs Python code through the full pipeline.
Accepts either a source string or a pre-compiled AST.
The optional second argument can be a `Pyex.Ctx` struct
or a keyword list of options (forwarded to `Pyex.Ctx.new/1`).
Returns `{:ok, value, ctx}` on success, or `{:error, reason}`.
## Options (when passing keyword list)
- `:modules` -- custom Python modules available via `import`
- `:filesystem` -- a filesystem backend struct (module derived automatically)
- `:env` -- environment variables for `os.environ`
- `:timeout` -- compute time budget in milliseconds
- `:limits` -- resource limits as a keyword list or `Pyex.Limits` struct.
Supported keys: `:timeout`, `:max_steps`, `:max_memory_bytes`,
`:max_output_bytes`. When `limits: [timeout: N]` is provided,
it supersedes a top-level `:timeout`.
- `:network` -- network access policy for the `requests` module.
A list of rule maps, each with `:allowed_url_prefix` or
`:dangerously_allow_full_internet_access`, plus optional `:methods`
(default `["GET", "HEAD"]`) and `:headers` (injected into matching
requests). When omitted, all network access is denied.
- `:capabilities` -- list of enabled I/O capabilities (e.g.
`[:boto3, :sql]`). All capabilities are denied by default.
- `:boto3` -- shorthand for adding `:boto3` to capabilities.
- `:sql` -- shorthand for adding `:sql` to capabilities.
## Examples
{:ok, 42, _ctx} = Pyex.run("40 + 2")
{:ok, ast} = Pyex.compile("40 + 2")
{:ok, 42, _ctx} = Pyex.run(ast)
{:ok, "hello world", _ctx} = Pyex.run(
~s|import mylib; mylib.greet("world")|,
modules: %{
"mylib" => %{
"greet" => {:builtin, fn [name] -> "hello " <> name end}
}
})
"""
@spec run(String.t() | Parser.ast_node(), Ctx.t() | keyword()) ::
{:ok, Interpreter.pyvalue(), Ctx.t()}
| {:error, Error.t()}
def run(source_or_ast, ctx_or_opts \\ [])
def run(source, ctx_or_opts) when is_binary(source) do
case compile(source) do
{:ok, ast} -> run(ast, ctx_or_opts)
{:error, msg} -> {:error, Error.syntax(msg)}
end
end
def run(ast, %Ctx{} = ctx) when is_tuple(ast) do
start_mono = System.monotonic_time()
:telemetry.execute([:pyex, :run, :start], %{system_time: System.system_time()}, %{})
ctx = %{ctx | compute: 0.0, compute_started_at: System.monotonic_time()}
env = Builtins.runtime_env(ctx)
result = Interpreter.run_with_ctx_result(ast, env, ctx)
case result do
{:ok, value, _env, final_ctx} ->
final_ctx = close_open_handles(final_ctx)
duration_ms =
System.convert_time_unit(System.monotonic_time() - start_mono, :native, :microsecond) /
1000.0
:telemetry.execute([:pyex, :run, :stop], %{duration_ms: duration_ms}, %{
compute: Ctx.compute_time(final_ctx)
})
derefed = Ctx.deep_deref(final_ctx, value)
{:ok, Interpreter.Helpers.to_python_view(derefed),
%{final_ctx | duration_ms: duration_ms}}
{:error, msg, final_ctx} ->
close_open_handles(final_ctx)
duration_ms =
System.convert_time_unit(System.monotonic_time() - start_mono, :native, :microsecond) /
1000.0
error = Error.from_message(msg)
:telemetry.execute([:pyex, :run, :exception], %{duration_ms: duration_ms}, %{
error: error
})
{:error, error}
end
end
def run(ast, opts) when is_tuple(ast) and is_list(opts) do
run(ast, Ctx.new(opts))
end
@doc """
Runs Python code and returns the result directly.
Raises on lexer, parser, or runtime errors. Accepts the
same arguments as `run/2`.
## Examples
42 = Pyex.run!("40 + 2")
"hello" = Pyex.run!(ast, ctx)
"""
@spec run!(String.t() | Parser.ast_node(), Ctx.t() | keyword()) :: Interpreter.pyvalue()
def run!(source_or_ast, ctx_or_opts \\ []) do
case run(source_or_ast, ctx_or_opts) do
{:ok, result, _ctx} -> result
{:error, %Error{message: msg}} -> raise msg
end
end
@doc """
Returns all captured print output as a single string.
Python's `print()` records each line as an `:output` event
in the context. This function extracts and joins them with newlines.
## Example
{:ok, _val, ctx} = Pyex.run("print('hello')")
"hello" = Pyex.output(ctx)
{:ok, _val, ctx} = Pyex.run("print('line1')\nprint('line2')")
"line1\nline2" = Pyex.output(ctx)
"""
@spec output(Ctx.t()) :: String.t()
def output(%Ctx{} = ctx), do: ctx |> Ctx.output() |> IO.iodata_to_binary()
@spec close_open_handles(Ctx.t()) :: Ctx.t()
defp close_open_handles(final_ctx) do
Enum.reduce(Map.keys(final_ctx.handles), final_ctx, fn id, ctx ->
case Ctx.close_handle(ctx, id) do
{:ok, ctx} -> ctx
{:error, _} -> ctx
end
end)
end
end