From a49b93c9f1421e4e893d39a4c511b26ddad72a9f Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Sat, 22 Nov 2025 08:20:51 -0600 Subject: [PATCH 01/14] fix assertion in command notebook --- examples/commands.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/commands.ipynb b/examples/commands.ipynb index 9ac8668c..6c4cd47a 100644 --- a/examples/commands.ipynb +++ b/examples/commands.ipynb @@ -290,7 +290,7 @@ "metadata": {}, "outputs": [], "source": [ - "assert 'random' in app.commands.list_commands()" + "assert 'update_data' in app.commands.list_commands()" ] }, { @@ -376,7 +376,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.0" + "version": "3.14.0" }, "widgets": { "application/vnd.jupyter.widget-state+json": { From 79a346032189ba85f5fa7b48eeacff482cd17872 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Sun, 23 Nov 2025 11:59:42 -0600 Subject: [PATCH 02/14] add described_py --- examples/commands.ipynb | 239 +++++++++++++++++++++++++++++++++++++--- ipylab/commands.py | 85 ++++++++++++-- src/widgets/commands.ts | 218 ++++++++++++++++++++++++++++++++---- 3 files changed, 495 insertions(+), 47 deletions(-) diff --git a/examples/commands.ipynb b/examples/commands.ipynb index 6c4cd47a..504664da 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, HBox\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,40 @@ "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, error):\n", + " command_info.clear_output()\n", + " with command_info:\n", + " if result:\n", + " display(JSON(result, expanded=True))\n", + " if error:\n", + " command_info.append_stderr(error)\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 +122,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Change the theme" + "### Change the theme" ] }, { @@ -93,7 +138,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Create a new terminal" + "### Create a new terminal" ] }, { @@ -109,7 +154,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", @@ -171,7 +216,14 @@ "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", @@ -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,9 @@ "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)" ] }, { @@ -245,9 +298,14 @@ "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:" ] }, { @@ -304,7 +362,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Add the command to the palette" + "### Add the command to the palette" ] }, { @@ -345,7 +403,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Remove a command\n", + "### Remove a command\n", "\n", "To remove a command that was previously added:" ] @@ -358,6 +416,151 @@ "source": [ "app.commands.remove_command('update_data')" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can also describe the shape of the function as JSON schema:" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Command Arguments\n", + "\n", + "Many commands provide a description of their arguments as JSON schema. Custom commands can also provide a [description](#Custom-command-argument-description)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Run with validation\n", + "\n", + "Similarly, for commands that have `describedBy` running `execute` with `validate=True` will first check the command arguments, and return an error _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()\n", + "def on_execute(result: str, error: str):\n", + " with validated:\n", + " validated.append_stdout(result)\n", + " validated.append_stderr(error)\n", + "app.commands.execute('console:create', {\"isPalette\": 1234}, handler=on_execute, validate=True)\n", + "validated" + ] + }, + { + "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: dict[str, dict[str, ...]], error):\n", + " with custom_described:\n", + " display({\"application/json\": result[\"args\"]}, 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()\n", + "def on_custom_execute(result: str | None, error: str | None):\n", + " custom_validated.clear_output()\n", + " with validated:\n", + " custom_validated.append_stdout(result)\n", + " custom_validated.append_stderr(error)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "HBox([fig, custom_validated])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "app.commands.execute('update_data', {\"d0\": False}, handler=on_custom_execute, validate=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "app.commands.execute('update_data', {\"d0\": 20, \"d1\": 5}, handler=on_custom_execute, validate=True)" + ] } ], "metadata": { diff --git a/ipylab/commands.py b/ipylab/commands.py index ea9a7211..69665b74 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,9 @@ def add_item(self, command_id, category, *, args=None, rank=None): ) +class ExecuteHandler(Protocol): + def __call__(self, result: str | None, error: str | None) -> None: ... + @register class CommandRegistry(Widget): _model_name = Unicode("CommandRegistryModel").tag(sync=True) @@ -44,27 +54,85 @@ 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": + event = content.get("event") + + if event == "execute": command_id = content.get("id") args = json.loads(content.get("args")) self._execute_callbacks[command_id](**args) - def execute(self, command_id, args=None): - args = args or {} - self.send({"func": "execute", "payload": {"id": command_id, "args": args}}) + if event in {"executed", "described"}: + result_id = content.get("result_id") + result = content.get("result") + error = content.get("error") + callback = self._result_callbacks[result_id] + callback(result, error) + + def _make_result_handler(self, handler: ExecuteHandler) -> str: + result_id = f"{uuid4()}" + + def _on_executed(result: str | None, error: str | None) -> None: + try: + self._result_callbacks.pop(result_id, _noop) + handler(result=result, error=error) + 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 +147,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/src/widgets/commands.ts b/src/widgets/commands.ts index 169d8ec2..b6a0fdfe 100644 --- a/src/widgets/commands.ts +++ b/src/widgets/commands.ts @@ -11,14 +11,16 @@ import { 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 { 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,10 +69,13 @@ export class CommandRegistryModel extends WidgetModel { * * @param msg The message to handle. */ - private async _onMessage(msg: any): Promise { + private async _onMessage(msg: Private.TAnyMessage): Promise { switch (msg.func) { case 'execute': - this._execute(msg.payload); + await this._execute(msg.payload); + break; + case 'describe': + await this._describe(msg.payload); break; case 'addCommand': { await this._addCommand(msg.payload); @@ -100,16 +105,102 @@ export class CommandRegistryModel extends WidgetModel { /** * Execute a command * - * @param bundle The command bundle. - * @param bundle.id - * @param bundle.args + * @param options The execute options. */ - 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 message: Private.IResult = { + event: 'executed', + result_id, + result: null, + errors: [] + }; + + try { + validate && (await this._validateArgs(options)); + message.result = await this._commands.execute(id, args); + } catch (err: any) { + message.errors.push(`${err}`); + } + + if (!result_id) { + return; + } + + try { + // results _should_ be well-formed JSON... + message.result = JSON.parse(JSON.stringify(message.result)); + } catch (err) { + // ... but in practice often aren't, and may have hot widget/DOM handles + message.result = `${message.result}`; + } + + this.send(message, {}); + } + /** + * Get command information. + * + * @param options The execute options. + */ + 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); + + this.send(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] = JSON.parse(JSON.stringify(r)); + } catch (err) { + errors.push({ [key]: `${err}` }); + } + } + + /** + * Validate command args (if constrained) + * + * @param options The validation options. + */ + private async _validateArgs(options: Private.IExecuteOptions): Promise { + const { id, args } = options; + const describedBy = await this._commands.describedBy(id, args); + if (!describedBy.args) { + return; + } + const ajv = await Private.ajv(); + if (!ajv.validate(describedBy.args, options.args)) { + throw new Error(JSON.stringify(ajv.errors, null, 2)); + } } /** @@ -118,9 +209,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,6 +224,7 @@ 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, @@ -146,7 +238,8 @@ export class CommandRegistryModel extends WidgetModel { this.send({ event: 'execute', id, args: JSON.stringify(args) }, {}); }, isEnabled: () => commandEnabled(command), - isVisible: () => commandEnabled(command) + isVisible: () => commandEnabled(command), + describedBy }); Private.customCommands.set(id, command); this._sendCommandList(); @@ -155,11 +248,10 @@ export class CommandRegistryModel extends WidgetModel { /** * 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(); } @@ -191,4 +283,88 @@ 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 type TAnyMessage = IExecute | IDescribe | IAddCommand | IRemoveCommand; + + export interface IMessage { + func: string; + payload: any; + } + + export interface IAddCommand extends IMessage { + func: 'addCommand'; + payload: IAddCommandOptions; + } + + export interface IWithCommandId { + /** command id */ + id: string; + } + + export interface IDescribe extends IWithCommandId { + func: 'describe'; + payload: IDescribeOptions; + } + + export interface IExecute extends IMessage { + func: 'execute'; + payload: IExecuteOptions; + } + + 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 IDescribeOptions extends ICommonOptions { + result_id: string; + } + + export interface IAddCommandOptions + extends IWithCommandId, + CommandRegistry.ICommandOptions {} + + export interface IRemoveCommand extends IMessage { + func: 'removeCommand'; + payload: IRemoveCommandOptions; + } + + 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[]; + } } From 5ec7a83788c36f35ea63d31769a50c793b18edfd Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Sun, 23 Nov 2025 20:35:05 -0600 Subject: [PATCH 03/14] more command results, apply type import linter --- .gitignore | 3 + docs/environment.yml | 38 +++--- docs/jupyter_lite_config.json | 3 +- examples/commands.ipynb | 95 ++++++++------- ipylab/commands.py | 28 +++-- package.json | 23 +++- src/plugin.ts | 6 +- src/widgets/commands.ts | 212 ++++++++++++++++++++++++---------- src/widgets/frontend.ts | 9 +- src/widgets/menu.ts | 21 ++-- src/widgets/palette.ts | 11 +- src/widgets/sessions.ts | 7 +- src/widgets/shell.ts | 12 +- src/widgets/split_panel.ts | 4 +- src/widgets/toolbar.ts | 15 +-- yarn.lock | 2 + 16 files changed, 308 insertions(+), 181 deletions(-) diff --git a/.gitignore b/.gitignore index f11690b7..16c47ad1 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 7f1bd009..25449e8a 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -1,18 +1,26 @@ name: ipylab-lite channels: -- conda-forge + - conda-forge/label/jupyterlite_pyodide_kernel_rc + - conda-forge/label/jupyterlite_core_rc + - conda-forge 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.11.* + - nodejs 24.* + # run + - ipywidgets >=8.0,<9 + # build + - mamba + - pip + - python-build + # docs + - pydata-sphinx-theme + - myst-parser + # demo + - jupyterlab >=4,<5 + - jupyterlite-core >=0.7.0rc0,<0.8 + - jupyterlite-pyodide-kernel >=0.7.0rc0,<0.8 + - jupyterlite-sphinx >=0.20.2,<0.21.0 + # install dev + - pip: + - .. diff --git a/docs/jupyter_lite_config.json b/docs/jupyter_lite_config.json index 4b8e7129..148e682a 100644 --- a/docs/jupyter_lite_config.json +++ b/docs/jupyter_lite_config.json @@ -2,7 +2,8 @@ "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/py3/i/ipywidgets/ipywidgets-8.1.0-py3-none-any.whl", + "https://files.pythonhosted.org/packages/73/03/6b5370fc626e6f480c4a0b4cb25b3459d390745010618b21b4b573423a53/bqplot-0.12.45-py2.py3-none-any.whl" ] } } diff --git a/examples/commands.ipynb b/examples/commands.ipynb index 504664da..dff154ce 100644 --- a/examples/commands.ipynb +++ b/examples/commands.ipynb @@ -25,7 +25,7 @@ "outputs": [], "source": [ "from ipylab import JupyterFrontEnd\n", - "from ipywidgets import Output, HBox\n", + "from ipywidgets import Output\n", "\n", "app = JupyterFrontEnd()" ] @@ -82,13 +82,12 @@ "def on_command_select(*_):\n", " if not command_list.value:\n", " return\n", - " def _on_described(result, error):\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", - " if error:\n", - " command_info.append_stderr(error)\n", " \n", " app.commands.describe(command_list.value, {}, _on_described)\n", "\n", @@ -199,17 +198,17 @@ "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" ] }, { @@ -236,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", @@ -282,7 +282,8 @@ "source": [ "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)" + " bar.y = np.random.rand(d1)\n", + " return {\"line\": line.y.tolist(), \"bar\": bar.y.tolist()}" ] }, { @@ -291,7 +292,7 @@ "metadata": {}, "outputs": [], "source": [ - "update_data()" + "update_data();" ] }, { @@ -337,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:" ] }, { @@ -355,7 +356,7 @@ "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!" ] }, { @@ -389,14 +390,15 @@ "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." ] }, { @@ -421,16 +423,20 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We can also describe the shape of the function as JSON schema:" + "## 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": "markdown", + "cell_type": "code", + "execution_count": null, "metadata": {}, + "outputs": [], "source": [ - "## Command Arguments\n", - "\n", - "Many commands provide a description of their arguments as JSON schema. Custom commands can also provide a [description](#Custom-command-argument-description)." + "from typing import Any" ] }, { @@ -439,7 +445,7 @@ "source": [ "### Run with validation\n", "\n", - "Similarly, for commands that have `describedBy` running `execute` with `validate=True` will first check the command arguments, and return an error _without_ running the command. \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." ] @@ -450,13 +456,14 @@ "metadata": {}, "outputs": [], "source": [ - "validated = Output()\n", - "def on_execute(result: str, error: str):\n", - " with validated:\n", - " validated.append_stdout(result)\n", - " validated.append_stderr(error)\n", - "app.commands.execute('console:create', {\"isPalette\": 1234}, handler=on_execute, validate=True)\n", - "validated" + "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)" ] }, { @@ -514,9 +521,9 @@ "outputs": [], "source": [ "custom_described = Output()\n", - "def on_custom_describe(result: dict[str, dict[str, ...]], error):\n", + "def on_custom_describe(result: Any, error: list[Any]):\n", " with custom_described:\n", - " display({\"application/json\": result[\"args\"]}, raw=True)\n", + " display({\"application/json\": result}, raw=True)\n", "app.commands.describe('update_data', {}, on_custom_describe)\n", "custom_described" ] @@ -527,21 +534,13 @@ "metadata": {}, "outputs": [], "source": [ - "custom_validated = Output()\n", - "def on_custom_execute(result: str | None, error: str | None):\n", - " custom_validated.clear_output()\n", - " with validated:\n", - " custom_validated.append_stdout(result)\n", - " custom_validated.append_stderr(error)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "HBox([fig, custom_validated])" + "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)" ] }, { @@ -550,7 +549,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute('update_data', {\"d0\": False}, handler=on_custom_execute, validate=True)" + "app.commands.execute('update_data', {\"d0\": False}, handler=on_validated_execute, validate=True)" ] }, { @@ -559,7 +558,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute('update_data', {\"d0\": 20, \"d1\": 5}, handler=on_custom_execute, validate=True)" + "app.commands.execute('update_data', {\"d0\": 20, \"d1\": 5}, handler=on_validated_execute, validate=True)" ] } ], diff --git a/ipylab/commands.py b/ipylab/commands.py index 69665b74..27c363a2 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -44,7 +44,7 @@ def add_item(self, command_id, category, *, args=None, rank=None): class ExecuteHandler(Protocol): - def __call__(self, result: str | None, error: str | None) -> None: ... + def __call__(self, result: Any, errors: list[Any]) -> None: ... @register class CommandRegistry(Widget): @@ -72,27 +72,39 @@ def _on_frontend_msg(self, _, content, buffers): if event == "execute": command_id = content.get("id") - args = json.loads(content.get("args")) - self._execute_callbacks[command_id](**args) + 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") - error = content.get("error") + errors = content.get("errors", []) callback = self._result_callbacks[result_id] - callback(result, error) + callback(result, errors) def _make_result_handler(self, handler: ExecuteHandler) -> str: result_id = f"{uuid4()}" - def _on_executed(result: str | None, error: str | None) -> None: + def _on_executed(result: Any, errors: list[Any]) -> None: try: self._result_callbacks.pop(result_id, _noop) - handler(result=result, error=error) + handler(result, errors) except Exception as err: self.log.error("handler error %s", err) - self._result_callbacks[result_id] = _on_executed return result_id diff --git a/package.json b/package.json index 5a4610ae..a756179a 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/src/plugin.ts b/src/plugin.ts index 431eb9fa..aef6d2f6 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 b6a0fdfe..e0a61a0f 100644 --- a/src/widgets/commands.ts +++ b/src/widgets/commands.ts @@ -2,18 +2,17 @@ // 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 type { CommandRegistry } from '@lumino/commands'; import type { JSONObject, ReadonlyPartialJSONObject } from '@lumino/coreutils'; +import { PromiseDelegate } from '@lumino/coreutils'; +import { UUID } from '@lumino/coreutils'; import type { IDisposable } from '@lumino/disposable'; @@ -69,28 +68,33 @@ export class CommandRegistryModel extends WidgetModel { * * @param msg The message to handle. */ - private async _onMessage(msg: Private.TAnyMessage): Promise { + private async _onMessage(msg: Private.TAnyRequest): Promise { + let result: Private.IResult | void = void 0; + switch (msg.func) { case 'execute': - await this._execute(msg.payload); + result = await this._execute(msg.payload); + break; + case 'finishExecute': + await this._finishExecute(msg.payload); break; case 'describe': - await this._describe(msg.payload); + result = await this._describe(msg.payload); break; - case 'addCommand': { + 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, {}); + } } /** @@ -103,25 +107,38 @@ export class CommandRegistryModel extends WidgetModel { } /** - * Execute a command - * - * @param options The execute options. + * Execute a command. */ - private async _execute(options: Private.IExecuteOptions): Promise { + private async _execute( + options: Private.IExecuteOptions + ): Promise { const { id, args, validate, result_id } = options; - const message: Private.IResult = { + const response: Private.IResult = { event: 'executed', result_id, result: null, errors: [] }; - try { - validate && (await this._validateArgs(options)); - message.result = await this._commands.execute(id, args); - } catch (err: any) { - message.errors.push(`${err}`); + 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) { @@ -130,20 +147,36 @@ export class CommandRegistryModel extends WidgetModel { try { // results _should_ be well-formed JSON... - message.result = JSON.parse(JSON.stringify(message.result)); + response.result = Private.maybeJson(response.result); } catch (err) { // ... but in practice often aren't, and may have hot widget/DOM handles - message.result = `${message.result}`; + // and this isn't really an error + response.result = `${response.result}`; } - this.send(message, {}); + 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. - * - * @param options The execute options. */ - private async _describe(options: Private.IDescribeOptions): Promise { + private async _describe( + options: Private.IDescribeOptions + ): Promise { const { id, result_id, args } = options; const message: Private.IResult = { result_id, @@ -162,7 +195,7 @@ export class CommandRegistryModel extends WidgetModel { await Promise.all(promises); - this.send(message, {}); + return message; } private async _reduceInfo( @@ -180,7 +213,7 @@ export class CommandRegistryModel extends WidgetModel { }; try { const r = await infoMethods[key].bind(this._commands)(id, args); - result[key] = JSON.parse(JSON.stringify(r)); + result[key] = Private.maybeJson(r); } catch (err) { errors.push({ [key]: `${err}` }); } @@ -188,19 +221,24 @@ export class CommandRegistryModel extends WidgetModel { /** * Validate command args (if constrained) - * - * @param options The validation options. */ - private async _validateArgs(options: Private.IExecuteOptions): Promise { + private async _validateArgs( + options: Private.IExecuteOptions + ): Promise { const { id, args } = options; const describedBy = await this._commands.describedBy(id, args); - if (!describedBy.args) { - return; - } - const ajv = await Private.ajv(); - if (!ajv.validate(describedBy.args, options.args)) { - throw new Error(JSON.stringify(ajv.errors, null, 2)); + 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; } /** @@ -230,12 +268,27 @@ export class CommandRegistryModel extends WidgetModel { 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), @@ -243,6 +296,11 @@ export class CommandRegistryModel extends WidgetModel { }); 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(); } /** @@ -274,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; } @@ -305,33 +364,63 @@ namespace Private { return _ajv; } - export type TAnyMessage = IExecute | IDescribe | IAddCommand | IRemoveCommand; + export function maybeJson(data: any): any { + try { + return JSON.parse(JSON.stringify(data, null, 2)); + } catch (err) { + return `${data}`; + } + } - export interface IMessage { - func: string; + // messages + export type TAnyRequest = + | IExecute + | IExecuteResults + | IDescribe + | IAddCommand + | IRemoveCommand; + + export interface IRequest { + func: + | 'addCommand' + | 'describe' + | 'finishExecute' + | 'removeCommand' + | 'execute'; payload: any; } - export interface IAddCommand extends IMessage { + export interface IAddCommand extends IRequest { func: 'addCommand'; payload: IAddCommandOptions; } - export interface IWithCommandId { - /** command id */ - id: string; + export interface IRemoveCommand extends IRequest { + func: 'removeCommand'; + payload: IRemoveCommandOptions; } - export interface IDescribe extends IWithCommandId { + export interface IDescribe extends IRequest { func: 'describe'; payload: IDescribeOptions; } - export interface IExecute extends IMessage { + 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; @@ -343,6 +432,16 @@ namespace Private { /** 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; } @@ -351,11 +450,6 @@ namespace Private { extends IWithCommandId, CommandRegistry.ICommandOptions {} - export interface IRemoveCommand extends IMessage { - func: 'removeCommand'; - payload: IRemoveCommandOptions; - } - export interface IRemoveCommandOptions extends IWithCommandId {} export interface IResult extends JSONObject { diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts index 15f9665b..0388c049 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 05f718b0..7433470a 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 27498dbb..5919e361 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 7daed119..a44b22e8 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 af21e029..f9f4b457 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 b2dd317c..e9253617 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 5ccb293b..a282974a 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 8ccdbaea..d3811c0b 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 From 8860f7174492bf91d8812b5e6a18520becbf61bf Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Sun, 23 Nov 2025 21:04:07 -0600 Subject: [PATCH 04/14] blacken --- ipylab/commands.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/ipylab/commands.py b/ipylab/commands.py index 27c363a2..775cb43e 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -46,6 +46,7 @@ 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) @@ -84,7 +85,7 @@ def _on_frontend_msg(self, _, content, buffers): "id": command_id, "result_id": result_id, "result": result, - "errors": errors + "errors": errors, } self.send({"func": "finishExecute", "payload": payload}) @@ -111,20 +112,24 @@ def _on_executed(result: Any, errors: list[Any]) -> None: def execute( self, command_id: str, - args: dict[str, Any] | None=None, - handler: ExecuteHandler | None=None, + args: dict[str, Any] | None = None, + handler: ExecuteHandler | None = None, *, - validate: bool | 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, + "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: + def describe( + self, command_id: str, args: dict[str, Any], handler: ExecuteHandler + ) -> None: payload = { "id": command_id, "args": args or {}, @@ -159,7 +164,7 @@ def add_command( "label": label, "iconClass": icon_class, "icon": f"IPY_MODEL_{icon.model_id}" if icon else None, - "describedBy": described_by + "describedBy": described_by, }, } ) From d970c5023ef841fc0256b602f4f30d2eae3345bd Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Tue, 25 Nov 2025 09:05:16 -0600 Subject: [PATCH 05/14] bump lite deps --- docs/environment.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/environment.yml b/docs/environment.yml index 25449e8a..22b0e663 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -18,8 +18,8 @@ dependencies: - myst-parser # demo - jupyterlab >=4,<5 - - jupyterlite-core >=0.7.0rc0,<0.8 - - jupyterlite-pyodide-kernel >=0.7.0rc0,<0.8 + - jupyterlite-core >=0.7.0rc2,<0.8 + - jupyterlite-pyodide-kernel >=0.7.0rc1,<0.8 - jupyterlite-sphinx >=0.20.2,<0.21.0 # install dev - pip: From aa3a7af87bd4961e2ab9a756a72fe063c019f622 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Tue, 25 Nov 2025 09:41:33 -0600 Subject: [PATCH 06/14] bump ipywidgets in lite --- docs/jupyter_lite_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/jupyter_lite_config.json b/docs/jupyter_lite_config.json index 148e682a..d6dc986a 100644 --- a/docs/jupyter_lite_config.json +++ b/docs/jupyter_lite_config.json @@ -2,7 +2,7 @@ "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/py3/i/ipywidgets/ipywidgets-8.1.8-py3-none-any.whl", "https://files.pythonhosted.org/packages/73/03/6b5370fc626e6f480c4a0b4cb25b3459d390745010618b21b4b573423a53/bqplot-0.12.45-py2.py3-none-any.whl" ] } From 11d041ec009b483e3faeb1568b00d583d6e39675 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Tue, 25 Nov 2025 09:51:07 -0600 Subject: [PATCH 07/14] update some more lite paths --- docs/jupyter_lite_config.json | 5 +++-- ipylab/__init__.py | 6 ++---- ipylab/_frontend.py | 3 +++ ipylab/jupyterfrontend.py | 3 --- ipylab/sessions.py | 3 --- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/jupyter_lite_config.json b/docs/jupyter_lite_config.json index d6dc986a..e578a1c5 100644 --- a/docs/jupyter_lite_config.json +++ b/docs/jupyter_lite_config.json @@ -2,8 +2,9 @@ "PipliteAddon": { "piplite_urls": [ "../dist", - "https://files.pythonhosted.org/packages/py3/i/ipywidgets/ipywidgets-8.1.8-py3-none-any.whl", - "https://files.pythonhosted.org/packages/73/03/6b5370fc626e6f480c4a0b4cb25b3459d390745010618b21b4b573423a53/bqplot-0.12.45-py2.py3-none-any.whl" + "https://files.pythonhosted.org/packages/py2.py3/i/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/ipylab/__init__.py b/ipylab/__init__.py index dfbb3af2..18de4ff3 100644 --- a/ipylab/__init__.py +++ b/ipylab/__init__.py @@ -1,15 +1,13 @@ -#!/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 2c03d5df..c5dab77e 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/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 6b5d1822..00acedea 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 09e7daac..04e3f7a0 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. From f484260cd9d1a661a122fbcfcaca09a538942d68 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Tue, 25 Nov 2025 09:52:32 -0600 Subject: [PATCH 08/14] linting --- ipylab/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ipylab/__init__.py b/ipylab/__init__.py index 18de4ff3..b5a8e42c 100644 --- a/ipylab/__init__.py +++ b/ipylab/__init__.py @@ -9,5 +9,6 @@ __all__ = ["__version__", "JupyterFrontEnd", "Panel", "SplitPanel", "Icon"] + def _jupyter_labextension_paths(): return [{"src": "labextension", "dest": "ipylab"}] From 001cfc10332db488988b5e014d41671d5aa87c7a Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Tue, 25 Nov 2025 10:00:27 -0600 Subject: [PATCH 09/14] fix bqplot url --- docs/jupyter_lite_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/jupyter_lite_config.json b/docs/jupyter_lite_config.json index e578a1c5..f97a3825 100644 --- a/docs/jupyter_lite_config.json +++ b/docs/jupyter_lite_config.json @@ -2,7 +2,7 @@ "PipliteAddon": { "piplite_urls": [ "../dist", - "https://files.pythonhosted.org/packages/py2.py3/i/bqplot/bqplot-0.12.45-py2.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" ] From f8d012ada9254ddf8cd5aaab5f5ff6f37e2e6b75 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Tue, 25 Nov 2025 10:34:23 -0600 Subject: [PATCH 10/14] more demo --- docs/jupyter-lite.json | 6 ++++++ docs/jupyter_lite_config.json | 8 ++++++++ examples/ipytree.ipynb | 6 ++++-- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 docs/jupyter-lite.json diff --git a/docs/jupyter-lite.json b/docs/jupyter-lite.json new file mode 100644 index 00000000..5ca403f1 --- /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 f97a3825..48b0472b 100644 --- a/docs/jupyter_lite_config.json +++ b/docs/jupyter_lite_config.json @@ -1,4 +1,12 @@ { + "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", diff --git a/examples/ipytree.ipynb b/examples/ipytree.ipynb index d1445712..0c75479a 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", From 0e0a4e1492eb5c468eb8afa4501c1f48e25d5965 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Wed, 26 Nov 2025 09:50:56 -0600 Subject: [PATCH 11/14] update lite deps --- docs/environment.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/environment.yml b/docs/environment.yml index 22b0e663..32b54221 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -1,8 +1,7 @@ name: ipylab-lite channels: - - conda-forge/label/jupyterlite_pyodide_kernel_rc - - conda-forge/label/jupyterlite_core_rc - conda-forge + - nodefaults dependencies: # runtimes - python 3.11.* @@ -18,9 +17,6 @@ dependencies: - myst-parser # demo - jupyterlab >=4,<5 - - jupyterlite-core >=0.7.0rc2,<0.8 - - jupyterlite-pyodide-kernel >=0.7.0rc1,<0.8 + - jupyterlite-core >=0.7.0,<0.8 + - jupyterlite-pyodide-kernel >=0.7.0,<0.8 - jupyterlite-sphinx >=0.20.2,<0.21.0 - # install dev - - pip: - - .. From ee1591182617985eba9e81244d47e3fb6c8fb411 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Wed, 26 Nov 2025 09:55:20 -0600 Subject: [PATCH 12/14] update jupyterlite-sphinx --- docs/environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/environment.yml b/docs/environment.yml index 32b54221..d9754070 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -19,4 +19,4 @@ dependencies: - jupyterlab >=4,<5 - jupyterlite-core >=0.7.0,<0.8 - jupyterlite-pyodide-kernel >=0.7.0,<0.8 - - jupyterlite-sphinx >=0.20.2,<0.21.0 + - jupyterlite-sphinx >=0.22.0,<0.23.0 From 9adb3aab48cfe5ba91e49af1b70c6b9cb0a19e6c Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Sun, 30 Nov 2025 09:57:52 -0600 Subject: [PATCH 13/14] update rtd --- docs/environment.yml | 11 +++-------- readthedocs.yml | 5 +---- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/docs/environment.yml b/docs/environment.yml index 22b0e663..92b5e080 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -1,7 +1,5 @@ name: ipylab-lite channels: - - conda-forge/label/jupyterlite_pyodide_kernel_rc - - conda-forge/label/jupyterlite_core_rc - conda-forge dependencies: # runtimes @@ -18,9 +16,6 @@ dependencies: - myst-parser # demo - jupyterlab >=4,<5 - - jupyterlite-core >=0.7.0rc2,<0.8 - - jupyterlite-pyodide-kernel >=0.7.0rc1,<0.8 - - jupyterlite-sphinx >=0.20.2,<0.21.0 - # install dev - - pip: - - .. + - jupyterlite-core >=0.7.0,<0.8 + - jupyterlite-pyodide-kernel >=0.7.0,<0.8 + - jupyterlite-sphinx >=0.22.0,<0.23.0 diff --git a/readthedocs.yml b/readthedocs.yml index b0ecbb8f..75593471 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 From dab482ff4afb9a13c8550dbc543ca01044607134 Mon Sep 17 00:00:00 2001 From: Nicholas Bollweg Date: Sun, 30 Nov 2025 10:01:48 -0600 Subject: [PATCH 14/14] update some more docs versions --- docs/environment.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/environment.yml b/docs/environment.yml index d9754070..00f409cf 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -4,7 +4,7 @@ channels: - nodefaults dependencies: # runtimes - - python 3.11.* + - python 3.12.* - nodejs 24.* # run - ipywidgets >=8.0,<9 @@ -16,7 +16,9 @@ dependencies: - pydata-sphinx-theme - myst-parser # demo - - jupyterlab >=4,<5 + - 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