Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for accessing notebook files - with FSS conversion #319

Merged
merged 10 commits into from
Aug 22, 2023
Merged
18 changes: 18 additions & 0 deletions lib/kino/bridge.ex
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,24 @@ defmodule Kino.Bridge do
with {:ok, reply} <- io_request({:livebook_get_file_path, file_ref}), do: reply
end

@doc """
Requests the file path for notebook file with the given name.
"""
@spec get_file_entry_path(String.t()) ::
{:ok, term()} | {:error, request_error() | :forbidden | String.t()}
def get_file_entry_path(name) do
with {:ok, reply} <- io_request({:livebook_get_file_entry_path, name}), do: reply
end

@doc """
Requests the file spec for notebook file with the given name.
"""
@spec get_file_entry_spec(String.t()) ::
{:ok, term()} | {:error, request_error() | :forbidden | String.t()}
def get_file_entry_spec(name) do
with {:ok, reply} <- io_request({:livebook_get_file_entry_spec, name}), do: reply
end

@doc """
Associates `object` with `pid`.

Expand Down
100 changes: 100 additions & 0 deletions lib/kino/fs.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
defmodule Kino.FS do
@moduledoc """
Provides access to notebook files.
"""

defmodule ForbiddenError do
@moduledoc """
Exception raised when access to a notebook file is forbidden.
"""

defexception [:name]

@impl true
def message(exception) do
"forbidden access to file #{inspect(exception.name)}"
end
end

@doc """
Accesses notebook file with the given name and returns a local path
to read its contents from.

This invocation may take a while, in case the file is downloaded
from a URL and is not in the cache.

> #### File operations {: .info}
>
> You should treat the file as read-only. To avoid unnecessary
> copies the path may potentially be pointing to the original file,
> in which case any write operations would be persisted. This
> behaviour is not always the case, so you should not rely on it
> either.
"""
@spec file_path(String.t()) :: String.t()
def file_path(name) when is_binary(name) do
case Kino.Bridge.get_file_entry_path(name) do
{:ok, path} ->
path

{:error, :forbidden} ->
raise ForbiddenError, name: name

{:error, message} when is_binary(message) ->
raise message

{:error, reason} when is_atom(reason) ->
raise "failed to access file path, reason: #{inspect(reason)}"
end
end

@doc """
Accesses notebook file with the given name and returns a specification
of the file location.

