From 015b44fb728859d37184b7fe63ff7ac779abda30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Tue, 18 Feb 2025 14:28:29 +0100 Subject: [PATCH] Add support for Python cells (#2936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- assets/css/js_interop.css | 77 ++-- assets/js/hooks/cell.js | 9 +- assets/js/hooks/cell_editor.js | 12 + assets/js/hooks/cell_editor/live_editor.js | 60 ++- .../live_editor/codemirror/languages.js | 9 + assets/js/lib/notebook.js | 4 +- lib/livebook/live_markdown/export.ex | 17 +- lib/livebook/live_markdown/import.ex | 31 +- lib/livebook/notebook.ex | 65 ++- lib/livebook/notebook/cell.ex | 21 +- lib/livebook/notebook/cell/code.ex | 14 +- lib/livebook/notebook/export/elixir.ex | 40 +- lib/livebook/runtime.ex | 11 +- lib/livebook/runtime/definitions.ex | 4 + .../runtime/erl_dist/runtime_server.ex | 2 +- lib/livebook/runtime/evaluator.ex | 144 ++++++- lib/livebook/runtime/evaluator/formatter.ex | 76 +++- lib/livebook/session.ex | 75 +++- lib/livebook/session/data.ex | 168 ++++++-- .../components/core_components.ex | 6 +- .../components/notebook_components.ex | 76 ++-- lib/livebook_web/helpers/session_helpers.ex | 8 +- lib/livebook_web/live/home_live.ex | 2 +- lib/livebook_web/live/output.ex | 2 +- lib/livebook_web/live/session_live.ex | 68 ++- .../live/session_live/cell_component.ex | 236 +++++++--- .../session_live/insert_buttons_component.ex | 21 +- lib/livebook_web/live/session_live/render.ex | 46 +- .../live/session_live/section_component.ex | 1 + mix.exs | 2 + mix.lock | 3 + test/livebook/apps/deployer_test.exs | 42 +- test/livebook/live_markdown/export_test.exs | 67 ++- test/livebook/live_markdown/import_test.exs | 60 +++ test/livebook/notebook/export/elixir_test.exs | 76 +++- test/livebook/runtime/evaluator_test.exs | 94 +++- test/livebook/session/data_test.exs | 405 ++++++++++++------ test/livebook/session_test.exs | 74 +++- test/livebook_web/live/session_live_test.exs | 94 ++++ test/support/session_helpers.ex | 4 +- 40 files changed, 1755 insertions(+), 471 deletions(-) diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index 33e746863..b0f426d46 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -124,33 +124,64 @@ server in solely client-side operations. } } - [data-el-cell][data-type="setup"]:not( - [data-js-focused], - [data-eval-validity="fresh"]:not([data-js-empty]), - [data-eval-errored], - [data-js-changed] + /* When all setup cells satisfy collapse conditions, collapse the + first one and hide the later ones */ + [data-el-setup-section]:not( + :has( + [data-el-cell][data-setup]:is( + [data-js-focused], + [data-eval-validity="fresh"]:not([data-js-empty]), + [data-eval-errored], + [data-js-changed] + ) + ), + :focus-within ) { - [data-el-editor-box] { + [data-el-cell][data-setup]:not(:first-child) { @apply hidden; } - [data-el-outputs-container] { - @apply hidden; + [data-el-cell][data-setup]:first-child { + [data-el-editor-box] { + @apply hidden; + } + + [data-el-outputs-container] { + @apply hidden; + } + + [data-el-cell-indicator] { + @apply bg-gray-50 border-gray-200 text-gray-500; + } } - [data-el-cell-indicator] { - @apply bg-gray-50 border-gray-200 text-gray-500; + [data-el-language-buttons] { + @apply hidden; } } - [data-el-cell][data-type="setup"]:is( - [data-js-focused], - [data-eval-validity="fresh"]:not([data-js-empty]), - [data-eval-errored], - [data-js-changed] - ) - [data-el-info-box] { - @apply hidden; + /* This is "else" for the above */ + [data-el-setup-section]:is( + :has( + [data-el-cell][data-setup]:is( + [data-js-focused], + [data-eval-validity="fresh"]:not([data-js-empty]), + [data-eval-errored], + [data-js-changed] + ) + ), + :focus-within + ) { + [data-el-cell][data-setup] { + [data-el-info-box] { + @apply hidden; + } + + /* Make the primary actions visible for all cells */ + [data-el-actions][data-primary] { + @apply opacity-100; + } + } } /* Outputs */ @@ -299,13 +330,11 @@ server in solely client-side operations. } &[data-js-hide-code] { - [data-el-cell]:is( - [data-type="code"], - [data-type="setup"], - [data-type="smart"] - ):not([data-js-insert-mode]) { + [data-el-cell]:is([data-type="code"], [data-type="smart"]):not( + [data-js-insert-mode] + ) { [data-el-editor-box], - &[data-type="setup"] [data-el-info-box], + &[data-setup] [data-el-info-box], &[data-type="smart"] [data-el-ui-box] { @apply hidden; } diff --git a/assets/js/hooks/cell.js b/assets/js/hooks/cell.js index 7725b4a81..f5f6b4577 100644 --- a/assets/js/hooks/cell.js +++ b/assets/js/hooks/cell.js @@ -41,10 +41,11 @@ const Cell = { // Setup action handlers - if (["code", "smart"].includes(this.props.type)) { - const amplifyButton = this.el.querySelector( - `[data-el-amplify-outputs-button]`, - ); + const amplifyButton = this.el.querySelector( + `[data-el-amplify-outputs-button]`, + ); + + if (amplifyButton) { amplifyButton.addEventListener("click", (event) => { this.el.toggleAttribute("data-js-amplified"); }); diff --git a/assets/js/hooks/cell_editor.js b/assets/js/hooks/cell_editor.js index b491eb6ac..2cbccea51 100644 --- a/assets/js/hooks/cell_editor.js +++ b/assets/js/hooks/cell_editor.js @@ -92,6 +92,18 @@ const CellEditor = { this.el.querySelector(`[data-el-editor-container]`).removeAttribute("id"); }, + updated() { + const prevProps = this.props; + this.props = this.getProps(); + + if ( + this.props.language !== prevProps.language || + this.props.intellisense !== prevProps.intellisense + ) { + this.liveEditor.setLanguage(this.props.language, this.props.intellisense); + } + }, + destroyed() { if (this.connection) { this.connection.destroy(); diff --git a/assets/js/hooks/cell_editor/live_editor.js b/assets/js/hooks/cell_editor/live_editor.js index 8dbc6918c..40df9aa4d 100644 --- a/assets/js/hooks/cell_editor/live_editor.js +++ b/assets/js/hooks/cell_editor/live_editor.js @@ -10,7 +10,7 @@ import { lineNumbers, highlightActiveLineGutter, } from "@codemirror/view"; -import { EditorState, EditorSelection } from "@codemirror/state"; +import { EditorState, EditorSelection, Compartment } from "@codemirror/state"; import { indentOnInput, bracketMatching, @@ -236,6 +236,15 @@ export default class LiveEditor { this.deltaSubscription.destroy(); } + setLanguage(language, intellisense) { + this.language = language; + this.intellisense = intellisense; + + this.view.dispatch({ + effects: this.languageCompartment.reconfigure(this.languageExtensions()), + }); + } + /** * Either adds or updates doctest indicators. */ @@ -322,13 +331,6 @@ export default class LiveEditor { }, }); - const lineWrappingEnabled = - this.language === "markdown" && settings.editor_markdown_word_wrap; - - const language = - this.language && - LanguageDescription.matchLanguageName(languages, this.language, false); - const customKeymap = [ { key: "Escape", run: exitMulticursor }, { key: "Alt-Enter", run: insertBlankLineAndCloseHints }, @@ -338,6 +340,8 @@ export default class LiveEditor { this.handleViewUpdate(update), ); + this.languageCompartment = new Compartment(); + this.view = new EditorView({ parent: this.container, doc: this.source, @@ -365,7 +369,6 @@ export default class LiveEditor { keymap.of(vscodeKeymap), EditorState.tabSize.of(2), EditorState.lineSeparator.of("\n"), - lineWrappingEnabled ? EditorView.lineWrapping : [], // We bind tab to actions within the editor, which would trap // the user if they tabbed into the editor, so we remove it // from the tab navigation @@ -379,19 +382,9 @@ export default class LiveEditor { activateOnTyping: settings.editor_auto_completion, defaultKeymap: false, }), - this.intellisense - ? [ - autocompletion({ override: [this.completionSource.bind(this)] }), - hoverDetails(this.docsHoverTooltipSource.bind(this)), - signature(this.signatureSource.bind(this), { - activateOnTyping: settings.editor_auto_signature, - }), - formatter(this.formatterSource.bind(this)), - ] - : [], settings.editor_mode === "vim" ? [vim()] : [], settings.editor_mode === "emacs" ? [emacs()] : [], - language ? language.support : [], + this.languageCompartment.of(this.languageExtensions()), EditorView.domEventHandlers({ click: this.handleEditorClick.bind(this), keydown: this.handleEditorKeydown.bind(this), @@ -404,6 +397,33 @@ export default class LiveEditor { }); } + /** @private */ + languageExtensions() { + const settings = settingsStore.get(); + + const lineWrappingEnabled = + this.language === "markdown" && settings.editor_markdown_word_wrap; + + const language = + this.language && + LanguageDescription.matchLanguageName(languages, this.language, false); + + return [ + lineWrappingEnabled ? EditorView.lineWrapping : [], + language ? language.support : [], + this.intellisense + ? [ + autocompletion({ override: [this.completionSource.bind(this)] }), + hoverDetails(this.docsHoverTooltipSource.bind(this)), + signature(this.signatureSource.bind(this), { + activateOnTyping: settings.editor_auto_signature, + }), + formatter(this.formatterSource.bind(this)), + ] + : [], + ]; + } + /** @private */ handleEditorClick(event) { const cmd = isMacOS() ? event.metaKey : event.ctrlKey; diff --git a/assets/js/hooks/cell_editor/live_editor/codemirror/languages.js b/assets/js/hooks/cell_editor/live_editor/codemirror/languages.js index e2c488360..fc48abf48 100644 --- a/assets/js/hooks/cell_editor/live_editor/codemirror/languages.js +++ b/assets/js/hooks/cell_editor/live_editor/codemirror/languages.js @@ -15,6 +15,7 @@ import { javascript } from "@codemirror/lang-javascript"; import { erlang } from "@codemirror/legacy-modes/mode/erlang"; import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile"; import { shell } from "@codemirror/legacy-modes/mode/shell"; +import { toml } from "@codemirror/legacy-modes/mode/toml"; import { elixir } from "codemirror-lang-elixir"; export const elixirDesc = LanguageDescription.of({ @@ -77,6 +78,12 @@ const shellDesc = LanguageDescription.of({ support: new LanguageSupport(StreamLanguage.define(shell)), }); +const tomlDesc = LanguageDescription.of({ + name: "TOML", + alias: ["pyproject.toml"], + support: new LanguageSupport(StreamLanguage.define(toml)), +}); + const markdownDesc = LanguageDescription.of({ name: "Markdown", support: markdown({ @@ -94,6 +101,7 @@ const markdownDesc = LanguageDescription.of({ javascriptDesc, dockerfileDesc, shellDesc, + tomlDesc, ], }), }); @@ -111,5 +119,6 @@ export const languages = [ javascriptDesc, dockerfileDesc, shellDesc, + tomlDesc, markdownDesc, ]; diff --git a/assets/js/lib/notebook.js b/assets/js/lib/notebook.js index 770adfd54..e785f4f0d 100644 --- a/assets/js/lib/notebook.js +++ b/assets/js/lib/notebook.js @@ -2,12 +2,12 @@ * Checks if the given cell type is eligible for evaluation. */ export function isEvaluable(cellType) { - return ["code", "smart", "setup"].includes(cellType); + return ["code", "smart"].includes(cellType); } /** * Checks if the given cell type has primary editable editor. */ export function isDirectlyEditable(cellType) { - return ["markdown", "code", "setup"].includes(cellType); + return ["markdown", "code"].includes(cellType); } diff --git a/lib/livebook/live_markdown/export.ex b/lib/livebook/live_markdown/export.ex index 557f05278..83be376a3 100644 --- a/lib/livebook/live_markdown/export.ex +++ b/lib/livebook/live_markdown/export.ex @@ -56,7 +56,7 @@ defmodule Livebook.LiveMarkdown.Export do end defp render_notebook(notebook, ctx) do - %{setup_section: %{cells: [setup_cell]}} = notebook + %{setup_section: %{cells: setup_cells}} = notebook comments = Enum.map(notebook.leading_comments, fn @@ -65,13 +65,13 @@ defmodule Livebook.LiveMarkdown.Export do end) name = ["# ", notebook.name] - setup_cell = render_setup_cell(setup_cell, %{ctx | include_outputs?: false}) + setup_cells = render_setup_cells(setup_cells, %{ctx | include_outputs?: false}) sections = Enum.map(notebook.sections, &render_section(&1, notebook, ctx)) metadata = notebook_metadata(notebook) notebook_with_metadata = - [name, setup_cell | sections] + [name | setup_cells ++ sections] |> Enum.reject(&is_nil/1) |> Enum.intersperse("\n\n") |> prepend_metadata(metadata) @@ -175,8 +175,13 @@ defmodule Livebook.LiveMarkdown.Export do %{"branch_parent_index" => parent_idx} end - defp render_setup_cell(%{source: ""}, _ctx), do: nil - defp render_setup_cell(cell, ctx), do: render_cell(cell, ctx) + defp render_setup_cells([%{source: ""}], _ctx), do: [] + + defp render_setup_cells(cells, ctx) do + Enum.map(cells, fn cell -> + render_cell(cell, ctx) + end) + end defp render_cell(%Cell.Markdown{} = cell, _ctx) do metadata = cell_metadata(cell) @@ -322,7 +327,7 @@ defmodule Livebook.LiveMarkdown.Export do defp add_markdown_annotation_before_elixir_block(ast) do Enum.flat_map(ast, fn {"pre", _, [{"code", [{"class", language}], [_source], %{}}], %{}} = ast_node - when language in ["elixir", "erlang"] -> + when language in ["elixir", "erlang", "python", "pyproject.toml"] -> [{:comment, [], [~s/livebook:{"force_markdown":true}/], %{comment: true}}, ast_node] ast_node -> diff --git a/lib/livebook/live_markdown/import.ex b/lib/livebook/live_markdown/import.ex index 42849b5de..c0ab4782c 100644 --- a/lib/livebook/live_markdown/import.ex +++ b/lib/livebook/live_markdown/import.ex @@ -162,9 +162,11 @@ defmodule Livebook.LiveMarkdown.Import do [{"pre", _, [{"code", [{"class", language}], [source], %{}}], %{}} | ast], elems ) - when language in ["elixir", "erlang"] do + when language in ["elixir", "erlang", "python", "pyproject.toml"] do {outputs, ast} = take_outputs(ast, []) + language = String.to_atom(language) + group_elements(ast, [{:cell, :code, language, source, outputs} | elems]) end @@ -358,13 +360,22 @@ defmodule Livebook.LiveMarkdown.Import do messages ++ [@unknown_hub_message]} end - # We identify a single leading cell as the setup cell, in any - # other case all extra cells are put in a default section - {setup_cell, extra_sections} = + # Check if the remaining cells form a valid setup section, otherwise + # we put them into a default section instead + {setup_cells, extra_sections} = case cells do - [] -> {nil, []} - [%Notebook.Cell.Code{} = setup_cell] when name != nil -> {setup_cell, []} - extra_cells -> {nil, [%{Notebook.Section.new() | cells: extra_cells}]} + [%Notebook.Cell.Code{language: :elixir}] when name != nil -> + {cells, []} + + [%Notebook.Cell.Code{language: :elixir}, %Notebook.Cell.Code{language: :"pyproject.toml"}] + when name != nil -> + {cells, []} + + [] -> + {nil, []} + + extra_cells -> + {nil, [%{Notebook.Section.new() | cells: extra_cells}]} end notebook = @@ -375,7 +386,7 @@ defmodule Livebook.LiveMarkdown.Import do output_counter: output_counter } |> maybe_put_name(name) - |> maybe_put_setup_cell(setup_cell) + |> maybe_put_setup_cells(setup_cells) |> Map.merge(attrs) {notebook, valid_hub?, messages} @@ -384,8 +395,8 @@ defmodule Livebook.LiveMarkdown.Import do defp maybe_put_name(notebook, nil), do: notebook defp maybe_put_name(notebook, name), do: %{notebook | name: name} - defp maybe_put_setup_cell(notebook, nil), do: notebook - defp maybe_put_setup_cell(notebook, cell), do: Notebook.put_setup_cell(notebook, cell) + defp maybe_put_setup_cells(notebook, nil), do: notebook + defp maybe_put_setup_cells(notebook, cells), do: Notebook.put_setup_cells(notebook, cells) # Takes optional leading metadata JSON object and returns {metadata, rest}. defp grab_metadata([{:metadata, metadata} | elems]) do diff --git a/lib/livebook/notebook.ex b/lib/livebook/notebook.ex index 40c337fc6..f92c4bd55 100644 --- a/lib/livebook/notebook.ex +++ b/lib/livebook/notebook.ex @@ -41,13 +41,13 @@ defmodule Livebook.Notebook do leading_comments: list(list(line :: String.t())), persist_outputs: boolean(), autosave_interval_s: non_neg_integer() | nil, - default_language: :elixir | :erlang, + default_language: :elixir | :erlang | :python, output_counter: non_neg_integer(), app_settings: AppSettings.t(), hub_id: String.t(), hub_secret_names: list(String.t()), file_entries: list(file_entry()), - quarantine_file_entry_names: MapSet.new(String.t()), + quarantine_file_entry_names: MapSet.t(), teams_enabled: boolean(), deployment_group_id: String.t() | nil } @@ -116,15 +116,62 @@ defmodule Livebook.Notebook do teams_enabled: false, deployment_group_id: nil } - |> put_setup_cell(Cell.new(:code)) + |> put_setup_cells([Cell.new(:code)]) end @doc """ - Sets the given cell as the setup cell. + Sets the given cells as the setup section cells. """ - @spec put_setup_cell(t(), Cell.Code.t()) :: t() - def put_setup_cell(notebook, %Cell.Code{} = cell) do - put_in(notebook.setup_section.cells, [%{cell | id: Cell.setup_cell_id()}]) + @spec put_setup_cells(t(), list(Cell.Code.t())) :: t() + def put_setup_cells(notebook, [main_setup_cell | setup_cells]) do + put_in(notebook.setup_section.cells, [ + %{main_setup_cell | id: Cell.main_setup_cell_id()} + | Enum.map(setup_cells, &%{&1 | id: Cell.extra_setup_cell_id(&1.language)}) + ]) + end + + @doc """ + Returns the list of languages used by the notebook. + """ + @spec enabled_languages(t()) :: list(atom()) + def enabled_languages(notebook) do + python_setup_cell_id = Cell.extra_setup_cell_id(:"pyproject.toml") + python_enabled? = Enum.any?(notebook.setup_section.cells, &(&1.id == python_setup_cell_id)) + if(python_enabled?, do: [:python], else: []) ++ [:elixir, :erlang] + end + + @doc """ + Adds extra setup cell specific to the given language. + """ + @spec add_extra_setup_cell(t(), atom()) :: t() + def add_extra_setup_cell(notebook, language) + + def add_extra_setup_cell(notebook, :python) do + cell = %{ + Cell.new(:code) + | id: Cell.extra_setup_cell_id(:"pyproject.toml"), + language: :"pyproject.toml", + source: """ + [project] + name = "project" + version = "0.0.0" + requires-python = "==3.13.*" + dependencies = []\ + """ + } + + update_in(notebook.setup_section.cells, &(&1 ++ [cell])) + end + + @doc """ + Retrieves extra setup cell specific to the given language. + """ + @spec get_extra_setup_cell(t(), atom()) :: Cell.Code.t() + def get_extra_setup_cell(notebook, language) + + def get_extra_setup_cell(notebook, :python) do + id = Cell.extra_setup_cell_id(:"pyproject.toml") + Enum.find(notebook.setup_section.cells, &(&1.id == id)) end @doc """ @@ -272,7 +319,7 @@ defmodule Livebook.Notebook do def delete_cell(notebook, cell_id) do {_, notebook} = pop_in(notebook, [ - Access.key(:sections), + access_all_sections(), Access.all(), Access.key(:cells), access_by_id(cell_id) @@ -791,7 +838,7 @@ defmodule Livebook.Notebook do Recursively adds index to all outputs, including frames. """ @spec index_outputs(list(Livebook.Runtime.output()), non_neg_integer()) :: - {list(Cell.index_output()), non_neg_integer()} + {list(Cell.indexed_output()), non_neg_integer()} def index_outputs(outputs, counter) do Enum.map_reduce(outputs, counter, &index_output/2) end diff --git a/lib/livebook/notebook/cell.ex b/lib/livebook/notebook/cell.ex index 61c1f3274..708d0a9fd 100644 --- a/lib/livebook/notebook/cell.ex +++ b/lib/livebook/notebook/cell.ex @@ -16,6 +16,9 @@ defmodule Livebook.Notebook.Cell do @type indexed_output :: {non_neg_integer(), Livebook.Runtime.output()} + @setup_cell_id_prefix "setup" + @setup_cell_id "setup" + @doc """ Returns an empty cell of the given type. """ @@ -88,20 +91,24 @@ defmodule Livebook.Notebook.Cell do def find_assets_in_output(_output), do: [] - @setup_cell_id "setup" - @doc """ - Checks if the given cell is the setup code cell. + Checks if the given cell is any of the setup code cells. """ @spec setup?(t()) :: boolean() def setup?(cell) - def setup?(%Cell.Code{id: @setup_cell_id}), do: true + def setup?(%Cell.Code{id: @setup_cell_id_prefix <> _}), do: true def setup?(_cell), do: false @doc """ - The fixed identifier of the setup cell. + The fixed identifier of the main setup cell. """ - @spec setup_cell_id() :: id() - def setup_cell_id(), do: @setup_cell_id + @spec main_setup_cell_id() :: id() + def main_setup_cell_id(), do: @setup_cell_id + + @doc """ + The identifier of extra setup cell for the given language. + """ + @spec extra_setup_cell_id(atom()) :: id() + def extra_setup_cell_id(language), do: "#{@setup_cell_id_prefix}-#{language}" end diff --git a/lib/livebook/notebook/cell/code.ex b/lib/livebook/notebook/cell/code.ex index 168f01220..c1c392637 100644 --- a/lib/livebook/notebook/cell/code.ex +++ b/lib/livebook/notebook/cell/code.ex @@ -20,7 +20,7 @@ defmodule Livebook.Notebook.Cell.Code do id: Cell.id(), source: String.t() | :__pruned__, outputs: list(Cell.indexed_output()), - language: :elixir | :erlang, + language: Livebook.Runtime.language(), reevaluate_automatically: boolean(), continue_on_error: boolean() } @@ -39,4 +39,16 @@ defmodule Livebook.Notebook.Cell.Code do continue_on_error: false } end + + @doc """ + Return the list of supported langauges for code cells. + """ + @spec languages() :: list(%{name: String.t(), language: atom()}) + def languages() do + [ + %{name: "Elixir", language: :elixir}, + %{name: "Erlang", language: :erlang}, + %{name: "Python", language: :python} + ] + end end diff --git a/lib/livebook/notebook/export/elixir.ex b/lib/livebook/notebook/export/elixir.ex index 29c46a67e..40b94426e 100644 --- a/lib/livebook/notebook/export/elixir.ex +++ b/lib/livebook/notebook/export/elixir.ex @@ -13,15 +13,15 @@ defmodule Livebook.Notebook.Export.Elixir do end defp render_notebook(notebook) do - %{setup_section: %{cells: [setup_cell]} = setup_section} = notebook + %{setup_section: %{cells: setup_cells} = setup_section} = notebook prelude = "# Run as: iex --dot-iex path/to/notebook.exs" name = ["# Title: ", notebook.name] - setup_cell = render_setup_cell(setup_cell, setup_section) + setup_cells = render_setup_cells(setup_cells, setup_section) sections = Enum.map(notebook.sections, &render_section(&1, notebook)) - [prelude, name, setup_cell | sections] + [prelude, name | setup_cells ++ sections] |> Enum.reject(&is_nil/1) |> Enum.intersperse("\n\n") end @@ -46,8 +46,13 @@ defmodule Livebook.Notebook.Export.Elixir do |> Enum.intersperse("\n\n") end - defp render_setup_cell(%{source: ""}, _section), do: nil - defp render_setup_cell(cell, section), do: render_cell(cell, section) + defp render_setup_cells([%{source: ""}], _section), do: [] + + defp render_setup_cells(cells, section) do + Enum.map(cells, fn cell -> + render_cell(cell, section) + end) + end defp render_cell(%Cell.Markdown{} = cell, _section) do cell.source @@ -66,6 +71,31 @@ defmodule Livebook.Notebook.Export.Elixir do end end + defp render_cell(%Cell.Code{language: :"pyproject.toml"} = cell, section) do + code = + {:__block__, [], + [ + {{:., [], [{:__aliases__, [alias: false], [:Pythonx]}, :uv_init]}, [], + [{:<<>>, [delimiter: ~s["""]], [cell.source <> "\n"]}]}, + {:import, [], [{:__aliases__, [], [:Pythonx]}]} + ]} + |> Code.quoted_to_algebra() + |> Inspect.Algebra.format(90) + |> IO.iodata_to_binary() + + render_cell(%{cell | language: :elixir, source: code}, section) + end + + defp render_cell(%Cell.Code{language: :python} = cell, section) do + code = + {:sigil_PY, [delimiter: ~s["""]], [{:<<>>, [], [cell.source <> "\n"]}, []]} + |> Code.quoted_to_algebra() + |> Inspect.Algebra.format(90) + |> IO.iodata_to_binary() + + render_cell(%{cell | language: :elixir, source: code}, section) + end + defp render_cell(%Cell.Code{} = cell, _section) do code = cell.source diff --git a/lib/livebook/runtime.ex b/lib/livebook/runtime.ex index 95b99b29e..7c55fbd9b 100644 --- a/lib/livebook/runtime.ex +++ b/lib/livebook/runtime.ex @@ -34,6 +34,11 @@ defprotocol Livebook.Runtime do # The owner replies with `{:runtime_app_info_reply, reply}`, where # reply is `{:ok, info}` and `info` is a details map. + @typedoc """ + A language accepted for code evaluation. + """ + @type language :: :elixir | :erlang | :python | :"pyproject.toml" + @typedoc """ An arbitrary term identifying an evaluation container. @@ -866,7 +871,7 @@ defprotocol Livebook.Runtime do any information added by `connect/1`. It should not have any side effects. """ - @spec duplicate(Runtime.t()) :: Runtime.t() + @spec duplicate(t()) :: t() def duplicate(runtime) @doc """ @@ -927,7 +932,7 @@ defprotocol Livebook.Runtime do they are fetched and compiled from scratch """ - @spec evaluate_code(t(), atom(), String.t(), locator(), parent_locators(), keyword()) :: :ok + @spec evaluate_code(t(), language(), String.t(), locator(), parent_locators(), keyword()) :: :ok def evaluate_code(runtime, language, code, locator, parent_locators, opts \\ []) @doc """ @@ -973,7 +978,7 @@ defprotocol Livebook.Runtime do @doc """ Reads file at the given absolute path within the runtime file system. """ - @spec read_file(Runtime.t(), String.t()) :: {:ok, binary()} | {:error, String.t()} + @spec read_file(t(), String.t()) :: {:ok, binary()} | {:error, String.t()} def read_file(runtime, path) @doc """ diff --git a/lib/livebook/runtime/definitions.ex b/lib/livebook/runtime/definitions.ex index 6950c96ae..e308e80f6 100644 --- a/lib/livebook/runtime/definitions.ex +++ b/lib/livebook/runtime/definitions.ex @@ -504,4 +504,8 @@ defmodule Livebook.Runtime.Definitions do def smart_cell_definitions(), do: @smart_cell_definitions def snippet_definitions(), do: @snippet_definitions + + def pythonx_dependency() do + %{dep: {:pythonx, github: "livebook-dev/pythonx"}, config: []} + end end diff --git a/lib/livebook/runtime/erl_dist/runtime_server.ex b/lib/livebook/runtime/erl_dist/runtime_server.ex index 2b40c3c70..14173bb3d 100644 --- a/lib/livebook/runtime/erl_dist/runtime_server.ex +++ b/lib/livebook/runtime/erl_dist/runtime_server.ex @@ -83,7 +83,7 @@ defmodule Livebook.Runtime.ErlDist.RuntimeServer do """ @spec evaluate_code( pid(), - :elixir | :erlang, + Runtime.language(), String.t(), Runtime.locator(), Runtime.parent_locators(), diff --git a/lib/livebook/runtime/evaluator.ex b/lib/livebook/runtime/evaluator.ex index ff930b767..006762610 100644 --- a/lib/livebook/runtime/evaluator.ex +++ b/lib/livebook/runtime/evaluator.ex @@ -142,7 +142,7 @@ defmodule Livebook.Runtime.Evaluator do as an argument """ - @spec evaluate_code(t(), :elixir | :erlang, ref(), list(ref()), keyword()) :: :ok + @spec evaluate_code(t(), Livebook.Runtime.language(), ref(), list(ref()), keyword()) :: :ok def evaluate_code(evaluator, language, code, ref, parent_refs, opts \\ []) do cast(evaluator, {:evaluate_code, language, code, ref, parent_refs, opts}) end @@ -434,7 +434,12 @@ defmodule Livebook.Runtime.Evaluator do start_time = System.monotonic_time() {eval_result, code_markers} = - eval(language, code, context.binding, context.env, state.tmp_dir) + case language do + :elixir -> eval_elixir(code, context.binding, context.env) + :erlang -> eval_erlang(code, context.binding, context.env, state.tmp_dir) + :python -> eval_python(code, context.binding, context.env) + :"pyproject.toml" -> eval_pyproject_toml(code, context.binding, context.env) + end evaluation_time_ms = time_diff_ms(start_time) @@ -491,7 +496,7 @@ defmodule Livebook.Runtime.Evaluator do end state = put_context(state, ref, new_context) - output = Evaluator.Formatter.format_result(result, language) + output = Evaluator.Formatter.format_result(language, result) metadata = %{ errored: error_result?(result), @@ -637,7 +642,7 @@ defmodule Livebook.Runtime.Evaluator do |> Map.update!(:context_modules, &(&1 ++ prev_env.context_modules)) end - defp eval(:elixir, code, binding, env, _tmp_dir) do + defp eval_elixir(code, binding, env) do {{result, extra_diagnostics}, diagnostics} = Code.with_diagnostics([log: true], fn -> try do @@ -701,6 +706,16 @@ defmodule Livebook.Runtime.Evaluator do {result, code_markers} end + defp extra_diagnostic?(%SyntaxError{}), do: true + defp extra_diagnostic?(%TokenMissingError{}), do: true + defp extra_diagnostic?(%MismatchedDelimiterError{}), do: true + + defp extra_diagnostic?(%CompileError{description: description}) do + not String.contains?(description, "(errors have been logged)") + end + + defp extra_diagnostic?(_error), do: false + # Erlang code is either statements as currently supported, or modules. # In case we want to support modules - it makes sense to allow users to use # includes, defines and thus we use the epp-module first - try to find out @@ -708,7 +723,7 @@ defmodule Livebook.Runtime.Evaluator do # if in the tokens from erl_scan we find at least 1 module-token we assume # that the user is defining a module, if not the previous code is called. - defp eval(:erlang, code, binding, env, tmp_dir) do + defp eval_erlang(code, binding, env, tmp_dir) do case :erl_scan.string(String.to_charlist(code), {1, 1}, [:text]) do {:ok, [{:-, _}, {:atom, _, :module} | _], _} -> eval_erlang_module(code, binding, env, tmp_dir) @@ -918,15 +933,122 @@ defmodule Livebook.Runtime.Evaluator do Enum.reject(code_markers, &(&1.line == 0)) end - defp extra_diagnostic?(%SyntaxError{}), do: true - defp extra_diagnostic?(%TokenMissingError{}), do: true - defp extra_diagnostic?(%MismatchedDelimiterError{}), do: true + @compile {:no_warn_undefined, {Pythonx, :eval, 2}} + @compile {:no_warn_undefined, {Pythonx, :decode, 1}} - defp extra_diagnostic?(%CompileError{description: description}) do - not String.contains?(description, "(errors have been logged)") + defp eval_python(code, binding, env) do + with :ok <- ensure_pythonx() do + {result, _diagnostics} = + Code.with_diagnostics([log: true], fn -> + try do + quoted = python_code_to_quoted(code) + + {value, binding, env} = + Code.eval_quoted_with_env(quoted, binding, env, prune_binding: true) + + result = {:ok, value, binding, env} + code_markers = [] + {result, code_markers} + catch + kind, error -> + code_markers = + if is_struct(error, Pythonx.Error) do + Pythonx.eval( + """ + import traceback + + if traceback_ is None: + diagnostic = None + elif isinstance(value, SyntaxError): + diagnostic = (value.lineno, "SyntaxError: invalid syntax") + else: + description = " ".join(traceback.format_exception_only(type, value)).strip() + diagnostic = (traceback_.tb_lineno, description) + + diagnostic + """, + %{ + "type" => error.type, + "value" => error.value, + "traceback_" => error.traceback + } + ) + |> elem(0) + |> Pythonx.decode() + |> case do + nil -> [] + {line, message} -> [%{line: line, description: message, severity: :error}] + end + else + [] + end + + result = {:error, kind, error, []} + {result, code_markers} + end + end) + + result + end end - defp extra_diagnostic?(_error), do: false + defp python_code_to_quoted(code) do + # We expand the sigil upfront, so it is not traced as import usage + # during evaluation. + + quoted = {:sigil_PY, [], [{:<<>>, [], [code]}, []]} + + env = Code.env_for_eval([]) + + env = + env + |> Map.replace!(:requires, [Pythonx]) + |> Map.replace!(:macros, [{Pythonx, [{:sigil_PY, 2}]}]) + + Macro.expand_once(quoted, env) + end + + defp eval_pyproject_toml(code, binding, env) do + with :ok <- ensure_pythonx() do + quoted = {{:., [], [{:__aliases__, [alias: false], [:Pythonx]}, :uv_init]}, [], [code]} + + {result, _diagnostics} = + Code.with_diagnostics([log: true], fn -> + try do + {value, binding, env} = + Code.eval_quoted_with_env(quoted, binding, env, prune_binding: true) + + result = {:ok, value, binding, env} + code_markers = [] + {result, code_markers} + catch + kind, error -> + code_markers = [] + + result = {:error, kind, error, []} + {result, code_markers} + end + end) + + result + end + end + + defp ensure_pythonx() do + if Code.ensure_loaded?(Pythonx) do + :ok + else + message = + """ + Pythonx is missing, make sure to add it as a dependency: + + #{Macro.to_string(Livebook.Runtime.Definitions.pythonx_dependency().dep)} + """ + + exception = RuntimeError.exception(message) + {{:error, :error, exception, []}, []} + end + end defp identifier_dependencies(context, tracer_info, prev_context) do identifiers_used = MapSet.new() diff --git a/lib/livebook/runtime/evaluator/formatter.ex b/lib/livebook/runtime/evaluator/formatter.ex index 0735402f6..2400b02bf 100644 --- a/lib/livebook/runtime/evaluator/formatter.ex +++ b/lib/livebook/runtime/evaluator/formatter.ex @@ -1,6 +1,10 @@ defmodule Livebook.Runtime.Evaluator.Formatter do require Logger + @compile {:no_warn_undefined, {Kino.Render, :to_livebook, 1}} + @compile {:no_warn_undefined, {Pythonx, :eval, 2}} + @compile {:no_warn_undefined, {Pythonx, :decode, 1}} + @doc """ Formats evaluation result into an output. @@ -10,28 +14,34 @@ defmodule Livebook.Runtime.Evaluator.Formatter do to format in the runtime node, because it oftentimes relies on the `inspect` protocol implementations from external packages. """ - @spec format_result(Livebook.Runtime.Evaluator.evaluation_result(), atom()) :: - Livebook.Runtime.output() - def format_result(result, language) + @spec format_result( + Livebook.Runtime.language(), + Livebook.Runtime.Evaluator.evaluation_result() + ) :: Livebook.Runtime.output() + def format_result(language, result) - def format_result({:ok, :"do not show this result in output"}, :elixir) do + def format_result(:elixir, {:ok, :"do not show this result in output"}) do # Functions in the `IEx.Helpers` module return this specific value # to indicate no result should be printed in the iex shell, # so we respect that as well. %{type: :ignored} end - def format_result({:ok, {:module, _, _, _} = value}, :elixir) do + def format_result(:elixir, {:ok, {:module, _, _, _} = value}) do to_inspect_output(value, limit: 10) end - def format_result({:ok, value}, :elixir) do + def format_result(:elixir, {:ok, value}) do to_output(value) end - def format_result({:error, kind, error, stacktrace}, :erlang) do + def format_result(:erlang, {:ok, value}) do + erlang_to_output(value) + end + + def format_result(:erlang, {:error, kind, error, stacktrace}) do if is_exception(error) do - format_result({:error, kind, error, stacktrace}, :elixir) + format_result(:elixir, {:error, kind, error, stacktrace}) else formatted = :erl_error.format_exception(kind, error, stacktrace) @@ -42,17 +52,53 @@ defmodule Livebook.Runtime.Evaluator.Formatter do end end - def format_result({:error, kind, error, stacktrace}, _language) do + def format_result(:python, {:ok, nil}) do + %{type: :ignored} + end + + def format_result(:python, {:ok, value}) do + repr_string = Pythonx.eval("repr(value)", %{"value" => value}) |> elem(0) |> Pythonx.decode() + %{type: :terminal_text, text: repr_string, chunk: false} + end + + def format_result(:python, {:error, _kind, error, _stacktrace}) + when is_struct(error, Pythonx.Error) do + formatted = + Pythonx.eval( + """ + import traceback + # For SyntaxErrors the traceback is not relevant + traceback_ = None if isinstance(value, SyntaxError) else traceback_ + traceback.format_exception(type, value, traceback_) + """, + %{"type" => error.type, "value" => error.value, "traceback_" => error.traceback} + ) + |> elem(0) + |> Pythonx.decode() + |> error_color() + |> IO.iodata_to_binary() + + %{type: :error, message: formatted, context: nil} + end + + def format_result(:"pyproject.toml", {:ok, _value}) do + %{type: :terminal_text, text: "Ok", chunk: false} + end + + def format_result(:"pyproject.toml", {:error, _kind, _error, _stacktrace}) do + formatted = + "Error, see the output above for details" + |> error_color() + |> IO.iodata_to_binary() + + %{type: :error, message: formatted, context: nil} + end + + def format_result(_language, {:error, kind, error, stacktrace}) do formatted = format_error(kind, error, stacktrace) %{type: :error, message: formatted, context: error_context(error)} end - def format_result({:ok, value}, :erlang) do - erlang_to_output(value) - end - - @compile {:no_warn_undefined, {Kino.Render, :to_livebook, 1}} - defp to_output(value) do # Kino is a "client side" extension for Livebook that may be # installed into the runtime node. If it is installed we use diff --git a/lib/livebook/session.ex b/lib/livebook/session.ex index 1a6343161..ddedcaf9a 100644 --- a/lib/livebook/session.ex +++ b/lib/livebook/session.ex @@ -445,6 +445,24 @@ defmodule Livebook.Session do GenServer.cast(pid, {:move_section, self(), section_id, offset}) end + @doc """ + Requests the given langauge to be enabled. + + This inserts extra cells and adds dependencies if applicable. + """ + @spec enable_language(pid(), atom()) :: :ok + def enable_language(pid, language) do + GenServer.cast(pid, {:enable_language, self(), language}) + end + + @doc """ + Requests the given langauge to be disabled. + """ + @spec disable_language(pid(), atom()) :: :ok + def disable_language(pid, language) do + GenServer.cast(pid, {:disable_language, self(), language}) + end + @doc """ Requests a smart cell to be recovered. @@ -1170,14 +1188,12 @@ defmodule Livebook.Session do def handle_cast({:set_section_parent, client_pid, section_id, parent_id}, state) do client_id = client_id(state, client_pid) - # Include new id in the operation, so it's reproducible operation = {:set_section_parent, client_id, section_id, parent_id} {:noreply, handle_operation(state, operation)} end def handle_cast({:unset_section_parent, client_pid, section_id}, state) do client_id = client_id(state, client_pid) - # Include new id in the operation, so it's reproducible operation = {:unset_section_parent, client_id, section_id} {:noreply, handle_operation(state, operation)} end @@ -1219,6 +1235,39 @@ defmodule Livebook.Session do {:noreply, handle_operation(state, operation)} end + def handle_cast({:enable_language, client_pid, language}, state) do + case do_add_dependencies(state, [Livebook.Runtime.Definitions.pythonx_dependency()]) do + {:ok, state} -> + client_id = client_id(state, client_pid) + + # If there is a single empty cell (new notebook), change its + # language automatically. Note that we cannot do it as part of + # the :enable_language operation, because clients prune the + # source. + state = + case state.data.notebook.sections do + [%{cells: [%{source: ""} = cell]}] -> + operation = {:set_cell_attributes, client_id, cell.id, %{language: language}} + handle_operation(state, operation) + + _ -> + state + end + + operation = {:enable_language, client_id, language} + {:noreply, handle_operation(state, operation)} + + {:error, state} -> + {:noreply, state} + end + end + + def handle_cast({:disable_language, client_pid, language}, state) do + client_id = client_id(state, client_pid) + operation = {:disable_language, client_id, language} + {:noreply, handle_operation(state, operation)} + end + def handle_cast({:recover_smart_cell, client_pid, cell_id}, state) do client_id = client_id(state, client_pid) operation = {:recover_smart_cell, client_id, cell_id} @@ -1257,7 +1306,8 @@ defmodule Livebook.Session do end def handle_cast({:add_dependencies, dependencies}, state) do - {:noreply, do_add_dependencies(state, dependencies)} + {_ok_error, state} = do_add_dependencies(state, dependencies) + {:noreply, state} end def handle_cast({:queue_cell_evaluation, client_pid, cell_id, evaluation_opts}, state) do @@ -2220,21 +2270,26 @@ defmodule Livebook.Session do end defp do_add_dependencies(state, dependencies) do - {:ok, cell, _} = Notebook.fetch_cell_and_section(state.data.notebook, Cell.setup_cell_id()) + {:ok, cell, _} = + Notebook.fetch_cell_and_section(state.data.notebook, Cell.main_setup_cell_id()) + source = cell.source case Runtime.add_dependencies(state.data.runtime, source, dependencies) do {:ok, ^source} -> - state + {:ok, state} {:ok, new_source} -> delta = Livebook.Text.Delta.diff(cell.source, new_source) revision = state.data.cell_infos[cell.id].sources.primary.revision - handle_operation( - state, - {:apply_cell_delta, @client_id, cell.id, :primary, delta, nil, revision} - ) + state = + handle_operation( + state, + {:apply_cell_delta, @client_id, cell.id, :primary, delta, nil, revision} + ) + + {:ok, state} {:error, message} -> broadcast_error( @@ -2242,7 +2297,7 @@ defmodule Livebook.Session do "failed to add dependencies to the setup cell, reason:\n\n#{message}" ) - state + {:error, state} end end diff --git a/lib/livebook/session/data.ex b/lib/livebook/session/data.ex index 4e5d2dc23..bb86cae90 100644 --- a/lib/livebook/session/data.ex +++ b/lib/livebook/session/data.ex @@ -196,6 +196,8 @@ defmodule Livebook.Session.Data do | {:restore_cell, client_id(), Cell.id()} | {:move_cell, client_id(), Cell.id(), offset :: integer()} | {:move_section, client_id(), Section.id(), offset :: integer()} + | {:enable_language, client_id(), atom()} + | {:disable_language, client_id(), atom()} | {:queue_cells_evaluation, client_id(), list(Cell.id()), evaluation_opts :: keyword()} | {:add_cell_doctest_report, client_id(), Cell.id(), Runtime.doctest_report()} | {:add_cell_evaluation_output, client_id(), Cell.id(), term()} @@ -567,6 +569,32 @@ defmodule Livebook.Session.Data do end end + def apply_operation(data, {:enable_language, _client_id, language}) do + with false <- language in Notebook.enabled_languages(data.notebook) do + data + |> with_actions() + |> enable_language(language) + |> update_validity_and_evaluation() + |> set_dirty() + |> wrap_ok() + else + _ -> :error + end + end + + def apply_operation(data, {:disable_language, _client_id, language}) do + with true <- language in Notebook.enabled_languages(data.notebook) do + data + |> with_actions() + |> disable_language(language) + |> update_validity_and_evaluation() + |> set_dirty() + |> wrap_ok() + else + _ -> :error + end + end + def apply_operation(data, {:queue_cells_evaluation, _client_id, cell_ids, evaluation_opts}) do cells_with_section = data.notebook @@ -581,10 +609,11 @@ defmodule Livebook.Session.Data do data |> with_actions() - |> queue_prerequisite_cells_evaluation(cell_ids) |> reduce(cells_with_section, fn data_actions, {cell, section} -> queue_cell_evaluation(data_actions, cell, section, evaluation_opts) end) + |> queue_prerequisite_cells_evaluation(cell_ids) + |> maybe_queue_other_setup_cells(evaluation_opts) |> maybe_connect_runtime(data) |> update_validity_and_evaluation() |> wrap_ok() @@ -719,8 +748,8 @@ defmodule Livebook.Session.Data do true <- eval_info.validity in [:evaluated, :stale] do data |> with_actions() - |> queue_prerequisite_cells_evaluation([cell.id]) |> queue_cell_evaluation(cell, section) + |> queue_prerequisite_cells_evaluation([cell.id]) |> maybe_evaluate_queued() |> wrap_ok() else @@ -1337,6 +1366,36 @@ defmodule Livebook.Session.Data do end end + defp enable_language({data, _} = data_actions, language) do + notebook = Notebook.add_extra_setup_cell(data.notebook, language) + cell = Notebook.get_extra_setup_cell(notebook, language) + + set!(data_actions, + notebook: %{notebook | default_language: language}, + cell_infos: Map.put(data.cell_infos, cell.id, new_cell_info(cell, data.clients_map)) + ) + end + + defp disable_language({data, _} = data_actions, language) do + cell = Notebook.get_extra_setup_cell(data.notebook, language) + section = data.notebook.setup_section + info = data.cell_infos[cell.id] + + data_actions = + if Cell.evaluable?(cell) and not pristine_evaluation?(info.eval) do + data_actions + |> cancel_cell_evaluation(cell, section) + |> add_action({:forget_evaluation, cell, section}) + else + data_actions + end + + set!(data_actions, + notebook: %{Notebook.delete_cell(data.notebook, cell.id) | default_language: :elixir} + ) + |> delete_cell_info(cell) + end + defp queue_cell_evaluation(data_actions, cell, section, evaluation_opts \\ []) do data_actions |> update_section_info!(section.id, fn section -> @@ -1431,10 +1490,13 @@ defmodule Livebook.Session.Data do do: {cell_id, eval_info.snapshot}, into: %{} + enabled_languages = Notebook.enabled_languages(eval_data.notebook) + # We compute evaluation snapshot based on the notebook state prior # to evaluation, but using the information about the dependencies # obtained during evaluation (identifiers, inputs) - evaluation_snapshot = cell_snapshot(cell, section, graph, cell_snapshots, eval_data) + evaluation_snapshot = + cell_snapshot(cell, section, graph, cell_snapshots, enabled_languages, eval_data) data_actions |> update_cell_eval_info!( @@ -1481,8 +1543,27 @@ defmodule Livebook.Session.Data do queue_prerequisite_cells_evaluation(data_actions, trailing_queued_cell_ids) end + defp maybe_queue_other_setup_cells({data, _} = data_actions, evaluation_opts) do + # If one of the setup cells is queued, we automatically queue the + # subsequent ones + + {queued, rest} = + Enum.split_while(data.notebook.setup_section.cells, fn cell -> + data.cell_infos[cell.id].eval.status == :queued + end) + + if queued != [] and rest != [] do + data_actions + |> reduce(rest, fn data_actions, cell -> + queue_cell_evaluation(data_actions, cell, data.notebook.setup_section, evaluation_opts) + end) + else + data_actions + end + end + defp maybe_evaluate_queued(data_actions) do - {data, _} = data_actions = check_setup_cell_for_reevaluation(data_actions) + {data, _} = data_actions = check_setup_cells_for_reevaluation(data_actions) if data.runtime_status == :connected do main_flow_evaluating? = main_flow_evaluating?(data) @@ -1533,40 +1614,43 @@ defmodule Livebook.Session.Data do end end - defp check_setup_cell_for_reevaluation({data, _} = data_actions) do + defp check_setup_cells_for_reevaluation({data, _} = data_actions) do # When setup cell has been evaluated and is queued again, we need # to reconnect the runtime to get a fresh evaluation environment # for setup. We subsequently queue all cells that are currently # queued - case data.cell_infos[Cell.setup_cell_id()].eval do - %{status: :queued, validity: :evaluated} when data.runtime_status == :connected -> - queued_cells_with_section = - data.notebook - |> Notebook.evaluable_cells_with_section() - |> Enum.filter(fn {cell, _} -> - data.cell_infos[cell.id].eval.status == :queued - end) - |> Enum.map(fn {cell, section} -> - {cell, section, data.cell_infos[cell.id].eval.evaluation_opts} - end) + setup_cell_evaluated_and_queued? = + Enum.any?(data.notebook.setup_section.cells, fn cell -> + match?(%{status: :queued, validity: :evaluated}, data.cell_infos[cell.id].eval) + end) - cell_ids = - for {cell, _section, _evaluation_opts} <- queued_cells_with_section, do: cell.id + if setup_cell_evaluated_and_queued? and data.runtime_status == :connected do + queued_cells_with_section = + data.notebook + |> Notebook.evaluable_cells_with_section() + |> Enum.filter(fn {cell, _} -> + data.cell_infos[cell.id].eval.status == :queued + end) + |> Enum.map(fn {cell, section} -> + {cell, section, data.cell_infos[cell.id].eval.evaluation_opts} + end) - data_actions - |> disconnect_runtime() - |> connect_runtime() - |> queue_prerequisite_cells_evaluation(cell_ids) - |> reduce( - queued_cells_with_section, - fn data_actions, {cell, section, evaluation_opts} -> - queue_cell_evaluation(data_actions, cell, section, evaluation_opts) - end - ) + cell_ids = + for {cell, _section, _evaluation_opts} <- queued_cells_with_section, do: cell.id - _ -> - data_actions + data_actions + |> disconnect_runtime() + |> connect_runtime() + |> reduce( + queued_cells_with_section, + fn data_actions, {cell, section, evaluation_opts} -> + queue_cell_evaluation(data_actions, cell, section, evaluation_opts) + end + ) + |> queue_prerequisite_cells_evaluation(cell_ids) + else + data_actions end end @@ -1715,6 +1799,7 @@ defmodule Livebook.Session.Data do |> Notebook.parent_cells_with_section(cell_ids) |> Enum.filter(fn {cell, _section} -> info = data.cell_infos[cell.id] + Cell.evaluable?(cell) and cell_outdated?(data, cell.id) and info.eval.status == :ready end) |> Enum.reverse() @@ -2550,9 +2635,11 @@ defmodule Livebook.Session.Data do cells_with_section = Notebook.evaluable_cells_with_section(data.notebook) + enabled_languages = Notebook.enabled_languages(data.notebook) + cell_snapshots = Enum.reduce(cells_with_section, %{}, fn {cell, section}, cell_snapshots -> - snapshot = cell_snapshot(cell, section, graph, cell_snapshots, data) + snapshot = cell_snapshot(cell, section, graph, cell_snapshots, enabled_languages, data) put_in(cell_snapshots[cell.id], snapshot) end) @@ -2564,9 +2651,15 @@ defmodule Livebook.Session.Data do end) end - defp cell_snapshot(cell, section, graph, cell_snapshots, data) do + defp cell_snapshot(cell, section, graph, cell_snapshots, enabled_languages, data) do info = data.cell_infos[cell.id] + language = + case cell do + %Cell.Code{language: language} -> language + _other -> nil + end + # Note that this is an implication of the Elixir runtime, we want # to reevaluate as much as possible in a branch, rather than copying # contexts between processes, because all structural sharing is @@ -2585,7 +2678,9 @@ defmodule Livebook.Session.Data do ) |> Enum.sort() - deps = {is_branch?, parent_snapshots, identifier_versions, bound_input_current_hashes} + deps = + {enabled_languages, language, is_branch?, parent_snapshots, identifier_versions, + bound_input_current_hashes} :erlang.phash2(deps) end @@ -2734,10 +2829,10 @@ defmodule Livebook.Session.Data do cell_ids = for {cell, _section} <- cells_to_reevaluate, do: cell.id data_actions - |> queue_prerequisite_cells_evaluation(cell_ids) |> reduce(cells_to_reevaluate, fn data_actions, {cell, section} -> queue_cell_evaluation(data_actions, cell, section) end) + |> queue_prerequisite_cells_evaluation(cell_ids) end defp app_update_execution_status({data, _} = data_actions) @@ -2819,8 +2914,9 @@ defmodule Livebook.Session.Data do @spec cell_ids_for_full_evaluation(t(), list(Cell.id())) :: list(Cell.id()) def cell_ids_for_full_evaluation(data, forced_cell_ids) do requires_reconnect? = - data.cell_infos[Cell.setup_cell_id()].eval.validity == :evaluated and - cell_outdated?(data, Cell.setup_cell_id()) + Enum.any?(data.notebook.setup_section.cells, fn cell -> + data.cell_infos[cell.id].eval.validity == :evaluated and cell_outdated?(data, cell.id) + end) evaluable_cells_with_section = Notebook.evaluable_cells_with_section(data.notebook) diff --git a/lib/livebook_web/components/core_components.ex b/lib/livebook_web/components/core_components.ex index 161e3b73c..3d5135a0d 100644 --- a/lib/livebook_web/components/core_components.ex +++ b/lib/livebook_web/components/core_components.ex @@ -887,11 +887,11 @@ defmodule LivebookWeb.CoreComponents do defp button_classes(small, disabled, color, outlined) do [ if small do - "px-2 py-1 font-normal text-xs" + "px-2 py-1 font-normal text-xs gap-1" else - "px-5 py-2 font-medium text-sm" + "px-5 py-2 font-medium text-sm gap-1.5" end, - "inline-flex rounded-lg border whitespace-nowrap items-center justify-center gap-1.5 focus-visible:outline-none", + "inline-flex rounded-lg border whitespace-nowrap items-center justify-center focus-visible:outline-none", if disabled do "cursor-default pointer-events-none border-transparent bg-gray-100 text-gray-400" else diff --git a/lib/livebook_web/components/notebook_components.ex b/lib/livebook_web/components/notebook_components.ex index e3a874738..80ce28a9a 100644 --- a/lib/livebook_web/components/notebook_components.ex +++ b/lib/livebook_web/components/notebook_components.ex @@ -44,82 +44,92 @@ defmodule LivebookWeb.NotebookComponents do def cell_icon(assigns) - def cell_icon(%{cell_type: :code, language: :elixir} = assigns) do + def cell_icon(%{cell_type: :code} = assigns) do ~H""" -
- <.language_icon language="elixir" class="w-full h-full text-[#663299]" /> -
- """ - end - - def cell_icon(%{cell_type: :code, language: :erlang} = assigns) do - ~H""" -
- <.language_icon language="erlang" class="w-full h-full text-[#a90533]" /> +
+ <.language_icon language={Atom.to_string(@language)} class="w-full h-full text-gray-600" />
""" end def cell_icon(%{cell_type: :markdown} = assigns) do ~H""" -
- <.language_icon language="markdown" class="w-full h-full text-[#3e64ff]" /> +
+ <.language_icon language="markdown" class="w-full h-full text-gray-600" />
""" end def cell_icon(%{cell_type: :smart} = assigns) do ~H""" -
- <.remix_icon icon="flashlight-line text-red-900" /> +
+ <.remix_icon icon="flashlight-line text-gray-600" />
""" end @doc """ Renders an icon for the given language. + + The icons are adapted from https://github.com/material-extensions/vscode-material-icon-theme. """ attr :language, :string, required: true attr :class, :string, default: nil def language_icon(%{language: "elixir"} = assigns) do ~H""" - + - + 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" + /> """ end def language_icon(%{language: "erlang"} = assigns) do ~H""" - - - - - + + """ end def language_icon(%{language: "markdown"} = assigns) do ~H""" - + """ end + + def language_icon(%{language: "python"} = assigns) do + ~H""" + + + + + """ + end + + def language_icon(%{language: "pyproject.toml"} = assigns) do + ~H""" + + + + + """ + end end diff --git a/lib/livebook_web/helpers/session_helpers.ex b/lib/livebook_web/helpers/session_helpers.ex index 6a04046ac..c24506ab1 100644 --- a/lib/livebook_web/helpers/session_helpers.ex +++ b/lib/livebook_web/helpers/session_helpers.ex @@ -30,14 +30,14 @@ defmodule LivebookWeb.SessionHelpers do ## Options - * `:queue_setup` - whether to queue the setup cell right after + * `:connect_runtime` - whether to connect the runtime right after the session is started. Defaults to `false` Accepts the same options as `Livebook.Sessions.create_session/1`. """ @spec create_session(Socket.t(), keyword()) :: Socket.t() def create_session(socket, opts \\ []) do - {queue_setup, opts} = Keyword.pop(opts, :queue_setup, false) + {connect_runtime, opts} = Keyword.pop(opts, :connect_runtime, false) # Revert persistence options to default values if there is # no file attached to the new session @@ -50,8 +50,8 @@ defmodule LivebookWeb.SessionHelpers do case Livebook.Sessions.create_session(opts) do {:ok, session} -> - if queue_setup do - Session.queue_cell_evaluation(session.pid, Livebook.Notebook.Cell.setup_cell_id()) + if connect_runtime do + Session.connect_runtime(session.pid) end redirect_path = session_path(session.id, opts) diff --git a/lib/livebook_web/live/home_live.ex b/lib/livebook_web/live/home_live.ex index c6d5fc941..f33cd96e3 100644 --- a/lib/livebook_web/live/home_live.ex +++ b/lib/livebook_web/live/home_live.ex @@ -225,7 +225,7 @@ defmodule LivebookWeb.HomeLive do end def handle_params(%{}, _url, socket) when socket.assigns.live_action == :public_new_notebook do - {:noreply, create_session(socket, queue_setup: true)} + {:noreply, create_session(socket, connect_runtime: true)} end def handle_params(_params, _url, socket), do: {:noreply, socket} diff --git a/lib/livebook_web/live/output.ex b/lib/livebook_web/live/output.ex index 05f3f211b..b7965c67d 100644 --- a/lib/livebook_web/live/output.ex +++ b/lib/livebook_web/live/output.ex @@ -349,7 +349,7 @@ defmodule LivebookWeb.Output do defp render_output(%{type: :error, context: :dependencies} = output, %{id: id, cell_id: cell_id}) do assigns = %{message: output.message, id: id, cell_id: cell_id} - if cell_id == Livebook.Notebook.Cell.setup_cell_id() do + if cell_id == Livebook.Notebook.Cell.main_setup_cell_id() do ~H"""
diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 44086be9b..4223eb8cf 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -255,6 +255,18 @@ defmodule LivebookWeb.SessionLive do {:noreply, socket} end + def handle_event("enable_language", %{"language" => language}, socket) do + language = language_to_string(language) + Session.enable_language(socket.assigns.session.pid, language) + {:noreply, socket} + end + + def handle_event("disable_language", %{"language" => language}, socket) do + language = language_to_string(language) + Session.disable_language(socket.assigns.session.pid, language) + {:noreply, socket} + end + def handle_event("insert_cell_below", params, socket) do {:noreply, insert_cell_below(socket, params)} end @@ -327,9 +339,8 @@ defmodule LivebookWeb.SessionLive do end end - def handle_event("set_default_language", %{"language" => language} = params, socket) - when language in ["elixir", "erlang"] do - language = String.to_atom(language) + def handle_event("set_default_language", %{"language" => language} = params, socket) do + language = language_to_string(language) Session.set_notebook_attributes(socket.assigns.session.pid, %{default_language: language}) {:noreply, insert_cell_below(socket, params)} end @@ -548,6 +559,12 @@ defmodule LivebookWeb.SessionLive do {:noreply, socket} end + def handle_event("set_cell_language", %{"cell_id" => cell_id, "language" => language}, socket) do + language = language_to_string(language) + Session.set_cell_attributes(socket.assigns.session.pid, cell_id, %{language: language}) + {:noreply, socket} + end + def handle_event("save", %{}, socket) do if socket.private.data.file do Session.save(socket.assigns.session.pid) @@ -1346,6 +1363,20 @@ defmodule LivebookWeb.SessionLive do end end + defp after_operation(socket, _prev_socket, {:enable_language, client_id, language}) do + cell = Notebook.get_extra_setup_cell(socket.private.data.notebook, language) + + socket = push_cell_editor_payloads(socket, socket.private.data, [cell]) + + socket = prune_cell_sources(socket) + + if client_id == socket.assigns.client_id do + push_event(socket, "cell_inserted", %{cell_id: cell.id}) + else + socket + end + end + defp after_operation( socket, _prev_socket, @@ -1471,12 +1502,10 @@ defmodule LivebookWeb.SessionLive do defp cell_type_and_attrs_from_params(%{"type" => "code"} = params, socket) do language = - case params["language"] do - language when language in ["elixir", "erlang"] -> - String.to_atom(language) - - _ -> - socket.private.data.notebook.default_language + if language = params["language"] do + language_to_string(language) + else + socket.private.data.notebook.default_language end {:code, %{language: language}} @@ -1558,7 +1587,7 @@ defmodule LivebookWeb.SessionLive do defp confirm_setup_runtime(socket, reason) do on_confirm = fn socket -> - Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id()) + Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.main_setup_cell_id()) socket end @@ -1616,7 +1645,7 @@ defmodule LivebookWeb.SessionLive do defp add_dependencies_and_reevaluate(socket, dependencies) do Session.add_dependencies(socket.assigns.session.pid, dependencies) - Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.setup_cell_id()) + Session.queue_cell_evaluation(socket.assigns.session.pid, Cell.main_setup_cell_id()) Session.queue_cells_reevaluation(socket.assigns.session.pid) socket end @@ -1762,6 +1791,13 @@ defmodule LivebookWeb.SessionLive do end) end + defp language_to_string(language) do + %{language: language} = + Enum.find(Cell.Code.languages(), &(Atom.to_string(&1.language) == language)) + + language + end + # Builds view-specific structure of data by cherry-picking # only the relevant attributes. # We then use `@data_view` in the templates and consequently @@ -1804,11 +1840,10 @@ defmodule LivebookWeb.SessionLive do data.clients_map |> Enum.map(fn {client_id, user_id} -> {client_id, data.users_map[user_id]} end) |> Enum.sort_by(fn {_client_id, user} -> user.name || "Anonymous" end), - installing?: data.cell_infos[Cell.setup_cell_id()].eval.status == :evaluating, - setup_cell_view: %{ - cell_to_view(hd(data.notebook.setup_section.cells), data, changed_input_ids) - | type: :setup - }, + enabled_languages: Notebook.enabled_languages(data.notebook), + installing?: data.cell_infos[Cell.main_setup_cell_id()].eval.status == :evaluating, + setup_cell_views: + Enum.map(data.notebook.setup_section.cells, &cell_to_view(&1, data, changed_input_ids)), section_views: section_views(data.notebook.sections, data, changed_input_ids), bin_entries: data.bin_entries, secrets: data.secrets, @@ -1913,6 +1948,7 @@ defmodule LivebookWeb.SessionLive do %{ id: cell.id, type: :code, + setup: Cell.setup?(cell), language: cell.language, empty: cell.source == "", eval: eval_info_to_view(cell, info.eval, data, changed_input_ids), diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex index 3e1fa79c9..032ac2aec 100644 --- a/lib/livebook_web/live/session_live/cell_component.ex +++ b/lib/livebook_web/live/session_live/cell_component.ex @@ -37,6 +37,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do data-el-cell id={"cell-#{@cell_view.id}"} data-type={@cell_view.type} + data-setup={@cell_view[:setup]} data-focusable-id={@cell_view.id} data-js-empty={@cell_view.empty} data-eval-validity={get_in(@cell_view, [:eval, :validity])} @@ -86,56 +87,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do """ end - defp render_cell(%{cell_view: %{type: :code}} = assigns) do - ~H""" - <.cell_actions> - <:primary> - <.cell_evaluation_button - session_id={@session_id} - cell_id={@cell_view.id} - validity={@cell_view.eval.validity} - status={@cell_view.eval.status} - reevaluate_automatically={@cell_view.reevaluate_automatically} - reevaluates_automatically={@cell_view.eval.reevaluates_automatically} - /> - - <: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} /> - - - <.cell_body> -
-
- <.cell_editor - cell_id={@cell_view.id} - tag="primary" - empty={@cell_view.empty} - language={@cell_view.language} - intellisense - /> -
-
- <.cell_indicators id={@cell_view.id} cell_view={@cell_view} /> -
-
- <.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} - /> - - """ - end - - defp render_cell(%{cell_view: %{type: :setup}} = assigns) do + defp render_cell(%{cell_view: %{type: :code, setup: true, language: :elixir}} = assigns) do ~H""" <.cell_actions> <:primary> @@ -183,6 +135,111 @@ defmodule LivebookWeb.SessionLive.CellComponent do """ end + defp render_cell( + %{cell_view: %{type: :code, setup: true, language: :"pyproject.toml"}} = assigns + ) do + ~H""" + <.cell_actions> + <:primary> +
+ <.language_icon language="python" class="w-4 h-4" /> + Python (pyproject.toml) +
+ + <:secondary> + <.cell_link_button cell_id={@cell_view.id} /> + <.disable_language_button language={:python} /> + <.pyproject_toml_cell_info /> + + + <.cell_body> +
+
+ <.cell_editor + cell_id={@cell_view.id} + tag="primary" + empty={@cell_view.empty} + language="pyproject.toml" + /> +
+
+ <.cell_indicators id={@cell_view.id} cell_view={@cell_view} /> +
+
+ <.evaluation_outputs + outputs={@streams.outputs} + cell_view={@cell_view} + session_id={@session_id} + session_pid={@session_pid} + client_id={@client_id} + /> + + """ + 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} + /> + + <: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} /> + + + <.cell_body> +
+
+ <.cell_editor + cell_id={@cell_view.id} + tag="primary" + empty={@cell_view.empty} + language={@cell_view.language} + intellisense={@cell_view.language == :elixir} + /> +
+
+ <.cell_indicators id={@cell_view.id} cell_view={@cell_view} langauge_toggle /> +
+
+
+ <.message_box kind="error"> +
+ {language_name(@cell_view.language)} is not enabled for the current notebook. + +
+ +
+ <.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} + /> + + """ + end + defp render_cell(%{cell_view: %{type: :smart}} = assigns) do ~H""" <.cell_actions> @@ -581,6 +638,20 @@ defmodule LivebookWeb.SessionLive.CellComponent do """ end + defp disable_language_button(assigns) do + ~H""" + + <.icon_button + aria-label="delete cell" + phx-click="disable_language" + phx-value-language={@language} + > + <.remix_icon icon="delete-bin-6-line" /> + + + """ + end + defp setup_cell_info(assigns) do ~H""" + <.icon_button> + <.remix_icon icon="question-line" /> + + + """ + end + attr :cell_id, :string, required: true attr :tag, :string, required: true attr :empty, :boolean, required: true @@ -680,24 +770,55 @@ defmodule LivebookWeb.SessionLive.CellComponent do """ end + attr :id, :string, required: true + attr :cell_view, :map, required: true + attr :langauge_toggle, :boolean, default: false + defp cell_indicators(assigns) do ~H"""
<.cell_indicator :if={has_status?(@cell_view)}> <.cell_status id={@id} cell_view={@cell_view} /> - <.cell_indicator> - <.language_icon language={cell_language(@cell_view)} class="w-3 h-3" /> - + <%= 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" /> + + + <.menu_item :for={language <- Livebook.Notebook.Cell.Code.languages()}> + + + + <% else %> + <.cell_indicator> + <.language_icon language={cell_language(@cell_view)} class="w-3 h-3" /> + + <% end %>
""" end + attr :class, :string, default: nil + slot :inner_block, required: true + defp cell_indicator(assigns) do ~H"""
{render_slot(@inner_block)}
@@ -806,4 +927,11 @@ defmodule LivebookWeb.SessionLive.CellComponent do defp smart_cell_js_view_ref(%{type: :smart, status: :started, js_view: %{ref: ref}}), do: ref defp smart_cell_js_view_ref(_cell_view), do: nil + + defp language_name(language) do + Enum.find_value( + Livebook.Notebook.Cell.Code.languages(), + &(&1.language == language && &1.name) + ) + end end diff --git a/lib/livebook_web/live/session_live/insert_buttons_component.ex b/lib/livebook_web/live/session_live/insert_buttons_component.ex index 8ad29bd6a..08957b236 100644 --- a/lib/livebook_web/live/session_live/insert_buttons_component.ex +++ b/lib/livebook_web/live/session_live/insert_buttons_component.ex @@ -42,30 +42,17 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
- <.menu_item> + <.menu_item :for={language <- Livebook.Notebook.Cell.Code.languages()}> - - <.menu_item> - diff --git a/lib/livebook_web/live/session_live/render.ex b/lib/livebook_web/live/session_live/render.ex index d784e6786..131a7bf24 100644 --- a/lib/livebook_web/live/session_live/render.ex +++ b/lib/livebook_web/live/session_live/render.ex @@ -1350,18 +1350,39 @@ defmodule LivebookWeb.SessionLive.Render do
-
- <.live_component - module={LivebookWeb.SessionLive.CellComponent} - id={@data_view.setup_cell_view.id} - session_id={@session.id} - session_pid={@session.pid} - client_id={@client_id} - runtime={@data_view.runtime} - installing?={@data_view.installing?} - allowed_uri_schemes={@allowed_uri_schemes} - cell_view={@data_view.setup_cell_view} - /> +
+
+ <.live_component + :for={setup_cell_view <- @data_view.setup_cell_views} + module={LivebookWeb.SessionLive.CellComponent} + id={setup_cell_view.id} + session_id={@session.id} + session_pid={@session.pid} + client_id={@client_id} + runtime={@data_view.runtime} + installing?={@data_view.installing?} + allowed_uri_schemes={@allowed_uri_schemes} + enabled_languages={@data_view.enabled_languages} + cell_view={setup_cell_view} + /> +
+
+ <.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" /> + Python + +
@@ -1384,6 +1405,7 @@ defmodule LivebookWeb.SessionLive.Render do allowed_uri_schemes={@allowed_uri_schemes} section_view={section_view} default_language={@data_view.default_language} + enabled_languages={@data_view.enabled_languages} />
diff --git a/lib/livebook_web/live/session_live/section_component.ex b/lib/livebook_web/live/session_live/section_component.ex index a4ea99f24..f3478440c 100644 --- a/lib/livebook_web/live/session_live/section_component.ex +++ b/lib/livebook_web/live/session_live/section_component.ex @@ -163,6 +163,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do runtime_status={@runtime_status} installing?={@installing?} allowed_uri_schemes={@allowed_uri_schemes} + enabled_languages={@enabled_languages} cell_view={cell_view} /> <.live_component diff --git a/mix.exs b/mix.exs index 656bafecb..cdf1b27cb 100644 --- a/mix.exs +++ b/mix.exs @@ -124,6 +124,8 @@ defmodule Livebook.MixProject do {:phoenix_live_reload, "~> 1.2", only: :dev}, {:floki, ">= 0.27.0", only: :test}, {:bypass, "~> 2.1", only: :test}, + # So that we can test Python evaluation in the same node + {:pythonx, github: "livebook-dev/pythonx", only: :test}, # ZTA deps {:jose, "~> 1.11.5"}, {:req, "~> 0.5.8"}, diff --git a/mix.lock b/mix.lock index cb54a89b7..874036535 100644 --- a/mix.lock +++ b/mix.lock @@ -3,6 +3,7 @@ "bandit": {:hex, :bandit, "1.6.5", "24096d6232e0d050096acec96a0a382c44de026f9b591b883ed45497e1ef4916", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "b6b91f630699c8b41f3f0184bd4f60b281e19a336ad9dc1a0da90637b6688332"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, "castore": {:hex, :castore, "1.0.9", "5cc77474afadf02c7c017823f460a17daa7908e991b0cc917febc90e466a375c", [:mix], [], "hexpm", "5ea956504f1ba6f2b4eb707061d8e17870de2bee95fb59d512872c2ef06925e7"}, + "cc_precompiler": {:hex, :cc_precompiler, "0.1.10", "47c9c08d8869cf09b41da36538f62bc1abd3e19e41701c2cea2675b53c704258", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f6e046254e53cd6b41c6bacd70ae728011aa82b2742a80d6e2214855c6e06b22"}, "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, @@ -11,6 +12,7 @@ "earmark_parser": {:hex, :earmark_parser, "1.4.42", "f23d856f41919f17cd06a493923a722d87a2d684f143a1e663c04a2b93100682", [:mix], [], "hexpm", "6915b6ca369b5f7346636a2f41c6a6d78b5af419d61a611079189233358b8b8b"}, "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, "eini": {:hex, :eini_beam, "2.2.4", "02143b1dce4dda4243248e7d9b3d8274b8d9f5a666445e3d868e2cce79e4ff22", [:rebar3], [], "hexpm", "12de479d144b19e09bb92ba202a7ea716739929afdf9dff01ad802e2b1508471"}, + "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, "ex_doc": {:hex, :ex_doc, "0.36.1", "4197d034f93e0b89ec79fac56e226107824adcce8d2dd0a26f5ed3a95efc36b1", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d7d26a7cf965dacadcd48f9fa7b5953d7d0cfa3b44fa7a65514427da44eafd89"}, "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, @@ -44,6 +46,7 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "pluggable": {:hex, :pluggable, "1.1.0", "7eba3bc70c0caf4d9056c63c882df8862f7534f0145da7ab3a47ca73e4adb1e4", [:mix], [], "hexpm", "d12eb00ea47b21e92cd2700d6fbe3737f04b64e71b63aad1c0accde87c751637"}, "protobuf": {:hex, :protobuf, "0.13.0", "7a9d9aeb039f68a81717eb2efd6928fdf44f03d2c0dfdcedc7b560f5f5aae93d", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "21092a223e3c6c144c1a291ab082a7ead32821ba77073b72c68515aa51fef570"}, + "pythonx": {:git, "https://github.com/livebook-dev/pythonx.git", "c7e18a55b67ca37de4962398a33a87260ddc31ca", []}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, diff --git a/test/livebook/apps/deployer_test.exs b/test/livebook/apps/deployer_test.exs index 7d5bfd47e..1be4557c3 100644 --- a/test/livebook/apps/deployer_test.exs +++ b/test/livebook/apps/deployer_test.exs @@ -126,12 +126,14 @@ defmodule Livebook.Apps.DeployerTest do notebook = %{Notebook.new() | app_settings: app_settings} - |> Notebook.put_setup_cell(%{ - Notebook.Cell.new(:code) - | source: """ - File.touch!("#{path}") - """ - }) + |> Notebook.put_setup_cells([ + %{ + Notebook.Cell.new(:code) + | source: """ + File.touch!("#{path}") + """ + } + ]) app_spec = Apps.NotebookAppSpec.new(notebook, should_warmup: true) @@ -152,12 +154,14 @@ defmodule Livebook.Apps.DeployerTest do notebook = %{Notebook.new() | app_settings: app_settings} - |> Notebook.put_setup_cell(%{ - Notebook.Cell.new(:code) - | source: """ - raise "error" - """ - }) + |> Notebook.put_setup_cells([ + %{ + Notebook.Cell.new(:code) + | source: """ + raise "error" + """ + } + ]) app_spec = Apps.NotebookAppSpec.new(notebook, should_warmup: true) @@ -178,12 +182,14 @@ defmodule Livebook.Apps.DeployerTest do notebook = %{Notebook.new() | app_settings: app_settings} - |> Notebook.put_setup_cell(%{ - Notebook.Cell.new(:code) - | source: """ - Process.sleep(:infinity) - """ - }) + |> Notebook.put_setup_cells([ + %{ + Notebook.Cell.new(:code) + | source: """ + Process.sleep(:infinity) + """ + } + ]) app_spec = Apps.NotebookAppSpec.new(notebook, should_warmup: true) diff --git a/test/livebook/live_markdown/export_test.exs b/test/livebook/live_markdown/export_test.exs index c7e44ff8e..81a890459 100644 --- a/test/livebook/live_markdown/export_test.exs +++ b/test/livebook/live_markdown/export_test.exs @@ -91,6 +91,13 @@ defmodule Livebook.LiveMarkdown.ExportTest do source: """ lists:seq(1, 10).\ """ + }, + %{ + Notebook.Cell.new(:code) + | language: :python, + source: """ + range(0, 10)\ + """ } ] } @@ -149,6 +156,10 @@ defmodule Livebook.LiveMarkdown.ExportTest do ```erlang lists:seq(1, 10). ``` + + ```python + range(0, 10) + ``` """ {document, []} = Export.notebook_to_livemd(notebook) @@ -1131,7 +1142,7 @@ defmodule Livebook.LiveMarkdown.ExportTest do | name: "My Notebook", sections: [%{Notebook.Section.new() | name: "Section 1"}] } - |> Notebook.put_setup_cell(%{Notebook.Cell.new(:code) | source: "Mix.install([...])"}) + |> Notebook.put_setup_cells([%{Notebook.Cell.new(:code) | source: "Mix.install([...])"}]) expected_document = """ # My Notebook @@ -1147,6 +1158,60 @@ defmodule Livebook.LiveMarkdown.ExportTest do assert expected_document == document end + + test "includes pyproject setup cell when present" do + notebook = + %{ + Notebook.new() + | name: "My Notebook", + sections: [%{Notebook.Section.new() | name: "Section 1"}] + } + |> Notebook.put_setup_cells([ + %{ + Notebook.Cell.new(:code) + | source: """ + Mix.install([ + {:pythonx, github: "livebook-dev/pythonx"} + ])\ + """ + }, + %{ + Notebook.Cell.new(:code) + | language: :"pyproject.toml", + source: """ + [project] + name = "project" + version = "0.0.0" + requires-python = "==3.13.*" + dependencies = []\ + """ + } + ]) + + expected_document = """ + # My Notebook + + ```elixir + Mix.install([ + {:pythonx, github: "livebook-dev/pythonx"} + ]) + ``` + + ```pyproject.toml + [project] + name = "project" + version = "0.0.0" + requires-python = "==3.13.*" + dependencies = [] + ``` + + ## Section 1 + """ + + {document, []} = Export.notebook_to_livemd(notebook) + + assert expected_document == document + end end describe "notebook stamp" do diff --git a/test/livebook/live_markdown/import_test.exs b/test/livebook/live_markdown/import_test.exs index 9baeaccf4..da5ecadb5 100644 --- a/test/livebook/live_markdown/import_test.exs +++ b/test/livebook/live_markdown/import_test.exs @@ -60,6 +60,10 @@ defmodule Livebook.LiveMarkdown.ImportTest do ```erlang lists:seq(1, 10). ``` + + ```python + range(0, 10) + ``` """ {notebook, %{warnings: []}} = Import.notebook_from_livemd(markdown) @@ -139,6 +143,12 @@ defmodule Livebook.LiveMarkdown.ImportTest do source: """ lists:seq(1, 10).\ """ + }, + %Cell.Code{ + language: :python, + source: """ + range(0, 10)\ + """ } ] } @@ -1140,6 +1150,56 @@ defmodule Livebook.LiveMarkdown.ImportTest do sections: [] } = notebook end + + test "imports pyproject setup cell" do + markdown = """ + # My Notebook + + ```elixir + Mix.install([ + {:pythonx, github: "livebook-dev/pythonx"} + ]) + ``` + + ```pyproject.toml + [project] + name = "project" + version = "0.0.0" + requires-python = "==3.13.*" + dependencies = [] + ``` + """ + + {notebook, %{warnings: []}} = Import.notebook_from_livemd(markdown) + + assert %Notebook{ + name: "My Notebook", + setup_section: %{ + cells: [ + %Cell.Code{ + id: "setup", + source: """ + Mix.install([ + {:pythonx, github: "livebook-dev/pythonx"} + ])\ + """ + }, + %Cell.Code{ + id: "setup-pyproject.toml", + language: :"pyproject.toml", + source: """ + [project] + name = "project" + version = "0.0.0" + requires-python = "==3.13.*" + dependencies = []\ + """ + } + ] + }, + sections: [] + } = notebook + end end describe "notebook stamp" do diff --git a/test/livebook/notebook/export/elixir_test.exs b/test/livebook/notebook/export/elixir_test.exs index b01eb3355..24aaf16e1 100644 --- a/test/livebook/notebook/export/elixir_test.exs +++ b/test/livebook/notebook/export/elixir_test.exs @@ -115,7 +115,7 @@ defmodule Livebook.Notebook.Export.ElixirTest do | name: "My Notebook", sections: [%{Notebook.Section.new() | name: "Section 1"}] } - |> Notebook.put_setup_cell(%{Notebook.Cell.new(:code) | source: "Mix.install([...])"}) + |> Notebook.put_setup_cells([%{Notebook.Cell.new(:code) | source: "Mix.install([...])"}]) expected_document = """ # Run as: iex --dot-iex path/to/notebook.exs @@ -176,4 +176,78 @@ defmodule Livebook.Notebook.Export.ElixirTest do assert expected_document == document end + + test "python" do + notebook = + %{ + Notebook.new() + | name: "My Notebook", + sections: [ + %{ + Notebook.Section.new() + | name: "Section 1", + cells: [ + %{ + Notebook.Cell.new(:code) + | language: :python, + source: """ + range(0, 10)\ + """ + } + ] + } + ] + } + |> Notebook.put_setup_cells([ + %{ + Notebook.Cell.new(:code) + | source: """ + Mix.install([ + {:pythonx, github: "livebook-dev/pythonx"} + ])\ + """ + }, + %{ + Notebook.Cell.new(:code) + | language: :"pyproject.toml", + source: """ + [project] + name = "project" + version = "0.0.0" + requires-python = "==3.13.*" + dependencies = []\ + """ + } + ]) + + expected_document = ~S''' + # Run as: iex --dot-iex path/to/notebook.exs + + # Title: My Notebook + + Mix.install([ + {:pythonx, github: "livebook-dev/pythonx"} + ]) + + Pythonx.uv_init(""" + [project] + name = "project" + version = "0.0.0" + requires-python = "==3.13.*" + dependencies = [] + """) + + import Pythonx + + # ── Section 1 ── + + ~PY""" + range(0, 10) + """ + ''' + + document = Export.Elixir.notebook_to_elixir(notebook) + + assert expected_document == document + end end diff --git a/test/livebook/runtime/evaluator_test.exs b/test/livebook/runtime/evaluator_test.exs index 5c5e982f2..d17592437 100644 --- a/test/livebook/runtime/evaluator_test.exs +++ b/test/livebook/runtime/evaluator_test.exs @@ -6,6 +6,20 @@ defmodule Livebook.Runtime.EvaluatorTest do @moduletag :tmp_dir + setup_all do + # We setup Pythonx in the current process, so we can test Python + # code evaluation. Testing pyproject.toml evaluation is tricky + # because it requires a separate VM, so we only rely on the LV + # integration tests. + Pythonx.uv_init(""" + [project] + name = "project" + version = "0.0.0" + requires-python = "==3.13.*" + dependencies = [] + """) + end + setup ctx do ebin_path = if ctx[:with_ebin_path] do @@ -1377,7 +1391,7 @@ defmodule Livebook.Runtime.EvaluatorTest do end describe "erlang evaluation" do - test "evaluate erlang code", %{evaluator: evaluator} do + test "evaluates erlang code", %{evaluator: evaluator} do Evaluator.evaluate_code( evaluator, :erlang, @@ -1390,7 +1404,7 @@ defmodule Livebook.Runtime.EvaluatorTest do end @tag :with_ebin_path - test "evaluate erlang-module code", %{evaluator: evaluator} do + test "evaluates erlang-module code", %{evaluator: evaluator} do code = """ -module(tryme). @@ -1410,7 +1424,7 @@ defmodule Livebook.Runtime.EvaluatorTest do end @tag tmp_dir: false - test "evaluate erlang-module code without filesystem", %{evaluator: evaluator} do + test "evaluates erlang-module code without filesystem", %{evaluator: evaluator} do code = """ -module(tryme). @@ -1425,7 +1439,7 @@ defmodule Livebook.Runtime.EvaluatorTest do end @tag :with_ebin_path - test "evaluate erlang-module error", %{ + test "evaluates erlang-module error", %{ evaluator: evaluator } do code = """ @@ -1570,6 +1584,78 @@ defmodule Livebook.Runtime.EvaluatorTest do end end + describe "python evaluation" do + test "evaluates python code", %{evaluator: evaluator} do + code = """ + x = [1, 2, 3] + sum(x) + """ + + Evaluator.evaluate_code(evaluator, :python, code, :code_1, []) + + assert_receive {:runtime_evaluation_response, :code_1, terminal_text("6"), metadata()} + end + + test "uses and defines binding", %{evaluator: evaluator} do + Evaluator.evaluate_code(evaluator, :elixir, "x = 1", :code_1, []) + assert_receive {:runtime_evaluation_response, :code_1, _, metadata()} + + Evaluator.evaluate_code(evaluator, :python, "y = x", :code_2, [:code_1]) + assert_receive {:runtime_evaluation_response, :code_2, _, metadata()} + + Evaluator.evaluate_code(evaluator, :elixir, "z = y", :code_3, [:code_2, :code_1]) + assert_receive {:runtime_evaluation_response, :code_3, _, metadata()} + + %{binding: binding} = + Evaluator.get_evaluation_context(evaluator, [:code_3, :code_2, :code_1]) + + assert [{:z, %Pythonx.Object{}}, {:y, %Pythonx.Object{}}, {:x, 1}] = binding + end + + test "syntax error", %{evaluator: evaluator} do + Evaluator.evaluate_code(evaluator, :python, "1 +", :code_1, []) + + assert_receive {:runtime_evaluation_response, :code_1, error(message), + %{ + code_markers: [ + %{ + line: 1, + description: "SyntaxError: invalid syntax", + severity: :error + } + ] + }} + + assert clean_message(message) == """ + File "", 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 "", line 1, in + ModuleNotFoundError: No module named 'unknown' + """ + end + end + describe "formatting" do test "gracefully handles errors in the inspect protocol", %{evaluator: evaluator} do code = "%Livebook.TestModules.BadInspect{}" diff --git a/test/livebook/session/data_test.exs b/test/livebook/session/data_test.exs index 6b9cb155a..a246c3177 100644 --- a/test/livebook/session/data_test.exs +++ b/test/livebook/session/data_test.exs @@ -22,6 +22,9 @@ defmodule Livebook.Session.DataTest do attrs: %{type: :text, default: "hey", label: "Text", debounce: :blur} } + @setup_id Notebook.Cell.main_setup_cell_id() + @pyproject_setup_id Notebook.Cell.extra_setup_cell_id(:"pyproject.toml") + defp eval_meta(opts \\ []) do uses = opts[:uses] || [] defines = opts[:defines] || %{} @@ -318,7 +321,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c3", %{}}, {:insert_cell, @cid, "s3", 1, :code, "c4", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4"], uses: %{"c3" => ["c2"]} ) ]) @@ -346,7 +349,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 2, "s3"}, {:insert_cell, @cid, "s3", 0, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c2", "c3"], []} ]) @@ -434,7 +437,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c3", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3"]) + evaluate_cells_operations([@setup_id, "c1", "c2", "c3"]) ]) operation = {:unset_section_parent, @cid, "s2"} @@ -460,7 +463,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c3", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c2", "c3"], []} ]) @@ -632,7 +635,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 1, "s2"}, {:insert_cell, @cid, "s2", 0, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"]) + evaluate_cells_operations([@setup_id, "c1", "c2"]) ]) operation = {:delete_section, @cid, "s2", true} @@ -665,7 +668,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s2", 0, :code, "c2", %{}}, {:insert_cell, @cid, "s2", 1, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3"], uses: %{"c2" => ["c1"]}) + evaluate_cells_operations([@setup_id, "c1", "c2", "c3"], uses: %{"c2" => ["c1"]}) ]) operation = {:delete_section, @cid, "s1", true} @@ -691,7 +694,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 1, :code, "c4", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4"], uses: %{"c3" => ["c2"]} ) ]) @@ -737,7 +740,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []} ]) @@ -789,7 +792,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]) + evaluate_cells_operations([@setup_id, "c1"]) ]) operation = {:delete_cell, @cid, "c1"} @@ -805,7 +808,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []} ]) @@ -824,7 +827,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3"], uses: %{"c2" => ["c1"]} ) ]) @@ -848,7 +851,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, {:set_cell_attributes, @cid, "c2", %{reevaluate_automatically: true}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"], + evaluate_cells_operations([@setup_id, "c1", "c2"], uses: %{"c2" => ["c1"]} ) ]) @@ -868,7 +871,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :markdown, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c2"]) + evaluate_cells_operations([@setup_id, "c2"]) ]) operation = {:delete_cell, @cid, "c1"} @@ -885,7 +888,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]) + evaluate_cells_operations([@setup_id, "c1"]) ]) operation = {:delete_cell, @cid, "c1"} @@ -917,7 +920,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :smart, "c2", %{kind: "text"}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:set_smart_cell_definitions, @cid, @smart_cell_definitions}, {:smart_cell_started, @cid, "c2", Delta.new(), nil, %{}, nil}, {:queue_cells_evaluation, @cid, ["c1"], []}, @@ -930,7 +933,7 @@ defmodule Livebook.Session.DataTest do [ {:forget_evaluation, _, _}, {:set_smart_cell_parents, %{id: "c2"}, %{id: "s1"}, - [{%{id: "setup"}, %{id: "setup-section"}}]} + [{%{id: @setup_id}, %{id: "setup-section"}}]} ]} = Data.apply_operation(data, operation) end @@ -940,7 +943,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, {:set_input_value, @cid, "i1", "value"} @@ -962,7 +965,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, evaluate_cells_operations(["c2"], %{bind_inputs: %{"c2" => ["i1"]}}) @@ -1090,7 +1093,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 1, "s2"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -1177,7 +1180,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, {:insert_cell, @cid, "s1", 3, :code, "c4", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4"], uses: %{"c2" => ["c1"], "c3" => ["c2"], "c4" => ["c1"]} ) ]) @@ -1203,7 +1206,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3"], uses: %{"c2" => :unknown} ) ]) @@ -1227,7 +1230,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :markdown, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]) + evaluate_cells_operations([@setup_id, "c1"]) ]) operation = {:move_cell, @cid, "c2", -1} @@ -1248,7 +1251,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2", "c3"], []} ]) @@ -1275,7 +1278,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c4", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4"], uses: %{"c4" => ["c2"]} ) ]) @@ -1301,7 +1304,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3"], uses: %{"c2" => ["c1"], "c3" => ["c1"]} ) ]) @@ -1419,7 +1422,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 1, :code, "c4", %{}}, {:insert_cell, @cid, "s3", 2, :code, "c5", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4", "c5"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4", "c5"], uses: %{"c2" => ["c1"], "c3" => ["c1"], "c4" => ["c2"]} ) ]) @@ -1448,7 +1451,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 2, "s3"}, {:insert_cell, @cid, "s3", 0, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2", "c3"], []} ]) @@ -1480,7 +1483,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s4", 0, :code, "c4", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4"], uses: %{"c2" => ["c3"]} ) ]) @@ -1499,6 +1502,108 @@ defmodule Livebook.Session.DataTest do end end + describe "apply_operation/2 given :enable_language" do + test "returns an error if the language is already enabled" do + data = + data_after_operations!([ + {:enable_language, @cid, :python} + ]) + + operation = {:enable_language, @cid, :python} + assert :error = Data.apply_operation(data, operation) + end + + test "adds extra setup cell" do + data = Data.new() + + operation = {:enable_language, @cid, :python} + + assert {:ok, + %{ + notebook: %{ + setup_section: %{ + cells: [ + %Notebook.Cell.Code{}, + %Notebook.Cell.Code{id: @pyproject_setup_id} + ] + } + }, + cell_infos: %{@pyproject_setup_id => _} + }, []} = Data.apply_operation(data, operation) + end + + test "updates the notebook default language" do + data = Data.new() + + operation = {:enable_language, @cid, :python} + + assert {:ok, %{notebook: %{default_language: :python}}, []} = + Data.apply_operation(data, operation) + end + end + + describe "apply_operation/2 given :disable_language" do + test "returns an error if the language is not enabled" do + data = Data.new() + + operation = {:disable_language, @cid, :python} + assert :error = Data.apply_operation(data, operation) + end + + test "removes extra setup cell" do + data = + data_after_operations!([ + {:enable_language, @cid, :python}, + connect_noop_runtime_operations(), + evaluate_cells_operations([@setup_id, @pyproject_setup_id]) + ]) + + operation = {:disable_language, @cid, :python} + + assert {:ok, + %{ + notebook: %{setup_section: %{cells: [%Notebook.Cell.Code{}]}} + }, + [{:forget_evaluation, %{id: @pyproject_setup_id}, %{id: "setup-section"}}]} = + Data.apply_operation(data, operation) + end + + test "updates the notebook default language" do + data = + data_after_operations!([ + {:enable_language, @cid, :python} + ]) + + operation = {:disable_language, @cid, :python} + + assert {:ok, %{notebook: %{default_language: :elixir}}, []} = + Data.apply_operation(data, operation) + end + + test "marks all cells as stale" do + data = + data_after_operations!([ + {:enable_language, @cid, :python}, + {:insert_section, @cid, 0, "s1"}, + {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, + {:insert_cell, @cid, "s1", 1, :code, "c2", %{language: :python}}, + connect_noop_runtime_operations(), + evaluate_cells_operations([@setup_id, @pyproject_setup_id, "c1", "c2"]) + ]) + + operation = {:disable_language, @cid, :python} + + assert {:ok, + %{ + cell_infos: %{ + @setup_id => %{eval: %{validity: :stale}}, + "c1" => %{eval: %{validity: :stale}}, + "c2" => %{eval: %{validity: :stale}} + } + }, _actions} = Data.apply_operation(data, operation) + end + end + describe "apply_operation/2 given :queue_cells_evaluation" do test "returns an error given an empty list of cells" do data = Data.new() @@ -1529,7 +1634,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -1588,7 +1693,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]) + evaluate_cells_operations([@setup_id]) ]) operation = {:queue_cells_evaluation, @cid, ["c1"], []} @@ -1610,7 +1715,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]) + evaluate_cells_operations([@setup_id]) ]) operation = {:queue_cells_evaluation, @cid, ["c1"], []} @@ -1626,7 +1731,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -1651,7 +1756,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 1, "s2"}, {:insert_cell, @cid, "s2", 0, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -1681,7 +1786,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s2", 1, :code, "c4", %{}}, # Evaluate first 2 cells connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"], uses: %{"c2" => ["c1"]}), + evaluate_cells_operations([@setup_id, "c1", "c2"], uses: %{"c2" => ["c1"]}), # Evaluate the first cell, so the second becomes stale evaluate_cells_operations(["c1"], versions: %{"c1" => 1}) ]) @@ -1728,7 +1833,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c3", %{}}, {:set_section_parent, @cid, "s3", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]) + evaluate_cells_operations([@setup_id]) ]) operation = {:queue_cells_evaluation, @cid, ["c3"], []} @@ -1765,7 +1870,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c3", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c3"], []} ]) @@ -1796,7 +1901,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s2", 0, :code, "c2", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]) + evaluate_cells_operations([@setup_id, "c1"]) ]) operation = {:queue_cells_evaluation, @cid, ["c2"], []} @@ -1826,7 +1931,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c4", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"]), + evaluate_cells_operations([@setup_id, "c1", "c2"]), {:queue_cells_evaluation, @cid, ["c4"], []} ]) @@ -1859,7 +1964,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c3", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c2"], []} ]) @@ -1882,7 +1987,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]) + evaluate_cells_operations([@setup_id]) ]) evaluation_opts = [disable_dependencies_cache: true] @@ -1906,19 +2011,19 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]) + evaluate_cells_operations([@setup_id]) ]) runtime = data.runtime evaluation_opts = [disable_dependencies_cache: true] - operation = {:queue_cells_evaluation, @cid, ["setup"], evaluation_opts} + operation = {:queue_cells_evaluation, @cid, [@setup_id], evaluation_opts} assert {:ok, %{ runtime_status: :connecting, cell_infos: %{ - "setup" => %{eval: %{status: :queued, evaluation_opts: ^evaluation_opts}} + @setup_id => %{eval: %{status: :queued, evaluation_opts: ^evaluation_opts}} } } = new_data, [{:disconnect_runtime, ^runtime}, :connect_runtime]} = @@ -1926,6 +2031,24 @@ defmodule Livebook.Session.DataTest do assert new_data.section_infos["s1"].evaluation_queue == MapSet.new([]) end + + test "marks all setup section cells as queued when the setup cell is queued" do + data = + data_after_operations!([ + {:enable_language, @cid, :python}, + connect_noop_runtime_operations() + ]) + + operation = {:queue_cells_evaluation, @cid, [@setup_id], []} + + assert {:ok, + %{ + cell_infos: %{ + @setup_id => %{eval: %{status: :evaluating}}, + @pyproject_setup_id => %{eval: %{status: :queued}} + } + }, _actions} = Data.apply_operation(data, operation) + end end describe "apply_operation/2 given :add_cell_evaluation_output" do @@ -1935,7 +2058,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -1959,7 +2082,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]) + evaluate_cells_operations([@setup_id, "c1"]) ]) operation = {:add_cell_evaluation_output, @cid, "c1", @stdout} @@ -1982,7 +2105,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:set_notebook_attributes, @cid, %{persist_outputs: true}}, {:notebook_saved, @cid, []} @@ -1999,7 +2122,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -2023,7 +2146,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -2047,7 +2170,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -2071,7 +2194,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), # Evaluate the first cell evaluate_cells_operations(["c1"]), # Start evaluating the second cell @@ -2095,7 +2218,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []} ]) @@ -2122,7 +2245,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 1, "s2"}, {:insert_cell, @cid, "s2", 0, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []} ]) @@ -2153,7 +2276,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s2", 0, :code, "c3", %{}}, {:insert_cell, @cid, "s2", 1, :code, "c4", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4"], uses: %{"c2" => ["c1"], "c4" => ["c2"]} ), {:queue_cells_evaluation, @cid, ["c1"], []} @@ -2180,7 +2303,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3"], uses: %{"c2" => :unknown} ), {:queue_cells_evaluation, @cid, ["c1"], []} @@ -2209,7 +2332,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 3, :code, "c4", %{}}, {:insert_cell, @cid, "s1", 4, :code, "c5", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4", "c5"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4", "c5"], uses: %{"c2" => ["c1"], "c3" => ["c2"]} ), {:queue_cells_evaluation, @cid, ["c1", "c5"], []} @@ -2249,7 +2372,7 @@ defmodule Livebook.Session.DataTest do {:set_section_parent, @cid, "s3", "s2"}, {:set_section_parent, @cid, "s4", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4"], uses: %{"c3" => ["c2"], "c4" => ["c1", "c2"]} ), {:queue_cells_evaluation, @cid, ["c2"], []} @@ -2280,7 +2403,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, {:set_cell_attributes, @cid, "c3", %{reevaluate_automatically: true}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3"], uses: %{"c2" => ["c1"], "c3" => ["c2"]} ), {:queue_cells_evaluation, @cid, ["c1"], []} @@ -2306,7 +2429,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, {:set_cell_attributes, @cid, "c2", %{reevaluate_automatically: true}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -2331,7 +2454,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s2", 0, :code, "c3", %{}}, {:insert_cell, @cid, "s2", 1, :code, "c4", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2", "c3", "c4"], []} ]) @@ -2361,7 +2484,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, {:set_cell_attributes, @cid, "c3", %{reevaluate_automatically: true}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3"], uses: %{"c2" => ["c1"], "c3" => ["c2"]} ), {:queue_cells_evaluation, @cid, ["c1"], []} @@ -2391,7 +2514,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, {:set_cell_attributes, @cid, "c3", %{reevaluate_automatically: true}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3"], uses: %{"c2" => ["c1"], "c3" => ["c2"]} ), {:queue_cells_evaluation, @cid, ["c1"], []}, @@ -2420,7 +2543,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, {:add_cell_evaluation_response, @cid, "c2", @eval_resp, eval_meta()}, @@ -2448,7 +2571,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -2468,7 +2591,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:set_notebook_attributes, @cid, %{persist_outputs: true}}, {:notebook_saved, @cid, []} @@ -2485,7 +2608,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -2503,7 +2626,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -2519,7 +2642,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, {:set_input_value, @cid, "i1", "value"}, @@ -2539,7 +2662,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, {:set_input_value, @cid, "i1", "value"}, @@ -2563,7 +2686,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, {:add_cell_evaluation_response, @cid, "c2", @input, eval_meta()}, @@ -2589,7 +2712,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s2", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s3", 0, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, {:set_input_value, @cid, "i1", "value"}, @@ -2611,7 +2734,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :smart, "c2", %{kind: "text"}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:set_smart_cell_definitions, @cid, @smart_cell_definitions}, {:smart_cell_started, @cid, "c2", Delta.new(), nil, %{}, nil}, {:queue_cells_evaluation, @cid, ["c1"], []} @@ -2622,7 +2745,7 @@ defmodule Livebook.Session.DataTest do assert {:ok, %{}, [ {:set_smart_cell_parents, %{id: "c2"}, %{id: "s1"}, - [{%{id: "c1"}, %{id: "s1"}}, {%{id: "setup"}, %{id: "setup-section"}}]} + [{%{id: "c1"}, %{id: "s1"}}, {%{id: @setup_id}, %{id: "setup-section"}}]} ]} = Data.apply_operation(data, operation) end end @@ -2640,7 +2763,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -2662,7 +2785,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_doctest_report, @cid, "c1", %{status: :running, line: 5}} ]) @@ -2711,7 +2834,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, {:queue_cells_evaluation, @cid, ["c2"], []} @@ -2739,7 +2862,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c2", "c3"], []} ]) @@ -2770,7 +2893,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s2", 1, :code, "c3", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"]), + evaluate_cells_operations([@setup_id, "c1", "c2"]), {:queue_cells_evaluation, @cid, ["c3"], []} ]) @@ -2807,7 +2930,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c4", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"]), + evaluate_cells_operations([@setup_id, "c1", "c2"]), {:queue_cells_evaluation, @cid, ["c3", "c4"], []} ]) @@ -2847,7 +2970,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]) + evaluate_cells_operations([@setup_id, "c1"]) ]) operation = {:cancel_cell_evaluation, @cid, "c1"} @@ -2863,7 +2986,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 1, "s2"}, {:insert_cell, @cid, "s2", 0, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c2", "c3"], []} ]) @@ -2893,7 +3016,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []} ]) @@ -2915,7 +3038,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c4", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c2", "c3", "c4"], []} ]) @@ -2948,7 +3071,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []} ]) @@ -2975,7 +3098,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2", "c3"], []} ]) @@ -3085,7 +3208,7 @@ defmodule Livebook.Session.DataTest do data_after_operations!([ {:insert_section, @cid, 0, "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:set_smart_cell_definitions, @cid, @smart_cell_definitions}, {:insert_cell, @cid, "s1", 0, :smart, "c1", %{kind: "text"}}, {:smart_cell_started, @cid, "c1", Delta.new(), nil, %{}, nil} @@ -3103,7 +3226,7 @@ defmodule Livebook.Session.DataTest do data_after_operations!([ {:insert_section, @cid, 0, "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:set_smart_cell_definitions, @cid, @smart_cell_definitions}, {:insert_cell, @cid, "s1", 0, :smart, "c1", %{kind: "text"}}, {:smart_cell_started, @cid, "c1", Delta.new(), nil, %{}, nil}, @@ -3180,7 +3303,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s2", 0, :code, "c3", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c2", "c3"], []} ]) @@ -3211,7 +3334,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :markdown, "c2", %{}}, {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c3"]) + evaluate_cells_operations([@setup_id, "c1", "c3"]) ]) operation = {:erase_outputs, @cid} @@ -3239,7 +3362,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:add_cell_doctest_report, @cid, "c1", %{status: :running, line: 5}} ]) @@ -3745,7 +3868,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"], uses: %{"c2" => ["c1"]}), + evaluate_cells_operations([@setup_id, "c1", "c2"], uses: %{"c2" => ["c1"]}), evaluate_cells_operations(["c1"], versions: %{"c1" => 1}) ]) @@ -3760,6 +3883,26 @@ defmodule Livebook.Session.DataTest do } }, _} = Data.apply_operation(data, operation) end + + test "setting language on evaluated cell marks it as stale" do + data = + data_after_operations!([ + {:insert_section, @cid, 0, "s1"}, + {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, + connect_noop_runtime_operations(), + evaluate_cells_operations([@setup_id, "c1"]) + ]) + + attrs = %{language: :erlang} + operation = {:set_cell_attributes, @cid, "c1", attrs} + + assert {:ok, + %{ + cell_infos: %{ + "c1" => %{eval: %{validity: :stale}} + } + }, _} = Data.apply_operation(data, operation) + end end describe "apply_operation/2 given :set_input_value" do @@ -3776,7 +3919,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()} ]) @@ -3797,7 +3940,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, {:insert_cell, @cid, "s1", 3, :code, "c4", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, evaluate_cells_operations(["c2", "c3", "c4"], @@ -3847,7 +3990,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -3917,7 +4060,7 @@ defmodule Livebook.Session.DataTest do data_after_operations!([ {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, - {:queue_cells_evaluation, @cid, ["setup"], []} + {:queue_cells_evaluation, @cid, [@setup_id], []} ]) runtime = Livebook.Runtime.Embedded.new() @@ -3926,13 +4069,13 @@ defmodule Livebook.Session.DataTest do assert {:ok, %{ cell_infos: %{ - "setup" => %{eval: %{status: :evaluating}} + @setup_id => %{eval: %{status: :evaluating}} }, section_infos: %{ - "setup-section" => %{evaluating_cell_id: "setup"} + "setup-section" => %{evaluating_cell_id: @setup_id} } } = new_data, - [{:start_evaluation, %{id: "setup"}, %{id: "setup-section"}, []}]} = + [{:start_evaluation, %{id: @setup_id}, %{id: "setup-section"}, []}]} = Data.apply_operation(data, operation) assert new_data.section_infos["setup-section"].evaluation_queue == MapSet.new([]) @@ -3972,7 +4115,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s2", 0, :code, "c3", %{}}, {:insert_cell, @cid, "s2", 1, :code, "c4", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2", "c3", "c4"], []} ]) @@ -4031,7 +4174,7 @@ defmodule Livebook.Session.DataTest do {:insert_section, @cid, 0, "s1"}, {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -4372,7 +4515,7 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!(Data.new(mode: :app), [ connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]) + evaluate_cells_operations([@setup_id]) ]) operation = {:app_shutdown, @cid} @@ -4385,7 +4528,7 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!(Data.new(mode: :app), [ connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:client_join, @cid, User.new()} ]) @@ -4404,7 +4547,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []} ]) @@ -4421,7 +4564,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []} ]) @@ -4439,7 +4582,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c2"], []} ]) @@ -4456,7 +4599,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []} ]) @@ -4473,7 +4616,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"]) + evaluate_cells_operations([@setup_id, "c1", "c2"]) ]) operation = {:reflect_main_evaluation_failure, @cid} @@ -4481,7 +4624,7 @@ defmodule Livebook.Session.DataTest do assert {:ok, %{ cell_infos: %{ - "setup" => %{eval: %{status: :queued}}, + @setup_id => %{eval: %{status: :queued}}, "c1" => %{eval: %{status: :queued}}, "c2" => %{eval: %{status: :queued}} }, @@ -4498,7 +4641,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"]) + evaluate_cells_operations([@setup_id, "c1", "c2"]) ]) operation = {:runtime_down, @cid} @@ -4506,7 +4649,7 @@ defmodule Livebook.Session.DataTest do assert {:ok, %{ cell_infos: %{ - "setup" => %{eval: %{status: :queued}}, + @setup_id => %{eval: %{status: :queued}}, "c1" => %{eval: %{status: :queued}}, "c2" => %{eval: %{status: :queued}} }, @@ -4521,10 +4664,10 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"]), + evaluate_cells_operations([@setup_id, "c1", "c2"]), {:reflect_main_evaluation_failure, @cid}, {:runtime_connected, @cid, Livebook.Runtime.NoopRuntime.new()}, - {:add_cell_evaluation_response, @cid, "setup", @eval_resp, eval_meta()}, + {:add_cell_evaluation_response, @cid, @setup_id, @eval_resp, eval_meta()}, {:add_cell_evaluation_response, @cid, "c1", @eval_resp, eval_meta()} ]) @@ -4541,7 +4684,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - {:queue_cells_evaluation, @cid, ["setup"], []} + {:queue_cells_evaluation, @cid, [@setup_id], []} ]) operation = {:runtime_down, @cid} @@ -4557,7 +4700,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c2"], []} ]) @@ -4575,7 +4718,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1"]), + evaluate_cells_operations([@setup_id, "c1"]), {:queue_cells_evaluation, @cid, ["c2"], []} ]) @@ -4590,7 +4733,7 @@ defmodule Livebook.Session.DataTest do data = data_after_operations!(Data.new(mode: :app), [ connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:client_join, @cid, User.new()}, {:app_shutdown, @cid} ]) @@ -4660,7 +4803,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, {:insert_cell, @cid, "s1", 4, :code, "c4", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, evaluate_cells_operations(["c2", "c3", "c4"], %{ @@ -4682,7 +4825,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, {:insert_cell, @cid, "s1", 3, :code, "c4", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4"], uses: %{"c4" => ["c2"]} ), # Modify cell 2 @@ -4700,7 +4843,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c3"]), + evaluate_cells_operations([@setup_id, "c1", "c3"]), # Insert a fresh cell between cell 1 and cell 3 {:insert_cell, @cid, "s1", 1, :code, "c2", %{}} ]) @@ -4715,7 +4858,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"], + evaluate_cells_operations([@setup_id, "c1", "c2"], uses: %{"c2" => ["c1"]} ), # Reevaluate cell 1 @@ -4734,7 +4877,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, {:insert_cell, @cid, "s1", 3, :code, "c4", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c3", "c4"], + evaluate_cells_operations([@setup_id, "c1", "c2", "c3", "c4"], uses: %{"c4" => ["c2"]} ) ]) @@ -4763,14 +4906,14 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"]), + evaluate_cells_operations([@setup_id, "c1", "c2"]), # Modify the setup cell {:client_join, @cid, User.new()}, - {:apply_cell_delta, @cid, "setup", :primary, Delta.new() |> Delta.insert("cats"), nil, + {:apply_cell_delta, @cid, @setup_id, :primary, Delta.new() |> Delta.insert("cats"), nil, 0} ]) - assert Data.cell_ids_for_full_evaluation(data, []) |> Enum.sort() == ["c1", "c2", "setup"] + assert Data.cell_ids_for_full_evaluation(data, []) |> Enum.sort() == ["c1", "c2", @setup_id] end end @@ -4780,7 +4923,7 @@ defmodule Livebook.Session.DataTest do data_after_operations!([ {:insert_section, @cid, 0, "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]) + evaluate_cells_operations([@setup_id]) ]) assert Data.cell_ids_for_reevaluation(data) == [] @@ -4793,7 +4936,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"]) + evaluate_cells_operations([@setup_id, "c1", "c2"]) ]) assert Data.cell_ids_for_reevaluation(data) |> Enum.sort() == ["c1", "c2"] @@ -4806,7 +4949,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"], + evaluate_cells_operations([@setup_id, "c1", "c2"], uses: %{"c2" => ["c1"]} ), # Reevaluate cell 1 @@ -4823,7 +4966,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2"]), + evaluate_cells_operations([@setup_id, "c1", "c2"]), # Insert a new cell between the two evaluated cells {:insert_cell, @cid, "s1", 1, :code, "c3", %{}} ]) @@ -4843,7 +4986,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s3", 0, :code, "c4", %{}}, {:set_section_parent, @cid, "s2", "s1"}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup", "c1", "c2", "c4"]) + evaluate_cells_operations([@setup_id, "c1", "c2", "c4"]) ]) assert Data.cell_ids_for_reevaluation(data) |> Enum.sort() == ["c1", "c2", "c4"] @@ -4880,7 +5023,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, {:insert_cell, @cid, "s1", 4, :code, "c4", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1", "c2"], []}, {:add_cell_evaluation_response, @cid, "c1", input1, eval_meta()}, {:add_cell_evaluation_response, @cid, "c2", input2, eval_meta()}, @@ -4901,7 +5044,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, {:insert_cell, @cid, "s1", 2, :code, "c3", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, evaluate_cells_operations(["c2", "c3"], %{ @@ -4922,7 +5065,7 @@ defmodule Livebook.Session.DataTest do {:insert_cell, @cid, "s1", 0, :code, "c1", %{}}, {:insert_cell, @cid, "s1", 1, :code, "c2", %{}}, connect_noop_runtime_operations(), - evaluate_cells_operations(["setup"]), + evaluate_cells_operations([@setup_id]), {:queue_cells_evaluation, @cid, ["c1"], []}, {:add_cell_evaluation_response, @cid, "c1", @input, eval_meta()}, evaluate_cells_operations(["c2"], %{bind_inputs: %{"c2" => ["i1"]}}), diff --git a/test/livebook/session_test.exs b/test/livebook/session_test.exs index 08d58c37f..901daddd0 100644 --- a/test/livebook/session_test.exs +++ b/test/livebook/session_test.exs @@ -21,6 +21,8 @@ defmodule Livebook.SessionTest do code_markers: [] } + @setup_id Notebook.Cell.main_setup_cell_id() + describe "file_name_for_download/1" do @tag :tmp_dir test "uses associated file name if one is attached", %{tmp_dir: tmp_dir} do @@ -130,6 +132,58 @@ defmodule Livebook.SessionTest do end end + describe "enable_language/2" do + test "sends setup cell diff and enable language operation to subscribers" do + session = start_session() + + Session.subscribe(session.id) + + Session.enable_language(session.pid, :python) + + assert_receive {:operation, + {:apply_cell_delta, _client_id, @setup_id, :primary, _delta, _selection, 0}} + + assert_receive {:operation, {:enable_language, _client_id, :python}} + end + + test "if there is a single empty cell, changes its language" do + session = start_session() + + Session.subscribe(session.id) + + Session.enable_language(session.pid, :python) + + assert_receive {:operation, + {:set_cell_attributes, _client_id, _cell_id, %{language: :python}}} + end + end + + describe "disable_language/2" do + test "sends a disable language operation to subscribers" do + session = start_session() + + Session.subscribe(session.id) + + Session.enable_language(session.pid, :python) + assert_receive {:operation, {:enable_language, _client_id, :python}} + + Session.disable_language(session.pid, :python) + + assert_receive {:operation, {:disable_language, _client_id, :python}} + end + + test "if there is a single empty cell, changes its language" do + session = start_session() + + Session.subscribe(session.id) + + Session.enable_language(session.pid, :python) + + assert_receive {:operation, + {:set_cell_attributes, _client_id, _cell_id, %{language: :python}}} + end + end + describe "recover_smart_cell/2" do test "sends a recover operations to subscribers and starts the smart cell" do smart_cell = %{Notebook.Cell.new(:smart) | kind: "text", source: "content"} @@ -224,7 +278,8 @@ defmodule Livebook.SessionTest do Session.add_dependencies(session.pid, [%{dep: {:req, "~> 0.5.0"}, config: []}]) assert_receive {:operation, - {:apply_cell_delta, "__server__", "setup", :primary, _delta, _selection, 0}} + {:apply_cell_delta, "__server__", @setup_id, :primary, _delta, _selection, + 0}} assert %{ notebook: %{ @@ -244,7 +299,7 @@ defmodule Livebook.SessionTest do end test "broadcasts an error if modifying the setup source fails" do - notebook = Notebook.new() |> Notebook.update_cell("setup", &%{&1 | source: "[,]"}) + notebook = Notebook.new() |> Notebook.update_cell(@setup_id, &%{&1 | source: "[,]"}) session = start_session(notebook: notebook) Session.subscribe(session.id) @@ -1121,7 +1176,7 @@ defmodule Livebook.SessionTest do Session.queue_cell_evaluation(session.pid, smart_cell.id) - send(session.pid, {:runtime_evaluation_response, "setup", {:ok, ""}, @eval_meta}) + send(session.pid, {:runtime_evaluation_response, @setup_id, {:ok, ""}, @eval_meta}) session_pid = session.pid assert_receive {:ping, ^session_pid, metadata, %{ref: "ref"}} @@ -1159,11 +1214,11 @@ defmodule Livebook.SessionTest do {:connect_runtime, self()}, {:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()}, {:queue_cells_evaluation, self(), ["c1"], []}, - {:add_cell_evaluation_response, self(), "setup", {:ok, nil}, @eval_meta}, + {:add_cell_evaluation_response, self(), @setup_id, {:ok, nil}, @eval_meta}, {:add_cell_evaluation_response, self(), "c1", {:ok, nil}, @eval_meta} ]) - assert [{:main_flow, "c1"}, {:main_flow, "setup"}] = + assert [{:main_flow, "c1"}, {:main_flow, @setup_id}] = Session.parent_locators_for_cell(data, cell3) end @@ -1190,11 +1245,12 @@ defmodule Livebook.SessionTest do {:connect_runtime, self()}, {:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()}, {:queue_cells_evaluation, self(), ["c1"], []}, - {:add_cell_evaluation_response, self(), "setup", {:ok, nil}, @eval_meta}, + {:add_cell_evaluation_response, self(), @setup_id, {:ok, nil}, @eval_meta}, {:add_cell_evaluation_response, self(), "c1", {:ok, nil}, @eval_meta} ]) - assert [{"s2", "c1"}, {:main_flow, "setup"}] = Session.parent_locators_for_cell(data, cell3) + assert [{"s2", "c1"}, {:main_flow, @setup_id}] = + Session.parent_locators_for_cell(data, cell3) end test "given cell in main flow returns an empty list if there is no previous cell" do @@ -1223,11 +1279,11 @@ defmodule Livebook.SessionTest do {:connect_runtime, self()}, {:runtime_connected, self(), Livebook.Runtime.NoopRuntime.new()}, {:queue_cells_evaluation, self(), ["c1"], []}, - {:add_cell_evaluation_response, self(), "setup", {:ok, nil}, @eval_meta}, + {:add_cell_evaluation_response, self(), @setup_id, {:ok, nil}, @eval_meta}, {:add_cell_evaluation_response, self(), "c1", {:ok, nil}, @eval_meta} ]) - assert [{:main_flow, "c1"}, {:main_flow, "setup"}] = + assert [{:main_flow, "c1"}, {:main_flow, @setup_id}] = Session.parent_locators_for_cell(data, cell3) data = diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index c55957278..a093a35ce 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -619,6 +619,72 @@ defmodule LivebookWeb.SessionLiveTest do assert_receive {:runtime_file_path_reply, {:ok, path}} assert File.read!(path) == "content" end + + test "enabling a language", %{conn: conn, session: session} do + {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}") + + view + |> element("[data-el-language-buttons] button", "Python") + |> render_click() + + assert %{ + notebook: %{ + setup_section: %{cells: [%Cell.Code{}, %Cell.Code{language: :"pyproject.toml"}]}, + default_language: :python + } + } = Session.get_data(session.pid) + + refute view + |> element("[data-el-language-buttons] button", "Python") + |> has_element?() + end + + test "disabling a language", %{conn: conn, session: session} do + Session.enable_language(session.pid, :python) + + {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}") + + refute view + |> element("[data-el-language-buttons] button", "Python") + |> has_element?() + + view + |> element(~s/button[phx-click="disable_language"]/) + |> render_click() + + assert %{notebook: %{setup_section: %{cells: [%Cell.Code{}]}}} = + Session.get_data(session.pid) + + assert view + |> element("[data-el-language-buttons] button", "Python") + |> has_element?() + end + + test "changing cell language", %{conn: conn, session: session} do + {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}") + + section_id = insert_section(session.pid) + cell_id = insert_text_cell(session.pid, section_id, :code) + + view + |> element(~s/#cell-#{cell_id} button/, "Erlang") + |> render_click() + + assert %{notebook: %{sections: [%{cells: [%Cell.Code{language: :erlang}]}]}} = + Session.get_data(session.pid) + end + + test "shows an error when a cell langauge is not enabled", %{conn: conn, session: session} do + section_id = insert_section(session.pid) + cell_id = insert_text_cell(session.pid, section_id, :code) + + Session.set_cell_attributes(session.pid, cell_id, %{language: :python}) + + {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}") + + assert render(view) =~ "Python is not enabled for the current notebook." + assert render(view) =~ "Enable Python" + end end describe "outputs" do @@ -2882,4 +2948,32 @@ defmodule LivebookWeb.SessionLiveTest do after Code.put_compiler_option(:debug_info, false) end + + test "python code evaluation end-to-end", %{conn: conn, session: session} do + # Use the standalone runtime, to install Pythonx and setup the interpreter + Session.set_runtime(session.pid, Runtime.Standalone.new()) + + {:ok, view, _} = live(conn, ~p"/sessions/#{session.id}") + + Session.subscribe(session.id) + + view + |> element("[data-el-language-buttons] button", "Python") + |> render_click() + + section_id = insert_section(session.pid) + + cell_id = + insert_text_cell(session.pid, section_id, :code, "len([1, 2])", %{language: :python}) + + view + |> element(~s{[data-el-session]}) + |> render_hook("queue_cell_evaluation", %{"cell_id" => cell_id}) + + assert_receive {:operation, + {:add_cell_evaluation_response, _, ^cell_id, terminal_text(output), _}}, + 20_000 + + assert output == "2" + end end diff --git a/test/support/session_helpers.ex b/test/support/session_helpers.ex index 620e45912..d52dcd287 100644 --- a/test/support/session_helpers.ex +++ b/test/support/session_helpers.ex @@ -44,8 +44,8 @@ defmodule Livebook.SessionHelpers do section.id end - def insert_text_cell(session_pid, section_id, type, content \\ " ") do - Session.insert_cell(session_pid, section_id, 0, type, %{source: content}) + def insert_text_cell(session_pid, section_id, type, content \\ " ", attrs \\ %{}) do + Session.insert_cell(session_pid, section_id, 0, type, Map.merge(attrs, %{source: content})) data = Session.get_data(session_pid) {:ok, section} = Livebook.Notebook.fetch_section(data.notebook, section_id) cell = hd(section.cells)