✍️
Livebook livemdファイルのmix化によるモジュール共有
Livebookで作業していると「共通モジュールを定義して使いまわしたい」ことがあります。(standaloneなLivebookの話です)
ということで下記に一案を記載しました。
ただ、がっつり複数モジュールを扱うコードをライブラリ化したいときは、mix化してしまうのもありかもしれません。
方針
- 共通モジュール化したいノートで用意した「ボタン」を押すことで、同フォルダに同ノートのmixプロジェクトをつくる。
- 使いたいノートでは
Mix.install([{:my_lib, path: "~"}])をして使用する。
本格的なライブラリならそもそも独立して作る方が当然素直なため、これはあくまで簡易的な方法です。
mixを作るためのボタン(Kino.ExportToMix)実装
このKino自体は、先の記事で提示した共有方法でどこでも使えるようにしておきます。
(あるいはそもそもこのKino自体もmix化して共有する)
このKinoは、現在の.livemdファイルからMixプロジェクトを自動生成します。
-
defmoduleを含むセルからモジュールを抽出 -
Mix.installから依存関係を抽出 -
{filename}/mix.exsと{filename}/lib/*.exを生成
setup
Mix.install([{:kino, "~> 0.14.0"}])
defmodule Kino.ExportToMix do
use Kino.JS
use Kino.JS.Live
def new(env_file) do
file_path = env_file |> URI.parse() |> Map.get(:path)
livebook_node = Node.list(:connected) |> hd()
Kino.JS.Live.new(__MODULE__, %{file_path: file_path, livebook_node: livebook_node})
end
@impl true
def init(data, ctx), do: {:ok, assign(ctx, data: data)}
@impl true
def handle_connect(ctx), do: {:ok, %{}, ctx}
@impl true
def handle_event("export", _params, ctx) do
data = ctx.assigns.data
case export_to_mix(data.file_path, data.livebook_node) do
{:ok, output_dir} ->
relative_path = Path.relative_to_cwd(output_dir)
broadcast_event(ctx, "result", %{
success: true,
message: "Successfully exported to: #{relative_path}"
})
{:error, reason} ->
broadcast_event(ctx, "result", %{success: false, message: "Error: #{reason}"})
end
{:noreply, ctx}
end
defp export_to_mix(file_path, livebook_node) do
with {:ok, content} <- File.read(file_path),
{notebook, _} <- :rpc.call(livebook_node, Livebook.LiveMarkdown.Import, :notebook_from_livemd, [content]) do
# プロジェクト名を決定
base_name = Path.basename(file_path, ".livemd")
output_dir = Path.join(Path.dirname(file_path), base_name)
# 依存関係とモジュールを抽出
deps_string = extract_deps_string(notebook)
module_strings = extract_module_strings(notebook)
# Mixプロジェクトを生成
generate_mix_project(output_dir, base_name, deps_string, module_strings)
{:ok, output_dir}
else
error -> {:error, inspect(error)}
end
end
# setupセクションからMix.install([...])の依存関係リストを文字列として抽出
# 戻り値: String.t() (例: "[{:kino, \"~> 0.14.0\"}]")
defp extract_deps_string(notebook) do
setup_cells =
case notebook.setup_section do
nil -> []
section -> section.cells
end
result = extract_from_cells(setup_cells, &find_mix_install_in_source/1)
List.first(result) || "[]"
end
# 全セクションからdefmoduleを文字列リストとして抽出
# 戻り値: [String.t()] (例: ["defmodule Foo do ... end", "defmodule Bar do ... end"])
defp extract_module_strings(notebook) do
all_cells =
Enum.flat_map(notebook.sections, fn section -> section.cells end)
extract_from_cells(all_cells, &find_defmodules_in_source/1)
end
# セルリストから特定のパターンを抽出する汎用関数
defp extract_from_cells(cells, extractor_fn) do
cells
|> Enum.filter(&elixir_code_cell?/1)
|> Enum.flat_map(fn cell ->
case Code.string_to_quoted(cell.source) do
{:ok, ast} -> extractor_fn.(ast)
_ -> []
end
end)
end
defp elixir_code_cell?(%{__struct__: Livebook.Notebook.Cell.Code, language: :elixir}), do: true
defp elixir_code_cell?(_), do: false
# ソースコードからMix.install([...])の依存関係リストを文字列として取得
defp find_mix_install_in_source(ast) do
find_in_ast(ast, fn
{{:., _, [{:__aliases__, _, [:Mix]}, :install]}, _, [deps_list | _]} ->
{:match, Macro.to_string(deps_list)}
_ ->
:no_match
end)
end
# ソースコードからdefmoduleを文字列リストとして取得
defp find_defmodules_in_source(ast) do
find_in_ast(ast, fn
{:defmodule, _, _} = node -> {:match, Macro.to_string(node)}
_ -> :no_match
end)
end
# ASTを再帰的に探索し、マッチャー関数でパターンを抽出
# マッチャー関数は {:match, result} | :no_match を返す
defp find_in_ast(ast, matcher_fn) do
case ast do
{:__block__, _, expressions} ->
Enum.flat_map(expressions, &find_in_ast(&1, matcher_fn))
node ->
case matcher_fn.(node) do
{:match, result} -> [result]
:no_match -> []
end
end
end
defp generate_mix_project(output_dir, base_name, deps_string, module_strings) do
# ディレクトリ作成
lib_dir = Path.join(output_dir, "lib")
File.mkdir_p!(lib_dir)
# mix.exsを生成
app_name_str = base_name |> String.replace("-", "_")
app_name = String.to_atom(app_name_str)
module_name = Macro.camelize(app_name_str)
mix_exs_content = """
defmodule #{module_name}.MixProject do
use Mix.Project
def project do
[
app: #{inspect(app_name)},
version: "0.1.0",
elixir: "~> 1.18",
deps: deps()
]
end
defp deps do
#{deps_string}
end
end
"""
File.write!(Path.join(output_dir, "mix.exs"), mix_exs_content)
# lib/配下にモジュールファイルを生成
lib_file_path = Path.join(lib_dir, "#{base_name}.ex")
lib_content = Enum.join(module_strings, "\n\n")
File.write!(lib_file_path, lib_content)
:ok
end
asset "main.js" do
"""
export function init(ctx, data) {
ctx.importCSS("main.css");
ctx.root.innerHTML = `
<div class="export-container">
<button id="export-btn" class="export-btn">
📦 Export to Mix Project
</button>
<div id="result" class="result-message"></div>
</div>
`;
const btn = ctx.root.querySelector("#export-btn");
const resultDiv = ctx.root.querySelector("#result");
btn.addEventListener("click", () => {
btn.disabled = true;
btn.textContent = "⏳ Exporting...";
ctx.pushEvent("export", {});
});
ctx.handleEvent("result", ({ success, message }) => {
btn.disabled = false;
btn.textContent = "📦 Export to Mix Project";
resultDiv.style.display = "block";
resultDiv.className = success ? "result-message success" : "result-message error";
resultDiv.textContent = (success ? "✅ " : "❌ ") + message;
});
}
"""
end
asset "main.css" do
"""
.export-container {
padding: 16px;
background: #f5f5f5;
border-radius: 8px;
}
.export-btn {
padding: 10px 20px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.result-message {
margin-top: 12px;
padding: 8px;
display: none;
border-radius: 4px;
}
.result-message.success {
background: #c8e6c9;
color: #2e7d32;
}
.result-message.error {
background: #ffcdd2;
color: #c62828;
}
"""
end
end
# assets生成のために実行が必須
Kino.ExportToMix.new(__ENV__.file)
# このライブラリ自体の共有(登録) LibStoreは https://zenn.dev/ta_to/articles/35bc17d24efc98 のもの
livebook = Node.list(:connected) |> hd()
:rpc.call(livebook, LibStore, :register, [Node.self(), Kino.ExportToMix])
共有したいモジュールのノートサンプル
setup
Mix.install([
{:kino, "~> 0.14.0"},
{:statistics, "~> 0.6"}
])
共有したいモジュールサンプル
defmodule Helper do
def hello do
"hello from helper"
end
end
defmodule LocalMixSample do
defdelegate hello, to: Helper
def mean(list) do
Statistics.mean(list)
end
end
mix化する先のKinoを用意して押下することで生成します。
livebook = Node.list(:connected) |> hd()
:rpc.call(livebook, LibStore, :load, [Node.self(), Kino.ExportToMix])
Kino.ExportToMix.new(__ENV__.file)
そして生成後は、どのノートブックでも Mix.install({})でpathオプションで使用可能です。
Discussion