Repository: livebook-dev/kino
Pull request #337 (2023-09-11 07:55:37)

Bump versions in GitHub test workflow
Fixes warning in recent workflow runs:

> The following actions uses node12 which is deprecated and will be forced
> to run on node16: actions/checkout@v2, actions/cache@v2.
>
> For more info:
> https://github.blog/changelog/2023-06-13-github-actions-all-actions-will-run-on-node16-instead-of-node12-by-default/

Example run: https://github.com/livebook-dev/kino/actions/runs/6142008298


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8038ee3..f240a58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [Unreleased](https://github.com/livebook-dev/kino)
+
+### Added
+
+* Added `Kino.Shorts`, a convenience module for building outputs and inputs ([#310](https://github.com/livebook-dev/kino/pull/310))
+* Added `Kino.Audio` and `Kino.Video` for displaying multimedia data ([#311](https://github.com/livebook-dev/kino/pull/311))
+* Option for `Kino.Control.keyboard/2` to not suppress default shortcuts ([#312](https://github.com/livebook-dev/kino/pull/312))
+* Added `:terminal` option to `Kino.Text` to support ANSI codes and to mirror stdio style ([#314](https://github.com/livebook-dev/kino/pull/314))
+* Support for remote processes in `Kino.Process` ([#317](https://github.com/livebook-dev/kino/pull/317))
+* Support for chunked text and markdown outputs ([#318](https://github.com/livebook-dev/kino/pull/318))
+* Added `Kino.FS` for accessing notebook files ([#319](https://github.com/livebook-dev/kino/pull/319))
+* Support for `Kino.JS.Live` outputs to be exported ([#321](https://github.com/livebook-dev/kino/pull/321))
+* Added `Kino.start_child!/1` ([#322](https://github.com/livebook-dev/kino/pull/322))
+* Remote execution cell ([#328](https://github.com/livebook-dev/kino/pull/328))
+
+### Changed
+
+* `Kino.listen/{2,3}` to return PID of the started process ([#306](https://github.com/livebook-dev/kino/pull/306))
+
+### Fixed
+
+* Fixed date validation when allowed range is given ([#326](https://github.com/livebook-dev/kino/pull/326))
+
 ## [v0.10.0](https://github.com/livebook-dev/kino/tree/v0.10.0) (2023-07-10)
 
 ### Added
diff --git a/lib/kino.ex b/lib/kino.ex
index 4be4624..a3e244d 100644
--- a/lib/kino.ex
+++ b/lib/kino.ex
@@ -516,4 +516,16 @@ defmodule Kino do
   def interrupt!(variant, message) when variant in [:normal, :error] and is_binary(message) do
     raise Kino.InterruptError, variant: variant, message: message
   end
+
+  @doc """
+  Returns a temporary directory that gets removed when the runtime
+  terminates.
+  """
+  @spec tmp_dir() :: String.t() | nil
+  def tmp_dir() do
+    case Kino.Bridge.get_tmp_dir() do
+      {:ok, path} -> path
+      {:error, _} -> nil
+    end
+  end
 end
diff --git a/lib/kino/application.ex b/lib/kino/application.ex
index de6c174..d154830 100644
--- a/lib/kino/application.ex
+++ b/lib/kino/application.ex
@@ -6,7 +6,7 @@ defmodule Kino.Application do
   def start(_type, _args) do
     Application.put_env(:elixir, :dbg_callback, {Kino.Debug, :dbg, []})
 
-    Kino.Counter.initialize()
+    Kino.AttributeStore.initialize()
 
     Kino.SmartCell.register(Kino.RemoteExecutionCell)
 
diff --git a/lib/kino/attribute_store.ex b/lib/kino/attribute_store.ex
new file mode 100644
index 0000000..1c27a9a
--- /dev/null
+++ b/lib/kino/attribute_store.ex
@@ -0,0 +1,54 @@
+defmodule Kino.AttributeStore do
+  @moduledoc false
+
+  @table_name __MODULE__
+
+  @doc """
+  Initializes resources for global attrs.
+  """
+  @spec initialize() :: :ok
+  def initialize() do
+    :ets.new(@table_name, [:set, :named_table, :public])
+    :ok
+  end
+
+  @doc """
+  Increments the given counter and returns the new value.
+  """
+  @spec counter_next(term()) :: integer()
+  def counter_next(key) do
+    :ets.update_counter(@table_name, key, {2, 1}, {key, 0})
+  end
+
+  @doc """
+  Sets the counter to `value` unless it already has a higher value.
+
+  Returns the new counter value.
+  """
+  @spec counter_put_max(term(), integer()) :: integer()
+  def counter_put_max(key, value) do
+    [_, counter] =
+      :ets.update_counter(@table_name, key, [{2, -1, value, value - 1}, {2, 1}], {key, 0})
+
+    counter
+  end
+
+  @doc """
+  Puts the shared attribute value.
+  """
+  @spec put_attribute(term(), term()) :: boolean()
+  def put_attribute(key, value) do
+    :ets.insert(@table_name, {key, value})
+  end
+
+  @doc """
+  Returns the attribute value for a given key.
+  """
+  @spec get_attribute(term()) :: term()
+  def get_attribute(key, default \\ nil) do
+    case :ets.lookup(@table_name, key) do
+      [] -> default
+      [{_key, value} | _] -> value
+    end
+  end
+end
diff --git a/lib/kino/bridge.ex b/lib/kino/bridge.ex
index ced289d..2f3f478 100644
--- a/lib/kino/bridge.ex
+++ b/lib/kino/bridge.ex
@@ -202,6 +202,14 @@ defmodule Kino.Bridge do
     with {:ok, reply} <- io_request(:livebook_get_app_info), do: reply
   end
 
+  @doc """
+  Returns a temporary directory tied to the current runtime.
+  """
+  @spec get_tmp_dir() :: {:ok, String.t()} | {:error, request_error() | :not_available}
+  def get_tmp_dir() do
+    with {:ok, reply} <- io_request(:livebook_get_tmp_dir), do: reply
+  end
+
   @doc """
   Checks if the caller is running within Livebook context (group leader).
   """
diff --git a/lib/kino/counter.ex b/lib/kino/counter.ex
deleted file mode 100644
index cbb5098..0000000
--- a/lib/kino/counter.ex
+++ /dev/null
@@ -1,35 +0,0 @@
-defmodule Kino.Counter do
-  @moduledoc false
-
-  @table_name __MODULE__
-
-  @doc """
-  Initializes resources for global counters.
-  """
-  @spec initialize() :: :ok
-  def initialize() do
-    :ets.new(@table_name, [:set, :named_table, :public])
-    :ok
-  end
-
-  @doc """
-  Increments the given counter and returns the new value.
-  """
-  @spec next(term()) :: integer()
-  def next(key) do
-    :ets.update_counter(@table_name, key, {2, 1}, {key, 0})
-  end
-
-  @doc """
-  Sets the counter to `value` unless it already has a higher value.
-
-  Returns the new counter value.
-  """
-  @spec put_max(term(), integer()) :: integer()
-  def put_max(key, value) do
-    [_, counter] =
-      :ets.update_counter(@table_name, key, [{2, -1, value, value - 1}, {2, 1}], {key, 0})
-
-    counter
-  end
-end
diff --git a/lib/kino/input.ex b/lib/kino/input.ex
index 556cdd2..acd8865 100644
--- a/lib/kino/input.ex
+++ b/lib/kino/input.ex
@@ -277,7 +277,7 @@ defmodule Kino.Input do
 
     if min && max && NaiveDateTime.compare(min, max) == :gt do
       raise ArgumentError,
-            "expected :min to be less than :max, got: #{inspect(min)} and #{inspect(max)}"
+            "expected a non-empty range, but :min (#{inspect(min)}) is after :max (#{inspect(max)})"
     end
 
     assert_default_value!(
@@ -287,11 +287,13 @@ defmodule Kino.Input do
     )
 
     if min && default && NaiveDateTime.compare(default, min) == :lt do
-      raise ArgumentError, "expected :default to be bigger than :min, got: #{inspect(default)} "
+      raise ArgumentError,
+            "invalid :default, #{inspect(default)} is before :min (#{inspect(min)})"
     end
 
     if max && default && NaiveDateTime.compare(default, max) == :gt do
-      raise ArgumentError, "expected :default to be smaller than :max, got: #{inspect(default)}"
+      raise ArgumentError,
+            "invalid :default, #{inspect(default)} is after :max (#{inspect(max)})"
     end
 
     new(%{
@@ -333,17 +335,19 @@ defmodule Kino.Input do
 
     if min && max && Time.compare(min, max) == :gt do
       raise ArgumentError,
-            "expected :min to be less than :max, got: #{inspect(min)} and #{inspect(max)}"
+            "expected a non-empty range, but :min (#{inspect(min)}) is after :max (#{inspect(max)})"
     end
 
     assert_default_value!(default, "be %Time{} or nil", &(is_struct(&1, Time) or &1 == nil))
 
     if min && default && Time.compare(default, min) == :lt do
-      raise ArgumentError, "expected :default to be bigger than :min, got: #{inspect(default)}"
+      raise ArgumentError,
+            "invalid :default, #{inspect(default)} is before :min (#{inspect(min)})"
     end
 
     if max && default && Time.compare(default, max) == :gt do
-      raise ArgumentError, "expected :default to be smaller than :max, got: #{inspect(default)}"
+      raise ArgumentError,
+            "invalid :default, #{inspect(default)} is after :max (#{inspect(max)})"
     end
 
     new(%{
@@ -384,17 +388,19 @@ defmodule Kino.Input do
 
     if min && max && Date.compare(min, max) == :gt do
       raise ArgumentError,
-            "expected :min to be less than :max, got: #{inspect(min)} and #{inspect(max)}"
+            "expected a non-empty range, but :min (#{inspect(min)}) is after :max (#{inspect(max)})"
     end
 
     assert_default_value!(default, "be %Date{} or nil", &(is_struct(&1, Date) or &1 == nil))
 
     if min && default && Date.compare(default, min) == :lt do
-      raise ArgumentError, "expected :default to be bigger than :min, got: #{inspect(default)}"
+      raise ArgumentError,
+            "invalid :default, #{inspect(default)} is before :min (#{inspect(min)})"
     end
 
     if max && default && Date.compare(default, max) == :gt do
-      raise ArgumentError, "expected :default to be smaller than :max, got: #{inspect(default)}"
+      raise ArgumentError,
+            "invalid :default, #{inspect(default)} is after :max (#{inspect(max)})"
     end
 
     new(%{
diff --git a/lib/kino/remote_ execution_cell.ex b/lib/kino/remote_execution_cell.ex
similarity index 70%
rename from lib/kino/remote_ execution_cell.ex
rename to lib/kino/remote_execution_cell.ex
index f9b58d8..e0b769b 100644
--- a/lib/kino/remote_ execution_cell.ex
+++ b/lib/kino/remote_execution_cell.ex
@@ -5,16 +5,24 @@ defmodule Kino.RemoteExecutionCell do
   use Kino.JS.Live
   use Kino.SmartCell, name: "Remote execution"
 
+  alias Kino.AttributeStore
+
   @default_code ":ok"
+  @global_key __MODULE__
+  @global_attrs ["node", "cookie", "cookie_secret"]
 
   @impl true
   def init(attrs, ctx) do
+    {shared_cookie, shared_cookie_secret} =
+      AttributeStore.get_attribute({@global_key, :cookie}, {nil, nil})
+
     fields = %{
       "assign_to" => attrs["assign_to"] || "",
-      "node" => attrs["node"] || "",
-      "cookie" => attrs["cookie"],
-      "use_cookie_secret" => Map.get(attrs, "use_cookie_secret", true),
-      "cookie_secret" => attrs["cookie_secret"]
+      "node" => attrs["node"] || AttributeStore.get_attribute({@global_key, :node}) || "",
+      "cookie" => attrs["cookie"] || shared_cookie || "",
+      "cookie_secret" => attrs["cookie_secret"] || shared_cookie_secret || "",
+      "use_cookie_secret" =>
+        if(shared_cookie, do: false, else: Map.get(attrs, "use_cookie_secret", true))
     }
 
     ctx = assign(ctx, fields: fields)
@@ -31,6 +39,7 @@ defmodule Kino.RemoteExecutionCell do
   @impl true
   def handle_event("update_field", %{"field" => field, "value" => value}, ctx) do
     ctx = update(ctx, :fields, &Map.put(&1, field, value))
+    if field in @global_attrs, do: put_shared_attr(field, value)
     broadcast_event(ctx, "update_field", %{"fields" => %{field => value}})
 
     {:noreply, ctx}
@@ -95,4 +104,16 @@ defmodule Kino.RemoteExecutionCell do
   end
 
   defp build_set_cookie(%{"cookie" => cookie}), do: String.to_atom(cookie)
+
+  defp put_shared_attr("cookie", value) do
+    AttributeStore.put_attribute({@global_key, :cookie}, {value, nil})
+  end
+
+  defp put_shared_attr("cookie_secret", value) do
+    AttributeStore.put_attribute({@global_key, :cookie}, {nil, value})
+  end
+
+  defp put_shared_attr(field, value) do
+    AttributeStore.put_attribute({@global_key, String.to_atom(field)}, value)
+  end
 end
diff --git a/lib/kino/smart_cell.ex b/lib/kino/smart_cell.ex
index 75db928..cb1accb 100644
--- a/lib/kino/smart_cell.ex
+++ b/lib/kino/smart_cell.ex
@@ -338,7 +338,7 @@ defmodule Kino.SmartCell do
   def prefixed_var_name(prefix, var_name)
 
   def prefixed_var_name(prefix, nil) do
-    case Kino.Counter.next(var_counter_key(prefix)) do
+    case Kino.AttributeStore.counter_next(var_counter_key(prefix)) do
       1 -> prefix
       n -> "#{prefix}#{n}"
     end
@@ -347,7 +347,7 @@ defmodule Kino.SmartCell do
   def prefixed_var_name(prefix, var_name) do
     with {:ok, suffix} <- parse_var_prefix(var_name, prefix),
          {:ok, n} <- parse_var_suffix(suffix) do
-      Kino.Counter.put_max(var_counter_key(prefix), n)
+      Kino.AttributeStore.counter_put_max(var_counter_key(prefix), n)
     end
 
     var_name


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index c099e4a..1090e90 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -12,18 +12,18 @@ jobs:
       matrix:
         include:
           - pair:
-              elixir: "1.15.2"
+              elixir: "1.15.5"
               otp: "26.0.2"
             lint: true
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
 
       - uses: erlef/setup-beam@v1
         with:
           otp-version: ${{matrix.pair.otp}}
           elixir-version: ${{matrix.pair.elixir}}
 
-      - uses: actions/cache@v2
+      - uses: actions/cache@v3
         with:
           path: |
             deps
diff --git a/test/kino/attribute_store_test.exs b/test/kino/attribute_store_test.exs
new file mode 100644
index 0000000..a4c0fb0
--- /dev/null
+++ b/test/kino/attribute_store_test.exs
@@ -0,0 +1,54 @@
+defmodule Kino.AttributeStoreTets do
+  use ExUnit.Case, async: true
+
+  alias Kino.AttributeStore
+
+  describe "counter_next/2" do
+    test "returns the default value if the counter doesn't exist", %{test: test} do
+      assert AttributeStore.counter_next(test) == 1
+    end
+
+    test "increments the counter on every call", %{test: test} do
+      AttributeStore.counter_next(test)
+
+      assert AttributeStore.counter_next(test) == 2
+      assert AttributeStore.counter_next(test) == 3
+      assert AttributeStore.counter_next(test) == 4
+    end
+  end
+
+  describe "counter_put_max/2" do
+    test "sets the given value if the counter doesn't exist", %{test: test} do
+      assert AttributeStore.counter_put_max(test, 10) == 10
+    end
+
+    test "keeps existing value if the given one is lower or equal", %{test: test} do
+      AttributeStore.counter_put_max(test, 10)
+
+      assert AttributeStore.counter_put_max(test, 5) == 10
+      assert AttributeStore.counter_put_max(test, 10) == 10
+    end
+
+    test "sets the given value if higher than the current one", %{test: test} do
+      AttributeStore.counter_put_max(test, 10)
+
+      assert AttributeStore.counter_put_max(test, 11) == 11
+      assert AttributeStore.counter_put_max(test, 15) == 15
+    end
+  end
+
+  describe "get_attr/1" do
+    test "returns the default value if the shared attr doesn't exist", %{test: test} do
+      assert AttributeStore.get_attribute({test, :no_attr}) == nil
+    end
+
+    test "returns a custom default value if the shared attr doesn't exist", %{test: test} do
+      assert AttributeStore.get_attribute({test, :no_attr}, {nil, nil}) == {nil, nil}
+    end
+
+    test "returns the value if the shared attr exist", %{test: test} do
+      AttributeStore.put_attribute({test, :shared_attr}, "plain value")
+      assert AttributeStore.get_attribute({test, :shared_attr}) == "plain value"
+    end
+  end
+end
diff --git a/test/kino/counter_test.exs b/test/kino/counter_test.exs
deleted file mode 100644
index ad6c3cd..0000000
--- a/test/kino/counter_test.exs
+++ /dev/null
@@ -1,39 +0,0 @@
-defmodule Kino.CounterTets do
-  use ExUnit.Case, async: true
-
-  alias Kino.Counter
-
-  describe "next/2" do
-    test "returns the default value if the counter doesn't exist", %{test: test} do
-      assert Counter.next(test) == 1
-    end
-
-    test "increments the counter on every call", %{test: test} do
-      Counter.next(test)
-
-      assert Counter.next(test) == 2
-      assert Counter.next(test) == 3
-      assert Counter.next(test) == 4
-    end
-  end
-
-  describe "bump/2" do
-    test "sets the given value if the counter doesn't exist", %{test: test} do
-      assert Counter.put_max(test, 10) == 10
-    end
-
-    test "keeps existing value if the given one is lower or equal", %{test: test} do
-      Counter.put_max(test, 10)
-
-      assert Counter.put_max(test, 5) == 10
-      assert Counter.put_max(test, 10) == 10
-    end
-
-    test "sets the given value if higher than the current one", %{test: test} do
-      Counter.put_max(test, 10)
-
-      assert Counter.put_max(test, 11) == 11
-      assert Counter.put_max(test, 15) == 15
-    end
-  end
-end
diff --git a/test/kino/remote_execution_cell_test.exs b/test/kino/remote_execution_cell_test.exs
index 3ceb82d..8b0baf0 100644
--- a/test/kino/remote_execution_cell_test.exs
+++ b/test/kino/remote_execution_cell_test.exs
@@ -4,6 +4,7 @@ defmodule Kino.RemoteExecutionCellTest do
   import Kino.Test
 
   alias Kino.RemoteExecutionCell
+  alias Kino.AttributeStore
 
   setup :configure_livebook_bridge
 
@@ -159,4 +160,68 @@ defmodule Kino.RemoteExecutionCellTest do
       assert RemoteExecutionCell.to_source(attrs) == ""
     end
   end
+
+  defmodule Global do
+    use ExUnit.Case, async: false
+
+    setup do
+      AttributeStore.put_attribute({Kino.RemoteExecutionCell, :node}, "name@node@global")
+
+      AttributeStore.put_attribute(
+        {Kino.RemoteExecutionCell, :cookie},
+        {"node-cookie-global", nil}
+      )
+
+      :ok
+    end
+
+    test "from stored attrs" do
+      {_kino, source} = start_smart_cell!(RemoteExecutionCell, %{})
+
+      assert source == """
+             node = :name@node@global
+             Node.set_cookie(node, :"node-cookie-global")
+             :erpc.call(node, fn -> :ok end)\
+             """
+    end
+
+    test "from stored attrs with cookie as a secret" do
+      AttributeStore.put_attribute(
+        {Kino.RemoteExecutionCell, :cookie},
+        {nil, "COOKIE_SECRET_GLOBAL"}
+      )
+
+      {_kino, source} = start_smart_cell!(RemoteExecutionCell, %{})
+
+      assert source == """
+             node = :name@node@global
+             Node.set_cookie(node, String.to_atom(System.fetch_env!("LB_COOKIE_SECRET_GLOBAL")))
+             :erpc.call(node, fn -> :ok end)\
+             """
+    end
+
+    test "init attrs precedes stored attrs" do
+      {_kino, source} = start_smart_cell!(RemoteExecutionCell, %{"node" => "name@node@attrs"})
+
+      assert source == """
+             node = :name@node@attrs
+             Node.set_cookie(node, :"node-cookie-global")
+             :erpc.call(node, fn -> :ok end)\
+             """
+    end
+
+    test "stored attrs always come from the most recent edited cell" do
+      {kino, _source} = start_smart_cell!(RemoteExecutionCell, %{})
+      push_event(kino, "update_field", %{"field" => "node", "value" => "edited@node@name"})
+      assert_receive {:runtime_smart_cell_update, _, _, _, _}
+
+      {_kino, source} = start_smart_cell!(RemoteExecutionCell, %{})
+
+      assert source == """
+             node = :edited@node@name
+             Node.set_cookie(node, :"node-cookie-global")
+             :erpc.call(node, fn -> :ok end)\
+             """
+    end
+  end
 end