SnakeBridge Logo

SnakeBridge

Hex.pm Docs

Type-safe Elixir bindings to Python libraries with compile-time code generation and runtime FFI.

Installation

# mix.exs
def project do
[
app: :my_app,
deps: deps(),
python_deps: python_deps(),
compilers: [:snakebridge] ++ Mix.compilers()
]
end
defp deps do
[{:snakebridge, "~> 0.13.0"}]
end
defp python_deps do
[
{:numpy, "1.26.0"},
{:pandas, "2.0.0", include: ["DataFrame", "read_csv"]},
{:json, :stdlib}
]
end

Add runtime configuration in config/runtime.exs:

import Config
SnakeBridge.ConfigHelper.configure_snakepit!()

Then fetch and compile:

mix deps.get && mix compile
mix snakebridge.setup # Creates managed venv + installs Python packages

SnakeBridge uses the managed venv at priv/snakepit/python/venv by default; no manual venv setup required.

Quick Start

Universal FFI (Any Python Module)

Call any Python function dynamically without code generation:

# Simple function calls
{:ok, 4.0} = SnakeBridge.call("math", "sqrt", [16])
{:ok, pi} = SnakeBridge.get("math", "pi")
# Create Python objects (refs)
{:ok, path} = SnakeBridge.call("pathlib", "Path", ["/tmp/file.txt"])
{:ok, exists?} = SnakeBridge.method(path, "exists", [])
{:ok, name} = SnakeBridge.attr(path, "name")
# Binary data with explicit bytes encoding
{:ok, md5} = SnakeBridge.call("hashlib", "md5", [SnakeBridge.bytes("data")])
{:ok, hex} = SnakeBridge.method(md5, "hexdigest", [])
# Bang variants raise on error
result = SnakeBridge.call!("json", "dumps", [%{key: "value"}])

Generated Wrappers (Configured Libraries)

Libraries in python_deps get Elixir modules with type hints and docs:

# Call like native Elixir
{:ok, result} = Numpy.mean([1, 2, 3, 4])
{:ok, result} = Numpy.mean([[1, 2], [3, 4]], axis: 0)
# Classes generate new/N constructors
{:ok, df} = Pandas.DataFrame.new(%{"a" => [1, 2], "b" => [3, 4]})
# Discovery APIs
Numpy.__functions__() # List all functions
Numpy.__search__("mean") # Search by name

When to Use Which

Scenario Use
Core library (NumPy, Pandas) Generated wrappers
One-off stdlib call Universal FFI
Runtime-determined module Universal FFI
IDE autocomplete needed Generated wrappers

Both approaches coexist in the same project.

Core Concepts

Python Object References

Non-serializable Python objects return as refs - handles to objects in Python memory:

{:ok, ref} = SnakeBridge.call("collections", "Counter", [["a", "b", "a"]])
SnakeBridge.ref?(ref) # true
# Call methods and access attributes
{:ok, count} = SnakeBridge.method(ref, "most_common", [2])

Session Management

Refs are scoped to sessions. By default, each Elixir process gets an auto-session:

# Explicit session scope
SnakeBridge.SessionContext.with_session(session_id: "my-session", fn ->
{:ok, ref} = SnakeBridge.call("pathlib", "Path", ["."])
# ref.session_id == "my-session"
end)
# Release session explicitly
SnakeBridge.release_auto_session()

Graceful Serialization

Containers preserve structure - only non-serializable leaves become refs:

{:ok, result} = SnakeBridge.call("module", "get_mixed_data", [])
# result = %{"name" => "test", "handler" => %SnakeBridge.Ref{...}}
# String fields accessible directly, handler is a ref

Type Encoding

Elixir Python Notes
integer int Direct
float float Direct
binary str UTF-8 strings
list list Recursive
map dict String keys direct
nil None Direct
SnakeBridge.bytes(data) bytes Explicit binary

See Type System Guide for complete mapping.

Configuration

python_deps Options

defp python_deps do
[
{:numpy, "1.26.0",
pypi_package: "numpy", # PyPI name if different
extras: ["sql"], # pip extras
include: ["array", "mean"], # Only these symbols
exclude: ["testing"], # Exclude these
submodules: true, # Include submodules
generate: :all, # Generate all symbols
streaming: ["generate"], # *_stream variants
min_signature_tier: :stub}, # Signature quality threshold
{:math, :stdlib} # Standard library module
]
end

Application Config

# config/config.exs
config :snakebridge,
generated_dir: "lib/snakebridge_generated",
generated_layout: :split, # :split (default) | :single
metadata_dir: ".snakebridge",
strict: false,
error_mode: :raw, # :raw | :translated | :raise_translated
atom_allowlist: ["ok", "error"]

Generated files mirror Python module structure (dspy/predict/__init__.ex for Dspy.Predict). See Generated Wrappers for details.

Runtime Options

Pass via __runtime__: key:

SnakeBridge.call("module", "fn", [args],
__runtime__: [
session_id: "custom",
timeout: 60_000,
affinity: :strict_queue,
pool_name: :gpu_pool
]
)

Runtime Defaults (Process-Scoped)

Set defaults once per process:

SnakeBridge.RuntimeContext.put_defaults(
pool_name: :gpu_pool,
timeout_profile: :ml_inference
)

Or scope them to a block:

