List predefined smart cells and automatically add their dependencies (#1078)

* List predefined smart cells and automatically add their dependencies

* Generalize dependency insertion to multiple entries

* Add runtime setup modal

* Don't evaluate setup when restarting the runtime fails

* Automatically add vega_lite for chart builder

* Improve confirmation modal actions when the action is not destructive
This commit is contained in:
Jonatan Kłosko 2022-03-30 21:55:50 +02:00 committed by GitHub
parent 108b40651d
commit df765f2dd3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 712 additions and 105 deletions

View file

@ -10,6 +10,8 @@ const ConfirmModal = {
const descriptionEl = this.el.querySelector("[data-description]");
const confirmIconEl = this.el.querySelector("[data-confirm-icon]");
const confirmTextEl = this.el.querySelector("[data-confirm-text]");
const confirmButtonEl = this.el.querySelector("[data-confirm-button]");
const actionsEl = this.el.querySelector("[data-actions]");
const optOutEl = this.el.querySelector("[data-opt-out]");
const optOutElCheckbox = optOutEl.querySelector("input");
@ -18,8 +20,14 @@ const ConfirmModal = {
this.handleConfirmRequest = (event) => {
confirmEvent = event;
const { title, description, confirm_text, confirm_icon, opt_out_id } =
event.detail;
const {
title,
description,
confirm_text,
confirm_icon,
danger,
opt_out_id,
} = event.detail;
if (opt_out_id && optedOutIds.includes(opt_out_id)) {
liveSocket.execJS(event.target, event.detail.on_confirm);
@ -34,6 +42,11 @@ const ConfirmModal = {
confirmIconEl.className = "hidden";
}
confirmButtonEl.classList.toggle("button-red", danger);
confirmButtonEl.classList.toggle("button-blue", !danger);
actionsEl.classList.toggle("flex-row-reverse", danger);
actionsEl.classList.toggle("space-x-reverse", danger);
optOutElCheckbox.checked = false;
optOutEl.classList.toggle("hidden", !opt_out_id);

View file

@ -64,7 +64,7 @@ defmodule Livebook.Notebook do
"""
@spec put_setup_cell(t(), Cell.Code.t()) :: t()
def put_setup_cell(notebook, %Cell.Code{} = cell) do
put_in(notebook.setup_section.cells, [%{cell | id: "setup"}])
put_in(notebook.setup_section.cells, [%{cell | id: Cell.setup_cell_id()}])
end
@doc """

View file

@ -68,12 +68,20 @@ defmodule Livebook.Notebook.Cell do
def find_inputs_in_output(_output), do: []
@setup_cell_id "setup"
@doc """
Checks if the given cell is the setup code cell.
"""
@spec setup?(t()) :: boolean()
def setup?(cell)
def setup?(%Cell.Code{id: "setup"}), do: true
def setup?(%Cell.Code{id: @setup_cell_id}), do: true
def setup?(_cell), do: false
@doc """
The fixed identifier of the setup cell.
"""
@spec setup_cell_id() :: id()
def setup_cell_id(), do: @setup_cell_id
end

View file

@ -187,12 +187,19 @@ defprotocol Livebook.Runtime do
the updated list as
* `{:runtime_smart_cell_definitions, list(smart_cell_definition())}`
Additionally, the runtime may report extra definitions that require
installing external packages, as described by `:requirement`. Also
see `add_dependencies/3`.
"""
@type smart_cell_definition :: %{
kind: String.t(),
name: String.t()
name: String.t(),
requirement: nil | %{name: String.t(), dependencies: list(dependency())}
}
@type dependency :: term()
@typedoc """
A JavaScript view definition.
@ -397,4 +404,11 @@ defprotocol Livebook.Runtime do
"""
@spec stop_smart_cell(t(), smart_cell_ref()) :: :ok
def stop_smart_cell(runtime, ref)
@doc """
Updates the given source code to install the given dependencies.
"""
@spec add_dependencies(t(), String.t(), list(dependency())) ::
{:ok, String.t()} | {:error, String.t()}
def add_dependencies(runtime, code, dependencies)
end

View file

@ -28,8 +28,11 @@ defmodule Livebook.Runtime.Attached do
case Node.ping(node) do
:pong ->
opts = [parent_node: node()]
server_pid = Livebook.Runtime.ErlDist.initialize(node, opts)
server_pid =
Livebook.Runtime.ErlDist.initialize(node,
node_manager_opts: [parent_node: node()]
)
{:ok, %__MODULE__{node: node, cookie: cookie, server_pid: server_pid}}
:pang ->
@ -96,4 +99,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Attached do
def stop_smart_cell(runtime, ref) do
ErlDist.RuntimeServer.stop_smart_cell(runtime.server_pid, ref)
end
def add_dependencies(_runtime, code, dependencies) do
Livebook.Runtime.Code.add_mix_deps(code, dependencies)
end
end

View file

@ -0,0 +1,99 @@
defmodule Livebook.Runtime.Code do
@moduledoc false
@doc """
Finds or adds a `Mix.install/2` call to `code` and modifies it to
include the given Mix deps.
"""
@spec add_mix_deps(String.t(), list(tuple())) :: {:ok, String.t()} | {:error, String.t()}
def add_mix_deps(code, deps) do
with {:ok, ast, comments} <- string_to_quoted_with_comments(code),
{:ok, ast} <- insert_deps(ast, deps),
do: {:ok, format(ast, comments)}
end
defp string_to_quoted_with_comments(code) do
try do
to_quoted_opts = [
literal_encoder: &{:ok, {:__block__, &2, [&1]}},
token_metadata: true,
unescape: false
]
{ast, comments} = Code.string_to_quoted_with_comments!(code, to_quoted_opts)
{:ok, ast, comments}
rescue
error -> {:error, Exception.format(:error, error)}
end
end
defp insert_deps(ast, deps) do
with :error <- update_install(ast, deps) do
dep_nodes = Enum.map(deps, &dep_node/1)
install_node =
{{:., [], [{:__aliases__, [], [:Mix]}, :install]}, [],
[{:__block__, [newlines: 1], [dep_nodes]}]}
{:ok, prepend_node(ast, install_node)}
end
end
defp format(ast, comments) do
ast
|> Code.quoted_to_algebra(comments: comments)
|> Inspect.Algebra.format(90)
|> IO.iodata_to_binary()
end
defp prepend_node({:__block__, meta, nodes}, node) do
{:__block__, meta, [node | nodes]}
end
defp prepend_node(ast, node) do
{:__block__, [], [node, ast]}
end
defp update_install(
{{:., _, [{:__aliases__, _, [:Mix]}, :install]} = target, meta1,
[{:__block__, meta2, [dep_nodes]} | args]},
deps
) do
new_dep_nodes = for dep <- deps, not has_dep?(dep_nodes, dep), do: dep_node(dep)
{:ok, {target, meta1, [{:__block__, meta2, [dep_nodes ++ new_dep_nodes]} | args]}}
end
defp update_install({:__block__, meta, nodes}, deps) do
{nodes, found} =
Enum.map_reduce(nodes, _found = false, fn
node, false ->
case update_install(node, deps) do
{:ok, node} -> {node, true}
_ -> {node, false}
end
node, true ->
{node, true}
end)
if found do
{:ok, {:__block__, meta, nodes}}
else
:error
end
end
defp update_install(_node, _deps), do: :error
defp has_dep?(deps, dep) do
name = elem(dep, 0)
Enum.any?(deps, fn
{:__block__, _, [{{:__block__, _, [^name]}, _}]} -> true
{:{}, _, [{:__block__, _, [^name]} | _]} -> true
_ -> false
end)
end
defp dep_node(dep), do: {:__block__, [], [Macro.escape(dep)]}
end

View file

@ -16,6 +16,27 @@ defmodule Livebook.Runtime.ElixirStandalone do
server_pid: pid()
}
kino_dep = {:kino, github: "livebook-dev/kino"}
vega_lite_dep = {:vega_lite, "~> 0.1.3"}
@extra_smart_cell_definitions [
%{
kind: "Elixir.Kino.SmartCell.DBConnection",
name: "Database connection",
requirement: %{name: "Kino", dependencies: [kino_dep]}
},
%{
kind: "Elixir.Kino.SmartCell.SQL",
name: "SQL query",
requirement: %{name: "Kino", dependencies: [kino_dep]}
},
%{
kind: "Elixir.Kino.SmartCell.ChartBuilder",
name: "Chart builder",
requirement: %{name: "Kino", dependencies: [kino_dep, vega_lite_dep]}
}
]
@doc """
Starts a new Elixir node (i.e. a system process) and initializes
it with Livebook-specific modules and processes.
@ -36,9 +57,13 @@ defmodule Livebook.Runtime.ElixirStandalone do
Utils.temporarily_register(self(), child_node, fn ->
argv = [parent_node]
init_opts = [
runtime_server_opts: [extra_smart_cell_definitions: @extra_smart_cell_definitions]
]
with {:ok, elixir_path} <- find_elixir_executable(),
port = start_elixir_node(elixir_path, child_node, child_node_eval_string(), argv),
{:ok, server_pid} <- parent_init_sequence(child_node, port) do
{:ok, server_pid} <- parent_init_sequence(child_node, port, init_opts: init_opts) do
runtime = %__MODULE__{
node: child_node,
server_pid: server_pid
@ -121,4 +146,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.ElixirStandalone do
def stop_smart_cell(runtime, ref) do
ErlDist.RuntimeServer.stop_smart_cell(runtime.server_pid, ref)
end
def add_dependencies(_runtime, code, dependencies) do
Livebook.Runtime.Code.add_mix_deps(code, dependencies)
end
end

View file

@ -34,7 +34,11 @@ defmodule Livebook.Runtime.Embedded do
# as we already do it for the Livebook application globally
# (see Livebook.Application.start/2).
server_pid = ErlDist.initialize(node(), unload_modules_on_termination: false)
server_pid =
ErlDist.initialize(node(),
node_manager_opts: [unload_modules_on_termination: false]
)
{:ok, %__MODULE__{node: node(), server_pid: server_pid}}
end
end
@ -94,4 +98,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.Embedded do
def stop_smart_cell(runtime, ref) do
ErlDist.RuntimeServer.stop_smart_cell(runtime.server_pid, ref)
end
def add_dependencies(_runtime, code, dependencies) do
Livebook.Runtime.Code.add_mix_deps(code, dependencies)
end
end

View file

@ -46,18 +46,24 @@ defmodule Livebook.Runtime.ErlDist do
If necessary, the required modules are loaded
into the given node and the node manager process
is started with `node_manager_opts`.
## Options
* `:node_manager_opts` - see `Livebook.Runtime.ErlDist.NodeManager.start/1`
* `:runtime_server_opts` - see `Livebook.Runtime.ErlDist.RuntimeServer.start_link/1`
"""
@spec initialize(node(), keyword()) :: pid()
def initialize(node, node_manager_opts \\ []) do
def initialize(node, opts \\ []) do
unless modules_loaded?(node) do
load_required_modules(node)
end
unless node_manager_started?(node) do
start_node_manager(node, node_manager_opts)
start_node_manager(node, opts[:node_manager_opts] || [])
end
start_runtime_server(node)
start_runtime_server(node, opts[:runtime_server_opts] || [])
end
defp load_required_modules(node) do
@ -87,8 +93,8 @@ defmodule Livebook.Runtime.ErlDist do
:rpc.call(node, Livebook.Runtime.ErlDist.NodeManager, :start, [opts])
end
defp start_runtime_server(node) do
Livebook.Runtime.ErlDist.NodeManager.start_runtime_server(node)
defp start_runtime_server(node, opts) do
Livebook.Runtime.ErlDist.NodeManager.start_runtime_server(node, opts)
end
defp modules_loaded?(node) do

View file

@ -35,6 +35,10 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
* `:smart_cell_definitions_module` - the module to read smart
cell definitions from, it needs to export a `definitions/0`
function. Defaults to `Kino.SmartCell`
* `:extra_smart_cell_definitions` - a list of predefined smart
cell definitions, that may be currently be unavailable, but
should be reported together with their requirements
"""
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts)
@ -192,9 +196,10 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
smart_cell_supervisor: nil,
smart_cell_gl: nil,
smart_cells: %{},
smart_cell_definitions: [],
smart_cell_definitions: nil,
smart_cell_definitions_module:
Keyword.get(opts, :smart_cell_definitions_module, Kino.SmartCell),
extra_smart_cell_definitions: Keyword.get(opts, :extra_smart_cell_definitions, []),
memory_timer_ref: nil
}}
end
@ -495,8 +500,16 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
if smart_cell_definitions == state.smart_cell_definitions do
state
else
defs = Enum.map(smart_cell_definitions, &Map.take(&1, [:kind, :name]))
send(state.owner, {:runtime_smart_cell_definitions, defs})
available_defs =
for definition <- smart_cell_definitions,
do: %{kind: definition.kind, name: definition.name, requirement: nil}
defs = Enum.uniq_by(available_defs ++ state.extra_smart_cell_definitions, & &1.kind)
if defs != [] do
send(state.owner, {:runtime_smart_cell_definitions, defs})
end
%{state | smart_cell_definitions: smart_cell_definitions}
end
end

View file

@ -56,7 +56,7 @@ defmodule Livebook.Runtime.MixStandalone do
:ok <- run_mix_task("compile", project_path, output_emitter),
eval = child_node_eval_string(),
port = start_elixir_mix_node(elixir_path, child_node, eval, argv, project_path),
{:ok, server_pid} <- parent_init_sequence(child_node, port, output_emitter) do
{:ok, server_pid} <- parent_init_sequence(child_node, port, emitter: output_emitter) do
runtime = %__MODULE__{
node: child_node,
server_pid: server_pid,
@ -188,4 +188,8 @@ defimpl Livebook.Runtime, for: Livebook.Runtime.MixStandalone do
def stop_smart_cell(runtime, ref) do
ErlDist.RuntimeServer.stop_smart_cell(runtime.server_pid, ref)
end
def add_dependencies(_runtime, code, dependencies) do
Livebook.Runtime.Code.add_mix_deps(code, dependencies)
end
end

View file

@ -80,18 +80,25 @@ defmodule Livebook.Runtime.StandaloneInit do
Performs the parent side of the initialization contract.
Should be called by the initializing process on the parent node.
## Options
* `:emitter` - an emitter through which all child outpt is passed
* `:init_opts` - see `Livebook.Runtime.ErlDist.initialize/2`
"""
@spec parent_init_sequence(node(), port(), Emitter.t() | nil) ::
{:ok, pid()} | {:error, String.t()}
def parent_init_sequence(child_node, port, emitter \\ nil) do
@spec parent_init_sequence(node(), port(), keyword()) :: {:ok, pid()} | {:error, String.t()}
def parent_init_sequence(child_node, port, opts \\ []) do
port_ref = Port.monitor(port)
emitter = opts[:emitter]
loop = fn loop ->
receive do
{:node_started, init_ref, ^child_node, primary_pid} ->
Port.demonitor(port_ref)
server_pid = Livebook.Runtime.ErlDist.initialize(child_node)
server_pid = Livebook.Runtime.ErlDist.initialize(child_node, opts[:init_opts] || [])
send(primary_pid, {:node_initialized, init_ref})

View file

@ -310,6 +310,14 @@ defmodule Livebook.Session do
GenServer.cast(pid, {:convert_smart_cell, self(), cell_id})
end
@doc """
Sends smart cell dependency addition request to the server.
"""
@spec add_smart_cell_dependencies(pid(), String.t()) :: :ok
def add_smart_cell_dependencies(pid, kind) do
GenServer.cast(pid, {:add_smart_cell_dependencies, self(), kind})
end
@doc """
Sends cell evaluation request to the server.
"""
@ -728,6 +736,16 @@ defmodule Livebook.Session do
{:noreply, state}
end
def handle_cast({:add_smart_cell_dependencies, _client_pid, kind}, state) do
state =
case Enum.find(state.data.smart_cell_definitions, &(&1.kind == kind)) do
%{requirement: %{dependencies: dependencies}} -> add_dependencies(state, dependencies)
_ -> state
end
{:noreply, state}
end
def handle_cast({:queue_cell_evaluation, client_pid, cell_id}, state) do
operation = {:queue_cells_evaluation, client_pid, [cell_id]}
{:noreply, handle_operation(state, operation)}
@ -1088,6 +1106,35 @@ defmodule Livebook.Session do
%{state | runtime_monitor_ref: runtime_monitor_ref}
end
defp add_dependencies(%{data: %{runtime: nil}} = state, _dependencies), do: state
defp add_dependencies(state, dependencies) do
{:ok, cell, _} = Notebook.fetch_cell_and_section(state.data.notebook, Cell.setup_cell_id())
source = cell.source
case Runtime.add_dependencies(state.data.runtime, source, dependencies) do
{:ok, ^source} ->
state
{:ok, new_source} ->
delta = Livebook.JSInterop.diff(cell.source, new_source)
revision = state.data.cell_infos[cell.id].sources.primary.revision + 1
handle_operation(
state,
{:apply_cell_delta, self(), cell.id, :primary, delta, revision}
)
{:error, message} ->
broadcast_error(
state.session_id,
"failed to add dependencies to the setup cell, reason:\n\n#{message}"
)
state
end
end
# Given any operation on `Livebook.Session.Data`, the process
# does the following:
#

View file

@ -641,7 +641,13 @@ defmodule Livebook.Session.Data do
with {:ok, cell, _} <- Notebook.fetch_cell_and_section(data.notebook, cell_id),
source_info <- data.cell_infos[cell_id].sources[tag],
true <- 0 < revision and revision <= source_info.revision + 1,
true <- Map.has_key?(data.clients_map, client_pid) do
# We either need to know the client, so that we can transform
# the delta, or the delta must apply to the latest revision,
# in which case no transformation is necessary. The latter is
# useful when we want to apply changes programatically
true <-
Map.has_key?(data.clients_map, client_pid) or
revision == source_info.revision + 1 do
data
|> with_actions()
|> apply_delta(client_pid, cell, tag, delta, revision)
@ -1375,12 +1381,16 @@ defmodule Livebook.Session.Data do
|> Map.update!(:deltas, &(&1 ++ [transformed_new_delta]))
|> Map.update!(:revision, &(&1 + 1))
# Before receiving acknowledgement, the client receives all
# the other deltas, so we can assume they are in sync with
# the server and have the same revision.
source_info =
put_in(source_info.revision_by_client_pid[client_pid], source_info.revision)
|> purge_deltas()
if Map.has_key?(source_info.revision_by_client_pid, client_pid) do
# Before receiving acknowledgement, the client receives all
# the other deltas, so we can assume they are in sync with
# the server and have the same revision.
put_in(source_info.revision_by_client_pid[client_pid], source_info.revision)
|> purge_deltas()
else
source_info
end
updated_cell =
update_in(cell, source_access(cell, tag), fn
@ -1446,7 +1456,12 @@ defmodule Livebook.Session.Data do
defp maybe_start_smart_cells({data, _} = data_actions) do
if data.runtime do
dead_cells = dead_smart_cells_with_section(data)
kinds = Enum.map(data.smart_cell_definitions, & &1.kind)
kinds =
for definition <- data.smart_cell_definitions,
definition.requirement == nil,
do: definition.kind
cells_ready_to_start = Enum.filter(dead_cells, fn {cell, _} -> cell.kind in kinds end)
reduce(data_actions, cells_ready_to_start, fn data_actions, {cell, section} ->

View file

@ -99,16 +99,19 @@ defmodule LivebookWeb.LiveHelpers do
Don't show this message again
</span>
</label>
<div class="mt-8 flex justify-end space-x-2">
<button class="button-base button-red"
phx-click={hide_modal(@id) |> JS.dispatch("lb:confirm", to: "##{@id}")}>
<i aria-hidden="true" data-confirm-icon></i>
<span data-confirm-text></span>
</button>
<button class="button-base button-outlined-gray"
phx-click={hide_modal(@id)}>
Cancel
</button>
<div class="mt-8 flex justify-end">
<div class="flex space-x-2" data-actions>
<button class="button-base button-outlined-gray"
phx-click={hide_modal(@id)}>
Cancel
</button>
<button class="button-base"
phx-click={hide_modal(@id) |> JS.dispatch("lb:confirm", to: "##{@id}")}
data-confirm-button>
<i aria-hidden="true" data-confirm-icon></i>
<span data-confirm-text></span>
</button>
</div>
</div>
</div>
</.modal>
@ -130,6 +133,8 @@ defmodule LivebookWeb.LiveHelpers do
* `:confirm_icon` - icon in the confirm button. Optional
* `:danger` - whether the action is destructive or regular. Defaults to `true`
* `:opt_out_id` - enables the "Don't show this message again"
checkbox. Once checked by the user, the confirmation with this
id is never shown again. Optional
@ -154,7 +159,14 @@ defmodule LivebookWeb.LiveHelpers do
opts =
Keyword.validate!(
opts,
[:confirm_icon, :description, :opt_out_id, title: "Are you sure?", confirm_text: "Yes"]
[
:confirm_icon,
:description,
:opt_out_id,
title: "Are you sure?",
confirm_text: "Yes",
danger: true
]
)
JS.dispatch(js, "lb:confirm_request",
@ -164,6 +176,7 @@ defmodule LivebookWeb.LiveHelpers do
description: Keyword.fetch!(opts, :description),
confirm_text: opts[:confirm_text],
confirm_icon: opts[:confirm_icon],
danger: opts[:danger],
opt_out_id: opts[:opt_out_id]
}
)

View file

@ -435,7 +435,7 @@ defmodule LivebookWeb.SessionLive do
<.labeled_text label="Type" text={runtime_type_label(@empty_default_runtime)} />
</div>
<div class="flex space-x-2">
<button class="button-base button-blue" phx-click="connect_default_runtime">
<button class="button-base button-blue" phx-click="start_default_runtime">
<.remix_icon icon="wireless-charging-line" class="align-middle mr-1" />
<span>Connect</span>
</button>
@ -721,19 +721,33 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("add_smart_cell_dependencies", %{"kind" => kind}, socket) do
Session.add_smart_cell_dependencies(socket.assigns.session.pid, kind)
{status, socket} = maybe_restart_runtime(socket)
if status == :ok do
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id())
end
{:noreply, socket}
end
def handle_event("queue_cell_evaluation", %{"cell_id" => cell_id}, socket) do
data = socket.private.data
socket =
{status, socket} =
with {:ok, cell, _section} <- Notebook.fetch_cell_and_section(data.notebook, cell_id),
true <- Cell.setup?(cell),
false <- data.cell_infos[cell.id].eval.validity == :fresh do
maybe_restart_runtime(socket)
else
_ -> socket
_ -> {:ok, socket}
end
Session.queue_cell_evaluation(socket.assigns.session.pid, cell_id)
if status == :ok do
Session.queue_cell_evaluation(socket.assigns.session.pid, cell_id)
end
{:noreply, socket}
end
@ -779,21 +793,21 @@ defmodule LivebookWeb.SessionLive do
end
def handle_event("restart_runtime", %{}, socket) do
{:noreply, maybe_restart_runtime(socket)}
{_, socket} = maybe_restart_runtime(socket)
{:noreply, socket}
end
def handle_event("connect_default_runtime", %{}, socket) do
{runtime_module, args} = Livebook.Config.default_runtime()
def handle_event("start_default_runtime", %{}, socket) do
{_, socket} = start_default_runtime(socket)
{:noreply, socket}
end
socket =
case apply(runtime_module, :init, args) do
{:ok, runtime} ->
Session.connect_runtime(socket.assigns.session.pid, runtime)
socket
def handle_event("setup_default_runtime", %{}, socket) do
{status, socket} = start_default_runtime(socket)
{:error, message} ->
put_flash(socket, :error, "Failed to setup runtime - #{message}")
end
if status == :ok do
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id())
end
{:noreply, socket}
end
@ -1316,16 +1330,29 @@ defmodule LivebookWeb.SessionLive do
defp autofocus_cell_id(%Notebook{sections: [%{cells: [%{id: id, source: ""}]}]}), do: id
defp autofocus_cell_id(_notebook), do: nil
defp maybe_restart_runtime(%{private: %{data: %{runtime: nil}}} = socket), do: socket
defp start_default_runtime(socket) do
{runtime_module, args} = Livebook.Config.default_runtime()
case apply(runtime_module, :init, args) do
{:ok, runtime} ->
Session.connect_runtime(socket.assigns.session.pid, runtime)
{:ok, socket}
{:error, message} ->
{:error, put_flash(socket, :error, "Failed to start runtime - #{message}")}
end
end
defp maybe_restart_runtime(%{private: %{data: %{runtime: nil}}} = socket), do: {:ok, socket}
defp maybe_restart_runtime(%{private: %{data: data}} = socket) do
case Runtime.duplicate(data.runtime) do
{:ok, new_runtime} ->
Session.connect_runtime(socket.assigns.session.pid, new_runtime)
clear_flash(socket, :error)
{:ok, clear_flash(socket, :error)}
{:error, message} ->
put_flash(socket, :error, "Failed to setup runtime - #{message}")
{:error, put_flash(socket, :error, "Failed to start runtime - #{message}")}
end
end

View file

@ -38,28 +38,76 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
</button>
</:content>
</.menu>
<%= if @smart_cell_definitions != [] do %>
<.menu id={"#{@id}-smart-menu"} position="left">
<:toggle>
<button class="button-base button-small">+ Smart</button>
</:toggle>
<:content>
<%= for smart_cell_definition <- Enum.sort_by(@smart_cell_definitions, & &1.name) do %>
<button class="menu-item text-gray-500"
role="menuitem"
phx-click="insert_cell_below"
phx-value-type="smart"
phx-value-kind={smart_cell_definition.kind}
phx-value-section_id={@section_id}
phx-value-cell_id={@cell_id}>
<span class="font-medium"><%= smart_cell_definition.name %></span>
</button>
<% end %>
</:content>
</.menu>
<%= cond do %>
<% @runtime == nil -> %>
<button class="button-base button-small"
phx-click={
with_confirm(
JS.push("setup_default_runtime"),
title: "Setup runtime",
description: ~s'''
To see the available smart cells, you need to start a runtime.
Do you want to start and setup the default one?
''',
confirm_text: "Setup runtime",
confirm_icon: "play-line",
danger: false
)
}>+ Smart</button>
<% @smart_cell_definitions == [] -> %>
<span class="tooltip right" data-tooltip="No smart cells available">
<button class="button-base button-small" disabled>+ Smart</button>
</span>
<% true -> %>
<.menu id={"#{@id}-smart-menu"} position="left">
<:toggle>
<button class="button-base button-small">+ Smart</button>
</:toggle>
<:content>
<%= for definition <- Enum.sort_by(@smart_cell_definitions, & &1.name) do %>
<button class="menu-item text-gray-500"
role="menuitem"
phx-click={on_smart_cell_click(definition, @section_id, @cell_id)}>
<span class="font-medium"><%= definition.name %></span>
</button>
<% end %>
</:content>
</.menu>
<% end %>
</div>
</div>
"""
end
defp on_smart_cell_click(%{requirement: nil} = definition, section_id, cell_id) do
insert_smart_cell(definition, section_id, cell_id)
end
defp on_smart_cell_click(%{requirement: %{}} = definition, section_id, cell_id) do
with_confirm(
JS.push("add_smart_cell_dependencies", value: %{kind: definition.kind})
|> insert_smart_cell(definition, section_id, cell_id),
title: "Add package",
description: ~s'''
The #{definition.name}“ smart cell requires #{definition.requirement.name}.
Do you want to add it as a dependency and restart the runtime?
''',
confirm_text: "Add and restart",
confirm_icon: "add-line",
danger: false
)
end
defp insert_smart_cell(js \\ %JS{}, definition, section_id, cell_id) do
JS.push(js, "insert_cell_below",
value: %{
type: "smart",
kind: definition.kind,
section_id: section_id,
cell_id: cell_id
}
)
end
end

View file

@ -102,6 +102,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
id={"insert-buttons-#{@section_view.id}-first"}
persistent={@section_view.cell_views == []}
smart_cell_definitions={@smart_cell_definitions}
runtime={@runtime}
section_id={@section_view.id}
cell_id={nil} />
<%= for {cell_view, index} <- Enum.with_index(@section_view.cell_views) do %>
@ -114,6 +115,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
id={"insert-buttons-#{@section_view.id}-#{index}"}
persistent={false}
smart_cell_definitions={@smart_cell_definitions}
runtime={@runtime}
section_id={@section_view.id}
cell_id={cell_view.id} />
<% end %>

View file

@ -0,0 +1,164 @@
defmodule Livebook.Runtime.CodeTest do
use ExUnit.Case, async: true
alias Livebook.Runtime
@kino {:kino, "~> 0.5.0"}
describe "add_mix_deps/2" do
test "prepends Mix.install/2 call if there is none" do
assert Runtime.Code.add_mix_deps("", [@kino]) ==
{:ok,
"""
Mix.install([
{:kino, "~> 0.5.0"}
])\
"""}
assert Runtime.Code.add_mix_deps("# Comment", [@kino]) ==
{:ok,
"""
Mix.install([
{:kino, "~> 0.5.0"}
])
# Comment\
"""}
assert Runtime.Code.add_mix_deps(
"""
# Outer comment
for key <- [:key1, :key2] do
# Inner comment
Application.put_env(:app, key, :value)
end
# Final comment\
""",
[@kino]
) ==
{:ok,
"""
Mix.install([
{:kino, "~> 0.5.0"}
])
# Outer comment
for key <- [:key1, :key2] do
# Inner comment
Application.put_env(:app, key, :value)
end
# Final comment\
"""}
end
test "appends dependency to an existing Mix.install/2 call" do
assert Runtime.Code.add_mix_deps(
"""
Mix.install([
{:req, "~> 0.2.0"}
])\
""",
[@kino]
) ==
{:ok,
"""
Mix.install([
{:req, "~> 0.2.0"},
{:kino, "~> 0.5.0"}
])\
"""}
assert Runtime.Code.add_mix_deps(
"""
# Outer comment
Mix.install([
# Inner comment leading
{:req, "~> 0.2.0"}
# Inner comment trailing
])
# Result
:ok\
""",
[@kino]
) ==
{:ok,
"""
# Outer comment
Mix.install([
# Inner comment leading
{:req, "~> 0.2.0"},
{:kino, "~> 0.5.0"}
# Inner comment trailing
])
# Result
:ok\
"""}
end
test "does not add the dependency if it already exists" do
code = """
Mix.install([
{:kino, "~> 0.5.2"}
])\
"""
assert Runtime.Code.add_mix_deps(code, [@kino]) == {:ok, code}
code = """
Mix.install([
{:kino, "~> 0.5.2", runtime: false}
])\
"""
assert Runtime.Code.add_mix_deps(code, [@kino]) == {:ok, code}
end
test "given multiple dependencies adds the missing ones" do
assert Runtime.Code.add_mix_deps(
"""
Mix.install([
{:kino, "~> 0.5.2"}
])\
""",
[{:vega_lite, "~> 0.1.3"}, {:kino, "~> 0.5.0"}, {:req, "~> 0.2.0"}]
) ==
{:ok,
"""
Mix.install([
{:kino, "~> 0.5.2"},
{:vega_lite, "~> 0.1.3"},
{:req, "~> 0.2.0"}
])\
"""}
code = """
Mix.install([
{:kino, "~> 0.5.2", runtime: false}
])\
"""
assert Runtime.Code.add_mix_deps(code, [@kino]) == {:ok, code}
end
test "returns an error if the code has a syntax error" do
assert Runtime.Code.add_mix_deps(
"""
# Comment
[,1]
""",
[@kino]
) ==
{:error,
"""
** (SyntaxError) nofile:2:2: syntax error before: ','
|
2 | [,1]
| ^\
"""}
end
end
end

View file

@ -12,6 +12,7 @@ defmodule Livebook.Session.DataTest do
@eval_resp {:ok, [1, 2, 3]}
@eval_meta %{evaluation_time_ms: 10}
@smart_cell_definitions [%{kind: "text", name: "Text", requirement: nil}]
describe "new/1" do
test "called with no arguments defaults to a blank notebook" do
@ -440,7 +441,7 @@ defmodule Livebook.Session.DataTest do
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}
{:set_smart_cell_definitions, self(), @smart_cell_definitions}
])
operation = {:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}}
@ -457,7 +458,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s1", 0, :code, "c1", %{}},
{:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}},
{:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:set_smart_cell_definitions, self(), @smart_cell_definitions},
{:smart_cell_started, self(), "c2", Delta.new(), %{}, nil}
])
@ -796,7 +797,7 @@ defmodule Livebook.Session.DataTest do
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:set_smart_cell_definitions, self(), @smart_cell_definitions},
{:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}},
{:smart_cell_started, self(), "c1", Delta.new(), %{}, nil}
])
@ -815,7 +816,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}},
{:set_runtime, self(), NoopRuntime.new()},
evaluate_cells_operations(["setup"]),
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:set_smart_cell_definitions, self(), @smart_cell_definitions},
{:smart_cell_started, self(), "c2", Delta.new(), %{}, nil},
{:queue_cells_evaluation, self(), ["c1"]},
{:add_cell_evaluation_response, self(), "c1", @eval_resp, @eval_meta}
@ -907,7 +908,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}},
{:delete_cell, self(), "c1"},
{:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}
{:set_smart_cell_definitions, self(), @smart_cell_definitions}
])
operation = {:restore_cell, self(), "c1"}
@ -2324,7 +2325,7 @@ defmodule Livebook.Session.DataTest do
{:insert_cell, self(), "s1", 1, :smart, "c2", %{kind: "text"}},
{:set_runtime, self(), NoopRuntime.new()},
evaluate_cells_operations(["setup"]),
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:set_smart_cell_definitions, self(), @smart_cell_definitions},
{:smart_cell_started, self(), "c2", Delta.new(), %{}, nil},
{:queue_cells_evaluation, self(), ["c1"]}
])
@ -2646,7 +2647,7 @@ defmodule Livebook.Session.DataTest do
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:set_smart_cell_definitions, self(), @smart_cell_definitions},
{:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}}
])
@ -2665,7 +2666,7 @@ defmodule Livebook.Session.DataTest do
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:set_smart_cell_definitions, self(), @smart_cell_definitions},
{:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}}
])
@ -2692,7 +2693,7 @@ defmodule Livebook.Session.DataTest do
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:set_smart_cell_definitions, self(), @smart_cell_definitions},
{:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}},
{:smart_cell_started, self(), "c1", delta1, %{}, nil}
])
@ -2719,7 +2720,7 @@ defmodule Livebook.Session.DataTest do
{:insert_section, self(), 0, "s1"},
{:set_runtime, self(), NoopRuntime.new()},
evaluate_cells_operations(["setup"]),
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:set_smart_cell_definitions, self(), @smart_cell_definitions},
{:insert_cell, self(), "s1", 0, :smart, "c1", %{kind: "text"}},
{:smart_cell_started, self(), "c1", Delta.new(), %{}, nil},
{:queue_cells_evaluation, self(), ["c1"]},
@ -2992,18 +2993,6 @@ defmodule Livebook.Session.DataTest do
assert :error = Data.apply_operation(data, operation)
end
test "returns an error given non-joined client pid" do
data =
data_after_operations!([
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :code, "c1", %{}}
])
delta = Delta.new() |> Delta.insert("cats")
operation = {:apply_cell_delta, self(), "c1", :primary, delta, 1}
assert :error = Data.apply_operation(data, operation)
end
test "returns an error given invalid revision" do
data =
data_after_operations!([
@ -3018,10 +3007,27 @@ defmodule Livebook.Session.DataTest do
assert :error = Data.apply_operation(data, operation)
end
test "returns an error given non-joined client pid and older revision" do
client1_pid = IEx.Helpers.pid(0, 0, 0)
delta1 = Delta.new() |> Delta.insert("cats")
data =
data_after_operations!([
{:client_join, client1_pid, User.new()},
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :code, "c1", %{}},
{:apply_cell_delta, client1_pid, "c1", :primary, delta1, 1}
])
delta = Delta.new() |> Delta.insert("cats")
operation = {:apply_cell_delta, self(), "c1", :primary, delta, 1}
assert :error = Data.apply_operation(data, operation)
end
test "updates cell source according to the given delta" do
data =
data_after_operations!([
{:client_join, self(), User.new()},
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 0, :code, "c1", %{}}
])
@ -3141,7 +3147,7 @@ defmodule Livebook.Session.DataTest do
{:insert_section, self(), 0, "s1"},
{:insert_cell, self(), "s1", 1, :smart, "c1", %{kind: "text"}},
{:set_runtime, self(), NoopRuntime.new()},
{:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]},
{:set_smart_cell_definitions, self(), @smart_cell_definitions},
{:smart_cell_started, self(), "c1", Delta.new(), %{},
%{language: "text", placement: :bottom, source: ""}}
])
@ -3436,7 +3442,7 @@ defmodule Livebook.Session.DataTest do
{:set_runtime, self(), NoopRuntime.new()}
])
operation = {:set_smart_cell_definitions, self(), [%{kind: "text", name: "Text"}]}
operation = {:set_smart_cell_definitions, self(), @smart_cell_definitions}
assert {:ok, %{cell_infos: %{"c1" => %{status: :starting}}}, _actions} =
Data.apply_operation(data, operation)

