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,12 +124,24 @@ server in solely client-side operations.
} }
} }
[data-el-cell][data-type="setup"]:not( /* 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-js-focused],
[data-eval-validity="fresh"]:not([data-js-empty]), [data-eval-validity="fresh"]:not([data-js-empty]),
[data-eval-errored], [data-eval-errored],
[data-js-changed] [data-js-changed]
)
),
:focus-within
) { ) {
[data-el-cell][data-setup]:not(:first-child) {
@apply hidden;
}
[data-el-cell][data-setup]:first-child {
[data-el-editor-box] { [data-el-editor-box] {
@apply hidden; @apply hidden;
} }
@ -143,16 +155,35 @@ server in solely client-side operations.
} }
} }
[data-el-cell][data-type="setup"]:is( [data-el-language-buttons] {
@apply hidden;
}
}
/* This is "else" for the above */
[data-el-setup-section]:is(
:has(
[data-el-cell][data-setup]:is(
[data-js-focused], [data-js-focused],
[data-eval-validity="fresh"]:not([data-js-empty]), [data-eval-validity="fresh"]:not([data-js-empty]),
[data-eval-errored], [data-eval-errored],
[data-js-changed] [data-js-changed]
) )
),
:focus-within
) {
[data-el-cell][data-setup] {
[data-el-info-box] { [data-el-info-box] {
@apply hidden; @apply hidden;
} }
/* Make the primary actions visible for all cells */
[data-el-actions][data-primary] {
@apply opacity-100;
}
}
}
/* Outputs */ /* Outputs */
[data-el-cell][data-js-amplified] { [data-el-cell][data-js-amplified] {
@ -299,13 +330,11 @@ server in solely client-side operations.
} }
&[data-js-hide-code] { &[data-js-hide-code] {
[data-el-cell]:is( [data-el-cell]:is([data-type="code"], [data-type="smart"]):not(
[data-type="code"], [data-js-insert-mode]
[data-type="setup"], ) {
[data-type="smart"]
):not([data-js-insert-mode]) {
[data-el-editor-box], [data-el-editor-box],
&[data-type="setup"] [data-el-info-box], &[data-setup] [data-el-info-box],
&[data-type="smart"] [data-el-ui-box] { &[data-type="smart"] [data-el-ui-box] {
@apply hidden; @apply hidden;
} }

View file

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

View file

@ -92,6 +92,18 @@ const CellEditor = {
this.el.querySelector(`[data-el-editor-container]`).removeAttribute("id"); 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() { destroyed() {
if (this.connection) { if (this.connection) {
this.connection.destroy(); this.connection.destroy();

View file

@ -10,7 +10,7 @@ import {
lineNumbers, lineNumbers,
highlightActiveLineGutter, highlightActiveLineGutter,
} from "@codemirror/view"; } from "@codemirror/view";
import { EditorState, EditorSelection } from "@codemirror/state"; import { EditorState, EditorSelection, Compartment } from "@codemirror/state";
import { import {
indentOnInput, indentOnInput,
bracketMatching, bracketMatching,
@ -236,6 +236,15 @@ export default class LiveEditor {
this.deltaSubscription.destroy(); 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. * 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 = [ const customKeymap = [
{ key: "Escape", run: exitMulticursor }, { key: "Escape", run: exitMulticursor },
{ key: "Alt-Enter", run: insertBlankLineAndCloseHints }, { key: "Alt-Enter", run: insertBlankLineAndCloseHints },
@ -338,6 +340,8 @@ export default class LiveEditor {
this.handleViewUpdate(update), this.handleViewUpdate(update),
); );
this.languageCompartment = new Compartment();
this.view = new EditorView({ this.view = new EditorView({
parent: this.container, parent: this.container,
doc: this.source, doc: this.source,
@ -365,7 +369,6 @@ export default class LiveEditor {
keymap.of(vscodeKeymap), keymap.of(vscodeKeymap),
EditorState.tabSize.of(2), EditorState.tabSize.of(2),
EditorState.lineSeparator.of("\n"), EditorState.lineSeparator.of("\n"),
lineWrappingEnabled ? EditorView.lineWrapping : [],
// We bind tab to actions within the editor, which would trap // We bind tab to actions within the editor, which would trap
// the user if they tabbed into the editor, so we remove it // the user if they tabbed into the editor, so we remove it
// from the tab navigation // from the tab navigation
@ -379,19 +382,9 @@ export default class LiveEditor {
activateOnTyping: settings.editor_auto_completion, activateOnTyping: settings.editor_auto_completion,
defaultKeymap: false, 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 === "vim" ? [vim()] : [],
settings.editor_mode === "emacs" ? [emacs()] : [], settings.editor_mode === "emacs" ? [emacs()] : [],
language ? language.support : [], this.languageCompartment.of(this.languageExtensions()),
EditorView.domEventHandlers({ EditorView.domEventHandlers({
click: this.handleEditorClick.bind(this), click: this.handleEditorClick.bind(this),
keydown: this.handleEditorKeydown.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 */ /** @private */
handleEditorClick(event) { handleEditorClick(event) {
const cmd = isMacOS() ? event.metaKey : event.ctrlKey; 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 { erlang } from "@codemirror/legacy-modes/mode/erlang";
import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile"; import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile";
import { shell } from "@codemirror/legacy-modes/mode/shell"; import { shell } from "@codemirror/legacy-modes/mode/shell";
import { toml } from "@codemirror/legacy-modes/mode/toml";
import { elixir } from "codemirror-lang-elixir"; import { elixir } from "codemirror-lang-elixir";
export const elixirDesc = LanguageDescription.of({ export const elixirDesc = LanguageDescription.of({
@ -77,6 +78,12 @@ const shellDesc = LanguageDescription.of({
support: new LanguageSupport(StreamLanguage.define(shell)), 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({ const markdownDesc = LanguageDescription.of({
name: "Markdown", name: "Markdown",
support: markdown({ support: markdown({
@ -94,6 +101,7 @@ const markdownDesc = LanguageDescription.of({
javascriptDesc, javascriptDesc,
dockerfileDesc, dockerfileDesc,
shellDesc, shellDesc,
tomlDesc,
], ],
}), }),
}); });
@ -111,5 +119,6 @@ export const languages = [
javascriptDesc, javascriptDesc,
dockerfileDesc, dockerfileDesc,
shellDesc, shellDesc,
tomlDesc,
markdownDesc, markdownDesc,
]; ];

View file

@ -2,12 +2,12 @@
* Checks if the given cell type is eligible for evaluation. * Checks if the given cell type is eligible for evaluation.
*/ */
export function isEvaluable(cellType) { 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. * Checks if the given cell type has primary editable editor.
*/ */
export function isDirectlyEditable(cellType) { 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 end
defp render_notebook(notebook, ctx) do defp render_notebook(notebook, ctx) do
%{setup_section: %{cells: [setup_cell]}} = notebook %{setup_section: %{cells: setup_cells}} = notebook
comments = comments =
Enum.map(notebook.leading_comments, fn Enum.map(notebook.leading_comments, fn
@ -65,13 +65,13 @@ defmodule Livebook.LiveMarkdown.Export do
end) end)
name = ["# ", notebook.name] 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)) sections = Enum.map(notebook.sections, &render_section(&1, notebook, ctx))
metadata = notebook_metadata(notebook) metadata = notebook_metadata(notebook)
notebook_with_metadata = notebook_with_metadata =
[name, setup_cell | sections] [name | setup_cells ++ sections]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
|> Enum.intersperse("\n\n") |> Enum.intersperse("\n\n")
|> prepend_metadata(metadata) |> prepend_metadata(metadata)
@ -175,8 +175,13 @@ defmodule Livebook.LiveMarkdown.Export do
%{"branch_parent_index" => parent_idx} %{"branch_parent_index" => parent_idx}
end end
defp render_setup_cell(%{source: ""}, _ctx), do: nil defp render_setup_cells([%{source: ""}], _ctx), do: []
defp render_setup_cell(cell, ctx), do: render_cell(cell, ctx)
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 defp render_cell(%Cell.Markdown{} = cell, _ctx) do
metadata = cell_metadata(cell) metadata = cell_metadata(cell)
@ -322,7 +327,7 @@ defmodule Livebook.LiveMarkdown.Export do
defp add_markdown_annotation_before_elixir_block(ast) do defp add_markdown_annotation_before_elixir_block(ast) do
Enum.flat_map(ast, fn Enum.flat_map(ast, fn
{"pre", _, [{"code", [{"class", language}], [_source], %{}}], %{}} = ast_node {"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] [{:comment, [], [~s/livebook:{"force_markdown":true}/], %{comment: true}}, ast_node]
ast_node -> ast_node ->

View file

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

View file

@ -41,13 +41,13 @@ defmodule Livebook.Notebook do
leading_comments: list(list(line :: String.t())), leading_comments: list(list(line :: String.t())),
persist_outputs: boolean(), persist_outputs: boolean(),
autosave_interval_s: non_neg_integer() | nil, autosave_interval_s: non_neg_integer() | nil,
default_language: :elixir | :erlang, default_language: :elixir | :erlang | :python,
output_counter: non_neg_integer(), output_counter: non_neg_integer(),
app_settings: AppSettings.t(), app_settings: AppSettings.t(),
hub_id: String.t(), hub_id: String.t(),
hub_secret_names: list(String.t()), hub_secret_names: list(String.t()),
file_entries: list(file_entry()), file_entries: list(file_entry()),
quarantine_file_entry_names: MapSet.new(String.t()), quarantine_file_entry_names: MapSet.t(),
teams_enabled: boolean(), teams_enabled: boolean(),
deployment_group_id: String.t() | nil deployment_group_id: String.t() | nil
} }
@ -116,15 +116,62 @@ defmodule Livebook.Notebook do
teams_enabled: false, teams_enabled: false,
deployment_group_id: nil deployment_group_id: nil
} }
|> put_setup_cell(Cell.new(:code)) |> put_setup_cells([Cell.new(:code)])
end end
@doc """ @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() @spec put_setup_cells(t(), list(Cell.Code.t())) :: t()
def put_setup_cell(notebook, %Cell.Code{} = cell) do def put_setup_cells(notebook, [main_setup_cell | setup_cells]) do
put_in(notebook.setup_section.cells, [%{cell | id: Cell.setup_cell_id()}]) 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 end
@doc """ @doc """
@ -272,7 +319,7 @@ defmodule Livebook.Notebook do
def delete_cell(notebook, cell_id) do def delete_cell(notebook, cell_id) do
{_, notebook} = {_, notebook} =
pop_in(notebook, [ pop_in(notebook, [
Access.key(:sections), access_all_sections(),
Access.all(), Access.all(),
Access.key(:cells), Access.key(:cells),
access_by_id(cell_id) access_by_id(cell_id)
@ -791,7 +838,7 @@ defmodule Livebook.Notebook do
Recursively adds index to all outputs, including frames. Recursively adds index to all outputs, including frames.
""" """
@spec index_outputs(list(Livebook.Runtime.output()), non_neg_integer()) :: @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 def index_outputs(outputs, counter) do
Enum.map_reduce(outputs, counter, &index_output/2) Enum.map_reduce(outputs, counter, &index_output/2)
end end

View file

@ -16,6 +16,9 @@ defmodule Livebook.Notebook.Cell do
@type indexed_output :: {non_neg_integer(), Livebook.Runtime.output()} @type indexed_output :: {non_neg_integer(), Livebook.Runtime.output()}
@setup_cell_id_prefix "setup"
@setup_cell_id "setup"
@doc """ @doc """
Returns an empty cell of the given type. Returns an empty cell of the given type.
""" """
@ -88,20 +91,24 @@ defmodule Livebook.Notebook.Cell do
def find_assets_in_output(_output), do: [] def find_assets_in_output(_output), do: []
@setup_cell_id "setup"
@doc """ @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() @spec setup?(t()) :: boolean()
def setup?(cell) 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 def setup?(_cell), do: false
@doc """ @doc """
The fixed identifier of the setup cell. The fixed identifier of the main setup cell.
""" """
@spec setup_cell_id() :: id() @spec main_setup_cell_id() :: id()
def setup_cell_id(), do: @setup_cell_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 end

View file

@ -20,7 +20,7 @@ defmodule Livebook.Notebook.Cell.Code do
id: Cell.id(), id: Cell.id(),
source: String.t() | :__pruned__, source: String.t() | :__pruned__,
outputs: list(Cell.indexed_output()), outputs: list(Cell.indexed_output()),
language: :elixir | :erlang, language: Livebook.Runtime.language(),
reevaluate_automatically: boolean(), reevaluate_automatically: boolean(),
continue_on_error: boolean() continue_on_error: boolean()
} }
@ -39,4 +39,16 @@ defmodule Livebook.Notebook.Cell.Code do
continue_on_error: false continue_on_error: false
} }
end 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 end

View file

@ -13,15 +13,15 @@ defmodule Livebook.Notebook.Export.Elixir do
end end
defp render_notebook(notebook) do 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" prelude = "# Run as: iex --dot-iex path/to/notebook.exs"
name = ["# Title: ", notebook.name] 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)) 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.reject(&is_nil/1)
|> Enum.intersperse("\n\n") |> Enum.intersperse("\n\n")
end end
@ -46,8 +46,13 @@ defmodule Livebook.Notebook.Export.Elixir do
|> Enum.intersperse("\n\n") |> Enum.intersperse("\n\n")
end end
defp render_setup_cell(%{source: ""}, _section), do: nil defp render_setup_cells([%{source: ""}], _section), do: []
defp render_setup_cell(cell, section), do: render_cell(cell, section)
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 defp render_cell(%Cell.Markdown{} = cell, _section) do
cell.source cell.source
@ -66,6 +71,31 @@ defmodule Livebook.Notebook.Export.Elixir do
end end
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 defp render_cell(%Cell.Code{} = cell, _section) do
code = cell.source code = cell.source

View file

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

View file

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

View file

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

View file

@ -142,7 +142,7 @@ defmodule Livebook.Runtime.Evaluator do
as an argument 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 def evaluate_code(evaluator, language, code, ref, parent_refs, opts \\ []) do
cast(evaluator, {:evaluate_code, language, code, ref, parent_refs, opts}) cast(evaluator, {:evaluate_code, language, code, ref, parent_refs, opts})
end end
@ -434,7 +434,12 @@ defmodule Livebook.Runtime.Evaluator do
start_time = System.monotonic_time() start_time = System.monotonic_time()
{eval_result, code_markers} = {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) evaluation_time_ms = time_diff_ms(start_time)
@ -491,7 +496,7 @@ defmodule Livebook.Runtime.Evaluator do
end end
state = put_context(state, ref, new_context) state = put_context(state, ref, new_context)
output = Evaluator.Formatter.format_result(result, language) output = Evaluator.Formatter.format_result(language, result)
metadata = %{ metadata = %{
errored: error_result?(result), errored: error_result?(result),
@ -637,7 +642,7 @@ defmodule Livebook.Runtime.Evaluator do
|> Map.update!(:context_modules, &(&1 ++ prev_env.context_modules)) |> Map.update!(:context_modules, &(&1 ++ prev_env.context_modules))
end end
defp eval(:elixir, code, binding, env, _tmp_dir) do defp eval_elixir(code, binding, env) do
{{result, extra_diagnostics}, diagnostics} = {{result, extra_diagnostics}, diagnostics} =
Code.with_diagnostics([log: true], fn -> Code.with_diagnostics([log: true], fn ->
try do try do
@ -701,6 +706,16 @@ defmodule Livebook.Runtime.Evaluator do
{result, code_markers} {result, code_markers}
end 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. # 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 # 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 # 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 # 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. # 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 case :erl_scan.string(String.to_charlist(code), {1, 1}, [:text]) do
{:ok, [{:-, _}, {:atom, _, :module} | _], _} -> {:ok, [{:-, _}, {:atom, _, :module} | _], _} ->
eval_erlang_module(code, binding, env, tmp_dir) eval_erlang_module(code, binding, env, tmp_dir)
@ -918,15 +933,122 @@ defmodule Livebook.Runtime.Evaluator do
Enum.reject(code_markers, &(&1.line == 0)) Enum.reject(code_markers, &(&1.line == 0))
end end
defp extra_diagnostic?(%SyntaxError{}), do: true @compile {:no_warn_undefined, {Pythonx, :eval, 2}}
defp extra_diagnostic?(%TokenMissingError{}), do: true @compile {:no_warn_undefined, {Pythonx, :decode, 1}}
defp extra_diagnostic?(%MismatchedDelimiterError{}), do: true
defp extra_diagnostic?(%CompileError{description: description}) do defp eval_python(code, binding, env) do
not String.contains?(description, "(errors have been logged)") 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 end
defp extra_diagnostic?(_error), do: false result = {:error, kind, error, []}
{result, code_markers}
end
end)
result
end
end
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 defp identifier_dependencies(context, tracer_info, prev_context) do
identifiers_used = MapSet.new() identifiers_used = MapSet.new()

View file

@ -1,6 +1,10 @@
defmodule Livebook.Runtime.Evaluator.Formatter do defmodule Livebook.Runtime.Evaluator.Formatter do
require Logger 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 """ @doc """
Formats evaluation result into an output. 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 to format in the runtime node, because it oftentimes relies on the
`inspect` protocol implementations from external packages. `inspect` protocol implementations from external packages.
""" """
@spec format_result(Livebook.Runtime.Evaluator.evaluation_result(), atom()) :: @spec format_result(
Livebook.Runtime.output() Livebook.Runtime.language(),
def format_result(result, 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 # Functions in the `IEx.Helpers` module return this specific value
# to indicate no result should be printed in the iex shell, # to indicate no result should be printed in the iex shell,
# so we respect that as well. # so we respect that as well.
%{type: :ignored} %{type: :ignored}
end end
def format_result({:ok, {:module, _, _, _} = value}, :elixir) do def format_result(:elixir, {:ok, {:module, _, _, _} = value}) do
to_inspect_output(value, limit: 10) to_inspect_output(value, limit: 10)
end end
def format_result({:ok, value}, :elixir) do def format_result(:elixir, {:ok, value}) do
to_output(value) to_output(value)
end 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 if is_exception(error) do
format_result({:error, kind, error, stacktrace}, :elixir) format_result(:elixir, {:error, kind, error, stacktrace})
else else
formatted = formatted =
:erl_error.format_exception(kind, error, stacktrace) :erl_error.format_exception(kind, error, stacktrace)
@ -42,17 +52,53 @@ defmodule Livebook.Runtime.Evaluator.Formatter do
end end
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) formatted = format_error(kind, error, stacktrace)
%{type: :error, message: formatted, context: error_context(error)} %{type: :error, message: formatted, context: error_context(error)}
end 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 defp to_output(value) do
# Kino is a "client side" extension for Livebook that may be # Kino is a "client side" extension for Livebook that may be
# installed into the runtime node. If it is installed we use # 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}) GenServer.cast(pid, {:move_section, self(), section_id, offset})
end 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 """ @doc """
Requests a smart cell to be recovered. 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 def handle_cast({:set_section_parent, client_pid, section_id, parent_id}, state) do
client_id = client_id(state, client_pid) 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} operation = {:set_section_parent, client_id, section_id, parent_id}
{:noreply, handle_operation(state, operation)} {:noreply, handle_operation(state, operation)}
end end
def handle_cast({:unset_section_parent, client_pid, section_id}, state) do def handle_cast({:unset_section_parent, client_pid, section_id}, state) do
client_id = client_id(state, client_pid) 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} operation = {:unset_section_parent, client_id, section_id}
{:noreply, handle_operation(state, operation)} {:noreply, handle_operation(state, operation)}
end end
@ -1219,6 +1235,39 @@ defmodule Livebook.Session do
{:noreply, handle_operation(state, operation)} {:noreply, handle_operation(state, operation)}
end 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 def handle_cast({:recover_smart_cell, client_pid, cell_id}, state) do
client_id = client_id(state, client_pid) client_id = client_id(state, client_pid)
operation = {:recover_smart_cell, client_id, cell_id} operation = {:recover_smart_cell, client_id, cell_id}
@ -1257,7 +1306,8 @@ defmodule Livebook.Session do
end end
def handle_cast({:add_dependencies, dependencies}, state) do 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 end
def handle_cast({:queue_cell_evaluation, client_pid, cell_id, evaluation_opts}, state) do def handle_cast({:queue_cell_evaluation, client_pid, cell_id, evaluation_opts}, state) do
@ -2220,29 +2270,34 @@ defmodule Livebook.Session do
end end
defp do_add_dependencies(state, dependencies) do 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 source = cell.source
case Runtime.add_dependencies(state.data.runtime, source, dependencies) do case Runtime.add_dependencies(state.data.runtime, source, dependencies) do
{:ok, ^source} -> {:ok, ^source} ->
state {:ok, state}
{:ok, new_source} -> {:ok, new_source} ->
delta = Livebook.Text.Delta.diff(cell.source, new_source) delta = Livebook.Text.Delta.diff(cell.source, new_source)
revision = state.data.cell_infos[cell.id].sources.primary.revision revision = state.data.cell_infos[cell.id].sources.primary.revision
state =
handle_operation( handle_operation(
state, state,
{:apply_cell_delta, @client_id, cell.id, :primary, delta, nil, revision} {:apply_cell_delta, @client_id, cell.id, :primary, delta, nil, revision}
) )
{:ok, state}
{:error, message} -> {:error, message} ->
broadcast_error( broadcast_error(
state.session_id, state.session_id,
"failed to add dependencies to the setup cell, reason:\n\n#{message}" "failed to add dependencies to the setup cell, reason:\n\n#{message}"
) )
state {:error, state}
end end
end end

View file

@ -196,6 +196,8 @@ defmodule Livebook.Session.Data do
| {:restore_cell, client_id(), Cell.id()} | {:restore_cell, client_id(), Cell.id()}
| {:move_cell, client_id(), Cell.id(), offset :: integer()} | {:move_cell, client_id(), Cell.id(), offset :: integer()}
| {:move_section, client_id(), Section.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()} | {:queue_cells_evaluation, client_id(), list(Cell.id()), evaluation_opts :: keyword()}
| {:add_cell_doctest_report, client_id(), Cell.id(), Runtime.doctest_report()} | {:add_cell_doctest_report, client_id(), Cell.id(), Runtime.doctest_report()}
| {:add_cell_evaluation_output, client_id(), Cell.id(), term()} | {:add_cell_evaluation_output, client_id(), Cell.id(), term()}
@ -567,6 +569,32 @@ defmodule Livebook.Session.Data do
end end
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 def apply_operation(data, {:queue_cells_evaluation, _client_id, cell_ids, evaluation_opts}) do
cells_with_section = cells_with_section =
data.notebook data.notebook
@ -581,10 +609,11 @@ defmodule Livebook.Session.Data do
data data
|> with_actions() |> with_actions()
|> queue_prerequisite_cells_evaluation(cell_ids)
|> reduce(cells_with_section, fn data_actions, {cell, section} -> |> reduce(cells_with_section, fn data_actions, {cell, section} ->
queue_cell_evaluation(data_actions, cell, section, evaluation_opts) queue_cell_evaluation(data_actions, cell, section, evaluation_opts)
end) end)
|> queue_prerequisite_cells_evaluation(cell_ids)
|> maybe_queue_other_setup_cells(evaluation_opts)
|> maybe_connect_runtime(data) |> maybe_connect_runtime(data)
|> update_validity_and_evaluation() |> update_validity_and_evaluation()
|> wrap_ok() |> wrap_ok()
@ -719,8 +748,8 @@ defmodule Livebook.Session.Data do
true <- eval_info.validity in [:evaluated, :stale] do true <- eval_info.validity in [:evaluated, :stale] do
data data
|> with_actions() |> with_actions()
|> queue_prerequisite_cells_evaluation([cell.id])
|> queue_cell_evaluation(cell, section) |> queue_cell_evaluation(cell, section)
|> queue_prerequisite_cells_evaluation([cell.id])
|> maybe_evaluate_queued() |> maybe_evaluate_queued()
|> wrap_ok() |> wrap_ok()
else else
@ -1337,6 +1366,36 @@ defmodule Livebook.Session.Data do
end end
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 defp queue_cell_evaluation(data_actions, cell, section, evaluation_opts \\ []) do
data_actions data_actions
|> update_section_info!(section.id, fn section -> |> update_section_info!(section.id, fn section ->
@ -1431,10 +1490,13 @@ defmodule Livebook.Session.Data do
do: {cell_id, eval_info.snapshot}, do: {cell_id, eval_info.snapshot},
into: %{} into: %{}
enabled_languages = Notebook.enabled_languages(eval_data.notebook)
# We compute evaluation snapshot based on the notebook state prior # We compute evaluation snapshot based on the notebook state prior
# to evaluation, but using the information about the dependencies # to evaluation, but using the information about the dependencies
# obtained during evaluation (identifiers, inputs) # 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 data_actions
|> update_cell_eval_info!( |> update_cell_eval_info!(
@ -1481,8 +1543,27 @@ defmodule Livebook.Session.Data do
queue_prerequisite_cells_evaluation(data_actions, trailing_queued_cell_ids) queue_prerequisite_cells_evaluation(data_actions, trailing_queued_cell_ids)
end 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 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 if data.runtime_status == :connected do
main_flow_evaluating? = main_flow_evaluating?(data) main_flow_evaluating? = main_flow_evaluating?(data)
@ -1533,14 +1614,18 @@ defmodule Livebook.Session.Data do
end end
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 # When setup cell has been evaluated and is queued again, we need
# to reconnect the runtime to get a fresh evaluation environment # to reconnect the runtime to get a fresh evaluation environment
# for setup. We subsequently queue all cells that are currently # for setup. We subsequently queue all cells that are currently
# queued # queued
case data.cell_infos[Cell.setup_cell_id()].eval do setup_cell_evaluated_and_queued? =
%{status: :queued, validity: :evaluated} when data.runtime_status == :connected -> Enum.any?(data.notebook.setup_section.cells, fn cell ->
match?(%{status: :queued, validity: :evaluated}, data.cell_infos[cell.id].eval)
end)
if setup_cell_evaluated_and_queued? and data.runtime_status == :connected do
queued_cells_with_section = queued_cells_with_section =
data.notebook data.notebook
|> Notebook.evaluable_cells_with_section() |> Notebook.evaluable_cells_with_section()
@ -1557,15 +1642,14 @@ defmodule Livebook.Session.Data do
data_actions data_actions
|> disconnect_runtime() |> disconnect_runtime()
|> connect_runtime() |> connect_runtime()
|> queue_prerequisite_cells_evaluation(cell_ids)
|> reduce( |> reduce(
queued_cells_with_section, queued_cells_with_section,
fn data_actions, {cell, section, evaluation_opts} -> fn data_actions, {cell, section, evaluation_opts} ->
queue_cell_evaluation(data_actions, cell, section, evaluation_opts) queue_cell_evaluation(data_actions, cell, section, evaluation_opts)
end end
) )
|> queue_prerequisite_cells_evaluation(cell_ids)
_ -> else
data_actions data_actions
end end
end end
@ -1715,6 +1799,7 @@ defmodule Livebook.Session.Data do
|> Notebook.parent_cells_with_section(cell_ids) |> Notebook.parent_cells_with_section(cell_ids)
|> Enum.filter(fn {cell, _section} -> |> Enum.filter(fn {cell, _section} ->
info = data.cell_infos[cell.id] info = data.cell_infos[cell.id]
Cell.evaluable?(cell) and cell_outdated?(data, cell.id) and info.eval.status == :ready Cell.evaluable?(cell) and cell_outdated?(data, cell.id) and info.eval.status == :ready
end) end)
|> Enum.reverse() |> Enum.reverse()
@ -2550,9 +2635,11 @@ defmodule Livebook.Session.Data do
cells_with_section = Notebook.evaluable_cells_with_section(data.notebook) cells_with_section = Notebook.evaluable_cells_with_section(data.notebook)
enabled_languages = Notebook.enabled_languages(data.notebook)
cell_snapshots = cell_snapshots =
Enum.reduce(cells_with_section, %{}, fn {cell, section}, 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) put_in(cell_snapshots[cell.id], snapshot)
end) end)
@ -2564,9 +2651,15 @@ defmodule Livebook.Session.Data do
end) end)
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] 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 # Note that this is an implication of the Elixir runtime, we want
# to reevaluate as much as possible in a branch, rather than copying # to reevaluate as much as possible in a branch, rather than copying
# contexts between processes, because all structural sharing is # contexts between processes, because all structural sharing is
@ -2585,7 +2678,9 @@ defmodule Livebook.Session.Data do
) )
|> Enum.sort() |> 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) :erlang.phash2(deps)
end end
@ -2734,10 +2829,10 @@ defmodule Livebook.Session.Data do
cell_ids = for {cell, _section} <- cells_to_reevaluate, do: cell.id cell_ids = for {cell, _section} <- cells_to_reevaluate, do: cell.id
data_actions data_actions
|> queue_prerequisite_cells_evaluation(cell_ids)
|> reduce(cells_to_reevaluate, fn data_actions, {cell, section} -> |> reduce(cells_to_reevaluate, fn data_actions, {cell, section} ->
queue_cell_evaluation(data_actions, cell, section) queue_cell_evaluation(data_actions, cell, section)
end) end)
|> queue_prerequisite_cells_evaluation(cell_ids)
end end
defp app_update_execution_status({data, _} = data_actions) 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()) @spec cell_ids_for_full_evaluation(t(), list(Cell.id())) :: list(Cell.id())
def cell_ids_for_full_evaluation(data, forced_cell_ids) do def cell_ids_for_full_evaluation(data, forced_cell_ids) do
requires_reconnect? = requires_reconnect? =
data.cell_infos[Cell.setup_cell_id()].eval.validity == :evaluated and Enum.any?(data.notebook.setup_section.cells, fn cell ->
cell_outdated?(data, Cell.setup_cell_id()) 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) 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 defp button_classes(small, disabled, color, outlined) do
[ [
if small do if small do
"px-2 py-1 font-normal text-xs" "px-2 py-1 font-normal text-xs gap-1"
else else
"px-5 py-2 font-medium text-sm" "px-5 py-2 font-medium text-sm gap-1.5"
end, 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 if disabled do
"cursor-default pointer-events-none border-transparent bg-gray-100 text-gray-400" "cursor-default pointer-events-none border-transparent bg-gray-100 text-gray-400"
else else

View file

@ -44,82 +44,92 @@ defmodule LivebookWeb.NotebookComponents do
def cell_icon(assigns) def cell_icon(assigns)
def cell_icon(%{cell_type: :code, language: :elixir} = assigns) do def cell_icon(%{cell_type: :code} = assigns) do
~H""" ~H"""
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-purple-100"> <div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-gray-100">
<.language_icon language="elixir" class="w-full h-full text-[#663299]" /> <.language_icon language={Atom.to_string(@language)} class="w-full h-full text-gray-600" />
</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> </div>
""" """
end end
def cell_icon(%{cell_type: :markdown} = assigns) do def cell_icon(%{cell_type: :markdown} = assigns) do
~H""" ~H"""
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-blue-100"> <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-[#3e64ff]" /> <.language_icon language="markdown" class="w-full h-full text-gray-600" />
</div> </div>
""" """
end end
def cell_icon(%{cell_type: :smart} = assigns) do def cell_icon(%{cell_type: :smart} = assigns) do
~H""" ~H"""
<div class="flex w-6 h-6 bg-red-100 rounded items-center justify-center"> <div class="flex w-6 h-6 p-1 rounded items-center justify-center bg-gray-100">
<.remix_icon icon="flashlight-line text-red-900" /> <.remix_icon icon="flashlight-line text-gray-600" />
</div> </div>
""" """
end end
@doc """ @doc """
Renders an icon for the given language. 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 :language, :string, required: true
attr :class, :string, default: nil attr :class, :string, default: nil
def language_icon(%{language: "elixir"} = assigns) do def language_icon(%{language: "elixir"} = assigns) do
~H""" ~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 <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" fill="currentColor"
> 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"
</path> />
</svg> </svg>
""" """
end end
def language_icon(%{language: "erlang"} = assigns) do def language_icon(%{language: "erlang"} = assigns) do
~H""" ~H"""
<svg class={@class} viewBox="0 0 15 10" xmlns="http://www.w3.org/2000/svg"> <svg class={@class} viewBox="0 0 30 30" xmlns="http://www.w3.org/2000/svg">
<g fill="currentColor"> <path
<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" /> fill="currentColor"
<path d="M5.5 2.3c.1-1.2 1-2 2.1-2s1.9.8 2 2Zm0 0" /> 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"
</g> />
</svg> </svg>
""" """
end end
def language_icon(%{language: "markdown"} = assigns) do def language_icon(%{language: "markdown"} = assigns) do
~H""" ~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 <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" fill="currentColor"
d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"
/> />
</svg> </svg>
""" """
end 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 end

View file

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

View file

@ -225,7 +225,7 @@ defmodule LivebookWeb.HomeLive do
end end
def handle_params(%{}, _url, socket) when socket.assigns.live_action == :public_new_notebook do 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 end
def handle_params(_params, _url, socket), do: {:noreply, socket} 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 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} 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""" ~H"""
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-2" style="color: var(--ansi-color-red);"> <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} {:noreply, socket}
end 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 def handle_event("insert_cell_below", params, socket) do
{:noreply, insert_cell_below(socket, params)} {:noreply, insert_cell_below(socket, params)}
end end
@ -327,9 +339,8 @@ defmodule LivebookWeb.SessionLive do
end end
end end
def handle_event("set_default_language", %{"language" => language} = params, socket) def handle_event("set_default_language", %{"language" => language} = params, socket) do
when language in ["elixir", "erlang"] do language = language_to_string(language)
language = String.to_atom(language)
Session.set_notebook_attributes(socket.assigns.session.pid, %{default_language: language}) Session.set_notebook_attributes(socket.assigns.session.pid, %{default_language: language})
{:noreply, insert_cell_below(socket, params)} {:noreply, insert_cell_below(socket, params)}
end end
@ -548,6 +559,12 @@ defmodule LivebookWeb.SessionLive do
{:noreply, socket} {:noreply, socket}
end 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 def handle_event("save", %{}, socket) do
if socket.private.data.file do if socket.private.data.file do
Session.save(socket.assigns.session.pid) Session.save(socket.assigns.session.pid)
@ -1346,6 +1363,20 @@ defmodule LivebookWeb.SessionLive do
end end
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( defp after_operation(
socket, socket,
_prev_socket, _prev_socket,
@ -1471,11 +1502,9 @@ defmodule LivebookWeb.SessionLive do
defp cell_type_and_attrs_from_params(%{"type" => "code"} = params, socket) do defp cell_type_and_attrs_from_params(%{"type" => "code"} = params, socket) do
language = language =
case params["language"] do if language = params["language"] do
language when language in ["elixir", "erlang"] -> language_to_string(language)
String.to_atom(language) else
_ ->
socket.private.data.notebook.default_language socket.private.data.notebook.default_language
end end
@ -1558,7 +1587,7 @@ defmodule LivebookWeb.SessionLive do
defp confirm_setup_runtime(socket, reason) do defp confirm_setup_runtime(socket, reason) do
on_confirm = fn socket -> 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 socket
end end
@ -1616,7 +1645,7 @@ defmodule LivebookWeb.SessionLive do
defp add_dependencies_and_reevaluate(socket, dependencies) do defp add_dependencies_and_reevaluate(socket, dependencies) do
Session.add_dependencies(socket.assigns.session.pid, dependencies) 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) Session.queue_cells_reevaluation(socket.assigns.session.pid)
socket socket
end end
@ -1762,6 +1791,13 @@ defmodule LivebookWeb.SessionLive do
end) end)
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 # Builds view-specific structure of data by cherry-picking
# only the relevant attributes. # only the relevant attributes.
# We then use `@data_view` in the templates and consequently # We then use `@data_view` in the templates and consequently
@ -1804,11 +1840,10 @@ defmodule LivebookWeb.SessionLive do
data.clients_map data.clients_map
|> Enum.map(fn {client_id, user_id} -> {client_id, data.users_map[user_id]} end) |> 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), |> Enum.sort_by(fn {_client_id, user} -> user.name || "Anonymous" end),
installing?: data.cell_infos[Cell.setup_cell_id()].eval.status == :evaluating, enabled_languages: Notebook.enabled_languages(data.notebook),
setup_cell_view: %{ installing?: data.cell_infos[Cell.main_setup_cell_id()].eval.status == :evaluating,
cell_to_view(hd(data.notebook.setup_section.cells), data, changed_input_ids) setup_cell_views:
| type: :setup 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), section_views: section_views(data.notebook.sections, data, changed_input_ids),
bin_entries: data.bin_entries, bin_entries: data.bin_entries,
secrets: data.secrets, secrets: data.secrets,
@ -1913,6 +1948,7 @@ defmodule LivebookWeb.SessionLive do
%{ %{
id: cell.id, id: cell.id,
type: :code, type: :code,
setup: Cell.setup?(cell),
language: cell.language, language: cell.language,
empty: cell.source == "", empty: cell.source == "",
eval: eval_info_to_view(cell, info.eval, data, changed_input_ids), 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 data-el-cell
id={"cell-#{@cell_view.id}"} id={"cell-#{@cell_view.id}"}
data-type={@cell_view.type} data-type={@cell_view.type}
data-setup={@cell_view[:setup]}
data-focusable-id={@cell_view.id} data-focusable-id={@cell_view.id}
data-js-empty={@cell_view.empty} data-js-empty={@cell_view.empty}
data-eval-validity={get_in(@cell_view, [:eval, :validity])} data-eval-validity={get_in(@cell_view, [:eval, :validity])}
@ -86,56 +87,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
""" """
end end
defp render_cell(%{cell_view: %{type: :code}} = assigns) do defp render_cell(%{cell_view: %{type: :code, setup: true, language: :elixir}} = 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
~H""" ~H"""
<.cell_actions> <.cell_actions>
<:primary> <:primary>
@ -183,6 +135,111 @@ defmodule LivebookWeb.SessionLive.CellComponent do
""" """
end 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 defp render_cell(%{cell_view: %{type: :smart}} = assigns) do
~H""" ~H"""
<.cell_actions> <.cell_actions>
@ -581,6 +638,20 @@ defmodule LivebookWeb.SessionLive.CellComponent do
""" """
end 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 defp setup_cell_info(assigns) do
~H""" ~H"""
<span <span
@ -600,6 +671,25 @@ defmodule LivebookWeb.SessionLive.CellComponent do
""" """
end 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 :cell_id, :string, required: true
attr :tag, :string, required: true attr :tag, :string, required: true
attr :empty, :boolean, required: true attr :empty, :boolean, required: true
@ -680,24 +770,55 @@ defmodule LivebookWeb.SessionLive.CellComponent do
""" """
end end
attr :id, :string, required: true
attr :cell_view, :map, required: true
attr :langauge_toggle, :boolean, default: false
defp cell_indicators(assigns) do defp cell_indicators(assigns) do
~H""" ~H"""
<div class="flex gap-1"> <div class="flex gap-1">
<.cell_indicator :if={has_status?(@cell_view)}> <.cell_indicator :if={has_status?(@cell_view)}>
<.cell_status id={@id} cell_view={@cell_view} /> <.cell_status id={@id} cell_view={@cell_view} />
</.cell_indicator> </.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> <.cell_indicator>
<.language_icon language={cell_language(@cell_view)} class="w-3 h-3" /> <.language_icon language={cell_language(@cell_view)} class="w-3 h-3" />
</.cell_indicator> </.cell_indicator>
<% end %>
</div> </div>
""" """
end end
attr :class, :string, default: nil
slot :inner_block, required: true
defp cell_indicator(assigns) do defp cell_indicator(assigns) do
~H""" ~H"""
<div <div
data-el-cell-indicator 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)} {render_slot(@inner_block)}
</div> </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(%{type: :smart, status: :started, js_view: %{ref: ref}}), do: ref
defp smart_cell_js_view_ref(_cell_view), do: nil 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 end

View file

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

View file

@ -1350,19 +1350,40 @@ defmodule LivebookWeb.SessionLive.Render do
</div> </div>
</div> </div>
</div> </div>
<div> <div data-el-setup-section>
<div class="flex flex-col gap-2">
<.live_component <.live_component
:for={setup_cell_view <- @data_view.setup_cell_views}
module={LivebookWeb.SessionLive.CellComponent} module={LivebookWeb.SessionLive.CellComponent}
id={@data_view.setup_cell_view.id} id={setup_cell_view.id}
session_id={@session.id} session_id={@session.id}
session_pid={@session.pid} session_pid={@session.pid}
client_id={@client_id} client_id={@client_id}
runtime={@data_view.runtime} runtime={@data_view.runtime}
installing?={@data_view.installing?} installing?={@data_view.installing?}
allowed_uri_schemes={@allowed_uri_schemes} allowed_uri_schemes={@allowed_uri_schemes}
cell_view={@data_view.setup_cell_view} enabled_languages={@data_view.enabled_languages}
cell_view={setup_cell_view}
/> />
</div> </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 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"> <div :if={@data_view.section_views == []} class="flex justify-center">
<LivebookWeb.SessionLive.InsertButtonsComponent.insert_button phx-click="append_section"> <LivebookWeb.SessionLive.InsertButtonsComponent.insert_button phx-click="append_section">
@ -1384,6 +1405,7 @@ defmodule LivebookWeb.SessionLive.Render do
allowed_uri_schemes={@allowed_uri_schemes} allowed_uri_schemes={@allowed_uri_schemes}
section_view={section_view} section_view={section_view}
default_language={@data_view.default_language} default_language={@data_view.default_language}
enabled_languages={@data_view.enabled_languages}
/> />
<div style="height: 80vh"></div> <div style="height: 80vh"></div>
</div> </div>

View file

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

View file

@ -124,6 +124,8 @@ defmodule Livebook.MixProject do
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:floki, ">= 0.27.0", only: :test}, {:floki, ">= 0.27.0", only: :test},
{:bypass, "~> 2.1", 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 # ZTA deps
{:jose, "~> 1.11.5"}, {:jose, "~> 1.11.5"},
{:req, "~> 0.5.8"}, {: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"}, "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"}, "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"}, "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": {: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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"pluggable": {:hex, :pluggable, "1.1.0", "7eba3bc70c0caf4d9056c63c882df8862f7534f0145da7ab3a47ca73e4adb1e4", [:mix], [], "hexpm", "d12eb00ea47b21e92cd2700d6fbe3737f04b64e71b63aad1c0accde87c751637"}, "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"}, "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"}, "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"}, "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"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},

View file

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

View file

@ -91,6 +91,13 @@ defmodule Livebook.LiveMarkdown.ExportTest do
source: """ source: """
lists:seq(1, 10).\ lists:seq(1, 10).\
""" """
},
%{
Notebook.Cell.new(:code)
| language: :python,
source: """
range(0, 10)\
"""
} }
] ]
} }
@ -149,6 +156,10 @@ defmodule Livebook.LiveMarkdown.ExportTest do
```erlang ```erlang
lists:seq(1, 10). lists:seq(1, 10).
``` ```
```python
range(0, 10)
```
""" """
{document, []} = Export.notebook_to_livemd(notebook) {document, []} = Export.notebook_to_livemd(notebook)
@ -1131,7 +1142,7 @@ defmodule Livebook.LiveMarkdown.ExportTest do
| name: "My Notebook", | name: "My Notebook",
sections: [%{Notebook.Section.new() | name: "Section 1"}] 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 = """ expected_document = """
# My Notebook # My Notebook
@ -1147,6 +1158,60 @@ defmodule Livebook.LiveMarkdown.ExportTest do
assert expected_document == document assert expected_document == document
end 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 end
describe "notebook stamp" do describe "notebook stamp" do

View file

@ -60,6 +60,10 @@ defmodule Livebook.LiveMarkdown.ImportTest do
```erlang ```erlang
lists:seq(1, 10). lists:seq(1, 10).
``` ```
```python
range(0, 10)
```
""" """
{notebook, %{warnings: []}} = Import.notebook_from_livemd(markdown) {notebook, %{warnings: []}} = Import.notebook_from_livemd(markdown)
@ -139,6 +143,12 @@ defmodule Livebook.LiveMarkdown.ImportTest do
source: """ source: """
lists:seq(1, 10).\ lists:seq(1, 10).\
""" """
},
%Cell.Code{
language: :python,
source: """
range(0, 10)\
"""
} }
] ]
} }
@ -1140,6 +1150,56 @@ defmodule Livebook.LiveMarkdown.ImportTest do
sections: [] sections: []
} = notebook } = notebook
end 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 end
describe "notebook stamp" do describe "notebook stamp" do

View file

@ -115,7 +115,7 @@ defmodule Livebook.Notebook.Export.ElixirTest do
| name: "My Notebook", | name: "My Notebook",
sections: [%{Notebook.Section.new() | name: "Section 1"}] 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 = """ expected_document = """
# Run as: iex --dot-iex path/to/notebook.exs # Run as: iex --dot-iex path/to/notebook.exs
@ -176,4 +176,78 @@ defmodule Livebook.Notebook.Export.ElixirTest do
assert expected_document == document assert expected_document == document
end 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 end

View file

@ -6,6 +6,20 @@ defmodule Livebook.Runtime.EvaluatorTest do
@moduletag :tmp_dir @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 setup ctx do
ebin_path = ebin_path =
if ctx[:with_ebin_path] do if ctx[:with_ebin_path] do
@ -1377,7 +1391,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
end end
describe "erlang evaluation" do describe "erlang evaluation" do
test "evaluate erlang code", %{evaluator: evaluator} do test "evaluates erlang code", %{evaluator: evaluator} do
Evaluator.evaluate_code( Evaluator.evaluate_code(
evaluator, evaluator,
:erlang, :erlang,
@ -1390,7 +1404,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
end end
@tag :with_ebin_path @tag :with_ebin_path
test "evaluate erlang-module code", %{evaluator: evaluator} do test "evaluates erlang-module code", %{evaluator: evaluator} do
code = """ code = """
-module(tryme). -module(tryme).
@ -1410,7 +1424,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
end end
@tag tmp_dir: false @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 = """ code = """
-module(tryme). -module(tryme).
@ -1425,7 +1439,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
end end
@tag :with_ebin_path @tag :with_ebin_path
test "evaluate erlang-module error", %{ test "evaluates erlang-module error", %{
evaluator: evaluator evaluator: evaluator
} do } do
code = """ code = """
@ -1570,6 +1584,78 @@ defmodule Livebook.Runtime.EvaluatorTest do
end end
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 describe "formatting" do
test "gracefully handles errors in the inspect protocol", %{evaluator: evaluator} do test "gracefully handles errors in the inspect protocol", %{evaluator: evaluator} do
code = "%Livebook.TestModules.BadInspect{}" 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: [] code_markers: []
} }
@setup_id Notebook.Cell.main_setup_cell_id()
describe "file_name_for_download/1" do describe "file_name_for_download/1" do
@tag :tmp_dir @tag :tmp_dir
test "uses associated file name if one is attached", %{tmp_dir: tmp_dir} do test "uses associated file name if one is attached", %{tmp_dir: tmp_dir} do
@ -130,6 +132,58 @@ defmodule Livebook.SessionTest do
end end
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 describe "recover_smart_cell/2" do
test "sends a recover operations to subscribers and starts the smart cell" do test "sends a recover operations to subscribers and starts the smart cell" do
smart_cell = %{Notebook.Cell.new(:smart) | kind: "text", source: "content"} 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: []}]) Session.add_dependencies(session.pid, [%{dep: {:req, "~> 0.5.0"}, config: []}])
assert_receive {:operation, assert_receive {:operation,
{:apply_cell_delta, "__server__", "setup", :primary, _delta, _selection, 0}} {:apply_cell_delta, "__server__", @setup_id, :primary, _delta, _selection,
0}}
assert %{ assert %{
notebook: %{ notebook: %{
@ -244,7 +299,7 @@ defmodule Livebook.SessionTest do
end end
test "broadcasts an error if modifying the setup source fails" do 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 = start_session(notebook: notebook)
Session.subscribe(session.id) Session.subscribe(session.id)
@ -1121,7 +1176,7 @@ defmodule Livebook.SessionTest do
Session.queue_cell_evaluation(session.pid, smart_cell.id) 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 session_pid = session.pid
assert_receive {:ping, ^session_pid, metadata, %{ref: "ref"}} assert_receive {:ping, ^session_pid, metadata, %{ref: "ref"}}
@ -1159,11 +1214,11 @@ defmodule Livebook.SessionTest do
{:connect_runtime, self()}, {:connect_runtime, self()},
{:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()}, {:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()},
{:queue_cells_evaluation, self(), ["c1"], []}, {: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} {: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) Session.parent_locators_for_cell(data, cell3)
end end
@ -1190,11 +1245,12 @@ defmodule Livebook.SessionTest do
{:connect_runtime, self()}, {:connect_runtime, self()},
{:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()}, {:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()},
{:queue_cells_evaluation, self(), ["c1"], []}, {: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} {: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 end
test "given cell in main flow returns an empty list if there is no previous cell" do 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()}, {:connect_runtime, self()},
{:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()}, {:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()},
{:queue_cells_evaluation, self(), ["c1"], []}, {: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} {: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) Session.parent_locators_for_cell(data, cell3)
data = data =

View file

@ -619,6 +619,72 @@ defmodule LivebookWeb.SessionLiveTest do
assert_receive {:runtime_file_path_reply, {:ok, path}} assert_receive {:runtime_file_path_reply, {:ok, path}}
assert File.read!(path) == "content" assert File.read!(path) == "content"
end 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 end
describe "outputs" do describe "outputs" do
@ -2882,4 +2948,32 @@ defmodule LivebookWeb.SessionLiveTest do
after after
Code.put_compiler_option(:debug_info, false) Code.put_compiler_option(:debug_info, false)
end 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 end

View file

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