Packages
livebook
0.17.2
0.19.8
0.19.7
0.19.6
0.19.5
0.19.4
0.19.3
0.19.2
0.19.1
0.19.0
0.18.6
0.18.5
0.18.4
0.18.3
0.18.2
0.18.1
0.18.0
0.17.3
0.17.2
0.17.1
0.17.0
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.7
0.14.6
0.14.5
0.14.4
0.14.3
0.14.2
0.14.1
0.14.0
0.14.0-rc.1
0.14.0-rc.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.1
0.12.0
0.11.4
0.11.3
0.11.2
0.11.1
0.11.0
0.10.0
0.9.3
0.9.2
0.9.1
0.9.0
0.8.2
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.1
0.4.0
0.3.2
0.3.1
0.3.0
0.2.3
0.2.2
0.2.1
0.2.0
0.1.2
0.1.1
0.1.0
Automate code & data workflows with interactive notebooks
Current section
Files
Jump to
Current section
Files
lib/livebook/live_markdown/import.ex
defmodule Livebook.LiveMarkdown.Import do
alias Livebook.Hubs
alias Livebook.Notebook
alias Livebook.LiveMarkdown.MarkdownHelpers
def notebook_from_livemd(markdown) do
{_, ast, earmark_messages} = MarkdownHelpers.markdown_to_block_ast(markdown)
earmark_messages = Enum.map(earmark_messages, &earmark_message_to_string/1)
{ast, rewrite_messages} = rewrite_ast(ast)
elements = group_elements(ast)
{stamp_data, elements} = take_stamp_data(elements)
{notebook, valid_hub?, build_messages} = build_notebook(elements)
{notebook, postprocess_messages} = postprocess_notebook(notebook)
{notebook, stamp_verified?, metadata_messages} =
if stamp_data != nil and valid_hub? do
postprocess_stamp(notebook, markdown, stamp_data)
else
{notebook, false, []}
end
messages =
earmark_messages ++
rewrite_messages ++ build_messages ++ postprocess_messages ++ metadata_messages
{notebook, %{warnings: messages, stamp_verified?: stamp_verified?}}
end
defp earmark_message_to_string({_severity, line_number, message}) do
"line #{line_number} - #{Livebook.Utils.downcase_first(message)}"
end
# Does initial pre-processing of the AST, so that it conforms to the expected form.
# Returns {altered_ast, messages}.
defp rewrite_ast(ast) do
{ast, messages1} = rewrite_multiple_primary_headings(ast)
{ast, messages2} = move_primary_heading_top(ast)
ast = normalize_comments(ast)
{ast, messages1 ++ messages2}
end
# There should be only one h1 tag indicating notebook name,
# if there are many we downgrade all headings.
# This doesn't apply to documents exported from Livebook,
# but may be the case for an arbitrary markdown file,
# so we do our best to preserve the intent.
defp rewrite_multiple_primary_headings(ast) do
primary_headings = Enum.count(ast, &(tag(&1) == "h1"))
if primary_headings > 1 do
ast = Enum.map(ast, &downgrade_heading/1)
message =
"downgrading all headings, because #{primary_headings} instances of heading 1 were found"
{ast, [message]}
else
{ast, []}
end
end
defp downgrade_heading({"h1", attrs, content, meta}), do: {"h2", attrs, content, meta}
defp downgrade_heading({"h2", attrs, content, meta}), do: {"h3", attrs, content, meta}
defp downgrade_heading({"h3", attrs, content, meta}), do: {"h4", attrs, content, meta}
defp downgrade_heading({"h4", attrs, content, meta}), do: {"h5", attrs, content, meta}
defp downgrade_heading({"h5", attrs, content, meta}), do: {"h6", attrs, content, meta}
defp downgrade_heading({"h6", attrs, content, meta}), do: {"strong", attrs, content, meta}
defp downgrade_heading(ast_node), do: ast_node
# This moves h1 together with any preceding comments to the top.
defp move_primary_heading_top(ast) do
case Enum.split_while(ast, &(tag(&1) != "h1")) do
{_ast, []} ->
{ast, []}
{leading, [heading | rest]} ->
{leading, comments} = split_while_right(leading, &(tag(&1) == :comment))
if leading == [] do
{ast, []}
else
ast = comments ++ [heading] ++ leading ++ rest
message = "moving heading 1 to the top of the notebook"
{ast, [message]}
end
end
end
defp tag(ast_node)
defp tag({tag, _, _, _}), do: tag
defp tag(_), do: nil
defp split_while_right(list, fun) do
{right_rev, left_rev} = list |> Enum.reverse() |> Enum.split_while(fun)
{Enum.reverse(left_rev), Enum.reverse(right_rev)}
end
# Normalizes comments to allow nice pattern matching on Livebook-specific
# annotations with no regard to surrounding whitespace.
defp normalize_comments(ast) do
Enum.map(ast, fn
{:comment, attrs, lines, %{comment: true}} ->
{:comment, attrs, MarkdownHelpers.normalize_comment_lines(lines), %{comment: true}}
ast_node ->
ast_node
end)
end
# Builds a list of classified elements from the AST.
defp group_elements(ast, elems \\ [])
defp group_elements([], elems), do: elems
defp group_elements([{"h1", _, [content], %{}} | ast], elems) do
group_elements(ast, [{:notebook_name, content} | elems])
end
defp group_elements([{"h2", _, [content], %{}} | ast], elems) do
group_elements(ast, [{:section_name, content} | elems])
end
# The <!-- livebook:{"force_markdown":true} --> annotation forces the next node
# to be interpreted as Markdown cell content.
defp group_elements(
[
{:comment, _, [~s/livebook:{"force_markdown":true}/], %{comment: true}},
ast_node | ast
],
[{:cell, :markdown, md_ast} | rest]
) do
group_elements(ast, [{:cell, :markdown, [ast_node | md_ast]} | rest])
end
defp group_elements(
[
{:comment, _, [~s/livebook:{"force_markdown":true}/], %{comment: true}},
ast_node | ast
],
elems
) do
group_elements(ast, [{:cell, :markdown, [ast_node]} | elems])
end
defp group_elements(
[{:comment, _, [~s/livebook:{"break_markdown":true}/], %{comment: true}} | ast],
elems
) do
group_elements(ast, [{:cell, :markdown, []} | elems])
end
defp group_elements(
[{:comment, _, ["livebook:" <> json], %{comment: true}} | ast],
elems
) do
group_elements(ast, [livebook_json_to_element(json) | elems])
end
defp group_elements(
[{"pre", _, [{"code", [{"class", language}], [source], %{}}], %{}} | ast],
elems
)
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
defp group_elements([ast_node | ast], [{:cell, :markdown, md_ast} | rest]) do
group_elements(ast, [{:cell, :markdown, [ast_node | md_ast]} | rest])
end
defp group_elements([ast_node | ast], elems) do
group_elements(ast, [{:cell, :markdown, [ast_node]} | elems])
end
defp livebook_json_to_element(json) do
data = JSON.decode!(json)
case data do
%{"livebook_object" => "cell_input"} ->
{:cell, :input, data}
%{"livebook_object" => "smart_cell"} ->
{:cell, :smart, data}
%{"stamp" => _} ->
{:stamp, data}
_ ->
{:metadata, data}
end
end
# Import ```output snippets for backward compatibility
defp take_outputs(
[{"pre", _, [{"code", [{"class", "output"}], [output], %{}}], %{}} | ast],
outputs
) do
take_outputs(ast, [%{type: :terminal_text, text: output, chunk: false} | outputs])
end
defp take_outputs(
[
{:comment, _, [~s/livebook:{"output":true}/], %{comment: true}},
{"pre", _, [{"code", [], [output], %{}}], %{}}
| ast
],
outputs
) do
take_outputs(ast, [%{type: :terminal_text, text: output, chunk: false} | outputs])
end
# Ignore other exported outputs
defp take_outputs(
[
{:comment, _, [~s/livebook:{"output":true}/], %{comment: true}},
{"pre", _, [{"code", [{"class", _info_string}], [_output], %{}}], %{}}
| ast
],
outputs
) do
take_outputs(ast, outputs)
end
defp take_outputs(ast, outputs), do: {outputs, ast}
# Builds a notebook from the list of elements obtained in the
# previous step. The elements are in reversed order, because we
# want to aggregate them into data structures going bottom-up.
defp build_notebook(elems) do
build_notebook(elems, _cells = [], _sections = [], _messages = [], _output_counter = 0)
end
defp build_notebook(
[{:cell, :code, :elixir, source, outputs}, {:cell, :smart, data} | elems],
cells,
sections,
messages,
output_counter
) do
{outputs, output_counter} = Notebook.index_outputs(outputs, output_counter)
%{"kind" => kind, "attrs" => attrs} = data
attrs =
case attrs do
# Import map attributes for backward compatibility
%{} ->
attrs
encoded when is_binary(encoded) ->
encoded |> Base.decode64!(padding: false) |> JSON.decode!()
end
chunks = if(chunks = data["chunks"], do: Enum.map(chunks, &List.to_tuple/1))
cell = %{
Notebook.Cell.new(:smart)
| source: source,
chunks: chunks,
outputs: outputs,
kind: kind,
attrs: attrs
}
build_notebook(elems, [cell | cells], sections, messages, output_counter)
end
defp build_notebook(
[{:cell, :code, language, source, outputs} | elems],
cells,
sections,
messages,
output_counter
) do
{metadata, elems} = grab_metadata(elems)
attrs = cell_metadata_to_attrs(:code, metadata)
{outputs, output_counter} = Notebook.index_outputs(outputs, output_counter)
cell =
%{Notebook.Cell.new(:code) | source: source, language: language, outputs: outputs}
|> Map.merge(attrs)
build_notebook(elems, [cell | cells], sections, messages, output_counter)
end
defp build_notebook(
[{:cell, :markdown, md_ast} | elems],
cells,
sections,
messages,
output_counter
) do
{metadata, elems} = grab_metadata(elems)
attrs = cell_metadata_to_attrs(:markdown, metadata)
source = md_ast |> Enum.reverse() |> MarkdownHelpers.markdown_from_ast()
cell = %{Notebook.Cell.new(:markdown) | source: source} |> Map.merge(attrs)
build_notebook(elems, [cell | cells], sections, messages, output_counter)
end
defp build_notebook([{:cell, :input, data} | elems], cells, sections, messages, output_counter) do
warning =
"found an input cell, but those are no longer supported, please use Kino.Input instead"
warning =
if data["reactive"] == true do
warning <>
". Also, to make the input reactive you can use an automatically reevaluating cell"
else
warning
end
build_notebook(elems, cells, sections, messages ++ [warning], output_counter)
end
defp build_notebook([{:section_name, name} | elems], cells, sections, messages, output_counter) do
{metadata, elems} = grab_metadata(elems)
attrs = section_metadata_to_attrs(metadata)
section = %{Notebook.Section.new() | name: name, cells: cells} |> Map.merge(attrs)
build_notebook(elems, [], [section | sections], messages, output_counter)
end
@unknown_hub_message "this notebook belongs to an Organization you don't have access to. " <>
"Head to Livebook's home and add its Organization before reopening this notebook"
defp build_notebook(elems, cells, sections, messages, output_counter) do
# At this point we expect the heading, otherwise we use the default
{name, elems} =
case elems do
[{:notebook_name, name} | elems] -> {name, elems}
[] -> {nil, []}
end
{metadata, elems} = grab_metadata(elems)
# If there are any non-metadata comments we keep them
{comments, elems} = grab_leading_comments(elems)
messages =
if elems == [] do
messages
else
messages ++
[
"found an invalid sequence of comments at the beginning, make sure custom comments are at the very top"
]
end
{attrs, messages} = notebook_metadata_to_attrs(metadata, messages)
hub_id = attrs[:hub_id]
{attrs, valid_hub?, messages} =
if is_nil(hub_id) or Hubs.hub_exists?(hub_id) do
{attrs, true, messages}
else
{Map.drop(attrs, [:hub_id, :deployment_group_id]), false,
messages ++ [@unknown_hub_message]}
end
# 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
[%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 =
%{
Notebook.new()
| sections: extra_sections ++ sections,
leading_comments: comments,
output_counter: output_counter
}
|> maybe_put_name(name)
|> maybe_put_setup_cells(setup_cells)
|> Map.merge(attrs)
{notebook, valid_hub?, messages}
end
defp maybe_put_name(notebook, nil), do: notebook
defp maybe_put_name(notebook, name), do: %{notebook | name: name}
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
{metadata, elems}
end
defp grab_metadata(elems), do: {%{}, elems}
defp grab_leading_comments([]), do: {[], []}
# Since these are not metadata comments they get wrapped in a markdown cell,
# so we unpack it
defp grab_leading_comments([{:cell, :markdown, md_ast}]) do
comments = for {:comment, _attrs, lines, %{comment: true}} <- Enum.reverse(md_ast), do: lines
{comments, []}
end
defp grab_leading_comments(elems), do: {[], elems}
defp notebook_metadata_to_attrs(metadata, messages) do
Enum.reduce(metadata, {%{}, messages}, fn
{"persist_outputs", persist_outputs}, {attrs, messages} ->
{Map.put(attrs, :persist_outputs, persist_outputs), messages}
{"autosave_interval_s", autosave_interval_s}, {attrs, messages} ->
{Map.put(attrs, :autosave_interval_s, autosave_interval_s), messages}
{"default_language", default_language}, {attrs, messages}
when default_language in ["elixir", "erlang"] ->
default_language = String.to_atom(default_language)
{Map.put(attrs, :default_language, default_language), messages}
{"hub_id", hub_id}, {attrs, messages} ->
{Map.put(attrs, :hub_id, hub_id), messages}
{"deployment_group_id", deployment_group_id}, {attrs, messages} ->
{Map.put(attrs, :deployment_group_id, deployment_group_id), messages}
{"app_settings", app_settings_metadata}, {attrs, messages} ->
app_settings =
Map.merge(
Notebook.AppSettings.new(),
app_settings_metadata_to_attrs(app_settings_metadata)
)
{Map.put(attrs, :app_settings, app_settings), messages}
{"file_entries", file_entry_metadata}, {attrs, messages}
when is_list(file_entry_metadata) ->
{file_entries, file_entry_messages} =
for file_entry_metadata <- file_entry_metadata, reduce: {[], []} do
{file_entries, warnings} ->
case file_entry_metadata_to_attrs(file_entry_metadata) do
{:ok, file_entry} -> {[file_entry | file_entries], warnings}
{:error, message} -> {file_entries, [message | warnings]}
end
end
# By default we put all :file entries in quarantine, if there
# is a valid stamp, we override this later
quarantine_file_entry_names =
for entry <- file_entries, entry.type == :file, into: MapSet.new(), do: entry.name
attrs =
attrs
|> Map.put(:file_entries, file_entries)
|> Map.put(:quarantine_file_entry_names, quarantine_file_entry_names)
{attrs, messages ++ file_entry_messages}
_entry, {attrs, messages} ->
{attrs, messages}
end)
end
defp app_settings_metadata_to_attrs(metadata) do
Enum.reduce(metadata, %{}, fn
{"slug", slug}, attrs ->
Map.put(attrs, :slug, slug)
{"multi_session", multi_session}, attrs ->
Map.put(attrs, :multi_session, multi_session)
{"zero_downtime", zero_downtime}, attrs ->
Map.put(attrs, :zero_downtime, zero_downtime)
{"show_existing_sessions", show_existing_sessions}, attrs ->
Map.put(attrs, :show_existing_sessions, show_existing_sessions)
{"auto_shutdown_ms", auto_shutdown_ms}, attrs ->
Map.put(attrs, :auto_shutdown_ms, auto_shutdown_ms)
{"access_type", access_type}, attrs when access_type in ["public", "protected"] ->
Map.put(attrs, :access_type, String.to_atom(access_type))
{"show_source", show_source}, attrs ->
Map.put(attrs, :show_source, show_source)
{"output_type", output_type}, attrs when output_type in ["all", "rich"] ->
Map.put(attrs, :output_type, String.to_atom(output_type))
_entry, attrs ->
attrs
end)
end
defp file_entry_metadata_to_attrs(%{"type" => "attachment", "name" => name}) do
{:ok, %{type: :attachment, name: name}}
end
defp file_entry_metadata_to_attrs(%{
"type" => "file",
"name" => name,
"file" => %{
"file_system_id" => file_system_id,
"file_system_type" => file_system_type,
"path" => path
}
}) do
file = %Livebook.FileSystem.File{
file_system_id: file_system_id,
file_system_module: Livebook.FileSystems.type_to_module(file_system_type),
path: path,
origin_pid: self()
}
{:ok, %{type: :file, name: name, file: file}}
end
defp file_entry_metadata_to_attrs(%{"type" => "url", "name" => name, "url" => url}) do
{:ok, %{type: :url, name: name, url: url}}
end
defp file_entry_metadata_to_attrs(_other) do
{:error, "discarding file entry in invalid format"}
end
defp section_metadata_to_attrs(metadata) do
Enum.reduce(metadata, %{}, fn
{"branch_parent_index", parent_idx}, attrs ->
# At this point we cannot extract other section id,
# so we temporarily keep the index
Map.put(attrs, :parent_id, {:idx, parent_idx})
_entry, attrs ->
attrs
end)
end
defp cell_metadata_to_attrs(:code, metadata) do
Enum.reduce(metadata, %{}, fn
{"reevaluate_automatically", reevaluate_automatically}, attrs ->
Map.put(attrs, :reevaluate_automatically, reevaluate_automatically)
{"continue_on_error", continue_on_error}, attrs ->
Map.put(attrs, :continue_on_error, continue_on_error)
_entry, attrs ->
attrs
end)
end
defp cell_metadata_to_attrs(_type, _metadata) do
%{}
end
defp postprocess_notebook(notebook) do
{sections, {_branching_ids, warnings}} =
notebook.sections
|> Enum.with_index()
|> Enum.map_reduce({MapSet.new(), []}, fn {section, section_idx},
{branching_ids, warnings} ->
# Set parent_id based on the persisted branch_parent_index if present
case section.parent_id do
nil ->
{section, {branching_ids, warnings}}
{:idx, parent_idx} ->
parent = Enum.at(notebook.sections, parent_idx)
cond do
section_idx <= parent_idx ->
{%{section | parent_id: nil},
{
branching_ids,
[
~s{ignoring the parent section of "#{section.name}", because it comes later in the notebook}
| warnings
]
}}
!is_nil(parent) && MapSet.member?(branching_ids, parent.id) ->
{%{section | parent_id: nil},
{
branching_ids,
[
~s{ignoring the parent section of "#{section.name}", because it is itself a branching section}
| warnings
]
}}
true ->
{%{section | parent_id: parent.id},
{MapSet.put(branching_ids, section.id), warnings}}
end
end
end)
notebook = %{notebook | sections: sections}
legacy_images? =
notebook
|> Notebook.cells_with_section()
|> Enum.any?(fn {cell, _section} ->
# A heuristic to detect legacy image source
is_struct(cell, Notebook.Cell.Markdown) and String.contains?(cell.source, "](images/")
end)
image_warnings =
if legacy_images? do
[
"found Markdown images pointing to the images/ directory." <>
" Using this directory has been deprecated, please use notebook files instead"
]
else
[]
end
{notebook, Enum.reverse(warnings) ++ image_warnings}
end
defp take_stamp_data([{:stamp, data} | elements]), do: {data, elements}
defp take_stamp_data(elements), do: {nil, elements}
@personal_stamp_message "this notebook can only access environment variables defined in this machine (the notebook was either authored in another machine or changed outside of Livebook)"
@org_stamp_message "invalid notebook stamp, disabling access to secrets and remote files (this may happen if you made changes to the notebook source outside of Livebook)"
@too_recent_stamp_message "invalid notebook stamp, disabling access to secrets and remote files (the stamp has been generated using a more recent Livebook version, you need to upgrade)"
defp postprocess_stamp(notebook, notebook_source, stamp_data) do
hub = Hubs.fetch_hub!(notebook.hub_id)
{stamp_verified?, notebook, messages} =
with %{"offset" => offset, "stamp" => stamp} <- stamp_data,
{:ok, notebook_source, rest_source} <- safe_binary_split(notebook_source, offset),
{:ok, ^stamp_data} <- only_stamp_data(rest_source),
{:ok, metadata} <- Livebook.Hubs.verify_notebook_stamp(hub, notebook_source, stamp) do
notebook = apply_stamp_metadata(notebook, metadata)
{true, notebook, []}
else
error ->
message =
cond do
error == {:error, :too_recent_version} -> @too_recent_stamp_message
notebook.hub_id == "personal-hub" -> @personal_stamp_message
true -> @org_stamp_message
end
{false, notebook, [message]}
end
# If the hub is online, then by definition it is a valid hub,
# so we enable team features. If the hub is offline, then
# we can only enable team features if the stamp is valid
# (which means the server signed with a private key and we
# validate it against the public key).
teams_enabled = is_struct(hub, Livebook.Hubs.Team) and (hub.offline == nil or stamp_verified?)
{%{notebook | teams_enabled: teams_enabled}, stamp_verified?, messages}
end
defp safe_binary_split(binary, offset)
when byte_size(binary) < offset,
do: :error
defp safe_binary_split(binary, offset) do
size = byte_size(binary)
{:ok, binary_slice(binary, 0, offset), binary_slice(binary, offset, size - offset)}
end
defp only_stamp_data(source) do
{_, ast, _} = source |> String.trim() |> MarkdownHelpers.markdown_to_block_ast()
{ast, _} = rewrite_ast(ast)
case group_elements(ast) do
[{:stamp, data}] -> {:ok, data}
_ -> :error
end
end
defp apply_stamp_metadata(notebook, metadata) do
Enum.reduce(metadata, notebook, fn
{:hub_secret_names, hub_secret_names}, notebook ->
%{notebook | hub_secret_names: hub_secret_names}
{:quarantine_file_entry_names, quarantine_file_entry_names}, notebook ->
%{notebook | quarantine_file_entry_names: MapSet.new(quarantine_file_entry_names)}
{:app_settings_password, password}, notebook ->
put_in(notebook.app_settings.password, password)
_entry, notebook ->
notebook
end)
end
end