View file

@ -134,6 +134,75 @@ defmodule Livebook.SessionTest do
end
end
describe "add_smart_cell_dependencies/2" do
test "applies source change to the setup cell to include the smart cell dependency",
%{session: session} do
{:ok, runtime} = Livebook.Runtime.Embedded.init()
Session.connect_runtime(session.pid, runtime)
send(
session.pid,
{:runtime_smart_cell_definitions,
[
%{
kind: "text",
name: "Text",
requirement: %{name: "Kino", dependencies: [{:kino, "~> 0.5.0"}]}
}
]}
)
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
Session.add_smart_cell_dependencies(session.pid, "text")
session_pid = session.pid
assert_receive {:operation, {:apply_cell_delta, ^session_pid, "setup", :primary, _delta, 1}}
assert %{
notebook: %{
setup_section: %{
cells: [
%{
source: """
Mix.install([
{:kino, "~> 0.5.0"}
])\
"""
}
]
}
}
} = Session.get_data(session.pid)
end
test "broadcasts an error if modifying the setup source fails" do
notebook = Notebook.new() |> Notebook.update_cell("setup", &%{&1 | source: "[,]"})
session = start_session(notebook: notebook)
{:ok, runtime} = Livebook.Runtime.Embedded.init()
Session.connect_runtime(session.pid, runtime)
send(
session.pid,
{:runtime_smart_cell_definitions,
[
%{
kind: "text",
name: "Text",
requirement: %{name: "Kino", dependencies: [{:kino, "~> 0.5.0"}]}
}
]}
)
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
Session.add_smart_cell_dependencies(session.pid, "text")
assert_receive {:error, "failed to add dependencies to the setup cell, reason:" <> _}
end
end
describe "queue_cell_evaluation/2" do
test "triggers evaluation and sends update operation once it finishes",
%{session: session} do
@ -577,7 +646,10 @@ defmodule Livebook.SessionTest do
runtime = Livebook.Runtime.NoopRuntime.new()
Session.connect_runtime(session.pid, runtime)
send(session.pid, {:runtime_smart_cell_definitions, [%{kind: "text", name: "Text"}]})
send(
session.pid,
{:runtime_smart_cell_definitions, [%{kind: "text", name: "Text", requirement: nil}]}
)
Phoenix.PubSub.subscribe(Livebook.PubSub, "sessions:#{session.id}")
@ -601,7 +673,10 @@ defmodule Livebook.SessionTest do
runtime = Livebook.Runtime.NoopRuntime.new()
Session.connect_runtime(session.pid, runtime)
send(session.pid, {:runtime_smart_cell_definitions, [%{kind: "text", name: "Text"}]})
send(
session.pid,
{:runtime_smart_cell_definitions, [%{kind: "text", name: "Text", requirement: nil}]}
)
server_pid = self()

View file

@ -399,7 +399,8 @@ defmodule LivebookWeb.SessionLiveTest do
send(
session.pid,
{:runtime_smart_cell_definitions, [%{kind: "dbconn", name: "Database connection"}]}
{:runtime_smart_cell_definitions,
[%{kind: "dbconn", name: "Database connection", requirement: nil}]}
)
wait_for_session_update(session.pid)

View file

@ -28,5 +28,6 @@ defmodule Livebook.Runtime.NoopRuntime do
def start_smart_cell(_, _, _, _, _), do: :ok
def set_smart_cell_base_locator(_, _, _), do: :ok
def stop_smart_cell(_, _), do: :ok
def add_dependencies(_, code, _), do: {:ok, code}
end
end