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

Allow Kino.JS.Live outputs to be exported #321

Merged
merged 1 commit into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions lib/kino/js.ex
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ defmodule Kino.JS do

defstruct [:module, :ref, :export]

@opaque t :: %__MODULE__{module: module(), ref: Kino.Output.ref(), export: map()}
@opaque t :: %__MODULE__{module: module(), ref: Kino.Output.ref(), export: boolean()}

defmacro __using__(opts) do
quote location: :keep, bind_quoted: [opts: opts] do
Expand Down Expand Up @@ -435,19 +435,23 @@ defmodule Kino.JS do

## Options

* `:export_info_string` - used as the info string for the Markdown
code block where output data is persisted

* `:export_key` - in case the data is a map and only a specific part
should be exported
* `:export` - a function called to export the given kino to Markdown.
See the "Export" section below

## Export

The output can optionally be exported in notebook source by specifying
`:export_info_string`. For example:
an `:export` function. The function receives `data` as an argument
and should return a tuple `{info_string, payload}`. `info_string`
is used to annotate the Markdown code block where the output is
persisted. `payload` is the value persisted in the code block. The
value is automatically serialized to JSON, unless it is already a
string.

For example:

data = "graph TD;A-->B;"
Kino.JS.new(__MODULE__, data, export_info_string: "mermaid")
Kino.JS.new(__MODULE__, data, export: fn data -> {"mermaid", data} end)

Would be rendered as the following Live Markdown:

