From b2503f62e8750e13fd4d8ef488f595cd40069f34 Mon Sep 17 00:00:00 2001 From: Alan Fleming <> Date: Fri, 7 Jun 2024 21:13:14 +1000 Subject: [PATCH] Refactoring + remove reliance on _model_id. --- examples/autostart.ipynb | 29 ++++++++++-------------- ipylab/asyncwidget.py | 17 +++++++------- ipylab/commands.py | 9 ++------ ipylab/jupyterfrontend.py | 8 +++---- ipylab/main_area.py | 13 ++++------- pyproject.toml | 1 + src/plugin.ts | 2 +- src/widgets/frontend.ts | 11 +++++---- src/widgets/ipylab.ts | 42 +++++++++++++++++------------------ src/widgets/python_backend.ts | 3 +-- src/widgets/utils.ts | 23 +++++++------------ 11 files changed, 67 insertions(+), 91 deletions(-) diff --git a/examples/autostart.ipynb b/examples/autostart.ipynb index 3dfa186b..c4cd1c32 100644 --- a/examples/autostart.ipynb +++ b/examples/autostart.ipynb @@ -72,7 +72,7 @@ "import ipylab\n", "\n", "\n", - "async def create_app():\n", + "async def create_app(path):\n", " # The code in this function is called in the new kernel.\n", " # Ensure imports are performed inside the function.\n", " import ipywidgets as ipw\n", @@ -81,7 +81,7 @@ "\n", " global ma # noqa: PLW0603\n", "\n", - " ma = ipylab.MainArea(name=\"My demo app\")\n", + " ma = ipylab.MainArea(name=\"My demo app\", path=path)\n", " await ma.wait_ready()\n", " ma.content.title.label = \"Simple app\"\n", " ma.content.title.caption = ma.kernelId\n", @@ -92,7 +92,7 @@ " \"The dialog demonstrates the use of the `on_frontend_error` plugin.\",\n", " )\n", " console_button.on_click(\n", - " lambda _: ma.load_console(path=\"console\") if ma.console_status == \"unloaded\" else ma.unload_console()\n", + " lambda _: ma.load_console(name=\"console\") if ma.console_status == \"unloaded\" else ma.unload_console()\n", " )\n", " error_button.on_click(lambda _: ma.executeCommand(\"Not a command\"))\n", " console_status = ipw.HTML()\n", @@ -118,7 +118,12 @@ "\n", " # Register plugin for this kernel.\n", " ipylab.hookspecs.pm.register(IpylabPlugins()) # type: ignore\n", + "\n", + " # Close the launcher if it still has the focus.\n", + " if ma.app.current_widget_id.startswith(\"launcher\"):\n", + " await ma.app.executeMethod(\"app.shell.currentWidget.dispose\")\n", " await ma.load()\n", + " return ipylab.pack(ma)\n", "\n", "\n", "n = 0\n", @@ -128,13 +133,11 @@ "async def start_my_app(cwd): # noqa: ARG001\n", " global n # noqa: PLW0603\n", " n += 1\n", - " # Currently we need to use notebooks for widgets to in a kernel.\n", - " session = await app.newNotebook(f\"test{n}\")\n", - " app.execEval(\n", + " path = f\"my app {n}\"\n", + " return await app.execEval(\n", " code=create_app,\n", - " user_expressions={\"main_area_widget\": \"create_app()\"},\n", - " path=f\"my app {n}\",\n", - " kernelId=session[\"kernel\"][\"id\"],\n", + " user_expressions={\"main_area_widget\": \"create_app(path)\"},\n", + " path=path,\n", " )\n", "\n", "\n", @@ -177,16 +180,8 @@ "metadata": {}, "outputs": [], "source": [ - "# There is a new launcher called 'Start custom app'\n", "t = app.executeCommand(\"launcher:create\")" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/ipylab/asyncwidget.py b/ipylab/asyncwidget.py index ab98867a..df57bc64 100644 --- a/ipylab/asyncwidget.py +++ b/ipylab/asyncwidget.py @@ -134,11 +134,11 @@ class AsyncWidgetBase(WidgetBase): """The base for all widgets that need async comms with the frontend model.""" kernelId = Unicode(read_only=True).tag(sync=True) # noqa: N815 + _async_widget_base_init_complete = False _ipylab_model_register: ClassVar[dict[str, Any]] = {} _singleton_register: ClassVar[dict[str, str]] = {} SINGLETON = False _ready_response = Instance(Response, ()) - _model_id = None _pending_operations: Dict[str, Response] = Dict() _tasks: Container[set[asyncio.Task]] = Set() _comm = None @@ -155,13 +155,15 @@ def __new__(cls, *, model_id=None, **kwgs): return super().__new__(cls, model_id=model_id, **kwgs) def __init__(self, *, model_id=None, **kwgs): - if self._model_id: + if self._async_widget_base_init_complete: return super().__init__(model_id=model_id, **kwgs) + assert self.model_id # noqa: S101 self._ipylab_model_register[self.model_id] = self if self.SINGLETON: self._singleton_register[self.__class__.__name__] = self.model_id self.on_msg(self._on_frontend_msg) + self._async_widget_base_init_complete = True async def __aenter__(self): if not self._ready_response.is_set(): @@ -172,7 +174,7 @@ async def __aexit__(self, exc_type, exc, tb): pass def close(self): - self._ipylab_model_register.pop(self._model_id, None) # type: ignore + self._ipylab_model_register.pop(self.model_id, None) # type: ignore for task in self._tasks: task.cancel() super().close() @@ -228,20 +230,19 @@ async def _wait_response_check_error(self, response: Response, content: dict, ca def _on_frontend_msg(self, _, content: dict, buffers: list): error = self._check_get_error(content) + if error: + pm.hook.on_frontend_error(obj=self, error=error, content=content, buffers=buffers) if operation := content.get("operation"): ipylab_backend = content.get("ipylab_BE", "") payload = content.get("payload", {}) if ipylab_backend: self._pending_operations.pop(ipylab_backend).set(payload, error) - ipylab_frontend = content.get("ipylab_FE", "") - if ipylab_frontend: + if "ipylab_FE" in content: task = asyncio.create_task( - self._handle_frontend_operation(ipylab_frontend, operation, payload, buffers) + self._handle_frontend_operation(content["ipylab_FE"], operation, payload, buffers) ) self._tasks.add(task) task.add_done_callback(self._tasks.discard) - if error: - pm.hook.on_frontend_error(obj=self, error=error, content=content, buffers=buffers) elif "init" in content: self._ready_response.set(content) diff --git a/ipylab/commands.py b/ipylab/commands.py index 767654ce..b158a633 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -22,14 +22,9 @@ class CommandPalette(AsyncWidgetBase): _model_name = Unicode("CommandPaletteModel").tag(sync=True) items = Tuple(read_only=True).tag(sync=True) - def add_item(self, command_id: str, category, *, rank=None, args: dict | None = None, **kwgs) -> asyncio.Task: + def add_item(self, command_id: str, category: str, *, rank=None, args: dict | None = None, **kwgs) -> asyncio.Task: return self.schedule_operation( - operation="addItem", - id=command_id, - category=category, - rank=rank, - args=args, - **kwgs, + operation="addItem", id=command_id, category=category, rank=rank, args=args, **kwgs ) def remove_item(self, command_id: str, category) -> asyncio.Task: diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index edbc5a35..5dc7c1ef 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -206,19 +206,19 @@ async def _execEval(self, payload: dict, buffers: list) -> Any: # TODO: consider if globals / locals / async scope should be supported. code = payload.get("code") user_expressions = payload.get("user_expressions") or {} - glbls = payload | {"buffers": buffers} + locals_ = payload | {"buffers": buffers} if code: - exec(code, glbls) # noqa: S102 + exec(code, None, locals_) # noqa: S102 if user_expressions: results = {} for name, expression in user_expressions.items(): - result = eval(expression, glbls) # noqa: S307 + result = eval(expression, None, locals_) # noqa: S307 if callable(result): result = result() if inspect.isawaitable(result): result = await result results[name] = result - return + return results return None def startIyplabPythonBackend(self) -> asyncio.Task: diff --git a/ipylab/main_area.py b/ipylab/main_area.py index 6309099a..c35d482d 100644 --- a/ipylab/main_area.py +++ b/ipylab/main_area.py @@ -74,7 +74,7 @@ def __new__(cls, *, name: str, model_id=None, content: Panel | None = None, **kw return super().__new__(cls, name=name, **kwgs) def __init__(self, *, name: str, path="", model_id=None, content: Panel | None = None, **kwgs): - if self._model_id: + if self._async_widget_base_init_complete: return path_ = str(pathlib.PurePosixPath(path or name)).lower().strip("/") if path and path != path_: @@ -136,19 +136,14 @@ def unload(self) -> asyncio.Task: self.set_trait("status", ViewStatus.unloading) return self.schedule_operation("unload") - def load_console( - self, - *, - name="Console", - mode: InsertMode = InsertMode.split_bottom, - **kwgs, - ) -> asyncio.Task: + def load_console(self, *, mode: InsertMode = InsertMode.split_bottom, **kwgs) -> asyncio.Task: """Load a console using for the same kernel. Opening the console will close any existing consoles. """ self.set_trait("console_status", ViewStatus.loading) - return self.schedule_operation("open_console", insertMode=InsertMode(mode), name=name, **kwgs) + kwgs = {"name": self.name, "path": self.path} | kwgs + return self.schedule_operation("open_console", insertMode=InsertMode(mode), **kwgs) # type: ignore def unload_console(self) -> asyncio.Task: """Unload the console.""" diff --git a/pyproject.toml b/pyproject.toml index bcfcef0e..a06dbdb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dynamic = ["version", "description", "authors", "urls", "keywords"] dependencies = [ "jupyterlab~=4.1", "ipywidgets~=8.1", + "jupyterlab_widgets>=3.0.11", "pluggy~=1.1", "backports.strenum; python_version < '3.11'", ] diff --git a/src/plugin.ts b/src/plugin.ts index f55382a2..01f0840b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -72,7 +72,7 @@ async function activate( registry.registerWidget(widgetExports.IpylabModel.exports); } - widgetExports.IpylabModel.python_backend.checkStart(); + widgetExports.IpylabModel.pythonBackend.checkStart(); } export default extension; diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts index 07dea93b..86d4f4d7 100644 --- a/src/widgets/frontend.ts +++ b/src/widgets/frontend.ts @@ -128,10 +128,7 @@ export class JupyterFrontEndModel extends IpylabModel { payload.manager = IpylabModel.defaultBrowser.model.manager; return await FileDialog.getExistingDirectory(payload).then(_get_result); case 'newSession': - result = await newSession({ - rendermime: IpylabModel.rendermime.clone(), - ...payload - }); + result = await newSession(payload); return result.model as any; case 'newNotebook': result = await newNotebook(payload); @@ -143,7 +140,7 @@ export class JupyterFrontEndModel extends IpylabModel { jfem = await this.getJupyterFrontEndModel(payload); return await jfem.scheduleOperation('execEval', payload); case 'startIyplabPythonBackend': - return (await IpylabModel.python_backend.checkStart()) as any; + return (await IpylabModel.pythonBackend.checkStart()) as any; case 'shutdownKernel': if (payload.kernelId) { await IpylabModel.app.commands.execute('kernelmenu:shutdown', { @@ -209,7 +206,9 @@ export class JupyterFrontEndModel extends IpylabModel { kernel = this.app.serviceManager.kernels.connectTo({ model: model }); } else { if (payload.kernelId) { - throw new Error(`Kernel does not exist: ${payload.kernelId}`); + throw new Error( + `A kernel does not exist for the kernelId= '${payload.kernelId}'` + ); } const session = await newSession(payload); kernel = session.kernel; diff --git a/src/widgets/ipylab.ts b/src/widgets/ipylab.ts index 54c73079..314a29eb 100644 --- a/src/widgets/ipylab.ts +++ b/src/widgets/ipylab.ts @@ -22,6 +22,7 @@ import { JSONObject, JSONValue, UUID } from '@lumino/coreutils'; import { ObjectHash } from 'backbone'; import { MODULE_NAME, MODULE_VERSION } from '../version'; import { PythonBackendModel } from './python_backend'; +import { PromiseDelegate } from '@lumino/coreutils'; import { getNestedObject, listAttributes, @@ -49,7 +50,6 @@ export class IpylabModel extends DOMWidgetModel { super.initialize(attributes, options); this._kernelId = (this.widget_manager as any).kernel.id; 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`; @@ -73,25 +73,23 @@ export class IpylabModel extends DOMWidgetModel { /** * Convert custom messages into operations for action. * There are two types: - * 1. Response to requested operation sent to Python backend. + * 1. Response to requested operation sent to Python backend (ipylab_FE). * 2. Operation requests received from the Python backend (ipylab_BE). * @param msg */ - private async _onCustomMessage(msg: any): Promise { - const ipylab_FE: string = msg.ipylab_FE; - if (ipylab_FE) { + private _onCustomMessage(msg: any) { + if (msg.ipylab_FE) { // Frontend operation result - delete (msg as any).ipylab_FE; - const [resolve, reject] = - this._pending_backend_operation_callbacks.get(ipylab_FE); - this._pending_backend_operation_callbacks.delete(ipylab_FE); - if (msg.error) { - reject(msg.error); - } else { - resolve(msg.payload); + const opDone = this._pendingBackendOperations.get(msg.ipylab_FE); + this._pendingBackendOperations.delete(msg.ipylab_FE); + if (opDone) { + if (msg.error) { + opDone.reject(msg.error); + } else { + opDone.resolve(msg.payload); + } } - } else { - // Backend operation (don't await it) + } else if (msg.ipylab_BE) { this._do_operation_for_backend(msg); } } @@ -226,12 +224,10 @@ export class IpylabModel extends DOMWidgetModel { }; // Create callbacks to be resolved when a custom message is received // with the key `ipylab_FE`. - const callbacks = this._pending_backend_operation_callbacks; - const promise = new Promise((resolve, reject) => { - callbacks.set(ipylab_FE, [resolve, reject]); - }); + const opDone = new PromiseDelegate(); + this._pendingBackendOperations.set(ipylab_FE, opDone); this.send(msg); - const result: any = await promise; + const result: any = await opDone.promise; return await transformObject(result, transform ?? 'raw', this); } @@ -248,6 +244,8 @@ export class IpylabModel extends DOMWidgetModel { } close(comm_closed?: boolean): Promise { + this._pendingBackendOperations.forEach(opDone => opDone.reject('Closed')); + this._pendingBackendOperations.clear(); comm_closed = comm_closed || !this.kernelLive; return super.close(comm_closed); } @@ -262,9 +260,9 @@ export class IpylabModel extends DOMWidgetModel { ...WidgetModel.serializers }; - private _pending_backend_operation_callbacks: Map; + private _pendingBackendOperations = new Map>(); private _kernelId: string; - static python_backend = new PythonBackendModel(); + static pythonBackend = new PythonBackendModel(); static model_name: string; static model_module = MODULE_NAME; static model_module_version = MODULE_VERSION; diff --git a/src/widgets/python_backend.ts b/src/widgets/python_backend.ts index 1843fa8c..d710951e 100644 --- a/src/widgets/python_backend.ts +++ b/src/widgets/python_backend.ts @@ -12,7 +12,6 @@ export class PythonBackendModel { this._backendSession = await newSession({ path: 'Ipylab backend', name: 'Ipylab backend', - rendermime: IpylabModel.rendermime.clone(), language: 'python3', code: 'import ipylab.scripts; ipylab.scripts.init_ipylab_backend()' }); @@ -29,7 +28,7 @@ export class PythonBackendModel { '[project.entry-points.ipylab-python-backend] \n' + '\tmyproject = "myproject.pluginmodule"', - execute: () => IpylabModel.python_backend.checkStart() + execute: () => IpylabModel.pythonBackend.checkStart() } ); if (this._palletItem) { diff --git a/src/widgets/utils.ts b/src/widgets/utils.ts index 47b5b4d6..90f63853 100644 --- a/src/widgets/utils.ts +++ b/src/widgets/utils.ts @@ -1,13 +1,13 @@ // Copyright (c) ipylab contributors // Distributed under the terms of the Modified BSD License. -import { registerWidgetManager } from '@jupyter-widgets/jupyterlab-manager'; +import { KernelWidgetManager } from '@jupyter-widgets/jupyterlab-manager'; import { SessionContext } from '@jupyterlab/apputils'; import { ObservableMap } from '@jupyterlab/observables'; +import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { Kernel, Session } from '@jupyterlab/services'; import { UUID } from '@lumino/coreutils'; import { Signal } from '@lumino/signaling'; import { IpylabModel, JSONObject, JSONValue } from './ipylab'; - /** * Start a new session that support comms needed for iplab needs for comms. * @returns @@ -22,7 +22,7 @@ export async function newSession({ }: { name: string; path: string; - rendermime: any; + rendermime?: IRenderMimeRegistry; kernelId?: string; language?: string; code?: string; @@ -41,18 +41,11 @@ export async function newSession({ await sessionContext.initialize(); await sessionContext.ready; - // For the moment we'll use a dummy session. - // In future it might be better to support a document... - const session = sessionContext.session; - const context = {}; - (context as any)['sessionContext'] = sessionContext; - (context as any)['saveState'] = new Signal({}); - (context as any).saveState.connect(() => { - null; - }); - registerWidgetManager(context as any, rendermime, [] as any); + // Create a manager for the kernel. It will stay alive for the life of the kernel. + // Requires https://github.com/jupyter-widgets/ipywidgets/pull/3922 to be adopted. + new KernelWidgetManager(sessionContext.session.kernel, rendermime as any); if (code) { - const future = session.kernel.requestExecute( + const future = sessionContext.session.kernel.requestExecute( { code: code, store_history: false @@ -62,7 +55,7 @@ export async function newSession({ await future.done; future.dispose(); } - return session; + return sessionContext.session; } export async function newNotebook({