Mix.install([
{:kino, "~> 0.17.0"}
])When developing in Jupyter notebooks with Python's fastcore, we can use the @patch decorator to incrementally add methods to a class across multiple cells. This creates a natural development narrative where we can see the evolution of our code.
In Elixir with Livebook, we face a challenge: modules are compiled as complete units, and Livebook prevents redefining a module in subsequent cells. Let's build a Patch system that gives us a similar iterative development experience.
Mix.install([
{:kino, "~> 0.17.0"}
])
Let's see what happens when we try to redefine a module in Livebook:
defmodule ModA do
def add(a, b), do: a # incorrect on purpose
endwarning: variable "b" is unused (if the variable is not meant to be used, prefix it with an underscore)
└─ Code/elixir/patch.livemd#cell:wzxny5ohqko6oh7y:2: ModA.add/2
{:module, ModA, <<70, 79, 82, 49, 0, 0, 6, ...>>, {:add, 2}}
If we try to redefine ModA in another cell, we get a compile error. Livebook won't allow it because it checks for duplicate module definitions at compile time.
Certainly you can come back to the cell and fix it, but sometimes, doing so prevent you to record your journey.
Our approach will be to:
- Store function definitions as quoted AST in an Agent
- Accumulate these definitions across cells
- Rebuild the entire module dynamically using
Code.compile_quoted/1
Let's build the Patch module:
defmodule Patch do
# Keep accumulated quoted defs per module in an Agent
def start_link do
Agent.start_link(fn -> %{} end, name: __MODULE__)
end
# Add a quoted block of definitions to a module's stash
def to(mod, quoted_block, opts \\ []) when is_atom(mod) do
# Auto-start the Agent if not running
unless Process.whereis(__MODULE__), do: start_link()
# Prepend new block (so newest definitions come first and win)
Agent.update(__MODULE__, fn acc ->
Map.update(acc, mod, [quoted_block], fn blocks -> [quoted_block | blocks] end)
end)
# Auto-rebuild unless explicitly disabled
if Keyword.get(opts, :rebuild, true), do: build(mod)
end
# Compile the module from accumulated blocks
def build(mod) when is_atom(mod) do
blocks =
Agent.get(__MODULE__, fn acc ->
Map.get(acc, mod, [])
end)
# Build the module AST and compile it
quote do
defmodule unquote(mod) do
unquote_splicing(blocks)
end
end
|> Code.compile_quoted()
mod
end
# Get the source code of accumulated functions
def get_source(mod, opts \\ []) when is_atom(mod) do
Agent.get(__MODULE__, fn acc ->
Map.get(acc, mod, [])
end)
|> then(fn blocks ->
if Keyword.get(opts, :final, false) do
# Show only final definitions (deduplicated)
Enum.uniq_by(blocks, &get_signature/1)
else
# Show full history
blocks
end
end)
|> Enum.map_join("\n\n", &Macro.to_string/1)
end
# Extract function name and arity for deduplication
defp get_signature({:def, _, [{name, _, args}, _]}) when is_list(args), do: {name, length(args)}
defp get_signature({:def, _, [{:when, _, [{name, _, args}, _]}, _]}) when is_list(args), do: {name, length(args)}
defp get_signature(other), do: other
# Display source code with nice formatting
def display_code(mod, opts \\ []) when is_atom(mod) do
source = get_source(mod, opts)
if Code.ensure_loaded?(Kino.Markdown) do
"```elixir\n#{source}\n```"
|> Kino.Markdown.new()
else
IO.puts(source)
source
end
end
end{:module, Patch, <<70, 79, 82, 49, 0, 0, 24, ...>>, {:display_code, 2}}
I wish we could have built this with Patch, because I did it piece by piece.
Now let's use our Patch system to build a module incrementally:
# Start with an incorrect implementation
Patch.to(ModA, quote do
def add(a, b), do: a # oops, forgot to add b!
end)ModA
# Test it - we'll see the bug
ModA.add(5, 6)5
# Add another function
Patch.to(ModA, quote do
def mul(a, b), do: a * b
end)ModA
# Check what functions we have
ModA.__info__(:functions)[add: 2, mul: 2]
Here's where it gets interesting - we can fix our buggy add/2 function:
# Fix the add function by redefining it
Patch.to(ModA, quote do
def add(a, b), do: a + b
end)ModA
# Now it works correctly!
ModA.add(5, 6)11
Why does this work? In Elixir, when multiple definitions of the same function exist, the first one wins. Our Patch system prepends new definitions, so the latest version comes first and takes precedence.
We can view the accumulated source code in two ways:
Patch.get_source(ModA)"def add(a, b) do\n a + b\nend\n\ndef mul(a, b) do\n a * b\nend\n\ndef add(a, b) do\n a\nend"
# Show the full development history
Patch.display_code(ModA)# Show only the final, deduplicated version
Patch.display_code(ModA, final: true)Our Patch system provides:
- Incremental development - Add functions one at a time across cells
- Function redefinition - Fix bugs by redefining functions in new cells
- Development narrative - Keep the full history visible in your notebook
- Source inspection - View accumulated code with
get_source/2 - Auto-initialization - No need to manually start the Agent
- Deferred building - Option to accumulate multiple functions before rebuilding
While we can't truly "patch" modules in Elixir like we can in Python, we've created a system that provides a similar iterative development experience in Livebook. The key insight is to store function definitions as AST and rebuild the entire module dynamically, giving us the flexibility to develop incrementally while maintaining the narrative flow of our notebook.