This does not copy any files and moves the responsibility of reading
the file to the caller. If you need to read a file directly, use
`file_path/1`.
"""
@spec file_spec(String.t()) :: FSS.entry()
def file_spec(name) do
case Kino.Bridge.get_file_entry_spec(name) do
{:ok, spec} ->
file_spec_to_fss(spec)

{:error, :forbidden} ->
raise ForbiddenError, name: name

{:error, message} when is_binary(message) ->
raise message

{:error, reason} when is_atom(reason) ->
raise "failed to access file spec, reason: #{inspect(reason)}"
end
end

defp file_spec_to_fss(%{type: :local} = file_spec) do
FSS.Local.from_path(file_spec.path)
end

defp file_spec_to_fss(%{type: :url} = file_spec) do
case FSS.HTTP.parse(file_spec.url) do
{:ok, entry} -> entry
{:error, error} -> raise error
end
end

defp file_spec_to_fss(%{type: :s3} = file_spec) do
case FSS.S3.parse("s3:///" <> file_spec.key,
config: [
region: file_spec.region,
endpoint: file_spec.bucket_url,
access_key_id: file_spec.access_key_id,
secret_access_key: file_spec.secret_access_key
]
) do
{:ok, entry} -> entry
{:error, error} -> raise error
end
end
end
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ defmodule Kino.MixProject do
defp deps do
[
{:table, "~> 0.1.2"},
{:fss, github: "elixir-explorer/fss", branch: "main"},
{:nx, "~> 0.1", optional: true},
{:ex_doc, "~> 0.28", only: :dev, runtime: false}
]
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"complex": {:hex, :complex, "0.4.2", "923e5db0be13dbb3ea00cf8459d9f75f3afdd9ff5a82742ded21064330d28273", [:mix], [], "hexpm", "069a085ef820ce675a2619fd125b963ff4514af2102c7f7d7965128e5ec0a429"},
"earmark_parser": {:hex, :earmark_parser, "1.4.31", "a93921cdc6b9b869f519213d5bc79d9e218ba768d7270d46fdcf1c01bacff9e2", [:mix], [], "hexpm", "317d367ee0335ef037a87e46c91a2269fef6306413f731e8ec11fc45a7efd059"},
"ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"},
"fss": {:git, "https://github.com/elixir-explorer/fss.git", "19ed6ce8359e9790e818b732ba8d552aaf36e029", [branch: "main"]},
"makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"},
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
Expand Down
96 changes: 96 additions & 0 deletions test/kino/fs_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
defmodule Kino.FSTest do
use ExUnit.Case, async: true

describe "file_spec/1" do
test "returns a file spec" do
name = "file.txt"
path = "/home/bob/file.txt"
spec = %{type: :local, path: path}

configure_gl_with_reply({:livebook_get_file_entry_spec, name}, {:ok, spec})

assert %FSS.Local.Entry{path: ^path} = Kino.FS.file_spec(name)
end

test "returns an HTTP FSS entry" do
name = "remote-file.txt"
url = "https://example.com/remote-file.txt"
spec = %{type: :url, url: url}

configure_gl_with_reply({:livebook_get_file_entry_spec, name}, {:ok, spec})

assert %FSS.HTTP.Entry{url: ^url, config: %FSS.HTTP.Config{headers: []}} =
Kino.FS.file_spec(name)
end

test "returns a S3 FSS entry" do
name = "file-from-s3.txt"
bucket_url = "https://s3.us-west-1.amazonaws.com/my-bucket"

spec = %{
type: :s3,
bucket_url: bucket_url,
region: "us-west-1",
access_key_id: "access-key-1",
secret_access_key: "secret-access-key-1",
key: "file-from-s3.txt"
}

configure_gl_with_reply({:livebook_get_file_entry_spec, name}, {:ok, spec})

assert %FSS.S3.Entry{} = s3 = Kino.FS.file_spec(name)

assert s3.key == spec.key

assert s3.config.region == spec.region
assert s3.config.endpoint == bucket_url
assert s3.config.access_key_id == spec.access_key_id
assert s3.config.secret_access_key == spec.secret_access_key
assert s3.config.bucket == nil
end

test "raises an error in case s3 file_spec has something nil" do
name = "file-from-s3.txt"

spec = %{
type: :s3,
bucket_url: nil,
region: "us-west-1",
access_key_id: "access-key-1",
secret_access_key: "secret-access-key-1",
key: name
}

configure_gl_with_reply({:livebook_get_file_entry_spec, name}, {:ok, spec})

assert_raise ArgumentError, "endpoint is required when bucket is nil", fn ->
Kino.FS.file_spec(name)
end

bucket_url = "https://s3.us-west-1.amazonaws.com/my-bucket"

spec =
spec
|> Map.replace!(:bucket_url, bucket_url)
|> Map.replace!(:access_key_id, nil)

configure_gl_with_reply({:livebook_get_file_entry_spec, name}, {:ok, spec})

assert_raise ArgumentError,
"missing :access_key_id for FSS.S3 (set the key or the AWS_ACCESS_KEY_ID env var)",
fn ->
Kino.FS.file_spec(name)
end
end
end

defp configure_gl_with_reply(request, reply) do
gl =
spawn(fn ->
assert_receive {:io_request, from, reply_as, ^request}
send(from, {:io_reply, reply_as, reply})
end)

Process.group_leader(self(), gl)
end
end
Loading