SnakeBridge.with_runtime(pool_name: :gpu_pool, timeout_profile: :ml_inference) do
{:ok, result} = SnakeBridge.call("module", "fn", [args])
result
end

Helper shortcuts for common option shapes:

SnakeBridge.call("numpy", "mean", [scores], SnakeBridge.rt(pool_name: :gpu_pool))
SnakeBridge.call("numpy", "mean", [scores],
SnakeBridge.opts(py: [axis: 0], runtime: [pool_name: :gpu_pool])
)

Testing

Use the built-in ExUnit template for automatic setup/teardown:

defmodule MyApp.SomeFeatureTest do
use SnakeBridge.TestCase, pool: :dspy_pool
test "runs pipeline" do
{:ok, out} = Dspy.SomeModule.some_call("x", y: 1)
assert out != nil
end
end

Advanced Features

Streaming and Generators

# Generators implement Enumerable
{:ok, counter} = SnakeBridge.call("itertools", "count", [1])
Enum.take(counter, 5) # [1, 2, 3, 4, 5]
# Callback-based streaming
SnakeBridge.stream("llm", "generate", ["prompt"], [], fn chunk ->
IO.write(chunk)
end)

ML Error Translation

config :snakebridge, error_mode: :translated
# Python errors become structured Elixir errors
%SnakeBridge.Error.ShapeMismatchError{expected: [3, 4], actual: [4, 3]}
%SnakeBridge.Error.OutOfMemoryError{device: :cuda, requested: 2048}

Protocol Integration

Refs implement Elixir protocols:

{:ok, ref} = SnakeBridge.call("builtins", "range", [0, 5])
inspect(ref) # Uses __repr__
"Range: #{ref}" # Uses __str__
Enum.count(ref) # Uses __len__
Enum.to_list(ref) # Uses __iter__

Session Affinity

For stateful workloads, ensure refs route to the same worker:

SnakeBridge.ConfigHelper.configure_snakepit!(affinity: :strict_queue)
# Or per-call
SnakeBridge.method(ref, "compute", [], __runtime__: [affinity: :strict_fail_fast])

Modes: :hint (default), :strict_queue, :strict_fail_fast

Telemetry

:telemetry.attach("my-handler", [:snakebridge, :compile, :stop], fn _, m, _, _ ->
IO.puts("Generated #{m.symbols_generated} symbols")
end, nil)

Events: [:snakebridge, :compile, :*], [:snakebridge, :runtime, :call, :*], [:snakebridge, :session, :cleanup]

Mix Tasks

mix snakebridge.setup # Install Python packages
mix snakebridge.setup --check # Verify installation
mix snakebridge.verify # Hardware compatibility check

Script Execution

For scripts and Mix tasks:

SnakeBridge.script do
{:ok, result} = SnakeBridge.call("math", "sqrt", [16])
IO.inspect(result)
end

SnakeBridge.run_as_script/2 remains available for custom lifecycle options.

Before/After

Test setup

Before:

setup_all do
Application.ensure_all_started(:snakebridge)
SnakeBridge.ConfigHelper.configure_snakepit!()
:ok
end
setup do
SnakeBridge.Runtime.clear_auto_session()
on_exit(fn -> SnakeBridge.release_auto_session() end)
end

After:

defmodule MyApp.SomeFeatureTest do
use SnakeBridge.TestCase, pool: :demo_pool
end

Pool selection defaults

Before:

SnakeBridge.call("numpy", "mean", [scores],
__runtime__: [pool_name: :analytics_pool, timeout_profile: :ml_inference]
)

After:

SnakeBridge.with_runtime(pool_name: :analytics_pool, timeout_profile: :ml_inference) do
SnakeBridge.call("numpy", "mean", [scores])
end

Callbacks

Before:

SnakeBridge.SessionContext.with_session([session_id: "shared"], fn ->
SnakeBridge.call("module", "fn", [fn x -> x end])
end)

After:

SnakeBridge.call("module", "fn", [fn x -> x end])

Guides

Guide Description
Getting Started Installation, setup, first calls
Universal FFI Dynamic Python calls without codegen
Generated Wrappers Compile-time code generation
Type System Wire protocol and type encoding
Refs and Sessions Python object lifecycle
Session Affinity Worker routing for stateful workloads
Streaming Generators, iterators, streaming calls
Error Handling Exception translation
Telemetry Observability and metrics
Best Practices Patterns and recommendations
Coverage Reports Signature and doc coverage
Configuration Reference All configuration options

Examples

The examples/ directory contains runnable demonstrations:

./examples/run_all.sh # Run all
cd examples/basic && mix run -e Demo.run # Individual

Key examples:

See Examples Overview for the complete list.

Architecture

SnakeBridge operates in two phases:

Compile-time: Scans your code, introspects Python modules, generates typed Elixir wrappers with proper arities and documentation.

Runtime: Delegates calls to Snakepit, which manages a gRPC-connected Python process pool.

Wire Protocol

Uses JSON-over-gRPC with tagged types (__type__, __schema__) for non-JSON values. Protocol version 1 with strict compatibility checking.

Timeout Profiles

Profile Timeout Stream Timeout
:default 2 min -
:streaming 2 min 30 min
:ml_inference 10 min 30 min
:batch_job infinity infinity
SnakeBridge.call("module", "fn", [], __runtime__: [timeout_profile: :ml_inference])

Requirements

curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv

License

MIT