diff --git a/.gitignore b/.gitignore
index f11690b..16c47ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -122,3 +122,6 @@ dmypy.json
# extension update
_temp_extension
+
+# lite
+.jupyterlite.doit.db
diff --git a/docs/environment.yml b/docs/environment.yml
index 7f1bd00..00f409c 100644
--- a/docs/environment.yml
+++ b/docs/environment.yml
@@ -1,18 +1,24 @@
name: ipylab-lite
channels:
-- conda-forge
+ - conda-forge
+ - nodefaults
dependencies:
-- python-build
-- python=3.11
-- pip
-- mamba
-- pydata-sphinx-theme
-- myst-parser
-- ipywidgets >=8.0,<9
-- jupyterlab >=4,<5
-- jupyterlite-core >=0.6.1,<0.7.0
-- jupyterlite-pyodide-kernel >=0.6.0,<0.7.0
-- jupyterlite-sphinx >=0.20.2,<0.21.0
-- nodejs=22
-- pip:
- - ..
+ # runtimes
+ - python 3.12.*
+ - nodejs 24.*
+ # run
+ - ipywidgets >=8.0,<9
+ # build
+ - mamba
+ - pip
+ - python-build
+ # docs
+ - pydata-sphinx-theme
+ - myst-parser
+ # demo
+ - notebook >=7.5.0,<8
+ - jupyterlite-core >=0.7.0,<0.8
+ - jupyterlite-core-with-libarchive
+ - jupyterlite-core-with-lab
+ - jupyterlite-pyodide-kernel >=0.7.0,<0.8
+ - jupyterlite-sphinx >=0.22.0,<0.23.0
diff --git a/docs/jupyter-lite.json b/docs/jupyter-lite.json
new file mode 100644
index 0000000..5ca403f
--- /dev/null
+++ b/docs/jupyter-lite.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://raw.githubusercontent.com/jupyterlite/jupyterlite/refs/heads/main/app/jupyterlite.schema.v0.json",
+ "jupyter-config-data": {
+ "disabledExtensions": ["jupyterlab-jupytext"]
+ }
+}
diff --git a/docs/jupyter_lite_config.json b/docs/jupyter_lite_config.json
index 4b8e712..48b0472 100644
--- a/docs/jupyter_lite_config.json
+++ b/docs/jupyter_lite_config.json
@@ -1,8 +1,18 @@
{
+ "LiteBuildConfig": {
+ "contents": ["."],
+ "output_dir": "../build/docs-app",
+ "federated_extensions": [
+ "https://files.pythonhosted.org/packages/py2.py3/b/bqplot/bqplot-0.12.45-py2.py3-none-any.whl",
+ "https://files.pythonhosted.org/packages/py2.py3/i/ipytree/ipytree-0.2.2-py2.py3-none-any.whl"
+ ]
+ },
"PipliteAddon": {
"piplite_urls": [
"../dist",
- "https://files.pythonhosted.org/packages/py3/i/ipywidgets/ipywidgets-8.1.0-py3-none-any.whl"
+ "https://files.pythonhosted.org/packages/py2.py3/b/bqplot/bqplot-0.12.45-py2.py3-none-any.whl",
+ "https://files.pythonhosted.org/packages/py2.py3/i/ipytree/ipytree-0.2.2-py2.py3-none-any.whl",
+ "https://files.pythonhosted.org/packages/py3/i/ipywidgets/ipywidgets-8.1.8-py3-none-any.whl"
]
}
}
diff --git a/examples/commands.ipynb b/examples/commands.ipynb
index 9ac8668..dff154c 100644
--- a/examples/commands.ipynb
+++ b/examples/commands.ipynb
@@ -4,7 +4,9 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "# Command Registry"
+ "# Commands\n",
+ "\n",
+ "[_Commands_](https://jupyterlab.readthedocs.io/en/stable/user/commands.html) are globally defined, asynchronous actions which allow plugins in Jupyter Front Ends to communicate. They also generally power user-facing actions, such as the launcher, toolbars, keyboard shortcuts, and the context and main menus. "
]
},
{
@@ -23,24 +25,41 @@
"outputs": [],
"source": [
"from ipylab import JupyterFrontEnd\n",
+ "from ipywidgets import Output\n",
"\n",
"app = JupyterFrontEnd()"
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Command Registry"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### List all commands"
+ ]
+ },
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
- "app.version"
+ "sorted(app.commands.list_commands())[:5]"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## List all commands"
+ "### Explore commands\n",
+ "\n",
+ "Beyond their `id`, commands contain a variety of other information."
]
},
{
@@ -49,14 +68,39 @@
"metadata": {},
"outputs": [],
"source": [
- "app.commands.list_commands()"
+ "from ipywidgets import VBox, Select\n",
+ "from IPython.display import JSON, Markdown\n",
+ "\n",
+ "command_list = Select(\n",
+ " description=\"command id\", \n",
+ " options=sorted(app.commands.list_commands()),\n",
+ " value=\"console:create\",\n",
+ " rows=1\n",
+ ")\n",
+ "command_info = Output()\n",
+ "\n",
+ "def on_command_select(*_):\n",
+ " if not command_list.value:\n",
+ " return\n",
+ " def _on_described(result, errors):\n",
+ " command_info.clear_output()\n",
+ " with command_info:\n",
+ " display(errors if errors else \"no errors\")\n",
+ " if result:\n",
+ " display(JSON(result, expanded=True))\n",
+ " \n",
+ " app.commands.describe(command_list.value, {}, _on_described)\n",
+ "\n",
+ "command_list.observe(on_command_select)\n",
+ "on_command_select()\n",
+ "VBox([command_list, command_info], layout={\"display\": \"flex\"})"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Create a new console"
+ "### Create a new console"
]
},
{
@@ -77,7 +121,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Change the theme"
+ "### Change the theme"
]
},
{
@@ -93,7 +137,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Create a new terminal"
+ "### Create a new terminal"
]
},
{
@@ -109,7 +153,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Ready event\n",
+ "### Ready event\n",
"\n",
"Some functionalities might require the `JupyterFrontEnd` widget to be ready on the frontend first.\n",
"\n",
@@ -154,24 +198,31 @@
"source": [
"import asyncio\n",
"\n",
- "app = JupyterFrontEnd()\n",
- "\n",
- "out = Output()\n",
+ "async_output = Output()\n",
"\n",
"async def init():\n",
" await app.ready()\n",
" cmds = app.commands.list_commands()[:5]\n",
- " out.append_stdout(cmds)\n",
+ " with async_output:\n",
+ " display(cmds)\n",
+ " return cmds\n",
"\n",
"asyncio.create_task(init())\n",
- "out"
+ "async_output"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Add your own command\n",
+ "## Custom Commands"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Add your own command\n",
"\n",
"Let's create a nice plot with `bqlot` and generate some random data.\n",
"\n",
@@ -184,6 +235,7 @@
"metadata": {},
"outputs": [],
"source": [
+ "%pip install -q bqplot\n",
"import numpy as np\n",
"\n",
"from bqplot import LinearScale, Lines, Bars, Axis, Figure\n",
@@ -211,7 +263,8 @@
"yax1 = Axis(scale=ys1, orientation='vertical', tick_format='0.1f', label='y', grid_lines='solid')\n",
"yax2 = Axis(scale=ys2, orientation='vertical', side='right', tick_format='0.0%', label='y1', grid_lines='none')\n",
"\n",
- "Figure(marks=[bar, line], axes=[xax, yax1, yax2], animation_duration=1000)"
+ "fig = Figure(marks=[bar, line], axes=[xax, yax1, yax2], animation_duration=1000, layout={\"flex\": \"1\"})\n",
+ "fig"
]
},
{
@@ -227,9 +280,10 @@
"metadata": {},
"outputs": [],
"source": [
- "def update_data():\n",
- " line.y = np.cumsum(np.random.randn(20))\n",
- " bar.y = np.random.rand(20)"
+ "def update_data(d0: int=20, d1: int=20):\n",
+ " line.y = np.cumsum(np.random.randn(d0))\n",
+ " bar.y = np.random.rand(d1)\n",
+ " return {\"line\": line.y.tolist(), \"bar\": bar.y.tolist()}"
]
},
{
@@ -238,16 +292,21 @@
"metadata": {},
"outputs": [],
"source": [
- "update_data()"
+ "update_data();"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "This function will now be called when the JupyterLab command is executed.\n",
- "\n",
- "> Commands can also custom [icons](./icons.ipynb) in place of `icon_class`."
+ "This function will now be called when the JupyterLab command is executed: commands can also use custom [icons](./icons.ipynb) in place of `icon_class`."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Register it:"
]
},
{
@@ -279,9 +338,9 @@
"cell_type": "markdown",
"metadata": {},
"source": [
- "The slider should now be moving and taking random values.\n",
+ "The plot values should now move and then settle on random values.\n",
"\n",
- "Also the list of commands gets updated with the newly added command:"
+ "Note the list of commands gets updated with the newly added command:"
]
},
{
@@ -290,21 +349,21 @@
"metadata": {},
"outputs": [],
"source": [
- "assert 'random' in app.commands.list_commands()"
+ "assert 'update_data' in app.commands.list_commands()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "That's great, but the command doesn't visually show up in the palette yet. So let's add it!"
+ "That's great, but the command doesn't visually show up visually yet. So let's add it!"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Add the command to the palette"
+ "### Add the command to the palette"
]
},
{
@@ -331,21 +390,22 @@
"metadata": {},
"outputs": [],
"source": [
- "palette.add_item('update_data', 'Python Commands')"
+ "palette.add_item('update_data', 'Python Commands')\n",
+ "fig"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "Open the command palette on the left side and the command should show now be visible."
+ "Open the command palette with ctrl+shift+c and the command should show now be visible."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
- "## Remove a command\n",
+ "### Remove a command\n",
"\n",
"To remove a command that was previously added:"
]
@@ -358,6 +418,148 @@
"source": [
"app.commands.remove_command('update_data')"
]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Command Arguments\n",
+ "\n",
+ "Much of the computable behavior of commands is underspecified, making them somewhat challenging to work with programatically.\n",
+ "\n",
+ "However, many commands provide a normative description of their arguments as JSON schema, included in the [description](#Explore-commands). These can optionally be check in the client before trying to execute a command."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from typing import Any"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Run with validation\n",
+ "\n",
+ "For commands that have `describedBy`, running `execute` with `validate=True` will first check the command arguments, and return any errors _without_ running the command. \n",
+ "\n",
+ "Some commands also provide return data: while this is _supposed_ to be JSON, practically some commands return handles to things that can't be serialized to JSON, like pointers to DOM elements or recursive structures."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "validated_output = Output()\n",
+ "display(validated_output)\n",
+ "def on_validated_execute(result: Any, errors: list[Any]):\n",
+ " validated_output.clear_output()\n",
+ " with validated_output:\n",
+ " display(result)\n",
+ " display(errors)\n",
+ "app.commands.execute('console:create', {\"isPalette\": 1234}, handler=on_validated_execute, validate=True)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Custom command argument description\n",
+ "\n",
+ "[Custom commands](#Custom-commands) can also [describe](#Command-argument-description) their arguments as JSON schema."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "args_schema = {\n",
+ " \"type\": \"object\",\n",
+ " \"additionalProperties\": False,\n",
+ " \"properties\": {\n",
+ " \"d0\": {\"type\": \"number\", \"default\": 20},\n",
+ " \"d1\": {\"type\": \"number\", \"default\": 20},\n",
+ " }\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "(Re-)reregister it, but with `described_by`:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "app.commands.add_command('update_data', execute=update_data, label=\"Update Data\", icon_class=\"jp-PythonIcon\", described_by={\"args\": args_schema})"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Describe it:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "custom_described = Output()\n",
+ "def on_custom_describe(result: Any, error: list[Any]):\n",
+ " with custom_described:\n",
+ " display({\"application/json\": result}, raw=True)\n",
+ "app.commands.describe('update_data', {}, on_custom_describe)\n",
+ "custom_described"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "custom_validated_output = Output()\n",
+ "def on_validated_execute(result: Any, errors: list[Any]):\n",
+ " custom_validated_output.clear_output()\n",
+ " with custom_validated_output:\n",
+ " display(result)\n",
+ " display(errors)\n",
+ "display(fig), display(custom_validated_output)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "app.commands.execute('update_data', {\"d0\": False}, handler=on_validated_execute, validate=True)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "app.commands.execute('update_data', {\"d0\": 20, \"d1\": 5}, handler=on_validated_execute, validate=True)"
+ ]
}
],
"metadata": {
@@ -376,7 +578,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.0"
+ "version": "3.14.0"
},
"widgets": {
"application/vnd.jupyter.widget-state+json": {
diff --git a/examples/ipytree.ipynb b/examples/ipytree.ipynb
index d144571..0c75479 100644
--- a/examples/ipytree.ipynb
+++ b/examples/ipytree.ipynb
@@ -19,7 +19,7 @@
"metadata": {},
"outputs": [],
"source": [
- "%pip install -q ipylab"
+ "%pip install -q ipylab ipytree"
]
},
{
@@ -29,6 +29,7 @@
"outputs": [],
"source": [
"import os\n",
+ "import sys\n",
"\n",
"from fnmatch import fnmatch\n",
"from pathlib import PurePath"
@@ -73,7 +74,8 @@
"metadata": {},
"outputs": [],
"source": [
- "def collect_files(root_path='..'):\n",
+ "def collect_files(root_path=None):\n",
+ " root_path = root_path or (\".\" if \"pyodide\" in sys.modules else \"..\")\n",
" files = []\n",
" for dirpath, dirnames, filenames in os.walk(root_path, followlinks=True):\n",
" dirnames[:] = [d for d in dirnames if d not in EXCLUDES]\n",
diff --git a/ipylab/__init__.py b/ipylab/__init__.py
index dfbb3af..b5a8e42 100644
--- a/ipylab/__init__.py
+++ b/ipylab/__init__.py
@@ -1,15 +1,14 @@
-#!/usr/bin/env python
-# coding: utf-8
-
# Copyright (c) ipylab contributors.
# Distributed under the terms of the Modified BSD License.
from ._version import __version__
from .jupyterfrontend import JupyterFrontEnd
-from .widgets import Panel, SplitPanel, Icon
+from .widgets import Panel, SplitPanel
from .icon import Icon
+__all__ = ["__version__", "JupyterFrontEnd", "Panel", "SplitPanel", "Icon"]
+
def _jupyter_labextension_paths():
return [{"src": "labextension", "dest": "ipylab"}]
diff --git a/ipylab/_frontend.py b/ipylab/_frontend.py
index 2c03d5d..c5dab77 100644
--- a/ipylab/_frontend.py
+++ b/ipylab/_frontend.py
@@ -1,3 +1,6 @@
+# Copyright (c) ipylab contributors.
+# Distributed under the terms of the Modified BSD License.
+
from ._version import __version__
module_name = "ipylab"
diff --git a/ipylab/commands.py b/ipylab/commands.py
index ea9a721..775cb43 100644
--- a/ipylab/commands.py
+++ b/ipylab/commands.py
@@ -1,15 +1,22 @@
# Copyright (c) ipylab contributors.
# Distributed under the terms of the Modified BSD License.
+
+from __future__ import annotations
import json
+from uuid import uuid4
from collections import defaultdict
+from typing import Any, TYPE_CHECKING, Protocol
+
+from ipywidgets import Widget, register
+from traitlets import List, Unicode, Bool
-from ipywidgets import CallbackDispatcher, Widget, register
-from traitlets import List, Unicode
+if TYPE_CHECKING:
+ from collections.abc import Callable
from ._frontend import module_name, module_version
-def _noop():
+def _noop(*args: Any, **kwargs: Any):
pass
@@ -36,6 +43,10 @@ def add_item(self, command_id, category, *, args=None, rank=None):
)
+class ExecuteHandler(Protocol):
+ def __call__(self, result: Any, errors: list[Any]) -> None: ...
+
+
@register
class CommandRegistry(Widget):
_model_name = Unicode("CommandRegistryModel").tag(sync=True)
@@ -44,27 +55,101 @@ class CommandRegistry(Widget):
_command_list = List(Unicode, read_only=True).tag(sync=True)
_commands = List([], read_only=True).tag(sync=True)
+
_execute_callbacks = defaultdict(_noop)
+ _result_callbacks = defaultdict(_noop)
+
+ validate_execute_args = Bool(
+ default=False,
+ help="whether to validate args before execution",
+ ).tag(sync=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.on_msg(self._on_frontend_msg)
def _on_frontend_msg(self, _, content, buffers):
- if content.get("event", "") == "execute":
- command_id = content.get("id")
- args = json.loads(content.get("args"))
- self._execute_callbacks[command_id](**args)
+ event = content.get("event")
- def execute(self, command_id, args=None):
- args = args or {}
- self.send({"func": "execute", "payload": {"id": command_id, "args": args}})
+ if event == "execute":
+ command_id = content.get("id")
+ result_id = content.get("result_id")
+ args = content.get("args")
+ result = None
+ errors = []
+ try:
+ result = self._execute_callbacks[command_id](**args)
+ except Exception as err:
+ errors += [err]
+ payload = {
+ "id": command_id,
+ "result_id": result_id,
+ "result": result,
+ "errors": errors,
+ }
+ self.send({"func": "finishExecute", "payload": payload})
+
+ if event in {"executed", "described"}:
+ result_id = content.get("result_id")
+ result = content.get("result")
+ errors = content.get("errors", [])
+ callback = self._result_callbacks[result_id]
+ callback(result, errors)
+
+ def _make_result_handler(self, handler: ExecuteHandler) -> str:
+ result_id = f"{uuid4()}"
+
+ def _on_executed(result: Any, errors: list[Any]) -> None:
+ try:
+ self._result_callbacks.pop(result_id, _noop)
+ handler(result, errors)
+ except Exception as err:
+ self.log.error("handler error %s", err)
+
+ self._result_callbacks[result_id] = _on_executed
+ return result_id
+
+ def execute(
+ self,
+ command_id: str,
+ args: dict[str, Any] | None = None,
+ handler: ExecuteHandler | None = None,
+ *,
+ validate: bool | None = None,
+ ):
+ payload = {
+ "id": command_id,
+ "args": args or {},
+ "validate": (
+ validate if validate is not None else self.validate_execute_args
+ ),
+ "result_id": self._make_result_handler(handler) if handler else None,
+ }
+ self.send({"func": "execute", "payload": payload})
+
+ def describe(
+ self, command_id: str, args: dict[str, Any], handler: ExecuteHandler
+ ) -> None:
+ payload = {
+ "id": command_id,
+ "args": args or {},
+ "result_id": self._make_result_handler(handler),
+ }
+ self.send({"func": "describe", "payload": payload})
def list_commands(self):
return self._command_list
def add_command(
- self, command_id, execute, *, caption="", label="", icon_class="", icon=None
+ self,
+ command_id,
+ execute,
+ *,
+ caption="",
+ label="",
+ icon_class="",
+ icon=None,
+ described_by=None,
):
if command_id in self._command_list:
raise Exception(f"Command {command_id} is already registered")
@@ -79,6 +164,7 @@ def add_command(
"label": label,
"iconClass": icon_class,
"icon": f"IPY_MODEL_{icon.model_id}" if icon else None,
+ "describedBy": described_by,
},
}
)
diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py
index 6b5d182..00acede 100644
--- a/ipylab/jupyterfrontend.py
+++ b/ipylab/jupyterfrontend.py
@@ -1,6 +1,3 @@
-#!/usr/bin/env python
-# coding: utf-8
-
# Copyright (c) ipylab contributors.
# Distributed under the terms of the Modified BSD License.
diff --git a/ipylab/sessions.py b/ipylab/sessions.py
index 09e7daa..04e3f7a 100644
--- a/ipylab/sessions.py
+++ b/ipylab/sessions.py
@@ -1,6 +1,3 @@
-#!/usr/bin/env python
-# coding: utf-8
-
# Copyright (c) ipylab contributors.
# Distributed under the terms of the Modified BSD License.
diff --git a/package.json b/package.json
index 5a4610a..a756179 100644
--- a/package.json
+++ b/package.json
@@ -93,7 +93,9 @@
"@lumino/commands": "^2",
"@lumino/disposable": "^1.10.2 || ^2",
"@lumino/messaging": "^1.10.2 || ^2",
- "@lumino/widgets": "^2"
+ "@lumino/widgets": "^2",
+ "ajv": "^8.12.0",
+ "jquery": "^3.1.1"
},
"devDependencies": {
"@jupyterlab/builder": "^4.0.0",
@@ -133,6 +135,18 @@
"@jupyter-widgets/base": {
"bundled": false,
"singleton": true
+ },
+ "@jupyter-widgets/controls": {
+ "bundled": false,
+ "singleton": true
+ },
+ "ajv": {
+ "bundled": true,
+ "singleton": false
+ },
+ "jquery": {
+ "bundled": false,
+ "singleton": false
}
}
},
@@ -152,6 +166,13 @@
"@typescript-eslint"
],
"rules": {
+ "@typescript-eslint/consistent-type-imports": [
+ "error",
+ {
+ "prefer": "type-imports",
+ "fixStyle": "separate-type-imports"
+ }
+ ],
"@typescript-eslint/naming-convention": [
"error",
{
diff --git a/readthedocs.yml b/readthedocs.yml
index b0ecbb8..7559347 100644
--- a/readthedocs.yml
+++ b/readthedocs.yml
@@ -10,9 +10,6 @@ build:
- jlpm
- jlpm build
- python -m build --wheel
-python:
- install:
- - method: pip
- path: .
+ - python -m pip install dist/*.whl --no-deps
conda:
environment: docs/environment.yml
diff --git a/src/plugin.ts b/src/plugin.ts
index 431eb9f..aef6d2f 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -1,11 +1,11 @@
// Copyright (c) ipylab contributors
// Distributed under the terms of the Modified BSD License.
-import {
+import type {
JupyterFrontEndPlugin,
- JupyterFrontEnd,
- ILabShell
+ JupyterFrontEnd
} from '@jupyterlab/application';
+import { ILabShell } from '@jupyterlab/application';
import { ICommandPalette } from '@jupyterlab/apputils';
diff --git a/src/widgets/commands.ts b/src/widgets/commands.ts
index 169d8ec..e0a61a0 100644
--- a/src/widgets/commands.ts
+++ b/src/widgets/commands.ts
@@ -2,23 +2,24 @@
// Distributed under the terms of the Modified BSD License.
import { ObservableMap } from '@jupyterlab/observables';
-import { LabIcon } from '@jupyterlab/ui-components';
-import {
- ISerializers,
- unpack_models,
- WidgetModel
-} from '@jupyter-widgets/base';
+import type { LabIcon } from '@jupyterlab/ui-components';
+import type { ISerializers } from '@jupyter-widgets/base';
+import { unpack_models, WidgetModel } from '@jupyter-widgets/base';
import { ArrayExt } from '@lumino/algorithm';
-import { CommandRegistry } from '@lumino/commands';
+import type { CommandRegistry } from '@lumino/commands';
-import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
+import type { JSONObject, ReadonlyPartialJSONObject } from '@lumino/coreutils';
+import { PromiseDelegate } from '@lumino/coreutils';
+import { UUID } from '@lumino/coreutils';
-import { IDisposable } from '@lumino/disposable';
+import type { IDisposable } from '@lumino/disposable';
import { MODULE_NAME, MODULE_VERSION } from '../version';
+import type AjvType from 'ajv';
+
/**
* The model for a command registry.
*/
@@ -67,25 +68,33 @@ export class CommandRegistryModel extends WidgetModel {
*
* @param msg The message to handle.
*/
- private async _onMessage(msg: any): Promise {
+ private async _onMessage(msg: Private.TAnyRequest): Promise {
+ let result: Private.IResult | void = void 0;
+
switch (msg.func) {
case 'execute':
- this._execute(msg.payload);
+ result = await this._execute(msg.payload);
+ break;
+ case 'finishExecute':
+ await this._finishExecute(msg.payload);
break;
- case 'addCommand': {
+ case 'describe':
+ result = await this._describe(msg.payload);
+ break;
+ case 'addCommand':
await this._addCommand(msg.payload);
- // keep track of the commands
- const commands = this.get('_commands');
- this.set('_commands', commands.concat(msg.payload));
- this.save_changes();
break;
- }
case 'removeCommand':
this._removeCommand(msg.payload);
break;
default:
+ console.error(`unexpected function: ${(msg as any).func}`);
break;
}
+
+ if (result) {
+ this.send(result, {});
+ }
}
/**
@@ -98,18 +107,138 @@ export class CommandRegistryModel extends WidgetModel {
}
/**
- * Execute a command
- *
- * @param bundle The command bundle.
- * @param bundle.id
- * @param bundle.args
+ * Execute a command.
*/
- private _execute(bundle: {
- id: string;
- args: ReadonlyPartialJSONObject;
- }): void {
- const { id, args } = bundle;
- void this._commands.execute(id, args);
+ private async _execute(
+ options: Private.IExecuteOptions
+ ): Promise {
+ const { id, args, validate, result_id } = options;
+
+ const response: Private.IResult = {
+ event: 'executed',
+ result_id,
+ result: null,
+ errors: []
+ };
+
+ if (validate) {
+ try {
+ // expected errors will have a nice structure
+ const validationErrors = await this._validateArgs(options);
+ response.errors.push(...validationErrors);
+ } catch (err) {
+ // ... best effort for an unexpected error
+ response.errors.push(Private.maybeJson(err));
+ }
+ }
+
+ if (!response.errors.length) {
+ try {
+ response.result = await this._commands.execute(id, args);
+ } catch (err) {
+ // ... a real execution error _is_ an error (though unlikely)
+ response.errors.push(Private.maybeJson(err));
+ }
+ }
+
+ if (!result_id) {
+ return;
+ }
+
+ try {
+ // results _should_ be well-formed JSON...
+ response.result = Private.maybeJson(response.result);
+ } catch (err) {
+ // ... but in practice often aren't, and may have hot widget/DOM handles
+ // and this isn't really an error
+ response.result = `${response.result}`;
+ }
+
+ return response;
+ }
+
+ /**
+ * Finish results from a custom kernel-side command.
+ */
+ private async _finishExecute(options: Private.IExecuteResultsOptions) {
+ const { result_id, result, errors } = options;
+ const delegate = this._executeResults.get(result_id);
+ this._executeResults.delete(result_id);
+ if (errors.length) {
+ delegate.reject(errors);
+ } else {
+ delegate.resolve(result);
+ }
+ }
+
+ /**
+ * Get command information.
+ */
+ private async _describe(
+ options: Private.IDescribeOptions
+ ): Promise {
+ const { id, result_id, args } = options;
+ const message: Private.IResult = {
+ result_id,
+ event: 'described',
+ result: { id },
+ errors: []
+ };
+
+ const promises: Promise[] = [];
+
+ for (const key of Private.DESCRIBE_KEYS) {
+ promises.push(
+ this._reduceInfo(message.result, message.errors, key, id, args)
+ );
+ }
+
+ await Promise.all(promises);
+
+ return message;
+ }
+
+ private async _reduceInfo(
+ result: Record,
+ errors: any[],
+ key: Private.TDecribeKey,
+ id: string,
+ args: ReadonlyPartialJSONObject
+ ): Promise {
+ const infoMethods: Private.IDescribeMethods = {
+ label: this._commands.label,
+ caption: this._commands.caption,
+ described_by: this._commands.describedBy,
+ icon_class: this._commands.iconClass
+ };
+ try {
+ const r = await infoMethods[key].bind(this._commands)(id, args);
+ result[key] = Private.maybeJson(r);
+ } catch (err) {
+ errors.push({ [key]: `${err}` });
+ }
+ }
+
+ /**
+ * Validate command args (if constrained)
+ */
+ private async _validateArgs(
+ options: Private.IExecuteOptions
+ ): Promise {
+ const { id, args } = options;
+ const describedBy = await this._commands.describedBy(id, args);
+ const errors: any[] = [];
+ if (describedBy.args) {
+ try {
+ const ajv = await Private.ajv();
+ if (!ajv.validate(describedBy.args, options.args)) {
+ errors.push(Private.maybeJson(ajv.errors));
+ }
+ } catch (err) {
+ errors.push(Private.maybeJson(err));
+ }
+ }
+ return errors;
}
/**
@@ -118,9 +247,9 @@ export class CommandRegistryModel extends WidgetModel {
* @param options The command options.
*/
private async _addCommand(
- options: CommandRegistry.ICommandOptions & { id: string }
+ options: Private.IAddCommandOptions
): Promise {
- const { id, caption, label, iconClass, icon } = options;
+ const { id, caption, label, iconClass, icon, describedBy } = options;
if (this._commands.hasCommand(id)) {
Private.customCommands.get(id).dispose();
}
@@ -133,33 +262,54 @@ export class CommandRegistryModel extends WidgetModel {
const commandEnabled = (command: IDisposable): boolean => {
return !command.isDisposed && !!this.comm && this.comm_live;
};
+
const command = this._commands.addCommand(id, {
caption,
label,
iconClass,
icon: labIcon,
- execute: args => {
+ execute: async (args): Promise => {
if (!this.comm_live) {
command.dispose();
- return;
+ throw Error(`${id} was disposed`);
}
- this.send({ event: 'execute', id, args: JSON.stringify(args) }, {});
+
+ const result_id = UUID.uuid4();
+ const delegate = new PromiseDelegate();
+
+ this._executeResults.set(result_id, delegate);
+
+ this.send(
+ {
+ event: 'execute',
+ id,
+ result_id,
+ args: Private.maybeJson(args)
+ },
+ {}
+ );
+ return delegate.promise;
},
isEnabled: () => commandEnabled(command),
- isVisible: () => commandEnabled(command)
+ isVisible: () => commandEnabled(command),
+ describedBy
});
Private.customCommands.set(id, command);
this._sendCommandList();
+
+ // keep track of the commands
+ const commands = this.get('_commands');
+ this.set('_commands', commands.concat(options));
+ this.save_changes();
}
/**
* Remove a command from the command registry.
*
- * @param bundle The command bundle.
- * @param bundle.id
+ * @param options The options for removing the command.
*/
- private _removeCommand(bundle: { id: string }): void {
- const { id } = bundle;
+ private _removeCommand(options: Private.IRemoveCommandOptions): void {
+ const { id } = options;
if (Private.customCommands.has(id)) {
Private.customCommands.get(id).dispose();
}
@@ -182,6 +332,7 @@ export class CommandRegistryModel extends WidgetModel {
static view_module_version = MODULE_VERSION;
private _commands: CommandRegistry;
+ private _executeResults: Map> = new Map();
static commands: CommandRegistry;
}
@@ -191,4 +342,123 @@ export class CommandRegistryModel extends WidgetModel {
*/
namespace Private {
export const customCommands = new ObservableMap();
+ let _ajv: AjvType | null = null;
+
+ export const DESCRIBE_KEYS = [
+ 'label',
+ 'caption',
+ 'icon_class',
+ 'described_by'
+ ];
+ export type TDecribeKey = (typeof DESCRIBE_KEYS)[number];
+
+ export interface IDescribeMethods {
+ [key: TDecribeKey]: (id: string, args: ReadonlyPartialJSONObject) => any;
+ }
+
+ export async function ajv(): Promise {
+ if (!_ajv) {
+ const Ajv = (await import('ajv')).default;
+ _ajv = new Ajv({ validateFormats: true });
+ }
+ return _ajv;
+ }
+
+ export function maybeJson(data: any): any {
+ try {
+ return JSON.parse(JSON.stringify(data, null, 2));
+ } catch (err) {
+ return `${data}`;
+ }
+ }
+
+ // messages
+ export type TAnyRequest =
+ | IExecute
+ | IExecuteResults
+ | IDescribe
+ | IAddCommand
+ | IRemoveCommand;
+
+ export interface IRequest {
+ func:
+ | 'addCommand'
+ | 'describe'
+ | 'finishExecute'
+ | 'removeCommand'
+ | 'execute';
+ payload: any;
+ }
+
+ export interface IAddCommand extends IRequest {
+ func: 'addCommand';
+ payload: IAddCommandOptions;
+ }
+
+ export interface IRemoveCommand extends IRequest {
+ func: 'removeCommand';
+ payload: IRemoveCommandOptions;
+ }
+
+ export interface IDescribe extends IRequest {
+ func: 'describe';
+ payload: IDescribeOptions;
+ }
+
+ export interface IExecute extends IRequest {
+ func: 'execute';
+ payload: IExecuteOptions;
+ }
+
+ export interface IExecuteResults extends IRequest {
+ func: 'finishExecute';
+ payload: IExecuteResultsOptions;
+ }
+
+ // options
+ export interface IWithCommandId {
+ /** command id */
+ id: string;
+ }
+
+ export interface ICommonOptions extends IWithCommandId {
+ /** optional command args */
+ args?: ReadonlyPartialJSONObject;
+ /** an optional identifier for an expected result */
+ result_id?: string | null;
+ }
+
+ export interface IExecuteOptions extends ICommonOptions {
+ /** whether to pre-validate args before execution (if defined) */
+ validate?: boolean;
+ }
+
+ export interface IExecuteResultsOptions extends ICommonOptions {
+ /** optional identifier for an expected result */
+ result_id: string;
+ /** result of the kernel-side execution */
+ result: ReadonlyPartialJSONObject;
+ /** errors encountered during kernel execution */
+ errors: any[];
+ }
+
+ export interface IDescribeOptions extends ICommonOptions {
+ result_id: string;
+ }
+
+ export interface IAddCommandOptions
+ extends IWithCommandId,
+ CommandRegistry.ICommandOptions {}
+
+ export interface IRemoveCommandOptions extends IWithCommandId {}
+
+ export interface IResult extends JSONObject {
+ event: 'executed' | 'described';
+ /** the execution request */
+ result_id: string;
+ /** a result, if successful */
+ result: any;
+ /** an error string, if failed */
+ errors: any[];
+ }
}
diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts
index 15f9665..0388c04 100644
--- a/src/widgets/frontend.ts
+++ b/src/widgets/frontend.ts
@@ -1,13 +1,10 @@
// Copyright (c) ipylab contributors
// Distributed under the terms of the Modified BSD License.
-import { JupyterFrontEnd } from '@jupyterlab/application';
+import type { JupyterFrontEnd } from '@jupyterlab/application';
-import {
- DOMWidgetModel,
- ISerializers,
- WidgetModel
-} from '@jupyter-widgets/base';
+import type { ISerializers } from '@jupyter-widgets/base';
+import { DOMWidgetModel, WidgetModel } from '@jupyter-widgets/base';
import { MODULE_NAME, MODULE_VERSION } from '../version';
diff --git a/src/widgets/menu.ts b/src/widgets/menu.ts
index 05f718b..7433470 100644
--- a/src/widgets/menu.ts
+++ b/src/widgets/menu.ts
@@ -1,27 +1,24 @@
// Copyright (c) ipylab contributors
// Distributed under the terms of the Modified BSD License.
-import { JupyterFrontEnd } from '@jupyterlab/application';
-import { IMainMenu, MainMenu } from '@jupyterlab/mainmenu';
+import type { JupyterFrontEnd } from '@jupyterlab/application';
+import type { IMainMenu, MainMenu } from '@jupyterlab/mainmenu';
import { Menu } from '@lumino/widgets';
+import type { ISerializers } from '@jupyter-widgets/base';
import {
- ISerializers,
WidgetModel
//unpack_models
} from '@jupyter-widgets/base';
import { MODULE_NAME, MODULE_VERSION } from '../version';
-import { CommandRegistry } from '@lumino/commands';
-import { Cell } from '@jupyterlab/cells';
-import {
- INotebookTracker,
- NotebookActions,
- NotebookPanel
-} from '@jupyterlab/notebook';
-import { IDisposable } from '@lumino/disposable';
+import type { CommandRegistry } from '@lumino/commands';
+import type { Cell } from '@jupyterlab/cells';
+import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
+import { NotebookActions } from '@jupyterlab/notebook';
+import type { IDisposable } from '@lumino/disposable';
import { ObservableMap } from '@jupyterlab/observables';
-import { ReadonlyJSONObject } from '@lumino/coreutils';
+import type { ReadonlyJSONObject } from '@lumino/coreutils';
namespace CommandIDs {
export const snippet = 'custom-menu:snippet';
diff --git a/src/widgets/palette.ts b/src/widgets/palette.ts
index 27498db..5919e36 100644
--- a/src/widgets/palette.ts
+++ b/src/widgets/palette.ts
@@ -1,17 +1,14 @@
// Copyright (c) ipylab contributors
// Distributed under the terms of the Modified BSD License.
-import { ICommandPalette, IPaletteItem } from '@jupyterlab/apputils';
+import type { ICommandPalette, IPaletteItem } from '@jupyterlab/apputils';
import { ObservableMap } from '@jupyterlab/observables';
-import {
- DOMWidgetModel,
- ISerializers,
- WidgetModel
-} from '@jupyter-widgets/base';
+import type { ISerializers } from '@jupyter-widgets/base';
+import { DOMWidgetModel, WidgetModel } from '@jupyter-widgets/base';
-import { IDisposable } from '@lumino/disposable';
+import type { IDisposable } from '@lumino/disposable';
import { MODULE_NAME, MODULE_VERSION } from '../version';
diff --git a/src/widgets/sessions.ts b/src/widgets/sessions.ts
index 7daed11..a44b22e 100644
--- a/src/widgets/sessions.ts
+++ b/src/widgets/sessions.ts
@@ -3,11 +3,12 @@
// SessionManager exposes `JupyterLab.serviceManager.sessions` to user python kernel
-import { ISerializers, WidgetModel } from '@jupyter-widgets/base';
+import type { ISerializers } from '@jupyter-widgets/base';
+import { WidgetModel } from '@jupyter-widgets/base';
import { toArray } from '@lumino/algorithm';
import { MODULE_NAME, MODULE_VERSION } from '../version';
-import { Session } from '@jupyterlab/services';
-import { ILabShell, JupyterFrontEnd } from '@jupyterlab/application';
+import type { Session } from '@jupyterlab/services';
+import type { ILabShell, JupyterFrontEnd } from '@jupyterlab/application';
/**
* The model for a Session Manager
diff --git a/src/widgets/shell.ts b/src/widgets/shell.ts
index af21e02..f9f4b45 100644
--- a/src/widgets/shell.ts
+++ b/src/widgets/shell.ts
@@ -1,19 +1,17 @@
// Copyright (c) ipylab contributors
// Distributed under the terms of the Modified BSD License.
-import { JupyterFrontEnd, ILabShell } from '@jupyterlab/application';
+import type { JupyterFrontEnd, ILabShell } from '@jupyterlab/application';
import { DOMUtils } from '@jupyterlab/apputils';
-import {
- ISerializers,
- WidgetModel,
- unpack_models
-} from '@jupyter-widgets/base';
+import type { ISerializers } from '@jupyter-widgets/base';
+import { WidgetModel, unpack_models } from '@jupyter-widgets/base';
import { ArrayExt } from '@lumino/algorithm';
-import { Message, MessageLoop } from '@lumino/messaging';
+import type { Message } from '@lumino/messaging';
+import { MessageLoop } from '@lumino/messaging';
import { MODULE_NAME, MODULE_VERSION } from '../version';
diff --git a/src/widgets/split_panel.ts b/src/widgets/split_panel.ts
index b2dd317..e925361 100644
--- a/src/widgets/split_panel.ts
+++ b/src/widgets/split_panel.ts
@@ -1,11 +1,11 @@
// Copyright (c) ipylab contributors
// Distributed under the terms of the Modified BSD License.
-import { JupyterLuminoWidget, DOMWidgetView } from '@jupyter-widgets/base';
+import type { JupyterLuminoWidget, DOMWidgetView } from '@jupyter-widgets/base';
import { VBoxView } from '@jupyter-widgets/controls';
-import { Message } from '@lumino/messaging';
+import type { Message } from '@lumino/messaging';
import { SplitPanel } from '@lumino/widgets';
diff --git a/src/widgets/toolbar.ts b/src/widgets/toolbar.ts
index 5ccb293..a282974 100644
--- a/src/widgets/toolbar.ts
+++ b/src/widgets/toolbar.ts
@@ -1,18 +1,15 @@
// Copyright (c) ipylab contributors
// Distributed under the terms of the Modified BSD License.
-import {
- ISerializers,
- unpack_models,
- WidgetModel
-} from '@jupyter-widgets/base';
-import { CommandRegistry } from '@lumino/commands';
+import type { ISerializers } from '@jupyter-widgets/base';
+import { unpack_models, WidgetModel } from '@jupyter-widgets/base';
+import type { CommandRegistry } from '@lumino/commands';
import { MODULE_NAME, MODULE_VERSION } from '../version';
-import { INotebookTracker } from '@jupyterlab/notebook';
+import type { INotebookTracker } from '@jupyterlab/notebook';
import { ToolbarButton } from '@jupyterlab/apputils';
-import { Widget } from '@lumino/widgets';
+import type { Widget } from '@lumino/widgets';
import { LabIcon } from '@jupyterlab/ui-components';
-import { Toolbar } from '@jupyterlab/ui-components';
+import type { Toolbar } from '@jupyterlab/ui-components';
import { ObservableMap } from '@jupyterlab/observables';
interface IToolbarButtonOptions {
diff --git a/yarn.lock b/yarn.lock
index 8ccdbae..d3811c0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4050,6 +4050,7 @@ __metadata:
"@types/react": ^18.0.26
"@typescript-eslint/eslint-plugin": ^6.1.0
"@typescript-eslint/parser": ^6.1.0
+ ajv: ^8.12.0
css-loader: ^6.7.1
eslint: ^8.36.0
eslint-config-prettier: ^8.8.0
@@ -4059,6 +4060,7 @@ __metadata:
expect.js: ^0.3.1
fs-extra: ^10.1.0
husky: ^8.0.1
+ jquery: ^3.1.1
lint-staged: ^13.0.3
mkdirp: ^1.0.4
npm-run-all: ^4.1.5