Current section
Files
Jump to
Current section
Files
lib/snakebridge.ex
defmodule SnakeBridge do
@moduledoc """
Universal FFI bridge to Python.
SnakeBridge provides two ways to call Python:
1. **Generated wrappers** (compile-time): Type-safe, documented Elixir modules
generated from Python library introspection.
2. **Dynamic calls** (runtime): Direct calls to any Python module without
code generation, using string module paths.
## Universal FFI API
The universal FFI requires no code generation:
# Call any Python function
{:ok, result} = SnakeBridge.call("math", "sqrt", [16])
# Get module attributes
{:ok, pi} = SnakeBridge.get("math", "pi")
# Work with Python objects
{:ok, path} = SnakeBridge.call("pathlib", "Path", ["/tmp"])
{:ok, exists?} = SnakeBridge.method(path, "exists", [])
## Sessions
SnakeBridge automatically manages Python object sessions. Each Elixir process
gets an isolated session, and refs are automatically cleaned up when the
process terminates.
For explicit session control, use `SnakeBridge.SessionContext.with_session/1`.
## Type Mapping
| Elixir | Python |
|--------|--------|
| `nil` | `None` |
| `true`/`false` | `True`/`False` |
| integers | `int` |
| floats | `float` |
| strings | `str` |
| `SnakeBridge.bytes(data)` | `bytes` |
| lists | `list` |
| maps | `dict` |
| tuples | `tuple` |
| `MapSet` | `set` |
| atoms | tagged atom (decoded to string by default) |
| `DateTime` | `datetime` |
| `SnakeBridge.Ref` | Python object reference |
"""
require SnakeBridge.WithContext
alias SnakeBridge.{Bytes, Dynamic, Ref, Runtime}
# ============================================================================
# Universal FFI API
# ============================================================================
@doc """
Call a Python function.
Accepts either a generated SnakeBridge module or a Python module path string.
## Parameters
- `module` - A generated module atom (e.g., `Numpy`) or a module path string (e.g., `"numpy"`)
- `function` - Function name as atom or string
- `args` - List of positional arguments (default: `[]`)
- `opts` - Keyword arguments passed to Python, plus:
- `:idempotent` - Mark call as cacheable (default: `false`)
- `:__runtime__` - Pass-through options to Snakepit
## Examples
# Call stdlib function
{:ok, 4.0} = SnakeBridge.call("math", "sqrt", [16])
# With keyword arguments
{:ok, 3.14} = SnakeBridge.call("builtins", "round", [3.14159], ndigits: 2)
# Submodule
{:ok, path} = SnakeBridge.call("os.path", "join", ["/tmp", "file.txt"])
# Create objects
{:ok, ref} = SnakeBridge.call("pathlib", "Path", ["."])
## Return Values
- `{:ok, value}` - Decoded Elixir value for JSON-serializable results
- `{:ok, %SnakeBridge.Ref{}}` - Reference for non-serializable Python objects
- `{:error, reason}` - Error from Python
## Notes
- String module paths trigger dynamic dispatch (no codegen required)
- Sessions are automatic; refs are isolated per Elixir process
- Non-JSON-serializable returns are wrapped in refs for safe access
"""
@spec call(module() | String.t(), atom() | String.t(), list(), keyword()) ::
{:ok, term()} | {:error, term()}
defdelegate call(module, function, args \\ [], opts \\ []), to: Runtime
@doc """
Call a Python function, raising on error.
Same as `call/4` but raises on error instead of returning `{:error, reason}`.
## Examples
result = SnakeBridge.call!("math", "sqrt", [16])
# => 4.0
# Raises on error
SnakeBridge.call!("nonexistent_module", "fn", [])
# ** (Snakepit.Error) ...
"""
@spec call!(module() | String.t(), atom() | String.t(), list(), keyword()) :: term()
def call!(module, function, args \\ [], opts \\ []) do
case call(module, function, args, opts) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
@doc """
Get a module-level attribute from Python.
Retrieves constants, classes, or any attribute from a Python module.
## Parameters
- `module` - A generated module atom or a module path string
- `attr` - Attribute name as atom or string
- `opts` - Runtime options
## Examples
# Module constant
{:ok, pi} = SnakeBridge.get("math", "pi")
# => {:ok, 3.141592653589793}
# Module-level class (returns ref)
{:ok, path_class} = SnakeBridge.get("pathlib", "Path")
# Nested attribute
{:ok, sep} = SnakeBridge.get("os", "sep")
"""
@spec get(module() | String.t(), atom() | String.t(), keyword()) ::
{:ok, term()} | {:error, term()}
defdelegate get(module, attr, opts \\ []), to: Runtime, as: :get_module_attr
@doc """
Get a module-level attribute, raising on error.
"""
@spec get!(module() | String.t(), atom() | String.t(), keyword()) :: term()
def get!(module, attr, opts \\ []) do
case get(module, attr, opts) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
@doc """
Stream results from a Python generator or iterator.
Calls a Python function that returns an iterable and invokes the callback
for each element.
## Parameters
- `module` - Module atom or path string
- `function` - Function name
- `args` - Positional arguments
- `opts` - Keyword arguments for the Python function
- `callback` - Function called with each streamed element
## Examples
# Process file in chunks
SnakeBridge.stream("pandas", "read_csv", ["large.csv"], [chunksize: 1000], fn chunk ->
IO.puts("Processing chunk")
end)
# Iterate range
SnakeBridge.stream("builtins", "range", [10], [], fn i ->
IO.puts("Got: \#{i}")
end)
## Return Value
- `{:ok, :done}` - Iteration completed successfully (for string module paths)
- `:ok` - Iteration completed successfully (for atom modules)
- `{:error, reason}` - Error during iteration
"""
@spec stream(module() | String.t(), atom() | String.t(), list(), keyword(), (term() -> term())) ::
:ok | {:ok, :done} | {:error, term()}
defdelegate stream(module, function, args, opts, callback), to: Runtime
@doc """
Call a method on a Python object reference.
## Parameters
- `ref` - A `SnakeBridge.Ref` from a previous call
- `method` - Method name as atom or string
- `args` - Positional arguments (default: `[]`)
- `opts` - Keyword arguments
## Examples
{:ok, path} = SnakeBridge.call("pathlib", "Path", ["."])
{:ok, exists?} = SnakeBridge.method(path, "exists", [])
{:ok, resolved} = SnakeBridge.method(path, "resolve", [])
# With arguments
{:ok, child} = SnakeBridge.method(path, "joinpath", ["subdir", "file.txt"])
## Notes
This is equivalent to `SnakeBridge.Dynamic.call/4` but with a clearer name
for the universal FFI context.
"""
@spec method(Ref.t(), atom() | String.t(), list(), keyword()) ::
{:ok, term()} | {:error, term()}
defdelegate method(ref, method, args \\ [], opts \\ []), to: Dynamic, as: :call
@doc """
Call a method on a ref, raising on error.
"""
@spec method!(Ref.t(), atom() | String.t(), list(), keyword()) :: term()
def method!(ref, method, args \\ [], opts \\ []) do
case method(ref, method, args, opts) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
@doc """
Get an attribute from a Python object reference.
## Parameters
- `ref` - A `SnakeBridge.Ref` from a previous call
- `attr` - Attribute name as atom or string
- `opts` - Runtime options
## Examples
{:ok, path} = SnakeBridge.call("pathlib", "Path", ["/tmp/file.txt"])
{:ok, name} = SnakeBridge.attr(path, "name")
# => {:ok, "file.txt"}
{:ok, parent} = SnakeBridge.attr(path, "parent")
# => {:ok, %SnakeBridge.Ref{...}} # parent is also a Path
"""
@spec attr(Ref.t(), atom() | String.t(), keyword()) ::
{:ok, term()} | {:error, term()}
defdelegate attr(ref, attr, opts \\ []), to: Dynamic, as: :get_attr
@doc """
Get an attribute from a ref, raising on error.
"""
@spec attr!(Ref.t(), atom() | String.t(), keyword()) :: term()
def attr!(ref, attr, opts \\ []) do
case attr(ref, attr, opts) do
{:ok, result} -> result
{:error, error} -> raise error
end
end
@doc """
Set an attribute on a Python object reference.
## Parameters
- `ref` - A `SnakeBridge.Ref` from a previous call
- `attr` - Attribute name as atom or string
- `value` - New value for the attribute
- `opts` - Runtime options
## Examples
{:ok, obj} = SnakeBridge.call("some_module", "SomeClass", [])
:ok = SnakeBridge.set_attr(obj, "property", "new_value")
"""
@spec set_attr(Ref.t(), atom() | String.t(), term(), keyword()) ::
:ok | {:error, term()}
defdelegate set_attr(ref, attr, value, opts \\ []), to: Dynamic
# ============================================================================
# Type Helpers
# ============================================================================
@doc """
Create a Bytes wrapper for explicit binary data.
By default, SnakeBridge encodes UTF-8 valid strings as Python `str`.
Use this function to explicitly send data as Python `bytes`.
## Examples
# Crypto
{:ok, hash_ref} = SnakeBridge.call("hashlib", "md5", [SnakeBridge.bytes("abc")])
{:ok, hex} = SnakeBridge.method(hash_ref, "hexdigest", [])
# Binary protocols
{:ok, packed} = SnakeBridge.call("struct", "pack", [">I", 12345])
# Base64
{:ok, encoded} = SnakeBridge.call("base64", "b64encode", [SnakeBridge.bytes("hello")])
## When to Use
Python distinguishes `str` (text) from `bytes` (binary). Use `bytes/1` for:
- Cryptographic operations (hashlib, hmac, cryptography)
- Binary packing (struct)
- Base64 encoding
- Network protocols
- File I/O in binary mode
"""
@spec bytes(binary()) :: Bytes.t()
def bytes(data) when is_binary(data) do
Bytes.new(data)
end
# ============================================================================
# Session Management
# ============================================================================
@doc """
Get the current session ID.
Returns the session ID for the current Elixir process. Sessions are
automatically created on first Python call.
## Examples
session_id = SnakeBridge.current_session()
# => "auto_<0.123.0>_1703944800000"
# With explicit session
SnakeBridge.SessionContext.with_session(session_id: "my_session", fn ->
SnakeBridge.current_session()
end)
# => "my_session"
"""
@spec current_session() :: String.t()
defdelegate current_session(), to: Runtime
@doc """
Release and clear the auto-session for the current process.
Call this to eagerly release Python object refs when you're done with
Python calls, rather than waiting for process termination.
## Examples
{:ok, ref} = SnakeBridge.call("numpy", "array", [[1,2,3]])
# ... use ref ...
SnakeBridge.release_auto_session() # Clean up now
## Notes
- This releases all refs in the current process's auto-session
- A new session is created automatically on the next Python call
- Use `SessionContext.with_session/1` for more fine-grained control
"""
@spec release_auto_session() :: :ok
defdelegate release_auto_session(), to: Runtime
# ============================================================================
# Ref Utilities
# ============================================================================
@doc """
Check if a value is a Python object reference.
## Examples
{:ok, path} = SnakeBridge.call("pathlib", "Path", ["."])
SnakeBridge.ref?(path)
# => true
SnakeBridge.ref?("string")
# => false
"""
@spec ref?(term()) :: boolean()
defdelegate ref?(value), to: Ref
# ============================================================================
# Helpers & Macros (Existing)
# ============================================================================
@doc """
Call a helper function.
"""
defdelegate call_helper(helper, args \\ [], opts \\ []), to: Runtime
@doc """
Context manager macro for Python with statements.
"""
defmacro with_python(ref, do: block) do
quote do
require SnakeBridge.WithContext
SnakeBridge.WithContext.with_python(unquote(ref), do: unquote(block))
end
end
@doc """
Returns the SnakeBridge version.
"""
@spec version() :: String.t()
def version do
Application.spec(:snakebridge, :vsn) |> to_string()
end
end