livebook/lib/livebook/utils.ex

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