mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-11 22:26:26 +08:00
Add support for Python cells (#2936)
Co-authored-by: José Valim <jose.valim@dashbit.co>
This commit is contained in:
parent
5309030aaa
commit
015b44fb72
40 changed files with 1755 additions and 471 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -44,82 +44,92 @@ defmodule LivebookWeb.NotebookComponents do
|
|||
|
||||
def cell_icon(assigns)
|
||||
|
||||
def cell_icon(%{cell_type: :code, language: :elixir} = assigns) do
|
||||
def cell_icon(%{cell_type: :code} = assigns) do
|
||||
~H"""
|
||||
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-purple-100">
|
||||
<.language_icon language="elixir" class="w-full h-full text-[#663299]" />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def cell_icon(%{cell_type: :code, language: :erlang} = assigns) do
|
||||
~H"""
|
||||
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-red-100">
|
||||
<.language_icon language="erlang" class="w-full h-full text-[#a90533]" />
|
||||
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-gray-100">
|
||||
<.language_icon language={Atom.to_string(@language)} class="w-full h-full text-gray-600" />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def cell_icon(%{cell_type: :markdown} = assigns) do
|
||||
~H"""
|
||||
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-blue-100">
|
||||
<.language_icon language="markdown" class="w-full h-full text-[#3e64ff]" />
|
||||
<div class="w-6 h-6 p-1 rounded flex items-center justify-center bg-gray-100">
|
||||
<.language_icon language="markdown" class="w-full h-full text-gray-600" />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def cell_icon(%{cell_type: :smart} = assigns) do
|
||||
~H"""
|
||||
<div class="flex w-6 h-6 bg-red-100 rounded items-center justify-center">
|
||||
<.remix_icon icon="flashlight-line text-red-900" />
|
||||
<div class="flex w-6 h-6 p-1 rounded items-center justify-center bg-gray-100">
|
||||
<.remix_icon icon="flashlight-line text-gray-600" />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an icon for the given language.
|
||||
|
||||
The icons are adapted from https://github.com/material-extensions/vscode-material-icon-theme.
|
||||
"""
|
||||
attr :language, :string, required: true
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def language_icon(%{language: "elixir"} = assigns) do
|
||||
~H"""
|
||||
<svg class={@class} viewBox="0 0 11 15" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class={@class} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5.7784 3.58083C7.4569 5.87527 9.67878 5.70652 10.0618 9.04833C10.1147 12.9425 8.03684
|
||||
14.27 6.55353 14.6441C4.02227 15.3635 1.7644 14.2813 0.875648 11.8316C-0.83154 7.89408 2.36684
|
||||
1.41746 4.42502 0.0668945C4.60193 1.32119 5.05745 2.51995 5.75815 3.57521L5.7784 3.58083Z"
|
||||
fill="currentColor"
|
||||
>
|
||||
</path>
|
||||
d="M12.173 22.681c-3.86 0-6.99-3.64-6.99-8.13 0-3.678 2.773-8.172 4.916-10.91 1.014-1.296 2.93-2.322 2.93-2.322s-.982 5.239 1.683 7.319c2.366 1.847 4.106 4.25 4.106 6.363 0 4.232-2.784 7.68-6.645 7.68"
|
||||
/>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
def language_icon(%{language: "erlang"} = assigns) do
|
||||
~H"""
|
||||
<svg class={@class} viewBox="0 0 15 10" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="currentColor">
|
||||
<path d="M2.4 10A7.7 7.7 0 0 1 .5 4.8c0-2 .6-3.6 1.6-4.8H0v10ZM13 10c.5-.6 1-1.2 1.4-2l-2.3-1.2c-.8 1.4-2 2.6-3.6 2.6-2.3 0-3.2-2-3.2-4.8H14V4c0-1.6-.3-3-1-4H15v10h-2Zm0 0" />
|
||||
<path d="M5.5 2.3c.1-1.2 1-2 2.1-2s1.9.8 2 2Zm0 0" />
|
||||
</g>
|
||||
<svg class={@class} viewBox="0 0 30 30" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.207 4.33q-.072.075-.143.153Q1.5 8.476 1.5 15.33c0 4.418 1.155 7.862 3.459 10.34h19.415c2.553-1.152 4.127-3.43 4.127-3.43l-3.147-2.52L23.9 21.1c-.867.773-.845.931-2.315 1.78-1.495.674-3.04.966-4.634.966-2.515 0-4.423-.909-5.723-2.059-1.286-1.15-1.985-4.511-2.096-6.68l17.458.067-.183-1.472s-.847-7.129-2.541-9.372zm8.76.846c1.565 0 3.22.535 3.961 1.471.74.937.931 1.667.973 3.524H9.11c.112-1.955.436-2.81 1.373-3.698.936-.887 2.03-1.297 3.484-1.297"
|
||||
/>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
def language_icon(%{language: "markdown"} = assigns) do
|
||||
~H"""
|
||||
<svg class={@class} viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class={@class} viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M1.25 0.25H14.75C14.9489 0.25 15.1397 0.329018 15.2803 0.46967C15.421 0.610322 15.5 0.801088
|
||||
15.5 1V13C15.5 13.1989 15.421 13.3897 15.2803 13.5303C15.1397 13.671 14.9489 13.75 14.75 13.75H1.25C1.05109
|
||||
13.75 0.860322 13.671 0.71967 13.5303C0.579018 13.3897 0.5 13.1989 0.5 13V1C0.5 0.801088 0.579018 0.610322
|
||||
0.71967 0.46967C0.860322 0.329018 1.05109 0.25 1.25 0.25ZM4.25 9.625V6.625L5.75 8.125L7.25
|
||||
6.625V9.625H8.75V4.375H7.25L5.75 5.875L4.25 4.375H2.75V9.625H4.25ZM12.5 7.375V4.375H11V7.375H9.5L11.75
|
||||
9.625L14 7.375H12.5Z"
|
||||
fill="currentColor"
|
||||
d="m14 10-4 3.5L6 10H4v12h4v-6l2 2 2-2v6h4V10zm12 6v-6h-4v6h-4l6 8 6-8z"
|
||||
/>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
def language_icon(%{language: "python"} = assigns) do
|
||||
~H"""
|
||||
<svg class={@class} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9.86 2A2.86 2.86 0 0 0 7 4.86v1.68h4.29c.39 0 .71.57.71.96H4.86A2.86 2.86 0 0 0 2 10.36v3.781a2.86 2.86 0 0 0 2.86 2.86h1.18v-2.68a2.85 2.85 0 0 1 2.85-2.86h5.25c1.58 0 2.86-1.271 2.86-2.851V4.86A2.86 2.86 0 0 0 14.14 2zm-.72 1.61c.4 0 .72.12.72.71s-.32.891-.72.891c-.39 0-.71-.3-.71-.89s.32-.711.71-.711"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17.959 7v2.68a2.85 2.85 0 0 1-2.85 2.859H9.86A2.85 2.85 0 0 0 7 15.389v3.75a2.86 2.86 0 0 0 2.86 2.86h4.28A2.86 2.86 0 0 0 17 19.14v-1.68h-4.291c-.39 0-.709-.57-.709-.96h7.14A2.86 2.86 0 0 0 22 13.64V9.86A2.86 2.86 0 0 0 19.14 7zM8.32 11.513l-.004.004.038-.004zm6.54 7.276c.39 0 .71.3.71.89a.71.71 0 0 1-.71.71c-.4 0-.72-.12-.72-.71s.32-.89.72-.89"
|
||||
/>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
def language_icon(%{language: "pyproject.toml"} = assigns) do
|
||||
~H"""
|
||||
<svg class={@class} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M4 6V4h8v2H9v7H7V6z" />
|
||||
<path fill="currentColor" d="M4 1v1H2v12h2v1H1V1zm8 0v1h2v12h-2v1h3V1z" />
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ defmodule LivebookWeb.Output do
|
|||
defp render_output(%{type: :error, context: :dependencies} = output, %{id: id, cell_id: cell_id}) do
|
||||
assigns = %{message: output.message, id: id, cell_id: cell_id}
|
||||
|
||||
if cell_id == Livebook.Notebook.Cell.setup_cell_id() do
|
||||
if cell_id == Livebook.Notebook.Cell.main_setup_cell_id() do
|
||||
~H"""
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between gap-2" style="color: var(--ansi-color-red);">
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
data-el-cell
|
||||
id={"cell-#{@cell_view.id}"}
|
||||
data-type={@cell_view.type}
|
||||
data-setup={@cell_view[:setup]}
|
||||
data-focusable-id={@cell_view.id}
|
||||
data-js-empty={@cell_view.empty}
|
||||
data-eval-validity={get_in(@cell_view, [:eval, :validity])}
|
||||
|
|
@ -86,56 +87,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
defp render_cell(%{cell_view: %{type: :code}} = assigns) do
|
||||
~H"""
|
||||
<.cell_actions>
|
||||
<:primary>
|
||||
<.cell_evaluation_button
|
||||
session_id={@session_id}
|
||||
cell_id={@cell_view.id}
|
||||
validity={@cell_view.eval.validity}
|
||||
status={@cell_view.eval.status}
|
||||
reevaluate_automatically={@cell_view.reevaluate_automatically}
|
||||
reevaluates_automatically={@cell_view.eval.reevaluates_automatically}
|
||||
/>
|
||||
</:primary>
|
||||
<:secondary>
|
||||
<.cell_settings_button cell_id={@cell_view.id} session_id={@session_id} />
|
||||
<.amplify_output_button />
|
||||
<.cell_link_button cell_id={@cell_view.id} />
|
||||
<.move_cell_up_button cell_id={@cell_view.id} />
|
||||
<.move_cell_down_button cell_id={@cell_view.id} />
|
||||
<.delete_cell_button cell_id={@cell_view.id} />
|
||||
</:secondary>
|
||||
</.cell_actions>
|
||||
<.cell_body>
|
||||
<div class="relative" data-el-cell-body-root>
|
||||
<div class="relative" data-el-editor-box>
|
||||
<.cell_editor
|
||||
cell_id={@cell_view.id}
|
||||
tag="primary"
|
||||
empty={@cell_view.empty}
|
||||
language={@cell_view.language}
|
||||
intellisense
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute bottom-2 right-2" data-el-cell-indicators>
|
||||
<.cell_indicators id={@cell_view.id} cell_view={@cell_view} />
|
||||
</div>
|
||||
</div>
|
||||
<.doctest_summary cell_id={@cell_view.id} doctest_summary={@cell_view.eval.doctest_summary} />
|
||||
<.evaluation_outputs
|
||||
outputs={@streams.outputs}
|
||||
cell_view={@cell_view}
|
||||
session_id={@session_id}
|
||||
session_pid={@session_pid}
|
||||
client_id={@client_id}
|
||||
/>
|
||||
</.cell_body>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_cell(%{cell_view: %{type: :setup}} = assigns) do
|
||||
defp render_cell(%{cell_view: %{type: :code, setup: true, language: :elixir}} = assigns) do
|
||||
~H"""
|
||||
<.cell_actions>
|
||||
<:primary>
|
||||
|
|
@ -183,6 +135,111 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
defp render_cell(
|
||||
%{cell_view: %{type: :code, setup: true, language: :"pyproject.toml"}} = assigns
|
||||
) do
|
||||
~H"""
|
||||
<.cell_actions>
|
||||
<:primary>
|
||||
<div class="flex gap-1 items-center text-gray-500 text-sm">
|
||||
<.language_icon language="python" class="w-4 h-4" />
|
||||
<span>Python (pyproject.toml)</span>
|
||||
</div>
|
||||
</:primary>
|
||||
<:secondary>
|
||||
<.cell_link_button cell_id={@cell_view.id} />
|
||||
<.disable_language_button language={:python} />
|
||||
<.pyproject_toml_cell_info />
|
||||
</:secondary>
|
||||
</.cell_actions>
|
||||
<.cell_body>
|
||||
<div class="relative" data-el-cell-body-root>
|
||||
<div data-el-editor-box>
|
||||
<.cell_editor
|
||||
cell_id={@cell_view.id}
|
||||
tag="primary"
|
||||
empty={@cell_view.empty}
|
||||
language="pyproject.toml"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute bottom-2 right-2" data-el-cell-indicators>
|
||||
<.cell_indicators id={@cell_view.id} cell_view={@cell_view} />
|
||||
</div>
|
||||
</div>
|
||||
<.evaluation_outputs
|
||||
outputs={@streams.outputs}
|
||||
cell_view={@cell_view}
|
||||
session_id={@session_id}
|
||||
session_pid={@session_pid}
|
||||
client_id={@client_id}
|
||||
/>
|
||||
</.cell_body>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_cell(%{cell_view: %{type: :code}} = assigns) do
|
||||
~H"""
|
||||
<.cell_actions>
|
||||
<:primary>
|
||||
<.cell_evaluation_button
|
||||
session_id={@session_id}
|
||||
cell_id={@cell_view.id}
|
||||
validity={@cell_view.eval.validity}
|
||||
status={@cell_view.eval.status}
|
||||
reevaluate_automatically={@cell_view.reevaluate_automatically}
|
||||
reevaluates_automatically={@cell_view.eval.reevaluates_automatically}
|
||||
/>
|
||||
</:primary>
|
||||
<:secondary>
|
||||
<.cell_settings_button cell_id={@cell_view.id} session_id={@session_id} />
|
||||
<.amplify_output_button />
|
||||
<.cell_link_button cell_id={@cell_view.id} />
|
||||
<.move_cell_up_button cell_id={@cell_view.id} />
|
||||
<.move_cell_down_button cell_id={@cell_view.id} />
|
||||
<.delete_cell_button cell_id={@cell_view.id} />
|
||||
</:secondary>
|
||||
</.cell_actions>
|
||||
<.cell_body>
|
||||
<div class="relative" data-el-cell-body-root>
|
||||
<div class="relative" data-el-editor-box>
|
||||
<.cell_editor
|
||||
cell_id={@cell_view.id}
|
||||
tag="primary"
|
||||
empty={@cell_view.empty}
|
||||
language={@cell_view.language}
|
||||
intellisense={@cell_view.language == :elixir}
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute bottom-2 right-2" data-el-cell-indicators>
|
||||
<.cell_indicators id={@cell_view.id} cell_view={@cell_view} langauge_toggle />
|
||||
</div>
|
||||
</div>
|
||||
<div :if={@cell_view.language not in @enabled_languages} class="mt-2">
|
||||
<.message_box kind="error">
|
||||
<div class="flex items-center justify-between">
|
||||
{language_name(@cell_view.language)} is not enabled for the current notebook.
|
||||
<button
|
||||
class="flex gap-1 items-center font-medium text-blue-600"
|
||||
phx-click="enable_language"
|
||||
phx-value-language="python"
|
||||
>
|
||||
Enable Python
|
||||
</button>
|
||||
</div>
|
||||
</.message_box>
|
||||
</div>
|
||||
<.doctest_summary cell_id={@cell_view.id} doctest_summary={@cell_view.eval.doctest_summary} />
|
||||
<.evaluation_outputs
|
||||
outputs={@streams.outputs}
|
||||
cell_view={@cell_view}
|
||||
session_id={@session_id}
|
||||
session_pid={@session_pid}
|
||||
client_id={@client_id}
|
||||
/>
|
||||
</.cell_body>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_cell(%{cell_view: %{type: :smart}} = assigns) do
|
||||
~H"""
|
||||
<.cell_actions>
|
||||
|
|
@ -581,6 +638,20 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
defp disable_language_button(assigns) do
|
||||
~H"""
|
||||
<span class="tooltip top" data-tooltip="Delete">
|
||||
<.icon_button
|
||||
aria-label="delete cell"
|
||||
phx-click="disable_language"
|
||||
phx-value-language={@language}
|
||||
>
|
||||
<.remix_icon icon="delete-bin-6-line" />
|
||||
</.icon_button>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp setup_cell_info(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
|
|
@ -600,6 +671,25 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
defp pyproject_toml_cell_info(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
class="tooltip left"
|
||||
data-tooltip={
|
||||
~s'''
|
||||
This cell specifies the Python environment using pyproject.toml
|
||||
configuration. While standardized to a certain extent, this
|
||||
configuration is used specifically with the uv package manager.\
|
||||
'''
|
||||
}
|
||||
>
|
||||
<.icon_button>
|
||||
<.remix_icon icon="question-line" />
|
||||
</.icon_button>
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :cell_id, :string, required: true
|
||||
attr :tag, :string, required: true
|
||||
attr :empty, :boolean, required: true
|
||||
|
|
@ -680,24 +770,55 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :cell_view, :map, required: true
|
||||
attr :langauge_toggle, :boolean, default: false
|
||||
|
||||
defp cell_indicators(assigns) do
|
||||
~H"""
|
||||
<div class="flex gap-1">
|
||||
<.cell_indicator :if={has_status?(@cell_view)}>
|
||||
<.cell_status id={@id} cell_view={@cell_view} />
|
||||
</.cell_indicator>
|
||||
<.cell_indicator>
|
||||
<.language_icon language={cell_language(@cell_view)} class="w-3 h-3" />
|
||||
</.cell_indicator>
|
||||
<%= if @langauge_toggle do %>
|
||||
<.menu id={"cell-#{@id}-language-menu"} position="bottom-right">
|
||||
<:toggle>
|
||||
<.cell_indicator class="cursor-pointer">
|
||||
<.language_icon language={cell_language(@cell_view)} class="w-3 h-3" />
|
||||
</.cell_indicator>
|
||||
</:toggle>
|
||||
<.menu_item :for={language <- Livebook.Notebook.Cell.Code.languages()}>
|
||||
<button
|
||||
role="menuitem"
|
||||
phx-click="set_cell_language"
|
||||
phx-value-language={language.language}
|
||||
phx-value-cell_id={@id}
|
||||
>
|
||||
<.cell_icon cell_type={:code} language={language.language} />
|
||||
<span>{language.name}</span>
|
||||
</button>
|
||||
</.menu_item>
|
||||
</.menu>
|
||||
<% else %>
|
||||
<.cell_indicator>
|
||||
<.language_icon language={cell_language(@cell_view)} class="w-3 h-3" />
|
||||
</.cell_indicator>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp cell_indicator(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
data-el-cell-indicator
|
||||
class="px-1.5 h-[22px] rounded-lg flex items-center border bg-editor-lighter border-editor text-editor"
|
||||
class={[
|
||||
"px-1.5 h-[22px] rounded-lg flex items-center border bg-editor-lighter border-editor text-editor",
|
||||
@class
|
||||
]}
|
||||
>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
|
|
@ -806,4 +927,11 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
|
||||
defp smart_cell_js_view_ref(%{type: :smart, status: :started, js_view: %{ref: ref}}), do: ref
|
||||
defp smart_cell_js_view_ref(_cell_view), do: nil
|
||||
|
||||
defp language_name(language) do
|
||||
Enum.find_value(
|
||||
Livebook.Notebook.Cell.Code.languages(),
|
||||
&(&1.language == language && &1.name)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -42,30 +42,17 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
|||
</div>
|
||||
</.insert_button>
|
||||
</:toggle>
|
||||
<.menu_item>
|
||||
<.menu_item :for={language <- Livebook.Notebook.Cell.Code.languages()}>
|
||||
<button
|
||||
role="menuitem"
|
||||
phx-click="set_default_language"
|
||||
phx-value-type="code"
|
||||
phx-value-language="elixir"
|
||||
phx-value-language={language.language}
|
||||
phx-value-section_id={@section_id}
|
||||
phx-value-cell_id={@cell_id}
|
||||
>
|
||||
<.cell_icon cell_type={:code} language={:elixir} />
|
||||
<span>Elixir</span>
|
||||
</button>
|
||||
</.menu_item>
|
||||
<.menu_item>
|
||||
<button
|
||||
role="menuitem"
|
||||
phx-click="set_default_language"
|
||||
phx-value-type="code"
|
||||
phx-value-language="erlang"
|
||||
phx-value-section_id={@section_id}
|
||||
phx-value-cell_id={@cell_id}
|
||||
>
|
||||
<.cell_icon cell_type={:code} language={:erlang} />
|
||||
<span>Erlang</span>
|
||||
<.cell_icon cell_type={:code} language={language.language} />
|
||||
<span>{language.name}</span>
|
||||
</button>
|
||||
</.menu_item>
|
||||
</.menu>
|
||||
|
|
|
|||
|
|
@ -1350,18 +1350,39 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<.live_component
|
||||
module={LivebookWeb.SessionLive.CellComponent}
|
||||
id={@data_view.setup_cell_view.id}
|
||||
session_id={@session.id}
|
||||
session_pid={@session.pid}
|
||||
client_id={@client_id}
|
||||
runtime={@data_view.runtime}
|
||||
installing?={@data_view.installing?}
|
||||
allowed_uri_schemes={@allowed_uri_schemes}
|
||||
cell_view={@data_view.setup_cell_view}
|
||||
/>
|
||||
<div data-el-setup-section>
|
||||
<div class="flex flex-col gap-2">
|
||||
<.live_component
|
||||
:for={setup_cell_view <- @data_view.setup_cell_views}
|
||||
module={LivebookWeb.SessionLive.CellComponent}
|
||||
id={setup_cell_view.id}
|
||||
session_id={@session.id}
|
||||
session_pid={@session.pid}
|
||||
client_id={@client_id}
|
||||
runtime={@data_view.runtime}
|
||||
installing?={@data_view.installing?}
|
||||
allowed_uri_schemes={@allowed_uri_schemes}
|
||||
enabled_languages={@data_view.enabled_languages}
|
||||
cell_view={setup_cell_view}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
:if={:python not in @data_view.enabled_languages}
|
||||
class="flex mt-2"
|
||||
data-el-language-buttons
|
||||
>
|
||||
<.button
|
||||
color="gray"
|
||||
outlined
|
||||
small
|
||||
phx-click="enable_language"
|
||||
phx-value-language="python"
|
||||
disabled={Livebook.Runtime.fixed_dependencies?(@data_view.runtime)}
|
||||
>
|
||||
<.remix_icon icon="add-line" class="text-sm -mx-0.5 leading-none" />
|
||||
<span>Python</span>
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 flex flex-col w-full space-y-16" data-el-sections-container>
|
||||
<div :if={@data_view.section_views == []} class="flex justify-center">
|
||||
|
|
@ -1384,6 +1405,7 @@ defmodule LivebookWeb.SessionLive.Render do
|
|||
allowed_uri_schemes={@allowed_uri_schemes}
|
||||
section_view={section_view}
|
||||
default_language={@data_view.default_language}
|
||||
enabled_languages={@data_view.enabled_languages}
|
||||
/>
|
||||
<div style="height: 80vh"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
mix.exs
2
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"},
|
||||
|
|
|
|||
3
mix.lock
3
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"},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,20 @@ defmodule Livebook.Runtime.EvaluatorTest do
|
|||
|
||||
@moduletag :tmp_dir
|
||||
|
||||
setup_all do
|
||||
# We setup Pythonx in the current process, so we can test Python
|
||||
# code evaluation. Testing pyproject.toml evaluation is tricky
|
||||
# because it requires a separate VM, so we only rely on the LV
|
||||
# integration tests.
|
||||
Pythonx.uv_init("""
|
||||
[project]
|
||||
name = "project"
|
||||
version = "0.0.0"
|
||||
requires-python = "==3.13.*"
|
||||
dependencies = []
|
||||
""")
|
||||
end
|
||||
|
||||
setup ctx do
|
||||
ebin_path =
|
||||
if ctx[:with_ebin_path] do
|
||||
|
|
@ -1377,7 +1391,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
|
|||
end
|
||||
|
||||
describe "erlang evaluation" do
|
||||
test "evaluate erlang code", %{evaluator: evaluator} do
|
||||
test "evaluates erlang code", %{evaluator: evaluator} do
|
||||
Evaluator.evaluate_code(
|
||||
evaluator,
|
||||
:erlang,
|
||||
|
|
@ -1390,7 +1404,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
|
|||
end
|
||||
|
||||
@tag :with_ebin_path
|
||||
test "evaluate erlang-module code", %{evaluator: evaluator} do
|
||||
test "evaluates erlang-module code", %{evaluator: evaluator} do
|
||||
code = """
|
||||
-module(tryme).
|
||||
|
||||
|
|
@ -1410,7 +1424,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
|
|||
end
|
||||
|
||||
@tag tmp_dir: false
|
||||
test "evaluate erlang-module code without filesystem", %{evaluator: evaluator} do
|
||||
test "evaluates erlang-module code without filesystem", %{evaluator: evaluator} do
|
||||
code = """
|
||||
-module(tryme).
|
||||
|
||||
|
|
@ -1425,7 +1439,7 @@ defmodule Livebook.Runtime.EvaluatorTest do
|
|||
end
|
||||
|
||||
@tag :with_ebin_path
|
||||
test "evaluate erlang-module error", %{
|
||||
test "evaluates erlang-module error", %{
|
||||
evaluator: evaluator
|
||||
} do
|
||||
code = """
|
||||
|
|
@ -1570,6 +1584,78 @@ defmodule Livebook.Runtime.EvaluatorTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "python evaluation" do
|
||||
test "evaluates python code", %{evaluator: evaluator} do
|
||||
code = """
|
||||
x = [1, 2, 3]
|
||||
sum(x)
|
||||
"""
|
||||
|
||||
Evaluator.evaluate_code(evaluator, :python, code, :code_1, [])
|
||||
|
||||
assert_receive {:runtime_evaluation_response, :code_1, terminal_text("6"), metadata()}
|
||||
end
|
||||
|
||||
test "uses and defines binding", %{evaluator: evaluator} do
|
||||
Evaluator.evaluate_code(evaluator, :elixir, "x = 1", :code_1, [])
|
||||
assert_receive {:runtime_evaluation_response, :code_1, _, metadata()}
|
||||
|
||||
Evaluator.evaluate_code(evaluator, :python, "y = x", :code_2, [:code_1])
|
||||
assert_receive {:runtime_evaluation_response, :code_2, _, metadata()}
|
||||
|
||||
Evaluator.evaluate_code(evaluator, :elixir, "z = y", :code_3, [:code_2, :code_1])
|
||||
assert_receive {:runtime_evaluation_response, :code_3, _, metadata()}
|
||||
|
||||
%{binding: binding} =
|
||||
Evaluator.get_evaluation_context(evaluator, [:code_3, :code_2, :code_1])
|
||||
|
||||
assert [{:z, %Pythonx.Object{}}, {:y, %Pythonx.Object{}}, {:x, 1}] = binding
|
||||
end
|
||||
|
||||
test "syntax error", %{evaluator: evaluator} do
|
||||
Evaluator.evaluate_code(evaluator, :python, "1 +", :code_1, [])
|
||||
|
||||
assert_receive {:runtime_evaluation_response, :code_1, error(message),
|
||||
%{
|
||||
code_markers: [
|
||||
%{
|
||||
line: 1,
|
||||
description: "SyntaxError: invalid syntax",
|
||||
severity: :error
|
||||
}
|
||||
]
|
||||
}}
|
||||
|
||||
assert clean_message(message) == """
|
||||
File "<unknown>", line 1
|
||||
1 +
|
||||
^
|
||||
SyntaxError: invalid syntax
|
||||
"""
|
||||
end
|
||||
|
||||
test "runtime error", %{evaluator: evaluator} do
|
||||
Evaluator.evaluate_code(evaluator, :python, "import unknown", :code_1, [])
|
||||
|
||||
assert_receive {:runtime_evaluation_response, :code_1, error(message),
|
||||
%{
|
||||
code_markers: [
|
||||
%{
|
||||
line: 1,
|
||||
description: "ModuleNotFoundError: No module named 'unknown'",
|
||||
severity: :error
|
||||
}
|
||||
]
|
||||
}}
|
||||
|
||||
assert clean_message(message) == """
|
||||
Traceback (most recent call last):
|
||||
File "<string>", line 1, in <module>
|
||||
ModuleNotFoundError: No module named 'unknown'
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
describe "formatting" do
|
||||
test "gracefully handles errors in the inspect protocol", %{evaluator: evaluator} do
|
||||
code = "%Livebook.TestModules.BadInspect{}"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue