Current section

Files

Jump to
snakebridge lib repomix-output.xml
Raw

lib/repomix-output.xml

This file is a merged representation of the entire codebase, combined into a single document by Repomix.
The content has been processed where content has been compressed (code blocks are separated by â‹®---- delimiter).
<file_summary>
This section contains a summary of this file.
<purpose>
This file contains a packed representation of the entire repository's contents.
It is designed to be easily consumable by AI systems for analysis, code review,
or other automated processes.
</purpose>
<file_format>
The content is organized as follows:
1. This summary section
2. Repository information
3. Directory structure
4. Repository files (if enabled)
5. Multiple file entries, each consisting of:
- File path as an attribute
- Full contents of the file
</file_format>
<usage_guidelines>
- This file should be treated as read-only. Any changes should be made to the
original repository files, not this packed version.
- When processing this file, use the file path to distinguish
between different files in the repository.
- Be aware that this file may contain sensitive information. Handle it with
the same level of security as you would the original repository.
</usage_guidelines>
<notes>
- Some files may have been excluded based on .gitignore rules and Repomix's configuration
- Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
- Files matching patterns in .gitignore are excluded
- Files matching default ignore patterns are excluded
- Content has been compressed - code blocks are separated by â‹®---- delimiter
- Files are sorted by Git change count (files with more changes are at the bottom)
</notes>
</file_summary>
<directory_structure>
mix/
tasks/
compile/
snakebridge.ex
snakebridge.setup.ex
snakebridge.verify.ex
snakebridge/
docs/
markdown_converter.ex
math_renderer.ex
rst_parser.ex
error/
dtype_mismatch_error.ex
out_of_memory_error.ex
shape_mismatch_error.ex
generator/
type_mapper.ex
lock/
verifier.ex
python_runner/
system.ex
telemetry/
handlers/
logger.ex
metrics.ex
runtime_forwarder.ex
types/
decoder.ex
encoder.ex
wheel_selector/
config_strategy.ex
adapter.ex
application.ex
benchmark.ex
bytes.ex
callback_registry.ex
compile_error.ex
config.ex
defaults.ex
docs.ex
dynamic_exception.ex
dynamic.ex
environment_error.ex
error_translator.ex
error.ex
examples.ex
generator.ex
helper_generator.ex
helper_not_found_error.ex
helper_registry_error.ex
helpers.ex
introspection_error.ex
introspector.ex
invalid_ref_error.ex
ledger.ex
lock.ex
manifest.ex
module_resolver.ex
python_env.ex
python_packages_runner.ex
python_runner.ex
python_runtime_runner.ex
ref_not_found_error.ex
ref.ex
registry.ex
runtime_client.ex
runtime.ex
scan_error.ex
scanner.ex
serialization_error.ex
session_context.ex
session_manager.ex
session_mismatch_error.ex
snakepit_types.ex
stream_ref.ex
telemetry.ex
types.ex
wheel_config.ex
wheel_selector.ex
with_context.ex
snakebridge.ex
</directory_structure>
<files>
This section contains the contents of the repository's files.
<file path="mix/tasks/compile/snakebridge.ex">
defmodule Mix.Tasks.Compile.Snakebridge do
@moduledoc """
Mix compiler that runs the SnakeBridge pre-pass (scan → introspect → generate).
"""
use Mix.Task.Compiler
alias SnakeBridge.{
Config,
Generator,
HelperGenerator,
Helpers,
Introspector,
Lock,
Manifest,
ModuleResolver,
PythonEnv,
Scanner,
Telemetry
}
@reserved_words ~w(def defp defmodule class do end if unless case cond for while with fn when and or not true false nil in try catch rescue after else raise throw receive)
@impl Mix.Task.Compiler
def run(_args) do
Mix.Task.run("loadconfig")
if skip_generation?() do
{:ok, []}
else
config = Config.load()
run_with_config(config)
end
end
defp run_with_config(%{libraries: []}), do: {:ok, []}
defp run_with_config(config) do
if strict_mode?(config) do
run_strict(config)
else
PythonEnv.ensure!(config)
run_normal(config)
end
end
@impl Mix.Task.Compiler
def manifests do
Mix.Task.run("loadconfig")
config = Config.load()
[
Path.join(config.metadata_dir, "manifest.json"),
"snakebridge.lock"
]
end
defp skip_generation? do
case System.get_env("SNAKEBRIDGE_SKIP") do
nil -> false
value -> value in ["1", "true", "TRUE", "yes", "YES"]
end
end
defp update_manifest(manifest, targets) do
{updated_manifest, errors} =
targets
|> Introspector.introspect_batch()
|> Enum.reduce({manifest, []}, fn {library, result, python_module}, {acc, errs} ->
case result do
{:ok, infos} ->
{symbol_entries, class_entries} =
build_manifest_entries(library, python_module, infos)
updated =
acc
|> Manifest.put_symbols(symbol_entries)
|> Manifest.put_classes(class_entries)
{updated, errs}
{:error, reason} ->
log_introspection_error(library, python_module, reason)
emit_introspection_error_telemetry(library, python_module, reason)
{acc, [{library, python_module, reason} | errs]}
end
end)
if errors != [] do
show_introspection_summary(errors)
end
updated_manifest
end
defp log_introspection_error(library, python_module, reason) do
formatted = format_introspection_error(library, python_module, reason)
Mix.shell().info(formatted)
end
defp format_introspection_error(library, python_module, reason) do
library_name = get_library_name(library)
base = build_base_message(library_name, python_module)
format_reason(base, reason)
end
defp get_library_name(library) when is_map(library), do: library.name || library.python_name
defp get_library_name(library), do: inspect(library)
defp build_base_message(library_name, python_module) do
base = " [warning] Introspection failed for #{library_name}"
if python_module && python_module != library_name do
base <> ".#{python_module}"
else
base
end
end
defp format_reason(base, %{type: _type, message: message, suggestion: suggestion}) do
lines = [base, " Error: #{message}"]
lines = if suggestion, do: lines ++ [" Suggestion: #{suggestion}"], else: lines
Enum.join(lines, "\n")
end
defp format_reason(base, %{message: message}) do
base <> "\n Error: #{message}"
end
defp format_reason(base, message) when is_binary(message) do
base <> "\n Error: #{message}"
end
defp format_reason(base, reason) do
base <> "\n Error: #{inspect(reason)}"
end
defp emit_introspection_error_telemetry(library, python_module, reason) do
library_name =
if is_map(library), do: library.name || library.python_name, else: inspect(library)
error_type =
case reason do
%{type: type} -> type
_ -> :unknown
end
:telemetry.execute(
[:snakebridge, :introspection, :error],
%{count: 1},
%{
library: library_name,
python_module: python_module,
error_type: error_type,
reason: reason
}
)
end
defp show_introspection_summary(errors) do
count = length(errors)
message = """
================================================================================
SnakeBridge Introspection Summary
================================================================================
#{count} introspection error(s) occurred. Some symbols may be missing from
the generated bindings.
To resolve:
1. Check the errors above for details
2. Ensure Python packages are installed: mix snakebridge.setup
3. Check for import errors in your Python dependencies
4. Re-run: mix compile
The compilation will continue, but affected symbols will not be available.
================================================================================
"""
Mix.shell().info(message)
end
defp build_targets(missing, config, manifest) do
initial =
Enum.reduce(missing, %{}, fn entry, acc ->
accumulate_missing_target(entry, acc, config)
end)
with_includes =
Enum.reduce(config.libraries, initial, fn library, acc ->
includes =
library.include
|> Enum.reject(&function_or_class_present?(manifest, library, &1))
accumulate_includes(includes, library, acc)
end)
with_includes
|> Enum.map(fn {{library, python_module}, functions} ->
filtered = Enum.reject(functions, &(&1 in library.exclude))
{library, python_module, Enum.uniq(filtered)}
end)
|> Enum.reject(fn {_library, _python_module, functions} -> functions == [] end)
end
defp accumulate_missing_target({module, function, _arity}, acc, config) do
case library_for_module(config, module) do
nil ->
acc
library ->
case ModuleResolver.resolve_class_or_submodule(library, module) do
{:class, class_name, parent_module} ->
add_target(acc, library, parent_module, class_name)
{:submodule, python_module} ->
python_function =
function
|> to_string()
|> python_name_for_elixir_name()
add_target(acc, library, python_module, python_function)
{:error, _reason} ->
python_module = python_module_for_elixir(library, module)
python_function =
function
|> to_string()
|> python_name_for_elixir_name()
add_target(acc, library, python_module, python_function)
end
end
end
defp accumulate_includes([], _library, acc), do: acc
defp accumulate_includes(includes, library, acc) do
key = {library, library.python_name}
Map.update(acc, key, includes, fn funcs -> includes ++ funcs end)
end
defp add_target(acc, library, python_module, function) do
key = {library, python_module}
Map.update(acc, key, [function], fn funcs -> [function | funcs] end)
end
defp library_for_module(config, module) do
module_parts = Module.split(module)
Enum.find(config.libraries, fn library ->
library_parts = Module.split(library.module_name)
Enum.take(module_parts, length(library_parts)) == library_parts
end)
end
defp build_manifest_entries(library, python_module, infos) do
Enum.reduce(infos, {[], []}, fn info, {symbols, classes} ->
cond do
info["error"] ->
{symbols, classes}
info["type"] == "class" ->
class_entries = class_entries_for(library, python_module, info)
{symbols, class_entries ++ classes}
true ->
symbol_entry = symbol_entry_for(library, python_module, info)
{[symbol_entry | symbols], classes}
end
end)
end
defp symbol_entry_for(library, python_module, info) do
module = module_for_python(library, python_module)
python_name = info["python_name"] || info["name"]
{elixir_name, _python_name} = sanitize_function_name(python_name)
attribute? = info["type"] == "attribute"
params = info["parameters"] || []
{arity, arity_info} =
if attribute? do
{0, module_attr_arity_info()}
else
{required_arity(params), compute_arity_info(params, info)}
end
key = Manifest.symbol_key({module, String.to_atom(elixir_name), arity})
{
key,
%{
"module" => Module.split(module) |> Enum.join("."),
"function" => python_name,
"name" => elixir_name,
"python_name" => python_name,
"elixir_name" => elixir_name,
"python_module" => python_module,
"signature_available" => Map.get(info, "signature_available", true),
"parameters" => params,
"docstring" => info["docstring"] || "",
"return_annotation" => info["return_annotation"],
"return_type" => info["return_type"]
}
|> Map.merge(arity_info)
|> maybe_put_call_type(attribute?)
}
end
defp maybe_put_call_type(entry, true), do: Map.put(entry, "call_type", "module_attr")
defp maybe_put_call_type(entry, false), do: entry
defp module_attr_arity_info do
%{
"required_arity" => 0,
"minimum_arity" => 0,
"maximum_arity" => 0,
"has_var_positional" => false,
"has_var_keyword" => false,
"required_keyword_only" => [],
"optional_keyword_only" => []
}
end
defp class_entries_for(library, python_module, info) do
class_name = info["name"] || info["class"] || "Class"
class_python_module = info["python_module"] || python_module || library.python_name
class_module = class_module_for(library, class_python_module, class_name)
key = Manifest.class_key(class_module)
methods =
info["methods"]
|> List.wrap()
|> Enum.map(&class_method_entry/1)
|> Enum.reject(&is_nil/1)
[
{
key,
%{
"module" => Module.split(class_module) |> Enum.join("."),
"class" => class_name,
"python_module" => class_python_module,
"docstring" => info["docstring"] || "",
"methods" => methods,
"attributes" => info["attributes"] || []
}
}
]
end
defp class_method_entry(method) do
name = method["name"] || method[:name] || ""
case Generator.sanitize_method_name(name) do
{elixir_name, python_name} ->
params = method["parameters"] || method[:parameters] || []
arity_info = compute_arity_info(params, method)
arity_info =
if python_name == "__init__" do
arity_info
else
add_ref_arity_info(arity_info)
end
method
|> Map.put("name", python_name)
|> Map.put("python_name", python_name)
|> Map.put("elixir_name", elixir_name)
|> Map.merge(arity_info)
nil ->
nil
end
end
defp add_ref_arity_info(arity_info) do
min_arity = Map.get(arity_info, "minimum_arity", 0) + 1
required_arity = Map.get(arity_info, "required_arity", 0) + 1
max_arity = Map.get(arity_info, "maximum_arity")
max_arity =
case max_arity do
value when is_integer(value) -> value + 1
_ -> max_arity
end
arity_info
|> Map.put("minimum_arity", min_arity)
|> Map.put("required_arity", required_arity)
|> Map.put("maximum_arity", max_arity)
end
defp class_module_for(library, python_module, class_name) do
python_parts = String.split(python_module, ".")
library_parts = String.split(library.python_name, ".")
extra_parts = Enum.drop(python_parts, length(library_parts))
extra_parts = drop_class_suffix(extra_parts, class_name)
library.module_name
|> Module.split()
|> Kernel.++(Enum.map(extra_parts, &Macro.camelize/1))
|> Kernel.++([class_name])
|> Module.concat()
end
defp drop_class_suffix(parts, class_name) when is_list(parts) and is_binary(class_name) do
class_suffix = Macro.underscore(class_name)
case List.last(parts) do
^class_suffix -> Enum.drop(parts, -1)
_ -> parts
end
end
defp drop_class_suffix(parts, _class_name), do: parts
defp module_for_python(library, python_module) do
python_parts = String.split(python_module, ".")
library_parts = String.split(library.python_name, ".")
extra_parts = Enum.drop(python_parts, length(library_parts))
library.module_name
|> Module.split()
|> Kernel.++(Enum.map(extra_parts, &Macro.camelize/1))
|> Module.concat()
end
defp python_module_for_elixir(library, module) do
module_parts = Module.split(module)
library_parts = Module.split(library.module_name)
extra_parts = Enum.drop(module_parts, length(library_parts))
case Enum.map(extra_parts, &Macro.underscore/1) do
[] -> library.python_name
parts -> library.python_name <> "." <> Enum.join(parts, ".")
end
end
defp function_or_class_present?(manifest, library, name) do
module_prefix = Module.split(library.module_name) |> Enum.join(".")
function_present_in_manifest?(manifest, module_prefix, name) or
class_present_in_manifest?(manifest, name)
end
defp function_present_in_manifest?(manifest, module_prefix, name) do
manifest
|> Map.get("symbols", %{})
|> Map.values()
|> Enum.any?(&symbol_matches?(&1, module_prefix, name))
end
defp symbol_matches?(info, module_prefix, name) do
module = info["module"] || ""
python_name = info["python_name"] || info["function"] || info["name"]
elixir_name = info["elixir_name"] || info["name"] || python_name
module == module_prefix and (python_name == name or elixir_name == name)
end
defp class_present_in_manifest?(manifest, name) do
manifest
|> Map.get("classes", %{})
|> Map.values()
|> Enum.any?(&class_matches?(&1, name))
end
defp class_matches?(info, name) do
info["class"] == name or String.ends_with?(info["module"] || "", ".#{name}")
end
defp generate_from_manifest(config, manifest) do
Enum.each(config.libraries, fn library ->
functions = functions_for_library(manifest, library)
classes = classes_for_library(manifest, library)
Generator.generate_library(library, functions, classes, config)
end)
end
defp functions_for_library(manifest, library) do
Map.get(manifest, "symbols", %{})
|> Map.values()
|> Enum.filter(fn info ->
python_module = info["python_module"] || ""
String.starts_with?(python_module, library.python_name)
end)
end
defp classes_for_library(manifest, library) do
Map.get(manifest, "classes", %{})
|> Map.values()
|> Enum.filter(fn info ->
python_module = info["python_module"] || ""
String.starts_with?(python_module, library.python_name)
end)
end
defp strict_mode?(config) do
System.get_env("SNAKEBRIDGE_STRICT") == "1" || config.strict == true
end
defp run_strict(config) do
manifest = Manifest.load(config)
detected = scanner_module().scan_project(config)
missing = Manifest.missing(manifest, detected)
if missing != [] do
formatted = format_missing(missing)
raise SnakeBridge.CompileError, """
Strict mode: #{length(missing)} symbol(s) not in manifest.
Missing:
#{formatted}
To fix:
1. Run `mix snakebridge.setup` locally
2. Run `mix compile` to generate bindings
3. Commit the updated manifest and generated files
4. Re-run CI
Set SNAKEBRIDGE_STRICT=0 to disable strict mode.
"""
end
verify_generated_files_exist!(config)
verify_symbols_present!(config, manifest)
{:ok, []}
end
defp run_normal(config) do
start_time = System.monotonic_time()
libraries = Enum.map(config.libraries, & &1.name)
Telemetry.compile_start(libraries, false)
try do
detected = scanner_module().scan_project(config)
manifest = Manifest.load(config)
missing = Manifest.missing(manifest, detected)
targets = build_targets(missing, config, manifest)
updated_manifest =
if targets != [] do
update_manifest(manifest, targets)
else
manifest
end
Manifest.save(config, updated_manifest)
generate_from_manifest(config, updated_manifest)
generate_helper_wrappers(config)
SnakeBridge.Registry.save()
Lock.update(config)
symbol_count = count_symbols(updated_manifest)
file_count = length(config.libraries)
Telemetry.compile_stop(start_time, symbol_count, file_count, libraries, :normal)
{:ok, []}
rescue
e ->
Telemetry.compile_exception(start_time, e, __STACKTRACE__)
reraise e, __STACKTRACE__
end
end
@spec verify_generated_files_exist!(Config.t()) :: :ok
def verify_generated_files_exist!(config) do
Enum.each(config.libraries, fn library ->
path = Path.join(config.generated_dir, "#{library.python_name}.ex")
unless File.exists?(path) do
raise SnakeBridge.CompileError, """
Strict mode: Generated file missing: #{path}
Run `mix compile` locally and commit the generated files.
"""
end
end)
:ok
end
@spec verify_symbols_present!(Config.t(), map()) :: :ok
def verify_symbols_present!(config, manifest) do
Enum.each(config.libraries, fn library ->
path = Path.join(config.generated_dir, "#{library.python_name}.ex")
content = read_generated_file!(path)
defs = parse_definitions!(content, path)
missing_functions = missing_functions_for_library(manifest, library, defs)
classes_for_library = classes_for_library(manifest, library)
missing_classes = missing_classes_for_library(classes_for_library, defs)
missing_class_members = missing_class_members_for_library(classes_for_library, defs)
maybe_raise_missing!(path, missing_functions, missing_classes, missing_class_members)
end)
:ok
end
defp missing_functions_for_library(manifest, library, defs) do
manifest
|> functions_for_library(library)
|> Enum.reject(fn info ->
function_defined?(defs, info["module"], info["name"])
end)
end
defp missing_classes_for_library(classes_for_library, defs) do
Enum.reject(classes_for_library, fn info ->
module = info["module"]
Map.has_key?(defs, module)
end)
end
defp missing_class_members_for_library(classes_for_library, defs) do
classes_for_library
|> Enum.filter(fn info -> Map.has_key?(defs, info["module"]) end)
|> Enum.flat_map(&missing_members_for_class(&1, defs))
end
defp missing_members_for_class(info, defs) do
module = info["module"]
method_names = missing_method_names(info["methods"] || [], defs, module)
attr_names = missing_attr_names(info["attributes"] || [], defs, module)
Enum.map(method_names ++ attr_names, fn name ->
{module, name}
end)
end
defp missing_method_names(methods, defs, module) do
methods
|> Enum.map(&method_expected_name/1)
|> Enum.reject(fn name ->
name == "" or function_defined?(defs, module, name)
end)
end
defp missing_attr_names(attrs, defs, module) do
attrs
|> Enum.map(&to_string/1)
|> Enum.reject(fn name ->
name == "" or function_defined?(defs, module, name)
end)
end
defp maybe_raise_missing!(path, missing_functions, missing_classes, missing_class_members) do
if missing_functions != [] or missing_classes != [] or missing_class_members != [] do
raise SnakeBridge.CompileError, """
Strict mode: Generated file #{path} is missing expected bindings.
#{missing_functions_message(missing_functions)}\
#{missing_classes_message(missing_classes)}\
#{missing_class_members_message(missing_class_members)}
Run `mix compile` locally to regenerate and commit the updated files.
"""
end
end
defp required_arity(params) do
params
|> Enum.filter(fn param ->
param_kind(param) in ["POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"]
end)
|> Enum.reject(&param_default?/1)
|> length()
end
defp compute_arity_info(params, info) do
required_positional = required_arity(params)
optional_positional = params |> Enum.filter(&optional_positional?/1) |> length()
has_var_positional = Enum.any?(params, &varargs?/1)
has_var_keyword = Enum.any?(params, &kwargs?/1)
required_kw_only =
params
|> Enum.filter(&keyword_only_required?/1)
|> Enum.map(& &1["name"])
optional_kw_only =
params
|> Enum.filter(&keyword_only_optional?/1)
|> Enum.map(& &1["name"])
signature_available = Map.get(info, "signature_available", true)
variadic_fallback = params == [] and signature_available == false
max_arity =
cond do
variadic_fallback -> variadic_max_arity() + 1
has_var_positional -> :unbounded
optional_positional > 0 -> required_positional + 2
true -> required_positional + 1
end
%{
"required_arity" => required_positional,
"minimum_arity" => required_positional,
"maximum_arity" => max_arity,
"has_var_positional" => has_var_positional,
"has_var_keyword" => has_var_keyword,
"required_keyword_only" => required_kw_only,
"optional_keyword_only" => optional_kw_only
}
end
defp optional_positional?(param) do
param_kind(param) in ["POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"] and param_default?(param)
end
defp varargs?(param), do: param_kind(param) == "VAR_POSITIONAL"
defp kwargs?(param), do: param_kind(param) == "VAR_KEYWORD"
defp keyword_only_required?(param) do
param_kind(param) == "KEYWORD_ONLY" and not param_default?(param)
end
defp keyword_only_optional?(param) do
param_kind(param) == "KEYWORD_ONLY" and param_default?(param)
end
defp param_kind(%{"kind" => kind}) when is_binary(kind), do: String.upcase(kind)
defp param_kind(%{kind: kind}) when is_binary(kind), do: String.upcase(kind)
defp param_kind(%{kind: kind}), do: kind
defp param_kind(_), do: nil
defp param_default?(%{"default" => _}), do: true
defp param_default?(%{default: _}), do: true
defp param_default?(_), do: false
defp variadic_max_arity do
Application.get_env(:snakebridge, :variadic_max_arity, 8)
end
defp sanitize_function_name(python_name) when is_binary(python_name) do
elixir_name =
python_name
|> Macro.underscore()
|> String.replace(~r/[^a-z0-9_?!]/, "_")
|> ensure_valid_identifier()
elixir_name =
if elixir_name in @reserved_words do
"py_#{elixir_name}"
else
elixir_name
end
{elixir_name, python_name}
end
defp ensure_valid_identifier(""), do: "_"
defp ensure_valid_identifier(name) do
if String.match?(name, ~r/^[a-z_][a-z0-9_?!]*$/) do
name
else
"_" <> name
end
end
defp python_name_for_elixir_name(elixir_name) when is_binary(elixir_name) do
case String.split(elixir_name, "py_", parts: 2) do
["", rest] when rest in @reserved_words -> rest
_ -> elixir_name
end
end
defp format_missing(missing) do
missing
|> Enum.sort()
|> Enum.map_join("\n", fn {mod, fun, arity} ->
module = Module.split(mod) |> Enum.join(".")
" - #{module}.#{fun}/#{arity}"
end)
end
defp scanner_module do
Application.get_env(:snakebridge, :scanner, Scanner)
end
defp read_generated_file!(path) do
case File.read(path) do
{:ok, content} ->
content
{:error, reason} ->
raise SnakeBridge.CompileError, """
Strict mode: Cannot read generated file #{path}: #{inspect(reason)}
"""
end
end
defp parse_definitions!(content, path) do
case Code.string_to_quoted(content, file: path) do
{:ok, ast} ->
collect_definitions(ast)
{:error, {line, error, token}} ->
raise SnakeBridge.CompileError, """
Strict mode: Cannot parse generated file #{path}: #{error} #{inspect(token)} on line #{line}
"""
end
end
defp collect_definitions(ast) do
initial = %{stack: [], defs: %{}}
{_, acc} =
Macro.traverse(ast, initial, &collect_pre/2, &collect_post/2)
acc.defs
end
defp collect_pre({:defmodule, _, [{:__aliases__, _, parts}, _]} = node, acc) do
segments = module_segments(parts, acc.stack)
module_name = Enum.join(segments, ".")
acc =
acc
|> Map.update!(:stack, &[segments | &1])
|> Map.update(:defs, %{}, &Map.put_new(&1, module_name, MapSet.new()))
{node, acc}
end
defp collect_pre({:def, _, [head | _]} = node, acc) do
{node, track_def(acc, head)}
end
defp collect_pre(node, acc), do: {node, acc}
defp track_def(acc, head) do
case {def_name(head), List.first(acc.stack)} do
{nil, _} ->
acc
{_, nil} ->
acc
{name, current_module} ->
module_name = Enum.join(current_module, ".")
Map.update(acc, :defs, %{}, fn defs ->
Map.update(defs, module_name, MapSet.new([name]), &MapSet.put(&1, name))
end)
end
end
defp collect_post({:defmodule, _, _} = node, acc) do
{_popped, rest} = List.pop_at(acc.stack, 0)
{node, %{acc | stack: rest}}
end
defp collect_post(node, acc), do: {node, acc}
defp module_segments(parts, []) do
Enum.map(parts, &Atom.to_string/1)
end
defp module_segments(parts, [parent | _]) do
parent ++ Enum.map(parts, &Atom.to_string/1)
end
defp def_name({:when, _, [inner | _]}), do: def_name(inner)
defp def_name({name, _, _}) when is_atom(name), do: Atom.to_string(name)
defp def_name(_), do: nil
defp function_defined?(defs, module, name) when is_binary(module) and is_binary(name) do
case Map.get(defs, module) do
nil -> false
set -> MapSet.member?(set, name)
end
end
defp method_expected_name(%{"elixir_name" => name}) when is_binary(name), do: name
defp method_expected_name(%{elixir_name: name}) when is_binary(name), do: name
defp method_expected_name(%{"name" => name}) when is_binary(name) do
case Generator.sanitize_method_name(name) do
{elixir_name, _} -> elixir_name
_ -> ""
end
end
defp method_expected_name(%{name: name}) when is_binary(name) do
case Generator.sanitize_method_name(name) do
{elixir_name, _} -> elixir_name
_ -> ""
end
end
defp method_expected_name(_), do: ""
defp missing_functions_message([]), do: ""
defp missing_functions_message(missing) do
formatted =
missing
|> Enum.map_join("\n", fn info ->
module = info["module"] || "Unknown"
name = info["name"] || "unknown"
" - #{module}.#{name}"
end)
"""
Missing functions:
#{formatted}
"""
end
defp missing_classes_message([]), do: ""
defp missing_classes_message(missing) do
formatted =
missing
|> Enum.map_join("\n", fn info ->
info["module"] || "Unknown"
end)
"""
Missing classes:
#{formatted}
"""
end
defp missing_class_members_message([]), do: ""
defp missing_class_members_message(missing) do
formatted =
missing
|> Enum.map_join("\n", fn {module, name} ->
" - #{module}.#{name}"
end)
"""
Missing class members:
#{formatted}
"""
end
defp count_symbols(manifest) do
symbols = Map.get(manifest, "symbols", %{}) |> map_size()
classes = Map.get(manifest, "classes", %{}) |> map_size()
symbols + classes
end
defp generate_helper_wrappers(config) do
if Helpers.enabled?(config) do
case Helpers.discover(config) do
{:ok, helpers} ->
HelperGenerator.generate_helpers(helpers, config)
{:error, %SnakeBridge.HelperRegistryError{} = error} ->
Mix.shell().error(Exception.message(error))
:ok
{:error, reason} ->
Mix.shell().error("Helper registry failed: #{inspect(reason)}")
:ok
end
else
:ok
end
end
end
</file>
<file path="mix/tasks/snakebridge.setup.ex">
defmodule Mix.Tasks.Snakebridge.Setup do
@shortdoc "Provision Python environment for SnakeBridge"
@moduledoc """
Provisions the Python environment for SnakeBridge introspection.
## Usage
mix snakebridge.setup
## Options
--upgrade Upgrade packages to latest matching versions
--verbose Show detailed output
--check Only check, don't install (exit 1 if missing)
"""
use Mix.Task
def run(args) do
{opts, _, _} =
OptionParser.parse(args,
switches: [upgrade: :boolean, verbose: :boolean, check: :boolean]
)
Mix.Task.run("loadpaths")
config = SnakeBridge.Config.load()
requirements = SnakeBridge.PythonEnv.derive_requirements(config.libraries)
if requirements == [] do
Mix.shell().info("No Python packages required (all stdlib)")
:ok
else
if opts[:check] do
run_check(requirements)
else
ensure_python_runtime!()
run_install(requirements, opts)
end
end
end
defp run_check(requirements) do
case python_packages_module().check_installed(requirements, python_packages_opts([])) do
{:ok, :all_installed} ->
Mix.shell().info("All packages installed")
{:ok, {:missing, missing}} ->
Mix.raise("Missing packages: #{inspect(missing)}")
end
end
defp run_install(requirements, opts) do
Mix.shell().info("Installing Python packages...")
install_opts = [
upgrade: opts[:upgrade] || false,
quiet: !opts[:verbose]
]
python_packages_module().ensure!({:list, requirements}, python_packages_opts(install_opts))
Mix.shell().info("Done. #{length(requirements)} package(s) ready.")
end
defp ensure_python_runtime! do
python_config = Application.get_env(:snakepit, :python, [])
if Keyword.get(python_config, :managed, false) do
python_runtime_module().install_managed(SnakeBridge.PythonRuntimeRunner, [])
end
:ok
end
defp python_packages_module do
Application.get_env(:snakebridge, :python_packages, Snakepit.PythonPackages)
end
defp python_packages_opts(opts) do
if python_packages_module() == Snakepit.PythonPackages do
Keyword.put_new(opts, :runner, SnakeBridge.PythonPackagesRunner)
else
opts
end
end
defp python_runtime_module do
Application.get_env(:snakebridge, :python_runtime, Snakepit.PythonRuntime)
end
end
</file>
<file path="mix/tasks/snakebridge.verify.ex">
defmodule Mix.Tasks.Snakebridge.Verify do
@moduledoc """
Verifies the lock file against the current hardware environment.
This task checks that the hardware environment where the lock file was created
is compatible with the current system. It detects:
- Platform mismatches (OS, architecture)
- CUDA version differences
- Missing GPU capabilities
- CPU feature mismatches
## Usage
mix snakebridge.verify # Verify with warnings
mix snakebridge.verify --strict # Fail on any mismatch
mix snakebridge.verify --verbose # Show detailed info
## Options
- `--strict` - Treat warnings as errors and fail
- `--verbose` - Print detailed hardware information
- `--file PATH` - Use a specific lock file (default: snakebridge.lock)
## Exit Codes
- 0 - Compatible environment
- 1 - Incompatible environment (or warnings in strict mode)
## Examples
# Standard verification
$ mix snakebridge.verify
✓ Lock file compatible with current environment
# Strict mode (CI)
$ mix snakebridge.verify --strict
✗ CUDA version mismatch: lock has 12.1, current has 11.8
# Verbose output
$ mix snakebridge.verify --verbose
Current hardware:
Platform: linux-x86_64
Accelerator: cuda
CUDA version: 12.1
GPU count: 2
Lock file:
Platform: linux-x86_64
Accelerator: cuda
CUDA version: 12.1
GPU count: 2
✓ Lock file compatible
"""
use Mix.Task
alias SnakeBridge.Lock.Verifier
@shortdoc "Verify lock file compatibility with current hardware"
@switches [
strict: :boolean,
verbose: :boolean,
file: :string
]
@impl Mix.Task
def run(args) do
{opts, _remaining, _invalid} = OptionParser.parse(args, switches: @switches)
lock_file = Keyword.get(opts, :file, "snakebridge.lock")
strict? = Keyword.get(opts, :strict, false)
verbose? = Keyword.get(opts, :verbose, false)
case load_lock(lock_file) do
{:ok, lock} ->
if verbose?, do: print_verbose_info(lock)
verify_and_report(lock, strict?)
{:error, :not_found} ->
Mix.shell().error("Lock file not found: #{lock_file}")
Mix.shell().error("Run `mix compile` to generate the lock file.")
raise Mix.Error, message: "Lock file not found"
{:error, reason} ->
Mix.shell().error("Failed to load lock file: #{inspect(reason)}")
raise Mix.Error, message: "Failed to load lock file"
end
end
defp load_lock(path) do
case File.read(path) do
{:ok, content} ->
{:ok, Jason.decode!(content)}
{:error, :enoent} ->
{:error, :not_found}
{:error, reason} ->
{:error, reason}
end
end
defp verify_and_report(lock, strict?) do
case Verifier.verify(lock) do
:ok ->
Mix.shell().info("✓ Lock file compatible with current environment")
:ok
{:warning, warnings} ->
Enum.each(warnings, fn warning ->
Mix.shell().info("âš  Warning: #{warning}")
end)
if strict? do
Mix.shell().error("Strict mode: treating warnings as errors")
raise Mix.Error, message: "Lock file has compatibility warnings"
else
Mix.shell().info("✓ Lock file compatible (with warnings)")
:ok
end
{:error, errors} ->
Enum.each(errors, fn error ->
Mix.shell().error("✗ Error: #{error}")
end)
raise Mix.Error, message: "Lock file incompatible with current environment"
end
end
defp print_verbose_info(lock) do
current = hardware_module().identity()
current_caps = hardware_module().capabilities()
Mix.shell().info("Current hardware:")
Mix.shell().info(" Platform: #{current["platform"]}")
Mix.shell().info(" Accelerator: #{current["accelerator"]}")
if current_caps.cuda do
Mix.shell().info(" CUDA version: #{current_caps.cuda_version}")
end
if current_caps.mps do
Mix.shell().info(" MPS: available")
end
Mix.shell().info(" GPU count: #{current["gpu_count"]}")
Mix.shell().info(" CPU features: #{Enum.join(current["cpu_features"] || [], ", ")}")
Mix.shell().info("")
lock_env = Map.get(lock, "environment", %{})
lock_hardware = Map.get(lock_env, "hardware", %{})
lock_platform = Map.get(lock_env, "platform", %{})
Mix.shell().info("Lock file:")
Mix.shell().info(" Platform: #{lock_platform["os"]}-#{lock_platform["arch"]}")
Mix.shell().info(" Accelerator: #{lock_hardware["accelerator"]}")
if cuda_version = lock_hardware["cuda_version"] do
Mix.shell().info(" CUDA version: #{cuda_version}")
end
Mix.shell().info(" GPU count: #{lock_hardware["gpu_count"]}")
cpu_features = lock_hardware["cpu_features"] || []
Mix.shell().info(" CPU features: #{Enum.join(cpu_features, ", ")}")
Mix.shell().info("")
end
defp hardware_module do
Application.get_env(:snakebridge, :hardware_module, Snakepit.Hardware)
end
end
</file>
<file path="snakebridge/docs/markdown_converter.ex">
defmodule SnakeBridge.Docs.MarkdownConverter do
@moduledoc """
Converts parsed Python docstrings to Elixir ExDoc Markdown format.
This module transforms structured docstring data into Markdown that
is compatible with ExDoc and follows Elixir documentation conventions.
"""
alias SnakeBridge.Docs.MathRenderer
@type_map %{
"int" => "integer()",
"float" => "float()",
"str" => "String.t()",
"string" => "String.t()",
"bool" => "boolean()",
"boolean" => "boolean()",
"None" => "nil",
"NoneType" => "nil",
"list" => "list()",
"dict" => "map()",
"tuple" => "tuple()",
"set" => "MapSet.t()",
"bytes" => "binary()",
"bytearray" => "binary()",
"Any" => "term()",
"object" => "term()"
}
@exception_map %{
"ValueError" => "ArgumentError",
"TypeError" => "ArgumentError",
"KeyError" => "KeyError",
"IndexError" => "Enum.OutOfBoundsError",
"RuntimeError" => "RuntimeError",
"NotImplementedError" => "RuntimeError",
"IOError" => "File.Error",
"OSError" => "File.Error",
"FileNotFoundError" => "File.Error",
"AttributeError" => "KeyError",
"NameError" => "UndefinedFunctionError"
}
# Section builders - each returns nil if the section should be skipped
@section_builders [
:short_description,
:long_description,
:params,
:returns,
:raises,
:examples,
:notes
]
@doc """
Converts a parsed docstring structure to ExDoc Markdown format.
## Parameters
- `parsed` - A map with keys: `:short_description`, `:long_description`,
`:params`, `:returns`, `:raises`, `:examples`
## Returns
A Markdown string suitable for use in `@doc` or `@moduledoc`.
"""
@spec convert(map()) :: String.t()
def convert(parsed) when is_map(parsed) do
@section_builders
|> Enum.map(&build_section(&1, parsed))
|> Enum.reject(&is_nil/1)
|> Enum.join("\n\n")
|> MathRenderer.render()
|> String.trim()
end
defp build_section(:short_description, parsed), do: parsed[:short_description]
defp build_section(:long_description, parsed), do: parsed[:long_description]
defp build_section(:params, %{params: params}) when is_list(params) and params != [] do
format_parameters(params)
end
defp build_section(:params, _parsed), do: nil
defp build_section(:returns, %{returns: returns}) when not is_nil(returns) do
format_returns(returns)
end
defp build_section(:returns, _parsed), do: nil
defp build_section(:raises, %{raises: raises}) when is_list(raises) and raises != [] do
format_raises(raises)
end
defp build_section(:raises, _parsed), do: nil
defp build_section(:examples, %{examples: examples})
when is_list(examples) and examples != [] do
format_examples(examples)
end
defp build_section(:examples, _parsed), do: nil
defp build_section(:notes, %{notes: notes}) when not is_nil(notes) do
"## Notes\n\n#{notes}"
end
defp build_section(:notes, _parsed), do: nil
# Generic type patterns with their prefixes and converters
# Format: {prefix, prefix_length, converter_function}
@generic_type_patterns [
{"Optional[", 9, :convert_optional},
{"Union[", 6, :convert_union},
{"list[", 5, :convert_list},
{"List[", 5, :convert_list},
{"dict[", 5, :convert_dict},
{"Dict[", 5, :convert_dict},
{"tuple[", 6, :convert_tuple},
{"Tuple[", 6, :convert_tuple},
{"set[", 4, :convert_set},
{"Set[", 4, :convert_set}
]
@doc """
Converts a Python type annotation to an Elixir typespec format.
## Examples
iex> MarkdownConverter.convert_type("int")
"integer()"
iex> MarkdownConverter.convert_type("list[str]")
"list(String.t())"
"""
@spec convert_type(String.t() | nil) :: String.t()
def convert_type(nil), do: "term()"
def convert_type(""), do: "term()"
def convert_type(python_type) do
python_type = String.trim(python_type)
case Map.fetch(@type_map, python_type) do
{:ok, elixir_type} -> elixir_type
:error -> convert_generic_type(python_type)
end
end
defp convert_generic_type(python_type) do
@generic_type_patterns
|> Enum.find_value(fn {prefix, prefix_len, converter} ->
if String.starts_with?(python_type, prefix) do
inner = extract_inner_type(python_type, prefix_len)
apply_type_converter(converter, inner)
end
end)
|> Kernel.||(python_type)
end
defp extract_inner_type(type_string, prefix_length) do
String.slice(type_string, prefix_length..-2//1)
end
defp apply_type_converter(:convert_optional, inner) do
"#{convert_type(inner)} | nil"
end
defp apply_type_converter(:convert_union, inner) do
inner
|> String.split(",")
|> Enum.map_join(" | ", &(&1 |> String.trim() |> convert_type()))
end
defp apply_type_converter(:convert_list, inner) do
"list(#{convert_type(inner)})"
end
defp apply_type_converter(:convert_dict, _inner) do
"map()"
end
defp apply_type_converter(:convert_tuple, _inner) do
"tuple()"
end
defp apply_type_converter(:convert_set, inner) do
"MapSet.t(#{convert_type(inner)})"
end
@doc """
Converts a Python exception type to an Elixir exception module.
## Examples
iex> MarkdownConverter.convert_exception("ValueError")
"ArgumentError"
"""
@spec convert_exception(String.t() | nil) :: String.t()
def convert_exception(nil), do: "RuntimeError"
def convert_exception(""), do: "RuntimeError"
def convert_exception(python_exception) do
Map.get(@exception_map, python_exception, python_exception)
end
@doc """
Converts a Python doctest example to Elixir iex format.
## Examples
iex> MarkdownConverter.convert_example(">>> func(1, 2)\\n3")
" iex> func(1, 2)\\n 3"
"""
@spec convert_example(String.t()) :: String.t()
def convert_example(example) do
Enum.map_join(String.split(example, "\n"), "\n", fn line ->
cond do
String.starts_with?(String.trim(line), ">>>") ->
code = line |> String.trim() |> String.slice(3..-1//1) |> String.trim()
" iex> #{code}"
String.starts_with?(String.trim(line), "...") ->
code = line |> String.trim() |> String.slice(3..-1//1) |> String.trim()
" ...> #{code}"
String.trim(line) == "" ->
""
true ->
" #{String.trim(line)}"
end
end)
end
defp format_parameters(params) do
param_lines =
Enum.map(params, fn param ->
name = param[:name] || param.name
type_name = param[:type_name]
description = param[:description]
type_str =
if type_name do
" (type: `#{convert_type(type_name)}`)"
else
""
end
default_str =
if param[:default] do
" Defaults to `#{param.default}`."
else
""
end
desc_str = if description, do: " - #{description}", else: ""
"- `#{name}`#{desc_str}#{type_str}#{default_str}"
end)
"## Parameters\n\n#{Enum.join(param_lines, "\n")}"
end
defp format_returns(returns) do
type_str =
if returns[:type_name] do
"Returns `#{convert_type(returns.type_name)}`."
else
""
end
desc_str =
if returns[:description] do
" #{returns.description}"
else
""
end
"## Returns\n\n#{type_str}#{desc_str}"
end
defp format_raises(raises) do
raise_lines =
Enum.map(raises, fn r ->
type = convert_exception(r[:type_name] || r.type_name)
desc = r[:description] || ""
"- `#{type}` - #{desc}"
end)
"## Raises\n\n#{Enum.join(raise_lines, "\n")}"
end
defp format_examples(examples) do
formatted = Enum.map_join(examples, "\n\n", &convert_example/1)
"## Examples\n\n#{formatted}"
end
end
</file>
<file path="snakebridge/docs/math_renderer.ex">
defmodule SnakeBridge.Docs.MathRenderer do
@moduledoc """
Renders LaTeX math expressions for documentation.
Converts reStructuredText math directives to Markdown-compatible
math notation (KaTeX/MathJax style).
## Supported Formats
- Inline math: ``:math:`E = mc^2` `` → `$E = mc^2$`
- Display math: `.. math::` blocks → `$$...$$`
"""
@doc """
Renders math expressions in a docstring, converting RST math to Markdown.
## Examples
iex> MathRenderer.render("The formula is :math:`E = mc^2`.")
"The formula is $E = mc^2$."
"""
@spec render(String.t() | nil) :: String.t() | nil
def render(nil), do: nil
def render(""), do: ""
def render(text) when is_binary(text) do
text
|> render_inline_math()
|> render_display_math()
end
@doc """
Extracts all math expressions from text.
Returns a list of math expression strings (without delimiters).
"""
@spec extract_math(String.t() | nil) :: [String.t()]
def extract_math(nil), do: []
def extract_math(""), do: []
def extract_math(text) do
inline = extract_inline_math(text)
display = extract_display_math(text)
inline ++ display
end
@doc """
Converts math expressions to KaTeX-compatible format.
KaTeX uses `$...$` for inline and `$$...$$` for display math.
"""
@spec to_katex(String.t() | nil) :: String.t() | nil
def to_katex(nil), do: nil
def to_katex(""), do: ""
def to_katex(text) do
# KaTeX format is the same as our render output
render(text)
end
# Render inline math: :math:`expr` → $expr$
defp render_inline_math(text) do
Regex.replace(
~r/:math:`([^`]+)`/,
text,
"$\\1$"
)
end
# Render display math blocks
defp render_display_math(text) do
# Pattern for RST math blocks:
# .. math::
#
# expression
Regex.replace(
~r/\.\.\s*math::\s*\n\s*\n((?:\s+.+\n?)+)/,
text,
fn _, content ->
expr =
content
|> String.trim()
|> String.replace(~r/^\s+/m, "")
"\n$$\n#{expr}\n$$\n"
end
)
end
# Extract inline math expressions
defp extract_inline_math(text) do
Regex.scan(~r/:math:`([^`]+)`/, text)
|> Enum.map(fn [_, expr] -> expr end)
end
# Extract display math expressions
defp extract_display_math(text) do
Regex.scan(
~r/\.\.\s*math::\s*\n\s*\n((?:\s+.+\n?)+)/,
text
)
|> Enum.map(fn [_, content] ->
content
|> String.trim()
|> String.replace(~r/^\s+/m, "")
end)
end
end
</file>
<file path="snakebridge/docs/rst_parser.ex">
defmodule SnakeBridge.Docs.RstParser do
@moduledoc """
Parses Python docstrings in various formats (Google, NumPy, Sphinx, Epytext).
This module detects the docstring format and extracts structured information
including parameters, return values, exceptions, and examples.
## Supported Formats
- **Google style**: Uses `Args:`, `Returns:`, `Raises:` sections
- **NumPy style**: Uses underlined section headers (`Parameters\n----------`)
- **Sphinx/reST style**: Uses `:param:`, `:type:`, `:returns:` directives
- **Epytext style**: Uses `@param`, `@type`, `@return` tags
"""
@type parsed_doc :: %{
short_description: String.t() | nil,
long_description: String.t() | nil,
params: [param()],
returns: returns() | nil,
raises: [raises()],
examples: [String.t()],
notes: String.t() | nil,
style: atom()
}
@type param :: %{
name: String.t(),
type_name: String.t() | nil,
description: String.t() | nil,
optional: boolean(),
default: String.t() | nil
}
@type returns :: %{
type_name: String.t() | nil,
description: String.t() | nil
}
@type raises :: %{
type_name: String.t(),
description: String.t() | nil
}
@doc """
Parses a Python docstring and returns structured data.
"""
@spec parse(String.t() | nil) :: parsed_doc()
def parse(nil), do: empty_result()
def parse(""), do: empty_result()
def parse(docstring) when is_binary(docstring) do
style = detect_style(docstring)
lines = String.split(docstring, "\n")
{short_desc, rest} = extract_short_description(lines)
{long_desc, sections} = extract_long_description(rest)
%{
short_description: short_desc,
long_description: long_desc,
params: extract_params(sections, style),
returns: extract_returns(sections, style),
raises: extract_raises(sections, style),
examples: extract_examples(sections, style),
notes: extract_notes(sections, style),
style: style
}
end
@doc """
Detects the docstring style based on content patterns.
"""
@spec detect_style(String.t() | nil) :: atom()
def detect_style(nil), do: :unknown
def detect_style(""), do: :unknown
def detect_style(docstring) do
cond do
epytext_style?(docstring) -> :epytext
sphinx_style?(docstring) -> :sphinx
numpy_style?(docstring) -> :numpy
google_style?(docstring) -> :google
true -> :unknown
end
end
defp epytext_style?(docstring) do
docstring =~ ~r/@param\s/ or docstring =~ ~r/@type\s/
end
defp sphinx_style?(docstring) do
docstring =~ ~r/:param\s+\w+:/ or docstring =~ ~r/:returns:/
end
defp numpy_style?(docstring) do
docstring =~ ~r/Parameters\n-+/ or docstring =~ ~r/Returns\n-+/
end
defp google_style?(docstring) do
docstring =~ ~r/\n\s*Args:\s*\n/ or
docstring =~ ~r/\n\s*Arguments:\s*\n/ or
docstring =~ ~r/\n\s*Returns:\s*\n/ or
docstring =~ ~r/\n\s*Raises:\s*\n/ or
docstring =~ ~r/\n\s*Example:\s*\n/ or
docstring =~ ~r/\n\s*Examples:\s*\n/
end
defp empty_result do
%{
short_description: nil,
long_description: nil,
params: [],
returns: nil,
raises: [],
examples: [],
notes: nil,
style: :unknown
}
end
defp extract_short_description([]), do: {nil, []}
defp extract_short_description(lines) do
# Skip leading empty lines
lines = Enum.drop_while(lines, &(String.trim(&1) == ""))
case lines do
[] ->
{nil, []}
[first | rest] ->
short = String.trim(first)
if short == "" do
{nil, rest}
else
{short, rest}
end
end
end
defp extract_long_description(lines) do
# Skip empty lines after short description
lines = Enum.drop_while(lines, &(String.trim(&1) == ""))
# Find where sections start
section_start = find_section_start(lines)
case section_start do
nil ->
# No sections, all is long description
long_desc =
lines
|> Enum.join("\n")
|> String.trim()
{(long_desc == "" && nil) || long_desc, []}
index ->
{desc_lines, section_lines} = Enum.split(lines, index)
long_desc =
desc_lines
|> Enum.join("\n")
|> String.trim()
{(long_desc == "" && nil) || long_desc, section_lines}
end
end
defp find_section_start(lines) do
Enum.find_index(lines, fn line ->
trimmed = String.trim(line)
# Google style sections
# NumPy style sections (check next line for dashes)
# Sphinx style
# Epytext style
trimmed in [
"Args:",
"Arguments:",
"Returns:",
"Yields:",
"Raises:",
"Example:",
"Examples:",
"Note:",
"Notes:",
"Warning:",
"Warnings:"
] or
trimmed in [
"Parameters",
"Returns",
"Yields",
"Raises",
"Examples",
"Notes",
"Warnings",
"See Also",
"References"
] or
String.starts_with?(trimmed, ":param ") or
String.starts_with?(trimmed, ":returns:") or
String.starts_with?(trimmed, "@param ") or
String.starts_with?(trimmed, "@return")
end)
end
defp extract_params(sections, :google), do: extract_google_params(sections)
defp extract_params(sections, :numpy), do: extract_numpy_params(sections)
defp extract_params(sections, :sphinx), do: extract_sphinx_params(sections)
defp extract_params(sections, :epytext), do: extract_epytext_params(sections)
defp extract_params(_sections, _style), do: []
defp extract_google_params(lines) do
lines
|> extract_section(["Args:", "Arguments:"])
|> parse_google_items()
|> Enum.map(&parse_google_param/1)
end
defp extract_numpy_params(lines) do
lines
|> extract_numpy_section("Parameters")
|> parse_numpy_items()
|> Enum.map(&parse_numpy_param/1)
end
defp extract_sphinx_params(lines) do
lines
|> Enum.filter(&String.starts_with?(String.trim(&1), ":param "))
|> Enum.map(&parse_sphinx_param/1)
end
defp extract_epytext_params(lines) do
lines
|> Enum.filter(&String.starts_with?(String.trim(&1), "@param "))
|> Enum.map(&parse_epytext_param/1)
end
defp parse_google_param({name_type, description}) do
{name, type_name, optional, default} = parse_param_name_type(name_type)
%{
name: name,
type_name: type_name,
description: description,
optional: optional,
default: default
}
end
defp parse_numpy_param({name_type, description}) do
[name_part | type_parts] = String.split(name_type, " : ", parts: 2)
name = String.trim(name_part)
type_info = if type_parts != [], do: hd(type_parts), else: nil
{type_name, optional} = parse_numpy_type(type_info)
%{
name: name,
type_name: type_name,
description: description,
optional: optional,
default: nil
}
end
defp parse_sphinx_param(line) do
case Regex.run(~r/:param\s+(\w+):\s*(.*)/, String.trim(line)) do
[_, name, desc] ->
%{name: name, type_name: nil, description: desc, optional: false, default: nil}
_ ->
%{name: "", type_name: nil, description: "", optional: false, default: nil}
end
end
defp parse_epytext_param(line) do
case Regex.run(~r/@param\s+(\w+):\s*(.*)/, String.trim(line)) do
[_, name, desc] ->
%{name: name, type_name: nil, description: desc, optional: false, default: nil}
_ ->
%{name: "", type_name: nil, description: "", optional: false, default: nil}
end
end
defp parse_param_name_type(name_type) do
# Pattern: "name (type, optional): description" or "name (type): description"
case Regex.run(~r/^(\w+)\s*\(([^)]+)\)/, name_type) do
[_, name, type_info] ->
optional = String.contains?(type_info, "optional")
type_name = type_info |> String.replace(~r/,?\s*optional/, "") |> String.trim()
default =
case Regex.run(~r/Defaults? to [`']?([^`']+)[`']?/, name_type) do
[_, val] -> val
_ -> nil
end
{name, type_name, optional, default}
_ ->
# Just name, no type
name = name_type |> String.split() |> List.first() || ""
{name, nil, false, nil}
end
end
defp parse_numpy_type(nil), do: {nil, false}
defp parse_numpy_type(type_info) do
optional = String.contains?(type_info, "optional")
type_name = type_info |> String.replace(~r/,?\s*optional/, "") |> String.trim()
{type_name, optional}
end
defp extract_returns(sections, :google), do: extract_google_returns(sections)
defp extract_returns(sections, :numpy), do: extract_numpy_returns(sections)
defp extract_returns(sections, :sphinx), do: extract_sphinx_returns(sections)
defp extract_returns(sections, :epytext), do: extract_epytext_returns(sections)
defp extract_returns(_sections, _style), do: nil
defp extract_google_returns(lines) do
case extract_section(lines, ["Returns:", "Yields:"]) |> parse_google_items() do
[] ->
nil
[{type_desc, description} | _] ->
%{type_name: String.trim(type_desc), description: description}
end
end
defp extract_numpy_returns(lines) do
case extract_numpy_section(lines, "Returns") |> parse_numpy_items() do
[] ->
nil
[{type_name, description} | _] ->
%{type_name: type_name, description: description}
end
end
defp extract_sphinx_returns(lines) do
case Enum.find(lines, &String.contains?(&1, ":returns:")) do
nil ->
nil
line ->
case Regex.run(~r/:returns:\s*(.*)/, line) do
[_, desc] -> %{type_name: nil, description: desc}
_ -> nil
end
end
end
defp extract_epytext_returns(lines) do
case Enum.find(lines, &String.contains?(&1, "@return")) do
nil ->
nil
line ->
case Regex.run(~r/@return\s*:?\s*(.*)/, line) do
[_, desc] -> %{type_name: nil, description: desc}
_ -> nil
end
end
end
defp extract_raises(sections, :google), do: extract_google_raises(sections)
defp extract_raises(sections, :numpy), do: extract_numpy_raises(sections)
defp extract_raises(sections, :sphinx), do: extract_sphinx_raises(sections)
defp extract_raises(sections, :epytext), do: extract_epytext_raises(sections)
defp extract_raises(_sections, _style), do: []
defp extract_google_raises(lines) do
lines
|> extract_section(["Raises:"])
|> parse_google_items()
|> Enum.map(fn {type_name, description} ->
%{type_name: String.trim(type_name), description: description}
end)
end
defp extract_numpy_raises(lines) do
lines
|> extract_numpy_section("Raises")
|> parse_numpy_items()
|> Enum.map(fn {type_name, description} ->
%{type_name: type_name, description: description}
end)
end
defp extract_sphinx_raises(lines) do
lines
|> Enum.filter(&String.contains?(&1, ":raises"))
|> Enum.map(fn line ->
case Regex.run(~r/:raises?\s+(\w+):\s*(.*)/, line) do
[_, type, desc] -> %{type_name: type, description: desc}
_ -> %{type_name: "Error", description: ""}
end
end)
end
defp extract_epytext_raises(lines) do
lines
|> Enum.filter(&String.contains?(&1, "@raise"))
|> Enum.map(fn line ->
case Regex.run(~r/@raise\s+(\w+):\s*(.*)/, line) do
[_, type, desc] -> %{type_name: type, description: desc}
_ -> %{type_name: "Error", description: ""}
end
end)
end
defp extract_examples(sections, style) do
section_content =
case style do
:google -> extract_section(sections, ["Example:", "Examples:"])
:numpy -> extract_numpy_section(sections, "Examples")
_ -> []
end
section_content
|> Enum.join("\n")
|> String.trim()
|> case do
"" -> []
content -> [content]
end
end
defp extract_notes(sections, style) do
section_content =
case style do
:google -> extract_section(sections, ["Note:", "Notes:"])
:numpy -> extract_numpy_section(sections, "Notes")
_ -> []
end
section_content
|> Enum.join("\n")
|> String.trim()
|> case do
"" -> nil
content -> content
end
end
defp extract_section(lines, headers) do
start_idx =
Enum.find_index(lines, fn line ->
String.trim(line) in headers
end)
case start_idx do
nil -> []
idx -> extract_section_content(lines, idx)
end
end
defp extract_section_content(lines, idx) do
lines
|> Enum.drop(idx + 1)
|> Enum.take_while(&line_in_section?/1)
end
defp line_in_section?(line) do
trimmed = String.trim(line)
cond do
trimmed == "" -> true
section_header?(trimmed) -> false
indented_line?(line) -> true
true -> false
end
end
defp indented_line?(line) do
String.starts_with?(line, " ") or String.starts_with?(line, "\t")
end
defp extract_numpy_section(lines, header) do
start_idx =
Enum.find_index(lines, fn line ->
String.trim(line) == header
end)
case start_idx do
nil -> []
idx -> extract_numpy_section_content(lines, idx)
end
end
defp extract_numpy_section_content(lines, idx) do
lines
|> Enum.drop(idx + 2)
|> Enum.with_index(idx + 2)
|> Enum.take_while(fn {line, line_idx} ->
line_in_numpy_section?(lines, line, line_idx)
end)
|> Enum.map(fn {line, _idx} -> line end)
end
defp line_in_numpy_section?(lines, line, line_idx) do
trimmed = String.trim(line)
cond do
trimmed == "" -> true
numpy_section_header?(lines, line_idx) -> false
true -> true
end
end
defp section_header?(line) do
line in [
"Args:",
"Arguments:",
"Returns:",
"Yields:",
"Raises:",
"Example:",
"Examples:",
"Note:",
"Notes:",
"Warning:",
"Warnings:"
]
end
defp numpy_section_header?(lines, idx) when is_integer(idx) and idx >= 0 do
case Enum.at(lines, idx + 1) do
nil -> false
next_line -> String.trim(next_line) =~ ~r/^-+$/
end
end
defp numpy_section_header?(_lines, _idx), do: false
defp parse_google_items(lines) do
lines
|> Enum.chunk_while(nil, &google_chunk_reducer/2, &chunk_finalizer/1)
|> Enum.reject(&is_nil/1)
|> Enum.map(&split_google_item/1)
end
defp google_chunk_reducer(line, acc) do
trimmed = String.trim(line)
cond do
trimmed == "" ->
emit_chunk_on_empty(acc)
google_item_header?(trimmed) ->
emit_and_start_new_chunk(acc, trimmed)
acc != nil ->
{:cont, acc <> " " <> trimmed}
true ->
{:cont, nil}
end
end
defp google_item_header?(trimmed) do
String.match?(trimmed, ~r/^\w+(\s*\([^)]*\))?:/)
end
defp split_google_item(item) do
case String.split(item, ":", parts: 2) do
[name_type, desc] -> {String.trim(name_type), String.trim(desc)}
[name_type] -> {String.trim(name_type), ""}
end
end
defp parse_numpy_items(lines) do
lines
|> Enum.chunk_while(nil, &numpy_chunk_reducer/2, &chunk_finalizer/1)
|> Enum.reject(&is_nil/1)
|> Enum.map(&split_numpy_item/1)
end
defp numpy_chunk_reducer(line, acc) do
trimmed = String.trim(line)
cond do
trimmed == "" ->
emit_chunk_on_empty(acc)
numpy_item_header?(line) ->
emit_and_start_new_chunk(acc, line)
acc != nil ->
{:cont, acc <> " " <> trimmed}
true ->
{:cont, nil}
end
end
defp numpy_item_header?(line) do
String.contains?(line, " : ") or not String.starts_with?(line, " ")
end
defp split_numpy_item(item) do
case String.split(item, "\n", parts: 2) do
[first, rest] ->
{String.trim(first), String.trim(rest)}
[first] ->
split_numpy_single_line(first)
end
end
defp split_numpy_single_line(line) do
case String.split(line, " : ", parts: 2) do
[name, desc] -> {String.trim(name), String.trim(desc)}
[name] -> {String.trim(name), ""}
end
end
# Shared chunk helpers for both Google and NumPy parsers
defp emit_chunk_on_empty(nil), do: {:cont, nil}
defp emit_chunk_on_empty(acc), do: {:cont, acc, nil}
defp emit_and_start_new_chunk(nil, new_value), do: {:cont, new_value}
defp emit_and_start_new_chunk(acc, new_value), do: {:cont, acc, new_value}
defp chunk_finalizer(nil), do: {:cont, []}
defp chunk_finalizer(acc), do: {:cont, acc, []}
end
</file>
<file path="snakebridge/error/dtype_mismatch_error.ex">
defmodule SnakeBridge.Error.DtypeMismatchError do
@moduledoc """
Error for tensor dtype incompatibilities.
Provides information about expected vs actual dtypes and
suggestions for converting between types.
## Examples
iex> error = %SnakeBridge.Error.DtypeMismatchError{
...> expected: :float32,
...> got: :float64,
...> operation: :matmul,
...> message: "Expected float32 but got float64"
...> }
iex> Exception.message(error)
"Dtype mismatch in matmul..."
"""
@type dtype :: :float16 | :float32 | :float64 | :int32 | :int64 | :bool | atom()
@type t :: %__MODULE__{
expected: dtype(),
got: dtype(),
operation: atom() | nil,
message: String.t(),
suggestion: String.t(),
python_traceback: String.t() | nil
}
defexception [
:expected,
:got,
:operation,
:python_traceback,
message: "Dtype mismatch",
suggestion: "Convert tensor to the expected dtype"
]
@impl Exception
def message(%__MODULE__{} = error) do
op_str = if error.operation, do: " in #{error.operation}", else: ""
"""
Dtype mismatch#{op_str}
Expected: #{format_dtype(error.expected)}
Got: #{format_dtype(error.got)}
#{error.message}
Suggestion: #{error.suggestion}
"""
|> String.trim()
end
@doc """
Creates a DtypeMismatchError error with conversion suggestion.
"""
@spec new(dtype(), dtype(), keyword()) :: t()
def new(expected, got, opts \\ []) do
suggestion =
Keyword.get(opts, :suggestion) ||
generate_suggestion(expected, got)
%__MODULE__{
expected: expected,
got: got,
operation: Keyword.get(opts, :operation),
message: Keyword.get(opts, :message, "Types do not match"),
suggestion: suggestion,
python_traceback: Keyword.get(opts, :python_traceback)
}
end
@doc """
Generates a suggestion for converting between dtypes.
"""
@spec generate_suggestion(dtype(), dtype()) :: String.t()
def generate_suggestion(expected, got) do
_from = format_dtype(got)
to = format_dtype(expected)
cond do
precision_loss?(got, expected) ->
"Convert with tensor.to(torch.#{to}) - note: this may lose precision"
requires_explicit?(got, expected) ->
"Convert with tensor.to(torch.#{to})"
true ->
"Use tensor.to(torch.#{to}) or tensor.type(torch.#{expected_torch_type(expected)})"
end
end
defp format_dtype(dtype) when is_atom(dtype) do
dtype
|> Atom.to_string()
|> String.replace("_", "")
end
defp format_dtype(dtype), do: inspect(dtype)
defp expected_torch_type(:float16), do: "HalfTensor"
defp expected_torch_type(:float32), do: "FloatTensor"
defp expected_torch_type(:float64), do: "DoubleTensor"
defp expected_torch_type(:int32), do: "IntTensor"
defp expected_torch_type(:int64), do: "LongTensor"
defp expected_torch_type(:bool), do: "BoolTensor"
defp expected_torch_type(other), do: Atom.to_string(other)
# Detect if conversion loses precision
defp precision_loss?(from, to) do
precision_rank(from) > precision_rank(to)
end
defp precision_rank(:float64), do: 3
defp precision_rank(:float32), do: 2
defp precision_rank(:float16), do: 1
defp precision_rank(:int64), do: 2
defp precision_rank(:int32), do: 1
defp precision_rank(_), do: 0
# Detect if explicit conversion is required (e.g., float to int)
defp requires_explicit?(from, to) do
float_type?(from) != float_type?(to)
end
defp float_type?(dtype) when dtype in [:float16, :float32, :float64], do: true
defp float_type?(_), do: false
end
</file>
<file path="snakebridge/error/out_of_memory_error.ex">
defmodule SnakeBridge.Error.OutOfMemoryError do
@moduledoc """
GPU out-of-memory error with recovery suggestions.
Provides detailed information about memory failures including
device info, memory stats, and actionable suggestions.
## Examples
iex> error = %SnakeBridge.Error.OutOfMemoryError{
...> device: {:cuda, 0},
...> requested_mb: 8192,
...> available_mb: 2048,
...> message: "CUDA out of memory"
...> }
iex> Exception.message(error)
"GPU Out of Memory on CUDA:0..."
"""
@type device :: :cpu | {:cuda, non_neg_integer()} | :mps | atom()
@type t :: %__MODULE__{
device: device(),
requested_mb: non_neg_integer() | nil,
available_mb: non_neg_integer() | nil,
total_mb: non_neg_integer() | nil,
message: String.t(),
suggestions: [String.t()],
python_traceback: String.t() | nil
}
defexception [
:device,
:requested_mb,
:available_mb,
:total_mb,
:python_traceback,
message: "Out of memory",
suggestions: []
]
@impl Exception
def message(%__MODULE__{} = error) do
parts = ["GPU Out of Memory on #{format_device(error.device)}"]
parts =
if error.requested_mb || error.available_mb || error.total_mb do
mem_info = [
"Memory Info:",
" Requested: #{error.requested_mb || "unknown"} MB",
" Available: #{error.available_mb || "unknown"} MB",
" Total: #{error.total_mb || "unknown"} MB"
]
parts ++ [""] ++ mem_info
else
parts
end
suggestions =
(error.suggestions ++ default_suggestions(error.device))
|> Enum.uniq()
|> Enum.with_index(1)
|> Enum.map(fn {s, i} -> " #{i}. #{s}" end)
parts = parts ++ ["", "Suggestions:"] ++ suggestions
parts
|> Enum.join("\n")
|> String.trim()
end
@doc """
Creates an OutOfMemoryError error with default suggestions.
"""
@spec new(device(), keyword()) :: t()
def new(device, opts \\ []) do
%__MODULE__{
device: device,
requested_mb: Keyword.get(opts, :requested_mb),
available_mb: Keyword.get(opts, :available_mb),
total_mb: Keyword.get(opts, :total_mb),
message: Keyword.get(opts, :message, "Out of memory on #{format_device(device)}"),
suggestions: Keyword.get(opts, :suggestions, []),
python_traceback: Keyword.get(opts, :python_traceback)
}
end
defp format_device(:cpu), do: "CPU"
defp format_device(:mps), do: "Apple MPS"
defp format_device({:cuda, id}), do: "CUDA:#{id}"
defp format_device(other), do: inspect(other)
defp default_suggestions(device) do
base = [
"Reduce batch size",
"Use gradient checkpointing",
"Enable mixed precision training",
"Clear cached memory"
]
case device do
{:cuda, _} -> base ++ ["Move some operations to CPU"]
:mps -> base ++ ["Move some operations to CPU"]
_ -> base
end
end
end
</file>
<file path="snakebridge/error/shape_mismatch_error.ex">
defmodule SnakeBridge.Error.ShapeMismatchError do
@moduledoc """
Error for tensor shape incompatibilities.
This error provides detailed information about shape mismatches including
the operation that failed, the shapes involved, and actionable suggestions.
## Examples
iex> error = %SnakeBridge.Error.ShapeMismatchError{
...> operation: :matmul,
...> shape_a: [3, 4],
...> shape_b: [2, 5],
...> message: "Cannot multiply matrices with incompatible shapes",
...> suggestion: "A has 4 columns but B has 2 rows. Transpose B."
...> }
iex> Exception.message(error)
"Shape mismatch in matmul..."
"""
@type t :: %__MODULE__{
operation: atom(),
shape_a: [non_neg_integer()] | nil,
shape_b: [non_neg_integer()] | nil,
expected: String.t() | nil,
got: String.t() | nil,
message: String.t(),
suggestion: String.t(),
python_traceback: String.t() | nil
}
defexception [
:operation,
:shape_a,
:shape_b,
:expected,
:got,
:python_traceback,
message: "Shape mismatch",
suggestion: "Check tensor shapes"
]
@impl Exception
def message(%__MODULE__{} = error) do
parts = ["Shape mismatch in #{error.operation}"]
parts =
if error.shape_a do
parts ++ [" Shape A: #{inspect(error.shape_a)}"]
else
parts
end
parts =
if error.shape_b do
parts ++ [" Shape B: #{inspect(error.shape_b)}"]
else
parts
end
parts =
if error.expected do
parts ++ [" Expected: #{error.expected}"]
else
parts
end
parts =
if error.got do
parts ++ [" Got: #{error.got}"]
else
parts
end
parts = parts ++ ["", error.message, "", "Suggestion: #{error.suggestion}"]
parts
|> Enum.join("\n")
|> String.trim()
end
@doc """
Creates a ShapeMismatchError error from context.
"""
@spec new(atom(), keyword()) :: t()
def new(operation, opts \\ []) do
shape_a = Keyword.get(opts, :shape_a)
shape_b = Keyword.get(opts, :shape_b)
suggestion =
Keyword.get(opts, :suggestion) ||
generate_suggestion(operation, shape_a, shape_b)
%__MODULE__{
operation: operation,
shape_a: shape_a,
shape_b: shape_b,
expected: Keyword.get(opts, :expected),
got: Keyword.get(opts, :got),
message: Keyword.get(opts, :message, "Shapes are incompatible for #{operation}"),
suggestion: suggestion,
python_traceback: Keyword.get(opts, :python_traceback)
}
end
@doc """
Generates a suggestion based on the operation and shapes.
"""
@spec generate_suggestion(atom(), [non_neg_integer()] | nil, [non_neg_integer()] | nil) ::
String.t()
def generate_suggestion(:matmul, shape_a, shape_b)
when is_list(shape_a) and is_list(shape_b) do
a_cols = List.last(shape_a)
b_rows = List.first(shape_b)
if a_cols != b_rows do
"For matrix multiplication, A columns (#{a_cols}) must equal B rows (#{b_rows}). " <>
"Try: tensor.transpose(dim0, dim1) if B needs transposing"
else
"Check that tensor shapes are compatible for matrix multiplication."
end
end
def generate_suggestion(_operation, shape_a, shape_b)
when is_list(shape_a) and is_list(shape_b) do
if length(shape_a) != length(shape_b) do
"Tensors have different number of dimensions (#{length(shape_a)} vs #{length(shape_b)}). " <>
"Use unsqueeze/squeeze to adjust dimensions."
else
mismatched = find_mismatched_dim(shape_a, shape_b)
if mismatched do
"Shapes differ at dimension #{mismatched}. Check broadcasting rules or reshape tensors."
else
"Verify tensor shapes are compatible for this operation."
end
end
end
def generate_suggestion(_operation, _shape_a, _shape_b) do
"Verify tensor shapes are compatible for this operation."
end
defp find_mismatched_dim(shape_a, shape_b) do
shape_a
|> Enum.zip(shape_b)
|> Enum.with_index()
|> Enum.find_value(fn
{{x, y}, idx} when x != y and x != 1 and y != 1 -> idx
_ -> nil
end)
end
end
</file>
<file path="snakebridge/generator/type_mapper.ex">
defmodule SnakeBridge.Generator.TypeMapper do
@moduledoc """
Maps Python type annotations to Elixir typespec AST.
This module converts Python type dictionaries (as produced by the introspection
script) into Elixir typespec AST using `quote`. The AST can then be used to
generate `@spec` declarations in generated modules.
## Type Mappings
| Python Type | Elixir Type |
|------------|-------------|
| `int` | `integer()` |
| `float` | `float()` |
| `str` | `String.t()` |
| `bool` | `boolean()` |
| `bytes` | `binary()` |
| `None` | `nil` |
| `list[T]` | `list(T)` |
| `dict[K, V]` | `map(K, V)` |
| `tuple[T1, T2, ...]` | `{T1, T2, ...}` |
| `set[T]` | `MapSet.t(T)` |
| `Optional[T]` | `T \\| nil` |
| `Union[T1, T2, ...]` | `T1 \\| T2 \\| ...` |
| `ClassName` | `ClassName.t()` |
| `Any` | `any()` |
## Examples
iex> TypeMapper.to_spec(%{"type" => "int"})
{:integer, [], []}
iex> TypeMapper.to_spec(%{"type" => "list", "element_type" => %{"type" => "str"}})
{{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
|> Macro.to_string()
"list(String.t())"
"""
@doc """
Converts a Python type dictionary to an Elixir typespec AST.
## Parameters
* `python_type` - A map representing a Python type annotation
## Returns
An AST node (quoted expression) representing the equivalent Elixir typespec.
## Examples
iex> python_type = %{"type" => "int"}
iex> ast = SnakeBridge.Generator.TypeMapper.to_spec(python_type)
iex> Macro.to_string(ast)
"integer()"
iex> python_type = %{"type" => "list", "element_type" => %{"type" => "int"}}
iex> ast = SnakeBridge.Generator.TypeMapper.to_spec(python_type)
iex> Macro.to_string(ast)
"list(integer())"
"""
@spec to_spec(map() | nil) :: Macro.t()
def to_spec(nil), do: quote(do: term())
def to_spec(%{} = python_type) when map_size(python_type) == 0, do: quote(do: term())
# Primitive types
def to_spec(%{"type" => "int"}), do: quote(do: integer())
def to_spec(%{"type" => "float"}), do: quote(do: float())
def to_spec(%{"type" => "str"}), do: quote(do: String.t())
def to_spec(%{"type" => "string"}), do: quote(do: String.t())
def to_spec(%{"type" => "bool"}), do: quote(do: boolean())
def to_spec(%{"type" => "boolean"}), do: quote(do: boolean())
def to_spec(%{"type" => "bytes"}), do: quote(do: binary())
def to_spec(%{"type" => "bytearray"}), do: quote(do: binary())
def to_spec(%{"type" => "none"}), do: quote(do: nil)
def to_spec(%{"type" => "any"}), do: quote(do: term())
# Complex types - delegate to specialized mappers
def to_spec(%{"type" => "list"} = python_type), do: map_list_type(python_type)
def to_spec(%{"type" => "dict"} = python_type), do: map_dict_type(python_type)
def to_spec(%{"type" => "tuple"} = python_type), do: map_tuple_type(python_type)
def to_spec(%{"type" => "set"} = python_type), do: map_set_type(python_type)
def to_spec(%{"type" => "frozenset"} = python_type), do: map_set_type(python_type)
def to_spec(%{"type" => "optional"} = python_type), do: map_optional_type(python_type)
def to_spec(%{"type" => "union"} = python_type), do: map_union_type(python_type)
def to_spec(%{"type" => "class"} = python_type), do: map_class_type(python_type)
# ML-specific types (NumPy, PyTorch, Pandas)
def to_spec(%{"type" => "numpy.ndarray"}), do: quote(do: Numpy.NDArray.t())
def to_spec(%{"type" => "numpy.dtype"}), do: quote(do: Numpy.DType.t())
def to_spec(%{"type" => "torch.tensor"}), do: quote(do: Torch.Tensor.t())
def to_spec(%{"type" => "torch.Tensor"}), do: quote(do: Torch.Tensor.t())
def to_spec(%{"type" => "torch.dtype"}), do: quote(do: Torch.DType.t())
def to_spec(%{"type" => "pandas.dataframe"}), do: quote(do: Pandas.DataFrame.t())
def to_spec(%{"type" => "pandas.DataFrame"}), do: quote(do: Pandas.DataFrame.t())
def to_spec(%{"type" => "pandas.series"}), do: quote(do: Pandas.Series.t())
def to_spec(%{"type" => "pandas.Series"}), do: quote(do: Pandas.Series.t())
# Python integer alias
def to_spec(%{"type" => "integer"}), do: quote(do: integer())
# Fallback for unknown types
def to_spec(%{"type" => _}), do: quote(do: term())
def to_spec(_), do: quote(do: term())
# Private Functions
@spec map_list_type(map()) :: Macro.t()
defp map_list_type(%{"element_type" => element_type}) do
element_spec = to_spec(element_type)
quote(do: list(unquote(element_spec)))
end
defp map_list_type(_), do: quote(do: list(term()))
@spec map_dict_type(map()) :: Macro.t()
defp map_dict_type(%{"key_type" => key_type, "value_type" => value_type}) do
key_spec = to_spec(key_type)
value_spec = to_spec(value_type)
quote(do: %{optional(unquote(key_spec)) => unquote(value_spec)})
end
defp map_dict_type(_), do: quote(do: %{optional(term()) => term()})
@spec map_tuple_type(map()) :: Macro.t()
defp map_tuple_type(%{"element_types" => element_types}) when is_list(element_types) do
case element_types do
[] ->
{:{}, [], []}
types ->
element_specs = Enum.map(types, &to_spec/1)
{:{}, [], element_specs}
end
end
defp map_tuple_type(_), do: quote(do: tuple())
@spec map_set_type(map()) :: Macro.t()
defp map_set_type(%{"element_type" => element_type}) do
element_spec = to_spec(element_type)
quote(do: MapSet.t(unquote(element_spec)))
end
defp map_set_type(_), do: quote(do: MapSet.t(term()))
@spec map_optional_type(map()) :: Macro.t()
defp map_optional_type(%{"inner_type" => inner_type}) do
inner_spec = to_spec(inner_type)
quote(do: unquote(inner_spec) | nil)
end
defp map_optional_type(_), do: quote(do: term() | nil)
@spec map_union_type(map()) :: Macro.t()
defp map_union_type(%{"types" => types}) when is_list(types) and length(types) > 0 do
type_specs = Enum.map(types, &to_spec/1)
# Build union type using |
Enum.reduce(type_specs, fn spec, acc ->
quote(do: unquote(acc) | unquote(spec))
end)
end
defp map_union_type(_), do: quote(do: term())
@spec map_class_type(map()) :: Macro.t()
defp map_class_type(%{"name" => name, "module" => module})
when is_binary(name) and is_binary(module) do
module_parts =
module
|> String.split(".")
|> Enum.map(&Macro.camelize/1)
|> Kernel.++([name])
module_alias = {:__aliases__, [alias: false], Enum.map(module_parts, &String.to_atom/1)}
{{:., [], [module_alias, :t]}, [], []}
end
defp map_class_type(%{"name" => name}) when is_binary(name) do
module_alias = {:__aliases__, [alias: false], [String.to_atom(name)]}
{{:., [], [module_alias, :t]}, [], []}
end
defp map_class_type(_), do: quote(do: term())
end
</file>
<file path="snakebridge/lock/verifier.ex">
defmodule SnakeBridge.Lock.Verifier do
@moduledoc """
Verifies hardware and environment compatibility between the lock file and current system.
The verifier compares the hardware identity in the lock file against the current
system's capabilities to detect potential compatibility issues before runtime.
## Verification Levels
- `:ok` - Full compatibility, no issues detected
- `{:warning, warnings}` - Minor differences that may work but could cause issues
- `{:error, errors}` - Incompatible environment that will likely fail
## Examples
# Verify lock file compatibility
lock = SnakeBridge.Lock.load()
case SnakeBridge.Lock.Verifier.verify(lock) do
:ok ->
IO.puts("Environment compatible")
{:warning, warnings} ->
Enum.each(warnings, &IO.warn/1)
{:error, errors} ->
raise SnakeBridge.EnvironmentError, message: Enum.join(errors, "; ")
end
"""
@type verification_result :: :ok | {:warning, [String.t()]} | {:error, [String.t()]}
@doc """
Verifies the lock file against the current hardware environment.
Returns `:ok` if compatible, `{:warning, warnings}` for minor issues,
or `{:error, errors}` for critical incompatibilities.
"""
@spec verify(map() | nil) :: verification_result()
def verify(nil) do
start_time = System.monotonic_time()
SnakeBridge.Telemetry.lock_verify(start_time, :error, ["No lock file provided"])
{:error, ["No lock file provided"]}
end
def verify(lock) when is_map(lock) do
start_time = System.monotonic_time()
errors = []
warnings = []
current = hardware_module().identity()
lock_env = Map.get(lock, "environment", %{})
lock_hardware = Map.get(lock_env, "hardware", %{})
lock_platform = Map.get(lock_env, "platform", %{})
compatibility = Map.get(lock, "compatibility", %{})
# Check platform
{platform_errors, platform_warnings} = verify_platform(lock_platform, current)
errors = errors ++ platform_errors
warnings = warnings ++ platform_warnings
# Check accelerator/CUDA
{accel_errors, accel_warnings} = verify_accelerator(lock_hardware, current, compatibility)
errors = errors ++ accel_errors
warnings = warnings ++ accel_warnings
# Check CPU features if required
{feature_errors, feature_warnings} = verify_cpu_features(lock_hardware, current)
errors = errors ++ feature_errors
warnings = warnings ++ feature_warnings
result =
cond do
errors != [] -> {:error, errors}
warnings != [] -> {:warning, warnings}
true -> :ok
end
case result do
:ok -> SnakeBridge.Telemetry.lock_verify(start_time, :ok, [])
{:warning, warn} -> SnakeBridge.Telemetry.lock_verify(start_time, :warning, warn)
{:error, errs} -> SnakeBridge.Telemetry.lock_verify(start_time, :error, errs)
end
result
end
@doc """
Verifies the lock file and raises on error.
Returns `:ok` on success or raises `SnakeBridge.EnvironmentError`.
Warnings are logged but do not raise.
"""
@spec verify!(map() | nil) :: :ok
def verify!(lock) do
case verify(lock) do
:ok ->
:ok
{:warning, warnings} ->
Enum.each(warnings, &Mix.shell().info("Warning: #{&1}"))
:ok
{:error, errors} ->
raise SnakeBridge.EnvironmentError,
message: "Lock file incompatible: #{Enum.join(errors, "; ")}"
end
end
# Private functions
defp verify_platform(lock_platform, _current) when map_size(lock_platform) == 0 do
{[], []}
end
defp verify_platform(lock_platform, current) do
errors = []
warnings = []
current_platform = current["platform"] || ""
[current_os, current_arch] = parse_platform(current_platform)
lock_os = Map.get(lock_platform, "os", "")
lock_arch = Map.get(lock_platform, "arch", "")
errors =
if lock_os != "" and lock_os != current_os do
["Platform mismatch: lock requires #{lock_os}, current is #{current_os}" | errors]
else
errors
end
errors =
if lock_arch != "" and lock_arch != current_arch do
["Architecture mismatch: lock requires #{lock_arch}, current is #{current_arch}" | errors]
else
errors
end
{errors, warnings}
end
defp verify_accelerator(lock_hardware, _current, _compatibility)
when map_size(lock_hardware) == 0 do
{[], []}
end
defp verify_accelerator(lock_hardware, current, _compatibility) do
lock_accelerator = Map.get(lock_hardware, "accelerator", "cpu")
current_accelerator = current["accelerator"] || "cpu"
current_caps = hardware_module().capabilities()
lock_cuda_version = Map.get(lock_hardware, "cuda_version")
current_cuda_version = current_caps.cuda_version
check_accelerator_compatibility(
lock_accelerator,
current_accelerator,
current_caps,
lock_cuda_version,
current_cuda_version
)
end
defp check_accelerator_compatibility(
"cuda",
_current_accel,
%{cuda: false},
_lock_ver,
_cur_ver
) do
{["Lock requires CUDA but no CUDA available on current system"], []}
end
defp check_accelerator_compatibility("mps", _current_accel, %{mps: false}, _lock_ver, _cur_ver) do
{["Lock requires MPS but MPS not available (requires macOS with Apple Silicon)"], []}
end
defp check_accelerator_compatibility("cuda", _current_accel, %{cuda: true}, lock_ver, cur_ver) do
check_cuda_version_compatibility(lock_ver, cur_ver)
end
defp check_accelerator_compatibility("cuda", "cpu", _caps, _lock_ver, _cur_ver) do
{[], ["Lock was built with CUDA, falling back to CPU"]}
end
defp check_accelerator_compatibility(_lock_accel, _current_accel, _caps, _lock_ver, _cur_ver) do
{[], []}
end
defp check_cuda_version_compatibility(lock_version, current_version) do
lock_major = major_version(lock_version)
current_major = major_version(current_version)
cond do
lock_major != current_major ->
{[], ["CUDA version mismatch: lock has #{lock_version}, current has #{current_version}"]}
lock_version != current_version ->
{[], ["CUDA version differs: lock has #{lock_version}, current has #{current_version}"]}
true ->
{[], []}
end
end
defp verify_cpu_features(lock_hardware, current) do
lock_features_list = Map.get(lock_hardware, "cpu_features", [])
current_features_list = Map.get(current, "cpu_features", [])
critical_list = ["avx512f"]
lock_features = MapSet.new(lock_features_list)
current_features = MapSet.new(current_features_list)
critical_features = MapSet.new(critical_list)
missing_critical =
lock_features
|> MapSet.intersection(critical_features)
|> MapSet.difference(current_features)
|> MapSet.to_list()
missing_optional =
lock_features
|> MapSet.difference(critical_features)
|> MapSet.difference(current_features)
|> MapSet.to_list()
errors =
if missing_critical != [] do
["Missing critical CPU features: #{Enum.join(missing_critical, ", ")}"]
else
[]
end
warnings =
if missing_optional != [] do
["Missing optional CPU features: #{Enum.join(missing_optional, ", ")}"]
else
[]
end
{errors, warnings}
end
defp parse_platform(platform_string) when is_binary(platform_string) do
case String.split(platform_string, "-", parts: 2) do
[os, arch] -> [os, arch]
[os] -> [os, "unknown"]
_ -> ["unknown", "unknown"]
end
end
defp parse_platform(_), do: ["unknown", "unknown"]
defp major_version(nil), do: nil
defp major_version(version) when is_binary(version) do
case String.split(version, ".") do
[major | _] -> major
_ -> version
end
end
defp hardware_module do
Application.get_env(:snakebridge, :hardware_module, Snakepit.Hardware)
end
end
</file>
<file path="snakebridge/python_runner/system.ex">
defmodule SnakeBridge.PythonRunner.System do
@moduledoc false
@behaviour SnakeBridge.PythonRunner
@impl SnakeBridge.PythonRunner
def run(script, args, opts \\ []) when is_binary(script) and is_list(args) do
with {:ok, python, _meta} <- Snakepit.PythonRuntime.resolve_executable() do
env = build_env(opts)
cmd_opts = Keyword.merge([stderr_to_stdout: true, env: env], Keyword.drop(opts, [:env]))
case System.cmd(python, ["-c", script | args], cmd_opts) do
{output, 0} -> {:ok, output}
{output, status} -> {:error, {:python_exit, status, output}}
end
end
end
defp build_env(opts) do
runtime_env = Snakepit.PythonRuntime.runtime_env()
extra_env =
Snakepit.PythonRuntime.config()
|> Map.get(:extra_env, %{})
|> Enum.to_list()
user_env =
opts
|> Keyword.get(:env, %{})
|> Enum.to_list()
runtime_env ++ extra_env ++ user_env
end
end
</file>
<file path="snakebridge/telemetry/handlers/logger.ex">
defmodule SnakeBridge.Telemetry.Handlers.Logger do
@moduledoc """
Logs SnakeBridge telemetry events.
This handler logs compilation events at appropriate log levels:
- Compile stop: `:info`
- Compile exception: `:error`
- Introspect/Generate: `:debug`
## Usage
# In your application startup
SnakeBridge.Telemetry.Handlers.Logger.attach()
"""
require Logger
@handler_id "snakebridge-logger"
@events [
[:snakebridge, :compile, :stop],
[:snakebridge, :compile, :exception],
[:snakebridge, :compile, :introspect, :stop],
[:snakebridge, :compile, :generate, :stop]
]
@doc """
Attaches the logger handler to telemetry events.
Returns `:ok` on success or `{:error, :already_exists}` if already attached.
"""
@spec attach() :: :ok | {:error, :already_exists}
def attach do
:telemetry.attach_many(
@handler_id,
@events,
&handle_event/4,
%{}
)
end
@doc """
Detaches the logger handler.
"""
@spec detach() :: :ok | {:error, :not_found}
def detach do
:telemetry.detach(@handler_id)
end
@doc false
def handle_event([:snakebridge, :compile, :stop], measurements, metadata, _config) do
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
libraries = metadata.details[:libraries] || []
Logger.info(
"SnakeBridge compiled #{measurements.symbols_generated} symbols " <>
"in #{duration_ms}ms (#{length(libraries)} libraries)"
)
end
def handle_event([:snakebridge, :compile, :exception], _measurements, metadata, _config) do
reason = metadata.details[:reason]
Logger.error("SnakeBridge compilation failed: #{inspect(reason)}")
end
def handle_event([:snakebridge, :compile, :introspect, :stop], measurements, metadata, _config) do
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
python_time = metadata.details[:python_time] || 0
python_ms = System.convert_time_unit(python_time, :native, :millisecond)
Logger.debug(
"Introspected #{measurements.symbols_introspected} symbols from #{metadata.library} " <>
"in #{duration_ms}ms (Python: #{python_ms}ms, cache hits: #{measurements.cache_hits})"
)
end
def handle_event([:snakebridge, :compile, :generate, :stop], measurements, metadata, _config) do
duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond)
Logger.debug(
"Generated #{measurements.functions_generated} functions for #{metadata.library} " <>
"in #{duration_ms}ms (#{measurements.bytes_written} bytes)"
)
end
end
</file>
<file path="snakebridge/telemetry/handlers/metrics.ex">
defmodule SnakeBridge.Telemetry.Handlers.Metrics do
@moduledoc """
Metric definitions for SnakeBridge telemetry.
This module provides metric definitions compatible with TelemetryMetrics
and reporters like TelemetryMetricsPrometheus.
## Usage
# In your application with TelemetryMetricsPrometheus
TelemetryMetricsPrometheus.Core.attach(
SnakeBridge.Telemetry.Handlers.Metrics.metrics()
)
## Metrics
### Compilation
- `snakebridge.compile.duration` - Distribution of compilation times
- `snakebridge.compile.symbols_generated` - Sum of symbols generated
- `snakebridge.compile.total` - Counter of compilations
### Scanning
- `snakebridge.scan.duration` - Distribution of scan times
- `snakebridge.scan.files_scanned` - Sum of files scanned
- `snakebridge.scan.symbols_found` - Sum of symbols found
### Introspection
- `snakebridge.introspect.duration` - Distribution of introspection times
- `snakebridge.introspect.symbols_introspected` - Sum of symbols introspected
- `snakebridge.introspect.cache_hits` - Sum of cache hits
### Generation
- `snakebridge.generate.duration` - Distribution of generation times
- `snakebridge.generate.bytes_written` - Sum of bytes written
### Documentation
- `snakebridge.docs.fetch.duration` - Distribution of doc fetch times
- `snakebridge.docs.fetch.total` - Counter of doc fetches
"""
@doc """
Returns a list of Telemetry.Metrics definitions.
These can be used with any TelemetryMetrics-compatible reporter.
"""
@spec metrics() :: [struct()]
def metrics do
import Telemetry.Metrics
[
# Compilation metrics
distribution("snakebridge.compile.duration",
event_name: [:snakebridge, :compile, :stop],
measurement: :duration,
unit: {:native, :millisecond},
reporter_options: [buckets: [100, 500, 1000, 5000, 10_000]]
),
sum("snakebridge.compile.symbols_generated",
event_name: [:snakebridge, :compile, :stop],
measurement: :symbols_generated
),
counter("snakebridge.compile.total",
event_name: [:snakebridge, :compile, :stop]
),
# Scan metrics
distribution("snakebridge.scan.duration",
event_name: [:snakebridge, :compile, :scan, :stop],
measurement: :duration,
unit: {:native, :millisecond}
),
sum("snakebridge.scan.files_scanned",
event_name: [:snakebridge, :compile, :scan, :stop],
measurement: :files_scanned
),
sum("snakebridge.scan.symbols_found",
event_name: [:snakebridge, :compile, :scan, :stop],
measurement: :symbols_found
),
# Introspection metrics
distribution("snakebridge.introspect.duration",
event_name: [:snakebridge, :compile, :introspect, :stop],
measurement: :duration,
tags: [:library],
unit: {:native, :millisecond}
),
sum("snakebridge.introspect.symbols_introspected",
event_name: [:snakebridge, :compile, :introspect, :stop],
measurement: :symbols_introspected,
tags: [:library]
),
sum("snakebridge.introspect.cache_hits",
event_name: [:snakebridge, :compile, :introspect, :stop],
measurement: :cache_hits,
tags: [:library]
),
# Generation metrics
distribution("snakebridge.generate.duration",
event_name: [:snakebridge, :compile, :generate, :stop],
measurement: :duration,
tags: [:library],
unit: {:native, :millisecond}
),
sum("snakebridge.generate.bytes_written",
event_name: [:snakebridge, :compile, :generate, :stop],
measurement: :bytes_written,
tags: [:library]
),
# Documentation metrics
distribution("snakebridge.docs.fetch.duration",
event_name: [:snakebridge, :docs, :fetch],
measurement: :duration,
tags: [:source],
unit: {:native, :millisecond}
),
counter("snakebridge.docs.fetch.total",
event_name: [:snakebridge, :docs, :fetch],
tags: [:source]
)
]
end
end
</file>
<file path="snakebridge/telemetry/runtime_forwarder.ex">
defmodule SnakeBridge.Telemetry.RuntimeForwarder do
@moduledoc """
Enriches Snakepit runtime telemetry with SnakeBridge context.
This module listens to Snakepit's call events and re-emits them under
the `:snakebridge` namespace with additional context like the SnakeBridge
version and library information.
## Events
Original Snakepit events:
- `[:snakepit, :python, :call, :start]`
- `[:snakepit, :python, :call, :stop]`
- `[:snakepit, :python, :call, :exception]`
Are forwarded as:
- `[:snakebridge, :runtime, :call, :start]`
- `[:snakebridge, :runtime, :call, :stop]`
- `[:snakebridge, :runtime, :call, :exception]`
With added metadata:
- `snakebridge_library` - The library name from the original event
- `snakebridge_version` - The current SnakeBridge version
## Usage
# In your application startup
SnakeBridge.Telemetry.RuntimeForwarder.attach()
"""
@handler_id "snakebridge-runtime-enricher"
@events [
[:snakepit, :python, :call, :start],
[:snakepit, :python, :call, :stop],
[:snakepit, :python, :call, :exception]
]
@doc """
Attaches the runtime forwarder to Snakepit events.
Returns `:ok` on success or `{:error, :already_exists}` if already attached.
"""
@spec attach() :: :ok | {:error, :already_exists}
def attach do
:telemetry.attach_many(
@handler_id,
@events,
&handle_event/4,
%{}
)
end
@doc """
Detaches the runtime forwarder.
"""
@spec detach() :: :ok | {:error, :not_found}
def detach do
:telemetry.detach(@handler_id)
end
@doc false
def handle_event([:snakepit, :python, :call, :start], measurements, metadata, _config) do
enriched = enrich_metadata(metadata)
:telemetry.execute(
[:snakebridge, :runtime, :call, :start],
measurements,
enriched
)
end
def handle_event([:snakepit, :python, :call, :stop], measurements, metadata, _config) do
enriched = enrich_metadata(metadata)
:telemetry.execute(
[:snakebridge, :runtime, :call, :stop],
measurements,
enriched
)
end
def handle_event([:snakepit, :python, :call, :exception], measurements, metadata, _config) do
enriched = enrich_metadata(metadata)
:telemetry.execute(
[:snakebridge, :runtime, :call, :exception],
measurements,
enriched
)
end
defp enrich_metadata(metadata) do
library =
Map.get(metadata, :library) || Map.get(metadata, :snakebridge_library) ||
Map.get(metadata, :python_module)
function = Map.get(metadata, :function) || Map.get(metadata, :name)
call_type = Map.get(metadata, :call_type) || Map.get(metadata, :type)
metadata
|> Map.put(:library, library || "unknown")
|> Map.put(:function, function || "unknown")
|> Map.put(:call_type, call_type || "unknown")
|> Map.put(:snakebridge_library, library || "unknown")
|> Map.put(:snakebridge_version, version())
end
defp version do
Application.spec(:snakebridge, :vsn) |> to_string()
end
end
</file>
<file path="snakebridge/types/decoder.ex">
defmodule SnakeBridge.Types.Decoder do
@moduledoc """
Decodes JSON-compatible data from Python into Elixir data structures.
Handles lossless decoding of tagged representations produced by the Python
side or by `SnakeBridge.Types.Encoder`. Recognizes special `__type__` markers
to reconstruct Elixir-specific types. Atom decoding is allowlist-based
(configure via `:snakebridge, :atom_allowlist`).
## Supported Tagged Types
- `{"__type__": "atom", "value": "ok"}` → `:ok` (allowlisted only)
- `{"__type__": "tuple", "elements": [...]}` → Elixir tuple
- `{"__type__": "set", "elements": [...]}` → MapSet
- `{"__type__": "frozenset", "elements": [...]}` → MapSet
- `{"__type__": "bytes", "data": "<base64>"}` → binary
- `{"__type__": "datetime", "value": "<iso8601>"}` → DateTime
- `{"__type__": "date", "value": "<iso8601>"}` → Date
- `{"__type__": "time", "value": "<iso8601>"}` → Time
- `{"__type__": "special_float", "value": "infinity"}` → `:infinity`
- `{"__type__": "special_float", "value": "neg_infinity"}` → `:neg_infinity`
- `{"__type__": "special_float", "value": "nan"}` → `:nan`
- `{"__type__": "ref", ...}` → `SnakeBridge.Ref`
- `{"__type__": "stream_ref", ...}` → `SnakeBridge.StreamRef`
## Direct JSON Types
- `null` → `nil`
- Booleans → `true`/`false`
- Numbers → integers or floats
- Strings → strings
- Arrays → lists (recursively decoded)
- Objects → maps with string keys (recursively decoded)
## Examples
iex> SnakeBridge.Types.Decoder.decode(%{"__type__" => "tuple", "elements" => [1, 2, 3]})
{1, 2, 3}
iex> SnakeBridge.Types.Decoder.decode(%{"__type__" => "set", "elements" => [1, 2, 3]})
#MapSet<[1, 2, 3]>
iex> SnakeBridge.Types.Decoder.decode(%{"a" => 1, "b" => 2})
%{"a" => 1, "b" => 2}
"""
@doc """
Decodes a JSON-compatible value into an Elixir data structure.
Recognizes and handles tagged types from the Python encoder.
## Examples
iex> decode(42)
42
iex> decode([1, 2, 3])
[1, 2, 3]
iex> decode(%{
...> "__type__" => "tuple",
...> "elements" => [%{"__type__" => "atom", "value" => "ok"}, "result"]
...> })
{:ok, "result"}
"""
@spec decode(term()) :: term()
def decode(nil), do: nil
def decode(true), do: true
def decode(false), do: false
def decode(num) when is_number(num), do: num
def decode(str) when is_binary(str), do: str
# Lists - recursively decode elements
def decode(list) when is_list(list) do
Enum.map(list, &decode/1)
end
def decode(%{"__type__" => "stream_ref"} = map) do
SnakeBridge.StreamRef.from_wire_format(map)
end
def decode(%{"__type__" => "ref"} = ref) do
required = ["id", "session_id"]
missing = Enum.filter(required, &(not Map.has_key?(ref, &1)))
if missing != [] do
raise ArgumentError, "Invalid ref: missing fields #{inspect(missing)}"
end
ref_struct = SnakeBridge.Ref.from_wire_format(ref)
maybe_register_ref(ref_struct)
ref_struct
end
# Maps with __type__ markers - decode based on type
def decode(%{"__type__" => "atom"} = map) do
value = Map.get(map, "value")
if is_binary(value) and atom_allowed?(value) do
String.to_atom(value)
else
value
end
end
def decode(%{"__type__" => "tuple"} = map) do
map
|> list_field()
|> Enum.map(&decode/1)
|> List.to_tuple()
end
def decode(%{"__type__" => "set"} = map) do
map
|> list_field()
|> Enum.map(&decode/1)
|> MapSet.new()
end
def decode(%{"__type__" => "frozenset"} = map) do
map
|> list_field()
|> Enum.map(&decode/1)
|> MapSet.new()
end
def decode(%{"__type__" => "bytes"} = map) do
data = Map.get(map, "data") || Map.get(map, "value")
if is_binary(data) do
case Base.decode64(data) do
{:ok, binary} -> binary
:error -> data
end
else
data
end
end
def decode(%{"__type__" => "datetime", "value" => value}) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, dt, _offset} -> dt
{:error, _} -> value
end
end
def decode(%{"__type__" => "date", "value" => value}) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> date
{:error, _} -> value
end
end
def decode(%{"__type__" => "time", "value" => value}) when is_binary(value) do
case Time.from_iso8601(value) do
{:ok, time} -> time
{:error, _} -> value
end
end
def decode(%{"__type__" => "special_float", "value" => "infinity"}), do: :infinity
def decode(%{"__type__" => "special_float", "value" => "neg_infinity"}), do: :neg_infinity
def decode(%{"__type__" => "special_float", "value" => "nan"}), do: :nan
def decode(%{"__type__" => "infinity"}), do: :infinity
def decode(%{"__type__" => "neg_infinity"}), do: :neg_infinity
def decode(%{"__type__" => "nan"}), do: :nan
def decode(%{"__type__" => "complex", "real" => real, "imag" => imag}) do
%{real: real, imag: imag}
end
# Tagged dict - maps with non-string keys
def decode(%{"__type__" => "dict", "pairs" => pairs}) when is_list(pairs) do
pairs
|> Enum.map(fn
[key, value] ->
{decode(key), decode(value)}
pair when is_list(pair) and length(pair) == 2 ->
[key, value] = pair
{decode(key), decode(value)}
end)
|> Map.new()
end
# Tagged dict with schema version
def decode(%{"__type__" => "dict", "__schema__" => _schema, "pairs" => pairs}) do
decode(%{"__type__" => "dict", "pairs" => pairs})
end
# Regular maps - recursively decode values
def decode(%{} = map) do
Map.new(map, fn {key, value} ->
{key, decode(value)}
end)
end
# Anything else passes through unchanged
def decode(other), do: other
defp list_field(map) do
case Map.get(map, "elements") do
nil -> Map.get(map, "value", [])
elements -> elements
end
|> List.wrap()
end
defp atom_allowed?(value) when is_binary(value) do
case atom_allowlist() do
:all -> true
allowlist -> value in allowlist
end
end
defp atom_allowed?(_), do: false
defp atom_allowlist do
case Application.get_env(:snakebridge, :atom_allowlist, ["ok", "error"]) do
:all -> :all
list -> Enum.map(List.wrap(list), &to_string/1)
end
end
defp maybe_register_ref(ref) do
session_id = Map.get(ref, "session_id") || Map.get(ref, :session_id)
if is_binary(session_id) and Process.whereis(SnakeBridge.SessionManager) do
case SnakeBridge.SessionManager.register_ref(session_id, ref) do
:ok -> :ok
{:error, _reason} -> :ok
end
end
:ok
end
end
</file>
<file path="snakebridge/types/encoder.ex">
defmodule SnakeBridge.Types.Encoder do
@moduledoc """
Encodes Elixir data structures into JSON-compatible formats for Python interop.
Handles lossless encoding of Elixir types that don't have direct JSON equivalents
using tagged representations. Tagged values include a `__schema__` marker for
the current wire schema version. Atom round-trips depend on the decoder
allowlist.
## Supported Types
### Direct JSON Types
- `nil` → `null`
- Booleans → `true`/`false`
- Integers → numbers
- Floats → numbers
- Strings (UTF-8) → strings
- Lists → arrays
- Maps with string keys → objects
### Tagged Types
- Atoms → `{"__type__": "atom", "value": "ok"}`
- Tuples → `{"__type__": "tuple", "elements": [...]}`
- MapSets → `{"__type__": "set", "elements": [...]}`
- Binaries (non-UTF-8) → `{"__type__": "bytes", "data": "<base64>"}`
- `SnakeBridge.Bytes` → `{"__type__": "bytes", "data": "<base64>"}` (always bytes)
- DateTime → `{"__type__": "datetime", "value": "<iso8601>"}`
- Date → `{"__type__": "date", "value": "<iso8601>"}`
- Time → `{"__type__": "time", "value": "<iso8601>"}`
- Special floats → `{"__type__": "special_float", "value": "infinity"|"neg_infinity"|"nan"}`
- Maps with string/atom keys → plain objects (keys converted to strings)
- Maps with non-string keys → `{"__type__": "dict", "pairs": [[key, val], ...]}`
## Unsupported Types
The following types cannot be serialized and will raise `SnakeBridge.SerializationError`:
- PIDs, ports, references
- Custom structs without explicit encoder support
## Examples
iex> SnakeBridge.Types.Encoder.encode(%{a: 1, b: 2})
%{"a" => 1, "b" => 2}
iex> SnakeBridge.Types.Encoder.encode({:ok, "result"})
%{
"__type__" => "tuple",
"__schema__" => 1,
"elements" => [%{"__type__" => "atom", "__schema__" => 1, "value" => "ok"}, "result"]
}
iex> SnakeBridge.Types.Encoder.encode(MapSet.new([1, 2, 3]))
%{"__type__" => "set", "__schema__" => 1, "elements" => [1, 2, 3]}
iex> SnakeBridge.Types.Encoder.encode(%{1 => "one", 2 => "two"})
%{"__type__" => "dict", "__schema__" => 1, "pairs" => [[1, "one"], [2, "two"]]}
"""
@doc """
Encodes an Elixir value into a JSON-compatible structure.
## Examples
iex> encode(42)
42
iex> encode(:ok)
%{"__type__" => "atom", "__schema__" => 1, "value" => "ok"}
iex> encode({1, 2, 3})
%{"__type__" => "tuple", "__schema__" => 1, "elements" => [1, 2, 3]}
## Raises
- `SnakeBridge.SerializationError` for unsupported types (PIDs, ports, refs, unknown structs)
"""
@spec encode(term()) :: term()
def encode(nil), do: nil
def encode(true), do: true
def encode(false), do: false
# Special float atoms
def encode(:infinity), do: tagged("special_float", %{"value" => "infinity"})
def encode(:neg_infinity), do: tagged("special_float", %{"value" => "neg_infinity"})
def encode(:nan), do: tagged("special_float", %{"value" => "nan"})
# Regular atoms are tagged for lossless interop
def encode(atom) when is_atom(atom) do
tagged("atom", %{"value" => Atom.to_string(atom)})
end
# Numbers
def encode(num) when is_integer(num), do: num
def encode(num) when is_float(num), do: num
# Explicit bytes wrapper - MUST come before generic binary clause
def encode(%SnakeBridge.Bytes{data: data}) when is_binary(data) do
tagged("bytes", %{"data" => Base.encode64(data)})
end
# Strings and binaries
def encode(binary) when is_binary(binary) do
if String.valid?(binary) do
binary
else
# Non-UTF-8 binary - encode as base64
tagged("bytes", %{"data" => Base.encode64(binary)})
end
end
# Lists
def encode(list) when is_list(list) do
Enum.map(list, &encode/1)
end
# Tuples
def encode(tuple) when is_tuple(tuple) do
tagged("tuple", %{"elements" => tuple |> Tuple.to_list() |> Enum.map(&encode/1)})
end
# MapSets
def encode(%MapSet{} = mapset) do
tagged("set", %{"elements" => mapset |> MapSet.to_list() |> Enum.map(&encode/1)})
end
# DateTime
def encode(%DateTime{} = dt) do
tagged("datetime", %{"value" => DateTime.to_iso8601(dt)})
end
# Date
def encode(%Date{} = date) do
tagged("date", %{"value" => Date.to_iso8601(date)})
end
# Time
def encode(%Time{} = time) do
tagged("time", %{"value" => Time.to_iso8601(time)})
end
# Snakepit PyRef - normalize to ref wire shape
def encode(%{__struct__: Snakepit.PyRef} = ref) do
ref
|> Map.from_struct()
|> normalize_pyref_map()
end
# SnakeBridge Ref - normalize to ref wire shape
def encode(%SnakeBridge.Ref{} = ref) do
SnakeBridge.Ref.to_wire_format(ref)
end
# Functions - encode as callback references
def encode(fun) when is_function(fun) do
{:ok, callback_id} = SnakeBridge.CallbackRegistry.register(fun)
arity = Function.info(fun)[:arity]
tagged("callback", %{
"ref_id" => callback_id,
"pid" => self() |> :erlang.pid_to_list() |> IO.iodata_to_binary(),
"arity" => arity
})
end
# Maps - empty map
def encode(%{} = map) when map_size(map) == 0, do: %{}
# Structs that aren't handled above - raise SerializationError
def encode(%{__struct__: _} = struct) do
raise SnakeBridge.SerializationError, value: struct
end
# Maps - check for string keys vs non-string keys
def encode(%{} = map) do
if all_string_keys?(map) do
encode_string_key_map(map)
else
encode_tagged_dict(map)
end
end
# Fallback for unsupported types - raise SerializationError
def encode(other) do
raise SnakeBridge.SerializationError, value: other
end
# Private helpers
defp tagged(type, fields) when is_map(fields) do
fields
|> Map.put("__type__", type)
|> Map.put("__schema__", SnakeBridge.Types.schema_version())
end
defp all_string_keys?(map) do
Enum.all?(map, fn {key, _value} ->
is_binary(key) or (is_atom(key) and key not in [nil, true, false])
end)
end
defp encode_string_key_map(map) do
Map.new(map, fn {key, value} ->
string_key = if is_atom(key), do: Atom.to_string(key), else: key
{string_key, encode(value)}
end)
end
defp encode_tagged_dict(map) do
pairs =
Enum.map(map, fn {key, value} ->
[encode(key), encode(value)]
end)
tagged("dict", %{"pairs" => pairs})
end
defp normalize_pyref_map(ref) do
ref_id = Map.get(ref, :id) || Map.get(ref, :ref_id)
%{}
|> Map.put("__type__", "ref")
|> Map.put("__schema__", SnakeBridge.Ref.schema_version())
|> maybe_put("id", ref_id)
|> maybe_put("session_id", Map.get(ref, :session_id))
|> maybe_put("python_module", Map.get(ref, :python_module))
|> maybe_put("library", Map.get(ref, :library))
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end
</file>
<file path="snakebridge/wheel_selector/config_strategy.ex">
defmodule SnakeBridge.WheelSelector.ConfigStrategy do
@moduledoc false
alias SnakeBridge.WheelConfig
@type wheel_info :: %{
package: String.t(),
version: String.t(),
variant: String.t() | nil,
index_url: String.t() | nil
}
@spec select_wheel(String.t(), String.t(), map()) :: wheel_info()
def select_wheel(package, version, caps) do
variant = variant_for(package, caps)
%{
package: package,
version: version,
variant: variant,
index_url: index_url_for_variant(variant)
}
end
@spec variant_for(String.t(), map()) :: String.t() | nil
def variant_for(package, caps) do
if variant_package?(package) do
variants = WheelConfig.get_variants(package)
cond do
caps.cuda and caps.cuda_version ->
pick_variant(best_cuda_variant(caps.cuda_version), variants)
caps.rocm ->
rocm_variant = WheelConfig.rocm_variant()
pick_variant(rocm_variant, variants)
true ->
pick_variant("cpu", variants)
end
else
nil
end
end
@spec available_variants(String.t()) :: [String.t()]
def available_variants(package) do
if variant_package?(package) do
WheelConfig.get_variants(package)
else
[]
end
end
@spec best_cuda_variant(String.t() | nil) :: String.t()
def best_cuda_variant(nil), do: "cpu"
def best_cuda_variant(cuda_version) do
WheelConfig.get_cuda_mapping(cuda_version) || cuda_variant_fallback(cuda_version)
end
@spec index_url_for_variant(String.t() | nil) :: String.t() | nil
def index_url_for_variant(nil), do: nil
def index_url_for_variant(variant) do
base_url =
Application.get_env(
:snakebridge,
:pytorch_index_base_url,
"https://download.pytorch.org/whl/"
)
"#{String.trim_trailing(base_url, "/")}/#{variant}"
end
defp variant_package?(package) do
package in WheelConfig.packages()
end
defp pick_variant(nil, variants) do
pick_variant("cpu", variants)
end
defp pick_variant(preferred, variants) do
cond do
preferred in variants ->
preferred
"cpu" in variants ->
"cpu"
variants == [] ->
nil
true ->
List.first(variants)
end
end
defp cuda_variant_fallback(version) do
thresholds =
Application.get_env(:snakebridge, :cuda_thresholds, [
{"cu124", 124},
{"cu121", 120},
{"cu118", 117}
])
normalized = normalize_cuda_version(version)
case Integer.parse(normalized || "") do
{value, _} -> find_matching_variant(thresholds, value)
_ -> "cpu"
end
end
defp find_matching_variant(thresholds, cuda_version) do
Enum.find_value(thresholds, "cpu", fn {variant, threshold} ->
if cuda_version >= threshold, do: variant
end)
end
defp normalize_cuda_version(version) when is_binary(version) do
version
|> String.split(".")
|> Enum.take(2)
|> Enum.join()
end
defp normalize_cuda_version(_), do: nil
end
</file>
<file path="snakebridge/adapter.ex">
defmodule SnakeBridge.Adapter do
@moduledoc """
Provides the `use SnakeBridge.Adapter` macro for generated Python adapters.
When you `use SnakeBridge.Adapter`, it imports the `__python_call__/2` function
that generated adapters use to call Python functions via Snakepit.
## Example
defmodule MyApp.Math do
use SnakeBridge.Adapter
@spec sqrt(number()) :: float()
def sqrt(x) do
__python_call__("sqrt", [x])
end
end
The adapter module tracks the Python module name and provides the runtime
bridge to execute Python functions.
"""
defmacro __using__(_opts) do
quote do
import SnakeBridge.Adapter, only: [__python_call__: 2]
# Register @python_function as an accumulating attribute for metadata
# This prevents "set but never used" warnings in generated code
Module.register_attribute(__MODULE__, :python_function, accumulate: true)
# Store the Python module name derived from the Elixir module name
@python_module __MODULE__
|> Module.split()
|> List.last()
|> Macro.underscore()
end
end
@doc """
Calls a Python function with the given arguments using SnakeBridge.Runtime.
"""
@spec __python_call__(String.t(), list()) :: {:ok, term()} | {:error, Snakepit.Error.t()}
def __python_call__(func_name, args) do
# Get the calling module to determine the Python module
{module, _func, _arity} =
Process.info(self(), :current_stacktrace)
|> elem(1)
|> Enum.find(fn {mod, _, _, _} ->
mod not in [__MODULE__, Process, :erlang]
end)
|> case do
{mod, func, arity, _} -> {mod, func, arity}
nil -> {nil, nil, nil}
end
if module do
SnakeBridge.Runtime.call(module, func_name, args)
else
{:error, Snakepit.Error.validation_error("Unable to determine calling module", %{})}
end
end
end
</file>
<file path="snakebridge/application.ex">
defmodule SnakeBridge.Application do
@moduledoc false
use Application
require Logger
@impl true
def start(_type, _args) do
# Suppress noisy logs from OpenTelemetry transitive dependencies
# These apps emit info/notice logs during startup that pollute console output
# Users can override by setting their own log levels in config
suppress_otel_transitive_logs()
children = [
SnakeBridge.SessionManager,
SnakeBridge.CallbackRegistry
]
Supervisor.start_link(children, strategy: :one_for_one, name: SnakeBridge.Supervisor)
end
defp suppress_otel_transitive_logs do
# tls_certificate_check logs "Loading X CA(s) from ..." at notice level
Logger.put_application_level(:tls_certificate_check, :warning)
# opentelemetry_exporter can log during exporter init
Logger.put_application_level(:opentelemetry_exporter, :warning)
end
end
</file>
<file path="snakebridge/benchmark.ex">
defmodule SnakeBridge.Benchmark do
@moduledoc """
Benchmark utilities for SnakeBridge performance measurement.
Provides functions for measuring execution time, collecting statistics,
and comparing performance across different configurations.
## Usage
# Single measurement
result = Benchmark.measure("my_operation", fn -> do_work() end)
# Multiple iterations with statistics
stats = Benchmark.run_iterations("my_operation", fn -> do_work() end, 10)
# Compare two runs
comparison = Benchmark.compare(baseline_stats, current_stats)
"""
@type measurement :: %{
name: String.t(),
time_us: non_neg_integer(),
value: term(),
error: String.t() | nil
}
@type stats :: %{
name: String.t(),
iterations: non_neg_integer(),
mean_us: float(),
median_us: float(),
min_us: non_neg_integer(),
max_us: non_neg_integer(),
std_dev_us: float(),
times_us: [non_neg_integer()]
}
@type comparison :: %{
speedup: float(),
improvement_percent: float(),
baseline_mean_us: float(),
current_mean_us: float()
}
@doc """
Measures the execution time of a single function call.
Returns a map with:
- `name` - The benchmark name
- `time_us` - Execution time in microseconds
- `value` - The function's return value
- `error` - Error message if the function raised
"""
@spec measure(String.t(), (-> term())) :: measurement()
def measure(name, fun) when is_function(fun, 0) do
{time_us, value} = :timer.tc(fun)
%{
name: name,
time_us: time_us,
value: value,
error: nil
}
rescue
e ->
%{
name: name,
time_us: 0,
value: nil,
error: Exception.message(e)
}
end
@doc """
Runs a function multiple times and collects statistics.
Returns a map with statistical measures:
- `mean_us` - Average time in microseconds
- `median_us` - Median time in microseconds
- `min_us` - Minimum time
- `max_us` - Maximum time
- `std_dev_us` - Standard deviation
"""
@spec run_iterations(String.t(), (-> term()), non_neg_integer()) :: stats()
def run_iterations(name, fun, iterations \\ 10) when is_function(fun, 0) do
# Warmup run
_ = fun.()
times =
Enum.map(1..iterations, fn _ ->
{time_us, _} = :timer.tc(fun)
time_us
end)
mean = Enum.sum(times) / length(times)
sorted = Enum.sort(times)
median = Enum.at(sorted, div(length(sorted), 2))
min = List.first(sorted)
max = List.last(sorted)
std_dev = calculate_std_dev(times, mean)
%{
name: name,
iterations: iterations,
mean_us: mean,
median_us: median,
min_us: min,
max_us: max,
std_dev_us: std_dev,
times_us: times
}
end
@doc """
Compares two benchmark results and calculates improvement metrics.
Returns:
- `speedup` - Ratio (> 1.0 means faster)
- `improvement_percent` - Percentage improvement (positive is faster)
"""
@spec compare(map(), map()) :: comparison()
def compare(%{mean_us: baseline}, %{mean_us: current}) do
speedup = baseline / current
improvement = (1 - current / baseline) * 100
%{
speedup: Float.round(speedup, 3),
improvement_percent: Float.round(improvement, 2),
baseline_mean_us: baseline,
current_mean_us: current
}
end
@doc """
Formats a time in microseconds to a human-readable string.
## Examples
iex> Benchmark.format_time(500)
"500 µs"
iex> Benchmark.format_time(5_000)
"5.00 ms"
iex> Benchmark.format_time(5_000_000)
"5.00 s"
"""
@spec format_time(number()) :: String.t()
def format_time(us) when us < 1_000 do
"#{round(us)} µs"
end
def format_time(us) when us < 1_000_000 do
"#{Float.round(us / 1_000, 2)} ms"
end
def format_time(us) do
"#{Float.round(us / 1_000_000, 2)} s"
end
@doc """
Formats a byte count to a human-readable string.
## Examples
iex> Benchmark.format_bytes(1024)
"1.00 KB"
iex> Benchmark.format_bytes(1_048_576)
"1.00 MB"
"""
@spec format_bytes(number()) :: String.t()
def format_bytes(bytes) when bytes < 1024 do
"#{round(bytes)} B"
end
def format_bytes(bytes) when bytes < 1024 * 1024 do
"#{Float.round(bytes / 1024, 2)} KB"
end
def format_bytes(bytes) when bytes < 1024 * 1024 * 1024 do
"#{Float.round(bytes / (1024 * 1024), 2)} MB"
end
def format_bytes(bytes) do
"#{Float.round(bytes / (1024 * 1024 * 1024), 2)} GB"
end
@doc """
Prints a summary of benchmark statistics.
"""
@spec print_stats(stats()) :: :ok
def print_stats(stats) do
IO.puts("")
IO.puts("#{stats.name}")
IO.puts(String.duplicate("-", String.length(stats.name)))
IO.puts(" Iterations: #{stats.iterations}")
IO.puts(" Mean: #{format_time(stats.mean_us)}")
IO.puts(" Median: #{format_time(stats.median_us)}")
IO.puts(" Min: #{format_time(stats.min_us)}")
IO.puts(" Max: #{format_time(stats.max_us)}")
IO.puts(" Std Dev: #{format_time(stats.std_dev_us)}")
:ok
end
@doc """
Prints a comparison between two benchmark runs.
"""
@spec print_comparison(comparison()) :: :ok
def print_comparison(comparison) do
IO.puts("")
status =
if comparison.improvement_percent >= 0 do
"FASTER"
else
"SLOWER"
end
IO.puts("Comparison: #{status}")
IO.puts(" Speedup: #{comparison.speedup}x")
IO.puts(" Change: #{comparison.improvement_percent}%")
IO.puts(" Baseline: #{format_time(comparison.baseline_mean_us)}")
IO.puts(" Current: #{format_time(comparison.current_mean_us)}")
:ok
end
defp calculate_std_dev(times, mean) do
variance =
times
|> Enum.map(fn t -> :math.pow(t - mean, 2) end)
|> Enum.sum()
|> Kernel./(length(times))
:math.sqrt(variance)
end
end
</file>
<file path="snakebridge/bytes.ex">
defmodule SnakeBridge.Bytes do
@moduledoc """
Wrapper struct for binary data that should be sent to Python as `bytes`, not `str`.
By default, SnakeBridge encodes UTF-8 valid Elixir binaries as Python strings.
Use this wrapper when you need to explicitly send data as Python bytes.
## Examples
# Hash a string as bytes
{:ok, hash} = SnakeBridge.call("hashlib", "md5", [SnakeBridge.bytes("abc")])
# Base64 encode
{:ok, encoded} = SnakeBridge.call("base64", "b64encode", [SnakeBridge.bytes("hello")])
# Binary protocol data
{:ok, _} = SnakeBridge.call("struct", "pack", [">I", 42])
## When to Use
Use `SnakeBridge.bytes/1` when calling Python functions that:
- Require `bytes` input (hashlib, cryptography, struct, etc.)
- Work with binary protocols
- Process raw byte data
## Wire Format
Encoded as:
{"__type__": "bytes", "__schema__": 1, "data": "<base64-encoded>"}
"""
@type t :: %__MODULE__{data: binary()}
defstruct [:data]
@doc """
Creates a Bytes wrapper from binary data.
## Examples
iex> SnakeBridge.Bytes.new("hello")
%SnakeBridge.Bytes{data: "hello"}
iex> SnakeBridge.Bytes.new(<<0, 1, 2, 255>>)
%SnakeBridge.Bytes{data: <<0, 1, 2, 255>>}
"""
@spec new(binary()) :: t()
def new(data) when is_binary(data) do
%__MODULE__{data: data}
end
@doc """
Returns the raw binary data from a Bytes wrapper.
## Examples
iex> bytes = SnakeBridge.Bytes.new("hello")
iex> SnakeBridge.Bytes.data(bytes)
"hello"
"""
@spec data(t()) :: binary()
def data(%__MODULE__{data: data}), do: data
end
</file>
<file path="snakebridge/callback_registry.ex">
defmodule SnakeBridge.CallbackRegistry do
@moduledoc """
Registry for Elixir callbacks passed to Python.
Manages callback lifecycle and provides invocation support.
"""
use GenServer
require Logger
alias SnakeBridge.SessionContext
alias Snakepit.Bridge.ToolRegistry
@tool_name "snakebridge.callback"
@tool_metadata %{
description: "Invoke an Elixir callback from Python",
exposed_to_python: true,
parameters: [
%{name: "callback_id", type: "string", required: true},
%{name: "args", type: "list", required: false}
]
}
# Client API
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Registers an Elixir function as a callback.
"""
@spec register(function(), pid()) :: {:ok, String.t()}
def register(fun, owner_pid \\ self()) when is_function(fun) do
ensure_tool_registered(current_session_id())
GenServer.call(__MODULE__, {:register, fun, owner_pid})
end
@doc """
Invokes a registered callback with arguments.
"""
@spec invoke(String.t(), list()) :: {:ok, term()} | {:error, term()}
def invoke(callback_id, args) do
GenServer.call(__MODULE__, {:invoke, callback_id, args}, :infinity)
end
@doc """
Unregisters a callback.
"""
@spec unregister(String.t()) :: :ok
def unregister(callback_id) do
GenServer.cast(__MODULE__, {:unregister, callback_id})
end
@doc """
Ensures the callback tool is registered for the session.
"""
@spec ensure_tool_registered(String.t() | nil) :: :ok
def ensure_tool_registered(session_id) do
session_id = session_id || "default"
if Code.ensure_loaded?(ToolRegistry) and
Process.whereis(ToolRegistry) do
case ToolRegistry.register_elixir_tool(
session_id,
@tool_name,
&__MODULE__.handle_tool/1,
@tool_metadata
) do
:ok -> :ok
{:error, {:duplicate_tool, _name}} -> :ok
{:error, _reason} -> :ok
end
end
:ok
end
@doc """
Handles callback tool invocations from Python.
"""
@spec handle_tool(map()) :: map()
def handle_tool(params) when is_map(params) do
callback_id = Map.get(params, "callback_id")
args = params |> Map.get("args", []) |> List.wrap()
decoded_args = Enum.map(args, &SnakeBridge.Types.decode/1)
case invoke(callback_id, decoded_args) do
{:ok, result} ->
SnakeBridge.Types.encode(result)
{:error, reason} ->
%{"__type__" => "callback_error", "reason" => inspect(reason)}
end
end
# Server Implementation
@impl true
def init(_opts) do
state = %{
callbacks: %{},
monitors: %{}
}
{:ok, state}
end
@impl true
def handle_call({:register, fun, owner_pid}, _from, state) do
callback_id = generate_callback_id()
monitor_ref = Process.monitor(owner_pid)
arity = Function.info(fun)[:arity]
callback_data = %{
fun: fun,
owner_pid: owner_pid,
monitor_ref: monitor_ref,
arity: arity
}
new_state = %{
state
| callbacks: Map.put(state.callbacks, callback_id, callback_data),
monitors: Map.put(state.monitors, monitor_ref, callback_id)
}
{:reply, {:ok, callback_id}, new_state}
end
@impl true
def handle_call({:invoke, callback_id, args}, _from, state) do
case Map.get(state.callbacks, callback_id) do
nil ->
{:reply, {:error, :callback_not_found}, state}
%{fun: fun, arity: arity} = _data ->
if length(args) != arity do
{:reply, {:error, {:arity_mismatch, arity}}, state}
else
try do
result = apply(fun, args)
{:reply, {:ok, result}, state}
rescue
exception ->
{:reply, {:error, {:exception, exception}}, state}
end
end
end
end
@impl true
def handle_cast({:unregister, callback_id}, state) do
new_state = do_unregister(state, callback_id)
{:noreply, new_state}
end
@impl true
def handle_info({:DOWN, monitor_ref, :process, _pid, _reason}, state) do
case Map.get(state.monitors, monitor_ref) do
nil ->
{:noreply, state}
callback_id ->
Logger.debug("Callback owner died, unregistering: #{callback_id}")
new_state = do_unregister(state, callback_id)
{:noreply, new_state}
end
end
defp do_unregister(state, callback_id) do
case Map.get(state.callbacks, callback_id) do
nil ->
state
%{monitor_ref: monitor_ref} ->
Process.demonitor(monitor_ref, [:flush])
%{
state
| callbacks: Map.delete(state.callbacks, callback_id),
monitors: Map.delete(state.monitors, monitor_ref)
}
end
end
defp generate_callback_id do
"cb_#{:erlang.unique_integer([:positive])}_#{System.system_time(:millisecond)}"
end
defp current_session_id do
case SessionContext.current() do
%{session_id: session_id} when is_binary(session_id) -> session_id
_ -> "default"
end
end
end
</file>
<file path="snakebridge/compile_error.ex">
defmodule SnakeBridge.CompileError do
@moduledoc """
Error raised when strict mode detects missing bindings.
"""
defexception [:message]
end
</file>
<file path="snakebridge/config.ex">
defmodule SnakeBridge.Config do
@moduledoc """
Compile-time configuration for SnakeBridge.
"""
defstruct [
:libraries,
:auto_install,
:generated_dir,
:metadata_dir,
:helper_paths,
:helper_pack_enabled,
:helper_allowlist,
:inline_enabled,
:strict,
:verbose,
:scan_paths,
:scan_exclude,
:introspector,
:docs,
:runtime_client,
:ledger
]
defmodule Library do
@moduledoc """
Configuration struct for a single Python library binding.
"""
defstruct [
:name,
:version,
:module_name,
:python_name,
:pypi_package,
:extras,
include: [],
exclude: [],
streaming: [],
submodules: false
]
@type t :: %__MODULE__{
name: atom(),
version: String.t() | :stdlib | nil,
module_name: module(),
python_name: String.t(),
pypi_package: String.t() | nil,
extras: [String.t()],
include: [String.t()],
exclude: [String.t()],
streaming: [String.t()],
submodules: boolean()
}
end
@type t :: %__MODULE__{
libraries: [Library.t()],
auto_install: :never | :dev | :always,
generated_dir: String.t(),
metadata_dir: String.t(),
helper_paths: [String.t()],
helper_pack_enabled: boolean(),
helper_allowlist: :all | [String.t()],
inline_enabled: boolean(),
strict: boolean(),
verbose: boolean(),
scan_paths: [String.t()],
scan_exclude: [String.t()],
introspector: keyword(),
docs: keyword(),
runtime_client: module(),
ledger: keyword()
}
@doc """
Load config from mix.exs dependency options and Application env.
"""
@spec load() :: t()
def load do
deps = Mix.Project.config()[:deps] || []
opts =
deps
|> Enum.find_value([], fn
{:snakebridge, opts} when is_list(opts) -> opts
{:snakebridge, _req, opts} when is_list(opts) -> opts
_ -> nil
end)
|> List.wrap()
%__MODULE__{
libraries: parse_libraries(Keyword.get(opts, :libraries, [])),
auto_install: Application.get_env(:snakebridge, :auto_install, :dev),
generated_dir: Keyword.get(opts, :generated_dir, "lib/snakebridge_generated"),
metadata_dir: Keyword.get(opts, :metadata_dir, ".snakebridge"),
helper_paths: Application.get_env(:snakebridge, :helper_paths, ["priv/python/helpers"]),
helper_pack_enabled: Application.get_env(:snakebridge, :helper_pack_enabled, true),
helper_allowlist: Application.get_env(:snakebridge, :helper_allowlist, :all),
inline_enabled: Application.get_env(:snakebridge, :inline_enabled, false),
strict: env_flag(:strict, "SNAKEBRIDGE_STRICT", false),
verbose: env_flag(:verbose, "SNAKEBRIDGE_VERBOSE", false),
scan_paths: Application.get_env(:snakebridge, :scan_paths, ["lib"]),
scan_exclude: Application.get_env(:snakebridge, :scan_exclude, []),
introspector: Application.get_env(:snakebridge, :introspector, []),
docs: Application.get_env(:snakebridge, :docs, []),
runtime_client: Application.get_env(:snakebridge, :runtime_client, Snakepit),
ledger: Application.get_env(:snakebridge, :ledger, [])
}
end
@doc false
def parse_libraries(libraries) when is_list(libraries) do
Enum.map(libraries, &parse_library/1)
end
defp parse_library({name, version}) when is_binary(version) or version == :stdlib do
build_library(name, version, [])
end
defp parse_library({name, opts}) when is_list(opts) do
version = Keyword.get(opts, :version)
build_library(name, version, opts)
end
defp parse_library(name) when is_atom(name) do
build_library(name, nil, [])
end
defp parse_library(name) when is_binary(name) do
build_library(String.to_atom(name), nil, [])
end
defp build_library(name, version, opts) do
module_name = Keyword.get(opts, :module_name, default_module_name(name))
python_name = Keyword.get(opts, :python_name, Atom.to_string(name))
extras = Keyword.get(opts, :extras, [])
%Library{
name: name,
version: version,
module_name: module_name,
python_name: python_name,
pypi_package: Keyword.get(opts, :pypi_package),
extras: List.wrap(extras),
include: Keyword.get(opts, :include, []),
exclude: Keyword.get(opts, :exclude, []),
streaming: Keyword.get(opts, :streaming, []),
submodules: Keyword.get(opts, :submodules, false)
}
end
defp default_module_name(name) do
name
|> Atom.to_string()
|> Macro.camelize()
|> then(&Module.concat([&1]))
end
defp env_flag(config_key, env_var, default) do
case System.get_env(env_var) do
nil -> Application.get_env(:snakebridge, config_key, default)
value -> value in ["1", "true", "TRUE", "yes", "YES"]
end
end
end
</file>
<file path="snakebridge/defaults.ex">
defmodule SnakeBridge.Defaults do
@moduledoc """
Centralized defaults for all configurable values in SnakeBridge.
All values can be overridden via `Application.get_env(:snakebridge, key)`.
## Configuration Options
### Introspection
- `:introspector_timeout` - Timeout in ms for introspecting Python modules (default: `30_000`)
- `:introspector_max_concurrency` - Max concurrent introspection tasks (default: `System.schedulers_online()`)
### Wheel Selector (PyTorch/CUDA)
- `:pytorch_index_base_url` - Base URL for PyTorch wheel index (default: `"https://download.pytorch.org/whl/"`)
- `:cuda_thresholds` - CUDA version to variant mapping (default: `[{"cu124", 124}, {"cu121", 120}, {"cu118", 117}]`)
### Session Lifecycle
- `:session_max_refs` - Maximum refs per session (default: `10_000`)
- `:session_ttl_seconds` - Session time-to-live in seconds (default: `3600`)
### Code Generation
- `:variadic_max_arity` - Max arity for variadic wrappers (default: `8`)
- `:generated_dir` - Directory for generated code (default: `"lib/snakebridge_generated"`)
- `:metadata_dir` - Directory for metadata files (default: `".snakebridge"`)
### Protocol
- `:protocol_version` - Wire protocol version (default: `1`)
- `:min_supported_version` - Minimum supported protocol version (default: `1`)
### Runtime Timeouts
Runtime timeout configuration is nested under the `:runtime` key:
- `:timeout_profile` - Default profile for calls (default: `:default` for calls, `:streaming` for streams)
- `:default_timeout` - Default unary call timeout in ms (default: `120_000`)
- `:default_stream_timeout` - Default stream timeout in ms (default: `1_800_000`)
- `:library_profiles` - Map of library names to profiles (default: `%{}`)
- `:profiles` - Map of profile names to timeout settings
Built-in profiles:
- `:default` - 120s timeout for regular calls
- `:streaming` - 120s timeout, 30min stream_timeout
- `:ml_inference` - 10min timeout for ML/LLM workloads
- `:batch_job` - infinity timeout for long-running jobs
## Example Configuration
config :snakebridge,
introspector_timeout: 60_000,
pytorch_index_base_url: "https://my-mirror.example.com/pytorch/",
cuda_thresholds: [
{"cu126", 126},
{"cu124", 124},
{"cu121", 120},
{"cu118", 117}
],
session_max_refs: 50_000,
session_ttl_seconds: 7200,
runtime: [
timeout_profile: :default,
library_profiles: %{
"transformers" => :ml_inference,
"torch" => :batch_job
},
profiles: %{
default: [timeout: 120_000],
ml_inference: [timeout: 600_000, stream_timeout: 1_800_000],
batch_job: [timeout: :infinity, stream_timeout: :infinity]
}
]
"""
# Introspection
def introspector_timeout, do: get(:introspector_timeout, 30_000)
def introspector_max_concurrency,
do: get(:introspector_max_concurrency, System.schedulers_online())
# Wheel selector
def pytorch_index_base_url,
do: get(:pytorch_index_base_url, "https://download.pytorch.org/whl/")
def cuda_thresholds do
get(:cuda_thresholds, [
{"cu124", 124},
{"cu121", 120},
{"cu118", 117}
])
end
# Session context
def session_max_refs, do: get(:session_max_refs, 10_000)
def session_ttl_seconds, do: get(:session_ttl_seconds, 3600)
# Protocol
def protocol_version, do: get(:protocol_version, 1)
def min_supported_version, do: get(:min_supported_version, 1)
# Code generation
def variadic_max_arity, do: get(:variadic_max_arity, 8)
def generated_dir, do: get(:generated_dir, "lib/snakebridge_generated")
def metadata_dir, do: get(:metadata_dir, ".snakebridge")
# ============================================================================
# Runtime Timeout Configuration
# ============================================================================
@default_runtime_profiles %{
default: [timeout: 120_000],
streaming: [timeout: 120_000, stream_timeout: 1_800_000],
ml_inference: [timeout: 600_000, stream_timeout: 1_800_000],
batch_job: [timeout: :infinity, stream_timeout: :infinity]
}
@doc """
Returns the runtime configuration keyword list.
"""
@spec runtime_config() :: keyword()
def runtime_config, do: Application.get_env(:snakebridge, :runtime, [])
@doc """
Returns the timeout profile for a given call kind.
Call kinds:
- `:call` - Regular function calls (default: `:default`)
- `:stream` - Streaming calls (default: `:streaming`)
"""
@spec runtime_timeout_profile(atom()) :: atom()
def runtime_timeout_profile(call_kind \\ :call) do
runtime_config()
|> Keyword.get(:timeout_profile, default_timeout_profile(call_kind))
end
@doc """
Returns configured library-to-profile mappings.
Example:
config :snakebridge, runtime: [
library_profiles: %{
"transformers" => :ml_inference,
"torch" => :batch_job
}
]
"""
@spec runtime_library_profiles() :: map()
def runtime_library_profiles do
runtime_config() |> Keyword.get(:library_profiles, %{})
end
@doc """
Returns all timeout profiles.
Default profiles:
- `:default` - 120s timeout for regular calls
- `:streaming` - 120s timeout, 30min stream_timeout
- `:ml_inference` - 10min timeout for ML/LLM workloads
- `:batch_job` - infinity timeout for long-running jobs
"""
@spec runtime_profiles() :: map()
def runtime_profiles do
runtime_config() |> Keyword.get(:profiles, @default_runtime_profiles)
end
@doc """
Returns the default unary call timeout in milliseconds.
"""
@spec runtime_default_timeout() :: timeout()
def runtime_default_timeout do
runtime_config() |> Keyword.get(:default_timeout, 120_000)
end
@doc """
Returns the default stream timeout in milliseconds.
"""
@spec runtime_default_stream_timeout() :: timeout()
def runtime_default_stream_timeout do
runtime_config() |> Keyword.get(:default_stream_timeout, 1_800_000)
end
defp default_timeout_profile(:stream), do: :streaming
defp default_timeout_profile(_), do: :default
@doc """
Returns all current configuration values as a map.
"""
@spec all() :: map()
def all do
%{
introspector_timeout: introspector_timeout(),
introspector_max_concurrency: introspector_max_concurrency(),
pytorch_index_base_url: pytorch_index_base_url(),
cuda_thresholds: cuda_thresholds(),
session_max_refs: session_max_refs(),
session_ttl_seconds: session_ttl_seconds(),
protocol_version: protocol_version(),
min_supported_version: min_supported_version(),
variadic_max_arity: variadic_max_arity(),
generated_dir: generated_dir(),
metadata_dir: metadata_dir(),
runtime_default_timeout: runtime_default_timeout(),
runtime_default_stream_timeout: runtime_default_stream_timeout(),
runtime_timeout_profile: runtime_timeout_profile()
}
end
defp get(key, default) do
Application.get_env(:snakebridge, key, default)
end
end
</file>
<file path="snakebridge/docs.ex">
defmodule SnakeBridge.Docs do
@moduledoc """
On-demand documentation fetching with optional caching.
"""
@cache_table :snakebridge_docs
@spec get(module(), atom() | String.t()) :: String.t()
def get(module, function) do
start_time = System.monotonic_time()
key = {module, function}
case lookup_cache(key) do
{:hit, doc} ->
SnakeBridge.Telemetry.docs_fetch(start_time, module, function, :cache)
doc
:miss ->
{doc, source} = fetch_doc_with_source(module, function)
maybe_cache(key, doc)
SnakeBridge.Telemetry.docs_fetch(start_time, module, function, source)
doc
end
end
defp fetch_doc_with_source(module, function) do
case docs_source() do
:python ->
{fetch_from_python(module, function), :python}
:metadata ->
{fetch_from_metadata(module, function) || "Documentation unavailable.", :metadata}
:hybrid ->
fetch_hybrid_doc(module, function)
end
end
defp fetch_hybrid_doc(module, function) do
case fetch_from_metadata(module, function) do
nil -> {fetch_from_python(module, function), :python}
metadata -> {metadata, :metadata}
end
end
@spec search(module(), String.t()) :: list()
def search(module, query) when is_binary(query) do
query = query |> String.trim() |> String.downcase()
module
|> functions_for_search()
|> Enum.map(fn {name, summary} -> {name, summary, score(name, query)} end)
|> Enum.filter(fn {_name, _summary, relevance} -> relevance > 0.3 end)
|> Enum.sort_by(fn {_name, _summary, relevance} -> -relevance end)
|> Enum.take(10)
|> Enum.map(fn {name, summary, relevance} ->
%{name: name, summary: summary, relevance: relevance}
end)
end
defp docs_source do
Application.get_env(:snakebridge, :docs, [])
|> Keyword.get(:source, :python)
end
defp functions_for_search(module) do
if function_exported?(module, :__functions__, 0) do
module.__functions__()
|> Enum.map(fn {name, _arity, _mod, summary} ->
{name, summary |> to_string()}
end)
else
[]
end
end
defp score(name, query) do
name = name |> to_string() |> String.downcase()
cond do
query == "" -> 0.0
name == query -> 1.0
String.starts_with?(name, query) -> 0.9
String.contains?(name, query) -> 0.7
true -> 0.0
end
end
defp fetch_from_metadata(module, function) do
function_name = to_string(function)
with {:docs_v1, _, _, _, _, _, docs} <- Code.fetch_docs(module),
entry when not is_nil(entry) <- Enum.find(docs, &doc_entry_matches?(&1, function_name)),
docstring when is_binary(docstring) <- docstring_from_entry(entry) do
normalize_docstring(docstring)
else
_ -> nil
end
end
defp doc_entry_matches?({{kind, name, _arity}, _, _, _, _}, function_name)
when kind in [:function, :macro] and is_atom(name) do
Atom.to_string(name) == function_name
end
defp doc_entry_matches?({{name, _arity}, _, _, _, _}, function_name) when is_atom(name) do
Atom.to_string(name) == function_name
end
defp doc_entry_matches?(_entry, _function_name), do: false
defp docstring_from_entry({_id, _anno, _signature, doc, _metadata}) do
case doc do
:hidden -> nil
:none -> nil
{_, text} when is_binary(text) -> text
text when is_binary(text) -> text
_ -> nil
end
end
defp normalize_docstring(docstring) do
case String.trim(docstring) do
"" -> nil
trimmed -> trimmed
end
end
defp fetch_from_python(module, function) do
python_name = python_module_name(module)
script = doc_script()
case python_runner().run(script, [python_name, to_string(function)], []) do
{:ok, output} -> String.trim(output)
{:error, _} -> "Documentation unavailable."
end
end
defp python_runner do
Application.get_env(:snakebridge, :python_runner, SnakeBridge.PythonRunner.System)
end
defp python_module_name(module) do
if function_exported?(module, :__snakebridge_python_name__, 0) do
module.__snakebridge_python_name__()
else
module
|> Module.split()
|> Enum.map_join(".", &Macro.underscore/1)
end
end
defp doc_script do
~S"""
import importlib
import inspect
import sys
module_name = sys.argv[1]
function_name = sys.argv[2]
module = importlib.import_module(module_name)
obj = getattr(module, function_name, None)
if obj is None:
print("Function not found.")
else:
print(inspect.getdoc(obj) or "Documentation unavailable.")
"""
end
defp lookup_cache(key) do
if cache_enabled?() do
ensure_cache_table()
case :ets.lookup(@cache_table, key) do
[{^key, doc}] -> {:hit, doc}
[] -> :miss
end
else
:miss
end
end
defp maybe_cache(key, doc) do
if cache_enabled?() do
ensure_cache_table()
:ets.insert(@cache_table, {key, doc})
end
end
defp cache_enabled? do
Application.get_env(:snakebridge, :docs, [])
|> Keyword.get(:cache_enabled, true)
end
defp ensure_cache_table do
case :ets.whereis(@cache_table) do
:undefined ->
:ets.new(@cache_table, [:set, :public, :named_table, read_concurrency: true])
_ ->
@cache_table
end
end
end
</file>
<file path="snakebridge/dynamic_exception.ex">
defmodule SnakeBridge.DynamicException do
@moduledoc """
Dynamically creates Elixir exception modules from Python exception class names.
This enables pattern matching on Python exceptions:
rescue
e in SnakeBridge.DynamicException.ValueError ->
handle_value_error(e)
"""
@exception_cache :snakebridge_exception_cache
@doc """
Creates an exception struct from a Python class name and message.
"""
@spec create(String.t(), String.t() | nil, keyword()) :: Exception.t()
def create(python_class_name, message, opts \\ []) when is_binary(python_class_name) do
module = get_or_create_module(python_class_name)
details =
opts
|> Keyword.delete(:python_traceback)
|> then(fn cleaned -> Keyword.get(cleaned, :details, cleaned) end)
struct(module,
message: message || "",
python_class: python_class_name,
details: details,
python_traceback: Keyword.get(opts, :python_traceback)
)
end
@doc """
Gets or creates an exception module for a Python class name.
"""
@spec get_or_create_module(String.t()) :: module()
def get_or_create_module(python_class_name) when is_binary(python_class_name) do
ensure_cache_exists()
class_name = sanitize_class_name(python_class_name)
module_name = Module.concat(__MODULE__, class_name)
if Code.ensure_loaded?(module_name) do
:ets.insert(@exception_cache, {module_name, true})
module_name
else
case :ets.lookup(@exception_cache, module_name) do
[{^module_name, true}] ->
module_name
[] ->
create_exception_module(module_name, python_class_name)
module_name
end
end
end
@doc false
def ensure_cache_exists do
if :ets.whereis(@exception_cache) == :undefined do
:ets.new(@exception_cache, [:named_table, :set, :public])
end
end
defp create_exception_module(module_name, python_class_name) do
unless Code.ensure_loaded?(module_name) do
Module.create(
module_name,
quote do
@moduledoc """
Dynamic exception for Python `#{unquote(python_class_name)}`.
"""
defexception [:message, :python_class, :details, :python_traceback]
@impl true
def message(%{message: message}), do: message || ""
end,
Macro.Env.location(__ENV__)
)
end
:ets.insert(@exception_cache, {module_name, true})
end
defp sanitize_class_name(python_class_name) do
python_class_name
|> String.split(".")
|> List.last()
|> String.replace(~r/[^A-Za-z0-9_]/, "")
|> String.split("_", trim: true)
|> Enum.map_join("", &capitalize_preserve/1)
|> normalize_name()
end
defp normalize_name(""), do: "PythonError"
defp normalize_name(<<first::utf8, _rest::binary>> = name) when first in ?0..?9 do
"Py" <> name
end
defp normalize_name(name), do: name
defp capitalize_preserve(<<first::utf8, rest::binary>>) when first in ?a..?z do
<<first - 32>> <> rest
end
defp capitalize_preserve(segment), do: segment
end
</file>
<file path="snakebridge/dynamic.ex">
defmodule SnakeBridge.Dynamic do
@moduledoc """
Dynamic dispatch for calling methods on Python objects without generated code.
Use this module when:
- Python returns an object of a class you did not generate bindings for
- You need to call methods dynamically at runtime
- You want a no-codegen escape hatch for refs
"""
alias SnakeBridge.Runtime
@type ref :: SnakeBridge.Ref.t() | map()
@type opts :: keyword()
@doc """
Calls a method on a Python object reference.
"""
@spec call(ref(), atom() | String.t(), list(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
def call(ref, method, args \\ [], opts \\ []) do
validate_ref!(ref)
Runtime.call_method(ref, method, args, opts)
end
@doc """
Gets an attribute from a Python object reference.
"""
@spec get_attr(ref(), atom() | String.t(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
def get_attr(ref, attr, opts \\ []) do
validate_ref!(ref)
Runtime.get_attr(ref, attr, opts)
end
@doc """
Sets an attribute on a Python object reference.
"""
@spec set_attr(ref(), atom() | String.t(), term(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
def set_attr(ref, attr, value, opts \\ []) do
validate_ref!(ref)
Runtime.set_attr(ref, attr, value, opts)
end
@doc """
Checks if a value is a valid Python reference.
"""
@spec ref?(term()) :: boolean()
def ref?(value), do: SnakeBridge.Ref.ref?(value)
@doc false
def build_call_payload(ref, method, args) do
wire_ref = SnakeBridge.Ref.to_wire_format(ref)
%{
"call_type" => "method",
"instance" => wire_ref,
"method" => to_string(method),
"args" => args
}
end
defp validate_ref!(ref) do
unless ref?(ref) do
raise ArgumentError, "Invalid ref: expected a SnakeBridge ref, got: #{inspect(ref)}"
end
end
end
</file>
<file path="snakebridge/environment_error.ex">
defmodule SnakeBridge.EnvironmentError do
@moduledoc """
Error raised when required Python packages are missing.
"""
defexception [:message, :missing_packages, :suggestion]
@type t :: %__MODULE__{
message: String.t(),
missing_packages: [String.t()],
suggestion: String.t()
}
@impl Exception
def message(%__MODULE__{message: message, suggestion: suggestion}) do
if suggestion do
message <> "\n\nSuggestion: " <> suggestion
else
message
end
end
end
</file>
<file path="snakebridge/error_translator.ex">
defmodule SnakeBridge.ErrorTranslator do
@moduledoc """
Translates Python/ML errors into structured SnakeBridge errors.
This module recognizes common ML error patterns from PyTorch, NumPy,
and other ML libraries, and translates them into structured error
types with actionable suggestions.
## Supported Error Types
- `SnakeBridge.Error.ShapeMismatchError` - Tensor shape incompatibilities
- `SnakeBridge.Error.OutOfMemoryError` - GPU/CPU memory exhaustion
- `SnakeBridge.Error.DtypeMismatchError` - Tensor dtype incompatibilities
## Examples
iex> error = %RuntimeError{message: "CUDA out of memory"}
iex> SnakeBridge.ErrorTranslator.translate(error)
%SnakeBridge.Error.OutOfMemoryError{device: {:cuda, 0}, ...}
"""
alias SnakeBridge.DynamicException
alias SnakeBridge.Error.{DtypeMismatchError, OutOfMemoryError, ShapeMismatchError}
alias SnakeBridge.{InvalidRefError, RefNotFoundError, SessionMismatchError}
# Mapping from normalized dtype strings to Elixir atoms
@dtype_map %{
# PyTorch short names
"float" => :float32,
"double" => :float64,
"half" => :float16,
"long" => :int64,
"int" => :int32,
"short" => :int16,
"byte" => :uint8,
"char" => :int8,
"bool" => :bool,
# PyTorch qualified names
"torch.float32" => :float32,
"torch.float64" => :float64,
"torch.float16" => :float16,
"torch.int64" => :int64,
"torch.int32" => :int32,
"torch.int16" => :int16,
"torch.int8" => :int8,
"torch.uint8" => :uint8,
"torch.bool" => :bool,
"torch.bfloat16" => :bfloat16
}
@doc """
Translates a Python/ML error to a structured SnakeBridge error.
Returns the original error if it cannot be translated.
"""
@spec translate(Exception.t() | map() | nil, String.t() | nil) :: Exception.t() | nil
def translate(error, traceback \\ nil)
def translate(nil, _traceback), do: nil
def translate(%RuntimeError{message: message} = error, traceback) do
case translate_message(message) do
nil -> error
translated -> maybe_add_traceback(translated, traceback)
end
end
def translate(%{python_type: type} = error, traceback) when is_binary(type) do
translate_python_error(type, error, traceback)
end
def translate(%{"python_type" => type} = error, traceback) when is_binary(type) do
translate_python_error(type, error, traceback)
end
def translate(%{error_type: type} = error, traceback) when is_binary(type) do
translate_python_error(type, error, traceback)
end
def translate(%{"error_type" => type} = error, traceback) when is_binary(type) do
translate_python_error(type, error, traceback)
end
def translate(error, _traceback), do: error
@doc """
Translates an error message string to a structured error.
Returns nil if the message cannot be translated.
"""
@spec translate_message(String.t()) :: Exception.t() | nil
def translate_message(message) when is_binary(message) do
cond do
ref_not_found?(message) -> translate_ref_not_found(message)
session_mismatch?(message) -> translate_session_mismatch(message)
invalid_ref?(message) -> translate_invalid_ref(message)
shape_mismatch?(message) -> translate_shape_error(message)
oom_error?(message) -> translate_oom_error(message)
dtype_mismatch?(message) -> translate_dtype_error(message)
true -> nil
end
end
defp translate_python_error(type, error, traceback) do
message = error_message(error)
case maybe_translate_message(message, traceback) do
{:ok, translated} ->
translated
:error ->
DynamicException.create(type, message, details: error, python_traceback: traceback)
end
end
defp maybe_translate_message(message, traceback) when is_binary(message) do
case translate_message(message) do
nil -> :error
translated -> {:ok, maybe_add_traceback(translated, traceback)}
end
end
defp maybe_translate_message(_message, _traceback), do: :error
defp error_message(error) when is_map(error) do
value =
Map.get(error, :message) ||
Map.get(error, "message") ||
Map.get(error, :error) ||
Map.get(error, "error")
cond do
is_binary(value) -> value
is_nil(value) -> ""
true -> inspect(value)
end
end
@doc """
Converts a Python/PyTorch dtype string to an Elixir atom.
## Examples
iex> SnakeBridge.ErrorTranslator.dtype_from_string("Float")
:float32
iex> SnakeBridge.ErrorTranslator.dtype_from_string("torch.float64")
:float64
"""
@spec dtype_from_string(String.t()) :: atom()
def dtype_from_string(dtype_str) do
normalized = dtype_str |> String.trim() |> String.downcase()
case Map.fetch(@dtype_map, normalized) do
{:ok, dtype} -> dtype
:error -> String.to_atom(String.replace(normalized, ".", "_"))
end
end
# Shape mismatch detection patterns
defp shape_mismatch?(message) do
String.contains?(message, "shapes cannot be multiplied") or
String.contains?(message, "size of tensor") or
String.contains?(message, "incompatible shapes") or
String.contains?(message, "Dimension out of range") or
String.contains?(message, "shape mismatch") or
String.contains?(message, "dimension mismatch")
end
# OOM error detection patterns
defp oom_error?(message) do
String.contains?(message, "out of memory") or
String.contains?(message, "OutOfMemory") or
String.contains?(message, "OOM")
end
# Dtype mismatch detection patterns
defp dtype_mismatch?(message) do
String.contains?(message, "expected scalar type") or
String.contains?(message, "expected dtype") or
String.contains?(message, "type mismatch")
end
# Ref lifecycle error detection patterns
defp ref_not_found?(message) do
String.contains?(message, "Unknown SnakeBridge reference")
end
defp session_mismatch?(message) do
String.contains?(message, "SnakeBridge reference session mismatch")
end
defp invalid_ref?(message) do
String.contains?(message, "Invalid SnakeBridge reference") or
String.contains?(message, "SnakeBridge reference missing id")
end
# Translate shape errors
defp translate_shape_error(message) do
cond do
# mat1 and mat2 shapes cannot be multiplied (3x4 and 5x6)
match = Regex.run(~r/shapes cannot be multiplied \((\d+)x(\d+) and (\d+)x(\d+)\)/, message) ->
[_, a_rows, a_cols, b_rows, b_cols] = match
ShapeMismatchError.new(:matmul,
shape_a: [String.to_integer(a_rows), String.to_integer(a_cols)],
shape_b: [String.to_integer(b_rows), String.to_integer(b_cols)],
message: extract_core_message(message)
)
# Broadcasting shape mismatch
String.contains?(message, "broadcasting") ->
shapes = extract_broadcast_shapes(message)
ShapeMismatchError.new(:broadcast,
shape_a: elem(shapes, 0),
shape_b: elem(shapes, 1),
message: extract_core_message(message)
)
# Dimension errors
String.contains?(message, "Dimension") ->
ShapeMismatchError.new(:index,
message: extract_core_message(message)
)
# Size mismatch
String.contains?(message, "size of tensor") ->
ShapeMismatchError.new(:elementwise,
message: extract_core_message(message)
)
# Generic shape error
true ->
ShapeMismatchError.new(:unknown,
message: extract_core_message(message)
)
end
end
# Translate OOM errors
defp translate_oom_error(message) do
device = detect_device(message)
memory_info = extract_memory_info(message)
OutOfMemoryError.new(device,
requested_mb: memory_info[:requested],
available_mb: memory_info[:available],
total_mb: memory_info[:total],
message: extract_core_message(message)
)
end
# Translate dtype errors
defp translate_dtype_error(message) do
{expected, got} = extract_dtype_info(message)
DtypeMismatchError.new(expected, got, message: extract_core_message(message))
end
# Translate ref not found errors
defp translate_ref_not_found(message) do
ref_id = extract_ref_id(message)
RefNotFoundError.exception(
ref_id: ref_id,
message: message
)
end
# Translate session mismatch errors
defp translate_session_mismatch(message) do
SessionMismatchError.exception(message: message)
end
# Translate invalid ref errors
defp translate_invalid_ref(message) do
reason = extract_invalid_reason(message)
InvalidRefError.exception(
reason: reason,
message: message
)
end
# Extract ref ID from error message
defp extract_ref_id(message) do
case Regex.run(~r/reference[:\s]+['\"]?([a-zA-Z0-9_]+)['\"]?/i, message) do
[_, ref_id] -> ref_id
nil -> nil
end
end
# Extract invalid ref reason
defp extract_invalid_reason(message) do
cond do
String.contains?(message, "missing id") -> :missing_id
String.contains?(message, "payload") -> :invalid_format
true -> :unknown
end
end
# Device detection from error message
defp detect_device(message) do
cond do
String.contains?(message, "MPS") -> :mps
match = Regex.run(~r/GPU (\d+)/, message) -> {:cuda, String.to_integer(Enum.at(match, 1))}
String.contains?(message, "CUDA") -> {:cuda, 0}
String.contains?(message, "cuda") -> {:cuda, 0}
true -> :cpu
end
end
# Extract memory information from OOM message
defp extract_memory_info(message) do
requested = extract_memory_value(message, ~r/allocate (\d+) MiB/)
total = extract_memory_value(message, ~r/(\d+) MiB total/)
available = extract_memory_value(message, ~r/(\d+) MiB free/)
%{requested: requested, total: total, available: available}
end
defp extract_memory_value(message, pattern) do
case Regex.run(pattern, message) do
[_, value] -> String.to_integer(value)
nil -> nil
end
end
# Extract dtype info from error message
defp extract_dtype_info(message) do
cond do
# "expected scalar type Float but found Double"
match = Regex.run(~r/expected scalar type (\w+) but found (\w+)/, message) ->
[_, expected, got] = match
{dtype_from_string(expected), dtype_from_string(got)}
# "expected dtype torch.float32 but got torch.int64"
match = Regex.run(~r/expected dtype ([\w.]+) but got ([\w.]+)/, message) ->
[_, expected, got] = match
{dtype_from_string(expected), dtype_from_string(got)}
true ->
{:unknown, :unknown}
end
end
# Extract broadcast shapes from message
defp extract_broadcast_shapes(message) do
case Regex.run(~r/\[([^\]]+)\] vs \[([^\]]+)\]/, message) do
[_, a, b] ->
{parse_shape(a), parse_shape(b)}
nil ->
{nil, nil}
end
end
defp parse_shape(shape_str) do
shape_str
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.to_integer/1)
end
# Extract the core error message
defp extract_core_message(message) do
message
|> String.trim()
|> String.replace(~r/^RuntimeError:\s*/, "")
|> String.replace(~r/^torch\.\w+Error:\s*/, "")
|> String.split("\n")
|> List.first()
|> String.trim()
end
# Add traceback if provided
defp maybe_add_traceback(error, nil), do: error
defp maybe_add_traceback(%{__struct__: _} = error, traceback) do
Map.put(error, :python_traceback, traceback)
end
end
</file>
<file path="snakebridge/error.ex">
defmodule SnakeBridge.Error do
@moduledoc """
ML-specific error types for SnakeBridge.
This module provides structured error types that translate Python/ML
errors into Elixir exceptions with actionable suggestions.
## Available Error Types
- `SnakeBridge.Error.ShapeMismatchError` - Tensor shape incompatibilities
- `SnakeBridge.Error.OutOfMemoryError` - GPU/CPU memory exhaustion
- `SnakeBridge.Error.DtypeMismatchError` - Tensor dtype incompatibilities
## Translation
Use `SnakeBridge.ErrorTranslator` to automatically translate Python
exceptions into these structured error types.
## Examples
# Creating errors directly
error = SnakeBridge.Error.ShapeMismatchError.new(:matmul,
shape_a: [3, 4],
shape_b: [5, 6]
)
# Raising errors
raise SnakeBridge.Error.OutOfMemoryError, device: {:cuda, 0}
# Translating Python errors
translated = SnakeBridge.ErrorTranslator.translate(python_error)
"""
# Re-export error modules for convenient access
defdelegate shape_mismatch(operation, opts \\ []), to: __MODULE__.ShapeMismatchError, as: :new
defdelegate out_of_memory(device, opts \\ []), to: __MODULE__.OutOfMemoryError, as: :new
defdelegate dtype_mismatch(expected, got, opts \\ []),
to: __MODULE__.DtypeMismatchError,
as: :new
end
</file>
<file path="snakebridge/examples.ex">
defmodule SnakeBridge.Examples do
@moduledoc false
@failure_key :snakebridge_example_failures
def reset_failures do
Process.put(@failure_key, 0)
end
def record_failure do
Process.put(@failure_key, failure_count() + 1)
end
def failure_count do
Process.get(@failure_key, 0)
end
def assert_no_failures! do
count = failure_count()
if count > 0 do
raise "Example failed with #{count} unexpected error(s)."
end
end
def assert_script_ok(result) do
case result do
{:error, reason} ->
raise "Snakepit script failed: #{inspect(reason)}"
_ ->
:ok
end
end
end
</file>
<file path="snakebridge/generator.ex">
defmodule SnakeBridge.Generator do
@moduledoc """
Generates Elixir source files from introspection data.
"""
alias SnakeBridge.Docs.{MarkdownConverter, RstParser}
alias SnakeBridge.Generator.TypeMapper
@reserved_words ~w(def defp defmodule class do end if unless case cond for while with fn when and or not true false nil in try catch rescue after else raise throw receive)
@dunder_mappings %{
"__init__" => "new",
"__str__" => "to_string",
"__repr__" => "inspect",
"__len__" => "length",
"__getitem__" => "get",
"__setitem__" => "put",
"__contains__" => "member?"
}
@spec render_library(SnakeBridge.Config.Library.t(), list(), list(), keyword()) :: String.t()
def render_library(library, functions, classes, opts \\ []) do
version = Keyword.get(opts, :version, Application.spec(:snakebridge, :vsn) |> to_string())
module_name = module_to_string(library.module_name)
header = """
# Generated by SnakeBridge v#{version} - DO NOT EDIT MANUALLY
# Regenerate with: mix compile
# Library: #{library.python_name} #{library.version || "unknown"}
"""
moduledoc = """
@moduledoc \"\"\"
SnakeBridge bindings for `#{library.python_name}`.
## Runtime Options
All functions accept a `__runtime__` option for controlling execution behavior:
#{library.module_name}.some_function(args, __runtime__: [timeout: 120_000])
### Supported runtime options
- `:timeout` - Call timeout in milliseconds (default: 120,000ms / 2 minutes)
- `:timeout_profile` - Use a named profile (`:default`, `:ml_inference`, `:batch_job`, `:streaming`)
- `:stream_timeout` - Timeout for streaming operations (default: 1,800,000ms / 30 minutes)
- `:session_id` - Override the session ID for this call
### Timeout Profiles
- `:default` - 2 minute timeout for regular calls
- `:ml_inference` - 10 minute timeout for ML/LLM workloads
- `:batch_job` - Unlimited timeout for long-running jobs
- `:streaming` - 2 minute timeout, 30 minute stream_timeout
### Example with timeout override
# For a long-running ML inference call
#{library.module_name}.predict(data, __runtime__: [timeout_profile: :ml_inference])
# Or explicit timeout
#{library.module_name}.predict(data, __runtime__: [timeout: 600_000])
See `SnakeBridge.Defaults` for global timeout configuration.
\"\"\"
def __snakebridge_python_name__, do: "#{library.python_name}"
def __snakebridge_library__, do: "#{library.python_name}"
"""
functions_by_module =
functions
|> Enum.map(&Map.put_new(&1, "python_module", library.python_name))
|> Enum.group_by(& &1["python_module"])
base_functions = Map.get(functions_by_module, library.python_name, [])
function_defs =
base_functions
|> Enum.sort_by(& &1["name"])
|> Enum.map_join("\n\n", &render_function(&1, library))
submodule_defs =
functions_by_module
|> Map.drop([library.python_name])
|> Enum.sort_by(fn {python_module, _} -> python_module end)
|> Enum.map_join("\n\n", fn {python_module, funcs} ->
render_submodule(python_module, funcs, library)
end)
class_defs =
classes
|> Enum.sort_by(&class_sort_key/1)
|> Enum.map_join("\n\n", &render_class(&1, library))
discovery = render_discovery(functions, classes)
"""
#{header}
defmodule #{module_name} do
#{moduledoc}
#{function_defs}
#{submodule_defs}
#{class_defs}
#{discovery}
end
"""
|> Code.format_string!()
|> IO.iodata_to_binary()
end
@spec generate_library(SnakeBridge.Config.Library.t(), list(), list(), SnakeBridge.Config.t()) ::
:ok
def generate_library(library, functions, classes, config) do
start_time = System.monotonic_time()
File.mkdir_p!(config.generated_dir)
path = Path.join(config.generated_dir, "#{library.python_name}.ex")
source =
render_library(library, functions, classes, version: Application.spec(:snakebridge, :vsn))
result = write_if_changed(path, source)
register_generated_library(library, functions, classes, config, path)
bytes_written = if result == :written, do: byte_size(source), else: 0
SnakeBridge.Telemetry.generate_stop(
start_time,
library.name,
path,
bytes_written,
length(functions),
length(classes)
)
:ok
end
@spec write_if_changed(String.t(), String.t()) :: :written | :unchanged
def write_if_changed(path, new_content) do
with_lock(path, fn ->
case File.read(path) do
{:ok, existing} when existing == new_content ->
:unchanged
_ ->
write_atomic(path, new_content)
end
end)
end
defp write_atomic(path, content) do
temp_path = "#{path}.tmp.#{System.unique_integer([:positive])}"
try do
File.mkdir_p!(Path.dirname(path))
File.write!(temp_path, content)
File.rename!(temp_path, path)
:written
rescue
exception ->
File.rm(temp_path)
reraise exception, __STACKTRACE__
end
end
defp with_lock(path, fun) when is_function(fun, 0) do
lock_key = {:snakebridge_write, Path.expand(path)}
:global.trans(lock_key, fun)
end
defp register_generated_library(library, functions, classes, config, path) do
entry = build_registry_entry(library, functions, classes, config, path)
_ = SnakeBridge.Registry.register(library.python_name, entry)
end
defp build_registry_entry(library, functions, classes, config, path) do
python_version =
case library.version do
nil -> "unknown"
:stdlib -> "stdlib"
value -> to_string(value)
end
%{
python_module: library.python_name,
python_version: python_version,
elixir_module: module_to_string(library.module_name),
generated_at: DateTime.utc_now(),
path: config.generated_dir,
files: [Path.basename(path)],
stats: %{
functions: length(functions),
classes: length(classes),
submodules: count_submodules(library, functions, classes)
}
}
end
defp count_submodules(library, functions, classes) do
base = library.python_name
function_modules =
functions
|> Enum.map(&(&1["python_module"] || &1[:python_module] || base))
class_modules =
classes
|> Enum.map(&(&1["python_module"] || &1[:python_module] || base))
(function_modules ++ class_modules)
|> Enum.uniq()
|> Enum.reject(&(&1 == base))
|> length()
end
@spec render_function(map(), SnakeBridge.Config.Library.t()) :: String.t()
def render_function(info, library) do
raw_name = info["name"] || ""
python_name = info["python_name"] || info["function"] || raw_name
{name, _python_name} = sanitize_function_name(raw_name)
if module_attribute?(info) do
render_module_attribute(name, python_name, info)
else
render_callable_function(info, library, name, python_name)
end
end
defp render_callable_function(info, library, name, python_name) do
params = info["parameters"] || []
doc = info["docstring"] || ""
plan = build_params(params, info)
param_names = Enum.map(plan.required, & &1.name)
args_name = extra_args_name(param_names)
return_type = info["return_type"] || %{"type" => "any"}
normal = render_function_body(name, python_name, plan, args_name, return_type, doc, params)
maybe_add_streaming(normal, name, python_name, plan, args_name, library)
end
defp render_function_body(name, python_name, plan, args_name, return_type, doc, params) do
if plan.is_variadic do
render_variadic_function(name, python_name, return_type, doc, params)
else
render_normal_function(name, python_name, plan, args_name, return_type, doc, params)
end
end
defp maybe_add_streaming(normal, name, python_name, plan, args_name, library) do
is_streaming = python_name in (library.streaming || [])
if is_streaming do
streaming = render_streaming_body(name, python_name, plan, args_name)
normal <> "\n\n" <> streaming
else
normal
end
end
defp render_streaming_body(name, python_name, plan, args_name) do
if plan.is_variadic do
render_variadic_streaming_variant(name, python_name)
else
render_streaming_variant(name, python_name, plan, args_name)
end
end
defp module_attribute?(info) do
info["call_type"] == "module_attr" or info[:call_type] == "module_attr" or
info["type"] == "attribute" or info[:type] == "attribute"
end
defp render_module_attribute(name, python_name, info) do
return_type = info["return_type"] || %{"type" => "any"}
doc = info["docstring"] || ""
formatted_doc = format_docstring(doc, [], return_type)
attr_ref = function_ref(name, python_name)
return_spec = type_spec_string(return_type)
"""
@doc \"\"\"
#{String.trim(formatted_doc)}
\"\"\"
@spec #{name}() :: {:ok, #{return_spec}} | {:error, Snakepit.Error.t()}
def #{name}() do
SnakeBridge.Runtime.get_module_attr(__MODULE__, #{attr_ref})
end
"""
end
defp render_normal_function(name, python_name, plan, args_name, return_type, doc, params) do
param_names = Enum.map(plan.required, & &1.name)
args = args_expr(param_names, plan.has_args, args_name)
call = runtime_call(name, python_name, args)
spec = function_spec(name, plan.required, plan.has_args, return_type)
formatted_doc = format_docstring(doc, params, return_type)
normalize = normalize_args_line(plan.has_args, args_name, 8)
kw_validation = keyword_only_validation(plan.required_keyword_only, 8)
"""
@doc \"\"\"
#{String.trim(formatted_doc)}
\"\"\"
#{spec}
def #{name}(#{param_list(param_names, plan.has_args, plan.has_opts, args_name)}) do
#{normalize}#{kw_validation} #{call}
end
"""
end
defp render_streaming_variant(name, python_name, plan, args_name) do
param_names = Enum.map(plan.required, & &1.name)
args = args_expr(param_names, plan.has_args, args_name)
stream_params =
param_names
|> maybe_add_args(plan.has_args, args_name)
|> Kernel.++(["opts \\\\ []", "callback"])
stream_params_str = Enum.join(stream_params, ", ")
stream_call = runtime_stream_call(name, python_name, args)
spec_args =
plan.required
|> Enum.map(&param_type_spec/1)
|> maybe_add_args_spec(plan.has_args)
|> Kernel.++(["keyword()", "(term() -> any())"])
spec_args_str = Enum.join(spec_args, ", ")
base_arity = length(param_names) + if(plan.has_args, do: 2, else: 1)
normalize = normalize_args_line(plan.has_args, args_name, 8)
kw_validation = keyword_only_validation(plan.required_keyword_only, 8)
"""
@doc \"\"\"
Streaming variant of `#{name}/#{base_arity}`.
The callback receives chunks as they arrive.
\"\"\"
@spec #{name}_stream(#{spec_args_str}) :: :ok | {:error, Snakepit.Error.t()}
def #{name}_stream(#{stream_params_str}) when is_function(callback, 1) do
#{normalize}#{kw_validation} #{stream_call}
end
"""
end
defp render_variadic_function(name, python_name, return_type, doc, params) do
max_arity = variadic_max_arity()
return_spec = type_spec_string(return_type)
formatted_doc = format_docstring(doc, params, return_type)
specs = variadic_specs(name, max_arity, return_spec)
clauses = variadic_function_clauses(name, python_name, max_arity)
"""
@doc \"\"\"
#{String.trim(formatted_doc)}
\"\"\"
#{specs}
#{indent(clauses, 6)}
"""
end
defp render_variadic_streaming_variant(name, python_name) do
max_arity = variadic_max_arity()
specs = variadic_streaming_specs(name, max_arity)
clauses = variadic_streaming_clauses(name, python_name, max_arity)
"""
@doc \"\"\"
Streaming variant of `#{name}`.
The callback receives chunks as they arrive.
\"\"\"
#{specs}
#{indent(clauses, 6)}
"""
end
defp render_variadic_constructor(_plan, _args_name) do
max_arity = variadic_max_arity()
specs = variadic_specs("new", max_arity, "SnakeBridge.Ref.t()")
clauses = variadic_constructor_clauses(max_arity)
"""
#{specs}
#{indent(clauses, 8)}
"""
end
defp render_variadic_method(name, python_name, return_type) do
max_arity = variadic_max_arity()
return_spec = type_spec_string(return_type)
specs = variadic_method_specs(name, max_arity, return_spec)
clauses = variadic_method_clauses(name, python_name, max_arity)
"""
#{specs}
#{indent(clauses, 8)}
"""
end
defp render_submodule(python_module, functions, library) do
module_name =
python_module
|> String.split(".")
|> Enum.drop(length(String.split(library.python_name, ".")))
|> Enum.map_join(".", &Macro.camelize/1)
function_defs =
functions
|> Enum.sort_by(& &1["name"])
|> Enum.map_join("\n\n", &render_function(&1, library))
"""
defmodule #{module_name} do
def __snakebridge_python_name__, do: "#{python_module}"
def __snakebridge_library__, do: "#{library.python_name}"
#{indent(function_defs, 4)}
end
"""
end
@spec render_class(map(), SnakeBridge.Config.Library.t()) :: String.t()
def render_class(class_info, library) do
class_name = class_name(class_info)
python_module = class_python_module(class_info, library)
module_name = class_module_name(class_info, library)
relative_module = relative_module_name(library, module_name)
methods = class_info["methods"] || []
attrs = class_info["attributes"] || []
init_method = Enum.find(methods, fn method -> method["name"] == "__init__" end)
init_params = if init_method, do: init_method["parameters"] || [], else: []
plan = build_params(init_params, init_method || %{})
param_names = Enum.map(plan.required, & &1.name)
args_name = extra_args_name(param_names)
constructor =
if plan.is_variadic do
render_variadic_constructor(plan, args_name)
else
render_constructor(plan, args_name)
end
methods_source =
methods
|> Enum.reject(fn method -> method["name"] == "__init__" end)
|> Enum.map_join("\n\n", &render_method/1)
attrs_source =
attrs
|> Enum.map_join("\n\n", &render_attribute/1)
"""
defmodule #{relative_module} do
def __snakebridge_python_name__, do: "#{python_module}"
def __snakebridge_python_class__, do: "#{class_name}"
def __snakebridge_library__, do: "#{library.python_name}"
@opaque t :: SnakeBridge.Ref.t()
#{indent(constructor, 4)}
#{indent(methods_source, 4)}
#{indent(attrs_source, 4)}
end
"""
end
defp render_constructor(plan, args_name) do
param_names = Enum.map(plan.required, & &1.name)
args = args_expr(param_names, plan.has_args, args_name)
param_list = param_list(param_names, plan.has_args, plan.has_opts, args_name)
call = "SnakeBridge.Runtime.call_class(__MODULE__, :__init__, #{args}, opts)"
spec_args =
plan.required
|> Enum.map(&param_type_spec/1)
|> maybe_add_args_spec(plan.has_args)
|> Kernel.++(["keyword()"])
spec_args_str = Enum.join(spec_args, ", ")
normalize = normalize_args_line(plan.has_args, args_name, 10)
kw_validation = keyword_only_validation(plan.required_keyword_only, 10)
"""
@spec new(#{spec_args_str}) :: {:ok, SnakeBridge.Ref.t()} | {:error, Snakepit.Error.t()}
def new(#{param_list}) do
#{normalize}#{kw_validation} #{call}
end
"""
end
defp render_method(%{"name" => "__init__"}), do: ""
defp render_method(%{name: "__init__"}), do: ""
defp render_method(info) do
python_name = info["python_name"] || info["name"] || info[:name] || ""
name_info = resolve_method_name(info, python_name)
do_render_method(name_info, info)
end
defp resolve_method_name(info, python_name) do
case info["elixir_name"] || info[:elixir_name] do
elixir_name when is_binary(elixir_name) -> {elixir_name, python_name}
_ -> sanitize_method_name(python_name)
end
end
defp do_render_method(nil, _info), do: ""
defp do_render_method({name, python_name}, info) do
params = info["parameters"] || []
plan = build_params(params, info)
return_type = info["return_type"] || %{"type" => "any"}
render_method_body(name, python_name, plan, return_type)
end
defp render_method_body(name, python_name, %{is_variadic: true}, return_type) do
render_variadic_method(name, python_name, return_type)
end
defp render_method_body(name, python_name, plan, return_type) do
param_names = Enum.map(plan.required, & &1.name)
args_name = extra_args_name(param_names)
spec = method_spec(name, plan.required, plan.has_args, return_type)
call = runtime_method_call(name, python_name, param_names, plan.has_args, args_name)
normalize = normalize_args_line(plan.has_args, args_name, 10)
kw_validation = keyword_only_validation(plan.required_keyword_only, 10)
"""
#{spec}
def #{name}(ref#{method_param_suffix(param_names, plan.has_args, plan.has_opts, args_name)}) do
#{normalize}#{kw_validation} #{call}
end
"""
end
defp render_attribute(attr) do
"""
@spec #{attr}(SnakeBridge.Ref.t()) :: {:ok, term()} | {:error, Snakepit.Error.t()}
def #{attr}(ref) do
SnakeBridge.Runtime.get_attr(ref, :#{attr})
end
"""
end
defp variadic_max_arity do
Application.get_env(:snakebridge, :variadic_max_arity, 8)
end
defp variadic_specs(name, max_arity, return_spec) do
Enum.map_join(0..max_arity, "\n", fn arity ->
args = variadic_term_args(arity)
variadic_spec_pair(name, args, return_spec)
end)
end
defp variadic_term_args(0), do: []
defp variadic_term_args(arity), do: Enum.map(1..arity, fn _ -> "term()" end)
defp variadic_spec_pair(name, args, return_spec) do
spec_no_opts =
"@spec #{name}(#{Enum.join(args, ", ")}) :: {:ok, #{return_spec}} | {:error, Snakepit.Error.t()}"
spec_with_opts =
"@spec #{name}(#{Enum.join(args ++ ["keyword()"], ", ")}) :: {:ok, #{return_spec}} | {:error, Snakepit.Error.t()}"
spec_no_opts <> "\n" <> spec_with_opts
end
defp variadic_method_specs(name, max_arity, return_spec) do
Enum.map_join(0..max_arity, "\n", fn arity ->
args = ["SnakeBridge.Ref.t()" | variadic_term_args(arity)]
variadic_spec_pair(name, args, return_spec)
end)
end
defp variadic_streaming_specs(name, max_arity) do
Enum.map_join(0..max_arity, "\n", fn arity ->
args = variadic_term_args(arity)
variadic_streaming_spec_pair(name, args)
end)
end
defp variadic_streaming_spec_pair(name, args) do
callback = "(term() -> any())"
spec_no_opts =
"@spec #{name}_stream(#{Enum.join(args ++ [callback], ", ")}) :: :ok | {:error, Snakepit.Error.t()}"
spec_with_opts =
"@spec #{name}_stream(#{Enum.join(args ++ ["keyword()", callback], ", ")}) :: :ok | {:error, Snakepit.Error.t()}"
spec_no_opts <> "\n" <> spec_with_opts
end
defp variadic_function_clauses(name, python_name, max_arity) do
Enum.map_join(0..max_arity, "\n\n", fn arity ->
args = variadic_args(arity)
args_list = variadic_args_list(args)
build_variadic_function_clause(name, python_name, arity, args, args_list)
end)
end
defp build_variadic_function_clause(name, python_name, 0, args, args_list) do
no_args_clause =
variadic_no_opts_clause(name, python_name, variadic_param_list(args), args_list)
opts_clause =
variadic_opts_clause(
name,
python_name,
variadic_param_list_with_opts(args),
args_list
)
no_args_clause <> "\n\n" <> opts_clause
end
defp build_variadic_function_clause(name, python_name, _arity, args, args_list) do
positional_clause =
variadic_no_opts_clause(name, python_name, variadic_param_list(args), args_list)
opts_clause =
variadic_opts_clause(
name,
python_name,
variadic_param_list_with_opts(args),
args_list
)
positional_clause <> "\n\n" <> opts_clause
end
defp variadic_constructor_clauses(max_arity) do
Enum.map_join(0..max_arity, "\n\n", fn arity ->
args = variadic_args(arity)
args_list = variadic_args_list(args)
build_variadic_constructor_clause(arity, args, args_list)
end)
end
defp build_variadic_constructor_clause(0, args, args_list) do
no_args_clause =
variadic_constructor_no_opts_clause(variadic_param_list(args), args_list)
opts_clause =
variadic_constructor_opts_clause(
variadic_param_list_with_opts(args),
args_list
)
no_args_clause <> "\n\n" <> opts_clause
end
defp build_variadic_constructor_clause(_arity, args, args_list) do
positional_clause =
variadic_constructor_no_opts_clause(variadic_param_list(args), args_list)
opts_clause =
variadic_constructor_opts_clause(
variadic_param_list_with_opts(args),
args_list
)
positional_clause <> "\n\n" <> opts_clause
end
defp variadic_method_clauses(name, python_name, max_arity) do
Enum.map_join(0..max_arity, "\n\n", fn arity ->
args = variadic_args(arity)
args_list = variadic_args_list(args)
build_variadic_method_clause(name, python_name, arity, args, args_list)
end)
end
defp build_variadic_method_clause(name, python_name, 0, args, args_list) do
no_args_clause =
variadic_method_no_opts_clause(
name,
python_name,
variadic_method_param_list(args),
args_list
)
opts_clause =
variadic_method_opts_clause(
name,
python_name,
variadic_method_param_list_with_opts(args),
args_list
)
no_args_clause <> "\n\n" <> opts_clause
end
defp build_variadic_method_clause(name, python_name, _arity, args, args_list) do
positional_clause =
variadic_method_no_opts_clause(
name,
python_name,
variadic_method_param_list(args),
args_list
)
opts_clause =
variadic_method_opts_clause(
name,
python_name,
variadic_method_param_list_with_opts(args),
args_list
)
positional_clause <> "\n\n" <> opts_clause
end
defp variadic_streaming_clauses(name, python_name, max_arity) do
Enum.map_join(0..max_arity, "\n\n", fn arity ->
args = variadic_args(arity)
args_list = variadic_args_list(args)
build_variadic_streaming_clause(name, python_name, arity, args, args_list)
end)
end
defp build_variadic_streaming_clause(name, python_name, 0, args, args_list) do
no_args_clause =
variadic_streaming_no_opts_clause(
name,
python_name,
variadic_streaming_param_list(args),
args_list
)
opts_clause =
variadic_streaming_opts_clause(
name,
python_name,
variadic_streaming_param_list_with_opts(args),
args_list
)
no_args_clause <> "\n\n" <> opts_clause
end
defp build_variadic_streaming_clause(name, python_name, _arity, args, args_list) do
positional_clause =
variadic_streaming_no_opts_clause(
name,
python_name,
variadic_streaming_param_list(args),
args_list
)
opts_clause =
variadic_streaming_opts_clause(
name,
python_name,
variadic_streaming_param_list_with_opts(args),
args_list
)
positional_clause <> "\n\n" <> opts_clause
end
defp variadic_no_opts_clause(name, python_name, params, args_list) do
call = runtime_call(name, python_name, args_list, "[]")
"""
def #{name}(#{params}) do
#{call}
end
"""
end
defp variadic_opts_clause(name, python_name, params, args_list) do
call = runtime_call(name, python_name, args_list, "opts")
"""
def #{name}(#{params}) when #{opts_guard()} do
#{call}
end
"""
end
defp variadic_constructor_no_opts_clause(params, args_list) do
call = "SnakeBridge.Runtime.call_class(__MODULE__, :__init__, #{args_list}, [])"
"""
def new(#{params}) do
#{call}
end
"""
end
defp variadic_constructor_opts_clause(params, args_list) do
call = "SnakeBridge.Runtime.call_class(__MODULE__, :__init__, #{args_list}, opts)"
"""
def new(#{params}) when #{opts_guard()} do
#{call}
end
"""
end
defp variadic_method_no_opts_clause(name, python_name, params, args_list) do
call =
"SnakeBridge.Runtime.call_method(ref, #{function_ref(name, python_name)}, #{args_list}, [])"
"""
def #{name}(#{params}) do
#{call}
end
"""
end
defp variadic_method_opts_clause(name, python_name, params, args_list) do
call =
"SnakeBridge.Runtime.call_method(ref, #{function_ref(name, python_name)}, #{args_list}, opts)"
"""
def #{name}(#{params}) when #{opts_guard()} do
#{call}
end
"""
end
defp variadic_streaming_no_opts_clause(name, python_name, params, args_list) do
call = runtime_stream_call(name, python_name, args_list, "[]")
"""
def #{name}_stream(#{params}) when is_function(callback, 1) do
#{call}
end
"""
end
defp variadic_streaming_opts_clause(name, python_name, params, args_list) do
call = runtime_stream_call(name, python_name, args_list, "opts")
"""
def #{name}_stream(#{params}) when #{opts_guard()} and is_function(callback, 1) do
#{call}
end
"""
end
defp variadic_args(arity) when is_integer(arity) and arity > 0 do
Enum.map(1..arity, &"arg#{&1}")
end
defp variadic_args(_arity), do: []
defp variadic_args_list([]), do: "[]"
defp variadic_args_list(args), do: "[" <> Enum.join(args, ", ") <> "]"
defp variadic_param_list([]), do: ""
defp variadic_param_list(args), do: Enum.join(args, ", ")
defp variadic_param_list_with_opts([]), do: "opts"
defp variadic_param_list_with_opts(args), do: Enum.join(args ++ ["opts"], ", ")
defp variadic_method_param_list(args), do: Enum.join(["ref" | args], ", ")
defp variadic_method_param_list_with_opts(args), do: Enum.join(["ref" | args] ++ ["opts"], ", ")
defp variadic_streaming_param_list(args), do: Enum.join(args ++ ["callback"], ", ")
defp variadic_streaming_param_list_with_opts(args),
do: Enum.join(args ++ ["opts", "callback"], ", ")
defp opts_guard do
"is_list(opts) and (opts == [] or (is_tuple(hd(opts)) and tuple_size(hd(opts)) == 2 and is_atom(elem(hd(opts), 0))))"
end
defp render_discovery(functions, classes) do
function_list =
functions
|> Enum.map_join(",\n ", fn info ->
name =
case info["elixir_name"] do
elixir when is_binary(elixir) ->
elixir
_ ->
info["name"] |> to_string() |> sanitize_function_name() |> elem(0)
end
arity = required_arity(info["parameters"] || [])
summary = info["docstring"] |> to_string() |> String.split("\n") |> List.first() || ""
"{:#{name}, #{arity}, __MODULE__, #{inspect(summary)}}"
end)
class_list =
classes
|> Enum.map_join(",\n ", fn info ->
module = class_module_name(info, nil)
doc = info["docstring"] |> to_string()
"{#{module}, #{inspect(doc)}}"
end)
"""
@doc false
def __functions__ do
[
#{function_list}
]
end
@doc false
def __classes__ do
[
#{class_list}
]
end
@doc false
def __search__(query) do
SnakeBridge.Docs.search(__MODULE__, query)
end
@doc false
def doc(function) do
SnakeBridge.Docs.get(__MODULE__, function)
end
"""
end
@spec format_docstring(String.t() | nil, list(), map() | nil) :: String.t()
def format_docstring(raw_doc, params \\ [], return_type \\ nil)
def format_docstring(nil, _params, _return_type), do: ""
def format_docstring("", _params, _return_type), do: ""
def format_docstring(raw_doc, params, return_type) when is_binary(raw_doc) do
base =
raw_doc
|> RstParser.parse()
|> MarkdownConverter.convert()
extras = format_param_docs(params, return_type)
if extras == "" do
base
else
base <> "\n\n" <> extras
end
rescue
_ ->
extras = format_param_docs(params, return_type)
if extras == "" do
raw_doc
else
raw_doc <> "\n\n" <> extras
end
end
@spec build_params(list(), map()) :: %{
required: list(map()),
has_args: boolean(),
has_opts: boolean(),
is_variadic: boolean(),
required_keyword_only: list(map()),
optional_keyword_only: list(map()),
has_var_keyword: boolean()
}
def build_params(params, info \\ %{}) when is_list(params) do
signature_available = Map.get(info, "signature_available", true)
if params == [] and not signature_available do
%{
required: [],
has_args: true,
has_opts: true,
is_variadic: true,
required_keyword_only: [],
optional_keyword_only: [],
has_var_keyword: false
}
else
required =
params
|> Enum.filter(&required_positional?/1)
|> Enum.map(&param_entry/1)
required_kw_only = Enum.filter(params, &keyword_only_required?/1)
optional_kw_only = Enum.filter(params, &keyword_only_optional?/1)
has_args =
Enum.any?(params, fn param ->
optional_positional?(param) or varargs?(param)
end)
%{
required: required,
has_args: has_args,
has_opts: true,
is_variadic: false,
required_keyword_only: required_kw_only,
optional_keyword_only: optional_kw_only,
has_var_keyword: Enum.any?(params, &kwargs?/1)
}
end
end
defp required_arity(params) do
params
|> Enum.filter(fn param ->
param_kind(param) in ["POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"]
end)
|> Enum.reject(&param_default?/1)
|> length()
end
defp param_list(param_names, has_args, has_opts, args_name) do
param_names
|> maybe_add_args(has_args, args_name)
|> maybe_add_opts(has_opts)
|> Enum.join(", ")
end
defp runtime_call(name, python_name, args, opts_expr \\ "opts") do
"SnakeBridge.Runtime.call(__MODULE__, #{function_ref(name, python_name)}, #{args}, #{opts_expr})"
end
defp runtime_stream_call(name, python_name, args, opts_expr \\ "opts") do
"SnakeBridge.Runtime.stream(__MODULE__, #{function_ref(name, python_name)}, #{args}, #{opts_expr}, callback)"
end
defp function_ref(name, python_name) do
if python_name == name do
":#{name}"
else
inspect(python_name)
end
end
defp function_spec(name, param_entries, has_args, return_type) do
args =
param_entries
|> Enum.map(&param_type_spec/1)
|> maybe_add_args_spec(has_args)
|> Kernel.++(["keyword()"])
return_spec = type_spec_string(return_type)
"@spec #{name}(#{Enum.join(args, ", ")}) :: {:ok, #{return_spec}} | {:error, Snakepit.Error.t()}"
end
defp method_spec(name, param_entries, has_args, return_type) do
args =
[ref_type_spec()]
|> Kernel.++(Enum.map(param_entries, &param_type_spec/1))
|> maybe_add_args_spec(has_args)
|> Kernel.++(["keyword()"])
return_spec = type_spec_string(return_type)
"@spec #{name}(#{Enum.join(args, ", ")}) :: {:ok, #{return_spec}} | {:error, Snakepit.Error.t()}"
end
defp runtime_method_call(name, python_name, param_names, has_args, args_name) do
args = args_expr(param_names, has_args, args_name)
"SnakeBridge.Runtime.call_method(ref, #{function_ref(name, python_name)}, #{args}, opts)"
end
defp method_param_suffix(param_names, has_args, has_opts, args_name) do
suffix =
param_names
|> maybe_add_args(has_args, args_name)
|> maybe_add_opts(has_opts)
", " <> Enum.join(suffix, ", ")
end
defp args_expr(param_names, true, args_name) do
base = "[" <> Enum.join(param_names, ", ") <> "]"
base <> " ++ List.wrap(" <> args_name <> ")"
end
defp args_expr(param_names, false, _args_name) do
"[" <> Enum.join(param_names, ", ") <> "]"
end
defp maybe_add_args(items, true, args_name), do: items ++ ["#{args_name} \\\\ []"]
defp maybe_add_args(items, false, _args_name), do: items
defp maybe_add_opts(items, _has_opts), do: items ++ ["opts \\\\ []"]
defp maybe_add_args_spec(items, true), do: items ++ ["list(term())"]
defp maybe_add_args_spec(items, false), do: items
defp normalize_args_line(true, args_name, indent) do
String.duplicate(" ", indent) <>
"{#{args_name}, opts} = SnakeBridge.Runtime.normalize_args_opts(#{args_name}, opts)\n"
end
defp normalize_args_line(false, _args_name, _indent), do: ""
defp keyword_only_validation(required_keyword_only, indent) do
names =
required_keyword_only
|> Enum.map(&param_name/1)
|> Enum.reject(&is_nil/1)
if names == [] do
""
else
padding = String.duplicate(" ", indent)
"""
#{padding}kw_keys = opts |> Keyword.keys() |> Enum.map(&to_string/1)
#{padding}missing_kw = #{inspect(names)} |> Enum.reject(&(&1 in kw_keys))
#{padding}if missing_kw != [] do
#{padding} raise ArgumentError,
#{padding} "Missing required keyword-only arguments: " <> Enum.join(missing_kw, ", ")
#{padding}end
"""
end
end
defp extra_args_name(param_names) do
if "args" in param_names do
"extra_args"
else
"args"
end
end
defp param_entry(param) do
%{
name: sanitize_name(param),
type: param_type(param)
}
end
defp param_type(%{"type" => type}) when is_map(type), do: type
defp param_type(%{type: type}) when is_map(type), do: type
defp param_type(_), do: %{"type" => "any"}
defp param_type_spec(%{type: type}), do: type_spec_string(type)
defp param_type_spec(_), do: "term()"
defp type_spec_string(type) do
type
|> TypeMapper.to_spec()
|> Macro.to_string()
end
defp ref_type_spec do
"SnakeBridge.Ref.t()"
end
defp required_positional?(param) do
kind = param_kind(param)
kind in ["POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"] and not param_default?(param)
end
defp optional_positional?(param) do
kind = param_kind(param)
kind in ["POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"] and param_default?(param)
end
defp varargs?(param), do: param_kind(param) == "VAR_POSITIONAL"
defp kwargs?(param), do: param_kind(param) == "VAR_KEYWORD"
defp keyword_only_required?(param) do
param_kind(param) == "KEYWORD_ONLY" and not param_default?(param)
end
defp keyword_only_optional?(param) do
param_kind(param) == "KEYWORD_ONLY" and param_default?(param)
end
defp param_kind(%{"kind" => kind}) when is_binary(kind), do: String.upcase(kind)
defp param_kind(%{kind: kind}) when is_binary(kind), do: String.upcase(kind)
defp param_kind(%{kind: kind}), do: kind
defp param_kind(_), do: nil
defp param_default?(%{"default" => _}), do: true
defp param_default?(%{default: _}), do: true
defp param_default?(_), do: false
defp sanitize_name(%{"name" => name}), do: sanitize_name(name)
defp sanitize_name(name) when is_binary(name) do
name
|> Macro.underscore()
|> String.replace(~r/[^a-z0-9_]/, "_")
|> ensure_identifier()
end
defp sanitize_function_name(python_name) when is_binary(python_name) do
elixir_name =
python_name
|> Macro.underscore()
|> String.replace(~r/[^a-z0-9_?!]/, "_")
|> ensure_valid_function_identifier()
elixir_name =
if elixir_name in @reserved_words do
"py_#{elixir_name}"
else
elixir_name
end
{elixir_name, python_name}
end
@doc false
@spec sanitize_method_name(String.t()) :: {String.t(), String.t()} | nil
def sanitize_method_name(python_name) when is_binary(python_name) do
cond do
Map.has_key?(@dunder_mappings, python_name) ->
{Map.get(@dunder_mappings, python_name), python_name}
String.starts_with?(python_name, "__") and String.ends_with?(python_name, "__") ->
nil
python_name in @reserved_words ->
{"py_#{python_name}", python_name}
true ->
elixir_name =
python_name
|> Macro.underscore()
|> String.replace(~r/[^a-z0-9_?!]/, "_")
|> ensure_valid_function_identifier()
{elixir_name, python_name}
end
end
defp ensure_valid_function_identifier(""), do: "_"
defp ensure_valid_function_identifier(name) do
if String.match?(name, ~r/^[a-z_][a-z0-9_?!]*$/) do
name
else
"_" <> name
end
end
defp ensure_identifier(<<first::utf8, _rest::binary>> = name)
when first in ?a..?z or first == ?_ do
name
end
defp ensure_identifier(<<first::utf8, _rest::binary>> = name) when first in ?0..?9 do
"_" <> name
end
defp ensure_identifier(""), do: "_unnamed"
defp ensure_identifier(name), do: "_" <> name
defp module_to_string(module) when is_atom(module),
do: module |> Module.split() |> Enum.join(".")
defp module_to_string(module) when is_binary(module), do: module
defp class_sort_key(info) do
{class_module_name(info, nil), class_name(info)}
end
defp class_name(info) do
info["name"] || info["class"] || "Class"
end
defp class_python_module(info, library) do
info["python_module"] || library.python_name
end
defp class_module_name(info, nil) do
info["module"] || class_name(info)
end
defp class_module_name(info, library) do
case info["module"] do
module when is_binary(module) ->
module
_ ->
python_module = class_python_module(info, library)
python_parts = String.split(python_module, ".")
library_parts = String.split(library.python_name, ".")
extra_parts = Enum.drop(python_parts, length(library_parts))
extra_parts = drop_class_suffix(extra_parts, class_name(info))
library.module_name
|> Module.split()
|> Kernel.++(Enum.map(extra_parts, &Macro.camelize/1))
|> Kernel.++([class_name(info)])
|> Module.concat()
|> module_to_string()
end
end
defp relative_module_name(library, module_name) when is_binary(module_name) do
base = module_to_string(library.module_name) <> "."
if String.starts_with?(module_name, base) do
String.replace_prefix(module_name, base, "")
else
module_name
end
end
defp drop_class_suffix(parts, class_name) when is_list(parts) and is_binary(class_name) do
class_suffix = Macro.underscore(class_name)
case List.last(parts) do
^class_suffix -> Enum.drop(parts, -1)
_ -> parts
end
end
defp drop_class_suffix(parts, _class_name), do: parts
defp indent(text, spaces) do
prefix = String.duplicate(" ", spaces)
text
|> String.split("\n")
|> Enum.map_join("\n", fn line -> prefix <> line end)
end
defp format_param_docs(params, return_type) do
param_lines =
params
|> Enum.map(&param_doc_line/1)
|> Enum.reject(&is_nil/1)
sections =
[]
|> maybe_add_params_section(param_lines)
|> maybe_add_return_section(return_type)
Enum.join(sections, "\n\n")
end
defp maybe_add_params_section(sections, []), do: sections
defp maybe_add_params_section(sections, lines) do
sections ++ ["Parameters:\n" <> Enum.join(lines, "\n")]
end
defp maybe_add_return_section(sections, nil), do: sections
defp maybe_add_return_section(sections, return_type) do
return_spec = type_spec_string(return_type)
sections ++ ["Returns:\n- `#{return_spec}`"]
end
defp param_doc_line(param) do
case param_name(param) do
nil -> nil
name -> format_param_doc_line(name, param)
end
end
defp format_param_doc_line(name, param) do
type = param_type(param) |> type_spec_string()
kind_fragment = param_kind_fragment(param)
default_fragment = param_default_fragment(param)
"- `#{name}` (#{type}#{kind_fragment}#{default_fragment})"
end
defp param_kind_fragment(param) do
case param_kind(param) do
"KEYWORD_ONLY" -> keyword_only_fragment(param)
_ -> ""
end
end
defp keyword_only_fragment(param) do
if param_default?(param), do: " keyword-only", else: " keyword-only, required"
end
defp param_default_fragment(param) do
case param_default_value(param) do
nil -> ""
default -> " default: #{default}"
end
end
defp param_name(%{"name" => name}) when is_binary(name), do: name
defp param_name(%{name: name}) when is_binary(name), do: name
defp param_name(_), do: nil
defp param_default_value(%{"default" => default}), do: default
defp param_default_value(%{default: default}), do: default
defp param_default_value(_), do: nil
end
</file>
<file path="snakebridge/helper_generator.ex">
defmodule SnakeBridge.HelperGenerator do
@moduledoc """
Generates Elixir helper wrappers from registry data.
"""
@spec render_library(String.t(), list(), keyword()) :: String.t()
def render_library(library, helpers, opts \\ []) when is_binary(library) do
version = Keyword.get(opts, :version, Application.spec(:snakebridge, :vsn) |> to_string())
header = """
# Generated by SnakeBridge v#{version} - DO NOT EDIT MANUALLY
# Regenerate with: mix compile
# Helper library: #{library}
"""
modules = render_modules(library, helpers)
"""
#{header}
#{modules}
"""
|> Code.format_string!()
|> IO.iodata_to_binary()
end
@spec generate_helpers(list(), SnakeBridge.Config.t()) :: :ok
def generate_helpers(helpers, config) do
grouped = group_by_library(helpers)
if map_size(grouped) == 0 do
:ok
else
dir = Path.join(config.generated_dir, "helpers")
File.mkdir_p!(dir)
Enum.each(grouped, fn {library, entries} ->
source = render_library(library, entries, version: Application.spec(:snakebridge, :vsn))
path = Path.join(dir, "#{library}.ex")
SnakeBridge.Generator.write_if_changed(path, source)
end)
:ok
end
end
defp group_by_library(helpers) do
helpers
|> Enum.map(&helper_name/1)
|> Enum.zip(helpers)
|> Enum.reduce(%{}, fn
{nil, _}, acc ->
acc
{name, helper}, acc ->
case String.split(name, ".", parts: 2) do
[library, _rest] ->
Map.update(acc, library, [helper], fn existing -> [helper | existing] end)
_ ->
acc
end
end)
|> Map.new(fn {library, entries} ->
{library, Enum.reverse(entries)}
end)
end
defp helper_name(%{"name" => name}) when is_binary(name), do: name
defp helper_name(%{name: name}) when is_binary(name), do: name
defp helper_name(_), do: nil
defp render_modules(library, helpers) do
root = root_module(library)
helpers
|> Enum.map(&normalize_helper(library, &1))
|> Enum.group_by(& &1.module)
|> Enum.sort_by(fn {module, _} -> module end)
|> Enum.map_join("\n\n", fn {module, entries} ->
moduledoc = if module == root, do: root_moduledoc(library), else: " @moduledoc false"
functions =
entries |> Enum.sort_by(& &1.function) |> Enum.map_join("\n\n", &render_function/1)
"""
defmodule #{module} do
#{moduledoc}
#{indent(functions, 2)}
end
"""
end)
end
defp root_module(library) do
[Macro.camelize(library), "Helpers"] |> Enum.join(".")
end
defp root_moduledoc(library) do
" @moduledoc #{inspect("Helper wrappers for `#{library}`.")}"
end
defp normalize_helper(library, helper) do
helper_name = helper_name(helper) || ""
segments = String.split(helper_name, ".")
module_segments =
segments
|> Enum.drop(1)
|> Enum.drop(-1)
|> Enum.map(&Macro.camelize/1)
module = ([Macro.camelize(library), "Helpers"] ++ module_segments) |> Enum.join(".")
%{
helper: helper_name,
module: module,
function: helper_function_name(segments),
parameters: helper_parameters(helper),
docstring: helper_docstring(helper)
}
end
defp helper_function_name(segments) do
segments
|> List.last()
|> sanitize_name()
end
defp helper_parameters(%{"parameters" => params}) when is_list(params), do: params
defp helper_parameters(%{parameters: params}) when is_list(params), do: params
defp helper_parameters(_), do: []
defp helper_docstring(%{"docstring" => doc}) when is_binary(doc), do: doc
defp helper_docstring(%{docstring: doc}) when is_binary(doc), do: doc
defp helper_docstring(_), do: ""
defp render_function(%{helper: helper_name, function: name, parameters: params, docstring: doc}) do
plan = SnakeBridge.Generator.build_params(params)
param_names = Enum.map(plan.required, & &1.name)
args_name = extra_args_name(param_names)
args = args_expr(param_names, plan.has_args, args_name)
call = helper_call(helper_name, args)
spec = function_spec(name, plan.required, plan.has_args)
normalize = normalize_args_line(plan.has_args, args_name, 8)
docstring = String.trim(doc)
"""
@doc #{inspect(docstring)}
#{spec}
def #{name}(#{param_list(param_names, plan.has_args, plan.has_opts, args_name)}) do
#{normalize} #{call}
end
"""
end
defp helper_call(name, args) do
"SnakeBridge.Runtime.call_helper(\"#{name}\", #{args}, opts)"
end
defp function_spec(name, param_entries, has_args) do
args = Enum.map(param_entries, fn _ -> "term()" end)
args = if has_args, do: args ++ ["list(term())"], else: args
args = args ++ ["keyword()"]
"@spec #{name}(#{Enum.join(args, ", ")}) :: {:ok, term()} | {:error, term()}"
end
defp param_list(param_names, has_args, has_opts, args_name) do
param_names
|> maybe_add_args(has_args, args_name)
|> maybe_add_opts(has_opts)
|> Enum.join(", ")
end
defp args_expr(param_names, true, args_name) do
base = "[" <> Enum.join(param_names, ", ") <> "]"
base <> " ++ List.wrap(" <> args_name <> ")"
end
defp args_expr(param_names, false, _args_name) do
"[" <> Enum.join(param_names, ", ") <> "]"
end
defp maybe_add_args(items, true, args_name), do: items ++ ["#{args_name} \\\\ []"]
defp maybe_add_args(items, false, _args_name), do: items
defp maybe_add_opts(items, _has_opts), do: items ++ ["opts \\\\ []"]
defp normalize_args_line(true, args_name, indent) do
String.duplicate(" ", indent) <>
"{#{args_name}, opts} = SnakeBridge.Runtime.normalize_args_opts(#{args_name}, opts)\n"
end
defp normalize_args_line(false, _args_name, _indent), do: ""
defp extra_args_name(param_names) do
if "args" in param_names do
"extra_args"
else
"args"
end
end
defp sanitize_name(%{"name" => name}), do: sanitize_name(name)
defp sanitize_name(name) when is_binary(name) do
name
|> Macro.underscore()
|> String.replace(~r/[^a-z0-9_]/, "_")
|> ensure_identifier()
end
defp ensure_identifier(<<first::utf8, _rest::binary>> = name)
when first in ?a..?z or first == ?_ do
name
end
defp ensure_identifier(<<first::utf8, _rest::binary>> = name) when first in ?0..?9 do
"_" <> name
end
defp ensure_identifier(""), do: "_unnamed"
defp ensure_identifier(name), do: "_" <> name
defp indent(text, spaces) do
prefix = String.duplicate(" ", spaces)
text
|> String.split("\n")
|> Enum.map_join("\n", fn line -> prefix <> line end)
end
end
</file>
<file path="snakebridge/helper_not_found_error.ex">
defmodule SnakeBridge.HelperNotFoundError do
@moduledoc """
Error raised when a helper name is not registered.
"""
defexception [:message, :helper, :suggestion]
@type t :: %__MODULE__{
message: String.t(),
helper: String.t() | nil,
suggestion: String.t() | nil
}
@impl Exception
def message(%__MODULE__{message: message, suggestion: suggestion}) do
if suggestion do
message <> "\n\nSuggestion: " <> suggestion
else
message
end
end
@spec new(String.t()) :: t()
def new(helper) do
%__MODULE__{
helper: helper,
message: "Helper '#{helper}' not found",
suggestion: "Add a helper under priv/python/helpers or enable the helper pack"
}
end
end
</file>
<file path="snakebridge/helper_registry_error.ex">
defmodule SnakeBridge.HelperRegistryError do
@moduledoc """
Error raised when helper registry discovery fails.
"""
defexception [:type, :message, :python_error, :suggestion]
@type t :: %__MODULE__{
type: :load_failed,
message: String.t(),
python_error: String.t() | nil,
suggestion: String.t() | nil
}
@impl Exception
def message(%__MODULE__{message: message, suggestion: suggestion}) do
if suggestion do
message <> "\n\nSuggestion: " <> suggestion
else
message
end
end
@doc """
Build an error from Python stderr output.
"""
@spec from_python_output(String.t()) :: t()
def from_python_output(output) when is_binary(output) do
%__MODULE__{
type: :load_failed,
message: "Helper registry failed to load",
python_error: String.trim(output),
suggestion: "Check helper paths or disable the helper pack"
}
end
end
</file>
<file path="snakebridge/helpers.ex">
defmodule SnakeBridge.Helpers do
@moduledoc """
Helper registry discovery and configuration for SnakeBridge.
"""
alias SnakeBridge.{Config, HelperRegistryError}
@type helper_info :: map()
@spec discover() :: {:ok, [helper_info()]} | {:error, term()}
def discover do
discover(runtime_config())
end
@spec discover(Config.t() | map()) :: {:ok, [helper_info()]} | {:error, term()}
def discover(%Config{} = config) do
discover(config_to_map(config))
end
def discover(%{} = config) do
payload = payload_config(config, include_adapter_root: true)
case python_runner().run(helper_index_script(), [Jason.encode!(payload)], runner_opts()) do
{:ok, output} ->
parse_output(output)
{:error, {:python_exit, _status, output}} ->
{:error, HelperRegistryError.from_python_output(output)}
{:error, reason} ->
{:error, reason}
end
end
@spec runtime_config() :: map()
def runtime_config do
%{
helper_paths: Application.get_env(:snakebridge, :helper_paths, ["priv/python/helpers"]),
helper_pack_enabled: Application.get_env(:snakebridge, :helper_pack_enabled, true),
helper_allowlist: Application.get_env(:snakebridge, :helper_allowlist, :all),
inline_enabled: Application.get_env(:snakebridge, :inline_enabled, false)
}
end
@spec enabled?(map()) :: boolean()
def enabled?(%{} = config) do
normalized = normalize_config(config)
normalized.helper_allowlist != [] and
(normalized.helper_pack_enabled == true or normalized.helper_paths != [])
end
@spec payload_config(map(), keyword()) :: map()
def payload_config(%{} = config, opts \\ []) do
normalized = normalize_config(config)
payload = %{
"helper_paths" => normalized.helper_paths,
"helper_pack_enabled" => normalized.helper_pack_enabled,
"helper_allowlist" => allowlist_payload(normalized.helper_allowlist)
}
if Keyword.get(opts, :include_adapter_root, false) do
Map.put(payload, "adapter_root", adapter_root())
else
payload
end
end
defp config_to_map(%Config{} = config) do
%{
helper_paths: config.helper_paths,
helper_pack_enabled: config.helper_pack_enabled,
helper_allowlist: config.helper_allowlist,
inline_enabled: config.inline_enabled
}
end
defp normalize_config(%{} = config) do
%{
helper_paths: normalize_paths(Map.get(config, :helper_paths, ["priv/python/helpers"])),
helper_pack_enabled: Map.get(config, :helper_pack_enabled, true) == true,
helper_allowlist: normalize_allowlist(Map.get(config, :helper_allowlist, :all)),
inline_enabled: Map.get(config, :inline_enabled, false) == true
}
end
defp normalize_paths(paths) do
paths
|> List.wrap()
|> Enum.map(&to_string/1)
|> Enum.map(&Path.expand/1)
|> Enum.uniq()
end
defp normalize_allowlist(:all), do: :all
defp normalize_allowlist("all"), do: :all
defp normalize_allowlist(nil), do: :all
defp normalize_allowlist(:none), do: []
defp normalize_allowlist(list) when is_list(list) do
list
|> Enum.map(&to_string/1)
|> Enum.uniq()
end
defp normalize_allowlist(other), do: [to_string(other)]
defp allowlist_payload(:all), do: "all"
defp allowlist_payload(list) when is_list(list), do: list
defp adapter_root do
:snakebridge
|> :code.priv_dir()
|> to_string()
|> Path.join("python")
end
defp python_runner do
Application.get_env(:snakebridge, :python_runner, SnakeBridge.PythonRunner.System)
end
defp runner_opts do
config = Application.get_env(:snakebridge, :helper_registry, [])
Keyword.take(config, [:timeout, :env, :cd])
end
defp parse_output(output) do
case Jason.decode(output) do
{:ok, results} when is_list(results) -> {:ok, results}
{:ok, %{"error" => error}} -> {:error, error}
{:error, _} -> {:error, {:json_parse, output}}
end
end
defp helper_index_script do
~S"""
import json
import sys
config = json.loads(sys.argv[1])
adapter_root = config.pop("adapter_root", None)
if adapter_root:
sys.path.insert(0, adapter_root)
from snakebridge_adapter import helper_registry_index
index = helper_registry_index(config)
print(json.dumps(index))
"""
end
end
</file>
<file path="snakebridge/introspection_error.ex">
defmodule SnakeBridge.IntrospectionError do
@moduledoc """
Structured error for Python introspection failures.
"""
defexception [:type, :package, :message, :python_error, :suggestion]
@type t :: %__MODULE__{
type: :package_not_found | :import_error | :timeout | :introspection_bug,
package: String.t() | nil,
message: String.t(),
python_error: String.t() | nil,
suggestion: String.t() | nil
}
@impl Exception
def message(%__MODULE__{message: message, suggestion: suggestion}) do
if suggestion do
message <> "\n\nSuggestion: " <> suggestion
else
message
end
end
@doc """
Parses Python stderr to classify the error.
"""
@spec from_python_output(String.t(), String.t()) :: t()
def from_python_output(output, package) when is_binary(output) do
output = String.trim(output)
cond do
module_not_found?(output) ->
missing = extract_missing_package(output) || package
%__MODULE__{
type: :package_not_found,
package: missing,
message: "Package '#{missing}' not found",
python_error: output,
suggestion: "Run: mix snakebridge.setup"
}
import_error?(output) ->
%__MODULE__{
type: :import_error,
package: package,
message: extract_import_error(output),
python_error: output,
suggestion: "Check library dependencies or install optional extras"
}
timeout_error?(output) ->
%__MODULE__{
type: :timeout,
package: package,
message: extract_timeout_error(output),
python_error: output,
suggestion: "Increase introspection timeout or retry"
}
true ->
%__MODULE__{
type: :introspection_bug,
package: package,
message: extract_generic_error(output),
python_error: output,
suggestion: "Please report this issue with the Python error output"
}
end
end
defp module_not_found?(output) do
String.contains?(output, "ModuleNotFoundError")
end
defp import_error?(output) do
String.contains?(output, "ImportError")
end
defp timeout_error?(output) do
String.contains?(output, "TimeoutError") or String.contains?(output, "timed out")
end
defp extract_missing_package(output) do
case Regex.run(~r/ModuleNotFoundError: No module named ['"]([^'"]+)['"]/m, output) do
[_, name] -> name
_ -> nil
end
end
defp extract_import_error(output) do
case Regex.run(~r/ImportError: (.+)$/m, output) do
[_, msg] -> String.trim(msg)
_ -> "ImportError"
end
end
defp extract_timeout_error(output) do
case Regex.run(~r/TimeoutError: (.+)$/m, output) do
[_, msg] -> String.trim(msg)
_ -> "Introspection timed out"
end
end
defp extract_generic_error(output) do
output
|> String.split("\n", trim: true)
|> Enum.reverse()
|> Enum.find(&String.contains?(&1, "Error"))
|> case do
nil -> "Unexpected error during introspection"
line -> String.trim(line)
end
end
end
</file>
<file path="snakebridge/introspector.ex">
defmodule SnakeBridge.Introspector do
@moduledoc """
Introspects Python functions using the standalone introspection script.
"""
alias SnakeBridge.IntrospectionError
@type function_name :: atom() | String.t()
@spec introspect(SnakeBridge.Config.Library.t() | map(), [function_name()]) ::
{:ok, map()} | {:error, term()}
def introspect(library, functions) when is_list(functions) do
introspect(library, functions, nil)
end
@spec introspect(SnakeBridge.Config.Library.t() | map(), [function_name()], String.t() | nil) ::
{:ok, map()} | {:error, term()}
def introspect(library, functions, python_module) when is_list(functions) do
case introspect_symbols(library, functions, python_module) do
{:ok, infos} ->
{:ok, group_symbols(infos)}
{:error, _reason} = error ->
error
end
end
@spec introspect_symbols(
SnakeBridge.Config.Library.t() | map(),
[function_name()],
String.t() | nil
) :: {:ok, list()} | {:error, term()}
defp introspect_symbols(library, functions, python_module) when is_list(functions) do
start_time = System.monotonic_time()
library_label = library_label(library)
SnakeBridge.Telemetry.introspect_start(library_label, length(functions))
python_name = python_module || library_python_name(library)
functions_json = Jason.encode!(Enum.map(functions, &to_string/1))
result =
case run_script(
[
script_path(),
"--module",
python_name,
"--symbols",
functions_json
],
runner_opts()
) do
{output, 0} -> parse_output(output)
{output, _status} -> handle_python_error(output, python_name)
end
symbols =
case result do
{:ok, results} when is_list(results) -> length(results)
_ -> 0
end
SnakeBridge.Telemetry.introspect_stop(
start_time,
library_label,
symbols,
0,
System.monotonic_time() - start_time
)
result
end
@spec introspect_batch([
{SnakeBridge.Config.Library.t() | map(), String.t(), [function_name()]}
]) ::
list(
{SnakeBridge.Config.Library.t() | map(), {:ok, list()} | {:error, term()}, String.t()}
)
def introspect_batch(libs_and_functions) when is_list(libs_and_functions) do
nested_config = Application.get_env(:snakebridge, :introspector, [])
default_timeout = Application.get_env(:snakebridge, :introspector_timeout, 30_000)
default_concurrency =
Application.get_env(:snakebridge, :introspector_max_concurrency, System.schedulers_online())
max_concurrency = Keyword.get(nested_config, :max_concurrency, default_concurrency)
timeout = Keyword.get(nested_config, :timeout, default_timeout)
results =
libs_and_functions
|> Task.async_stream(
fn {library, python_module, functions} ->
{library, introspect_symbols(library, functions, python_module), python_module}
end,
max_concurrency: max_concurrency,
timeout: timeout
)
|> Enum.to_list()
libs_and_functions
|> Enum.zip(results)
|> Enum.map(fn
{{_library, _python_module, _functions}, {:ok, result}} ->
result
{{library, python_module, functions}, {:exit, reason}} ->
{library, {:error, batch_error(library, python_module, functions, reason)}, python_module}
{{library, python_module, functions}, {:error, reason}} ->
{library, {:error, batch_error(library, python_module, functions, reason)}, python_module}
end)
end
@doc """
Introspects a single attribute on a module to determine its type.
"""
@spec introspect_attribute(String.t() | atom(), String.t() | atom(), keyword()) ::
{:ok, map()} | {:error, term()}
def introspect_attribute(module_path, attr_name, opts \\ []) do
runner_opts = Keyword.merge(runner_opts(), opts)
case run_script(
[
script_path(),
"--module",
to_string(module_path),
"--attribute",
to_string(attr_name)
],
runner_opts
) do
{output, 0} -> parse_attribute_output(output)
{output, _status} -> handle_python_error(output, to_string(module_path))
end
end
defp library_python_name(%{python_name: python_name}) when is_binary(python_name),
do: python_name
defp library_python_name(%{name: name}) when is_atom(name), do: Atom.to_string(name)
defp library_python_name(name) when is_atom(name), do: Atom.to_string(name)
defp library_python_name(name) when is_binary(name), do: name
defp library_python_name(_), do: "unknown"
defp script_path do
Path.join(to_string(:code.priv_dir(:snakebridge)), "python/introspect.py")
end
defp library_label(%{name: name}) when is_atom(name), do: name
defp library_label(name) when is_atom(name), do: name
defp library_label(_), do: :unknown
defp runner_opts do
config = Application.get_env(:snakebridge, :introspector, [])
Keyword.take(config, [:timeout, :env, :cd])
end
defp parse_output(output) do
case Jason.decode(output) do
{:ok, results} when is_list(results) -> {:ok, results}
{:ok, %{"error" => error}} -> {:error, error}
{:error, _} -> {:error, {:json_parse, output}}
end
end
defp parse_attribute_output(output) do
case Jason.decode(output) do
{:ok, %{"error" => error}} -> {:error, error}
{:ok, result} when is_map(result) -> {:ok, result}
{:error, _} -> {:error, {:json_parse, output}}
end
end
defp group_symbols(infos) do
infos
|> Enum.reduce(%{"functions" => [], "classes" => [], "attributes" => []}, fn info, acc ->
case info["type"] || info[:type] do
"class" ->
Map.update!(acc, "classes", &[info | &1])
"attribute" ->
Map.update!(acc, "attributes", &[info | &1])
_ ->
Map.update!(acc, "functions", &[info | &1])
end
end)
|> Map.update!("functions", &Enum.reverse/1)
|> Map.update!("classes", &Enum.reverse/1)
|> Map.update!("attributes", &Enum.reverse/1)
end
defp run_script(args, opts) do
{timeout, opts} = Keyword.pop(opts, :timeout)
cmd_opts = [stderr_to_stdout: true]
env = build_env(opts)
cmd_opts = if env == [], do: cmd_opts, else: Keyword.put(cmd_opts, :env, env)
cmd_opts = maybe_put_opt(cmd_opts, :cd, opts)
run = fn -> System.cmd(python_executable(), args, cmd_opts) end
if is_integer(timeout) do
task = Task.async(run)
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, result} -> result
nil -> {"Command timed out after #{timeout}ms", 124}
end
else
run.()
end
end
defp python_executable do
Application.get_env(:snakebridge, :python_executable) ||
resolve_snakepit_executable() ||
System.find_executable("python3") ||
"python3"
end
defp resolve_snakepit_executable do
if Code.ensure_loaded?(Snakepit.PythonRuntime) and
function_exported?(Snakepit.PythonRuntime, :resolve_executable, 0) do
case Snakepit.PythonRuntime.resolve_executable() do
{:ok, python, _meta} -> python
_ -> nil
end
else
nil
end
end
defp build_env(opts) do
runtime_env =
if Code.ensure_loaded?(Snakepit.PythonRuntime) and
function_exported?(Snakepit.PythonRuntime, :runtime_env, 0) do
Snakepit.PythonRuntime.runtime_env()
else
[]
end
extra_env =
if Code.ensure_loaded?(Snakepit.PythonRuntime) and
function_exported?(Snakepit.PythonRuntime, :config, 0) do
Snakepit.PythonRuntime.config() |> Map.get(:extra_env, %{}) |> Enum.to_list()
else
[]
end
user_env =
opts
|> Keyword.get(:env, %{})
|> Enum.to_list()
runtime_env ++ extra_env ++ user_env
end
defp maybe_put_opt(cmd_opts, key, opts) do
case Keyword.get(opts, key) do
nil -> cmd_opts
value -> Keyword.put(cmd_opts, key, value)
end
end
defp handle_python_error(output, package) do
{:error, IntrospectionError.from_python_output(output, package)}
end
defp batch_error(library, python_module, functions, reason) do
%{
type: :introspection_batch_failed,
library: library_label(library),
python_module: python_module,
functions: functions,
reason: reason
}
end
end
</file>
<file path="snakebridge/invalid_ref_error.ex">
defmodule SnakeBridge.InvalidRefError do
@moduledoc """
Raised when a ref payload is malformed or invalid.
This occurs when the ref structure is missing required fields or has
an unrecognized format.
## Fields
- `:reason` - Why the ref is invalid (atom or string)
- `:message` - Human-readable error message
"""
defexception [:reason, :message]
@type t :: %__MODULE__{
reason: atom() | String.t() | nil,
message: String.t()
}
@impl Exception
def exception(opts) when is_list(opts) do
reason = Keyword.get(opts, :reason)
message = Keyword.get(opts, :message) || build_message(reason)
%__MODULE__{
reason: reason,
message: message
}
end
@impl Exception
def message(%__MODULE__{message: message}), do: message
defp build_message(reason) when is_atom(reason) do
case reason do
:missing_id -> "Invalid SnakeBridge reference: missing 'id' field"
:missing_type -> "Invalid SnakeBridge reference: missing '__type__' field"
:invalid_format -> "Invalid SnakeBridge reference: unrecognized payload format"
_ -> "Invalid SnakeBridge reference: #{reason}"
end
end
defp build_message(reason) when is_binary(reason) do
"Invalid SnakeBridge reference: #{reason}"
end
defp build_message(_) do
"Invalid SnakeBridge reference"
end
end
</file>
<file path="snakebridge/ledger.ex">
defmodule SnakeBridge.Ledger do
@moduledoc """
Wrapper for recording dynamic calls through Snakepit.
"""
@spec dynamic_call(atom() | String.t(), atom() | String.t(), list(), keyword()) ::
{:ok, term()} | {:error, term()}
def dynamic_call(library, function, args, opts \\ []) do
if function_exported?(Snakepit, :dynamic_call, 4) do
# credo:disable-for-next-line Credo.Check.Refactor.Apply
apply(Snakepit, :dynamic_call, [library, function, args, opts])
else
{:error, :snakepit_dynamic_call_unavailable}
end
end
end
</file>
<file path="snakebridge/lock.ex">
defmodule SnakeBridge.Lock do
@moduledoc """
Manages snakebridge.lock with runtime identity, hardware info, and library versions.
The lock file captures:
- Hardware identity (accelerator, CUDA version, CPU features)
- Platform information (OS, architecture)
- Python environment (version, packages)
- Library configurations
## Hardware-Aware Lock Files
The lock file includes hardware information to detect compatibility issues:
%{
"environment" => %{
"hardware" => %{
"accelerator" => "cuda",
"cuda_version" => "12.1",
"gpu_count" => 2,
"cpu_features" => ["avx", "avx2"]
},
"platform" => %{
"os" => "linux",
"arch" => "x86_64"
}
}
}
Use `SnakeBridge.Lock.Verifier` to verify compatibility.
"""
alias SnakeBridge.Config
@spec load() :: map() | nil
def load do
case File.read(lock_path()) do
{:ok, content} -> Jason.decode!(content)
{:error, :enoent} -> nil
end
end
@spec update(SnakeBridge.Config.t()) :: :ok
def update(config) do
lock = build(config)
lock
|> Jason.encode!(pretty: true)
|> then(&File.write!(lock_path(), &1))
end
@spec build(SnakeBridge.Config.t()) :: map()
def build(config) do
runtime =
case python_runtime_module().runtime_identity() do
{:ok, identity} -> identity
{:error, _} -> %{version: "unknown", platform: "unknown", hash: "unknown"}
end
packages = get_package_metadata(config)
packages_hash = "sha256:" <> compute_packages_hash(packages)
hardware = build_hardware_section()
platform = build_platform_section()
%{
"version" => version(),
"environment" => %{
"snakebridge_version" => version(),
"generator_hash" => generator_hash(),
"python_version" => runtime.version,
"python_platform" => runtime.platform,
"python_runtime_hash" => runtime.hash,
"python_packages_hash" => packages_hash,
"elixir_version" => System.version(),
"otp_version" => System.otp_release(),
"hardware" => hardware,
"platform" => platform
},
"compatibility" => build_compatibility_section(hardware),
"libraries" => libraries_lock(config),
"python_packages" => packages
}
end
@doc """
Builds the hardware section for the lock file.
Returns a map with hardware identity including accelerator type,
CUDA version if available, GPU count, and CPU features.
"""
@spec build_hardware_section() :: map()
def build_hardware_section do
identity = hardware_module().identity()
caps = hardware_module().capabilities()
base = %{
"accelerator" => identity["accelerator"],
"gpu_count" => identity["gpu_count"],
"cpu_features" => identity["cpu_features"]
}
# Add CUDA-specific info if available
base =
if caps.cuda do
base
|> Map.put("cuda_version", caps.cuda_version)
|> Map.put("cudnn_version", caps.cudnn_version)
else
base
end
base
end
@doc """
Builds the platform section for the lock file.
"""
@spec build_platform_section() :: map()
def build_platform_section do
identity = hardware_module().identity()
platform = identity["platform"] || "unknown-unknown"
case String.split(platform, "-", parts: 2) do
[os, arch] ->
%{"os" => os, "arch" => arch}
[os] ->
%{"os" => os, "arch" => "unknown"}
_ ->
%{"os" => "unknown", "arch" => "unknown"}
end
end
@doc """
Builds the compatibility section with minimum requirements.
"""
@spec build_compatibility_section(map()) :: map()
def build_compatibility_section(hardware) do
%{
"cuda_min" => hardware["cuda_version"],
"compute_capability_min" => nil
}
end
@doc """
Deterministic hash from sorted package versions.
"""
@spec compute_packages_hash(map()) :: String.t()
def compute_packages_hash(packages) when is_map(packages) do
packages
|> Enum.sort_by(fn {name, _} -> name end)
|> Enum.map_join("\n", fn {name, info} ->
version = Map.get(info, "version") || Map.get(info, :version) || "unknown"
"#{name}==#{version}"
end)
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
end
@doc """
Gets package metadata for the lockfile.
"""
@spec get_package_metadata(Config.t()) :: map()
def get_package_metadata(config) do
requirements = SnakeBridge.PythonEnv.derive_requirements(config.libraries)
if requirements == [] do
%{}
else
case python_packages_module().lock_metadata(requirements, python_packages_opts([])) do
{:ok, metadata} when is_map(metadata) -> metadata
{:error, _} -> %{}
end
end
end
defp libraries_lock(config) do
config.libraries
|> Enum.map(fn library ->
{
library.python_name,
%{
"requested" => library.version,
"resolved" => library.version,
"hash" => nil
}
}
end)
|> Map.new()
end
defp lock_path do
"snakebridge.lock"
end
defp version do
Application.spec(:snakebridge, :vsn) |> to_string()
end
@generator_files [
"lib/snakebridge/generator.ex",
"lib/snakebridge/docs.ex",
"priv/python/snakebridge_types.py",
"priv/python/snakebridge_adapter.py"
]
@doc """
Computes the generator hash from generator and adapter source contents.
"""
@spec generator_hash() :: String.t()
def generator_hash do
content = Enum.map_join(@generator_files, "\n", &read_generator_file/1)
:crypto.hash(:sha256, content)
|> Base.encode16(case: :lower)
end
defp read_generator_file(relative_path) do
candidates =
[
Application.app_dir(:snakebridge, relative_path),
Path.join(File.cwd!(), relative_path)
]
|> Enum.uniq()
Enum.find_value(candidates, "", fn path ->
case File.read(path) do
{:ok, content} -> content
{:error, _} -> nil
end
end) || ""
end
@doc """
Checks if the lock was generated with the current generator version.
"""
@spec verify_generator_unchanged?(map()) :: boolean()
def verify_generator_unchanged?(lock) do
lock_hash = get_in(lock, ["environment", "generator_hash"])
current_hash = generator_hash()
lock_hash == current_hash
end
defp python_packages_module do
Application.get_env(:snakebridge, :python_packages, Snakepit.PythonPackages)
end
defp python_packages_opts(opts) do
if python_packages_module() == Snakepit.PythonPackages do
Keyword.put_new(opts, :runner, SnakeBridge.PythonPackagesRunner)
else
opts
end
end
defp python_runtime_module do
Application.get_env(:snakebridge, :python_runtime, Snakepit.PythonRuntime)
end
defp hardware_module do
Application.get_env(:snakebridge, :hardware_module, Snakepit.Hardware)
end
end
</file>
<file path="snakebridge/manifest.ex">
defmodule SnakeBridge.Manifest do
@moduledoc """
Manifest storage for generated symbols.
"""
@spec load(SnakeBridge.Config.t()) :: map()
def load(config) do
path = manifest_path(config)
case File.read(path) do
{:ok, content} ->
content
|> Jason.decode!()
|> normalize_manifest()
{:error, :enoent} ->
%{"version" => version(), "symbols" => %{}, "classes" => %{}}
end
end
@spec save(SnakeBridge.Config.t(), map()) :: :ok
def save(config, manifest) do
path = manifest_path(config)
File.mkdir_p!(Path.dirname(path))
manifest
|> sort_manifest()
|> Jason.encode!(pretty: true)
|> then(&File.write!(path, &1))
end
@spec missing(map(), list({module(), atom(), non_neg_integer()})) ::
list({module(), atom(), non_neg_integer()})
def missing(manifest, detected) do
classes = Map.get(manifest, "classes", %{})
detected
|> Enum.reject(fn {mod, func, arity} ->
module_key = module_to_string(mod)
case Map.get(classes, module_key) do
nil -> call_supported?(manifest, mod, func, arity)
class_info -> class_call_supported?(class_info, func, arity)
end
end)
end
@spec call_supported?(map(), module(), atom(), non_neg_integer()) :: boolean()
def call_supported?(manifest, module, function, call_site_arity) do
prefix = "#{module_to_string(module)}.#{function}/"
manifest
|> Map.get("symbols", %{})
|> Enum.any?(fn {key, info} ->
String.starts_with?(key, prefix) and
symbol_arity_matches?(key, info, call_site_arity)
end)
end
defp symbol_arity_matches?(key, info, call_site_arity) do
arity_from_key = symbol_arity_from_key(key)
min_arity = info["minimum_arity"] || info["required_arity"] || arity_from_key || 0
max_arity = info["maximum_arity"] || arity_from_key
has_var_positional = info["has_var_positional"] == true
arity_in_range?(call_site_arity, min_arity, max_arity, has_var_positional)
end
defp arity_in_range?(call_site_arity, min_arity, max_arity, has_var_positional) do
cond do
max_arity in [:unbounded, "unbounded"] or has_var_positional ->
call_site_arity >= min_arity
is_integer(max_arity) ->
call_site_arity >= min_arity and call_site_arity <= max_arity
true ->
call_site_arity == min_arity
end
end
defp class_call_supported?(class_info, function, call_site_arity) do
function_name = to_string(function)
methods = method_field(class_info, "methods") || []
attrs = method_field(class_info, "attributes") || []
if methods == [] and attrs == [] do
true
else
method_supported? =
Enum.any?(methods, fn method ->
method_name(method) == function_name and
method_arity_supported?(method, call_site_arity)
end)
attr_supported? =
Enum.any?(attrs, fn attr ->
to_string(attr) == function_name and call_site_arity == 1
end)
method_supported? or attr_supported?
end
end
defp method_name(method) do
method_field(method, "elixir_name") ||
case method_field(method, "name") do
"__init__" -> "new"
name when is_binary(name) -> name
_ -> ""
end
end
defp method_arity_supported?(method, call_site_arity) do
{min_arity, max_arity, has_var_positional} = method_arity_info(method)
arity_in_range?(call_site_arity, min_arity, max_arity, has_var_positional)
end
defp method_arity_info(method) do
min_arity = method_field(method, "minimum_arity")
max_arity = method_field(method, "maximum_arity")
required_arity = method_field(method, "required_arity")
has_var_positional = method_field(method, "has_var_positional") == true
if has_explicit_arity_info?(min_arity, max_arity, has_var_positional) do
{min_arity || required_arity || 0, max_arity, has_var_positional}
else
compute_arity_from_params(method)
end
end
defp has_explicit_arity_info?(min_arity, max_arity, has_var_positional) do
is_integer(min_arity) or is_integer(max_arity) or has_var_positional
end
defp compute_arity_from_params(method) do
params = method_field(method, "parameters") || []
signature_available = method_field(method, "signature_available") != false
raw_name = method_field(method, "name") || ""
{min_base, max_base, var_positional?} =
compute_method_arity(params, signature_available)
ref_offset = if raw_name == "__init__", do: 0, else: 1
apply_ref_offset(min_base, max_base, ref_offset, var_positional?)
end
defp apply_ref_offset(min_base, max_base, ref_offset, var_positional?) do
min_arity = min_base + ref_offset
max_arity =
case max_base do
:unbounded -> :unbounded
value when is_integer(value) -> value + ref_offset
_ -> min_arity
end
{min_arity, max_arity, var_positional?}
end
defp compute_method_arity(params, signature_available) do
positional_params =
Enum.filter(params, fn param ->
param_kind(param) in ["POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"]
end)
required_positional =
positional_params
|> Enum.reject(&param_default?/1)
|> length()
optional_positional =
positional_params
|> Enum.filter(&param_default?/1)
|> length()
has_var_positional = Enum.any?(params, &varargs?/1)
variadic_fallback = params == [] and signature_available == false
max_arity =
cond do
variadic_fallback -> variadic_max_arity() + 1
has_var_positional -> :unbounded
optional_positional > 0 -> required_positional + 2
true -> required_positional + 1
end
{required_positional, max_arity, has_var_positional}
end
defp variadic_max_arity do
Application.get_env(:snakebridge, :variadic_max_arity, 8)
end
defp varargs?(param), do: param_kind(param) == "VAR_POSITIONAL"
defp param_kind(%{"kind" => kind}) when is_binary(kind), do: String.upcase(kind)
defp param_kind(%{kind: kind}) when is_binary(kind), do: String.upcase(kind)
defp param_kind(%{kind: kind}), do: kind
defp param_kind(_), do: nil
defp param_default?(%{"default" => _}), do: true
defp param_default?(%{default: _}), do: true
defp param_default?(_), do: false
defp method_field(map, key) when is_binary(key) do
Map.get(map, key) || Map.get(map, String.to_atom(key))
end
defp symbol_arity_from_key(key) when is_binary(key) do
case String.split(key, "/") do
[_prefix, arity] ->
case Integer.parse(arity) do
{value, ""} -> value
_ -> nil
end
_ ->
nil
end
end
@spec put_symbols(map(), list({String.t(), map()})) :: map()
def put_symbols(manifest, entries) do
symbols =
manifest
|> Map.get("symbols", %{})
|> Map.merge(Map.new(entries))
Map.put(manifest, "symbols", symbols)
end
@spec put_classes(map(), list({String.t(), map()})) :: map()
def put_classes(manifest, entries) do
classes =
manifest
|> Map.get("classes", %{})
|> Map.merge(Map.new(entries))
Map.put(manifest, "classes", classes)
end
@spec symbol_key({module(), atom(), non_neg_integer()}) :: String.t()
def symbol_key({module, function, arity}) do
mod = module |> Module.split() |> Enum.join(".")
"#{mod}.#{function}/#{arity}"
end
@spec class_key(module()) :: String.t()
def class_key(module) when is_atom(module) do
Module.split(module) |> Enum.join(".")
end
defp module_to_string(module) when is_atom(module) do
Module.split(module) |> Enum.join(".")
end
defp manifest_path(config) do
Path.join(config.metadata_dir, "manifest.json")
end
defp version do
Application.spec(:snakebridge, :vsn) |> to_string()
end
defp normalize_manifest(manifest) do
symbols = Map.get(manifest, "symbols", %{})
normalized_symbols =
Enum.reduce(symbols, %{}, fn {key, value}, acc ->
normalized = normalize_symbol_key(key)
if normalized == key do
Map.put(acc, normalized, value)
else
Map.put_new(acc, normalized, value)
end
end)
Map.put(manifest, "symbols", normalized_symbols)
end
defp normalize_symbol_key(key) when is_binary(key) do
case String.split(key, ".") do
["Elixir" | rest] ->
case Enum.split(rest, -1) do
{module_parts, [fun_part]} when module_parts != [] ->
Enum.join(module_parts, ".") <> "." <> fun_part
_ ->
key
end
_ ->
key
end
end
defp normalize_symbol_key(key), do: key
defp sort_manifest(manifest) do
manifest
|> update_in(["symbols"], fn symbols ->
symbols
|> Enum.sort_by(fn {key, _} -> key end)
|> Map.new()
end)
|> update_in(["classes"], fn classes ->
classes
|> Enum.sort_by(fn {key, _} -> key end)
|> Map.new()
end)
end
end
</file>
<file path="snakebridge/module_resolver.ex">
defmodule SnakeBridge.ModuleResolver do
@moduledoc """
Resolves ambiguous module paths to class attributes or submodules.
"""
alias SnakeBridge.Introspector
@type resolution ::
{:class, String.t(), String.t()}
| {:submodule, String.t()}
| {:error, term()}
@doc """
Determines if an Elixir module maps to a Python class attribute or submodule.
Returns:
- `{:class, class_name, parent_module}` when the last path segment is a class.
- `{:submodule, module_path}` when the path resolves to a submodule.
- `{:error, reason}` when introspection fails.
"""
@spec resolve_class_or_submodule(map(), module()) :: resolution()
def resolve_class_or_submodule(library, elixir_module) do
module_parts = Module.split(elixir_module)
library_parts = Module.split(library.module_name)
extra_parts = Enum.drop(module_parts, length(library_parts))
case extra_parts do
[] ->
{:submodule, library.python_name}
_ ->
{parent_parts, [candidate]} = Enum.split(extra_parts, -1)
parent_module = build_parent_module(library.python_name, parent_parts)
case class_or_module(parent_module, candidate) do
{:class, class_name} ->
{:class, class_name, parent_module}
{:module, module_name} ->
{:submodule, join_module(parent_module, module_name)}
:unknown ->
{:submodule, fallback_submodule(library.python_name, extra_parts)}
{:error, _} = error ->
error
end
end
end
defp class_or_module(parent_module, candidate) do
case introspect_attribute_type(parent_module, candidate) do
{:ok, :class} ->
{:class, candidate}
{:ok, :module} ->
{:module, candidate}
{:ok, :other} ->
maybe_downcase(parent_module, candidate)
{:error, _} = error ->
error
end
end
defp maybe_downcase(parent_module, candidate) do
downcased = String.downcase(candidate)
if downcased == candidate do
:unknown
else
case introspect_attribute_type(parent_module, downcased) do
{:ok, :class} -> {:class, downcased}
{:ok, :module} -> {:module, downcased}
{:ok, :other} -> :unknown
{:error, _} = error -> error
end
end
end
defp introspect_attribute_type(module_path, attr_name) do
case Introspector.introspect_attribute(module_path, attr_name) do
{:ok, %{"exists" => false}} ->
{:ok, :other}
{:ok, %{"is_class" => true}} ->
{:ok, :class}
{:ok, %{"is_module" => true}} ->
{:ok, :module}
{:ok, _} ->
{:ok, :other}
{:error, _} = error ->
error
end
end
defp build_parent_module(base, []), do: base
defp build_parent_module(base, parts) do
base <> "." <> Enum.map_join(parts, ".", &Macro.underscore/1)
end
defp fallback_submodule(base, parts) do
[base | Enum.map(parts, &Macro.underscore/1)]
|> Enum.join(".")
end
defp join_module(parent, child), do: parent <> "." <> child
end
</file>
<file path="snakebridge/python_env.ex">
defmodule SnakeBridge.PythonEnv do
@moduledoc """
Compile-time orchestrator for Python environment provisioning.
"""
alias SnakeBridge.{Config, EnvironmentError}
@type requirement :: String.t()
@doc """
Ensures the Python environment is ready for introspection.
In dev with auto_install enabled, installs missing packages.
In strict mode, verifies the environment without installing.
"""
@spec ensure!(Config.t()) :: :ok | no_return()
def ensure!(config) do
cond do
strict_mode?(config) ->
verify_environment!(config)
auto_install_enabled?(config) ->
do_ensure!(config)
true ->
verify_environment!(config)
end
end
@doc """
Converts library config to PEP-440 requirement strings.
Skips stdlib libraries and applies pypi_package and extras overrides.
"""
@spec derive_requirements([Config.Library.t()]) :: [requirement()]
def derive_requirements(libraries) when is_list(libraries) do
libraries
|> Enum.reject(&stdlib_library?/1)
|> Enum.map(&library_to_requirement/1)
end
@doc """
Checks packages are installed without installing.
"""
@spec verify_environment!(Config.t()) :: :ok | no_return()
def verify_environment!(config) do
requirements = derive_requirements(config.libraries)
case python_packages_module().check_installed(requirements, python_packages_opts([])) do
{:ok, :all_installed} ->
:ok
{:ok, {:missing, missing}} ->
raise EnvironmentError,
message: "Missing Python packages: #{inspect(missing)}",
missing_packages: missing,
suggestion: "Run: mix snakebridge.setup"
end
end
defp do_ensure!(config) do
ensure_python_runtime!()
ensure_snakepit_adapter!()
ensure_snakepit_requirements!(config)
requirements = derive_requirements(config.libraries)
if requirements != [] do
python_packages_module().ensure!(
{:list, requirements},
python_packages_opts(quiet: !config.verbose)
)
end
:ok
end
defp ensure_snakepit_requirements!(config) do
if python_packages_module() == Snakepit.PythonPackages do
case snakepit_requirements_path() do
nil ->
:ok
path ->
python_packages_module().ensure!(
{:file, path},
python_packages_opts(quiet: !config.verbose)
)
end
else
:ok
end
end
defp ensure_python_runtime! do
python_config = Application.get_env(:snakepit, :python, [])
if Keyword.get(python_config, :managed, false) do
python_runtime_module().install_managed(SnakeBridge.PythonRuntimeRunner, [])
end
:ok
end
defp auto_install_enabled?(config) do
case auto_install_setting(config) do
:never -> false
:always -> true
:dev -> Mix.env() == :dev
end
end
defp auto_install_setting(config) do
case System.get_env("SNAKEBRIDGE_AUTO_INSTALL") do
"never" -> :never
"always" -> :always
"dev" -> :dev
nil -> config.auto_install || :dev
_ -> config.auto_install || :dev
end
end
defp strict_mode?(config) do
System.get_env("SNAKEBRIDGE_STRICT") == "1" || config.strict == true
end
defp stdlib_library?(%Config.Library{version: :stdlib}), do: true
defp stdlib_library?(_), do: false
defp library_to_requirement(library) do
package = library.pypi_package || library.python_name || Atom.to_string(library.name)
extras = List.wrap(library.extras || [])
version = translate_version(library.version)
base =
if extras == [] do
package
else
package <> "[" <> Enum.join(extras, ",") <> "]"
end
if version do
base <> version
else
base
end
end
defp translate_version(nil), do: nil
defp translate_version(:stdlib), do: nil
defp translate_version(v) when is_binary(v) do
v = String.trim(v)
if String.starts_with?(v, ["~=", ">=", "<=", "==", "!="]) do
v
else
case Regex.run(~r/^~>\s*(.+)$/, v) do
[_, ver] -> "~=#{ver}"
nil -> "==#{v}"
end
end
end
defp python_packages_module do
Application.get_env(:snakebridge, :python_packages, Snakepit.PythonPackages)
end
defp python_packages_opts(opts) do
if python_packages_module() == Snakepit.PythonPackages do
Keyword.put_new(opts, :runner, SnakeBridge.PythonPackagesRunner)
else
opts
end
end
defp python_runtime_module do
Application.get_env(:snakebridge, :python_runtime, Snakepit.PythonRuntime)
end
defp snakepit_requirements_path do
case :code.priv_dir(:snakepit) do
{:error, _} ->
nil
priv_dir ->
path = Path.join([to_string(priv_dir), "python", "requirements.txt"])
if File.exists?(path), do: path, else: nil
end
end
defp ensure_snakepit_adapter! do
if is_list(Application.get_env(:snakepit, :pools)) do
:ok
else
adapter_module = Application.get_env(:snakepit, :adapter_module)
if is_nil(adapter_module) do
Application.put_env(:snakepit, :adapter_module, Snakepit.Adapters.GRPCPython)
end
pool_config =
:snakepit
|> Application.get_env(:pool_config, %{})
|> normalize_config_input()
adapter_args = Map.get(pool_config, :adapter_args, [])
if adapter_args_missing?(adapter_args) do
updated = Map.put(pool_config, :adapter_args, default_adapter_args())
Application.put_env(:snakepit, :pool_config, updated)
end
:ok
end
end
defp adapter_args_missing?(adapter_args) when is_list(adapter_args) do
not Enum.any?(adapter_args, fn arg ->
is_binary(arg) and (arg == "--adapter" or String.starts_with?(arg, "--adapter="))
end)
end
defp adapter_args_missing?(_), do: true
defp default_adapter_args do
["--adapter", "snakebridge_adapter.SnakeBridgeAdapter"]
end
defp normalize_config_input(nil), do: %{}
defp normalize_config_input(%{} = map), do: map
defp normalize_config_input(list) when is_list(list), do: Map.new(list)
defp normalize_config_input(_), do: %{}
end
</file>
<file path="snakebridge/python_packages_runner.ex">
defmodule SnakeBridge.PythonPackagesRunner do
@moduledoc false
@behaviour Snakepit.PythonPackages.Runner
@impl true
@doc false
def cmd(command, args, opts) do
{timeout, opts} = Keyword.pop(opts, :timeout)
run = fn -> System.cmd(command, args, opts) end
if is_integer(timeout) do
task = Task.async(run)
case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
{:ok, result} -> result
nil -> {"Command timed out after #{timeout}ms", 124}
end
else
run.()
end
rescue
error in ErlangError ->
{Exception.message(error), 127}
end
end
</file>
<file path="snakebridge/python_runner.ex">
defmodule SnakeBridge.PythonRunner do
@moduledoc """
Behaviour for executing Python scripts in the Snakepit-configured runtime.
"""
@type script :: String.t()
@type args :: [String.t()]
@type opts :: keyword()
@callback run(script(), args(), opts()) :: {:ok, String.t()} | {:error, term()}
end
</file>
<file path="snakebridge/python_runtime_runner.ex">
defmodule SnakeBridge.PythonRuntimeRunner do
@moduledoc false
@behaviour Snakepit.Bootstrap.Runner
@impl true
def mix(_task, _args), do: :ok
@impl true
def cmd(command, args, opts) do
opts = Keyword.put_new(opts, :stderr_to_stdout, true)
{actual_command, actual_args} =
if String.ends_with?(command, ".sh") do
{"bash", [command | args]}
else
{command, args}
end
case System.cmd(actual_command, actual_args, opts) do
{output, 0} ->
write_output(output)
:ok
{output, status} ->
write_output(output)
{:error, {:command_failed, command, status}}
end
rescue
error in ErlangError ->
{:error, {:command_failed, command, Exception.message(error)}}
end
defp write_output(output) do
if String.trim(output) != "" do
IO.write(output)
end
end
end
</file>
<file path="snakebridge/ref_not_found_error.ex">
defmodule SnakeBridge.RefNotFoundError do
@moduledoc """
Raised when a Python object reference cannot be found in the registry.
This typically occurs when:
- The ref was already released via `release_ref/1`
- The session was released via `release_session/1`
- The ref expired due to TTL
- The ref was evicted due to registry size limits
## Fields
- `:ref_id` - The ref ID that was not found
- `:session_id` - The session ID the ref was looked up in
- `:message` - Human-readable error message
"""
defexception [:ref_id, :session_id, :message]
@type t :: %__MODULE__{
ref_id: String.t() | nil,
session_id: String.t() | nil,
message: String.t()
}
@impl Exception
def exception(opts) when is_list(opts) do
ref_id = Keyword.get(opts, :ref_id)
session_id = Keyword.get(opts, :session_id)
message = Keyword.get(opts, :message) || build_message(ref_id, session_id)
%__MODULE__{
ref_id: ref_id,
session_id: session_id,
message: message
}
end
@impl Exception
def message(%__MODULE__{message: message}), do: message
defp build_message(ref_id, session_id) do
base = "SnakeBridge reference '#{ref_id || "unknown"}' not found"
if session_id do
base <> " in session '#{session_id}'. The ref may have been released, expired, or evicted."
else
base <> ". The ref may have been released, expired, or evicted."
end
end
end
</file>
<file path="snakebridge/ref.ex">
defmodule SnakeBridge.Ref do
@moduledoc """
Structured reference to a Python object managed by SnakeBridge.
This struct defines the cross-language wire shape for Python object references.
"""
@schema_version 1
@typedoc """
Structured reference to a Python object.
"""
@type t :: %__MODULE__{
id: String.t(),
session_id: String.t(),
python_module: String.t() | nil,
library: String.t() | nil,
type_name: String.t() | nil,
schema: pos_integer()
}
defstruct [
:id,
:session_id,
:python_module,
:library,
:type_name,
schema: @schema_version
]
@spec schema_version() :: pos_integer()
def schema_version, do: @schema_version
@doc """
Creates a Ref from a wire format map.
"""
@spec from_wire_format(map() | t()) :: t()
def from_wire_format(%__MODULE__{} = ref), do: ref
def from_wire_format(map) when is_map(map) do
%__MODULE__{
id: get_wire_field(map, ["id", "ref_id"]),
session_id: get_wire_field(map, ["session_id"]),
python_module: get_wire_field(map, ["python_module"]),
library: get_wire_field(map, ["library"]),
type_name: get_wire_field(map, ["type_name", "__type_name__"]),
schema: get_wire_field(map, ["__schema__", "schema"]) || @schema_version
}
end
defp get_wire_field(map, keys) do
Enum.find_value(keys, fn key ->
Map.get(map, key) || Map.get(map, String.to_atom(key))
end)
end
@doc """
Converts a Ref to wire format for Python calls.
"""
@spec to_wire_format(t() | map()) :: map()
def to_wire_format(%__MODULE__{} = ref) do
%{}
|> Map.put("__type__", "ref")
|> Map.put("__schema__", ref.schema || @schema_version)
|> maybe_put("id", ref.id)
|> maybe_put("session_id", ref.session_id)
|> maybe_put("python_module", ref.python_module)
|> maybe_put("library", ref.library)
end
def to_wire_format(%{"__type__" => "ref"} = ref) do
ref
|> from_wire_format()
|> to_wire_format()
end
def to_wire_format(%{__type__: "ref"} = ref) do
ref
|> from_wire_format()
|> to_wire_format()
end
@doc """
Checks if a value is a valid ref.
"""
@spec ref?(term()) :: boolean()
def ref?(%__MODULE__{id: id, session_id: session_id})
when is_binary(id) and is_binary(session_id),
do: true
def ref?(%{"__type__" => "ref"} = ref) do
ref_id =
Map.get(ref, "id") || Map.get(ref, :id) || Map.get(ref, "ref_id") || Map.get(ref, :ref_id)
session_id = Map.get(ref, "session_id") || Map.get(ref, :session_id)
is_binary(ref_id) and is_binary(session_id)
end
def ref?(%{__type__: "ref"} = ref), do: ref?(Map.new(ref, fn {k, v} -> {to_string(k), v} end))
def ref?(_), do: false
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end
defimpl Inspect, for: SnakeBridge.Ref do
import Inspect.Algebra
alias SnakeBridge.Runtime
alias SnakeBridge.Ref
def inspect(%Ref{} = ref, _opts) do
case python_repr(ref) do
{:ok, repr} when is_binary(repr) ->
concat(["#Python<", repr, ">"])
_ ->
concat(["#SnakeBridge.Ref<", to_string(ref.id || "unknown"), ">"])
end
end
defp python_repr(ref) do
case safe_call(ref, :__repr__) do
{:ok, repr} when is_binary(repr) ->
{:ok, repr}
_ ->
python_str(ref)
end
end
defp python_str(ref) do
case safe_call(ref, :__str__) do
{:ok, str} when is_binary(str) -> {:ok, str}
_ -> {:error, :unavailable}
end
end
defp safe_call(ref, method) do
Runtime.call_method(ref, method, [])
rescue
exception -> {:error, exception}
end
end
defimpl String.Chars, for: SnakeBridge.Ref do
alias SnakeBridge.Runtime
alias SnakeBridge.Ref
def to_string(%Ref{} = ref) do
case safe_call(ref, :__str__) do
{:ok, str} when is_binary(str) -> str
_ -> "#SnakeBridge.Ref<#{ref.id || "unknown"}>"
end
end
defp safe_call(ref, method) do
Runtime.call_method(ref, method, [])
rescue
exception -> {:error, exception}
end
end
defimpl Enumerable, for: SnakeBridge.Ref do
alias SnakeBridge.Runtime
alias SnakeBridge.StreamRef
def count(ref) do
case safe_call(ref, :__len__, []) do
{:ok, len} when is_integer(len) -> {:ok, len}
_ -> {:error, __MODULE__}
end
end
def member?(ref, value) do
case safe_call(ref, :__contains__, [value]) do
{:ok, result} when is_boolean(result) -> {:ok, result}
_ -> {:error, __MODULE__}
end
end
def slice(_ref), do: {:error, __MODULE__}
def reduce(ref, acc, fun) do
case safe_call(ref, :__iter__, []) do
{:ok, %StreamRef{} = stream_ref} ->
Enumerable.reduce(stream_ref, acc, fun)
{:ok, iterator_ref} ->
do_reduce(iterator_ref, acc, fun)
{:error, _} ->
do_reduce_by_index(ref, 0, acc, fun)
end
end
defp do_reduce(_iterator, {:halt, acc}, _fun), do: {:halted, acc}
defp do_reduce(iterator, {:suspend, acc}, fun) do
{:suspended, acc, &do_reduce(iterator, &1, fun)}
end
defp do_reduce(iterator, {:cont, acc}, fun) do
case safe_call(iterator, :__next__, []) do
{:ok, value} ->
do_reduce(iterator, fun.(value, acc), fun)
{:error, reason} ->
if stop_iteration?(reason) do
{:done, acc}
else
{:halted, {:error, reason}}
end
end
end
defp do_reduce_by_index(_ref, _index, {:halt, acc}, _fun), do: {:halted, acc}
defp do_reduce_by_index(ref, index, {:suspend, acc}, fun) do
{:suspended, acc, &do_reduce_by_index(ref, index, &1, fun)}
end
defp do_reduce_by_index(ref, index, {:cont, acc}, fun) do
case safe_call(ref, :__getitem__, [index]) do
{:ok, value} ->
do_reduce_by_index(ref, index + 1, fun.(value, acc), fun)
{:error, reason} ->
if index_error?(reason) or stop_iteration?(reason) do
{:done, acc}
else
{:halted, {:error, reason}}
end
end
end
defp safe_call(ref, method, args) do
Runtime.call_method(ref, method, args)
rescue
exception -> {:error, exception}
end
defp stop_iteration?(reason), do: error_type(reason) == "StopIteration"
defp index_error?(reason), do: error_type(reason) == "IndexError"
defp error_type(%{python_class: class}) when is_binary(class) do
class
|> String.split(".")
|> List.last()
end
# Check for python_type field (these are atom-keyed struct fields)
defp error_type(%{python_type: type}) when is_binary(type), do: type
defp error_type(%{python_type: type}) when is_atom(type), do: Atom.to_string(type)
# Check for error_type field
defp error_type(%{error_type: type}) when is_binary(type), do: type
defp error_type(%{error_type: type}) when is_atom(type), do: Atom.to_string(type)
# Check for type field
defp error_type(%{type: type}) when is_binary(type), do: type
defp error_type(%{type: type}) when is_atom(type), do: Atom.to_string(type)
# Handle exception structs by extracting the module name
defp error_type(%{__struct__: struct}) do
struct
|> Module.split()
|> List.last()
end
end
</file>
<file path="snakebridge/registry.ex">
defmodule SnakeBridge.Registry do
@moduledoc """
Registry system for tracking generated SnakeBridge adapters.
The registry maintains a record of all generated Python library adapters,
allowing agents and tools to introspect what libraries are available without
parsing code.
## Registry Format
The registry stores library information including:
- Python module name and version
- Generated Elixir module name
- Generation timestamp
- File locations and structure
- Statistics (function count, class count, etc.)
## Usage
# Register a new library
SnakeBridge.Registry.register("numpy", %{
python_module: "numpy",
python_version: "1.26.0",
elixir_module: "Numpy",
generated_at: ~U[2024-12-24 14:00:00Z],
path: "lib/snakebridge/adapters/numpy/",
files: ["numpy.ex", "linalg.ex", "_meta.ex"],
stats: %{functions: 165, classes: 2, submodules: 4}
})
# Check if a library is generated
SnakeBridge.Registry.generated?("numpy")
# => true
# Get library information
SnakeBridge.Registry.get("numpy")
# => %{python_module: "numpy", ...}
# List all generated libraries
SnakeBridge.Registry.list_libraries()
# => ["json", "numpy", "sympy"]
## Persistence
The registry is automatically persisted to a JSON file at:
`priv/snakebridge/registry.json`
Use `save/0` to persist changes and `load/0` to restore from disk.
"""
use Agent
require Logger
@type library_name :: String.t()
@type registry_entry :: %{
python_module: String.t(),
python_version: String.t(),
elixir_module: String.t(),
generated_at: DateTime.t(),
path: String.t(),
files: [String.t()],
stats: %{
functions: non_neg_integer(),
classes: non_neg_integer(),
submodules: non_neg_integer()
}
}
@type registry_state :: %{
optional(library_name()) => registry_entry()
}
# Registry version for compatibility tracking
@registry_version "2.1"
# Required entry fields for validation
@required_fields [
:python_module,
:python_version,
:elixir_module,
:generated_at,
:path,
:files,
:stats
]
@required_stat_fields [:functions, :classes, :submodules]
## Client API
@doc """
Starts the registry agent.
This is typically called by the application supervisor.
"""
@spec start_link(keyword()) :: Agent.on_start()
def start_link(opts \\ []) do
Agent.start_link(fn -> %{} end, Keyword.merge([name: __MODULE__], opts))
end
@doc """
Returns a list of all registered library names, sorted alphabetically.
## Examples
iex> SnakeBridge.Registry.register("numpy", entry)
:ok
iex> SnakeBridge.Registry.list_libraries()
["numpy"]
"""
@spec list_libraries() :: [library_name()]
def list_libraries do
registry_get(fn state ->
state
|> Map.keys()
|> Enum.sort()
end)
end
@doc """
Gets information about a registered library.
Returns `nil` if the library is not registered.
## Examples
iex> SnakeBridge.Registry.get("numpy")
%{python_module: "numpy", python_version: "1.26.0", ...}
iex> SnakeBridge.Registry.get("nonexistent")
nil
"""
@spec get(library_name()) :: registry_entry() | nil
def get(library_name) do
registry_get(fn state -> Map.get(state, library_name) end)
end
@doc """
Checks if a library is registered.
## Examples
iex> SnakeBridge.Registry.generated?("numpy")
true
iex> SnakeBridge.Registry.generated?("nonexistent")
false
"""
@spec generated?(library_name()) :: boolean()
def generated?(library_name) do
registry_get(fn state -> Map.has_key?(state, library_name) end)
end
@doc """
Registers a library in the registry.
Updates the entry if the library is already registered.
## Parameters
- `library_name` - The library identifier (e.g., "numpy")
- `entry` - A map containing library information (see module documentation)
## Returns
- `:ok` on success
- `{:error, reason}` if the entry is invalid
## Examples
iex> entry = %{
...> python_module: "numpy",
...> python_version: "1.26.0",
...> elixir_module: "Numpy",
...> generated_at: ~U[2024-12-24 14:00:00Z],
...> path: "lib/snakebridge/adapters/numpy/",
...> files: ["numpy.ex"],
...> stats: %{functions: 10, classes: 0, submodules: 1}
...> }
iex> SnakeBridge.Registry.register("numpy", entry)
:ok
"""
@spec register(library_name(), map()) :: :ok | {:error, String.t()}
def register(library_name, entry) when is_binary(library_name) and is_map(entry) do
case validate_entry(entry) do
:ok ->
registry_update(fn state ->
Map.put(state, library_name, normalize_entry(entry))
end)
:ok
{:error, _reason} = error ->
error
end
end
@doc """
Removes a library from the registry.
Returns `:ok` even if the library was not registered.
## Examples
iex> SnakeBridge.Registry.unregister("numpy")
:ok
"""
@spec unregister(library_name()) :: :ok
def unregister(library_name) do
registry_update(fn state ->
Map.delete(state, library_name)
end)
:ok
end
@doc """
Clears all entries from the registry.
## Examples
iex> SnakeBridge.Registry.clear()
:ok
"""
@spec clear() :: :ok
def clear do
registry_update(fn _state -> %{} end)
:ok
end
@doc """
Saves the registry to the JSON file.
Creates the parent directory if it doesn't exist.
## Returns
- `:ok` on success
- `{:error, reason}` if saving fails
## Examples
iex> SnakeBridge.Registry.save()
:ok
"""
@spec save() :: :ok | {:error, term()}
def save do
registry_path = get_registry_path()
with :ok <- ensure_registry_dir(registry_path),
{:ok, data} <- build_registry_data(),
{:ok, json} <- Jason.encode(data, pretty: true),
:ok <- File.write(registry_path, json) do
:ok
else
{:error, reason} = error ->
Logger.error("Failed to save registry to #{registry_path}: #{inspect(reason)}")
error
end
end
@doc """
Loads the registry from the JSON file.
If the file doesn't exist, initializes an empty registry.
## Returns
- `:ok` on success
- `{:error, reason}` if loading fails
## Examples
iex> SnakeBridge.Registry.load()
:ok
"""
@spec load() :: :ok | {:error, term()}
def load do
registry_path = get_registry_path()
case File.read(registry_path) do
{:ok, content} ->
with {:ok, data} <- Jason.decode(content),
{:ok, libraries} <- parse_registry_data(data) do
registry_update(fn _state -> libraries end)
:ok
else
{:error, reason} = error ->
Logger.error("Failed to parse registry from #{registry_path}: #{inspect(reason)}")
error
end
{:error, :enoent} ->
# File doesn't exist yet - start with empty registry
Logger.debug("Registry file not found at #{registry_path}, starting with empty registry")
:ok
{:error, reason} = error ->
Logger.error("Failed to read registry from #{registry_path}: #{inspect(reason)}")
error
end
end
## Private Functions
# Ensures the registry agent is started
defp ensure_started do
case Process.whereis(__MODULE__) do
nil ->
start_registry()
pid ->
if Process.alive?(pid) do
:ok
else
start_registry()
end
end
end
defp start_registry do
case Agent.start(fn -> %{} end, name: __MODULE__) do
{:ok, _pid} -> :ok
{:error, {:already_started, _pid}} -> :ok
{:error, reason} -> raise "Failed to start registry: #{inspect(reason)}"
end
end
# Gets the registry file path from config or default
defp get_registry_path do
Application.get_env(:snakebridge, :registry_path) ||
Path.join([File.cwd!(), "priv", "snakebridge", "registry.json"])
end
# Ensures the registry directory exists
defp ensure_registry_dir(registry_path) do
registry_path
|> Path.dirname()
|> File.mkdir_p()
end
# Validates a registry entry has all required fields
defp validate_entry(entry) do
# Check required top-level fields
missing_fields =
@required_fields
|> Enum.reject(fn field -> Map.has_key?(entry, field) end)
cond do
length(missing_fields) > 0 ->
{:error, "Missing required fields: #{inspect(missing_fields)}"}
not is_map(entry.stats) ->
{:error, "stats must be a map"}
true ->
validate_stats(entry.stats)
end
end
# Validates the stats sub-map
defp validate_stats(stats) do
missing_stat_fields =
@required_stat_fields
|> Enum.reject(fn field -> Map.has_key?(stats, field) end)
if length(missing_stat_fields) > 0 do
{:error, "Missing required stat fields: #{inspect(missing_stat_fields)}"}
else
:ok
end
end
# Normalizes an entry to ensure consistent structure
defp normalize_entry(entry) do
%{
python_module: entry.python_module,
python_version: entry.python_version,
elixir_module: entry.elixir_module,
generated_at: normalize_datetime(entry.generated_at),
path: entry.path,
files: entry.files,
stats: normalize_stats(entry.stats)
}
end
# Normalizes stats to ensure atoms as keys
defp normalize_stats(stats) do
%{
functions: stats[:functions] || stats["functions"],
classes: stats[:classes] || stats["classes"],
submodules: stats[:submodules] || stats["submodules"]
}
end
# Normalizes datetime - accepts DateTime or string
defp normalize_datetime(%DateTime{} = dt), do: dt
defp normalize_datetime(string) when is_binary(string) do
case DateTime.from_iso8601(string) do
{:ok, dt, _offset} -> dt
{:error, _} -> raise ArgumentError, "Invalid datetime string: #{string}"
end
end
# Builds the registry data structure for JSON serialization
defp build_registry_data do
libraries =
registry_get(fn state ->
state
|> Enum.map(fn {name, entry} ->
{name, serialize_entry(entry)}
end)
|> Enum.into(%{})
end)
data = %{
"version" => @registry_version,
"generated_at" => DateTime.utc_now() |> DateTime.to_iso8601(),
"libraries" => libraries
}
{:ok, data}
end
# Serializes a registry entry for JSON
defp serialize_entry(entry) do
%{
"python_module" => entry.python_module,
"python_version" => entry.python_version,
"elixir_module" => entry.elixir_module,
"generated_at" => DateTime.to_iso8601(entry.generated_at),
"path" => entry.path,
"files" => entry.files,
"stats" => %{
"functions" => entry.stats.functions,
"classes" => entry.stats.classes,
"submodules" => entry.stats.submodules
}
}
end
# Parses registry data from JSON
defp parse_registry_data(%{"libraries" => libraries}) when is_map(libraries) do
parsed =
libraries
|> Enum.map(fn {name, entry} ->
case deserialize_entry(entry) do
{:ok, parsed_entry} -> {name, parsed_entry}
{:error, reason} -> {:error, {name, reason}}
end
end)
# Check for any errors
errors =
Enum.filter(parsed, fn
{:error, _} -> true
_ -> false
end)
if length(errors) > 0 do
{:error, "Invalid entries: #{inspect(errors)}"}
else
{:ok, Enum.into(parsed, %{})}
end
end
defp parse_registry_data(_data) do
{:error, "Invalid registry format: missing 'libraries' key"}
end
# Deserializes a registry entry from JSON
defp deserialize_entry(entry) when is_map(entry) do
{:ok, generated_at, _offset} = DateTime.from_iso8601(entry["generated_at"])
parsed = %{
python_module: entry["python_module"],
python_version: entry["python_version"],
elixir_module: entry["elixir_module"],
generated_at: generated_at,
path: entry["path"],
files: entry["files"],
stats: %{
functions: entry["stats"]["functions"],
classes: entry["stats"]["classes"],
submodules: entry["stats"]["submodules"]
}
}
{:ok, parsed}
rescue
e ->
{:error, "Failed to deserialize entry: #{inspect(e)}"}
end
defp deserialize_entry(_entry) do
{:error, "Entry must be a map"}
end
defp registry_get(fun) do
with_registry(fn -> Agent.get(__MODULE__, fun) end)
end
defp registry_update(fun) do
with_registry(fn -> Agent.update(__MODULE__, fun) end)
end
defp with_registry(fun) do
ensure_started()
try do
fun.()
catch
:exit, {:noproc, _} ->
start_registry()
fun.()
:exit, :noproc ->
start_registry()
fun.()
end
end
end
</file>
<file path="snakebridge/runtime_client.ex">
defmodule SnakeBridge.RuntimeClient do
@moduledoc """
Behaviour for runtime clients that execute SnakeBridge payloads.
The default runtime client is `Snakepit`, but tests can override
this via the `:runtime_client` config.
"""
@type tool :: String.t()
@type payload :: map()
@type opts :: keyword()
@type callback :: (term() -> any())
@callback execute(tool(), payload(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
@callback execute_stream(tool(), payload(), callback(), opts()) ::
:ok | {:error, Snakepit.Error.t()}
end
</file>
<file path="snakebridge/runtime.ex">
defmodule SnakeBridge.Runtime do
@moduledoc """
Thin payload helper for SnakeBridge that delegates execution to Snakepit.
This module is compile-time agnostic and focuses on building payloads that
match the Snakepit Prime runtime contract.
"""
alias SnakeBridge.SessionManager
alias SnakeBridge.Types
require Logger
@type module_ref :: module()
@type function_name :: atom() | String.t()
@type args :: list()
@type opts :: keyword()
@protocol_version 1
@min_supported_version 1
# Process dictionary key for auto-session
@auto_session_key :snakebridge_auto_session
@doc """
Call a Python function.
## Parameters
- `module` - Either a generated SnakeBridge module atom OR a Python module path string
- `function` - Function name (atom or string)
- `args` - Positional arguments (list)
- `opts` - Options including kwargs, :idempotent, :__runtime__
## Examples
# With generated module
{:ok, result} = SnakeBridge.Runtime.call(Numpy, :mean, [[1,2,3]])
# With string module path (dynamic)
{:ok, result} = SnakeBridge.Runtime.call("numpy", "mean", [[1,2,3]])
{:ok, result} = SnakeBridge.Runtime.call("math", :sqrt, [16])
"""
@spec call(module_ref() | String.t(), function_name() | String.t(), args(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
def call(module, function, args \\ [], opts \\ [])
# String module path - delegate to dynamic
def call(module, function, args, opts) when is_binary(module) do
function_name = to_string(function)
call_dynamic(module, function_name, args, opts)
end
# Atom module - existing behavior
def call(module, function, args, opts) when is_atom(module) do
{kwargs, idempotent, extra_args, runtime_opts} = split_opts(opts)
encoded_args = encode_args(args ++ extra_args)
encoded_kwargs = encode_kwargs(kwargs)
# Determine session_id ONCE using correct priority
session_id = resolve_session_id(runtime_opts)
payload =
base_payload(module, function, encoded_args, encoded_kwargs, idempotent)
|> Map.put("session_id", session_id)
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :call)
|> ensure_session_opt(session_id)
metadata = call_metadata(payload, module, function, "function")
execute_with_telemetry(metadata, fn ->
runtime_client().execute("snakebridge.call", payload, runtime_opts)
end)
|> apply_error_mode()
|> decode_result()
end
@doc """
Calls any Python function dynamically without requiring generated bindings.
This is the no-codegen escape hatch for calling functions that were not
scanned during compilation.
"""
@spec call_dynamic(String.t(), function_name(), args(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
def call_dynamic(module_path, function, args \\ [], opts \\ []) when is_binary(module_path) do
{args, opts} = normalize_args_opts(args, opts)
{kwargs, idempotent, extra_args, runtime_opts} = split_opts(opts)
encoded_args = encode_args(args ++ extra_args)
encoded_kwargs = encode_kwargs(kwargs)
# Determine session_id ONCE - this is the single source of truth
session_id = resolve_session_id(runtime_opts)
library = module_path |> String.split(".") |> List.first()
payload =
protocol_payload()
|> Map.put("call_type", "dynamic")
|> Map.put("module_path", module_path)
|> Map.put("library", library)
|> Map.put("function", to_string(function))
|> Map.put("args", encoded_args)
|> Map.put("kwargs", encoded_kwargs)
|> Map.put("idempotent", idempotent)
|> maybe_put_session_id(session_id)
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :call)
|> ensure_session_opt(session_id)
metadata = %{
module: module_path,
function: to_string(function),
library: library,
python_module: module_path,
call_type: "dynamic"
}
execute_with_telemetry(metadata, fn ->
runtime_client().execute("snakebridge.call", payload, runtime_opts)
end)
|> apply_error_mode()
|> decode_result()
end
@spec call_helper(String.t(), args(), opts() | map()) :: {:ok, term()} | {:error, term()}
def call_helper(helper, args \\ [], opts \\ [])
def call_helper(helper, args, opts) when is_map(opts) do
encoded_args = encode_args(args)
encoded_kwargs = encode_kwargs(stringify_keys(opts))
# Map opts cannot have __runtime__, use context/auto-session
session_id = resolve_session_id([])
payload =
helper_payload(helper, encoded_args, encoded_kwargs, false)
|> Map.put("session_id", session_id)
runtime_opts =
[]
|> apply_runtime_defaults(payload, :call)
|> ensure_session_opt(session_id)
metadata = helper_metadata(helper)
execute_with_telemetry(metadata, fn ->
runtime_client().execute("snakebridge.call", payload, runtime_opts)
end)
|> classify_helper_result(helper)
|> decode_result()
end
def call_helper(helper, args, opts) when is_list(opts) do
{kwargs, idempotent, extra_args, runtime_opts} = split_opts(opts)
encoded_args = encode_args(args ++ extra_args)
encoded_kwargs = encode_kwargs(kwargs)
# Determine session_id ONCE using correct priority
session_id = resolve_session_id(runtime_opts)
payload =
helper_payload(helper, encoded_args, encoded_kwargs, idempotent)
|> Map.put("session_id", session_id)
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :call)
|> ensure_session_opt(session_id)
metadata = helper_metadata(helper)
execute_with_telemetry(metadata, fn ->
runtime_client().execute("snakebridge.call", payload, runtime_opts)
end)
|> classify_helper_result(helper)
|> decode_result()
end
@doc """
Stream results from a Python generator/iterator.
## Parameters
- `module` - Either a generated SnakeBridge module atom OR a Python module path string
- `function` - Function name (atom or string)
- `args` - Positional arguments (list)
- `opts` - Options including kwargs
- `callback` - Function called for each streamed item
## Performance
When called with a **generated module atom**, this function can use Snakepit's
native gRPC streaming for efficient data transfer.
When called with a **string module path**, this delegates to `stream_dynamic/5`
which uses RPC-per-item iteration. See `stream_dynamic/5` docs for performance
guidance on large streams.
## Examples
# With string module path (dynamic, RPC-per-item)
SnakeBridge.Runtime.stream("pandas", "read_csv", ["file.csv"], [chunksize: 100], fn chunk ->
process(chunk)
end)
# With generated module (native streaming when available)
SnakeBridge.Runtime.stream(MyApp.Pandas, :read_csv, ["file.csv"], [chunksize: 100], fn chunk ->
process(chunk)
end)
"""
@spec stream(module_ref() | String.t(), function_name() | String.t(), args(), opts(), (term() ->
any())) ::
:ok | {:ok, :done} | {:error, Snakepit.Error.t()}
def stream(module, function, args \\ [], opts \\ [], callback)
# String module path - use stream_dynamic
def stream(module, function, args, opts, callback)
when is_binary(module) and is_function(callback, 1) do
function_name = to_string(function)
stream_dynamic(module, function_name, args, opts, callback)
end
# Atom module - existing behavior
def stream(module, function, args, opts, callback)
when is_atom(module) and is_function(callback, 1) do
{kwargs, idempotent, extra_args, runtime_opts} = split_opts(opts)
encoded_args = encode_args(args ++ extra_args)
encoded_kwargs = encode_kwargs(kwargs)
# Determine session_id ONCE using correct priority
session_id = resolve_session_id(runtime_opts)
payload =
base_payload(module, function, encoded_args, encoded_kwargs, idempotent)
|> Map.put("session_id", session_id)
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :stream)
|> ensure_session_opt(session_id)
metadata = call_metadata(payload, module, function, "stream")
decode_callback = fn chunk -> callback.(Types.decode(chunk)) end
execute_with_telemetry(metadata, fn ->
runtime_client().execute_stream(
"snakebridge.stream",
payload,
decode_callback,
runtime_opts
)
end)
|> apply_error_mode()
end
@doc """
Stream results from a Python generator using dynamic dispatch.
Creates a stream reference and iterates via stream_next until exhausted.
## Performance Note
Dynamic streaming uses an RPC-per-item approach: each item from the Python
iterator triggers a separate `stream_next` gRPC call. This is correct and
safe but may be slow for large streams (thousands of items).
For high-throughput streaming workloads, consider:
- **Generated streaming wrappers**: Use `SnakeBridge.stream/5` with compiled
modules, which can leverage Snakepit's server-side streaming for better
throughput.
- **Batched iteration**: Have Python yield batches of items rather than
individual items.
- **Dedicated data transfer**: For very large datasets, consider writing
Python results to files/databases and loading from Elixir.
Dynamic streaming is ideal for convenience and moderate-sized iterables.
"""
@spec stream_dynamic(String.t(), String.t(), args(), opts(), (term() -> any())) ::
{:ok, :done} | {:error, term()}
def stream_dynamic(module_path, function, args, opts, callback)
when is_binary(module_path) and is_function(callback, 1) do
case call_dynamic(module_path, function, args, opts) do
{:ok, %SnakeBridge.StreamRef{} = stream_ref} ->
stream_iterate(stream_ref, callback, [])
{:ok, %SnakeBridge.Ref{} = ref} ->
# Try to iterate if it's an iterator
stream_iterate_ref(ref, callback, [])
{:ok, other} ->
# Not a stream/iterator - return as single value
callback.(other)
{:ok, :done}
error ->
error
end
end
defp stream_iterate(stream_ref, callback, opts) do
case stream_next(stream_ref, opts) do
{:ok, item} ->
callback.(item)
stream_iterate(stream_ref, callback, opts)
{:error, :stop_iteration} ->
{:ok, :done}
{:error, _} = error ->
error
end
end
defp stream_iterate_ref(ref, callback, opts) do
case call_method(ref, :__iter__, [], opts) do
{:ok, %SnakeBridge.StreamRef{} = stream_ref} ->
stream_iterate(stream_ref, callback, opts)
{:ok, %SnakeBridge.Ref{} = iter_ref} ->
stream_iterate_ref_next(iter_ref, callback, opts)
{:ok, other} ->
callback.(other)
{:ok, :done}
{:error, reason} ->
if stop_iteration?(reason) do
{:ok, :done}
else
stream_iterate_ref_next(ref, callback, opts)
end
end
end
defp stream_iterate_ref_next(ref, callback, opts) do
case call_method(ref, :__next__, [], opts) do
{:ok, item} ->
callback.(item)
stream_iterate_ref_next(ref, callback, opts)
{:error, reason} ->
if stop_iteration?(reason) do
{:ok, :done}
else
{:error, reason}
end
end
end
defp stop_iteration?(reason) when is_map(reason) do
type =
Map.get(reason, :python_type) || Map.get(reason, "python_type") ||
Map.get(reason, :error_type) || Map.get(reason, "error_type")
type == "StopIteration"
end
@doc """
Gets the next item from a Python iterator or generator.
Each call makes a separate RPC to Python. For high-throughput streaming,
see the performance note on `stream_dynamic/5`.
"""
@spec stream_next(SnakeBridge.StreamRef.t(), opts()) ::
{:ok, term()} | {:error, :stop_iteration} | {:error, Snakepit.Error.t()}
def stream_next(stream_ref, opts \\ []) do
{_args, opts} = normalize_args_opts([], opts)
{_, _, _, runtime_opts} = split_opts(opts)
wire_ref = SnakeBridge.StreamRef.to_wire_format(stream_ref)
# Single source of truth: prioritize runtime_opts, then stream_ref session, then context
session_id = resolve_session_id(runtime_opts, stream_ref)
library =
case stream_ref.library do
lib when is_binary(lib) and lib != "" -> lib
_ -> "unknown"
end
payload =
protocol_payload()
|> Map.put("call_type", "stream_next")
|> Map.put("stream_ref", wire_ref)
|> Map.put("library", library)
|> maybe_put_session_id(session_id)
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :stream)
|> ensure_session_opt(session_id)
result =
runtime_client().execute("snakebridge.call", payload, runtime_opts)
|> apply_error_mode()
case result do
{:ok, %{"__type__" => "stop_iteration"}} ->
{:error, :stop_iteration}
{:ok, value} ->
{:ok, Types.decode(value)}
error ->
error
end
end
@doc """
Gets the length of a Python iterable (if supported).
"""
@spec stream_len(SnakeBridge.StreamRef.t(), opts()) ::
{:ok, non_neg_integer()} | {:error, term()}
def stream_len(stream_ref, opts \\ []) do
wire_ref = SnakeBridge.StreamRef.to_wire_format(stream_ref)
call_method(wire_ref, :__len__, [], opts)
end
@spec call_class(module_ref(), function_name(), args(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
def call_class(module, function, args \\ [], opts \\ []) do
{kwargs, idempotent, extra_args, runtime_opts} = split_opts(opts)
encoded_args = encode_args(args ++ extra_args)
encoded_kwargs = encode_kwargs(kwargs)
# Determine session_id ONCE using correct priority
session_id = resolve_session_id(runtime_opts)
payload =
module
|> base_payload(function, encoded_args, encoded_kwargs, idempotent)
|> Map.put("call_type", "class")
|> Map.put("class", python_class_name(module))
|> Map.put("session_id", session_id)
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :call)
|> ensure_session_opt(session_id)
metadata = call_metadata(payload, module, function, "class")
execute_with_telemetry(metadata, fn ->
runtime_client().execute("snakebridge.call", payload, runtime_opts)
end)
|> apply_error_mode()
|> decode_result()
end
@spec call_method(SnakeBridge.Ref.t() | map(), function_name(), args(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
def call_method(ref, function, args \\ [], opts \\ []) do
{kwargs, idempotent, extra_args, runtime_opts} = split_opts(opts)
encoded_args = encode_args(args ++ extra_args)
encoded_kwargs = encode_kwargs(kwargs)
wire_ref = normalize_ref(ref)
# Single source of truth: prioritize runtime_opts, then ref session, then context
session_id = resolve_session_id(runtime_opts, wire_ref)
payload =
wire_ref
|> base_payload_for_ref(function, encoded_args, encoded_kwargs, idempotent)
|> Map.put("call_type", "method")
|> Map.put("instance", wire_ref)
|> Map.put("session_id", session_id)
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :call)
|> ensure_session_opt(session_id)
metadata = ref_metadata(payload, function, "method")
execute_with_telemetry(metadata, fn ->
runtime_client().execute("snakebridge.call", payload, runtime_opts)
end)
|> apply_error_mode()
|> decode_result()
end
@doc """
Retrieves a module-level attribute (constant, class, etc.).
## Parameters
- `module` - Either a generated SnakeBridge module atom OR a Python module path string
- `attr` - Attribute name (atom or string)
- `opts` - Runtime options
## Examples
# Get math.pi
{:ok, pi} = SnakeBridge.Runtime.get_module_attr("math", "pi")
{:ok, pi} = SnakeBridge.Runtime.get_module_attr("math", :pi)
"""
@spec get_module_attr(module_ref() | String.t(), atom() | String.t(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
def get_module_attr(module, attr, opts \\ [])
# String module path
def get_module_attr(module, attr, opts) when is_binary(module) do
{_kwargs, _idempotent, _extra_args, runtime_opts} = split_opts(opts)
attr_name = to_string(attr)
# Determine session_id ONCE using correct priority
session_id = resolve_session_id(runtime_opts)
payload =
protocol_payload()
|> Map.put("call_type", "module_attr")
|> Map.put("python_module", module)
|> Map.put("library", library_from_module_path(module))
|> Map.put("attr", attr_name)
|> Map.put("session_id", session_id)
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :call)
|> ensure_session_opt(session_id)
metadata = %{
module: module,
function: attr_name,
library: library_from_module_path(module),
python_module: module,
call_type: "module_attr"
}
execute_with_telemetry(metadata, fn ->
runtime_client().execute("snakebridge.call", payload, runtime_opts)
end)
|> apply_error_mode()
|> decode_result()
end
# Atom module - existing behavior
def get_module_attr(module, attr, opts) when is_atom(module) do
{kwargs, idempotent, _extra_args, runtime_opts} = split_opts(opts)
encoded_kwargs = encode_kwargs(kwargs)
# Determine session_id ONCE using correct priority
session_id = resolve_session_id(runtime_opts)
payload =
module
|> base_payload(attr, [], encoded_kwargs, idempotent)
|> Map.put("call_type", "module_attr")
|> Map.put("attr", to_string(attr))
|> Map.put("session_id", session_id)
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :call)
|> ensure_session_opt(session_id)
metadata = call_metadata(payload, module, attr, "module_attr")
execute_with_telemetry(metadata, fn ->
runtime_client().execute("snakebridge.call", payload, runtime_opts)
end)
|> apply_error_mode()
|> decode_result()
end
# Helper to extract library name from module path
defp library_from_module_path(module_path) when is_binary(module_path) do
module_path
|> String.split(".")
|> List.first()
end
@spec get_attr(SnakeBridge.Ref.t(), atom() | String.t(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
def get_attr(ref, attr, opts \\ []) do
{kwargs, idempotent, _extra_args, runtime_opts} = split_opts(opts)
encoded_kwargs = encode_kwargs(kwargs)
wire_ref = normalize_ref(ref)
# Single source of truth: prioritize runtime_opts, then ref session, then context
session_id = resolve_session_id(runtime_opts, wire_ref)
payload =
wire_ref
|> base_payload_for_ref(attr, [], encoded_kwargs, idempotent)
|> Map.put("call_type", "get_attr")
|> Map.put("instance", wire_ref)
|> Map.put("attr", to_string(attr))
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :call)
|> ensure_session_opt(session_id)
metadata = ref_metadata(payload, attr, "get_attr")
execute_with_telemetry(metadata, fn ->
runtime_client().execute("snakebridge.call", payload, runtime_opts)
end)
|> apply_error_mode()
|> decode_result()
end
@doc false
def build_module_attr_payload(module, attr) do
module
|> base_payload(attr, [], %{}, false)
|> Map.put("call_type", "module_attr")
|> Map.put("attr", to_string(attr))
end
@doc false
def build_dynamic_payload(module_path, function, args, opts) do
{args, opts} = normalize_args_opts(args, opts)
{kwargs, idempotent, extra_args, _runtime_opts} = split_opts(opts)
protocol_payload()
|> Map.put("call_type", "dynamic")
|> Map.put("module_path", module_path)
|> Map.put("function", to_string(function))
|> Map.put("args", List.wrap(args ++ extra_args))
|> Map.put("kwargs", Map.new(kwargs, fn {key, value} -> {to_string(key), value} end))
|> Map.put("idempotent", idempotent)
|> maybe_put_session_id(current_session_id())
end
@spec set_attr(SnakeBridge.Ref.t(), atom() | String.t(), term(), opts()) ::
{:ok, term()} | {:error, Snakepit.Error.t()}
def set_attr(ref, attr, value, opts \\ []) do
{kwargs, idempotent, _extra_args, runtime_opts} = split_opts(opts)
encoded_kwargs = encode_kwargs(kwargs)
encoded_args = encode_args([value])
wire_ref = normalize_ref(ref)
# Single source of truth: prioritize runtime_opts, then ref session, then context
session_id = resolve_session_id(runtime_opts, wire_ref)
payload =
wire_ref
|> base_payload_for_ref(attr, encoded_args, encoded_kwargs, idempotent)
|> Map.put("call_type", "set_attr")
|> Map.put("instance", wire_ref)
|> Map.put("attr", to_string(attr))
runtime_opts =
runtime_opts
|> apply_runtime_defaults(payload, :call)
|> ensure_session_opt(session_id)
metadata = ref_metadata(payload, attr, "set_attr")
execute_with_telemetry(metadata, fn ->
runtime_client().execute("snakebridge.call", payload, runtime_opts)
end)
|> apply_error_mode()
|> decode_result()
end
@spec release_ref(SnakeBridge.Ref.t(), opts()) :: :ok | {:error, Snakepit.Error.t()}
def release_ref(ref, opts \\ []) do
{_, _, _, runtime_opts} = split_opts(opts)
wire_ref = normalize_ref(ref)
# Single source of truth: prioritize runtime_opts, then ref session, then context
session_id = resolve_session_id(runtime_opts, wire_ref)
runtime_opts = ensure_session_opt(runtime_opts, session_id)
payload =
protocol_payload()
|> Map.put("ref", wire_ref)
|> maybe_put_session_id(session_id)
runtime_client().execute("snakebridge.release_ref", payload, runtime_opts)
|> apply_error_mode()
|> normalize_release_result()
end
@spec release_session(String.t(), opts()) :: :ok | {:error, Snakepit.Error.t()}
def release_session(session_id, opts \\ []) when is_binary(session_id) do
{_, _, _, runtime_opts} = split_opts(opts)
runtime_opts = ensure_session_opt(runtime_opts, session_id)
payload =
protocol_payload()
|> Map.put("session_id", session_id)
runtime_client().execute("snakebridge.release_session", payload, runtime_opts)
|> apply_error_mode()
|> normalize_release_result()
end
defp runtime_client do
Application.get_env(:snakebridge, :runtime_client, Snakepit)
end
defp execute_with_telemetry(metadata, fun) do
start_time = System.monotonic_time()
emit_runtime_event(
[:snakepit, :python, :call, :start],
%{system_time: System.system_time()},
metadata
)
try do
result = fun.()
case result do
{:error, reason} ->
emit_runtime_event(
[:snakepit, :python, :call, :exception],
%{duration: System.monotonic_time() - start_time},
Map.put(metadata, :error, reason)
)
_ ->
emit_runtime_event(
[:snakepit, :python, :call, :stop],
%{duration: System.monotonic_time() - start_time},
metadata
)
end
result
rescue
exception ->
emit_runtime_event(
[:snakepit, :python, :call, :exception],
%{duration: System.monotonic_time() - start_time},
Map.put(metadata, :reason, exception)
)
reraise exception, __STACKTRACE__
end
end
defp emit_runtime_event(event, measurements, metadata) do
case Application.ensure_all_started(:telemetry) do
{:ok, _} -> :telemetry.execute(event, measurements, metadata)
{:error, _} -> :ok
end
end
defp call_metadata(payload, module, function, call_type) do
%{
module: module,
function: to_string(function),
library: payload["library"],
python_module: payload["python_module"],
call_type: call_type
}
end
defp ref_metadata(payload, function, call_type) do
%{
module: payload["python_module"],
function: to_string(function),
library: payload["library"],
python_module: payload["python_module"],
call_type: call_type
}
end
defp helper_metadata(helper) do
%{
module: helper,
function: helper,
library: helper_library(helper),
python_module: helper_library(helper),
call_type: "helper"
}
end
defp split_opts(opts) do
extra_args = Keyword.get(opts, :__args__, [])
idempotent = Keyword.get(opts, :idempotent, false)
runtime_opts = Keyword.get(opts, :__runtime__, [])
kwargs =
opts
|> Keyword.drop([:__args__, :idempotent, :__runtime__])
|> Enum.into(%{}, fn {key, value} -> {to_string(key), value} end)
{kwargs, idempotent, List.wrap(extra_args), runtime_opts}
end
# Session ID single source of truth: determine once, use everywhere
# Priority: runtime_opts override > ref session_id > context session > auto-session
@doc false
def resolve_session_id(runtime_opts, ref \\ nil) do
session_id_from_runtime_opts(runtime_opts) ||
session_id_from_ref(ref) ||
current_session_id()
end
defp session_id_from_runtime_opts(runtime_opts) when is_list(runtime_opts) do
Keyword.get(runtime_opts, :session_id)
end
defp session_id_from_runtime_opts(_), do: nil
defp session_id_from_ref(%SnakeBridge.Ref{session_id: id}) when is_binary(id), do: id
defp session_id_from_ref(%SnakeBridge.StreamRef{session_id: id}) when is_binary(id), do: id
defp session_id_from_ref(ref) when is_map(ref) do
if Map.has_key?(ref, "session_id") or Map.has_key?(ref, :session_id) do
ref_field(ref, "session_id")
end
end
defp session_id_from_ref(_), do: nil
defp ensure_session_opt(runtime_opts, session_id) when is_binary(session_id) do
cond do
runtime_opts == nil ->
[session_id: session_id]
is_list(runtime_opts) ->
Keyword.put_new(runtime_opts, :session_id, session_id)
true ->
runtime_opts
end
end
defp ensure_session_opt(runtime_opts, _session_id), do: runtime_opts
# ============================================================================
# Runtime Timeout Defaults
# ============================================================================
# Applies runtime timeout defaults to the given runtime options.
#
# This function merges profile-based timeouts with user-provided options,
# respecting the following priority (highest to lowest):
# 1. Explicit user-provided options (e.g., `timeout: 60_000`)
# 2. User-selected profile (via `timeout_profile:` or `profile:`)
# 3. Library-specific profile (from `runtime.library_profiles` config)
# 4. Default profile based on call kind (`:default` for calls, `:streaming` for streams)
@doc false
@spec apply_runtime_defaults(keyword() | nil, map(), atom()) :: keyword()
def apply_runtime_defaults(runtime_opts, payload, call_kind) do
runtime_opts = List.wrap(runtime_opts || [])
library = payload["library"]
profile = resolve_timeout_profile(runtime_opts, library, call_kind)
profile_opts = get_profile_opts(profile)
# Merge: profile defaults < user overrides
merged =
profile_opts
|> Keyword.merge(runtime_opts)
merged
|> Keyword.put_new(:timeout_profile, profile)
|> Keyword.put_new(:timeout, SnakeBridge.Defaults.runtime_default_timeout())
|> maybe_put_stream_defaults(call_kind)
end
defp resolve_timeout_profile(runtime_opts, library, call_kind) do
# Priority: explicit > library_profiles > global default
Keyword.get(runtime_opts, :timeout_profile) ||
Keyword.get(runtime_opts, :profile) ||
library_profile(library) ||
SnakeBridge.Defaults.runtime_timeout_profile(call_kind)
end
defp get_profile_opts(profile) do
SnakeBridge.Defaults.runtime_profiles()
|> Map.get(profile, [])
end
defp library_profile(nil), do: nil
defp library_profile(library) when is_binary(library) do
profiles = SnakeBridge.Defaults.runtime_library_profiles()
Map.get(profiles, library) ||
Map.get(profiles, String.to_existing_atom(library))
rescue
ArgumentError -> nil
end
defp library_profile(_), do: nil
defp maybe_put_stream_defaults(opts, :stream) do
Keyword.put_new(opts, :stream_timeout, SnakeBridge.Defaults.runtime_default_stream_timeout())
end
defp maybe_put_stream_defaults(opts, _), do: opts
@doc false
@spec normalize_args_opts(list(), keyword()) :: {list(), keyword()}
def normalize_args_opts(args, opts) do
if opts == [] and Keyword.keyword?(args) do
{[], args}
else
{args, opts}
end
end
defp base_payload(module, function, args, kwargs, idempotent) do
python_module = python_module_name(module)
%{
"protocol_version" => @protocol_version,
"min_supported_version" => @min_supported_version,
"library" => library_name(module, python_module),
"python_module" => python_module,
"function" => to_string(function),
"args" => List.wrap(args),
"kwargs" => kwargs,
"idempotent" => idempotent
}
|> maybe_put_session_id(current_session_id())
end
defp base_payload_for_ref(ref, function, args, kwargs, idempotent) do
python_module =
ref_field(ref, "python_module") || ref_field(ref, "library") || python_module_name(ref)
library = ref_field(ref, "library") || library_name(ref, python_module)
session_id = ref_field(ref, "session_id") || current_session_id()
%{
"protocol_version" => @protocol_version,
"min_supported_version" => @min_supported_version,
"library" => library,
"python_module" => python_module,
"function" => to_string(function),
"args" => List.wrap(args),
"kwargs" => kwargs,
"idempotent" => idempotent
}
|> maybe_put_session_id(session_id)
end
defp python_module_name(module) when is_atom(module) do
if function_exported?(module, :__snakebridge_python_name__, 0) do
module.__snakebridge_python_name__()
else
module
|> Module.split()
|> Enum.map_join(".", &Macro.underscore/1)
end
end
defp python_module_name(%{python_module: python_module}) when is_binary(python_module),
do: python_module
defp python_module_name(_), do: "unknown"
defp library_name(module, python_module) when is_atom(module) do
if function_exported?(module, :__snakebridge_library__, 0) do
module.__snakebridge_library__()
else
python_module |> String.split(".") |> List.first()
end
end
defp library_name(_module, python_module) do
python_module |> String.split(".") |> List.first()
end
defp python_class_name(module) when is_atom(module) do
if function_exported?(module, :__snakebridge_python_class__, 0) do
module.__snakebridge_python_class__()
else
module |> Module.split() |> List.last()
end
end
defp helper_payload(helper, args, kwargs, idempotent) do
%{
"protocol_version" => @protocol_version,
"min_supported_version" => @min_supported_version,
"call_type" => "helper",
"helper" => helper,
"function" => helper,
"library" => helper_library(helper),
"args" => List.wrap(args),
"kwargs" => kwargs,
"idempotent" => idempotent,
"helper_config" => SnakeBridge.Helpers.payload_config(SnakeBridge.Helpers.runtime_config())
}
|> maybe_put_session_id(current_session_id())
end
@doc false
def protocol_payload do
%{
"protocol_version" => @protocol_version,
"min_supported_version" => @min_supported_version
}
end
defp helper_library(helper) when is_binary(helper) do
case String.split(helper, ".", parts: 2) do
[library, _rest] -> library
_ -> "unknown"
end
end
defp helper_library(_), do: "unknown"
defp current_session_id do
case SnakeBridge.SessionContext.current() do
%{session_id: session_id} when is_binary(session_id) -> session_id
_ -> ensure_auto_session()
end
end
# Auto-session management
@doc """
Returns the current session ID (explicit or auto-generated).
This is useful for debugging or when you need to know which session is active.
"""
@spec current_session() :: String.t()
def current_session do
current_session_id()
end
@doc """
Clears the auto-session for the current process.
Useful for testing or when you want to force a new session.
Does NOT release the session on the Python side - use `release_auto_session/0` for that.
"""
@spec clear_auto_session() :: :ok
def clear_auto_session do
Process.delete(@auto_session_key)
:ok
end
@doc """
Releases and clears the auto-session for the current process.
This releases all refs associated with the session on both Elixir and Python sides.
"""
@spec release_auto_session() :: :ok
def release_auto_session do
case Process.get(@auto_session_key) do
nil ->
:ok
session_id ->
# Release on Python side
release_session(session_id)
# Unregister from SessionManager
SessionManager.unregister_session(session_id)
# Clear from process dictionary
Process.delete(@auto_session_key)
:ok
end
end
defp ensure_auto_session do
case Process.get(@auto_session_key) do
nil ->
session_id = generate_auto_session_id()
setup_auto_session(session_id)
session_id
session_id ->
session_id
end
end
defp generate_auto_session_id do
pid_string = self() |> :erlang.pid_to_list() |> to_string()
timestamp = System.system_time(:millisecond)
"auto_#{pid_string}_#{timestamp}"
end
defp setup_auto_session(session_id) do
# Store in process dictionary
Process.put(@auto_session_key, session_id)
# Register with SessionManager for monitoring
# This ensures cleanup when the process dies
SessionManager.register_session(session_id, self())
# Ensure Snakepit session exists (if SessionStore is available)
ensure_snakepit_session(session_id)
end
defp ensure_snakepit_session(session_id) do
# Only call if SessionStore module is available
# Use apply/3 to avoid compile-time warnings about undefined module
if Code.ensure_loaded?(Snakepit.SessionStore) do
# credo:disable-for-next-line Credo.Check.Refactor.Apply
case apply(Snakepit.SessionStore, :create_session, [session_id]) do
{:ok, _} ->
:ok
{:error, :already_exists} ->
:ok
{:error, reason} ->
Logger.warning("Failed to create Snakepit session #{session_id}: #{inspect(reason)}")
:ok
end
else
:ok
end
end
defp maybe_put_session_id(payload, nil), do: payload
defp maybe_put_session_id(payload, session_id) when is_binary(session_id) do
Map.put(payload, "session_id", session_id)
end
defp classify_helper_result({:error, reason}, helper) do
{:error, classify_helper_error(reason, helper)}
end
defp classify_helper_result(result, _helper), do: result
defp classify_helper_error({:invalid_parameter, :json_encode_failed, message}, _helper) do
SnakeBridge.SerializationError.new(message)
end
defp classify_helper_error(
%{python_type: "SnakeBridgeHelperNotFoundError", message: message},
helper
) do
helper_name = extract_helper_name(message) || helper
SnakeBridge.HelperNotFoundError.new(helper_name)
end
defp classify_helper_error(
%{python_type: "SnakeBridgeSerializationError", message: message},
_helper
) do
SnakeBridge.SerializationError.new(message)
end
defp classify_helper_error(reason, _helper), do: reason
defp extract_helper_name(message) when is_binary(message) do
case Regex.run(~r/Helper ['"]([^'"]+)['"]/, message) do
[_, helper] -> helper
_ -> nil
end
end
defp extract_helper_name(_), do: nil
defp encode_args(args) do
args
|> List.wrap()
|> Enum.map(&Types.encode/1)
end
defp encode_kwargs(kwargs) do
kwargs
|> Enum.into(%{}, fn {key, value} -> {to_string(key), Types.encode(value)} end)
end
defp stringify_keys(map) when is_map(map) do
Enum.into(map, %{}, fn {key, value} -> {to_string(key), value} end)
end
defp decode_result({:ok, value}), do: {:ok, Types.decode(value)}
defp decode_result(result), do: result
defp apply_error_mode({:error, reason}) do
case error_mode() do
:raw ->
{:error, reason}
:translated ->
{:error, translate_reason(reason)}
:raise_translated ->
translated = translate_reason(reason)
if translated == reason do
{:error, reason}
else
raise translated
end
end
end
defp apply_error_mode(result), do: result
defp normalize_release_result({:ok, _}), do: :ok
defp normalize_release_result(:ok), do: :ok
defp normalize_release_result(result), do: result
defp translate_reason(reason) do
case python_error_payload(reason) do
{_message, traceback, type} when is_binary(type) ->
translated = SnakeBridge.ErrorTranslator.translate(reason, traceback)
if translated == reason, do: reason, else: translated
{message, traceback, _type} when is_binary(message) ->
translated =
SnakeBridge.ErrorTranslator.translate(%RuntimeError{message: message}, traceback)
case translated do
%RuntimeError{} -> reason
_ -> translated
end
_ ->
reason
end
end
defp python_error_payload(error) when is_map(error) do
{extract_error_message(error), extract_error_traceback(error), extract_error_type(error)}
end
defp python_error_payload(_), do: {nil, nil, nil}
defp extract_error_message(error) do
get_first_present(error, [:message, "message", :error, "error"])
end
defp extract_error_traceback(error) do
get_first_present(error, [:traceback, "traceback", :python_traceback, "python_traceback"])
end
defp extract_error_type(error) do
get_first_present(error, [:python_type, "python_type", :error_type, "error_type"])
end
defp get_first_present(map, keys) do
Enum.find_value(keys, fn key -> Map.get(map, key) end)
end
defp error_mode do
Application.get_env(:snakebridge, :error_mode, :raw)
end
defp ref_field(ref, "python_module") when is_map(ref),
do: Map.get(ref, "python_module") || Map.get(ref, :python_module)
defp ref_field(ref, "library") when is_map(ref),
do: Map.get(ref, "library") || Map.get(ref, :library)
defp ref_field(ref, "session_id") when is_map(ref),
do: Map.get(ref, "session_id") || Map.get(ref, :session_id)
defp ref_field(ref, "id") when is_map(ref),
do: Map.get(ref, "id") || Map.get(ref, :id) || Map.get(ref, "ref_id") || Map.get(ref, :ref_id)
defp ref_field(_ref, _key), do: nil
defp normalize_ref(%SnakeBridge.Ref{} = ref), do: SnakeBridge.Ref.to_wire_format(ref)
defp normalize_ref(ref) when is_map(ref) do
if Map.get(ref, "__type__") == "ref" or Map.get(ref, :__type__) == "ref" do
SnakeBridge.Ref.to_wire_format(ref)
else
ref
end
end
defp normalize_ref(ref), do: ref
end
</file>
<file path="snakebridge/scan_error.ex">
defmodule SnakeBridge.ScanError do
@moduledoc """
Structured error for scan failures.
"""
defexception [:failures]
@type t :: %__MODULE__{failures: list(map())}
@impl Exception
def message(%__MODULE__{failures: failures}) do
failures
|> Enum.map_join("\n", fn %{path: path, reason: reason} ->
" - #{path}: #{inspect(reason)}"
end)
|> then(&("Scan failed for #{length(failures)} file(s):\n" <> &1))
end
end
</file>
<file path="snakebridge/scanner.ex">
defmodule SnakeBridge.Scanner do
@moduledoc """
Scans project source files for Python library calls.
"""
@type call_ref :: {module(), atom(), non_neg_integer()}
@spec scan_project(SnakeBridge.Config.t()) :: [call_ref()]
def scan_project(config) do
start_time = System.monotonic_time()
library_modules = Enum.map(config.libraries, & &1.module_name)
files = source_files(config)
{calls, failures} =
files
|> Task.async_stream(&scan_file(&1, library_modules))
|> Enum.zip(files)
|> Enum.reduce({[], []}, fn
{{:ok, calls}, _path}, {acc_calls, acc_failures} ->
{calls ++ acc_calls, acc_failures}
{{:exit, reason}, path}, {acc_calls, acc_failures} ->
{acc_calls, [%{path: path, reason: reason, type: :exit} | acc_failures]}
{{:error, reason}, path}, {acc_calls, acc_failures} ->
{acc_calls, [%{path: path, reason: reason, type: :error} | acc_failures]}
end)
calls =
calls
|> Enum.uniq()
|> Enum.sort()
SnakeBridge.Telemetry.scan_stop(
start_time,
length(files),
length(calls),
config.scan_paths || ["lib"]
)
if failures != [] do
raise SnakeBridge.ScanError, failures: Enum.reverse(failures)
end
calls
end
defp source_files(config) do
scan_paths = config.scan_paths || ["lib"]
scan_exclude = config.scan_exclude || []
scan_paths
|> Enum.flat_map(&Path.wildcard(Path.join(&1, "**/*.ex")))
|> Enum.reject(fn path ->
in_generated_dir?(path, config.generated_dir) or excluded_path?(path, scan_exclude)
end)
end
defp in_generated_dir?(path, generated_dir) do
String.starts_with?(path, generated_dir)
end
defp excluded_path?(path, patterns) do
Enum.any?(patterns, fn pattern ->
path in Path.wildcard(pattern)
end)
end
defp scan_file(path, library_modules) do
case File.read(path) do
{:ok, content} ->
case Code.string_to_quoted(content, file: path) do
{:ok, ast} ->
context = build_context(ast, library_modules)
extract_calls(ast, context)
{:error, _} ->
[]
end
{:error, _} ->
[]
end
end
defp build_context(ast, library_modules) do
{_, context} =
Macro.prewalk(ast, %{aliases: %{}, imports: []}, fn
{:alias, _, [{:__aliases__, _, parts} | opts]}, ctx ->
module = Module.concat(parts)
if library_module?(module, library_modules) do
alias_name = alias_name(parts, opts)
{nil, put_in(ctx, [:aliases, alias_name], module)}
else
{nil, ctx}
end
{:import, _, [{:__aliases__, _, parts} | opts]}, ctx ->
module = Module.concat(parts)
if library_module?(module, library_modules) do
{nil, update_in(ctx, [:imports], &[{module, opts} | &1])}
else
{nil, ctx}
end
node, ctx ->
{node, ctx}
end)
Map.put(context, :library_modules, library_modules)
end
defp alias_name(parts, opts) do
case Keyword.get(opts, :as) do
{:__aliases__, _, [name]} -> name
nil -> List.last(parts)
end
end
defp extract_calls(ast, context) do
{_, calls} =
Macro.prewalk(ast, [], fn
{{:., _, [{:__aliases__, _, parts}, function]}, _, args} = node, acc
when is_atom(function) and is_list(args) ->
module = resolve_module(parts, context)
if module do
{node, [{module, function, length(args)} | acc]}
else
{node, acc}
end
{function, _, args} = node, acc
when is_atom(function) and is_list(args) ->
case find_import(function, length(args), context) do
{:ok, module} -> {node, [{module, function, length(args)} | acc]}
:not_found -> {node, acc}
end
node, acc ->
{node, acc}
end)
calls
end
defp resolve_module(parts, context) do
module = Module.concat(parts)
case parts do
[name] when is_atom(name) ->
Map.get(context.aliases, name) ||
if library_module?(module, context.library_modules), do: module, else: nil
_ ->
if library_module?(module, context.library_modules), do: module, else: nil
end
end
defp library_module?(module, library_modules) do
module_parts = Module.split(module)
Enum.any?(library_modules, fn library_module ->
library_parts = Module.split(library_module)
Enum.take(module_parts, length(library_parts)) == library_parts
end)
end
defp find_import(function, arity, context) do
Enum.find_value(context.imports, :not_found, fn {module, opts} ->
only = Keyword.get(opts, :only, nil)
except = Keyword.get(opts, :except, [])
cond do
{function, arity} in except -> nil
only && {function, arity} not in only -> nil
true -> {:ok, module}
end
end)
end
end
</file>
<file path="snakebridge/serialization_error.ex">
defmodule SnakeBridge.SerializationError do
@moduledoc """
Raised when attempting to encode a value that cannot be serialized for Python.
SnakeBridge supports encoding:
- Primitives: `nil`, booleans, integers, floats, strings
- Collections: lists, maps, tuples, MapSets
- Special types: atoms, DateTime, Date, Time, SnakeBridge.Bytes
- References: SnakeBridge.Ref, SnakeBridge.StreamRef
- Functions: anonymous functions (as callbacks)
- Special floats: `:infinity`, `:neg_infinity`, `:nan`
Types that cannot be serialized:
- PIDs, ports, references
- Custom structs without serialization support
- File handles, sockets, other system resources
## Resolution
For unsupported types, you have several options:
1. **Create a Python object and pass the ref**:
{:ok, ref} = SnakeBridge.call("module", "create_object", [...])
SnakeBridge.call("module", "use_object", [ref])
2. **Convert to a supported type**:
# Instead of passing a PID
SnakeBridge.call("module", "fn", [inspect(pid)])
# Or extract relevant data
SnakeBridge.call("module", "fn", [pid_to_list(pid)])
3. **Use explicit bytes for binary data**:
SnakeBridge.call("module", "fn", [SnakeBridge.bytes(binary)])
"""
defexception [:message, :value, :type]
@type t :: %__MODULE__{
message: String.t(),
value: term(),
type: atom() | module()
}
@impl true
def exception(opts) when is_list(opts) do
value = Keyword.fetch!(opts, :value)
type = get_type(value)
message = build_message(value, type)
%__MODULE__{
message: message,
value: value,
type: type
}
end
@doc """
Creates a SerializationError from a message string.
This is used for error messages from the Python side.
"""
@spec new(String.t() | nil) :: t()
def new(message \\ nil) do
%__MODULE__{
message: message || "Arguments are not JSON-serializable",
value: nil,
type: :unknown
}
end
defp get_type(value) when is_pid(value), do: :pid
defp get_type(value) when is_port(value), do: :port
defp get_type(value) when is_reference(value), do: :reference
defp get_type(%{__struct__: struct_name}), do: struct_name
defp get_type(_), do: :unknown
defp build_message(value, type) do
"""
Cannot serialize value of type #{inspect(type)} for Python.
Value: #{inspect(value, limit: 50, printable_limit: 100)}
SnakeBridge cannot automatically serialize this type. See the module documentation
for SnakeBridge.SerializationError for resolution options.
"""
end
end
</file>
<file path="snakebridge/session_context.ex">
defmodule SnakeBridge.SessionContext do
@moduledoc """
Provides scoped session context for Python calls.
Sessions control the lifecycle of Python object references (refs). Each session
is isolated, meaning refs from one session cannot be used in another.
## Automatic vs Explicit Sessions
By default, SnakeBridge creates an auto-session for each Elixir process. This is
convenient for most use cases where Python objects don't need to be shared.
Use explicit sessions when you need:
- Multiple processes to access the same Python objects
- Long-lived refs that outlive a single request/task
- Fine-grained control over cleanup timing
## Usage
# Explicit session with custom ID
SnakeBridge.SessionContext.with_session([session_id: "my-session"], fn ->
{:ok, model} = SnakeBridge.call("sklearn.linear_model", "LinearRegression", [])
# model ref is accessible by other processes using "my-session"
model
end)
# Simple scoped session (auto-generated ID)
SnakeBridge.SessionContext.with_session(fn ->
# All Python calls here use the same session
{:ok, df} = SnakeBridge.call("pandas", "DataFrame", [[1, 2, 3]])
{:ok, mean} = SnakeBridge.method(df, "mean", [])
mean
end)
## Session Cleanup
Sessions are automatically cleaned up when:
- The owning process dies (auto-sessions)
- `SnakeBridge.Runtime.release_session/1` is called explicitly
- Refs exceed TTL (SessionContext default: 1 hour) or max count (default 10,000)
## Sharing Refs Across Processes
To share Python objects across processes, use the same explicit session_id:
# Process A
session_id = "shared-#{System.unique_integer()}"
SessionContext.with_session([session_id: session_id], fn ->
{:ok, ref} = SnakeBridge.call("heavy_model", "load", [])
send(process_b, {:model, session_id, ref})
end)
# Process B - can use the ref if it adopts the same session
receive do
{:model, session_id, ref} ->
SessionContext.with_session([session_id: session_id], fn ->
{:ok, result} = SnakeBridge.method(ref, "predict", [data])
result
end)
end
## Options
- `:session_id` - Custom session ID (default: auto-generated)
- `:max_refs` - Maximum refs per session (default: 10,000)
- `:ttl_seconds` - Session time-to-live in seconds (default: 3600, i.e., 1 hour)
- `:tags` - Custom metadata for debugging
"""
alias Snakepit.Bridge.SessionStore
@context_key :snakebridge_session_context
defstruct [
:session_id,
:owner_pid,
:created_at,
max_refs: 10_000,
ttl_seconds: 3600,
tags: %{}
]
@type t :: %__MODULE__{
session_id: String.t(),
owner_pid: pid(),
created_at: integer(),
max_refs: pos_integer(),
ttl_seconds: pos_integer(),
tags: map()
}
@doc """
Creates a new session context.
"""
@spec create(keyword()) :: t()
def create(opts \\ []) do
default_max_refs = Application.get_env(:snakebridge, :session_max_refs, 10_000)
default_ttl = Application.get_env(:snakebridge, :session_ttl_seconds, 3600)
%__MODULE__{
session_id: Keyword.get(opts, :session_id, generate_session_id()),
owner_pid: Keyword.get(opts, :owner_pid, self()),
created_at: System.system_time(:second),
max_refs: Keyword.get(opts, :max_refs, default_max_refs),
ttl_seconds: Keyword.get(opts, :ttl_seconds, default_ttl),
tags: Keyword.get(opts, :tags, %{})
}
end
@doc """
Gets the current session context from the process dictionary.
"""
@spec current() :: t() | nil
def current do
Process.get(@context_key)
end
@doc """
Sets the current session context in the process dictionary.
"""
@spec put_current(t()) :: t() | nil
def put_current(context) do
Process.put(@context_key, context)
end
@doc """
Clears the current session context.
"""
@spec clear_current() :: t() | nil
def clear_current do
Process.delete(@context_key)
end
@doc """
Executes a function within a session context.
The session is automatically registered and will be released
when the owner process dies.
"""
@spec with_session((-> result)) :: result when result: term()
def with_session(fun) when is_function(fun, 0) do
with_session([], fun)
end
@spec with_session(keyword(), (-> result)) :: result when result: term()
def with_session(opts, fun) when is_list(opts) and is_function(fun, 0) do
context = create(opts)
case SnakeBridge.SessionManager.register_session(context.session_id, context.owner_pid) do
:ok -> :ok
{:error, :already_exists} -> :ok
end
ensure_snakepit_session(context.session_id)
old_context = put_current(context)
try do
fun.()
after
if old_context do
put_current(old_context)
else
clear_current()
end
end
end
defp generate_session_id do
"session_#{:erlang.unique_integer([:positive])}_#{System.system_time(:millisecond)}"
end
defp ensure_snakepit_session(session_id) when is_binary(session_id) do
if Code.ensure_loaded?(SessionStore) and Process.whereis(SessionStore) do
_ = SessionStore.create_session(session_id)
end
:ok
end
end
</file>
<file path="snakebridge/session_manager.ex">
defmodule SnakeBridge.SessionManager do
@moduledoc """
Manages Python session lifecycle with process monitoring.
Sessions are automatically released when their owner process dies,
preventing memory leaks in long-running applications.
"""
use GenServer
require Logger
@type session_id :: String.t()
@type ref :: map()
# Client API
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Registers a new session with an owner process.
The session will be released when the owner dies.
"""
@spec register_session(session_id(), pid()) :: :ok | {:error, :already_exists}
def register_session(session_id, owner_pid) do
GenServer.call(__MODULE__, {:register_session, session_id, owner_pid})
end
@doc """
Registers a ref with its session for tracking.
"""
@spec register_ref(session_id(), ref()) :: :ok | {:error, :session_not_found}
def register_ref(session_id, ref) do
GenServer.call(__MODULE__, {:register_ref, session_id, ref})
end
@doc """
Checks if a session exists.
"""
@spec session_exists?(session_id()) :: boolean()
def session_exists?(session_id) do
GenServer.call(__MODULE__, {:session_exists?, session_id})
end
@doc """
Lists all refs in a session.
"""
@spec list_refs(session_id()) :: [ref()]
def list_refs(session_id) do
GenServer.call(__MODULE__, {:list_refs, session_id})
end
@doc """
Explicitly releases a session and all its refs.
"""
@spec release_session(session_id()) :: :ok
def release_session(session_id) do
GenServer.call(__MODULE__, {:release_session, session_id})
end
@doc """
Unregisters a session without releasing refs on the Python side.
Typically called when manually cleaning up before process death,
or when the caller has already released the session.
"""
@spec unregister_session(session_id()) :: :ok
def unregister_session(session_id) do
GenServer.call(__MODULE__, {:unregister_session, session_id})
end
# Server Implementation
@impl true
def init(_opts) do
state = %{
# session_id => %{owner_pid, monitor_ref, refs, created_at}
sessions: %{},
# monitor_ref => session_id
monitors: %{}
}
{:ok, state}
end
@impl true
def handle_call({:register_session, session_id, owner_pid}, _from, state) do
if Map.has_key?(state.sessions, session_id) do
{:reply, {:error, :already_exists}, state}
else
monitor_ref = Process.monitor(owner_pid)
session_data = %{
owner_pid: owner_pid,
monitor_ref: monitor_ref,
refs: [],
created_at: System.system_time(:second)
}
new_state = %{
state
| sessions: Map.put(state.sessions, session_id, session_data),
monitors: Map.put(state.monitors, monitor_ref, session_id)
}
{:reply, :ok, new_state}
end
end
@impl true
def handle_call({:register_ref, session_id, ref}, _from, state) do
case Map.get(state.sessions, session_id) do
nil ->
{:reply, {:error, :session_not_found}, state}
session_data ->
updated = %{session_data | refs: [ref | session_data.refs]}
new_state = put_in(state.sessions[session_id], updated)
{:reply, :ok, new_state}
end
end
@impl true
def handle_call({:session_exists?, session_id}, _from, state) do
{:reply, Map.has_key?(state.sessions, session_id), state}
end
@impl true
def handle_call({:list_refs, session_id}, _from, state) do
refs =
case Map.get(state.sessions, session_id) do
nil -> []
session_data -> session_data.refs
end
{:reply, refs, state}
end
@impl true
def handle_call({:release_session, session_id}, _from, state) do
new_state = do_release_session(state, session_id)
{:reply, :ok, new_state}
end
@impl true
def handle_call({:unregister_session, session_id}, _from, state) do
case Map.get(state.sessions, session_id) do
nil ->
{:reply, :ok, state}
%{monitor_ref: ref} ->
Process.demonitor(ref, [:flush])
new_state = %{
state
| sessions: Map.delete(state.sessions, session_id),
monitors: Map.delete(state.monitors, ref)
}
{:reply, :ok, new_state}
end
end
@impl true
def handle_info({:DOWN, monitor_ref, :process, _pid, _reason}, state) do
case Map.get(state.monitors, monitor_ref) do
nil ->
{:noreply, state}
session_id ->
Logger.debug("Session owner died, releasing session: #{session_id}")
new_state = do_release_session(state, session_id)
{:noreply, new_state}
end
end
defp do_release_session(state, session_id) do
case Map.get(state.sessions, session_id) do
nil ->
state
session_data ->
Process.demonitor(session_data.monitor_ref, [:flush])
Task.start(fn ->
try do
SnakeBridge.Runtime.release_session(session_id, [])
rescue
_ -> :ok
catch
:exit, _ -> :ok
end
end)
%{
state
| sessions: Map.delete(state.sessions, session_id),
monitors: Map.delete(state.monitors, session_data.monitor_ref)
}
end
end
end
</file>
<file path="snakebridge/session_mismatch_error.ex">
defmodule SnakeBridge.SessionMismatchError do
@moduledoc """
Raised when a ref is used with a different session than it was created in.
SnakeBridge refs are session-scoped: a ref created in session A cannot be
used in session B. This error indicates a ref is being used across session
boundaries.
## Fields
- `:ref_id` - The ref ID that caused the mismatch
- `:expected_session` - The session ID the ref belongs to
- `:actual_session` - The session ID the ref was used in
- `:message` - Human-readable error message
"""
defexception [:ref_id, :expected_session, :actual_session, :message]
@type t :: %__MODULE__{
ref_id: String.t() | nil,
expected_session: String.t() | nil,
actual_session: String.t() | nil,
message: String.t()
}
@impl Exception
def exception(opts) when is_list(opts) do
ref_id = Keyword.get(opts, :ref_id)
expected_session = Keyword.get(opts, :expected_session)
actual_session = Keyword.get(opts, :actual_session)
message =
Keyword.get(opts, :message) || build_message(ref_id, expected_session, actual_session)
%__MODULE__{
ref_id: ref_id,
expected_session: expected_session,
actual_session: actual_session,
message: message
}
end
@impl Exception
def message(%__MODULE__{message: message}), do: message
defp build_message(ref_id, expected, actual) do
"SnakeBridge reference '#{ref_id || "unknown"}' belongs to session '#{expected || "unknown"}' " <>
"but was used in session '#{actual || "unknown"}'. Refs cannot be shared across sessions."
end
end
</file>
<file path="snakebridge/snakepit_types.ex">
if Code.ensure_loaded?(Snakepit.PyRef) == false do
defmodule Snakepit.PyRef do
@moduledoc """
Reference to a Python object managed by Snakepit.
This is a stub type definition used when the Snakepit library is not loaded.
When Snakepit is available, its actual `Snakepit.PyRef` module takes precedence.
"""
@type t :: SnakeBridge.Ref.t()
end
end
if Code.ensure_loaded?(Snakepit.ZeroCopyRef) == false do
defmodule Snakepit.ZeroCopyRef do
@moduledoc """
Reference to a zero-copy Python buffer managed by Snakepit.
This is a stub type definition used when the Snakepit library is not loaded.
When Snakepit is available, its actual `Snakepit.ZeroCopyRef` module takes precedence.
"""
@type t :: term()
end
end
if Code.ensure_loaded?(Snakepit.Error) == false do
defmodule Snakepit.Error do
@moduledoc """
Error struct for Snakepit operations.
This is a stub type definition used when the Snakepit library is not loaded.
When Snakepit is available, its actual `Snakepit.Error` module takes precedence.
"""
@type t :: term()
@doc "Creates a validation error."
def validation_error(message, metadata \\ %{}) do
%{type: :validation_error, message: message, metadata: metadata}
end
end
end
</file>
<file path="snakebridge/stream_ref.ex">
defmodule SnakeBridge.StreamRef do
@moduledoc """
Represents a Python iterator or generator as an Elixir stream.
Implements the `Enumerable` protocol for lazy iteration.
"""
defstruct [
:ref_id,
:session_id,
:stream_type,
:python_module,
:library,
exhausted: false
]
@type t :: %__MODULE__{
ref_id: String.t(),
session_id: String.t(),
stream_type: String.t(),
python_module: String.t(),
library: String.t(),
exhausted: boolean()
}
@doc """
Creates a StreamRef from a decoded wire format.
"""
@spec from_wire_format(map()) :: t()
def from_wire_format(map) when is_map(map) do
%__MODULE__{
ref_id: map["id"],
session_id: map["session_id"],
stream_type: map["stream_type"] || "iterator",
python_module: map["python_module"],
library: map["library"],
exhausted: false
}
end
@doc """
Converts back to wire format for Python calls.
"""
@spec to_wire_format(t()) :: map()
def to_wire_format(%__MODULE__{} = ref) do
%{
"__type__" => "ref",
"id" => ref.ref_id,
"session_id" => ref.session_id,
"python_module" => ref.python_module,
"library" => ref.library
}
end
end
defimpl Enumerable, for: SnakeBridge.StreamRef do
alias SnakeBridge.{Runtime, StreamRef}
def count(%StreamRef{stream_type: "generator"}), do: {:error, __MODULE__}
def count(%StreamRef{} = ref) do
case Runtime.stream_len(ref) do
{:ok, len} when is_integer(len) -> {:ok, len}
_ -> {:error, __MODULE__}
end
end
def member?(%StreamRef{}, _value), do: {:error, __MODULE__}
def slice(%StreamRef{}), do: {:error, __MODULE__}
def reduce(%StreamRef{exhausted: true}, {:cont, acc}, _fun) do
{:done, acc}
end
def reduce(%StreamRef{} = ref, {:cont, acc}, fun) do
case Runtime.stream_next(ref) do
{:ok, value} ->
reduce(ref, fun.(value, acc), fun)
{:error, :stop_iteration} ->
{:done, acc}
{:error, reason} ->
{:halted, {:error, reason}}
end
end
def reduce(_ref, {:halt, acc}, _fun), do: {:halted, acc}
def reduce(ref, {:suspend, acc}, fun), do: {:suspended, acc, &reduce(ref, &1, fun)}
end
</file>
<file path="snakebridge/telemetry.ex">
defmodule SnakeBridge.Telemetry do
@moduledoc """
Telemetry event definitions for SnakeBridge.
This module provides instrumentation for compile-time operations including:
- Source scanning
- Python introspection
- Code generation
- Lock file verification
## Event List
| Event | Measurements | Metadata |
|-------|-------------|----------|
| `[:snakebridge, :compile, :start]` | `system_time` | `libraries`, `strict` |
| `[:snakebridge, :compile, :stop]` | `duration`, `symbols_generated`, `files_written` | `libraries`, `mode` |
| `[:snakebridge, :compile, :exception]` | `duration` | `reason`, `stacktrace` |
| `[:snakebridge, :compile, :scan, :stop]` | `duration`, `files_scanned`, `symbols_found` | `library`, `phase`, `details` |
| `[:snakebridge, :compile, :introspect, :start]` | `system_time` | `library`, `phase`, `details` |
| `[:snakebridge, :compile, :introspect, :stop]` | `duration`, `symbols_introspected`, `cache_hits` | `library`, `phase`, `details` |
| `[:snakebridge, :compile, :generate, :stop]` | `duration`, `bytes_written`, `functions_generated`, `classes_generated` | `library`, `phase`, `details` |
| `[:snakebridge, :docs, :fetch]` | `duration` | `module`, `function`, `source` |
| `[:snakebridge, :lock, :verify]` | `duration` | `result`, `warnings` |
## Usage
# Attach handlers in your application
SnakeBridge.Telemetry.Handlers.Logger.attach()
# Compile-time events are automatically emitted during mix compile
"""
# ============================================================
# COMPILATION EVENTS
# ============================================================
@doc """
Emits compile start event.
## Measurements
- `system_time` - System time when compilation started
## Metadata
- `library` - `:all`
- `phase` - `:compile`
- `details` - `%{libraries: [...], strict: boolean()}`
"""
@spec compile_start([atom()], boolean()) :: :ok
def compile_start(libraries, strict) do
emit(
[:snakebridge, :compile, :start],
%{system_time: System.system_time()},
%{library: :all, phase: :compile, details: %{libraries: libraries, strict: strict}}
)
end
@doc """
Emits compile stop event.
## Measurements
- `duration` - Time in native units
- `symbols_generated` - Number of symbols generated
- `files_written` - Number of files written
## Metadata
- `library` - `:all`
- `phase` - `:compile`
- `details` - `%{libraries: [...], mode: :normal | :strict}`
"""
@spec compile_stop(integer(), non_neg_integer(), non_neg_integer(), [atom()], :normal | :strict) ::
:ok
def compile_stop(start_time, symbols, files, libraries, mode) do
emit(
[:snakebridge, :compile, :stop],
%{
duration: System.monotonic_time() - start_time,
symbols_generated: symbols,
files_written: files
},
%{library: :all, phase: :compile, details: %{libraries: libraries, mode: mode}}
)
end
@doc """
Emits compile exception event.
## Measurements
- `duration` - Time in native units
## Metadata
- `library` - `:all`
- `phase` - `:compile`
- `details` - `%{reason: term(), stacktrace: list()}`
"""
@spec compile_exception(integer(), term(), list()) :: :ok
def compile_exception(start_time, reason, stacktrace) do
emit(
[:snakebridge, :compile, :exception],
%{duration: System.monotonic_time() - start_time},
%{library: :all, phase: :compile, details: %{reason: reason, stacktrace: stacktrace}}
)
end
# ============================================================
# SCANNING EVENTS
# ============================================================
@doc """
Emits scan stop event.
## Measurements
- `duration` - Time in native units
- `files_scanned` - Number of files scanned
- `symbols_found` - Number of symbols found
## Metadata
- `library` - `:all`
- `phase` - `:scan`
- `details` - `%{paths: [String.t()]}`
"""
@spec scan_stop(integer(), non_neg_integer(), non_neg_integer(), [String.t()]) :: :ok
def scan_stop(start_time, files, symbols, paths) do
emit(
[:snakebridge, :compile, :scan, :stop],
%{
duration: System.monotonic_time() - start_time,
files_scanned: files,
symbols_found: symbols
},
%{library: :all, phase: :scan, details: %{paths: paths}}
)
end
# ============================================================
# INTROSPECTION EVENTS
# ============================================================
@doc """
Emits introspect start event.
## Measurements
- `system_time` - System time when introspection started
## Metadata
- `library` - Library atom being introspected
- `phase` - `:introspect`
- `details` - `%{batch_size: non_neg_integer()}`
"""
@spec introspect_start(atom(), non_neg_integer()) :: :ok
def introspect_start(library, batch_size) do
emit(
[:snakebridge, :compile, :introspect, :start],
%{system_time: System.system_time()},
%{library: library, phase: :introspect, details: %{batch_size: batch_size}}
)
end
@doc """
Emits introspect stop event.
## Measurements
- `duration` - Time in native units
- `symbols_introspected` - Number of symbols introspected
- `cache_hits` - Number of cache hits
## Metadata
- `library` - Library atom introspected
- `phase` - `:introspect`
- `details` - `%{python_time: integer()}`
"""
@spec introspect_stop(integer(), atom(), non_neg_integer(), non_neg_integer(), integer()) :: :ok
def introspect_stop(start_time, library, symbols, cache_hits, python_time) do
emit(
[:snakebridge, :compile, :introspect, :stop],
%{
duration: System.monotonic_time() - start_time,
symbols_introspected: symbols,
cache_hits: cache_hits
},
%{library: library, phase: :introspect, details: %{python_time: python_time}}
)
end
# ============================================================
# GENERATION EVENTS
# ============================================================
@doc """
Emits generate stop event.
## Measurements
- `duration` - Time in native units
- `bytes_written` - Number of bytes written
- `functions_generated` - Number of functions generated
- `classes_generated` - Number of classes generated
## Metadata
- `library` - Library atom generated
- `phase` - `:generate`
- `details` - `%{file: String.t()}`
"""
@spec generate_stop(
integer(),
atom(),
String.t(),
non_neg_integer(),
non_neg_integer(),
non_neg_integer()
) :: :ok
def generate_stop(start_time, library, file, bytes, functions, classes) do
emit(
[:snakebridge, :compile, :generate, :stop],
%{
duration: System.monotonic_time() - start_time,
bytes_written: bytes,
functions_generated: functions,
classes_generated: classes
},
%{library: library, phase: :generate, details: %{file: file}}
)
end
# ============================================================
# DOCUMENTATION EVENTS
# ============================================================
@doc """
Emits docs fetch event.
## Measurements
- `duration` - Time in native units
## Metadata
- `module` - Module fetched
- `function` - Function name
- `source` - `:cache`, `:python`, or `:metadata`
"""
@spec docs_fetch(integer(), module(), atom(), :cache | :python | :metadata) :: :ok
def docs_fetch(start_time, module, function, source) do
emit(
[:snakebridge, :docs, :fetch],
%{duration: System.monotonic_time() - start_time},
%{module: module, function: function, source: source}
)
end
# ============================================================
# LOCK FILE EVENTS
# ============================================================
@doc """
Emits lock verify event.
## Measurements
- `duration` - Time in native units
## Metadata
- `result` - `:ok`, `:warning`, or `:error`
- `warnings` - List of warning strings
"""
@spec lock_verify(integer(), :ok | :warning | :error, [String.t()]) :: :ok
def lock_verify(start_time, result, warnings \\ []) do
emit(
[:snakebridge, :lock, :verify],
%{duration: System.monotonic_time() - start_time},
%{result: result, warnings: warnings}
)
end
@doc """
Returns the expected metadata fields for an event.
"""
@spec event_metadata_schema([atom()]) :: [atom()]
def event_metadata_schema([:snakebridge, :compile | _]) do
[:library, :phase, :details]
end
def event_metadata_schema([:snakebridge, :runtime | _]) do
[:library, :function, :call_type]
end
def event_metadata_schema(_event), do: []
defp emit(event, measurements, metadata) do
case Application.ensure_all_started(:telemetry) do
{:ok, _} -> :telemetry.execute(event, measurements, metadata)
{:error, _} -> :ok
end
end
end
</file>
<file path="snakebridge/types.ex">
defmodule SnakeBridge.Types do
@moduledoc """
Public API for encoding and decoding Elixir types for Python interop.
This module provides a unified interface for type conversion between Elixir
and Python. It handles the serialization of Elixir-specific types (tuples,
MapSets, DateTime, etc.) into JSON-compatible formats and vice versa.
## Usage
# Encoding Elixir to JSON-compatible format
iex> SnakeBridge.Types.encode({:ok, 42})
%{
"__type__" => "tuple",
"__schema__" => 1,
"elements" => [%{"__type__" => "atom", "__schema__" => 1, "value" => "ok"}, 42]
}
# Decoding JSON-compatible format back to Elixir
iex> SnakeBridge.Types.decode(%{
...> "__type__" => "tuple",
...> "__schema__" => 1,
...> "elements" => [%{"__type__" => "atom", "__schema__" => 1, "value" => "ok"}, 42]
...> })
{:ok, 42}
## Type System
The type system uses tagged JSON representations to preserve type information
across the Elixir-Python boundary. See `SnakeBridge.Types.Encoder` and
`SnakeBridge.Types.Decoder` for details on supported types and their
representations.
## Round-trip Safety
All encoded values can be round-tripped (atoms depend on the decode allowlist):
iex> data = {:ok, MapSet.new([1, 2, 3])}
iex> data |> SnakeBridge.Types.encode() |> SnakeBridge.Types.decode()
{:ok, MapSet.new([1, 2, 3])}
"""
alias SnakeBridge.Types.{Decoder, Encoder}
@schema_version 1
@doc """
Returns the current SnakeBridge wire schema version for tagged values.
"""
@spec schema_version() :: pos_integer()
def schema_version, do: @schema_version
@doc """
Encodes an Elixir value into a JSON-compatible structure.
Delegates to `SnakeBridge.Types.Encoder.encode/1`.
## Examples
iex> SnakeBridge.Types.encode(:ok)
%{"__type__" => "atom", "__schema__" => 1, "value" => "ok"}
iex> SnakeBridge.Types.encode({:ok, 42})
%{
"__type__" => "tuple",
"__schema__" => 1,
"elements" => [%{"__type__" => "atom", "__schema__" => 1, "value" => "ok"}, 42]
}
iex> SnakeBridge.Types.encode(MapSet.new([1, 2, 3]))
%{"__type__" => "set", "__schema__" => 1, "elements" => [1, 2, 3]}
"""
@spec encode(term()) :: term()
defdelegate encode(value), to: Encoder
@doc """
Decodes a JSON-compatible structure back into Elixir types.
Delegates to `SnakeBridge.Types.Decoder.decode/1`.
## Examples
iex> SnakeBridge.Types.decode("ok")
"ok"
iex> SnakeBridge.Types.decode(%{
...> "__type__" => "tuple",
...> "__schema__" => 1,
...> "elements" => [%{"__type__" => "atom", "__schema__" => 1, "value" => "ok"}, 42]
...> })
{:ok, 42}
iex> SnakeBridge.Types.decode(%{"__type__" => "set", "elements" => [1, 2, 3]})
MapSet.new([1, 2, 3])
"""
@spec decode(term()) :: term()
defdelegate decode(value), to: Decoder
end
</file>
<file path="snakebridge/wheel_config.ex">
defmodule SnakeBridge.WheelConfig do
@moduledoc """
Configuration-based wheel variant selection.
"""
@default_config_path Path.join(["config", "wheel_variants.json"])
@doc """
Loads wheel configuration from file or uses defaults.
"""
@spec load_config() :: map()
def load_config do
case File.read(config_path()) do
{:ok, content} ->
Jason.decode!(content)
{:error, _} ->
default_config()
end
end
@doc """
Gets available variants for a package.
"""
@spec get_variants(String.t()) :: [String.t()]
def get_variants(package) do
config = load_config()
get_in(config, ["packages", package, "variants"]) || ["cpu"]
end
@doc """
Returns the configured packages that define variants.
"""
@spec packages() :: [String.t()]
def packages do
config = load_config()
config
|> Map.get("packages", %{})
|> Map.keys()
end
@doc """
Gets CUDA mapping for a version string.
"""
@spec get_cuda_mapping(String.t() | nil) :: String.t() | nil
def get_cuda_mapping(nil), do: nil
def get_cuda_mapping(version) do
config = load_config()
Map.get(config["cuda_mappings"] || %{}, version) ||
Map.get(config["cuda_mappings"] || %{}, normalize_cuda_version(version))
end
@doc """
Returns the configured ROCm variant, if any.
"""
@spec rocm_variant() :: String.t() | nil
def rocm_variant do
config = load_config()
config["rocm_variant"]
end
@doc false
def config_path do
Application.get_env(:snakebridge, :wheel_config_path) ||
Path.join(File.cwd!(), @default_config_path)
end
defp default_config do
%{
"packages" => %{
"torch" => %{"variants" => ["cpu", "cu118", "cu121", "cu124", "rocm5.7"]},
"torchvision" => %{"variants" => ["cpu", "cu118", "cu121", "cu124", "rocm5.7"]},
"torchaudio" => %{"variants" => ["cpu", "cu118", "cu121", "cu124", "rocm5.7"]}
},
"cuda_mappings" => %{
"11.7" => "cu118",
"11.8" => "cu118",
"12.0" => "cu121",
"12.1" => "cu121",
"12.2" => "cu121",
"12.3" => "cu124",
"12.4" => "cu124",
"12.5" => "cu124"
},
"rocm_variant" => "rocm5.7"
}
end
defp normalize_cuda_version(version) when is_binary(version) do
version
|> String.split(".")
|> Enum.take(2)
|> Enum.join()
end
defp normalize_cuda_version(_), do: nil
end
</file>
<file path="snakebridge/wheel_selector.ex">
defmodule SnakeBridge.WheelSelector do
@moduledoc """
Selects the appropriate wheel variant for Python packages based on hardware.
PyTorch and related packages (torchvision, torchaudio) have different wheel
variants for different hardware configurations:
- `cpu` - CPU-only build
- `cu118` - CUDA 11.8
- `cu121` - CUDA 12.1
- `cu124` - CUDA 12.4
- `rocm5.7` - AMD ROCm 5.7
This module detects the current hardware and selects the appropriate variant.
## Examples
# Get the PyTorch variant for current hardware
variant = SnakeBridge.WheelSelector.pytorch_variant()
#=> "cu121" or "cpu"
# Get the index URL for pip
url = SnakeBridge.WheelSelector.pytorch_index_url()
#=> "https://download.pytorch.org/whl/cu121"
# Generate pip install command
cmd = SnakeBridge.WheelSelector.pip_install_command("torch", "2.1.0")
#=> "pip install torch==2.1.0 --index-url https://download.pytorch.org/whl/cu121"
"""
@type wheel_info :: %{
package: String.t(),
version: String.t(),
variant: String.t() | nil,
index_url: String.t() | nil
}
@doc """
Returns the PyTorch wheel variant for the current hardware.
## Examples
SnakeBridge.WheelSelector.pytorch_variant()
#=> "cu121" # On CUDA 12.1 system
#=> "cpu" # On CPU-only system
"""
@spec pytorch_variant() :: String.t()
def pytorch_variant do
caps = hardware_module().capabilities()
strategy_module().variant_for("torch", caps) || "cpu"
end
@doc """
Returns the PyTorch index URL for pip based on current hardware.
## Examples
SnakeBridge.WheelSelector.pytorch_index_url()
#=> "https://download.pytorch.org/whl/cu121"
"""
@spec pytorch_index_url() :: String.t()
def pytorch_index_url do
strategy_module().index_url_for_variant(pytorch_variant())
end
@doc """
Generates a pip install command for a package.
For PyTorch packages (torch, torchvision, torchaudio), includes the
appropriate --index-url for hardware-specific wheels.
## Examples
SnakeBridge.WheelSelector.pip_install_command("torch", "2.1.0")
#=> "pip install torch==2.1.0 --index-url https://download.pytorch.org/whl/cu121"
SnakeBridge.WheelSelector.pip_install_command("numpy", "1.26.4")
#=> "pip install numpy==1.26.4"
"""
@spec pip_install_command(String.t(), String.t()) :: String.t()
def pip_install_command(package, version) do
base = "pip install #{package}==#{version}"
wheel_info = select_wheel(package, version)
if wheel_info.index_url do
"#{base} --index-url #{wheel_info.index_url}"
else
base
end
end
@doc """
Normalizes a CUDA version string for wheel naming.
## Examples
SnakeBridge.WheelSelector.normalize_cuda_version("12.1")
#=> "121"
SnakeBridge.WheelSelector.normalize_cuda_version("11.8")
#=> "118"
"""
@spec normalize_cuda_version(String.t() | nil) :: String.t() | nil
def normalize_cuda_version(nil), do: nil
def normalize_cuda_version(version) when is_binary(version) do
version
|> String.split(".")
|> Enum.take(2)
|> Enum.join()
end
@doc """
Selects the appropriate wheel for a package based on current hardware.
Returns wheel info including variant and index URL if applicable.
## Examples
SnakeBridge.WheelSelector.select_wheel("torch", "2.1.0")
#=> %{package: "torch", version: "2.1.0", variant: "cu121", index_url: "..."}
SnakeBridge.WheelSelector.select_wheel("numpy", "1.26.4")
#=> %{package: "numpy", version: "1.26.4", variant: nil, index_url: nil}
"""
@spec select_wheel(String.t(), String.t()) :: wheel_info()
def select_wheel(package, version) do
strategy_module().select_wheel(package, version, hardware_module().capabilities())
end
@doc """
Checks if a package is a PyTorch package that needs hardware-specific wheels.
"""
@spec pytorch_package?(String.t()) :: boolean()
def pytorch_package?(package) do
package in SnakeBridge.WheelConfig.packages()
end
@doc """
Returns all available PyTorch variants for the given CUDA versions.
Useful for generating lock files that support multiple hardware configurations.
"""
@spec available_variants() :: [String.t()]
def available_variants do
available_variants("torch")
end
@spec available_variants(String.t()) :: [String.t()]
def available_variants(package) do
strategy_module().available_variants(package)
end
@doc """
Returns the best matching CUDA variant for a given CUDA version.
Falls back to the closest available version.
## Examples
SnakeBridge.WheelSelector.best_cuda_variant("12.3")
#=> "cu124"
SnakeBridge.WheelSelector.best_cuda_variant("12.1")
#=> "cu121"
"""
@spec best_cuda_variant(String.t() | nil) :: String.t()
def best_cuda_variant(cuda_version) do
strategy_module().best_cuda_variant(cuda_version)
end
# Private functions
defp hardware_module do
Application.get_env(:snakebridge, :hardware_module, Snakepit.Hardware)
end
defp strategy_module do
Application.get_env(:snakebridge, :wheel_strategy, SnakeBridge.WheelSelector.ConfigStrategy)
end
end
</file>
<file path="snakebridge/with_context.ex">
defmodule SnakeBridge.WithContext do
@moduledoc """
Provides Python context manager support via `with_python/2` macro.
Ensures `__exit__` is always called, even on exception.
## Example
SnakeBridge.with_python(file_ref) do
SnakeBridge.Dynamic.call(file_ref, :read, [])
end
"""
alias SnakeBridge.Runtime
@doc """
Executes a block with a Python context manager.
Calls `__enter__` before the block and guarantees `__exit__` after,
even if an exception occurs.
"""
defmacro with_python(ref, do: block) do
quote do
ref = unquote(ref)
case SnakeBridge.WithContext.call_enter(ref) do
{:ok, context_value} ->
var!(context) = context_value
_ = var!(context)
SnakeBridge.WithContext.execute_with_exit(ref, fn -> unquote(block) end)
{:error, reason} ->
{:error, reason}
end
end
end
@doc false
def execute_with_exit(ref, fun) when is_function(fun, 0) do
outcome =
try do
{:ok, fun.()}
rescue
exception ->
{:exception, exception, __STACKTRACE__}
end
case outcome do
{:ok, result} ->
call_exit(ref, nil)
result
{:exception, exception, stacktrace} ->
call_exit(ref, exception)
reraise exception, stacktrace
end
end
@doc """
Calls __enter__ on a Python context manager.
"""
@spec call_enter(SnakeBridge.Ref.t() | map(), keyword()) :: {:ok, term()} | {:error, term()}
def call_enter(ref, opts \\ []) do
Runtime.call_method(ref, :__enter__, [], opts)
end
@doc """
Calls __exit__ on a Python context manager.
"""
@spec call_exit(SnakeBridge.Ref.t() | map(), Exception.t() | nil, keyword()) ::
{:ok, term()} | {:error, term()}
def call_exit(ref, exception, opts \\ []) do
{exc_type, exc_value, exc_tb} =
if exception do
{
to_string(exception.__struct__),
Exception.message(exception),
nil
}
else
{nil, nil, nil}
end
Runtime.call_method(ref, :__exit__, [exc_type, exc_value, exc_tb], opts)
end
@doc false
def build_enter_payload(ref) do
wire_ref = SnakeBridge.Ref.to_wire_format(ref)
%{
"call_type" => "method",
"instance" => wire_ref,
"method" => "__enter__",
"args" => []
}
end
@doc false
def build_exit_payload(ref, exception) do
{exc_type, exc_value, exc_tb} =
if exception do
{to_string(exception.__struct__), Exception.message(exception), nil}
else
{nil, nil, nil}
end
wire_ref = SnakeBridge.Ref.to_wire_format(ref)
%{
"call_type" => "method",
"instance" => wire_ref,
"method" => "__exit__",
"args" => [exc_type, exc_value, exc_tb]
}
end
end
</file>
<file path="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 and Ref Lifecycle
SnakeBridge automatically manages Python object sessions. Each Elixir process
gets an isolated session, and refs are automatically cleaned up when the
process terminates.
### Key Rules
1. **Refs are session-scoped**: A ref is only valid within its session. Don't
pass refs between processes without ensuring they share a session.
2. **Process death triggers cleanup**: When an Elixir process dies, its session
is released and all associated Python objects are garbage collected.
3. **Auto-session per process**: By default, each process gets an auto-session
(prefixed with `auto_`). Refs created in one process cannot be used from
another without explicit session sharing.
4. **Explicit sessions for sharing**: Use `SessionContext.with_session/2` with
a shared `session_id` to allow multiple processes to access the same refs.
5. **Ref TTL**: Python ref TTL is disabled by default. Enable via
`SNAKEBRIDGE_REF_TTL_SECONDS` environment variable. When enabled, refs
not accessed within the TTL window are cleaned up automatically.
6. **Max refs limit**: Each session can hold up to 10,000 refs by default.
Excess refs are pruned oldest-first. Configure via `SNAKEBRIDGE_REF_MAX`.
### Recommended Patterns
# Pattern 1: Single process, automatic cleanup
def process_data do
{:ok, df} = SnakeBridge.call("pandas", "read_csv", ["data.csv"])
{:ok, result} = SnakeBridge.method(df, "mean", [])
result # df is cleaned up when this process exits
end
# Pattern 2: Explicit session for long-lived refs
def with_shared_session(session_id) do
SnakeBridge.SessionContext.with_session([session_id: session_id], fn ->
{:ok, model} = SnakeBridge.call("sklearn.linear_model", "LinearRegression", [])
# Model ref can be accessed by other processes using same session_id
model
end)
end
# Pattern 3: Release refs explicitly when done
{:ok, ref} = SnakeBridge.call("io", "StringIO", ["test"])
# ... use ref ...
SnakeBridge.release_ref(ref) # Explicit cleanup
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 |
## Advanced Features (Opt-In)
SnakeBridge includes optional compile-time features that are disabled by default:
### Strict Mode
Enables compile-time verification of lock files and binding consistency.
Enable via `config :snakebridge, strict: true` or `SNAKEBRIDGE_STRICT=1`.
### Lock File Verification
Run `mix snakebridge.verify` to check that your lock file matches the current
environment. Useful in CI/CD to catch hardware/package drift.
### Wheel Selection
`SnakeBridge.WheelSelector` provides hardware-aware PyTorch wheel selection.
Call `WheelSelector.pytorch_variant/0` to get the appropriate CUDA/CPU variant.
### Helper Packs
Built-in helpers are enabled by default. Disable with:
config :snakebridge, helper_pack_enabled: false
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `SNAKEBRIDGE_STRICT` | `false` | Enable strict mode |
| `SNAKEBRIDGE_VERBOSE` | `false` | Verbose logging |
| `SNAKEBRIDGE_REF_TTL_SECONDS` | `0` | Ref TTL in seconds (0 = disabled) |
| `SNAKEBRIDGE_REF_MAX` | `10000` | Max refs per session |
| `SNAKEBRIDGE_STRICT_MODE` | `false` | Python strict mode (warns on ref accumulation) |
| `SNAKEBRIDGE_STRICT_MODE_THRESHOLD` | `1000` | Strict mode warning threshold |
"""
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, term()} | {: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
@doc """
Releases a Python object reference, freeing memory in the Python process.
Call this to explicitly release a ref when you're done with it, rather than
waiting for session cleanup or process termination.
## Parameters
- `ref` - A `SnakeBridge.Ref` to release
- `opts` - Runtime options (optional)
## Examples
{:ok, ref} = SnakeBridge.call("pathlib", "Path", ["/tmp"])
# ... use ref ...
:ok = SnakeBridge.release_ref(ref)
## Notes
- After release, the ref is invalid and should not be used
- Releasing an already-released ref is a no-op
- For bulk cleanup, use `release_session/1` instead
"""
@spec release_ref(Ref.t(), keyword()) :: :ok | {:error, term()}
defdelegate release_ref(ref, opts \\ []), to: Runtime
@doc """
Releases all Python object references associated with a session.
Use this for bulk cleanup of all refs in a session, rather than releasing
them individually.
## Parameters
- `session_id` - The session ID to release
- `opts` - Runtime options (optional)
## Examples
session_id = SnakeBridge.current_session()
# ... create many refs ...
:ok = SnakeBridge.release_session(session_id)
## Notes
- After release, all refs from that session are invalid
- The session can still be reused for new calls
- For auto-sessions, prefer `release_auto_session/0`
"""
@spec release_session(String.t(), keyword()) :: :ok | {:error, term()}
defdelegate release_session(session_id, opts \\ []), 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
</file>
</files>