Current section
Files
Jump to
Current section
Files
lib/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,
PythonEnv,
Scanner,
Telemetry
}
@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
[]
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
targets
|> Introspector.introspect_batch()
|> Enum.reduce(manifest, fn {library, result, python_module}, acc ->
case result do
{:ok, infos} ->
{symbol_entries, class_entries} =
build_manifest_entries(library, python_module, infos)
acc
|> Manifest.put_symbols(symbol_entries)
|> Manifest.put_classes(class_entries)
{:error, _reason} ->
acc
end
end)
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 ->
python_module = python_module_for_elixir(library, module)
add_target(acc, library, python_module, function)
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)
function = info["name"]
arity = required_arity(info["parameters"] || [])
key = Manifest.symbol_key({module, String.to_atom(function), arity})
{
key,
%{
"module" => Module.split(module) |> Enum.join("."),
"function" => function,
"name" => function,
"python_module" => python_module,
"parameters" => info["parameters"] || [],
"docstring" => info["docstring"] || "",
"return_annotation" => info["return_annotation"]
}
}
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)
[
{
key,
%{
"module" => Module.split(class_module) |> Enum.join("."),
"class" => class_name,
"python_module" => class_python_module,
"docstring" => info["docstring"] || "",
"methods" => info["methods"] || [],
"attributes" => info["attributes"] || []
}
}
]
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_prefix = "#{module_prefix}.#{name}/"
function_exists? =
manifest
|> Map.get("symbols", %{})
|> Map.keys()
|> Enum.any?(&String.starts_with?(&1, function_prefix))
class_exists? =
manifest
|> Map.get("classes", %{})
|> Map.values()
|> Enum.any?(fn info ->
info["class"] == name or String.ends_with?(info["module"] || "", ".#{name}")
end)
function_exists? or class_exists?
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)
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
symbols = Map.get(manifest, "symbols", %{})
Enum.each(config.libraries, fn library ->
path = Path.join(config.generated_dir, "#{library.python_name}.ex")
content = read_generated_file!(path)
expected_functions = expected_functions_for_library(symbols, library)
missing_in_file =
Enum.reject(expected_functions, fn name ->
String.contains?(content, "def #{name}(")
end)
if missing_in_file != [] do
raise SnakeBridge.CompileError, """
Strict mode: Generated file #{path} is missing expected functions:
#{Enum.map_join(missing_in_file, "\n", &" - #{&1}")}
Run `mix compile` locally to regenerate and commit the updated files.
"""
end
end)
:ok
end
defp required_arity(params) do
params
|> Enum.filter(&(&1["kind"] in ["POSITIONAL_ONLY", "POSITIONAL_OR_KEYWORD"]))
|> Enum.reject(&Map.has_key?(&1, "default"))
|> length()
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 expected_functions_for_library(symbols, library) do
symbols
|> Map.values()
|> Enum.filter(fn info ->
python_module = info["python_module"] || ""
String.starts_with?(python_module, library.python_name)
end)
|> Enum.map(& &1["name"])
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