Add support for Python cells (#2936)

Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
Jonatan Kłosko 2025-02-18 14:28:29 +01:00 committed by GitHub
parent 5309030aaa
commit 015b44fb72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1755 additions and 471 deletions

View file

@ -124,33 +124,64 @@ server in solely client-side operations.
}
}
[data-el-cell][data-type="setup"]:not(
[data-js-focused],
[data-eval-validity="fresh"]:not([data-js-empty]),
[data-eval-errored],
[data-js-changed]
/* When all setup cells satisfy collapse conditions, collapse the
first one and hide the later ones */
[data-el-setup-section]:not(
:has(
[data-el-cell][data-setup]:is(
[data-js-focused],
[data-eval-validity="fresh"]:not([data-js-empty]),
[data-eval-errored],
[data-js-changed]
)
),
:focus-within
) {
[data-el-editor-box] {
[data-el-cell][data-setup]:not(:first-child) {
@apply hidden;
}
[data-el-outputs-container] {
@apply hidden;
[data-el-cell][data-setup]:first-child {
[data-el-editor-box] {
@apply hidden;
}
[data-el-outputs-container] {
@apply hidden;
}
[data-el-cell-indicator] {
@apply bg-gray-50 border-gray-200 text-gray-500;
}
}
[data-el-cell-indicator] {
@apply bg-gray-50 border-gray-200 text-gray-500;
[data-el-language-buttons] {
@apply hidden;
}
}
[data-el-cell][data-type="setup"]:is(
[data-js-focused],
[data-eval-validity="fresh"]:not([data-js-empty]),
[data-eval-errored],
[data-js-changed]
)
[data-el-info-box] {
@apply hidden;
/* This is "else" for the above */
[data-el-setup-section]:is(
:has(
[data-el-cell][data-setup]:is(
[data-js-focused],
[data-eval-validity="fresh"]:not([data-js-empty]),
[data-eval-errored],
[data-js-changed]
)
),
:focus-within
) {
[data-el-cell][data-setup] {
[data-el-info-box] {
@apply hidden;
}
/* Make the primary actions visible for all cells */
[data-el-actions][data-primary] {
@apply opacity-100;
}
}
}
/* Outputs */
@ -299,13 +330,11 @@ server in solely client-side operations.
}
&[data-js-hide-code] {
[data-el-cell]:is(
[data-type="code"],
[data-type="setup"],
[data-type="smart"]
):not([data-js-insert-mode]) {
[data-el-cell]:is([data-type="code"], [data-type="smart"]):not(
[data-js-insert-mode]
) {
[data-el-editor-box],
&[data-type="setup"] [data-el-info-box],
&[data-setup] [data-el-info-box],
&[data-type="smart"] [data-el-ui-box] {
@apply hidden;
}

View file

@ -41,10 +41,11 @@ const Cell = {
// Setup action handlers
if (["code", "smart"].includes(this.props.type)) {
const amplifyButton = this.el.querySelector(
`[data-el-amplify-outputs-button]`,
);
const amplifyButton = this.el.querySelector(
`[data-el-amplify-outputs-button]`,
);
if (amplifyButton) {
amplifyButton.addEventListener("click", (event) => {
this.el.toggleAttribute("data-js-amplified");
});

View file

@ -92,6 +92,18 @@ const CellEditor = {
this.el.querySelector(`[data-el-editor-container]`).removeAttribute("id");
},
updated() {
const prevProps = this.props;
this.props = this.getProps();
if (
this.props.language !== prevProps.language ||
this.props.intellisense !== prevProps.intellisense
) {
this.liveEditor.setLanguage(this.props.language, this.props.intellisense);
}
},
destroyed() {
if (this.connection) {
this.connection.destroy();

View file

@ -10,7 +10,7 @@ import {
lineNumbers,
highlightActiveLineGutter,
} from "@codemirror/view";
import { EditorState, EditorSelection } from "@codemirror/state";
import { EditorState, EditorSelection, Compartment } from "@codemirror/state";
import {
indentOnInput,
bracketMatching,
@ -236,6 +236,15 @@ export default class LiveEditor {
this.deltaSubscription.destroy();
}
setLanguage(language, intellisense) {
this.language = language;
this.intellisense = intellisense;
this.view.dispatch({
effects: this.languageCompartment.reconfigure(this.languageExtensions()),
});
}
/**
* Either adds or updates doctest indicators.
*/
@ -322,13 +331,6 @@ export default class LiveEditor {
},
});
const lineWrappingEnabled =
this.language === "markdown" && settings.editor_markdown_word_wrap;
const language =
this.language &&
LanguageDescription.matchLanguageName(languages, this.language, false);
const customKeymap = [
{ key: "Escape", run: exitMulticursor },
{ key: "Alt-Enter", run: insertBlankLineAndCloseHints },
@ -338,6 +340,8 @@ export default class LiveEditor {
this.handleViewUpdate(update),
);
this.languageCompartment = new Compartment();
this.view = new EditorView({
parent: this.container,
doc: this.source,
@ -365,7 +369,6 @@ export default class LiveEditor {
keymap.of(vscodeKeymap),
EditorState.tabSize.of(2),
EditorState.lineSeparator.of("\n"),
lineWrappingEnabled ? EditorView.lineWrapping : [],
// We bind tab to actions within the editor, which would trap
// the user if they tabbed into the editor, so we remove it
// from the tab navigation
@ -379,19 +382,9 @@ export default class LiveEditor {
activateOnTyping: settings.editor_auto_completion,
defaultKeymap: false,
}),
this.intellisense
? [
autocompletion({ override: [this.completionSource.bind(this)] }),
hoverDetails(this.docsHoverTooltipSource.bind(this)),
signature(this.signatureSource.bind(this), {
activateOnTyping: settings.editor_auto_signature,
}),
formatter(this.formatterSource.bind(this)),
]
: [],
settings.editor_mode === "vim" ? [vim()] : [],
settings.editor_mode === "emacs" ? [emacs()] : [],
language ? language.support : [],
this.languageCompartment.of(this.languageExtensions()),
EditorView.domEventHandlers({
click: this.handleEditorClick.bind(this),
keydown: this.handleEditorKeydown.bind(this),
@ -404,6 +397,33 @@ export default class LiveEditor {
});
}
/** @private */
languageExtensions() {
const settings = settingsStore.get();
const lineWrappingEnabled =
this.language === "markdown" && settings.editor_markdown_word_wrap;
const language =
this.language &&
LanguageDescription.matchLanguageName(languages, this.language, false);
return [
lineWrappingEnabled ? EditorView.lineWrapping : [],
language ? language.support : [],
this.intellisense
? [
autocompletion({ override: [this.completionSource.bind(this)] }),
hoverDetails(this.docsHoverTooltipSource.bind(this)),
signature(this.signatureSource.bind(this), {
activateOnTyping: settings.editor_auto_signature,
}),
formatter(this.formatterSource.bind(this)),
]
: [],
];
}
/** @private */
handleEditorClick(event) {
const cmd = isMacOS() ? event.metaKey : event.ctrlKey;

View file

@ -15,6 +15,7 @@ import { javascript } from "@codemirror/lang-javascript";
import { erlang } from "@codemirror/legacy-modes/mode/erlang";
import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { toml } from "@codemirror/legacy-modes/mode/toml";
import { elixir } from "codemirror-lang-elixir";
export const elixirDesc = LanguageDescription.of({
@ -77,6 +78,12 @@ const shellDesc = LanguageDescription.of({
support: new LanguageSupport(StreamLanguage.define(shell)),
});
const tomlDesc = LanguageDescription.of({
name: "TOML",
alias: ["pyproject.toml"],
support: new LanguageSupport(StreamLanguage.define(toml)),
});
const markdownDesc = LanguageDescription.of({
name: "Markdown",
support: markdown({
@ -94,6 +101,7 @@ const markdownDesc = LanguageDescription.of({
javascriptDesc,
dockerfileDesc,
shellDesc,
tomlDesc,
],
}),
});
@ -111,5 +119,6 @@ export const languages = [
javascriptDesc,
dockerfileDesc,
shellDesc,
tomlDesc,
markdownDesc,
];

View file

@ -2,12 +2,12 @@
* Checks if the given cell type is eligible for evaluation.
*/
export function isEvaluable(cellType) {
return ["code", "smart", "setup"].includes(cellType);
return ["code", "smart"].includes(cellType);
}
/**
* Checks if the given cell type has primary editable editor.
*/
export function isDirectlyEditable(cellType) {
return ["markdown", "code", "setup"].includes(cellType);
return ["markdown", "code"].includes(cellType);
}

View file

@ -56,7 +56,7 @@ defmodule Livebook.LiveMarkdown.Export do
end
defp render_notebook(notebook, ctx) do
%{setup_section: %{cells: [setup_cell]}} = notebook
%{setup_section: %{cells: setup_cells}} = notebook
comments =
Enum.map(notebook.leading_comments, fn
@ -65,13 +65,13 @@ defmodule Livebook.LiveMarkdown.Export do
end)
name = ["# ", notebook.name]
setup_cell = render_setup_cell(setup_cell, %{ctx | include_outputs?: false})
setup_cells = render_setup_cells(setup_cells, %{ctx | include_outputs?: false})
sections = Enum.map(notebook.sections, &render_section(&1, notebook, ctx))
metadata = notebook_metadata(notebook)
notebook_with_metadata =
[name, setup_cell | sections]
[name | setup_cells ++ sections]
|> Enum.reject(&is_nil/1)
|> Enum.intersperse("\n\n")
|> prepend_metadata(metadata)
@ -175,8 +175,13 @@ defmodule Livebook.LiveMarkdown.Export do
%{"branch_parent_index" => parent_idx}
end
defp render_setup_cell(%{source: ""}, _ctx), do: nil
defp render_setup_cell(cell, ctx), do: render_cell(cell, ctx)
defp render_setup_cells([%{source: ""}], _ctx), do: []
defp render_setup_cells(cells, ctx) do
Enum.map(cells, fn cell ->
render_cell(cell, ctx)
end)
end
defp render_cell(%Cell.Markdown{} = cell, _ctx) do
metadata = cell_metadata(cell)
@ -322,7 +327,7 @@ defmodule Livebook.LiveMarkdown.Export do
defp add_markdown_annotation_before_elixir_block(ast) do
Enum.flat_map(ast, fn
{"pre", _, [{"code", [{"class", language}], [_source], %{}}], %{}} = ast_node
when language in ["elixir", "erlang"] ->
when language in ["elixir", "erlang", "python", "pyproject.toml"] ->
[{:comment, [], [~s/livebook:{"force_markdown":true}/], %{comment: true}}, ast_node]
ast_node ->

View file

@ -162,9 +162,11 @@ defmodule Livebook.LiveMarkdown.Import do
[{"pre", _, [{"code", [{"class", language}], [source], %{}}], %{}} | ast],
elems
)
when language in ["elixir", "erlang"] do
when language in ["elixir", "erlang", "python", "pyproject.toml"] do
{outputs, ast} = take_outputs(ast, [])
language = String.to_atom(language)
group_elements(ast, [{:cell, :code, language, source, outputs} | elems])
end
@ -358,13 +360,22 @@ defmodule Livebook.LiveMarkdown.Import do
messages ++ [@unknown_hub_message]}
end
# We identify a single leading cell as the setup cell, in any
# other case all extra cells are put in a default section
{setup_cell, extra_sections} =
# Check if the remaining cells form a valid setup section, otherwise
# we put them into a default section instead
{setup_cells, extra_sections} =
case cells do
[] -> {nil, []}
[%Notebook.Cell.Code{} = setup_cell] when name != nil -> {setup_cell, []}
extra_cells -> {nil, [%{Notebook.Section.new() | cells: extra_cells}]}
[%Notebook.Cell.Code{language: :elixir}] when name != nil ->
{cells, []}
[%Notebook.Cell.Code{language: :elixir}, %Notebook.Cell.Code{language: :"pyproject.toml"}]
when name != nil ->
{cells, []}
[] ->
{nil, []}
extra_cells ->
{nil, [%{Notebook.Section.new() | cells: extra_cells}]}
end
notebook =
@ -375,7 +386,7 @@ defmodule Livebook.LiveMarkdown.Import do
output_counter: output_counter
}
|> maybe_put_name(name)
|> maybe_put_setup_cell(setup_cell)
|> maybe_put_setup_cells(setup_cells)
|> Map.merge(attrs)
{notebook, valid_hub?, messages}
@ -384,8 +395,8 @@ defmodule Livebook.LiveMarkdown.Import do
defp maybe_put_name(notebook, nil), do: notebook
defp maybe_put_name(notebook, name), do: %{notebook | name: name}
defp maybe_put_setup_cell(notebook, nil), do: notebook
defp maybe_put_setup_cell(notebook, cell), do: Notebook.put_setup_cell(notebook, cell)
defp maybe_put_setup_cells(notebook, nil), do: notebook
defp maybe_put_setup_cells(notebook, cells), do: Notebook.put_setup_cells(notebook, cells)
# Takes optional leading metadata JSON object and returns {metadata, rest}.
defp grab_metadata([{:metadata, metadata} | elems]) do

View file

@ -41,13 +41,13 @@ defmodule Livebook.Notebook do
leading_comments: list(list(line :: String.t())),
persist_outputs: boolean(),
autosave_interval_s: non_neg_integer() | nil,
default_language: :elixir | :erlang,
default_language: :elixir | :erlang | :python,
output_counter: non_neg_integer(),
app_settings: AppSettings.t(),
hub_id: String.t(),
hub_secret_names: list(String.t()),
file_entries: list(file_entry()),
quarantine_file_entry_names: MapSet.new(String.t()),
quarantine_file_entry_names: MapSet.t(),
teams_enabled: boolean(),
deployment_group_id: String.t() | nil
}
@ -116,15 +116,62 @@ defmodule Livebook.Notebook do
teams_enabled: false,
deployment_group_id: nil
}
|> put_setup_cell(Cell.new(:code))
|> put_setup_cells([Cell.new(:code)])
end
@doc """
Sets the given cell as the setup cell.
Sets the given cells as the setup section cells.
"""
@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: Cell.setup_cell_id()}])
@spec put_setup_cells(t(), list(Cell.Code.t())) :: t()
def put_setup_cells(notebook, [main_setup_cell | setup_cells]) do
put_in(notebook.setup_section.cells, [
%{main_setup_cell | id: Cell.main_setup_cell_id()}
| Enum.map(setup_cells, &%{&1 | id: Cell.extra_setup_cell_id(&1.language)})
])
end
@doc """
Returns the list of languages used by the notebook.
"""
@spec enabled_languages(t()) :: list(atom())
def enabled_languages(notebook) do
python_setup_cell_id = Cell.extra_setup_cell_id(:"pyproject.toml")
python_enabled? = Enum.any?(notebook.setup_section.cells, &(&1.id == python_setup_cell_id))
if(python_enabled?, do: [:python], else: []) ++ [:elixir, :erlang]
end
@doc """
Adds extra setup cell specific to the given language.
"""
@spec add_extra_setup_cell(t(), atom()) :: t()
def add_extra_setup_cell(notebook, language)
def add_extra_setup_cell(notebook, :python) do
cell = %{
Cell.new(:code)
| id: Cell.extra_setup_cell_id(:"pyproject.toml"),
language: :"pyproject.toml",
source: """
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = []\
"""
}
update_in(notebook.setup_section.cells, &(&1 ++ [cell]))
end
@doc """
Retrieves extra setup cell specific to the given language.
"""
@spec get_extra_setup_cell(t(), atom()) :: Cell.Code.t()
def get_extra_setup_cell(notebook, language)
def get_extra_setup_cell(notebook, :python) do
id = Cell.extra_setup_cell_id(:"pyproject.toml")
Enum.find(notebook.setup_section.cells, &(&1.id == id))
end
@doc """
@ -272,7 +319,7 @@ defmodule Livebook.Notebook do
def delete_cell(notebook, cell_id) do
{_, notebook} =
pop_in(notebook, [
Access.key(:sections),
access_all_sections(),
Access.all(),
Access.key(:cells),
access_by_id(cell_id)
@ -791,7 +838,7 @@ defmodule Livebook.Notebook do
Recursively adds index to all outputs, including frames.
"""
@spec index_outputs(list(Livebook.Runtime.output()), non_neg_integer()) ::
{list(Cell.index_output()), non_neg_integer()}
{list(Cell.indexed_output()), non_neg_integer()}
def index_outputs(outputs, counter) do
Enum.map_reduce(outputs, counter, &index_output/2)
end

View file

@ -16,6 +16,9 @@ defmodule Livebook.Notebook.Cell do
@type indexed_output :: {non_neg_integer(), Livebook.Runtime.output()}
@setup_cell_id_prefix "setup"
@setup_cell_id "setup"
@doc """
Returns an empty cell of the given type.
"""
@ -88,20 +91,24 @@ defmodule Livebook.Notebook.Cell do
def find_assets_in_output(_output), do: []
@setup_cell_id "setup"
@doc """
Checks if the given cell is the setup code cell.
Checks if the given cell is any of the setup code cells.
"""
@spec setup?(t()) :: boolean()
def setup?(cell)
def setup?(%Cell.Code{id: @setup_cell_id}), do: true
def setup?(%Cell.Code{id: @setup_cell_id_prefix <> _}), do: true
def setup?(_cell), do: false
@doc """
The fixed identifier of the setup cell.
The fixed identifier of the main setup cell.
"""
@spec setup_cell_id() :: id()
def setup_cell_id(), do: @setup_cell_id
@spec main_setup_cell_id() :: id()
def main_setup_cell_id(), do: @setup_cell_id
@doc """
The identifier of extra setup cell for the given language.
"""
@spec extra_setup_cell_id(atom()) :: id()
def extra_setup_cell_id(language), do: "#{@setup_cell_id_prefix}-#{language}"
end

View file

@ -20,7 +20,7 @@ defmodule Livebook.Notebook.Cell.Code do
id: Cell.id(),
source: String.t() | :__pruned__,
outputs: list(Cell.indexed_output()),
language: :elixir | :erlang,
language: Livebook.Runtime.language(),
reevaluate_automatically: boolean(),
continue_on_error: boolean()
}
@ -39,4 +39,16 @@ defmodule Livebook.Notebook.Cell.Code do
continue_on_error: false
}
end
@doc """
Return the list of supported langauges for code cells.
"""
@spec languages() :: list(%{name: String.t(), language: atom()})
def languages() do
[
%{name: "Elixir", language: :elixir},
%{name: "Erlang", language: :erlang},
%{name: "Python", language: :python}
]
end
end

View file

@ -13,15 +13,15 @@ defmodule Livebook.Notebook.Export.Elixir do
end
defp render_notebook(notebook) do
%{setup_section: %{cells: [setup_cell]} = setup_section} = notebook
%{setup_section: %{cells: setup_cells} = setup_section} = notebook
prelude = "# Run as: iex --dot-iex path/to/notebook.exs"
name = ["# Title: ", notebook.name]
setup_cell = render_setup_cell(setup_cell, setup_section)
setup_cells = render_setup_cells(setup_cells, setup_section)
sections = Enum.map(notebook.sections, &render_section(&1, notebook))
[prelude, name, setup_cell | sections]
[prelude, name | setup_cells ++ sections]
|> Enum.reject(&is_nil/1)
|> Enum.intersperse("\n\n")
end
@ -46,8 +46,13 @@ defmodule Livebook.Notebook.Export.Elixir do
|> Enum.intersperse("\n\n")
end
defp render_setup_cell(%{source: ""}, _section), do: nil
defp render_setup_cell(cell, section), do: render_cell(cell, section)
defp render_setup_cells([%{source: ""}], _section), do: []
defp render_setup_cells(cells, section) do
Enum.map(cells, fn cell ->
render_cell(cell, section)
end)
end
defp render_cell(%Cell.Markdown{} = cell, _section) do
cell.source
@ -66,6 +71,31 @@ defmodule Livebook.Notebook.Export.Elixir do
end
end
defp render_cell(%Cell.Code{language: :"pyproject.toml"} = cell, section) do
code =
{:__block__, [],
[
{{:., [], [{:__aliases__, [alias: false], [:Pythonx]}, :uv_init]}, [],
[{:<<>>, [delimiter: ~s["""]], [cell.source <> "\n"]}]},
{:import, [], [{:__aliases__, [], [:Pythonx]}]}
]}
|> Code.quoted_to_algebra()
|> Inspect.Algebra.format(90)
|> IO.iodata_to_binary()
render_cell(%{cell | language: :elixir, source: code}, section)
end
defp render_cell(%Cell.Code{language: :python} = cell, section) do
code =
{:sigil_PY, [delimiter: ~s["""]], [{:<<>>, [], [cell.source <> "\n"]}, []]}
|> Code.quoted_to_algebra()
|> Inspect.Algebra.format(90)
|> IO.iodata_to_binary()
render_cell(%{cell | language: :elixir, source: code}, section)
end
defp render_cell(%Cell.Code{} = cell, _section) do
code = cell.source

View file

@ -34,6 +34,11 @@ defprotocol Livebook.Runtime do
# The owner replies with `{:runtime_app_info_reply, reply}`, where
# reply is `{:ok, info}` and `info` is a details map.
@typedoc """
A language accepted for code evaluation.
"""
@type language :: :elixir | :erlang | :python | :"pyproject.toml"
@typedoc """
An arbitrary term identifying an evaluation container.
@ -866,7 +871,7 @@ defprotocol Livebook.Runtime do
any information added by `connect/1`. It should not have any side
effects.
"""
@spec duplicate(Runtime.t()) :: Runtime.t()
@spec duplicate(t()) :: t()
def duplicate(runtime)
@doc """
@ -927,7 +932,7 @@ defprotocol Livebook.Runtime do
they are fetched and compiled from scratch
"""
@spec evaluate_code(t(), atom(), String.t(), locator(), parent_locators(), keyword()) :: :ok
@spec evaluate_code(t(), language(), String.t(), locator(), parent_locators(), keyword()) :: :ok
def evaluate_code(runtime, language, code, locator, parent_locators, opts \\ [])
@doc """
@ -973,7 +978,7 @@ defprotocol Livebook.Runtime do
@doc """
Reads file at the given absolute path within the runtime file system.
"""
@spec read_file(Runtime.t(), String.t()) :: {:ok, binary()} | {:error, String.t()}
@spec read_file(t(), String.t()) :: {:ok, binary()} | {:error, String.t()}
def read_file(runtime, path)
@doc """

View file

@ -504,4 +504,8 @@ defmodule Livebook.Runtime.Definitions do
def smart_cell_definitions(), do: @smart_cell_definitions
def snippet_definitions(), do: @snippet_definitions
def pythonx_dependency() do
%{dep: {:pythonx, github: "livebook-dev/pythonx"}, config: []}
end
end

View file

@ -83,7 +83,7 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do
"""
@spec evaluate_code(
pid(),
:elixir | :erlang,
Runtime.language(),
String.t(),
Runtime.locator(),
Runtime.parent_locators(),

View file

@ -142,7 +142,7 @@ defmodule Livebook.Runtime.Evaluator do
as an argument
"""
@spec evaluate_code(t(), :elixir | :erlang, ref(), list(ref()), keyword()) :: :ok
@spec evaluate_code(t(), Livebook.Runtime.language(), ref(), list(ref()), keyword()) :: :ok
def evaluate_code(evaluator, language, code, ref, parent_refs, opts \\ []) do
cast(evaluator, {:evaluate_code, language, code, ref, parent_refs, opts})
end
@ -434,7 +434,12 @@ defmodule Livebook.Runtime.Evaluator do
start_time = System.monotonic_time()
{eval_result, code_markers} =
eval(language, code, context.binding, context.env, state.tmp_dir)
case language do
:elixir -> eval_elixir(code, context.binding, context.env)
:erlang -> eval_erlang(code, context.binding, context.env, state.tmp_dir)
:python -> eval_python(code, context.binding, context.env)
:"pyproject.toml" -> eval_pyproject_toml(code, context.binding, context.env)
end
evaluation_time_ms = time_diff_ms(start_time)
@ -491,7 +496,7 @@ defmodule Livebook.Runtime.Evaluator do
end
state = put_context(state, ref, new_context)
output = Evaluator.Formatter.format_result(result, language)
output = Evaluator.Formatter.format_result(language, result)
metadata = %{
errored: error_result?(result),
@ -637,7 +642,7 @@ defmodule Livebook.Runtime.Evaluator do
|> Map.update!(:context_modules, &(&1 ++ prev_env.context_modules))
end
defp eval(:elixir, code, binding, env, _tmp_dir) do
defp eval_elixir(code, binding, env) do
{{result, extra_diagnostics}, diagnostics} =
Code.with_diagnostics([log: true], fn ->
try do
@ -701,6 +706,16 @@ defmodule Livebook.Runtime.Evaluator do
{result, code_markers}
end
defp extra_diagnostic?(%SyntaxError{}), do: true
defp extra_diagnostic?(%TokenMissingError{}), do: true
defp extra_diagnostic?(%MismatchedDelimiterError{}), do: true
defp extra_diagnostic?(%CompileError{description: description}) do
not String.contains?(description, "(errors have been logged)")
end
defp extra_diagnostic?(_error), do: false
# Erlang code is either statements as currently supported, or modules.
# In case we want to support modules - it makes sense to allow users to use
# includes, defines and thus we use the epp-module first - try to find out
@ -708,7 +723,7 @@ defmodule Livebook.Runtime.Evaluator do
# if in the tokens from erl_scan we find at least 1 module-token we assume
# that the user is defining a module, if not the previous code is called.
defp eval(:erlang, code, binding, env, tmp_dir) do
defp eval_erlang(code, binding, env, tmp_dir) do
case :erl_scan.string(String.to_charlist(code), {1, 1}, [:text]) do
{:ok, [{:-, _}, {:atom, _, :module} | _], _} ->
eval_erlang_module(code, binding, env, tmp_dir)
@ -918,15 +933,122 @@ defmodule Livebook.Runtime.Evaluator do
Enum.reject(code_markers, &(&1.line == 0))
end
defp extra_diagnostic?(%SyntaxError{}), do: true
defp extra_diagnostic?(%TokenMissingError{}), do: true
defp extra_diagnostic?(%MismatchedDelimiterError{}), do: true
@compile {:no_warn_undefined, {Pythonx, :eval, 2}}
@compile {:no_warn_undefined, {Pythonx, :decode, 1}}
defp extra_diagnostic?(%CompileError{description: description}) do
not String.contains?(description, "(errors have been logged)")
defp eval_python(code, binding, env) do
with :ok <- ensure_pythonx() do
{result, _diagnostics} =
Code.with_diagnostics([log: true], fn ->
try do
quoted = python_code_to_quoted(code)
{value, binding, env} =
Code.eval_quoted_with_env(quoted, binding, env, prune_binding: true)
result = {:ok, value, binding, env}
code_markers = []
{result, code_markers}
catch
kind, error ->
code_markers =
if is_struct(error, Pythonx.Error) do
Pythonx.eval(
"""
import traceback
if traceback_ is None:
diagnostic = None
elif isinstance(value, SyntaxError):
diagnostic = (value.lineno, "SyntaxError: invalid syntax")
else:
description = " ".join(traceback.format_exception_only(type, value)).strip()
diagnostic = (traceback_.tb_lineno, description)
diagnostic
""",
%{
"type" => error.type,
"value" => error.value,
"traceback_" => error.traceback
}
)
|> elem(0)
|> Pythonx.decode()
|> case do
nil -> []
{line, message} -> [%{line: line, description: message, severity: :error}]
end
else
[]
end
result = {:error, kind, error, []}
{result, code_markers}
end
end)
result
end
end
defp extra_diagnostic?(_error), do: false
defp python_code_to_quoted(code) do
# We expand the sigil upfront, so it is not traced as import usage
# during evaluation.
quoted = {:sigil_PY, [], [{:<<>>, [], [code]}, []]}
env = Code.env_for_eval([])
env =
env
|> Map.replace!(:requires, [Pythonx])
|> Map.replace!(:macros, [{Pythonx, [{:sigil_PY, 2}]}])
Macro.expand_once(quoted, env)
end
defp eval_pyproject_toml(code, binding, env) do
with :ok <- ensure_pythonx() do
quoted = {{:., [], [{:__aliases__, [alias: false], [:Pythonx]}, :uv_init]}, [], [code]}
{result, _diagnostics} =
Code.with_diagnostics([log: true], fn ->
try do
{value, binding, env} =
Code.eval_quoted_with_env(quoted, binding, env, prune_binding: true)
result = {:ok, value, binding, env}
code_markers = []
{result, code_markers}
catch
kind, error ->
code_markers = []
result = {:error, kind, error, []}
{result, code_markers}
end
end)
result
end
end
defp ensure_pythonx() do
if Code.ensure_loaded?(Pythonx) do
:ok
else
message =
"""
Pythonx is missing, make sure to add it as a dependency:
#{Macro.to_string(Livebook.Runtime.Definitions.pythonx_dependency().dep)}
"""
exception = RuntimeError.exception(message)
{{:error, :error, exception, []}, []}
end
end
defp identifier_dependencies(context, tracer_info, prev_context) do
identifiers_used = MapSet.new()

View file

@ -1,6 +1,10 @@
defmodule Livebook.Runtime.Evaluator.Formatter do
require Logger
@compile {:no_warn_undefined, {Kino.Render, :to_livebook, 1}}
@compile {:no_warn_undefined, {Pythonx, :eval, 2}}
@compile {:no_warn_undefined, {Pythonx, :decode, 1}}
@doc """
Formats evaluation result into an output.
@ -10,28 +14,34 @@ defmodule Livebook.Runtime.Evaluator.Formatter do
to format in the runtime node, because it oftentimes relies on the
`inspect` protocol implementations from external packages.
"""
@spec format_result(Livebook.Runtime.Evaluator.evaluation_result(), atom()) ::
Livebook.Runtime.output()
def format_result(result, language)
@spec format_result(
Livebook.Runtime.language(),
Livebook.Runtime.Evaluator.evaluation_result()
) :: Livebook.Runtime.output()
def format_result(language, result)
def format_result({:ok, :"do not show this result in output"}, :elixir) do
def format_result(:elixir, {:ok, :"do not show this result in output"}) do
# Functions in the `IEx.Helpers` module return this specific value
# to indicate no result should be printed in the iex shell,
# so we respect that as well.
%{type: :ignored}
end
def format_result({:ok, {:module, _, _, _} = value}, :elixir) do
def format_result(:elixir, {:ok, {:module, _, _, _} = value}) do
to_inspect_output(value, limit: 10)
end
def format_result({:ok, value}, :elixir) do
def format_result(:elixir, {:ok, value}) do
to_output(value)
end
def format_result({:error, kind, error, stacktrace}, :erlang) do
def format_result(:erlang, {:ok, value}) do
erlang_to_output(value)
end
def format_result(:erlang, {:error, kind, error, stacktrace}) do
if is_exception(error) do
format_result({:error, kind, error, stacktrace}, :elixir)
format_result(:elixir, {:error, kind, error, stacktrace})
else
formatted =
:erl_error.format_exception(kind, error, stacktrace)
@ -42,17 +52,53 @@ defmodule Livebook.Runtime.Evaluator.Formatter do
end
end
def format_result({:error, kind, error, stacktrace}, _language) do
def format_result(:python, {:ok, nil}) do
%{type: :ignored}
end
def format_result(:python, {:ok, value}) do
repr_string = Pythonx.eval("repr(value)", %{"value" => value}) |> elem(0) |> Pythonx.decode()
%{type: :terminal_text, text: repr_string, chunk: false}
end
def format_result(:python, {:error, _kind, error, _stacktrace})
when is_struct(error, Pythonx.Error) do
formatted =
Pythonx.eval(
"""
import traceback
# For SyntaxErrors the traceback is not relevant
traceback_ = None if isinstance(value, SyntaxError) else traceback_
traceback.format_exception(type, value, traceback_)
""",
%{"type" => error.type, "value" => error.value, "traceback_" => error.traceback}
)
|> elem(0)
|> Pythonx.decode()
|> error_color()
|> IO.iodata_to_binary()
%{type: :error, message: formatted, context: nil}
end
def format_result(:"pyproject.toml", {:ok, _value}) do
%{type: :terminal_text, text: "Ok", chunk: false}
end
def format_result(:"pyproject.toml", {:error, _kind, _error, _stacktrace}) do
formatted =
"Error, see the output above for details"
|> error_color()
|> IO.iodata_to_binary()
%{type: :error, message: formatted, context: nil}
end
def format_result(_language, {:error, kind, error, stacktrace}) do
formatted = format_error(kind, error, stacktrace)
%{type: :error, message: formatted, context: error_context(error)}
end
def format_result({:ok, value}, :erlang) do
erlang_to_output(value)
end
@compile {:no_warn_undefined, {Kino.Render, :to_livebook, 1}}
defp to_output(value) do
# Kino is a "client side" extension for Livebook that may be
# installed into the runtime node. If it is installed we use

View file

@ -445,6 +445,24 @@ defmodule Livebook.Session do
GenServer.cast(pid, {:move_section, self(), section_id, offset})
end
@doc """
Requests the given langauge to be enabled.
This inserts extra cells and adds dependencies if applicable.
"""
@spec enable_language(pid(), atom()) :: :ok
def enable_language(pid, language) do
GenServer.cast(pid, {:enable_language, self(), language})
end
@doc """
Requests the given langauge to be disabled.
"""
@spec disable_language(pid(), atom()) :: :ok
def disable_language(pid, language) do
GenServer.cast(pid, {:disable_language, self(), language})
end
@doc """
Requests a smart cell to be recovered.
@ -1170,14 +1188,12 @@ defmodule Livebook.Session do
def handle_cast({:set_section_parent, client_pid, section_id, parent_id}, state) do
client_id = client_id(state, client_pid)
# Include new id in the operation, so it's reproducible
operation = {:set_section_parent, client_id, section_id, parent_id}
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:unset_section_parent, client_pid, section_id}, state) do
client_id = client_id(state, client_pid)
# Include new id in the operation, so it's reproducible
operation = {:unset_section_parent, client_id, section_id}
{:noreply, handle_operation(state, operation)}
end
@ -1219,6 +1235,39 @@ defmodule Livebook.Session do
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:enable_language, client_pid, language}, state) do
case do_add_dependencies(state, [Livebook.Runtime.Definitions.pythonx_dependency()]) do
{:ok, state} ->
client_id = client_id(state, client_pid)
# If there is a single empty cell (new notebook), change its
# language automatically. Note that we cannot do it as part of
# the :enable_language operation, because clients prune the
# source.
state =
case state.data.notebook.sections do
[%{cells: [%{source: ""} = cell]}] ->
operation = {:set_cell_attributes, client_id, cell.id, %{language: language}}
handle_operation(state, operation)
_ ->
state
end
operation = {:enable_language, client_id, language}
{:noreply, handle_operation(state, operation)}
{:error, state} ->
{:noreply, state}
end
end
def handle_cast({:disable_language, client_pid, language}, state) do
client_id = client_id(state, client_pid)
operation = {:disable_language, client_id, language}
{:noreply, handle_operation(state, operation)}
end
def handle_cast({:recover_smart_cell, client_pid, cell_id}, state) do
client_id = client_id(state, client_pid)
operation = {:recover_smart_cell, client_id, cell_id}
@ -1257,7 +1306,8 @@ defmodule Livebook.Session do
end
def handle_cast({:add_dependencies, dependencies}, state) do
{:noreply, do_add_dependencies(state, dependencies)}
{_ok_error, state} = do_add_dependencies(state, dependencies)
{:noreply, state}
end
def handle_cast({:queue_cell_evaluation, client_pid, cell_id, evaluation_opts}, state) do
@ -2220,21 +2270,26 @@ defmodule Livebook.Session do
end
defp do_add_dependencies(state, dependencies) do
{:ok, cell, _} = Notebook.fetch_cell_and_section(state.data.notebook, Cell.setup_cell_id())
{:ok, cell, _} =
Notebook.fetch_cell_and_section(state.data.notebook, Cell.main_setup_cell_id())
source = cell.source
case Runtime.add_dependencies(state.data.runtime, source, dependencies) do
{:ok, ^source} ->
state
{:ok, state}
{:ok, new_source} ->
delta = Livebook.Text.Delta.diff(cell.source, new_source)
revision = state.data.cell_infos[cell.id].sources.primary.revision
handle_operation(
state,
{:apply_cell_delta, @client_id, cell.id, :primary, delta, nil, revision}
)
state =
handle_operation(
state,
{:apply_cell_delta, @client_id, cell.id, :primary, delta, nil, revision}
)
{:ok, state}
{:error, message} ->
broadcast_error(
@ -2242,7 +2297,7 @@ defmodule Livebook.Session do
"failed to add dependencies to the setup cell, reason:\n\n#{message}"
)
state
{:error, state}
end
end

View file

@ -196,6 +196,8 @@ defmodule Livebook.Session.Data do
| {:restore_cell, client_id(), Cell.id()}
| {:move_cell, client_id(), Cell.id(), offset :: integer()}
| {:move_section, client_id(), Section.id(), offset :: integer()}
| {:enable_language, client_id(), atom()}
| {:disable_language, client_id(), atom()}
| {:queue_cells_evaluation, client_id(), list(Cell.id()), evaluation_opts :: keyword()}
| {:add_cell_doctest_report, client_id(), Cell.id(), Runtime.doctest_report()}
| {:add_cell_evaluation_output, client_id(), Cell.id(), term()}
@ -567,6 +569,32 @@ defmodule Livebook.Session.Data do
end
end
def apply_operation(data, {:enable_language, _client_id, language}) do
with false <- language in Notebook.enabled_languages(data.notebook) do
data
|> with_actions()
|> enable_language(language)
|> update_validity_and_evaluation()
|> set_dirty()
|> wrap_ok()
else
_ -> :error
end
end
def apply_operation(data, {:disable_language, _client_id, language}) do
with true <- language in Notebook.enabled_languages(data.notebook) do
data
|> with_actions()
|> disable_language(language)
|> update_validity_and_evaluation()
|> set_dirty()
|> wrap_ok()
else
_ -> :error
end
end
def apply_operation(data, {:queue_cells_evaluation, _client_id, cell_ids, evaluation_opts}) do
cells_with_section =
data.notebook
@ -581,10 +609,11 @@ defmodule Livebook.Session.Data do
data
|> with_actions()
|> queue_prerequisite_cells_evaluation(cell_ids)
|> reduce(cells_with_section, fn data_actions, {cell, section} ->
queue_cell_evaluation(data_actions, cell, section, evaluation_opts)
end)
|> queue_prerequisite_cells_evaluation(cell_ids)
|> maybe_queue_other_setup_cells(evaluation_opts)
|> maybe_connect_runtime(data)
|> update_validity_and_evaluation()
|> wrap_ok()
@ -719,8 +748,8 @@ defmodule Livebook.Session.Data do
true <- eval_info.validity in [:evaluated, :stale] do
data
|> with_actions()
|> queue_prerequisite_cells_evaluation([cell.id])
|> queue_cell_evaluation(cell, section)
|> queue_prerequisite_cells_evaluation([cell.id])
|> maybe_evaluate_queued()
|> wrap_ok()
else
@ -1337,6 +1366,36 @@ defmodule Livebook.Session.Data do
end
end
defp enable_language({data, _} = data_actions, language) do
notebook = Notebook.add_extra_setup_cell(data.notebook, language)
cell = Notebook.get_extra_setup_cell(notebook, language)
set!(data_actions,
notebook: %{notebook | default_language: language},
cell_infos: Map.put(data.cell_infos, cell.id, new_cell_info(cell, data.clients_map))
)
end
defp disable_language({data, _} = data_actions, language) do
cell = Notebook.get_extra_setup_cell(data.notebook, language)
section = data.notebook.setup_section
info = data.cell_infos[cell.id]
data_actions =
if Cell.evaluable?(cell) and not pristine_evaluation?(info.eval) do
data_actions
|> cancel_cell_evaluation(cell, section)
|> add_action({:forget_evaluation, cell, section})
else
data_actions
end
set!(data_actions,
notebook: %{Notebook.delete_cell(data.notebook, cell.id) | default_language: :elixir}
)
|> delete_cell_info(cell)
end
defp queue_cell_evaluation(data_actions, cell, section, evaluation_opts \\ []) do
data_actions
|> update_section_info!(section.id, fn section ->
@ -1431,10 +1490,13 @@ defmodule Livebook.Session.Data do
do: {cell_id, eval_info.snapshot},
into: %{}
enabled_languages = Notebook.enabled_languages(eval_data.notebook)
# We compute evaluation snapshot based on the notebook state prior
# to evaluation, but using the information about the dependencies
# obtained during evaluation (identifiers, inputs)
evaluation_snapshot = cell_snapshot(cell, section, graph, cell_snapshots, eval_data)
evaluation_snapshot =
cell_snapshot(cell, section, graph, cell_snapshots, enabled_languages, eval_data)
data_actions
|> update_cell_eval_info!(
@ -1481,8 +1543,27 @@ defmodule Livebook.Session.Data do
queue_prerequisite_cells_evaluation(data_actions, trailing_queued_cell_ids)
end
defp maybe_queue_other_setup_cells({data, _} = data_actions, evaluation_opts) do
# If one of the setup cells is queued, we automatically queue the
# subsequent ones
{queued, rest} =
Enum.split_while(data.notebook.setup_section.cells, fn cell ->
data.cell_infos[cell.id].eval.status == :queued
end)
if queued != [] and rest != [] do
data_actions
|> reduce(rest, fn data_actions, cell ->
queue_cell_evaluation(data_actions, cell, data.notebook.setup_section, evaluation_opts)
end)
else
data_actions
end
end
defp maybe_evaluate_queued(data_actions) do
{data, _} = data_actions = check_setup_cell_for_reevaluation(data_actions)
{data, _} = data_actions = check_setup_cells_for_reevaluation(data_actions)
if data.runtime_status == :connected do
main_flow_evaluating? = main_flow_evaluating?(data)
@ -1533,40 +1614,43 @@ defmodule Livebook.Session.Data do
end
end
defp check_setup_cell_for_reevaluation({data, _} = data_actions) do
defp check_setup_cells_for_reevaluation({data, _} = data_actions) do
# When setup cell has been evaluated and is queued again, we need
# to reconnect the runtime to get a fresh evaluation environment
# for setup. We subsequently queue all cells that are currently
# queued
case data.cell_infos[Cell.setup_cell_id()].eval do
%{status: :queued, validity: :evaluated} when data.runtime_status == :connected ->
queued_cells_with_section =
data.notebook
|> Notebook.evaluable_cells_with_section()
|> Enum.filter(fn {cell, _} ->
data.cell_infos[cell.id].eval.status == :queued
end)
|> Enum.map(fn {cell, section} ->
{cell, section, data.cell_infos[cell.id].eval.evaluation_opts}
end)
setup_cell_evaluated_and_queued? =
Enum.any?(data.notebook.setup_section.cells, fn cell ->
match?(%{status: :queued, validity: :evaluated}, data.cell_infos[cell.id].eval)
end)
cell_ids =
for {cell, _section, _evaluation_opts} <- queued_cells_with_section, do: cell.id
if setup_cell_evaluated_and_queued? and data.runtime_status == :connected do
queued_cells_with_section =
data.notebook
|> Notebook.evaluable_cells_with_section()
|> Enum.filter(fn {cell, _} ->
data.cell_infos[cell.id].eval.status == :queued
end)
|> Enum.map(fn {cell, section} ->
{cell, section, data.cell_infos[cell.id].eval.evaluation_opts}
end)
data_actions
|> disconnect_runtime()
|> connect_runtime()
|> queue_prerequisite_cells_evaluation(cell_ids)
|> reduce(
queued_cells_with_section,
fn data_actions, {cell, section, evaluation_opts} ->
queue_cell_evaluation(data_actions, cell, section, evaluation_opts)
end
)
cell_ids =
for {cell, _section, _evaluation_opts} <- queued_cells_with_section, do: cell.id
_ ->
data_actions
data_actions
|> disconnect_runtime()
|> connect_runtime()
|> reduce(
queued_cells_with_section,
fn data_actions, {cell, section, evaluation_opts} ->
queue_cell_evaluation(data_actions, cell, section, evaluation_opts)
end
)
|> queue_prerequisite_cells_evaluation(cell_ids)
else
data_actions
end
end
@ -1715,6 +1799,7 @@ defmodule Livebook.Session.Data do
|> Notebook.parent_cells_with_section(cell_ids)
|> Enum.filter(fn {cell, _section} ->
info = data.cell_infos[cell.id]
Cell.evaluable?(cell) and cell_outdated?(data, cell.id) and info.eval.status == :ready
end)
|> Enum.reverse()
@ -2550,9 +2635,11 @@ defmodule Livebook.Session.Data do
cells_with_section = Notebook.evaluable_cells_with_section(data.notebook)
enabled_languages = Notebook.enabled_languages(data.notebook)
cell_snapshots =
Enum.reduce(cells_with_section, %{}, fn {cell, section}, cell_snapshots ->
snapshot = cell_snapshot(cell, section, graph, cell_snapshots, data)
snapshot = cell_snapshot(cell, section, graph, cell_snapshots, enabled_languages, data)
put_in(cell_snapshots[cell.id], snapshot)
end)
@ -2564,9 +2651,15 @@ defmodule Livebook.Session.Data do
end)
end
defp cell_snapshot(cell, section, graph, cell_snapshots, data) do
defp cell_snapshot(cell, section, graph, cell_snapshots, enabled_languages, data) do
info = data.cell_infos[cell.id]
language =
case cell do
%Cell.Code{language: language} -> language
_other -> nil
end
# Note that this is an implication of the Elixir runtime, we want
# to reevaluate as much as possible in a branch, rather than copying
# contexts between processes, because all structural sharing is
@ -2585,7 +2678,9 @@ defmodule Livebook.Session.Data do
)
|> Enum.sort()
deps = {is_branch?, parent_snapshots, identifier_versions, bound_input_current_hashes}
deps =
{enabled_languages, language, is_branch?, parent_snapshots, identifier_versions,
bound_input_current_hashes}
:erlang.phash2(deps)
end
@ -2734,10 +2829,10 @@ defmodule Livebook.Session.Data do
cell_ids = for {cell, _section} <- cells_to_reevaluate, do: cell.id
data_actions
|> queue_prerequisite_cells_evaluation(cell_ids)
|> reduce(cells_to_reevaluate, fn data_actions, {cell, section} ->
queue_cell_evaluation(data_actions, cell, section)
end)
|> queue_prerequisite_cells_evaluation(cell_ids)
end
defp app_update_execution_status({data, _} = data_actions)
@ -2819,8 +2914,9 @@ defmodule Livebook.Session.Data do
@spec cell_ids_for_full_evaluation(t(), list(Cell.id())) :: list(Cell.id())
def cell_ids_for_full_evaluation(data, forced_cell_ids) do
requires_reconnect? =
data.cell_infos[Cell.setup_cell_id()].eval.validity == :evaluated and
cell_outdated?(data, Cell.setup_cell_id())
Enum.any?(data.notebook.setup_section.cells, fn cell ->
data.cell_infos[cell.id].eval.validity == :evaluated and cell_outdated?(data, cell.id)
end)
evaluable_cells_with_section = Notebook.evaluable_cells_with_section(data.notebook)

View file

@ -887,11 +887,11 @@ defmodule LivebookWeb.CoreComponents do
defp button_classes(small, disabled, color, outlined) do
[
if small do
"px-2 py-1 font-normal text-xs"
"px-2 py-1 font-normal text-xs gap-1"
else
"px-5 py-2 font-medium text-sm"
"px-5 py-2 font-medium text-sm gap-1.5"
end,
"inline-flex rounded-lg border whitespace-nowrap items-center justify-center gap-1.5 focus-visible:outline-none",
"inline-flex rounded-lg border whitespace-nowrap items-center justify-center focus-visible:outline-none",
if disabled do
"cursor-default pointer-events-none border-transparent bg-gray-100 text-gray-400"
else

View file

@ -44,82 +44,92 @@ defmodule LivebookWeb.NotebookComponents do
def cell_icon(assigns)
def cell_icon(%{cell_type: :code, language: :elixir} = assigns) do
def cell_icon(%{cell_type: :code} = assigns) do
~H"""
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-purple-100">
<.language_icon language="elixir" class="w-full h-full text-[#663299]" />
</div>
"""
end
def cell_icon(%{cell_type: :code, language: :erlang} = assigns) do
~H"""
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-red-100">
<.language_icon language="erlang" class="w-full h-full text-[#a90533]" />
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-gray-100">
<.language_icon language={Atom.to_string(@language)} class="w-full h-full text-gray-600" />
</div>
"""
end
def cell_icon(%{cell_type: :markdown} = assigns) do
~H"""
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-blue-100">
<.language_icon language="markdown" class="w-full h-full text-[#3e64ff]" />
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-gray-100">
<.language_icon language="markdown" class="w-full h-full text-gray-600" />
</div>
"""
end
def cell_icon(%{cell_type: :smart} = assigns) do
~H"""
<div class="flex w-6 h-6 bg-red-100 rounded items-center justify-center">
<.remix_icon icon="flashlight-line text-red-900" />
<div class="flex w-6 h-6 p-1 rounded items-center justify-center bg-gray-100">
<.remix_icon icon="flashlight-line text-gray-600" />
</div>
"""
end
@doc """
Renders an icon for the given language.
The icons are adapted from https://github.com/material-extensions/vscode-material-icon-theme.
"""
attr :language, :string, required: true
attr :class, :string, default: nil
def language_icon(%{language: "elixir"} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 11 15" xmlns="http://www.w3.org/2000/svg">
<svg class={@class} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.7784 3.58083C7.4569 5.87527 9.67878 5.70652 10.0618 9.04833C10.1147 12.9425 8.03684
14.27 6.55353 14.6441C4.02227 15.3635 1.7644 14.2813 0.875648 11.8316C-0.83154 7.89408 2.36684
1.41746 4.42502 0.0668945C4.60193 1.32119 5.05745 2.51995 5.75815 3.57521L5.7784 3.58083Z"
fill="currentColor"
>
</path>
d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"
/>
</svg>
"""
end
def language_icon(%{language: "erlang"} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 15 10" xmlns="http://www.w3.org/2000/svg">
<g fill="currentColor">
<path d="M2.4 10A7.7 7.7 0 0 1 .5 4.8c0-2 .6-3.6 1.6-4.8H0v10ZM13 10c.5-.6 1-1.2 1.4-2l-2.3-1.2c-.8 1.4-2 2.6-3.6 2.6-2.3 0-3.2-2-3.2-4.8H14V4c0-1.6-.3-3-1-4H15v10h-2Zm0 0" />
<path d="M5.5 2.3c.1-1.2 1-2 2.1-2s1.9.8 2 2Zm0 0" />
</g>
<svg class={@class} viewBox="0 0 30 30" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"
/>
</svg>
"""
end
def language_icon(%{language: "markdown"} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg class={@class} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path
d="M1.25 0.25H14.75C14.9489 0.25 15.1397 0.329018 15.2803 0.46967C15.421 0.610322 15.5 0.801088
15.5 1V13C15.5 13.1989 15.421 13.3897 15.2803 13.5303C15.1397 13.671 14.9489 13.75 14.75 13.75H1.25C1.05109
13.75 0.860322 13.671 0.71967 13.5303C0.579018 13.3897 0.5 13.1989 0.5 13V1C0.5 0.801088 0.579018 0.610322
0.71967 0.46967C0.860322 0.329018 1.05109 0.25 1.25 0.25ZM4.25 9.625V6.625L5.75 8.125L7.25
6.625V9.625H8.75V4.375H7.25L5.75 5.875L4.25 4.375H2.75V9.625H4.25ZM12.5 7.375V4.375H11V7.375H9.5L11.75
9.625L14 7.375H12.5Z"
fill="currentColor"
d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"
/>
</svg>
"""
end
def language_icon(%{language: "python"} = assigns) do
~H"""
<svg class={@class} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711"
/>
<path
fill="currentColor"
d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89"
/>
</svg>
"""
end
def language_icon(%{language: "pyproject.toml"} = assigns) do
~H"""
<svg class={@class} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="currentColor" d="M4 6V4h8v2H9v7H7V6z" />
<path fill="currentColor" d="M4 1v1H2v12h2v1H1V1zm8 0v1h2v12h-2v1h3V1z" />
</svg>
"""
end
end

View file

@ -30,14 +30,14 @@ defmodule LivebookWeb.SessionHelpers do
## Options
* `:queue_setup` - whether to queue the setup cell right after
* `:connect_runtime` - whether to connect the runtime right after
the session is started. Defaults to `false`
Accepts the same options as `Livebook.Sessions.create_session/1`.
"""
@spec create_session(Socket.t(), keyword()) :: Socket.t()
def create_session(socket, opts \\ []) do
{queue_setup, opts} = Keyword.pop(opts, :queue_setup, false)
{connect_runtime, opts} = Keyword.pop(opts, :connect_runtime, false)
# Revert persistence options to default values if there is
# no file attached to the new session
@ -50,8 +50,8 @@ defmodule LivebookWeb.SessionHelpers do
case Livebook.Sessions.create_session(opts) do
{:ok, session} ->
if queue_setup do
Session.queue_cell_evaluation(session.pid, Livebook.Notebook.Cell.setup_cell_id())
if connect_runtime do
Session.connect_runtime(session.pid)
end
redirect_path = session_path(session.id, opts)

View file

@ -225,7 +225,7 @@ defmodule LivebookWeb.HomeLive do
end
def handle_params(%{}, _url, socket) when socket.assigns.live_action == :public_new_notebook do
{:noreply, create_session(socket, queue_setup: true)}
{:noreply, create_session(socket, connect_runtime: true)}
end
def handle_params(_params, _url, socket), do: {:noreply, socket}

View file

@ -349,7 +349,7 @@ defmodule LivebookWeb.Output do
defp render_output(%{type: :error, context: :dependencies} = output, %{id: id, cell_id: cell_id}) do
assigns = %{message: output.message, id: id, cell_id: cell_id}
if cell_id == Livebook.Notebook.Cell.setup_cell_id() do
if cell_id == Livebook.Notebook.Cell.main_setup_cell_id() do
~H"""
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-2" style="color: var(--ansi-color-red);">

View file

@ -255,6 +255,18 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("enable_language", %{"language" => language}, socket) do
language = language_to_string(language)
Session.enable_language(socket.assigns.session.pid, language)
{:noreply, socket}
end
def handle_event("disable_language", %{"language" => language}, socket) do
language = language_to_string(language)
Session.disable_language(socket.assigns.session.pid, language)
{:noreply, socket}
end
def handle_event("insert_cell_below", params, socket) do
{:noreply, insert_cell_below(socket, params)}
end
@ -327,9 +339,8 @@ defmodule LivebookWeb.SessionLive do
end
end
def handle_event("set_default_language", %{"language" => language} = params, socket)
when language in ["elixir", "erlang"] do
language = String.to_atom(language)
def handle_event("set_default_language", %{"language" => language} = params, socket) do
language = language_to_string(language)
Session.set_notebook_attributes(socket.assigns.session.pid, %{default_language: language})
{:noreply, insert_cell_below(socket, params)}
end
@ -548,6 +559,12 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("set_cell_language", %{"cell_id" => cell_id, "language" => language}, socket) do
language = language_to_string(language)
Session.set_cell_attributes(socket.assigns.session.pid, cell_id, %{language: language})
{:noreply, socket}
end
def handle_event("save", %{}, socket) do
if socket.private.data.file do
Session.save(socket.assigns.session.pid)
@ -1346,6 +1363,20 @@ defmodule LivebookWeb.SessionLive do
end
end
defp after_operation(socket, _prev_socket, {:enable_language, client_id, language}) do
cell = Notebook.get_extra_setup_cell(socket.private.data.notebook, language)
socket = push_cell_editor_payloads(socket, socket.private.data, [cell])
socket = prune_cell_sources(socket)
if client_id == socket.assigns.client_id do
push_event(socket, "cell_inserted", %{cell_id: cell.id})
else
socket
end
end
defp after_operation(
socket,
_prev_socket,
@ -1471,12 +1502,10 @@ defmodule LivebookWeb.SessionLive do
defp cell_type_and_attrs_from_params(%{"type" => "code"} = params, socket) do
language =
case params["language"] do
language when language in ["elixir", "erlang"] ->
String.to_atom(language)
_ ->
socket.private.data.notebook.default_language
if language = params["language"] do
language_to_string(language)
else
socket.private.data.notebook.default_language
end
{:code, %{language: language}}
@ -1558,7 +1587,7 @@ defmodule LivebookWeb.SessionLive do
defp confirm_setup_runtime(socket, reason) do
on_confirm = fn socket ->
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id())
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.main_setup_cell_id())
socket
end
@ -1616,7 +1645,7 @@ defmodule LivebookWeb.SessionLive do
defp add_dependencies_and_reevaluate(socket, dependencies) do
Session.add_dependencies(socket.assigns.session.pid, dependencies)
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id())
Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.main_setup_cell_id())
Session.queue_cells_reevaluation(socket.assigns.session.pid)
socket
end
@ -1762,6 +1791,13 @@ defmodule LivebookWeb.SessionLive do
end)
end
defp language_to_string(language) do
%{language: language} =
Enum.find(Cell.Code.languages(), &(Atom.to_string(&1.language) == language))
language
end
# Builds view-specific structure of data by cherry-picking
# only the relevant attributes.
# We then use `@data_view` in the templates and consequently
@ -1804,11 +1840,10 @@ defmodule LivebookWeb.SessionLive do
data.clients_map
|> Enum.map(fn {client_id, user_id} -> {client_id, data.users_map[user_id]} end)
|> Enum.sort_by(fn {_client_id, user} -> user.name || "Anonymous" end),
installing?: data.cell_infos[Cell.setup_cell_id()].eval.status == :evaluating,
setup_cell_view: %{
cell_to_view(hd(data.notebook.setup_section.cells), data, changed_input_ids)
| type: :setup
},
enabled_languages: Notebook.enabled_languages(data.notebook),
installing?: data.cell_infos[Cell.main_setup_cell_id()].eval.status == :evaluating,
setup_cell_views:
Enum.map(data.notebook.setup_section.cells, &cell_to_view(&1, data, changed_input_ids)),
section_views: section_views(data.notebook.sections, data, changed_input_ids),
bin_entries: data.bin_entries,
secrets: data.secrets,
@ -1913,6 +1948,7 @@ defmodule LivebookWeb.SessionLive do
%{
id: cell.id,
type: :code,
setup: Cell.setup?(cell),
language: cell.language,
empty: cell.source == "",
eval: eval_info_to_view(cell, info.eval, data, changed_input_ids),

View file

@ -37,6 +37,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
data-el-cell
id={"cell-#{@cell_view.id}"}
data-type={@cell_view.type}
data-setup={@cell_view[:setup]}
data-focusable-id={@cell_view.id}
data-js-empty={@cell_view.empty}
data-eval-validity={get_in(@cell_view, [:eval, :validity])}
@ -86,56 +87,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
"""
end
defp render_cell(%{cell_view: %{type: :code}} = assigns) do
~H"""
<.cell_actions>
<:primary>
<.cell_evaluation_button
session_id={@session_id}
cell_id={@cell_view.id}
validity={@cell_view.eval.validity}
status={@cell_view.eval.status}
reevaluate_automatically={@cell_view.reevaluate_automatically}
reevaluates_automatically={@cell_view.eval.reevaluates_automatically}
/>
</:primary>
<:secondary>
<.cell_settings_button cell_id={@cell_view.id} session_id={@session_id} />
<.amplify_output_button />
<.cell_link_button cell_id={@cell_view.id} />
<.move_cell_up_button cell_id={@cell_view.id} />
<.move_cell_down_button cell_id={@cell_view.id} />
<.delete_cell_button cell_id={@cell_view.id} />
</:secondary>
</.cell_actions>
<.cell_body>
<div class="relative" data-el-cell-body-root>
<div class="relative" data-el-editor-box>
<.cell_editor
cell_id={@cell_view.id}
tag="primary"
empty={@cell_view.empty}
language={@cell_view.language}
intellisense
/>
</div>
<div class="absolute bottom-2 right-2" data-el-cell-indicators>
<.cell_indicators id={@cell_view.id} cell_view={@cell_view} />
</div>
</div>
<.doctest_summary cell_id={@cell_view.id} doctest_summary={@cell_view.eval.doctest_summary} />
<.evaluation_outputs
outputs={@streams.outputs}
cell_view={@cell_view}
session_id={@session_id}
session_pid={@session_pid}
client_id={@client_id}
/>
</.cell_body>
"""
end
defp render_cell(%{cell_view: %{type: :setup}} = assigns) do
defp render_cell(%{cell_view: %{type: :code, setup: true, language: :elixir}} = assigns) do
~H"""
<.cell_actions>
<:primary>
@ -183,6 +135,111 @@ defmodule LivebookWeb.SessionLive.CellComponent do
"""
end
defp render_cell(
%{cell_view: %{type: :code, setup: true, language: :"pyproject.toml"}} = assigns
) do
~H"""
<.cell_actions>
<:primary>
<div class="flex gap-1 items-center text-gray-500 text-sm">
<.language_icon language="python" class="w-4 h-4" />
<span>Python (pyproject.toml)</span>
</div>
</:primary>
<:secondary>
<.cell_link_button cell_id={@cell_view.id} />
<.disable_language_button language={:python} />
<.pyproject_toml_cell_info />
</:secondary>
</.cell_actions>
<.cell_body>
<div class="relative" data-el-cell-body-root>
<div data-el-editor-box>
<.cell_editor
cell_id={@cell_view.id}
tag="primary"
empty={@cell_view.empty}
language="pyproject.toml"
/>
</div>
<div class="absolute bottom-2 right-2" data-el-cell-indicators>
<.cell_indicators id={@cell_view.id} cell_view={@cell_view} />
</div>
</div>
<.evaluation_outputs
outputs={@streams.outputs}
cell_view={@cell_view}
session_id={@session_id}
session_pid={@session_pid}
client_id={@client_id}
/>
</.cell_body>
"""
end
defp render_cell(%{cell_view: %{type: :code}} = assigns) do
~H"""
<.cell_actions>
<:primary>
<.cell_evaluation_button
session_id={@session_id}
cell_id={@cell_view.id}
validity={@cell_view.eval.validity}
status={@cell_view.eval.status}
reevaluate_automatically={@cell_view.reevaluate_automatically}
reevaluates_automatically={@cell_view.eval.reevaluates_automatically}
/>
</:primary>
<:secondary>
<.cell_settings_button cell_id={@cell_view.id} session_id={@session_id} />
<.amplify_output_button />
<.cell_link_button cell_id={@cell_view.id} />
<.move_cell_up_button cell_id={@cell_view.id} />
<.move_cell_down_button cell_id={@cell_view.id} />
<.delete_cell_button cell_id={@cell_view.id} />
</:secondary>
</.cell_actions>
<.cell_body>
<div class="relative" data-el-cell-body-root>
<div class="relative" data-el-editor-box>
<.cell_editor
cell_id={@cell_view.id}
tag="primary"
empty={@cell_view.empty}
language={@cell_view.language}
intellisense={@cell_view.language == :elixir}
/>
</div>
<div class="absolute bottom-2 right-2" data-el-cell-indicators>
<.cell_indicators id={@cell_view.id} cell_view={@cell_view} langauge_toggle />
</div>
</div>
<div :if={@cell_view.language not in @enabled_languages} class="mt-2">
<.message_box kind="error">
<div class="flex items-center justify-between">
{language_name(@cell_view.language)} is not enabled for the current notebook.
<button
class="flex gap-1 items-center font-medium text-blue-600"
phx-click="enable_language"
phx-value-language="python"
>
Enable Python
</button>
</div>
</.message_box>
</div>
<.doctest_summary cell_id={@cell_view.id} doctest_summary={@cell_view.eval.doctest_summary} />
<.evaluation_outputs
outputs={@streams.outputs}
cell_view={@cell_view}
session_id={@session_id}
session_pid={@session_pid}
client_id={@client_id}
/>
</.cell_body>
"""
end
defp render_cell(%{cell_view: %{type: :smart}} = assigns) do
~H"""
<.cell_actions>
@ -581,6 +638,20 @@ defmodule LivebookWeb.SessionLive.CellComponent do
"""
end
defp disable_language_button(assigns) do
~H"""
<span class="tooltip top" data-tooltip="Delete">
<.icon_button
aria-label="delete cell"
phx-click="disable_language"
phx-value-language={@language}
>
<.remix_icon icon="delete-bin-6-line" />
</.icon_button>
</span>
"""
end
defp setup_cell_info(assigns) do
~H"""
<span
@ -600,6 +671,25 @@ defmodule LivebookWeb.SessionLive.CellComponent do
"""
end
defp pyproject_toml_cell_info(assigns) do
~H"""
<span
class="tooltip left"
data-tooltip={
~s'''
This cell specifies the Python environment using pyproject.toml
configuration. While standardized to a certain extent, this
configuration is used specifically with the uv package manager.\
'''
}
>
<.icon_button>
<.remix_icon icon="question-line" />
</.icon_button>
</span>
"""
end
attr :cell_id, :string, required: true
attr :tag, :string, required: true
attr :empty, :boolean, required: true
@ -680,24 +770,55 @@ defmodule LivebookWeb.SessionLive.CellComponent do
"""
end
attr :id, :string, required: true
attr :cell_view, :map, required: true
attr :langauge_toggle, :boolean, default: false
defp cell_indicators(assigns) do
~H"""
<div class="flex gap-1">
<.cell_indicator :if={has_status?(@cell_view)}>
<.cell_status id={@id} cell_view={@cell_view} />
</.cell_indicator>
<.cell_indicator>
<.language_icon language={cell_language(@cell_view)} class="w-3 h-3" />
</.cell_indicator>
<%= if @langauge_toggle do %>
<.menu id={"cell-#{@id}-language-menu"} position="bottom-right">
<:toggle>
<.cell_indicator class="cursor-pointer">
<.language_icon language={cell_language(@cell_view)} class="w-3 h-3" />
</.cell_indicator>
</:toggle>
<.menu_item :for={language <- Livebook.Notebook.Cell.Code.languages()}>
<button
role="menuitem"
phx-click="set_cell_language"
phx-value-language={language.language}
phx-value-cell_id={@id}
>
<.cell_icon cell_type={:code} language={language.language} />
<span>{language.name}</span>
</button>
</.menu_item>
</.menu>
<% else %>
<.cell_indicator>
<.language_icon language={cell_language(@cell_view)} class="w-3 h-3" />
</.cell_indicator>
<% end %>
</div>
"""
end
attr :class, :string, default: nil
slot :inner_block, required: true
defp cell_indicator(assigns) do
~H"""
<div
data-el-cell-indicator
class="px-1.5 h-[22px] rounded-lg flex items-center border bg-editor-lighter border-editor text-editor"
class={[
"px-1.5 h-[22px] rounded-lg flex items-center border bg-editor-lighter border-editor text-editor",
@class
]}
>
{render_slot(@inner_block)}
</div>
@ -806,4 +927,11 @@ defmodule LivebookWeb.SessionLive.CellComponent do
defp smart_cell_js_view_ref(%{type: :smart, status: :started, js_view: %{ref: ref}}), do: ref
defp smart_cell_js_view_ref(_cell_view), do: nil
defp language_name(language) do
Enum.find_value(
Livebook.Notebook.Cell.Code.languages(),
&(&1.language == language && &1.name)
)
end
end

View file

@ -42,30 +42,17 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
</div>
</.insert_button>
</:toggle>
<.menu_item>
<.menu_item :for={language <- Livebook.Notebook.Cell.Code.languages()}>
<button
role="menuitem"
phx-click="set_default_language"
phx-value-type="code"
phx-value-language="elixir"
phx-value-language={language.language}
phx-value-section_id={@section_id}
phx-value-cell_id={@cell_id}
>
<.cell_icon cell_type={:code} language={:elixir} />
<span>Elixir</span>
</button>
</.menu_item>
<.menu_item>
<button
role="menuitem"
phx-click="set_default_language"
phx-value-type="code"
phx-value-language="erlang"
phx-value-section_id={@section_id}
phx-value-cell_id={@cell_id}
>
<.cell_icon cell_type={:code} language={:erlang} />
<span>Erlang</span>
<.cell_icon cell_type={:code} language={language.language} />
<span>{language.name}</span>
</button>
</.menu_item>
</.menu>

View file

@ -1350,18 +1350,39 @@ defmodule LivebookWeb.SessionLive.Render do
</div>
</div>
</div>
<div>
<.live_component
module={LivebookWeb.SessionLive.CellComponent}
id={@data_view.setup_cell_view.id}
session_id={@session.id}
session_pid={@session.pid}
client_id={@client_id}
runtime={@data_view.runtime}
installing?={@data_view.installing?}
allowed_uri_schemes={@allowed_uri_schemes}
cell_view={@data_view.setup_cell_view}
/>
<div data-el-setup-section>
<div class="flex flex-col gap-2">
<.live_component
:for={setup_cell_view <- @data_view.setup_cell_views}
module={LivebookWeb.SessionLive.CellComponent}
id={setup_cell_view.id}
session_id={@session.id}
session_pid={@session.pid}
client_id={@client_id}
runtime={@data_view.runtime}
installing?={@data_view.installing?}
allowed_uri_schemes={@allowed_uri_schemes}
enabled_languages={@data_view.enabled_languages}
cell_view={setup_cell_view}
/>
</div>
<div
:if={:python not in @data_view.enabled_languages}
class="flex mt-2"
data-el-language-buttons
>
<.button
color="gray"
outlined
small
phx-click="enable_language"
phx-value-language="python"
disabled={Livebook.Runtime.fixed_dependencies?(@data_view.runtime)}
>
<.remix_icon icon="add-line" class="text-sm -mx-0.5 leading-none" />
<span>Python</span>
</.button>
</div>
</div>
<div class="mt-8 flex flex-col w-full space-y-16" data-el-sections-container>
<div :if={@data_view.section_views == []} class="flex justify-center">
@ -1384,6 +1405,7 @@ defmodule LivebookWeb.SessionLive.Render do
allowed_uri_schemes={@allowed_uri_schemes}
section_view={section_view}
default_language={@data_view.default_language}
enabled_languages={@data_view.enabled_languages}
/>
<div style="height: 80vh"></div>
</div>

View file

@ -163,6 +163,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
runtime_status={@runtime_status}
installing?={@installing?}
allowed_uri_schemes={@allowed_uri_schemes}
enabled_languages={@enabled_languages}
cell_view={cell_view}
/>
<.live_component

View file

@ -124,6 +124,8 @@ defmodule Livebook.MixProject do
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:floki, ">= 0.27.0", only: :test},
{:bypass, "~> 2.1", only: :test},
# So that we can test Python evaluation in the same node
{:pythonx, github: "livebook-dev/pythonx", only: :test},
# ZTA deps
{:jose, "~> 1.11.5"},
{:req, "~> 0.5.8"},

View file

@ -3,6 +3,7 @@
"bandit": {:hex, :bandit, "1.6.5", "24096d6232e0d050096acec96a0a382c44de026f9b591b883ed45497e1ef4916", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "b6b91f630699c8b41f3f0184bd4f60b281e19a336ad9dc1a0da90637b6688332"},
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
"castore": {:hex, :castore, "1.0.9", "5cc77474afadf02c7c017823f460a17daa7908e991b0cc917febc90e466a375c", [:mix], [], "hexpm", "5ea956504f1ba6f2b4eb707061d8e17870de2bee95fb59d512872c2ef06925e7"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"},
"cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"},
@ -11,6 +12,7 @@
"earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"},
"ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"},
"eini": {:hex, :eini_beam, "2.2.4", "02143b1dce4dda4243248e7d9b3d8274b8d9f5a666445e3d868e2cce79e4ff22", [:rebar3], [], "hexpm", "12de479d144b19e09bb92ba202a7ea716739929afdf9dff01ad802e2b1508471"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"},
"file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
@ -44,6 +46,7 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"pluggable": {:hex, :pluggable, "1.1.0", "7eba3bc70c0caf4d9056c63c882df8862f7534f0145da7ab3a47ca73e4adb1e4", [:mix], [], "hexpm", "d12eb00ea47b21e92cd2700d6fbe3737f04b64e71b63aad1c0accde87c751637"},
"protobuf": {:hex, :protobuf, "0.13.0", "7a9d9aeb039f68a81717eb2efd6928fdf44f03d2c0dfdcedc7b560f5f5aae93d", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "21092a223e3c6c144c1a291ab082a7ead32821ba77073b72c68515aa51fef570"},
"pythonx": {:git, "https://github.com/livebook-dev/pythonx.git", "c7e18a55b67ca37de4962398a33a87260ddc31ca", []},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},

View file

@ -126,12 +126,14 @@ defmodule Livebook.Apps.DeployerTest do
notebook =
%{Notebook.new() | app_settings: app_settings}
|> Notebook.put_setup_cell(%{
Notebook.Cell.new(:code)
| source: """
File.touch!("#{path}")
"""
})
|> Notebook.put_setup_cells([
%{
Notebook.Cell.new(:code)
| source: """
File.touch!("#{path}")
"""
}
])
app_spec = Apps.NotebookAppSpec.new(notebook, should_warmup: true)
@ -152,12 +154,14 @@ defmodule Livebook.Apps.DeployerTest do
notebook =
%{Notebook.new() | app_settings: app_settings}
|> Notebook.put_setup_cell(%{
Notebook.Cell.new(:code)
| source: """
raise "error"
"""
})
|> Notebook.put_setup_cells([
%{
Notebook.Cell.new(:code)
| source: """
raise "error"
"""
}
])
app_spec = Apps.NotebookAppSpec.new(notebook, should_warmup: true)
@ -178,12 +182,14 @@ defmodule Livebook.Apps.DeployerTest do
notebook =
%{Notebook.new() | app_settings: app_settings}
|> Notebook.put_setup_cell(%{
Notebook.Cell.new(:code)
| source: """
Process.sleep(:infinity)
"""
})
|> Notebook.put_setup_cells([
%{
Notebook.Cell.new(:code)
| source: """
Process.sleep(:infinity)
"""
}
])
app_spec = Apps.NotebookAppSpec.new(notebook, should_warmup: true)

View file

@ -91,6 +91,13 @@ defmodule Livebook.LiveMarkdown.ExportTest do
source: """
lists:seq(1, 10).\
"""
},
%{
Notebook.Cell.new(:code)
| language: :python,
source: """
range(0, 10)\
"""
}
]
}
@ -149,6 +156,10 @@ defmodule Livebook.LiveMarkdown.ExportTest do
```erlang
lists:seq(1, 10).
```
```python
range(0, 10)
```
"""
{document, []} = Export.notebook_to_livemd(notebook)
@ -1131,7 +1142,7 @@ defmodule Livebook.LiveMarkdown.ExportTest do
| name: "My Notebook",
sections: [%{Notebook.Section.new() | name: "Section 1"}]
}
|> Notebook.put_setup_cell(%{Notebook.Cell.new(:code) | source: "Mix.install([...])"})
|> Notebook.put_setup_cells([%{Notebook.Cell.new(:code) | source: "Mix.install([...])"}])
expected_document = """
# My Notebook
@ -1147,6 +1158,60 @@ defmodule Livebook.LiveMarkdown.ExportTest do
assert expected_document == document
end
test "includes pyproject setup cell when present" do
notebook =
%{
Notebook.new()
| name: "My Notebook",
sections: [%{Notebook.Section.new() | name: "Section 1"}]
}
|> Notebook.put_setup_cells([
%{
Notebook.Cell.new(:code)
| source: """
Mix.install([
{:pythonx, github: "livebook-dev/pythonx"}
])\
"""
},
%{
Notebook.Cell.new(:code)
| language: :"pyproject.toml",
source: """
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = []\
"""
}
])
expected_document = """
# My Notebook
```elixir
Mix.install([
{:pythonx, github: "livebook-dev/pythonx"}
])
```
```pyproject.toml
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = []
```
## Section 1
"""
{document, []} = Export.notebook_to_livemd(notebook)
assert expected_document == document
end
end
describe "notebook stamp" do

View file

@ -60,6 +60,10 @@ defmodule Livebook.LiveMarkdown.ImportTest do
```erlang
lists:seq(1, 10).
```
```python
range(0, 10)
```
"""
{notebook, %{warnings: []}} = Import.notebook_from_livemd(markdown)
@ -139,6 +143,12 @@ defmodule Livebook.LiveMarkdown.ImportTest do
source: """
lists:seq(1, 10).\
"""
},
%Cell.Code{
language: :python,
source: """
range(0, 10)\
"""
}
]
}
@ -1140,6 +1150,56 @@ defmodule Livebook.LiveMarkdown.ImportTest do
sections: []
} = notebook
end
test "imports pyproject setup cell" do
markdown = """
# My Notebook
```elixir
Mix.install([
{:pythonx, github: "livebook-dev/pythonx"}
])
```
```pyproject.toml
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = []
```
"""
{notebook, %{warnings: []}} = Import.notebook_from_livemd(markdown)
assert %Notebook{
name: "My Notebook",
setup_section: %{
cells: [
%Cell.Code{
id: "setup",
source: """
Mix.install([
{:pythonx, github: "livebook-dev/pythonx"}
])\
"""
},
%Cell.Code{
id: "setup-pyproject.toml",
language: :"pyproject.toml",
source: """
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = []\
"""
}
]
},
sections: []
} = notebook
end
end
describe "notebook stamp" do

View file

@ -115,7 +115,7 @@ defmodule Livebook.Notebook.Export.ElixirTest do
| name: "My Notebook",
sections: [%{Notebook.Section.new() | name: "Section 1"}]
}
|> Notebook.put_setup_cell(%{Notebook.Cell.new(:code) | source: "Mix.install([...])"})
|> Notebook.put_setup_cells([%{Notebook.Cell.new(:code) | source: "Mix.install([...])"}])
expected_document = """
# Run as: iex --dot-iex path/to/notebook.exs
@ -176,4 +176,78 @@ defmodule Livebook.Notebook.Export.ElixirTest do
assert expected_document == document
end
test "python" do
notebook =
%{
Notebook.new()
| name: "My Notebook",
sections: [
%{
Notebook.Section.new()
| name: "Section 1",
cells: [
%{
Notebook.Cell.new(:code)
| language: :python,
source: """
range(0, 10)\
"""
}
]
}
]
}
|> Notebook.put_setup_cells([
%{
Notebook.Cell.new(:code)
| source: """
Mix.install([
{:pythonx, github: "livebook-dev/pythonx"}
])\
"""
},
%{
Notebook.Cell.new(:code)
| language: :"pyproject.toml",
source: """
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = []\
"""
}
])
expected_document = ~S'''
# Run as: iex --dot-iex path/to/notebook.exs
# Title: My Notebook
Mix.install([
{:pythonx, github: "livebook-dev/pythonx"}
])
Pythonx.uv_init("""
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = []
""")
import Pythonx
# ── Section 1 ──
~PY"""
range(0, 10)
"""
'''
document = Export.Elixir.notebook_to_elixir(notebook)
assert expected_document == document
end
end

View file

@ -6,6 +6,20 @@ defmodule Livebook.Runtime.EvaluatorTest do
@moduletag :tmp_dir
setup_all do
# We setup Pythonx in the current process, so we can test Python
# code evaluation. Testing pyproject.toml evaluation is tricky
# because it requires a separate VM, so we only rely on the LV
# integration tests.
Pythonx.uv_init("""
[project]
name = "project"
version = "0.0.0"
requires-python = "==3.13.*"
dependencies = []
""")
end
setup ctx do
ebin_path =
if ctx[:with_ebin_path] do
@ -1377,7 +1391,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
end
describe "erlang evaluation" do
test "evaluate erlang code", %{evaluator: evaluator} do
test "evaluates erlang code", %{evaluator: evaluator} do
Evaluator.evaluate_code(
evaluator,
:erlang,
@ -1390,7 +1404,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
end
@tag :with_ebin_path
test "evaluate erlang-module code", %{evaluator: evaluator} do
test "evaluates erlang-module code", %{evaluator: evaluator} do
code = """
-module(tryme).
@ -1410,7 +1424,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
end
@tag tmp_dir: false
test "evaluate erlang-module code without filesystem", %{evaluator: evaluator} do
test "evaluates erlang-module code without filesystem", %{evaluator: evaluator} do
code = """
-module(tryme).
@ -1425,7 +1439,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
end
@tag :with_ebin_path
test "evaluate erlang-module error", %{
test "evaluates erlang-module error", %{
evaluator: evaluator
} do
code = """
@ -1570,6 +1584,78 @@ defmodule Livebook.Runtime.EvaluatorTest do
end
end
describe "python evaluation" do
test "evaluates python code", %{evaluator: evaluator} do
code = """
x = [1, 2, 3]
sum(x)
"""
Evaluator.evaluate_code(evaluator, :python, code, :code_1, [])
assert_receive {:runtime_evaluation_response, :code_1, terminal_text("6"), metadata()}
end
test "uses and defines binding", %{evaluator: evaluator} do
Evaluator.evaluate_code(evaluator, :elixir, "x = 1", :code_1, [])
assert_receive {:runtime_evaluation_response, :code_1, _, metadata()}
Evaluator.evaluate_code(evaluator, :python, "y = x", :code_2, [:code_1])
assert_receive {:runtime_evaluation_response, :code_2, _, metadata()}
Evaluator.evaluate_code(evaluator, :elixir, "z = y", :code_3, [:code_2, :code_1])
assert_receive {:runtime_evaluation_response, :code_3, _, metadata()}
%{binding: binding} =
Evaluator.get_evaluation_context(evaluator, [:code_3, :code_2, :code_1])
assert [{:z, %Pythonx.Object{}}, {:y, %Pythonx.Object{}}, {:x, 1}] = binding
end
test "syntax error", %{evaluator: evaluator} do
Evaluator.evaluate_code(evaluator, :python, "1 +", :code_1, [])
assert_receive {:runtime_evaluation_response, :code_1, error(message),
%{
code_markers: [
%{
line: 1,
description: "SyntaxError: invalid syntax",
severity: :error
}
]
}}
assert clean_message(message) == """
File "<unknown>", line 1
1 +
^
SyntaxError: invalid syntax
"""
end
test "runtime error", %{evaluator: evaluator} do
Evaluator.evaluate_code(evaluator, :python, "import unknown", :code_1, [])
assert_receive {:runtime_evaluation_response, :code_1, error(message),
%{
code_markers: [
%{
line: 1,
description: "ModuleNotFoundError: No module named 'unknown'",
severity: :error
}
]
}}
assert clean_message(message) == """
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'unknown'
"""
end
end
describe "formatting" do
test "gracefully handles errors in the inspect protocol", %{evaluator: evaluator} do
code = "%Livebook.TestModules.BadInspect{}"

File diff suppressed because it is too large Load diff

View file

@ -21,6 +21,8 @@ defmodule Livebook.SessionTest do
code_markers: []
}
@setup_id Notebook.Cell.main_setup_cell_id()
describe "file_name_for_download/1" do
@tag :tmp_dir
test "uses associated file name if one is attached", %{tmp_dir: tmp_dir} do
@ -130,6 +132,58 @@ defmodule Livebook.SessionTest do
end
end
describe "enable_language/2" do
test "sends setup cell diff and enable language operation to subscribers" do
session = start_session()
Session.subscribe(session.id)
Session.enable_language(session.pid, :python)
assert_receive {:operation,
{:apply_cell_delta, _client_id, @setup_id, :primary, _delta, _selection, 0}}
assert_receive {:operation, {:enable_language, _client_id, :python}}
end
test "if there is a single empty cell, changes its language" do
session = start_session()
Session.subscribe(session.id)
Session.enable_language(session.pid, :python)
assert_receive {:operation,
{:set_cell_attributes, _client_id, _cell_id, %{language: :python}}}
end
end
describe "disable_language/2" do
test "sends a disable language operation to subscribers" do
session = start_session()
Session.subscribe(session.id)
Session.enable_language(session.pid, :python)
assert_receive {:operation, {:enable_language, _client_id, :python}}
Session.disable_language(session.pid, :python)
assert_receive {:operation, {:disable_language, _client_id, :python}}
end
test "if there is a single empty cell, changes its language" do
session = start_session()
Session.subscribe(session.id)
Session.enable_language(session.pid, :python)
assert_receive {:operation,
{:set_cell_attributes, _client_id, _cell_id, %{language: :python}}}
end
end
describe "recover_smart_cell/2" do
test "sends a recover operations to subscribers and starts the smart cell" do
smart_cell = %{Notebook.Cell.new(:smart) | kind: "text", source: "content"}
@ -224,7 +278,8 @@ defmodule Livebook.SessionTest do
Session.add_dependencies(session.pid, [%{dep: {:req, "~> 0.5.0"}, config: []}])
assert_receive {:operation,
{:apply_cell_delta, "__server__", "setup", :primary, _delta, _selection, 0}}
{:apply_cell_delta, "__server__", @setup_id, :primary, _delta, _selection,
0}}
assert %{
notebook: %{
@ -244,7 +299,7 @@ defmodule Livebook.SessionTest do
end
test "broadcasts an error if modifying the setup source fails" do
notebook = Notebook.new() |> Notebook.update_cell("setup", &%{&1 | source: "[,]"})
notebook = Notebook.new() |> Notebook.update_cell(@setup_id, &%{&1 | source: "[,]"})
session = start_session(notebook: notebook)
Session.subscribe(session.id)
@ -1121,7 +1176,7 @@ defmodule Livebook.SessionTest do
Session.queue_cell_evaluation(session.pid, smart_cell.id)
send(session.pid, {:runtime_evaluation_response, "setup", {:ok, ""}, @eval_meta})
send(session.pid, {:runtime_evaluation_response, @setup_id, {:ok, ""}, @eval_meta})
session_pid = session.pid
assert_receive {:ping, ^session_pid, metadata, %{ref: "ref"}}
@ -1159,11 +1214,11 @@ defmodule Livebook.SessionTest do
{:connect_runtime, self()},
{:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()},
{:queue_cells_evaluation, self(), ["c1"], []},
{:add_cell_evaluation_response, self(), "setup", {:ok, nil}, @eval_meta},
{:add_cell_evaluation_response, self(), @setup_id, {:ok, nil}, @eval_meta},
{:add_cell_evaluation_response, self(), "c1", {:ok, nil}, @eval_meta}
])
assert [{:main_flow, "c1"}, {:main_flow, "setup"}] =
assert [{:main_flow, "c1"}, {:main_flow, @setup_id}] =
Session.parent_locators_for_cell(data, cell3)
end
@ -1190,11 +1245,12 @@ defmodule Livebook.SessionTest do
{:connect_runtime, self()},
{:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()},
{:queue_cells_evaluation, self(), ["c1"], []},
{:add_cell_evaluation_response, self(), "setup", {:ok, nil}, @eval_meta},
{:add_cell_evaluation_response, self(), @setup_id, {:ok, nil}, @eval_meta},
{:add_cell_evaluation_response, self(), "c1", {:ok, nil}, @eval_meta}
])
assert [{"s2", "c1"}, {:main_flow, "setup"}] = Session.parent_locators_for_cell(data, cell3)
assert [{"s2", "c1"}, {:main_flow, @setup_id}] =
Session.parent_locators_for_cell(data, cell3)
end
test "given cell in main flow returns an empty list if there is no previous cell" do
@ -1223,11 +1279,11 @@ defmodule Livebook.SessionTest do
{:connect_runtime, self()},
{:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()},
{:queue_cells_evaluation, self(), ["c1"], []},
{:add_cell_evaluation_response, self(), "setup", {:ok, nil}, @eval_meta},
{:add_cell_evaluation_response, self(), @setup_id, {:ok, nil}, @eval_meta},
{:add_cell_evaluation_response, self(), "c1", {:ok, nil}, @eval_meta}
])
assert [{:main_flow, "c1"}, {:main_flow, "setup"}] =
assert [{:main_flow, "c1"}, {:main_flow, @setup_id}] =
Session.parent_locators_for_cell(data, cell3)
data =

View file

@ -619,6 +619,72 @@ defmodule LivebookWeb.SessionLiveTest do
assert_receive {:runtime_file_path_reply, {:ok, path}}
assert File.read!(path) == "content"
end
test "enabling a language", %{conn: conn, session: session} do
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
view
|> element("[data-el-language-buttons] button", "Python")
|> render_click()
assert %{
notebook: %{
setup_section: %{cells: [%Cell.Code{}, %Cell.Code{language: :"pyproject.toml"}]},
default_language: :python
}
} = Session.get_data(session.pid)
refute view
|> element("[data-el-language-buttons] button", "Python")
|> has_element?()
end
test "disabling a language", %{conn: conn, session: session} do
Session.enable_language(session.pid, :python)
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
refute view
|> element("[data-el-language-buttons] button", "Python")
|> has_element?()
view
|> element(~s/button[phx-click="disable_language"]/)
|> render_click()
assert %{notebook: %{setup_section: %{cells: [%Cell.Code{}]}}} =
Session.get_data(session.pid)
assert view
|> element("[data-el-language-buttons] button", "Python")
|> has_element?()
end
test "changing cell language", %{conn: conn, session: session} do
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :code)
view
|> element(~s/#cell-#{cell_id} button/, "Erlang")
|> render_click()
assert %{notebook: %{sections: [%{cells: [%Cell.Code{language: :erlang}]}]}} =
Session.get_data(session.pid)
end
test "shows an error when a cell langauge is not enabled", %{conn: conn, session: session} do
section_id = insert_section(session.pid)
cell_id = insert_text_cell(session.pid, section_id, :code)
Session.set_cell_attributes(session.pid, cell_id, %{language: :python})
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
assert render(view) =~ "Python is not enabled for the current notebook."
assert render(view) =~ "Enable Python"
end
end
describe "outputs" do
@ -2882,4 +2948,32 @@ defmodule LivebookWeb.SessionLiveTest do
after
Code.put_compiler_option(:debug_info, false)
end
test "python code evaluation end-to-end", %{conn: conn, session: session} do
# Use the standalone runtime, to install Pythonx and setup the interpreter
Session.set_runtime(session.pid, Runtime.Standalone.new())
{:ok, view, _} = live(conn, ~p"/sessions/#{session.id}")
Session.subscribe(session.id)
view
|> element("[data-el-language-buttons] button", "Python")
|> render_click()
section_id = insert_section(session.pid)
cell_id =
insert_text_cell(session.pid, section_id, :code, "len([1, 2])", %{language: :python})
view
|> element(~s{[data-el-session]})
|> render_hook("queue_cell_evaluation", %{"cell_id" => cell_id})
assert_receive {:operation,
{:add_cell_evaluation_response, _, ^cell_id, terminal_text(output), _}},
20_000
assert output == "2"
end
end

View file

@ -44,8 +44,8 @@ defmodule Livebook.SessionHelpers do
section.id
end
def insert_text_cell(session_pid, section_id, type, content \\ " ") do
Session.insert_cell(session_pid, section_id, 0, type, %{source: content})
def insert_text_cell(session_pid, section_id, type, content \\ " ", attrs \\ %{}) do
Session.insert_cell(session_pid, section_id, 0, type, Map.merge(attrs, %{source: content}))
data = Session.get_data(session_pid)
{:ok, section} = Livebook.Notebook.fetch_section(data.notebook, section_id)
cell = hd(section.cells)