diff --git a/examples/autostart.ipynb b/examples/autostart.ipynb index 096e8669..0baaff45 100644 --- a/examples/autostart.ipynb +++ b/examples/autostart.ipynb @@ -15,13 +15,12 @@ "source": [ "# Autostart\n", "\n", - "Autostart is a feature implemented using the [`pluggy`](https://pluggy.readthedocs.io/en/stable/index.html#pluggy) plugin system. Plugins registered under the entry point `ipylab-python-backend` will be called once when `ipylab` is activated. Normally activation of `ipylab` will occur when Jupyterlab is started (assuming `ipylab` is installed and enabled). \n", + "Autostart is a feature implemented using the [`pluggy`](https://pluggy.readthedocs.io/en/stable/index.html#pluggy) plugin system. The code associated with the entry point `ipylab-python-backend` will be called (imported) when `ipylab` is activated. `ipylab` will activate when Jupyterlab is started (provided `ipylab` is installed and enabled). \n", "\n", - "There are no limitations to what can be done. But some possibilities include:\n", - "* Launch an app to run in its own thread;\n", + "There are no limitations to what can be done. But it is recommended to import on demand to minimise the time required to launch. Some possibilities include:\n", "* Create and register custom commands;\n", "* Create launchers;\n", - "* Create new notebooks;\n", + "* Modify the appearance of Jupyterlab.\n", "\n", "## Entry points\n", "\n", @@ -32,27 +31,24 @@ "my-plugins-name = \"my_module.ipylab_plugin:ipylab_plugin\"\n", "```\n", "\n", - "In `my_module.autostart.py` define the plugins.\n", + "In `my_module.autostart.py` write code that will be run once.\n", "\n", "Example:\n", "\n", "```python\n", "# @ipylab_plugin.py\n", "\n", - "import ipylab\n", - "\n", - "app = ipylab.JupyterFrontEnd()\n", + "import asyncio\n", "\n", - "def create_app():\n", - " Add code here to create the app\n", + "async def startup():\n", + " import ipylab\n", + " \n", + " app = ipylab.JupyterFrontEnd() \n", + " await app.read_wait()\n", + " #Do everything to startup\n", "\n", - "class IpylabPlugins:\n", - " @ipylab.hookspecs.hookimpl()\n", - " def run_once_at_startup(self):\n", - " # May want to use a launcher instead\n", - " app.newSession(path=\"my app\", code=create_app)\n", - " # Do more stuff ...\n", - "ipylab_plugin = IpylabPlugins()\n", + "ipylab_plugin = None # Provide an empty object with the expected name.\n", + "asyncio.create_task(startup())\n", "```" ] }, @@ -60,7 +56,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Example launching a small app" + "## Example creating a launcher" ] }, { @@ -71,50 +67,78 @@ "source": [ "# @my_module.autostart.py\n", "\n", + "import asyncio\n", + "\n", "import ipylab\n", "\n", "app = ipylab.JupyterFrontEnd()\n", "\n", + "n = 0\n", + "\n", "\n", "async def create_app():\n", - " # Ensure this function provides all the imports.\n", + " # The code in this function is called in the new kernel (session).\n", + " # Ensure imports are performed inside the function.\n", " global ma\n", " import ipywidgets as ipw\n", "\n", " import ipylab\n", "\n", - " # app = ipylab.JupyterFrontEnd()\n", - " # await app.wait_ready()\n", " ma = ipylab.MainArea(name=\"My demo app\")\n", + " ma.content.title.label = \"Simple app\"\n", + " ma.content.title.caption = ma.kernelId\n", + " await ma.load()\n", " console_button = ipw.Button(description=\"Toggle console\")\n", + " error_button = ipw.Button(\n", + " description=\"Do an error\",\n", + " tooltip=\"An error dialog will pop up when this is clicked.\\n\"\n", + " \"The dialog demonstrates the use of the `on_frontend_error` plugin.\",\n", + " )\n", " console_button.on_click(\n", " lambda b: ma.load_console() if ma.console_status == \"unloaded\" else ma.unload_console()\n", " )\n", + " error_button.on_click(lambda b: ma.execute_command(\"Not a command\"))\n", " ma.content.children = [\n", " ipw.HTML(f\"

My simple app