Expand All @@ -457,12 +461,22 @@ defmodule Kino.JS do
```
````

Non-binary data is automatically serialized to JSON.
> #### Export function {: .info}
>
> You should prefer to use the `data` argument for computing the
> export payload. However, if it cannot be inferred from `data`,
> you should just reference the original value. Do not put additional
> fields in `data`, just to use it for export.
"""
@spec new(module(), term(), keyword()) :: t()
def new(module, data, opts \\ []) do
# TODO: remove the old export options in Kino v0.14.0
export =
if info_string = opts[:export_info_string] do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opts[:export] || if ...

IO.warn(
"passing :export_info_string to Kino.JS.new/3 is deprecated. Specify an :export function instead"
)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will we have to update many kinos or only Kino.VegaLite?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kino.Maplibre too!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only other one I found is kino_wardely.

export_key = opts[:export_key]

if export_key do
Expand All @@ -477,17 +491,19 @@ defmodule Kino.JS do
end
end

%{info_string: info_string, key: export_key}
fn data -> {info_string, data[export_key]} end
end

export = export || opts[:export]

ref = Kino.Output.random_ref()

Kino.JS.DataStore.store(ref, data)
Kino.JS.DataStore.store(ref, data, export)

Kino.Bridge.reference_object(ref, self())
Kino.Bridge.monitor_object(ref, Kino.JS.DataStore, {:remove, ref})

%__MODULE__{module: module, ref: ref, export: export}
%__MODULE__{module: module, ref: ref, export: export != nil}
end

@doc false
Expand Down
34 changes: 28 additions & 6 deletions lib/kino/js/data_store.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ defmodule Kino.JS.DataStore do
@doc """
Stores output data under the given ref.
"""
@spec store(Kino.Output.ref(), term()) :: :ok
def store(ref, data) do
GenServer.cast(@name, {:store, ref, data})
@spec store(Kino.Output.ref(), term(), function()) :: :ok
def store(ref, data, export) do
GenServer.cast(@name, {:store, ref, data, export})
end

@impl true
Expand All @@ -35,19 +35,41 @@ defmodule Kino.JS.DataStore do
end

@impl true
def handle_cast({:store, ref, data}, state) do
{:noreply, put_in(state.ref_with_data[ref], data)}
def handle_cast({:store, ref, data, export}, state) do
state = put_in(state.ref_with_data[ref], %{data: data, export: export, export_result: nil})
{:noreply, state}
end

@impl true
def handle_info({:connect, pid, %{origin: _origin, ref: ref}}, state) do
with {:ok, data} <- Map.fetch(state.ref_with_data, ref) do
with {:ok, %{data: data}} <- Map.fetch(state.ref_with_data, ref) do
Kino.Bridge.send(pid, {:connect_reply, data, %{ref: ref}})
end

{:noreply, state}
end

def handle_info({:export, pid, %{ref: ref}}, state) do
case state.ref_with_data do
%{^ref => info} ->
{state, export_result} =
if info.export_result do
{state, info.export_result}
else
export_result = info.export.(info.data)
state = put_in(state.ref_with_data[ref].export_result, export_result)
{state, export_result}
end

Kino.Bridge.send(pid, {:export_reply, export_result, %{ref: ref}})

{:noreply, state}

_ ->
{:noreply, state}
end
end

def handle_info({:remove, ref}, state) do
{_, state} = pop_in(state.ref_with_data[ref])
{:noreply, state}
Expand Down
28 changes: 21 additions & 7 deletions lib/kino/js/live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,16 @@ defmodule Kino.JS.Live do
end
'''

defstruct [:module, :pid, :ref]
defstruct [:module, :pid, :ref, :export]

alias Kino.JS.Live.Context

@opaque t :: %__MODULE__{module: module(), pid: pid(), ref: Kino.Output.ref()}
@opaque t :: %__MODULE__{
module: module(),
pid: pid(),
ref: Kino.Output.ref(),
export: boolean()
}

@type payload :: term() | {:binary, info :: term(), binary()}

Expand Down Expand Up @@ -322,16 +327,25 @@ defmodule Kino.JS.Live do

The given `init_arg` is passed to the `init/2` callback when
the underlying kino process is started.

## Options

* `:export` - a function called to export the given kino to Markdown.
This works the same as `Kino.JS.new/3`, except the function
receives `t:Kino.JS.Live.Context.t/0` as an argument
Comment on lines +331 to +335
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@josevalim I made this an option instead of a callback, for two reasons:

  1. It better mirrors Kino.JS, so should be more intuitive. We also have the option on Kino.Table, this way they are all consistent.

  2. In some cases the original data may not be passed to the server directly (see Kino.DataTable.new/2). Passing it to the state for the purpose of export is not different from closure, but if it's large data then it may be better to just call inspect/1 right away and close over that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm not sure if should actually be passing the state to that function. If we do that, then people may make the export reflect the state (e.g. page when paginating the table). It is undesired, because (a) we wouldn't know that we should re-save when the page changes, so it would end up pretty arbitrary (b) it wouldn't reflect the actual value, that is, if the cell returns df and we suddenly persist 10th page, it's weird. Thoughts?


"""
@spec new(module(), term()) :: t()
def new(module, init_arg) do
@spec new(module(), term(), keyword()) :: t()
def new(module, init_arg, opts \\ []) do
export = opts[:export]

ref = Kino.Output.random_ref()

case Kino.start_child({Kino.JS.Live.Server, {module, init_arg, ref}}) do
case Kino.start_child({Kino.JS.Live.Server, {module, init_arg, ref, export}}) do
{:ok, pid} ->
subscription_manager = Kino.SubscriptionManager.cross_node_name()
Kino.Bridge.monitor_object(pid, subscription_manager, {:clear_topic, ref})
%__MODULE__{module: module, pid: pid, ref: ref}
%__MODULE__{module: module, pid: pid, ref: ref, export: export != nil}

{:error, reason} ->
raise ArgumentError,
Expand All @@ -348,7 +362,7 @@ defmodule Kino.JS.Live do
pid: kino.pid,
assets: kino.module.__assets_info__()
},
export: nil
export: kino.export
}
end

Expand Down
10 changes: 8 additions & 2 deletions lib/kino/js/live/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ defmodule Kino.JS.Live.Server do
end

@impl true
def init({module, init_arg, ref}) do
def init({module, init_arg, ref, export}) do
{:ok, ctx, _opts} = call_init(module, init_arg, ref)
{:ok, %{module: module, ctx: ctx}}
{:ok, %{module: module, ctx: ctx, export: export}}
end

@impl true
Expand All @@ -61,6 +61,12 @@ defmodule Kino.JS.Live.Server do
end

@impl true
def handle_info({:export, pid, %{}}, state) do
export_result = state.export.(state.ctx)
Kino.Bridge.send(pid, {:export_reply, export_result, %{ref: state.ctx.__private__.ref}})
{:noreply, state}
end

def handle_info(msg, state) do
case call_handle_info(msg, state.module, state.ctx) do
{:ok, ctx} -> {:noreply, %{state | ctx: ctx}}
Expand Down
2 changes: 1 addition & 1 deletion lib/kino/mermaid.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ defmodule Kino.Mermaid do
"""
@spec new(binary()) :: t()
def new(content) do
Kino.JS.new(__MODULE__, content, export_info_string: "mermaid")
Kino.JS.new(__MODULE__, content, export: fn content -> {"mermaid", content} end)
end
end
18 changes: 15 additions & 3 deletions lib/kino/table.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,22 @@ defmodule Kino.Table do
@doc """
Creates a new tabular kino using the given module as data
specification.

## Options

* `:export` - a function called to export the given kino to Markdown.
This works the same as `Kino.JS.new/3`, except the function
receives the state as an argument

"""
@spec new(module(), term()) :: t()
def new(module, init_arg) do
Kino.JS.Live.new(__MODULE__, {module, init_arg})
@spec new(module(), term(), keyword()) :: t()
def new(module, init_arg, opts \\ []) do
export =
if export = opts[:export] do
fn ctx -> export.(ctx.assigns.state) end
end

Kino.JS.Live.new(__MODULE__, {module, init_arg}, export: export)
end

@impl true
Expand Down
14 changes: 12 additions & 2 deletions test/kino/js/data_store_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ defmodule Kino.JS.DataStoreTest do
test "replies to connect messages with stored data" do
ref = Kino.Output.random_ref()
data = [1, 2, 3]
DataStore.store(ref, data)
DataStore.store(ref, data, nil)

send(DataStore, {:connect, self(), %{origin: "client1", ref: ref}})
assert_receive {:connect_reply, ^data, %{ref: ^ref}}
Expand All @@ -15,11 +15,21 @@ defmodule Kino.JS.DataStoreTest do
test "{:remove, ref} removes data for the given ref" do
ref = Kino.Output.random_ref()
data = [1, 2, 3]
DataStore.store(ref, data)
DataStore.store(ref, data, nil)

send(DataStore, {:remove, ref})

send(DataStore, {:connect, self(), %{origin: "client1", ref: ref}})
refute_receive {:connect_reply, _, %{ref: ^ref}}
end

test "replies to export messages when configured to" do
ref = Kino.Output.random_ref()
data = [1, 2, 3]
export = fn data -> {"text", inspect(data)} end
DataStore.store(ref, data, export)

send(DataStore, {:export, self(), %{origin: "client1", ref: ref}})
assert_receive {:export_reply, {"text", "[1, 2, 3]"}, %{ref: ^ref}}
end
end
6 changes: 6 additions & 0 deletions test/kino/js/live_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,10 @@ defmodule Kino.JS.LiveTest do
Process.exit(pid, :kill)
assert_receive {:DOWN, ^ref, :process, ^pid, :killed}
end

test "export" do
%{ref: ref} = kino = LiveCounter.new(0)
send(kino.pid, {:export, self(), %{ref: ref}})
assert_receive {:export_reply, {"text", 0}, %{ref: ^ref}}
end
end
33 changes: 5 additions & 28 deletions test/kino/js_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -16,43 +16,20 @@ defmodule Kino.JSTest do
end

describe "new/3" do
test "raises an error when :export_key is specified but data is not a map" do
assert_raise ArgumentError,
"expected data to be a map, because :export_key is specified, got: []",
fn ->
Kino.JS.new(Kino.TestModules.JSExternalAssets, [],
export_info_string: "lang",
export_key: :spec
)
end
end

test "raises an error when :export_key not in data" do
assert_raise ArgumentError,
"got :export_key of :spec, but no such key found in data: %{width: 10}",
fn ->
Kino.JS.new(Kino.TestModules.JSExternalAssets, %{width: 10},
export_info_string: "lang",
export_key: :spec
)
end
end

test "builds export info when :export_info_string is specified" do
test "sets export to true when :export is specified" do
kino =
Kino.JS.new(Kino.TestModules.JSExternalAssets, %{spec: %{"width" => 10, "height" => 10}},
export_info_string: "vega-lite",
export_key: :spec
export: fn vl -> {"vega-lite", vl.spec} end
)

assert %{export: %{info_string: "vega-lite", key: :spec}} = kino
assert %{export: true} = kino
end

test "sets export info to nil when :export_info_string is not specified" do
test "sets export to false when :export is not specified" do
kino =
Kino.JS.new(Kino.TestModules.JSExternalAssets, %{spec: %{"width" => 10, "height" => 10}})

assert %{export: nil} = kino
assert %{export: false} = kino
end
end
end
2 changes: 1 addition & 1 deletion test/support/test_modules/live_counter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule Kino.TestModules.LiveCounter do
use Kino.JS.Live

def new(count) do
Kino.JS.Live.new(__MODULE__, count)
Kino.JS.Live.new(__MODULE__, count, export: fn ctx -> {"text", ctx.assigns.count} end)
end

def bump(kino, by \\ 1) do
Expand Down
Loading