mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-04 12:04:20 +08:00
556 lines
14 KiB
Elixir
556 lines
14 KiB
Elixir
defmodule Livebook.Utils do
|
|
@moduledoc false
|
|
|
|
require Logger
|
|
|
|
@type id :: binary()
|
|
|
|
@doc """
|
|
Generates a random binary id.
|
|
"""
|
|
@spec random_id() :: id()
|
|
def random_id() do
|
|
:crypto.strong_rand_bytes(20) |> Base.encode32(case: :lower)
|
|
end
|
|
|
|
@doc """
|
|
Generates a random short binary id.
|
|
"""
|
|
@spec random_short_id() :: id()
|
|
def random_short_id() do
|
|
:crypto.strong_rand_bytes(5) |> Base.encode32(case: :lower)
|
|
end
|
|
|
|
@doc """
|
|
Generates a random cookie for a distributed node.
|
|
"""
|
|
@spec random_cookie() :: atom()
|
|
def random_cookie() do
|
|
:"c_#{Base.url_encode64(:crypto.strong_rand_bytes(39))}"
|
|
end
|
|
|
|
@doc """
|
|
Generates a random binary id that includes node information.
|
|
|
|
## Format
|
|
|
|
The id is formed from the following binary parts:
|
|
|
|
* 16B - hashed node name
|
|
* 9B - random bytes
|
|
|
|
The binary is base32 encoded.
|
|
"""
|
|
@spec random_node_aware_id() :: id()
|
|
def random_node_aware_id() do
|
|
node_part = node_hash(node())
|
|
random_part = :crypto.strong_rand_bytes(9)
|
|
binary = <<node_part::binary, random_part::binary>>
|
|
# 16B + 9B = 25B is suitable for base32 encoding without padding
|
|
Base.encode32(binary, case: :lower)
|
|
end
|
|
|
|
# Note: the result is always 16 bytes long
|
|
defp node_hash(node) do
|
|
content = Atom.to_string(node)
|
|
:erlang.md5(content)
|
|
end
|
|
|
|
@doc """
|
|
Extracts node name from the given node aware id.
|
|
|
|
The node in question must be connected, otherwise it won't be found.
|
|
"""
|
|
@spec node_from_node_aware_id(id()) :: {:ok, node()} | :error
|
|
def node_from_node_aware_id(id) do
|
|
binary = Base.decode32!(id, case: :lower)
|
|
<<node_part::binary-size(16), _random_part::binary-size(9)>> = binary
|
|
|
|
known_nodes = [node() | Node.list()]
|
|
|
|
Enum.find_value(known_nodes, :error, fn node ->
|
|
node_hash(node) == node_part && {:ok, node}
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Converts the given name to node identifier.
|
|
"""
|
|
@spec node_from_name(String.t()) :: atom()
|
|
def node_from_name(name) do
|
|
if name =~ "@" do
|
|
String.to_atom(name)
|
|
else
|
|
# Default to the same host as the current node
|
|
:"#{name}@#{node_host()}"
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns the host part of a node.
|
|
"""
|
|
@spec node_host() :: binary()
|
|
def node_host do
|
|
[_, host] = node() |> Atom.to_string() |> :binary.split("@")
|
|
host
|
|
end
|
|
|
|
@doc """
|
|
Registers the given process under `name` for the time of `fun` evaluation.
|
|
"""
|
|
@spec temporarily_register(pid(), atom(), (... -> any())) :: any()
|
|
def temporarily_register(pid, name, fun) do
|
|
Process.register(pid, name)
|
|
fun.()
|
|
after
|
|
Process.unregister(name)
|
|
end
|
|
|
|
@doc """
|
|
Returns a function that accesses list items by the given id.
|
|
|
|
## Examples
|
|
|
|
iex> list = [%{id: 1, name: "Jake"}, %{id: 2, name: "Amy"}]
|
|
iex> get_in(list, [Livebook.Utils.access_by_id(2), Access.key(:name)])
|
|
"Amy"
|
|
|
|
iex> list = [%{id: 1, name: "Jake"}, %{id: 2, name: "Amy"}]
|
|
iex> put_in(list, [Livebook.Utils.access_by_id(2), Access.key(:name)], "Amy Santiago")
|
|
[%{id: 1, name: "Jake"}, %{id: 2, name: "Amy Santiago"}]
|
|
|
|
An error is raised if the accessed structure is not a list:
|
|
|
|
iex> get_in(%{}, [Livebook.Utils.access_by_id(1)])
|
|
** (RuntimeError) Livebook.Utils.access_by_id/1 expected a list, got: %{}
|
|
"""
|
|
@spec access_by_id(term()) ::
|
|
Access.access_fun(data :: struct() | map(), current_value :: term())
|
|
def access_by_id(id) do
|
|
fn
|
|
:get, data, next when is_list(data) ->
|
|
data
|
|
|> Enum.find(fn item -> item.id == id end)
|
|
|> next.()
|
|
|
|
:get_and_update, data, next when is_list(data) ->
|
|
case Enum.split_while(data, fn item -> item.id != id end) do
|
|
{prev, [item | cons]} ->
|
|
case next.(item) do
|
|
{get, update} ->
|
|
{get, prev ++ [update | cons]}
|
|
|
|
:pop ->
|
|
{item, prev ++ cons}
|
|
end
|
|
|
|
_ ->
|
|
{nil, data}
|
|
end
|
|
|
|
_op, data, _next ->
|
|
raise "Livebook.Utils.access_by_id/1 expected a list, got: #{inspect(data)}"
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Validates if the given URL is syntactically valid.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.valid_url?("not_a_url")
|
|
false
|
|
|
|
iex> Livebook.Utils.valid_url?("https://example.com")
|
|
true
|
|
|
|
iex> Livebook.Utils.valid_url?("http://localhost")
|
|
true
|
|
|
|
iex> Livebook.Utils.valid_url?("http://")
|
|
false
|
|
"""
|
|
@spec valid_url?(String.t()) :: boolean()
|
|
def valid_url?(url) do
|
|
uri = URI.parse(url)
|
|
uri.scheme != nil and uri.host not in [nil, ""]
|
|
end
|
|
|
|
@doc ~S"""
|
|
Validates if the given string forms valid CLI flags.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.valid_cli_flags?("")
|
|
true
|
|
|
|
iex> Livebook.Utils.valid_cli_flags?("--arg1 value --arg2 'value'")
|
|
true
|
|
|
|
iex> Livebook.Utils.valid_cli_flags?("--arg1 \"")
|
|
false
|
|
"""
|
|
@spec valid_cli_flags?(String.t()) :: boolean()
|
|
def valid_cli_flags?(flags) do
|
|
try do
|
|
OptionParser.split(flags)
|
|
true
|
|
rescue
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Changes the first letter in the given string to upper case.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.upcase_first("sippin tea")
|
|
"Sippin tea"
|
|
|
|
iex> Livebook.Utils.upcase_first("short URL")
|
|
"Short URL"
|
|
|
|
iex> Livebook.Utils.upcase_first("")
|
|
""
|
|
"""
|
|
@spec upcase_first(String.t()) :: String.t()
|
|
def upcase_first(string) do
|
|
{first, rest} = String.split_at(string, 1)
|
|
String.upcase(first) <> rest
|
|
end
|
|
|
|
@doc """
|
|
Changes the first letter in the given string to lower case.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.downcase_first("Sippin tea")
|
|
"sippin tea"
|
|
|
|
iex> Livebook.Utils.downcase_first("Short URL")
|
|
"short URL"
|
|
|
|
iex> Livebook.Utils.downcase_first("")
|
|
""
|
|
"""
|
|
@spec downcase_first(String.t()) :: String.t()
|
|
def downcase_first(string) do
|
|
{first, rest} = String.split_at(string, 1)
|
|
String.downcase(first) <> rest
|
|
end
|
|
|
|
@doc """
|
|
Expands a relative path in terms of the given URL.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.expand_url("file:///home/user/lib/file.ex", "../root.ex")
|
|
"file:///home/user/root.ex"
|
|
|
|
iex> Livebook.Utils.expand_url("https://example.com/lib/file.ex?token=supersecret", "../root.ex")
|
|
"https://example.com/root.ex?token=supersecret"
|
|
"""
|
|
@spec expand_url(String.t(), String.t()) :: String.t()
|
|
def expand_url(url, relative_path) do
|
|
url
|
|
|> URI.parse()
|
|
|> Map.update!(:path, fn path ->
|
|
Livebook.FileSystem.Utils.resolve_unix_like_path(path, relative_path)
|
|
end)
|
|
|> URI.to_string()
|
|
end
|
|
|
|
@doc ~S"""
|
|
Wraps the given line into lines that fit in `width` characters.
|
|
|
|
Words longer than `width` are not broken apart.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.wrap_line("cat on the roof", 7)
|
|
"cat on\nthe\nroof"
|
|
|
|
iex> Livebook.Utils.wrap_line("cat in the cup", 7)
|
|
"cat in\nthe cup"
|
|
|
|
iex> Livebook.Utils.wrap_line("cat in the cup", 2)
|
|
"cat\nin\nthe\ncup"
|
|
"""
|
|
@spec wrap_line(String.t(), pos_integer()) :: String.t()
|
|
def wrap_line(line, width) do
|
|
line
|
|
|> String.split()
|
|
|> Enum.reduce({[[]], 0}, fn part, {[group | groups], group_size} ->
|
|
size = String.length(part)
|
|
|
|
cond do
|
|
group == [] ->
|
|
{[[part] | groups], size}
|
|
|
|
group_size + 1 + size <= width ->
|
|
{[[part, " " | group] | groups], group_size + 1 + size}
|
|
|
|
true ->
|
|
{[[part], group | groups], size}
|
|
end
|
|
end)
|
|
|> elem(0)
|
|
|> Enum.map(&Enum.reverse/1)
|
|
|> Enum.reverse()
|
|
|> Enum.intersperse("\n")
|
|
|> IO.iodata_to_binary()
|
|
end
|
|
|
|
@doc """
|
|
Reads file contents and encodes it into a data URL.
|
|
"""
|
|
@spec read_as_data_url!(Path.t()) :: binary()
|
|
def read_as_data_url!(path) do
|
|
content = File.read!(path)
|
|
mime = MIME.from_path(path)
|
|
data = Base.encode64(content)
|
|
"data:#{mime};base64,#{data}"
|
|
end
|
|
|
|
@doc """
|
|
Opens the given `url` in the browser.
|
|
"""
|
|
def browser_open(url) do
|
|
win_cmd_args = ["/c", "start", String.replace(url, "&", "^&")]
|
|
|
|
cmd_args =
|
|
case :os.type() do
|
|
{:win32, _} ->
|
|
{"cmd", win_cmd_args}
|
|
|
|
{:unix, :darwin} ->
|
|
{"open", [url]}
|
|
|
|
{:unix, _} ->
|
|
cond do
|
|
System.find_executable("xdg-open") -> {"xdg-open", [url]}
|
|
# When inside WSL
|
|
System.find_executable("cmd.exe") -> {"cmd.exe", win_cmd_args}
|
|
true -> nil
|
|
end
|
|
end
|
|
|
|
case cmd_args do
|
|
{cmd, args} -> System.cmd(cmd, args)
|
|
nil -> Logger.warn("could not open the browser, no open command found in the system")
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
@doc """
|
|
Splits the given string at the last occurrence of `pattern`.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.split_at_last_occurrence("1,2,3", ",")
|
|
{:ok, "1,2", "3"}
|
|
|
|
iex> Livebook.Utils.split_at_last_occurrence("123", ",")
|
|
:error
|
|
"""
|
|
@spec split_at_last_occurrence(String.t(), String.pattern()) ::
|
|
{:ok, left :: String.t(), right :: String.t()} | :error
|
|
def split_at_last_occurrence(string, pattern) when is_binary(string) do
|
|
case :binary.matches(string, pattern) do
|
|
[] ->
|
|
:error
|
|
|
|
parts ->
|
|
{start, _} = List.last(parts)
|
|
size = byte_size(string)
|
|
{:ok, binary_part(string, 0, start), binary_part(string, start + 1, size - start - 1)}
|
|
end
|
|
end
|
|
|
|
@doc ~S"""
|
|
Finds CR characters and removes leading text in the same line.
|
|
|
|
Note that trailing CRs are kept.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.apply_rewind("Hola\nHmm\rHey")
|
|
"Hola\nHey"
|
|
|
|
iex> Livebook.Utils.apply_rewind("\rHey")
|
|
"Hey"
|
|
|
|
iex> Livebook.Utils.apply_rewind("Hola\r\nHey\r")
|
|
"Hola\r\nHey\r"
|
|
"""
|
|
@spec apply_rewind(String.t()) :: String.t()
|
|
def apply_rewind(text) when is_binary(text) do
|
|
apply_rewind(text, "", "")
|
|
end
|
|
|
|
defp apply_rewind(<<?\n, rest::binary>>, acc, line),
|
|
do: apply_rewind(rest, <<acc::binary, line::binary, ?\n>>, "")
|
|
|
|
defp apply_rewind(<<?\r, byte, rest::binary>>, acc, _line) when byte != ?\n,
|
|
do: apply_rewind(rest, acc, <<byte>>)
|
|
|
|
defp apply_rewind(<<byte, rest::binary>>, acc, line),
|
|
do: apply_rewind(rest, acc, <<line::binary, byte>>)
|
|
|
|
defp apply_rewind("", acc, line), do: acc <> line
|
|
|
|
@doc ~S"""
|
|
Limits `text` to last `max_lines`.
|
|
|
|
Replaces the removed lines with `"..."`.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.cap_lines("Line 1\nLine 2\nLine 3\nLine 4", 2)
|
|
"...\nLine 3\nLine 4"
|
|
|
|
iex> Livebook.Utils.cap_lines("Line 1\nLine 2", 2)
|
|
"Line 1\nLine 2"
|
|
|
|
iex> Livebook.Utils.cap_lines("Line 1\nLine 2", 3)
|
|
"Line 1\nLine 2"
|
|
"""
|
|
@spec cap_lines(String.t(), non_neg_integer()) :: String.t()
|
|
def cap_lines(text, max_lines) do
|
|
text
|
|
|> :binary.matches("\n")
|
|
|> Enum.at(-max_lines)
|
|
|> case do
|
|
nil ->
|
|
text
|
|
|
|
{pos, _len} ->
|
|
<<_ignore::binary-size(pos), rest::binary>> = text
|
|
"..." <> rest
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Returns a URL (including localhost) to import the given `url` as a notebook.
|
|
|
|
iex> Livebook.Utils.notebook_import_url("https://example.com/foo.livemd")
|
|
"http://localhost:4002/import?url=https%3A%2F%2Fexample.com%2Ffoo.livemd"
|
|
|
|
iex> Livebook.Utils.notebook_import_url("https://my_host", "https://example.com/foo.livemd")
|
|
"https://my_host/import?url=https%3A%2F%2Fexample.com%2Ffoo.livemd"
|
|
|
|
"""
|
|
def notebook_import_url(base_url \\ LivebookWeb.Endpoint.access_struct_url(), url) do
|
|
base_url
|
|
|> URI.parse()
|
|
|> Map.replace!(:path, "/import")
|
|
|> URI.append_query("url=#{URI.encode_www_form(url)}")
|
|
|> URI.to_string()
|
|
end
|
|
|
|
@doc """
|
|
Returns a URL (including localhost) to open the given `path` as a notebook
|
|
|
|
iex> Livebook.Utils.notebook_open_url("/data/foo.livemd")
|
|
"http://localhost:4002/open?path=%2Fdata%2Ffoo.livemd"
|
|
|
|
iex> Livebook.Utils.notebook_open_url("https://my_host", "/data/foo.livemd")
|
|
"https://my_host/open?path=%2Fdata%2Ffoo.livemd"
|
|
|
|
"""
|
|
def notebook_open_url(base_url \\ LivebookWeb.Endpoint.access_struct_url(), path) do
|
|
base_url
|
|
|> URI.parse()
|
|
|> Map.replace!(:path, "/open")
|
|
|> URI.append_query("path=#{URI.encode_www_form(path)}")
|
|
|> URI.to_string()
|
|
end
|
|
|
|
@doc """
|
|
Formats the given number of bytes into a human-friendly text.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.format_bytes(0)
|
|
"0 B"
|
|
|
|
iex> Livebook.Utils.format_bytes(1000)
|
|
"1000 B"
|
|
|
|
iex> Livebook.Utils.format_bytes(1100)
|
|
"1.1 KB"
|
|
|
|
iex> Livebook.Utils.format_bytes(1_228_800)
|
|
"1.2 MB"
|
|
|
|
iex> Livebook.Utils.format_bytes(1_363_148_800)
|
|
"1.3 GB"
|
|
|
|
iex> Livebook.Utils.format_bytes(1_503_238_553_600)
|
|
"1.4 TB"
|
|
"""
|
|
def format_bytes(bytes) when is_integer(bytes) do
|
|
cond do
|
|
bytes >= memory_unit(:TB) -> format_bytes(bytes, :TB)
|
|
bytes >= memory_unit(:GB) -> format_bytes(bytes, :GB)
|
|
bytes >= memory_unit(:MB) -> format_bytes(bytes, :MB)
|
|
bytes >= memory_unit(:KB) -> format_bytes(bytes, :KB)
|
|
true -> format_bytes(bytes, :B)
|
|
end
|
|
end
|
|
|
|
defp format_bytes(bytes, :B) when is_integer(bytes), do: "#{bytes} B"
|
|
|
|
defp format_bytes(bytes, unit) when is_integer(bytes) do
|
|
value = bytes / memory_unit(unit)
|
|
"#{:erlang.float_to_binary(value, decimals: 1)} #{unit}"
|
|
end
|
|
|
|
defp memory_unit(:TB), do: 1024 * 1024 * 1024 * 1024
|
|
defp memory_unit(:GB), do: 1024 * 1024 * 1024
|
|
defp memory_unit(:MB), do: 1024 * 1024
|
|
defp memory_unit(:KB), do: 1024
|
|
|
|
@doc """
|
|
Gets the port for an existing listener.
|
|
|
|
The listener references usually follow the pattern `plug.HTTP`
|
|
and `plug.HTTPS`.
|
|
"""
|
|
@spec get_port(:ranch.ref(), :inet.port_number()) :: :inet.port_number()
|
|
def get_port(ref, default) do
|
|
try do
|
|
:ranch.get_addr(ref)
|
|
rescue
|
|
_ -> default
|
|
else
|
|
{_, port} when is_integer(port) -> port
|
|
_ -> default
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Converts the given IP address into a valid hostname.
|
|
|
|
## Examples
|
|
|
|
iex> Livebook.Utils.ip_to_host({192, 168, 0, 1})
|
|
"192.168.0.1"
|
|
|
|
iex> Livebook.Utils.ip_to_host({127, 0, 0, 1})
|
|
"localhost"
|
|
|
|
iex> Livebook.Utils.ip_to_host({0, 0, 0, 0})
|
|
"0.0.0.0"
|
|
"""
|
|
@spec ip_to_host(:inet.ip_address()) :: String.t()
|
|
def ip_to_host(ip)
|
|
|
|
def ip_to_host({127, 0, 0, 1}), do: "localhost"
|
|
|
|
def ip_to_host(ip) do
|
|
ip |> :inet.ntoa() |> List.to_string()
|
|
end
|
|
end
|