Welcome to my app.
kernel id: {ma.kernelId}\"),\n", - " console_button,\n", + " ipw.HBox([console_button, error_button]),\n", " ]\n", - " ma.content.label = \"This is my app\"\n", - " ma.load()\n", - " print(\"Finished creating my app\")\n", "\n", + " # Shutdown when MainArea is unloaded.\n", + " def on_status_change(change):\n", + " if change[\"new\"] == \"unloaded\":\n", + " ma.app.shutdownKernel()\n", "\n", - "class MyPlugins:\n", - " @ipylab.hookspecs.hookimpl()\n", - " def run_once_at_startup(self):\n", - " app.newSession(path=\"my app\", code=create_app)\n", + " ma.observe(on_status_change, \"status\")\n", "\n", + " class IpylabPlugins:\n", + " # Define plugins (see IpylabHookspec for available hooks)\n", + " @ipylab.hookimpl\n", + " def on_frontend_error(self, obj, error, content):\n", + " ma.app.dialog.show_error_message(\"Error\", str(error))\n", "\n", - "ipylab_plugin = MyPlugins()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Launch the app manually\n", + " # Register plugin for this kernel.\n", + " ipylab.hookspecs.pm.register(IpylabPlugins())\n", "\n", - "We can 'launch' the app in a new kernel." + "\n", + "def start_my_app(cwd):\n", + " global n\n", + " n += 1\n", + " app.newSession(path=f\"my app {n}\", code=create_app)\n", + "\n", + "\n", + "async def register_commands():\n", + " await app.command.addPythonCommand(\n", + " \"start_my_app\",\n", + " execute=start_my_app,\n", + " label=\"Start Custom App\",\n", + " icon_class=\"jp-PythonIcon\",\n", + " )\n", + " await app.launcher.add_item(\"start_my_app\", \"Ipylab\")\n", + " return \"done\"\n", + "\n", + "\n", + "ipylab_plugin = None\n", + "t = asyncio.create_task(register_commands())" ] }, { @@ -123,15 +147,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.newSession(path=\"my app\", code=create_app)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Auto Launch app\n", - "Simulate code launch in the as it happens in `Ipylab backend`" + "t.result()" ] }, { @@ -140,11 +156,8 @@ "metadata": {}, "outputs": [], "source": [ - "# Register plugin (normally via the entry point `ipylab-python-backend`)\n", - "ipylab.hookspecs.pm.register(ipylab_plugin)\n", - "\n", - "# Called when Ipylab is activated and Ipylab backend launches\n", - "app._init_python_backend()" + "# There is a new launcher called 'Start custom app'\n", + "t = app.execute_command(\"launcher:create\")" ] } ], diff --git a/examples/commands.ipynb b/examples/commands.ipynb index 6c054502..6d6b68f0 100644 --- a/examples/commands.ipynb +++ b/examples/commands.ipynb @@ -58,7 +58,7 @@ "metadata": {}, "outputs": [], "source": [ - "len(app.commands.commands)" + "len(app.command.commands)" ] }, { @@ -74,7 +74,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.commands.execute(\n", + "t = app.execute_command(\n", " \"console:create\",\n", " insertMode=\"split-right\",\n", " kernelPreference={\"id\": app.kernelId},\n", @@ -104,7 +104,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute(\"apputils:change-theme\", theme=\"JupyterLab Dark\")" + "app.execute_command(\"apputils:change-theme\", theme=\"JupyterLab Dark\")" ] }, { @@ -113,7 +113,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.commands.execute(\"apputils:change-theme\", theme=\"JupyterLab Light\")" + "t = app.execute_command(\"apputils:change-theme\", theme=\"JupyterLab Light\")" ] }, { @@ -138,7 +138,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute(\"terminal:create-new\")\n", + "app.execute_command(\"terminal:create-new\")\n", "# It can take up to 10 seconds for the terminal to show up after the cell returns." ] }, @@ -208,7 +208,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.addPythonCommand(\n", + "app.command.addPythonCommand(\n", " \"swap_orientation\",\n", " execute=toggle_orientation,\n", " label=\"Swap orientation\",\n", @@ -229,7 +229,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.commands.execute(\"swap_orientation\")" + "t = app.execute_command(\"swap_orientation\")" ] }, { @@ -270,7 +270,7 @@ "metadata": {}, "outputs": [], "source": [ - "assert \"swap_orientation\" in app.commands.commands" + "assert \"swap_orientation\" in app.command.commands" ] }, { @@ -312,6 +312,24 @@ "Open the command palette CTRL + SHIFT + C and the command should show now be visible. Look for `Swap orientation`" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "app.launcher.add_item(\"swap_orientation\", \"Python Commands\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "t.result()" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -327,7 +345,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.remove_command(\"swap_orientation\")" + "app.command.remove_command(\"swap_orientation\")" ] }, { @@ -336,7 +354,7 @@ "metadata": {}, "outputs": [], "source": [ - "assert \"swap_orientation\" not in app.commands.commands" + "assert \"swap_orientation\" not in app.command.commands" ] } ], @@ -356,14 +374,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.1" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "state": {}, - "version_major": 2, - "version_minor": 0 - } + "version": "3.10.13" } }, "nbformat": 4, diff --git a/examples/icons.ipynb b/examples/icons.ipynb index ea645f4f..0b19c28e 100644 --- a/examples/icons.ipynb +++ b/examples/icons.ipynb @@ -240,7 +240,7 @@ }, "outputs": [], "source": [ - "panel.app.commands.addPythonCommand(\n", + "panel.app.command.addPythonCommand(\n", " \"my-icon:randomize\",\n", " randomize_icon,\n", " label=\"Randomize My Icon\",\n", @@ -295,7 +295,7 @@ }, "outputs": [], "source": [ - "panel.app.commands.execute(\"apputils:activate-command-palette\")" + "panel.app.execute_command(\"apputils:activate-command-palette\")" ] }, { @@ -313,7 +313,7 @@ "metadata": {}, "outputs": [], "source": [ - "panel.app.commands.execute(\"my-icon:randomize\", count=1)" + "panel.app.execute_command(\"my-icon:randomize\", count=1)" ] } ], diff --git a/examples/ipytree.ipynb b/examples/ipytree.ipynb index dd73a29f..42059255 100644 --- a/examples/ipytree.ipynb +++ b/examples/ipytree.ipynb @@ -262,7 +262,7 @@ "Let's also define a couple of buttons to:\n", "\n", "- open the selected files\n", - "- expand all nodes of the tre\n", + "- expand all nodes of the tree\n", "- collapse all nodes of the tree" ] }, @@ -315,7 +315,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "When the \"Open\" button is clicked, we call `app.commands.execute` with the path to the file to open it in the JupyterLab interface." + "When the \"Open\" button is clicked, we call `app.execute_command` with the path to the file to open it in the JupyterLab interface." ] }, { @@ -328,7 +328,7 @@ " for node in file_tree.selected_nodes:\n", " filepath = node.fullpath\n", " if filepath:\n", - " app.commands.execute(\"docmanager:open\", {\"path\": filepath})\n", + " app.execute_command(\"docmanager:open\", {\"path\": filepath})\n", "\n", "\n", "open_button.on_click(on_open_clicked)" @@ -413,13 +413,6 @@ "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.8.2" - }, - "widgets": { - "application/vnd.jupyter.widget-state+json": { - "state": {}, - "version_major": 2, - "version_minor": 0 - } } }, "nbformat": 4, diff --git a/examples/sessions.ipynb b/examples/sessions.ipynb index d0ed6f35..d3eef614 100644 --- a/examples/sessions.ipynb +++ b/examples/sessions.ipynb @@ -80,7 +80,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute(\"console:create\", **app.current_session)" + "app.execute_command(\"console:create\", **app.current_session)" ] }, { @@ -89,7 +89,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute(\"notebook:create-console\")" + "app.execute_command(\"notebook:create-console\")" ] }, { diff --git a/ipylab/asyncwidget.py b/ipylab/asyncwidget.py index 3f7e1de3..35118a3a 100644 --- a/ipylab/asyncwidget.py +++ b/ipylab/asyncwidget.py @@ -15,6 +15,7 @@ from traitlets import Bool, Dict, Instance, Set, Unicode import ipylab._frontend as _fe +from ipylab.hasapp import HasApp from ipylab.hookspecs import pm if sys.version_info >= (3, 11): @@ -113,7 +114,7 @@ class IpylabFrontendError(IOError): pass -class WidgetBase(Widget): +class WidgetBase(Widget, HasApp): _model_module = Unicode(_fe.module_name, read_only=True).tag(sync=True) _model_module_version = Unicode(_fe.module_version, read_only=True).tag(sync=True) _view_module = Unicode(_fe.module_name, read_only=True).tag(sync=True) @@ -156,6 +157,7 @@ def __init__(self, *, model_id=None, **kwgs): self.on_msg(self._on_frontend_msg) async def __aenter__(self): + self._check_closed() if not self._ready_response.is_set(): await self.wait_ready() @@ -180,25 +182,31 @@ def _check_closed(self): def _check_get_error(self, content={}) -> IpylabFrontendError | None: error = content.get("error") if error: - if operation := content.get("operation"): - return IpylabFrontendError( - f"{self.__class__.__name__} operation '{operation}' failed with message \"{error}\"" - ) + operation = content.get("operation") + if operation: + msg = f"{self.__class__.__name__} operation '{operation}' failed with message \"{error}\"" + if "cyclic" in error: + msg += ( + "\nNote: A cyclic error may be due a return value that cannot be converted to JSON. " + "Try changing the transform (eg: transform=ipylab.TransformMode.done)." + ) + return IpylabFrontendError(msg) + return IpylabFrontendError(f'{self.__class__.__name__} failed with message "{error}"') else: return None async def wait_ready(self) -> None: if not self._ready_response.is_set(): - self.log.info(f"Connecting to frontend {self._model_name}") + self.log.info(f"Connecting to frontend model '{self._model_name}'") await self._ready_response.wait() - self.log.info(f"Connected to frontend {self._model_name}") + self.log.info(f"Connected to frontend model '{self._model_name}'") def send(self, content, buffers=None): try: super().send(content, buffers) except Exception as error: - pm.hook.on_send_error(self, error, content, buffers) + pm.hook.on_send_error(obj=self, error=error, content=content, buffers=buffers) async def _send_receive(self, content: dict, callback: Callable): async with self: @@ -223,8 +231,6 @@ def _on_frontend_msg(self, _, content: dict, buffers: list): payload = content.get("payload", {}) if ipylab_BE: self._pending_operations.pop(ipylab_BE).set(payload, error) - elif error: - pm.hook.on_frontend_error(obj=self, error=self.error, content=content) ipylab_FE = content.get("ipylab_FE", "") if ipylab_FE: task = asyncio.create_task( @@ -232,6 +238,8 @@ def _on_frontend_msg(self, _, content: dict, buffers: list): ) self._tasks.add(task) task.add_done_callback(self._tasks.discard) + if error: + pm.hook.on_frontend_error(obj=self, error=error, content=content) elif init_message := content.get("init"): self._ready_response.set(content) print(init_message) @@ -247,9 +255,6 @@ async def _handle_frontend_operation( buffers = [] try: result = await self._do_operation_for_frontend(operation, payload, buffers) - if result is None: - pm.hook.unhandled_frontend_operation_message(self, operation) - raise ValueError(f"{operation=}") if isinstance(result, dict) and "buffers" in result: buffers = result["buffers"] result = result["payload"] @@ -258,7 +263,7 @@ async def _handle_frontend_operation( content["error"] = "Cancelled" except Exception as e: content["error"] = str(e) - pm.hook.on_frontend_operation_error(self, error=e, content=payload) + pm.hook.on_frontend_operation_error(obj=self, error=e, content=payload) finally: self.send(content, buffers) @@ -266,7 +271,7 @@ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffer """Overload this function as required. or if there is a buffer can return a dict {"payload":dict, "buffers":[]} """ - pm.hook.unhandled_frontend_operation_message(self, operation) + pm.hook.unhandled_frontend_operation_message(obj=self, operation=operation) def schedule_operation( self, @@ -414,3 +419,20 @@ def callback_(content: dict, payload: list): transform=transform, widget=widget, ) + + def execute_command( + self, + command_id: str, + transform: TransformMode | dict[str, str] = TransformMode.done, + **args, + ) -> asyncio.Task: + """Execute command_id. + + `args` correspond to `args` in JupyterLab. + + Finding what the `args` are remains an outstanding issue in JupyterLab. + + see: https://github.com/jtpio/ipylab/issues/128#issuecomment-1683097383 for hints + about how args can be found. + """ + return self.execute_method("app.commands.execute", command_id, args, transform=transform) diff --git a/ipylab/commands.py b/ipylab/commands.py index 4717c760..ac787cfb 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio +import inspect from collections.abc import Callable -from traitlets import Dict, Tuple, Unicode, observe +from traitlets import Dict, Tuple, Unicode -from ipylab.asyncwidget import AsyncWidgetBase, TransformMode, pack, register +from ipylab.asyncwidget import AsyncWidgetBase, pack, register from ipylab.hookspecs import pm from ipylab.widgets import Icon @@ -15,7 +16,6 @@ @register class CommandPalette(AsyncWidgetBase): _model_name = Unicode("CommandPaletteModel").tag(sync=True) - items = Tuple(read_only=True).tag(sync=True) def add_item( @@ -31,7 +31,12 @@ def add_item( ) def remove_item(self, command_id: str, category) -> asyncio.Task: - return self.schedule_operation(operation="addItem", id=command_id, category=category) + return self.schedule_operation(operation="removeItem", id=command_id, category=category) + + +@register +class Launcher(CommandPalette): + _model_name = Unicode("LauncherModel").tag(sync=True) @register @@ -39,45 +44,30 @@ class CommandRegistry(AsyncWidgetBase): _model_name = Unicode("CommandRegistryModel").tag(sync=True) SINGLETON = True commands = Tuple(read_only=True).tag(sync=True) - _execute_callbacks: dict[str : Callable[[], None]] = Dict() - @observe("commands") - def _observe_commands(self, change): - commands = self.commands - for k in tuple(self._execute_callbacks): - if k not in commands: - self._execute_callbacks.pop(k) - async def _do_operation_for_frontend( self, operation: str, payload: dict, buffers: list ) -> bool | None: if operation == "execute": command_id = payload.get("id") - cmd = self._execute_callbacks[command_id] - result = cmd(**payload.get("kwgs", {})) + cmd = self._get_command(command_id) + kwgs = dict(payload.get("kwgs", {})) + for k in set(inspect.signature(cmd).parameters.keys()).difference(kwgs): + kwgs.pop(k) + result = cmd(**kwgs) if asyncio.iscoroutine(result): result = await result return result else: - pm.hook.unhandled_frontend_operation_message(self, operation) - - def execute( - self, - command_id: str, - transform: TransformMode | dict[str, str] = TransformMode.raw, - **args, - ) -> asyncio.Task: - """Execute command_id. - - `args` correspond to `args` in JupyterLab. - - Finding what the `args` are remains an outstanding issue in JupyterLab. + pm.hook.unhandled_frontend_operation_message(obj=self, operation=operation) - see: https://github.com/jtpio/ipylab/issues/128#issuecomment-1683097383 for hints - about how args can be found. - """ - return self.execute_method("app.commands.execute", command_id, args, transform=transform) + def _get_command(self, command_id: str) -> Callable: + "Get a registered Python command" + if command_id not in self._execute_callbacks: + msg = f"{command_id} is not a registered command!" + raise KeyError(msg) + return self._execute_callbacks[command_id] def addPythonCommand( self, @@ -90,8 +80,6 @@ def addPythonCommand( icon: Icon = None, **kwgs, ): - if command_id in self.commands: - raise Exception(f"Command '{command_id} is already registered!") # TODO: support other parameters (isEnabled, isVisible...) self._execute_callbacks = self._execute_callbacks | {command_id: execute} return self.schedule_operation( @@ -104,8 +92,14 @@ def addPythonCommand( **kwgs, ) - def remove_command(self, command_id: str, **kwgs) -> asyncio.Task: + def removePythonCommand(self, command_id: str, **kwgs) -> asyncio.Task: # TODO: check whether to keep this method, or return disposables like in lab - if command_id not in self.commands: + if command_id not in self._execute_callbacks: raise ValueError(f"{command_id=} is not a registered command!") - return self.schedule_operation("removePythonCommand", command_id=command_id, **kwgs) + + def callback(content: dict, payload: list): + self._execute_callbacks.pop(command_id, None) + + return self.schedule_operation( + "removePythonCommand", command_id=command_id, callback=callback, **kwgs + ) diff --git a/ipylab/hookspecs.py b/ipylab/hookspecs.py index 80bc5e89..97145e43 100644 --- a/ipylab/hookspecs.py +++ b/ipylab/hookspecs.py @@ -11,8 +11,6 @@ hookspec = pluggy.HookspecMarker("ipylab") pm = pluggy.PluginManager("ipylab") if t.TYPE_CHECKING: - import jupyterlab.labapp - from ipylab.asyncwidget import AsyncWidgetBase @@ -42,62 +40,12 @@ def on_send_error(self, obj: AsyncWidgetBase, error: Exception, content: dict, b def on_frontend_operation_error(self, obj: AsyncWidgetBase, error: Exception, content: dict): """Handle an error processing an operation from the frontend.""" - @hookspec(firstresult=True) - def get_ipylab_backend_class(self) -> type[jupyterlab.labapp.LabApp]: - """Return the class to use as the backend. - - This will override the app used when launching with the console command `ipylab ...`. - or when calling `python -m ipylab ...` - """ - - @hookspec() - def run_once_at_startup(self): - """The function will run once when Ipylab is activated (requires entry point as explained below). - - ``` python - # @ ipylab_plugin.py - import ipylab - - class myPluginDefs: - @ipylab.hookimpl(specname="run_once_at_startup") - def plugin_my_launcher(): - # Do my startup tasks - - - - myPlugins = myPluginDefs() - - ``` - - Note: The package should be installed (re-installed) with the entry point "ipylab-python-backend" - - in pyproject.toml - ``` toml - [project.entry-points.ipylab-python-backend] - my-plugins-name = "my_module.ipylab_plugin:myPlugins" - - ``` - - entry_point: str [:[.[.]*]] - The script called - Uses the same convention as setup tools. - - https://setuptools.pypa.io/en/latest/userguide/entry_point.html#entry-points-syntax - - """ - @hookspec(firstresult=True) def unhandled_frontend_operation_message(self, obj: AsyncWidgetBase, operation: str): """Handle a message from the frontend.""" class IpylabDefaultsPlugin: - @hookimpl - def get_ipylab_backend_class(self): - import ipylab.labapp - - return ipylab.labapp.IPLabApp - @hookimpl def unhandled_frontend_operation_message(self, obj: AsyncWidgetBase, operation: str): raise RuntimeError(f"Unhandled frontend_operation_message from {obj=} {operation=}") diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index be3701d5..be83fd2b 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -15,7 +15,7 @@ register, widget_serialization, ) -from ipylab.commands import CommandPalette, CommandRegistry +from ipylab.commands import CommandPalette, CommandRegistry, Launcher from ipylab.dialog import Dialog, FileDialog from ipylab.sessions import SessionManager from ipylab.shell import Shell @@ -34,10 +34,11 @@ class JupyterFrontEnd(AsyncWidgetBase): SINGLETON = True version = Unicode(read_only=True).tag(sync=True) - commands = Instance(CommandRegistry, (), read_only=True).tag(sync=True, **widget_serialization) + command = Instance(CommandRegistry, (), read_only=True).tag(sync=True, **widget_serialization) command_pallet = Instance(CommandPalette, (), read_only=True).tag( sync=True, **widget_serialization ) + launcher = Instance(Launcher, (), read_only=True).tag(sync=True, **widget_serialization) current_widget_id = Unicode(read_only=True).tag(sync=True) current_session = Dict(read_only=True).tag(sync=True) @@ -71,7 +72,10 @@ async def wait_ready(self, timeout=5) -> Self: """Wait until connected to app indicates it is ready.""" if not self._ready_response.is_set(): future = asyncio.gather( - super().wait_ready(), self.commands.wait_ready(), self.command_pallet.wait_ready() + super().wait_ready(), + self.command.wait_ready(), + self.command_pallet.wait_ready(), + self.launcher.wait_ready(), ) await asyncio.wait_for(future, timeout) return self @@ -88,8 +92,10 @@ def _init_python_backend(self) -> str: except Exception as e: self.log.error("An exception occurred when loading plugins") self.dialog.show_error_message("Plugin failure", str(e)) - result = pm.hook.run_once_at_startup() - self.log.info("Finished loading plugins.") + + def shutdownKernel(self, kernelId: str | None = None) -> asyncio.Task: + """Shutdown the kernel""" + return self.schedule_operation("shutdownKernel", kernelId=kernelId) def newSession( self, diff --git a/ipylab/scripts.py b/ipylab/scripts.py index 5277f5e6..92e4fab2 100644 --- a/ipylab/scripts.py +++ b/ipylab/scripts.py @@ -3,10 +3,6 @@ from __future__ import annotations import sys -import typing as t - -if t.TYPE_CHECKING: - from ipylab.labapp import IPLabApp def init_ipylab_backend() -> str: @@ -21,7 +17,6 @@ def init_ipylab_backend() -> str: def launch_jupyterlab(): - from ipylab.hookspecs import pm + from ipylab.labapp import IPLabApp - cls: IPLabApp = pm.hook.get_ipylab_backend_class() - sys.exit(cls.launch_instance()) + sys.exit(IPLabApp.launch_instance()) diff --git a/src/widget.ts b/src/widget.ts index 9925a81e..6c0bf8fe 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -6,13 +6,14 @@ import { JupyterFrontEndModel } from './widgets/frontend'; import { IconModel, IconView } from './widgets/icon'; import { IpylabModel } from './widgets/ipylab'; import { MainAreaModel } from './widgets/main_area'; -import { CommandPaletteModel } from './widgets/palette'; +import { CommandPaletteModel, LauncherModel } from './widgets/palette'; import { PanelModel, PanelView } from './widgets/panel'; import { SplitPanelModel, SplitPanelView } from './widgets/split_panel'; import { TitleModel } from './widgets/title'; export { CommandPaletteModel, + LauncherModel, CommandRegistryModel, IconModel, IconView, diff --git a/src/widgets/commands.ts b/src/widgets/commands.ts index a91a9ff0..5d1b7612 100644 --- a/src/widgets/commands.ts +++ b/src/widgets/commands.ts @@ -5,12 +5,7 @@ import { unpack_models } from '@jupyter-widgets/base'; import { ObservableMap } from '@jupyterlab/observables'; import { LabIcon } from '@jupyterlab/ui-components'; import { IDisposable } from '@lumino/disposable'; -import { - CommandRegistry, - ISerializers, - IpylabModel, - JSONValue -} from './ipylab'; +import { ISerializers, IpylabModel, JSONValue } from './ipylab'; /** * The model for a command registry. @@ -37,18 +32,15 @@ export class CommandRegistryModel extends IpylabModel { * @param options The initialization options. */ initialize(attributes: any, options: any): void { - this._commands = IpylabModel.app.commands; super.initialize(attributes, options); - this.on('comm_live_update', () => { - if (!this.comm_live) { - Private.customCommands.values().forEach(command => command.dispose()); - } - }); - - this._commands.commandChanged.connect(this._sendCommandList, this); + this.commands.commandChanged.connect(this._sendCommandList, this); this._sendCommandList(); } + get commands() { + return IpylabModel.app.commands; + } + /** * Close model * @@ -57,8 +49,7 @@ export class CommandRegistryModel extends IpylabModel { * @returns - a promise that is fulfilled when all the associated views have been removed. */ close(comm_closed = false): Promise { - // can only be closed once. - this._commands.commandChanged.disconnect(this._sendCommandList, this); + this.commands.commandChanged.disconnect(this._sendCommandList, this); return super.close(comm_closed); } @@ -68,7 +59,7 @@ export class CommandRegistryModel extends IpylabModel { case 'execute': id = payload.id; args = payload.args; - return await this._commands.execute(id, args); + return await this.commands.execute(id, args); case 'addPythonCommand': { result = await this._addCommand(payload); // keep track of the commands @@ -96,7 +87,7 @@ export class CommandRegistryModel extends IpylabModel { if (args && args.type !== 'added' && args.type !== 'removed') { return; } - this.set('commands', this._commands.listCommands()); + this.set('commands', this.commands.listCommands()); this.save_changes(); } @@ -112,7 +103,7 @@ export class CommandRegistryModel extends IpylabModel { */ private async _addCommand(options: any): Promise { const { id, caption, label, iconClass, icon } = options; - if (this._commands.hasCommand(id)) { + if (this.commands.hasCommand(id)) { const cmd = Private.customCommands.get(id); if (cmd) { cmd.dispose(); @@ -129,7 +120,7 @@ export class CommandRegistryModel extends IpylabModel { const commandEnabled = (command: IDisposable): boolean => { return !command.isDisposed && !!this.comm && this.comm_live; }; - const command = this._commands.addCommand(id, { + const command = this.commands.addCommand(id, { caption, label, iconClass, @@ -170,8 +161,6 @@ export class CommandRegistryModel extends IpylabModel { }; static model_name = 'CommandRegistryModel'; - - private _commands!: CommandRegistry; } /** diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts index 9611c1a2..83b6b649 100644 --- a/src/widgets/frontend.ts +++ b/src/widgets/frontend.ts @@ -18,7 +18,7 @@ import { JupyterFrontEnd } from './ipylab'; import { IpylabMainAreaWidget } from './main_area'; -import { injectCode, newNotebook, newSession } from './utils'; +import { injectCode, newNotebook, newSession, onKernelLost } from './utils'; /** * The model for a JupyterFrontEnd. @@ -125,6 +125,15 @@ export class JupyterFrontEndModel extends IpylabModel { return await injectCode(payload); case 'startIyplabPythonBackend': return (await IpylabModel.python_backend.checkStart()) as any; + case 'shutdownKernel': + if (payload.kernelId) { + await IpylabModel.app.commands.execute('kernelmenu:shutdown', { + id: payload.kernelId ?? this.kernelId + }); + } else { + (this.widget_manager as any).kernel.shutdown(); + } + return null; default: throw new Error( `operation='${op}' has not been implemented in ${JupyterFrontEndModel.model_name}!` @@ -153,11 +162,11 @@ export class JupyterFrontEndModel extends IpylabModel { luminoWidget.id = DOMUtils.createDomID(); } this.shell.add(luminoWidget, area, options); - model.on('comm_live_update', () => { - if (!model.comm_live) { - luminoWidget.dispose(); - } - }); + onKernelLost( + (this.widget_manager as any).kernel, + luminoWidget.dispose, + luminoWidget + ); return { id: luminoWidget.id }; } diff --git a/src/widgets/ipylab.ts b/src/widgets/ipylab.ts index 63d1a8a2..4a60f31e 100644 --- a/src/widgets/ipylab.ts +++ b/src/widgets/ipylab.ts @@ -22,7 +22,12 @@ import { JSONValue, UUID } from '@lumino/coreutils'; import { ObjectHash } from 'backbone'; import { MODULE_NAME, MODULE_VERSION } from '../version'; import { PythonBackendModel } from './python_backend'; -import { getNestedObject, listAttributes, transformObject } from './utils'; +import { + getNestedObject, + listAttributes, + transformObject, + onKernelLost +} from './utils'; export { CommandRegistry, @@ -45,14 +50,10 @@ export class IpylabModel extends DOMWidgetModel { this.set('kernelId', this._kernelId); this._pending_backend_operation_callbacks = new Map(); this.on('msg:custom', this._onCustomMessage.bind(this)); + this.save_changes(); const msg = `ipylab ${this.get('_model_name')} ready for operations`; this.send({ init: msg }); - this.on('comm_live_update', () => { - if (!this.comm_live && this.comm) { - this.close(); - } - }); - this.save_changes(); + onKernelLost((this.widget_manager as any).kernel, this.close, this); } get app() { diff --git a/src/widgets/main_area.ts b/src/widgets/main_area.ts index e916f4bc..615ee2d3 100644 --- a/src/widgets/main_area.ts +++ b/src/widgets/main_area.ts @@ -144,13 +144,13 @@ export class MainAreaModel extends IpylabModel { type: this.sessionContext.type, className: className }); + this._unload_mainarea_widget(); // unload any existing widgets. luminoWidget.disposed.connect(() => { - this.set('loaded', 'unloaded'); + this.set('status', 'unloaded'); this.save_changes(); this._luminoWidget = null; this._close_console(); }, this); - this._unload_mainarea_widget(); IpylabModel.app.shell.add(luminoWidget, area, options); await luminoWidget.sessionContext.ready; this._luminoWidget = luminoWidget; diff --git a/src/widgets/palette.ts b/src/widgets/palette.ts index 437ac9fa..af356c50 100644 --- a/src/widgets/palette.ts +++ b/src/widgets/palette.ts @@ -1,7 +1,7 @@ // Copyright (c) ipylab contributors // Distributed under the terms of the Modified BSD License. -import { ICommandPalette, IPaletteItem } from '@jupyterlab/apputils'; +import { IPaletteItem } from '@jupyterlab/apputils'; import { ObservableMap } from '@jupyterlab/observables'; import { IDisposable } from '@lumino/disposable'; import { IpylabModel, JSONValue } from './ipylab'; @@ -31,7 +31,6 @@ export class CommandPaletteModel extends IpylabModel { */ initialize(attributes: any, options: any): void { super.initialize(attributes, options); - this._palette = IpylabModel.palette; this._customItems = new ObservableMap(); this._customItems.changed.connect(this._sendItems, this); } @@ -60,10 +59,15 @@ export class CommandPaletteModel extends IpylabModel { */ close(comm_closed = false): Promise { // can only be closed once. - if (this.comm) { + if (this._customItems) { this._customItems.changed.disconnect(this._sendItems, this); - this._customItems.values().forEach(item => item.dispose()); + this._customItems.values().forEach(item => { + if (!item.isDisposed) { + item.dispose(); + } + }); this._customItems.clear(); + this._customItems = null; } return super.close(comm_closed); } @@ -82,19 +86,25 @@ export class CommandPaletteModel extends IpylabModel { * @param options The item options. */ private _addItem(options: IPaletteItem & { id: string }): JSONValue { - if (!this._palette) { - throw new Error('The command pallet is not loaded!'); - } - const { id, category, args, rank } = options; - const itemId = `${id} | ${category}`; + const itemId = `${options.id} | ${options.category}`; if (this._customItems.has(itemId)) { this._removeItem(options); } - const item = this._palette.addItem({ command: id, category, args, rank }); + const item = this.addItem(options); this._customItems.set(itemId, item); return { id: itemId }; } + /** + * Add an item for the interface + * @param options + * @returns + */ + addItem(options: any) { + options.command = options.id; + return this.interface.addItem(options); + } + /** * Remove an item (custom only) from the command pallet. * @@ -114,9 +124,36 @@ export class CommandPaletteModel extends IpylabModel { return null; } + get interface(): any { + if (!IpylabModel.palette) { + throw new Error('The command pallet is not loaded!'); + } + + return IpylabModel.palette; + } + static model_name = 'CommandPaletteModel'; private _customItems: ObservableMap; +} + +export class LauncherModel extends CommandPaletteModel { + get interface() { + if (!IpylabModel.launcher) { + throw new Error('The launcher is not loaded!'); + } + return IpylabModel.launcher; + } + + /** + * Add an item for the interface + * @param options + * @returns + */ + addItem(options: any) { + options.command = options.id; + return this.interface.add(options); + } - private _palette!: ICommandPalette; + static model_name = 'LauncherModel'; } diff --git a/src/widgets/panel.ts b/src/widgets/panel.ts index 5f71c679..8c04187e 100644 --- a/src/widgets/panel.ts +++ b/src/widgets/panel.ts @@ -11,6 +11,7 @@ import { BoxModel, BoxView } from '@jupyter-widgets/controls'; import { ObjectHash } from 'backbone'; import { MODULE_NAME, MODULE_VERSION } from '../version'; import { TitleModel } from '../widgets/title'; +import { onKernelLost } from './utils'; /** * The model for a panel. @@ -33,11 +34,7 @@ export class PanelModel extends BoxModel { initialize(attributes: ObjectHash, options: IBackboneModelOptions): void { super.initialize(attributes, options); - this.on('comm_live_update', () => { - if (!this.comm_live && this.comm) { - this.close(); - } - }); + onKernelLost((this.widget_manager as any).kernel, this.close, this); } close(comm_closed?: boolean): Promise { diff --git a/src/widgets/utils.ts b/src/widgets/utils.ts index 3317879b..65b90b3a 100644 --- a/src/widgets/utils.ts +++ b/src/widgets/utils.ts @@ -10,8 +10,10 @@ import { OutputView } from '@jupyter-widgets/output'; import { SessionContext } from '@jupyterlab/apputils'; -import { Session } from '@jupyterlab/services'; +import { ObservableMap } from '@jupyterlab/observables'; +import { Kernel, Session } from '@jupyterlab/services'; import { UUID } from '@lumino/coreutils'; +import { Signal } from '@lumino/signaling'; import { IpylabModel, JSONValue } from './ipylab'; /** @@ -316,3 +318,56 @@ export function listAttributes({ type: typeof obj[p] })); } + +/** + * Call slot when kernel is restarting or dead. + * + * As soon as the kernel is restarted, all Python objects are lost. Use this + * function to close the corresponding frontend objects. + * @param kernel + * @param slot + * @param thisArg + * @param onceOnly - [true] Once called the slot will be disconnected. + */ +export function onKernelLost( + kernel: Kernel.IKernelConnection, + slot: any, + thisArg?: any, + onceOnly = true +) { + if (!Private.kernelLostSlot.has(kernel.id)) { + kernel.statusChanged.connect(_onKernelStatusChanged); + Private.kernelLostSlot.set(kernel.id, new Signal(kernel)); + kernel.disposed.connect(() => { + Private.kernelLostSlot.get(kernel.id).emit(null); + Signal.clearData(Private.kernelLostSlot.get(kernel.id)); + Private.kernelLostSlot.delete(kernel.id); + kernel.statusChanged.disconnect(_onKernelStatusChanged); + }); + } + const callback = () => { + slot.bind(thisArg)(); + if (onceOnly) { + Private.kernelLostSlot.get(kernel.id)?.disconnect(callback); + } + }; + Private.kernelLostSlot.get(kernel.id).connect(callback); +} + +/** + * React to changes to the kernel status. + */ +function _onKernelStatusChanged(kernel: Kernel.IKernelConnection) { + if (['dead', 'restarting'].includes(kernel.status)) { + Private.kernelLostSlot.get(kernel.id).emit(null); + } +} + +/** + * A namespace for private data + */ +namespace Private { + export const kernelLostSlot = new ObservableMap< + Signal + >(); +}