Current section
Files
Jump to
Current section
Files
lib/snakebridge/docs.ex
defmodule SnakeBridge.Docs do
@moduledoc """
On-demand documentation fetching with optional caching.
"""
@cache_table :snakebridge_docs
alias SnakeBridge.Manifest
@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
@doc """
Builds an ExDoc `groups_for_modules` keyword list using the SnakeBridge manifest.
This keeps HexDocs navigation aligned with Python package paths, while
remaining purely an Elixir configuration concern.
## Options
- `:config` - `SnakeBridge.Config` struct (defaults to `SnakeBridge.Config.load/0`)
- `:manifest` - manifest map (defaults to `SnakeBridge.Manifest.load/1`)
- `:depth` - group depth beyond the library root (`:full` or non-negative integer, default: 1)
- `:libraries` - list of library names to include (atoms or strings)
- `:include_functions` - include module functions (default: true)
- `:include_classes` - include class modules (default: true)
"""
@spec groups_for_modules(keyword()) :: [{String.t(), [module()]}]
def groups_for_modules(opts \\ []) do
config = Keyword.get(opts, :config) || SnakeBridge.Config.load()
manifest = Keyword.get(opts, :manifest) || Manifest.load(config)
depth =
case Keyword.get(opts, :depth, 1) do
:full -> :full
value when is_integer(value) -> value
_ -> 1
end
include_functions = Keyword.get(opts, :include_functions, true)
include_classes = Keyword.get(opts, :include_classes, true)
only_libraries = Keyword.get(opts, :libraries)
libraries = filter_libraries(config.libraries, only_libraries)
entries =
[]
|> maybe_add_function_entries(manifest, include_functions)
|> maybe_add_class_entries(manifest, include_classes)
entries
|> Enum.reduce(%{}, fn entry, acc ->
case process_entry(entry, libraries, depth) do
nil ->
acc
{group, module} ->
Map.update(acc, group, MapSet.new([module]), &MapSet.put(&1, module))
end
end)
|> Enum.map(fn {group, modules} ->
modules =
modules
|> Enum.map(&Module.split/1)
|> Enum.sort()
|> Enum.map(&Module.concat/1)
{group, modules}
end)
|> Enum.sort_by(fn {group, _} -> group end)
end
@doc """
Builds an ExDoc `nest_modules_by_prefix` list using the SnakeBridge manifest.
This keeps the navigation tree aligned with generated Python packages.
## Options
- `:config` - `SnakeBridge.Config` struct (defaults to `SnakeBridge.Config.load/0`)
- `:manifest` - manifest map (defaults to `SnakeBridge.Manifest.load/1`)
- `:libraries` - list of library names to include (atoms or strings)
"""
@spec nest_modules_by_prefix(keyword()) :: [module()]
def nest_modules_by_prefix(opts \\ []) do
config = Keyword.get(opts, :config) || SnakeBridge.Config.load()
manifest = Keyword.get(opts, :manifest) || Manifest.load(config)
only_libraries = Keyword.get(opts, :libraries)
libraries = filter_libraries(config.libraries, only_libraries)
module_keys = Map.keys(Map.get(manifest, "modules", %{}))
roots =
module_keys
|> Enum.map(&library_for_module(&1, libraries))
|> Enum.reject(&is_nil/1)
|> Enum.map(& &1.module_name)
|> Enum.uniq()
roots =
if roots == [] do
Enum.map(libraries, & &1.module_name)
else
roots
end
Enum.sort(roots)
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 maybe_add_function_entries(entries, _manifest, false), do: entries
defp maybe_add_function_entries(entries, manifest, true) do
entries ++
(manifest
|> Map.get("symbols", %{})
|> Map.values()
|> Enum.map(fn info -> {info["python_module"] || "", info["module"] || ""} end))
end
defp maybe_add_class_entries(entries, _manifest, false), do: entries
defp maybe_add_class_entries(entries, manifest, true) do
entries ++
(manifest
|> Map.get("classes", %{})
|> Map.values()
|> Enum.map(fn info -> {info["python_module"] || "", info["module"] || ""} end))
end
defp filter_libraries(libraries, nil), do: libraries
defp filter_libraries(libraries, only) do
only =
only
|> List.wrap()
|> Enum.map(&to_string/1)
Enum.filter(libraries, fn library ->
to_string(library.python_name) in only
end)
end
defp library_for_module(python_module, libraries) do
libraries
|> Enum.filter(fn library ->
String.starts_with?(python_module, library.python_name)
end)
|> Enum.max_by(fn library -> String.length(library.python_name) end, fn -> nil end)
end
defp group_name_for(python_module, library, depth) do
if depth == :full do
python_module
else
depth = max(depth, 0)
root_parts = String.split(library.python_name, ".")
module_parts = String.split(python_module, ".")
extra_parts = Enum.drop(module_parts, length(root_parts))
if extra_parts == [] or depth == 0 do
Enum.join(root_parts, ".")
else
Enum.join(root_parts ++ Enum.take(extra_parts, depth), ".")
end
end
end
defp process_entry({python_module, elixir_module}, libraries, depth) do
if python_module in [nil, ""] or elixir_module in [nil, ""] do
nil
else
process_valid_entry(python_module, elixir_module, libraries, depth)
end
end
defp process_valid_entry(python_module, elixir_module, libraries, depth) do
case library_for_module(python_module, libraries) do
nil ->
nil
library ->
group = group_name_for(python_module, library, depth)
module =
elixir_module
|> to_string()
|> String.split(".")
|> Module.concat()
{group, module}
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
with true <- cache_enabled?(),
table when table != :undefined <- cache_table() do
lookup_cache_table(key)
else
_ -> :miss
end
end
defp lookup_cache_table(key) do
case :ets.lookup(@cache_table, key) do
[{^key, doc}] -> {:hit, doc}
[] -> :miss
end
end
defp maybe_cache(key, doc) do
if cache_enabled?() do
case cache_table() do
:undefined -> :ok
_ -> :ets.insert(@cache_table, {key, doc})
end
end
end
defp cache_enabled? do
Application.get_env(:snakebridge, :docs, [])
|> Keyword.get(:cache_enabled, true)
end
defp cache_table do
case :ets.whereis(@cache_table) do
:undefined -> :undefined
_ -> @cache_table
end
end
end