From d843521153319ee2560ff657f6747cb5f7df2f74 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 16 Mar 2025 19:30:08 +1100 Subject: [PATCH 01/47] Refactor app references to use ipylab.App() instead of ipylab.app across examples and tests. Moved Singular functionality from Ipylab to a new class Singular. --- examples/code_editor.ipynb | 6 +- examples/commands.ipynb | 4 +- examples/css_stylesheet.ipynb | 2 +- examples/dialogs.ipynb | 2 +- examples/generic.ipynb | 2 +- examples/icons.ipynb | 12 ++-- examples/ipytree.ipynb | 2 +- examples/menu.ipynb | 2 +- examples/notifications.ipynb | 2 +- examples/plugins.ipynb | 2 +- examples/resize_box.ipynb | 4 +- examples/sessions.ipynb | 2 +- examples/simple_output.ipynb | 17 +++-- ipylab/__init__.py | 1 - ipylab/code_editor.py | 9 +-- ipylab/commands.py | 33 +++++----- ipylab/common.py | 116 ++++++++++++++++++++++++++++++---- ipylab/connection.py | 83 +++++++++--------------- ipylab/css_stylesheet.py | 2 +- ipylab/ipylab.py | 101 ++++++++++++----------------- ipylab/jupyterfrontend.py | 18 ++---- ipylab/launcher.py | 6 +- ipylab/log.py | 20 ++++-- ipylab/log_viewer.py | 26 ++++---- ipylab/menu.py | 24 +++---- ipylab/notification.py | 16 ++--- ipylab/sessions.py | 6 +- ipylab/shell.py | 35 ++++------ ipylab/widgets.py | 13 ++-- src/widgets/frontend.ts | 3 +- tests/conftest.py | 2 +- tests/test_common.py | 33 ++++++++++ tests/test_jupyterfrontend.py | 13 ++-- tests/test_log.py | 20 +++--- 34 files changed, 359 insertions(+), 280 deletions(-) diff --git a/examples/code_editor.ipynb b/examples/code_editor.ipynb index 45bd1916..cd0a009f 100644 --- a/examples/code_editor.ipynb +++ b/examples/code_editor.ipynb @@ -51,7 +51,7 @@ " mime_type=\"text/x-python\",\n", " description=\"Code editor\",\n", " tooltip=\"This is a code editor. Code completion is provided for Python\",\n", - " value=\"def test():\\n ipylab.app.notification.notify('CodeEditor evaluation')\\n\\n# Place the cursor in the CodeEditor and press `Shift Enter`\\ntest()\",\n", + " value=\"def test():\\n app.notification.notify('CodeEditor evaluation')\\n\\n# Place the cursor in the CodeEditor and press `Shift Enter`\\ntest()\",\n", " layout={\"height\": \"120px\", \"overflow\": \"hidden\"},\n", " description_allow_html=True,\n", ")\n", @@ -180,7 +180,7 @@ "outputs": [], "source": [ "# Add the same editor to the shell.\n", - "ipylab.app.shell.add(ce)" + "ce.app.shell.add(ce)" ] }, { @@ -230,7 +230,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/examples/commands.ipynb b/examples/commands.ipynb index a4d181e4..27059ecc 100644 --- a/examples/commands.ipynb +++ b/examples/commands.ipynb @@ -24,7 +24,7 @@ "source": [ "import ipylab\n", "\n", - "app = ipylab.app\n", + "app = ipylab.App()\n", "app.commands" ] }, @@ -142,7 +142,7 @@ "\n", "import ipylab\n", "\n", - "app = ipylab.app" + "app = ipylab.App()" ] }, { diff --git a/examples/css_stylesheet.ipynb b/examples/css_stylesheet.ipynb index df732f29..9a9a50ec 100644 --- a/examples/css_stylesheet.ipynb +++ b/examples/css_stylesheet.ipynb @@ -38,7 +38,7 @@ "import ipylab\n", "from ipylab.css_stylesheet import CSSStyleSheet\n", "\n", - "app = ipylab.app" + "app = ipylab.App()" ] }, { diff --git a/examples/dialogs.ipynb b/examples/dialogs.ipynb index aca38339..03de1ff3 100644 --- a/examples/dialogs.ipynb +++ b/examples/dialogs.ipynb @@ -46,7 +46,7 @@ "\n", "import ipylab\n", "\n", - "app = ipylab.app" + "app = ipylab.App()" ] }, { diff --git a/examples/generic.ipynb b/examples/generic.ipynb index b4a058a0..7b5d0b8a 100644 --- a/examples/generic.ipynb +++ b/examples/generic.ipynb @@ -98,7 +98,7 @@ "source": [ "import ipylab\n", "\n", - "app = ipylab.app\n", + "app = ipylab.App()\n", "\n", "t = app.list_properties(\"shell\", depth=2)" ] diff --git a/examples/icons.ipynb b/examples/icons.ipynb index 89ad91ba..2bbcefa9 100644 --- a/examples/icons.ipynb +++ b/examples/icons.ipynb @@ -48,7 +48,9 @@ "import ipywidgets as ipw\n", "import traitlets\n", "\n", - "import ipylab" + "import ipylab\n", + "\n", + "app = ipylab.App()" ] }, { @@ -240,7 +242,7 @@ }, "outputs": [], "source": [ - "t = ipylab.app.commands.add_command(\n", + "t = app.commands.add_command(\n", " \"randomize\",\n", " randomize_icon,\n", " label=\"Randomize My Icon\",\n", @@ -265,7 +267,7 @@ "metadata": {}, "outputs": [], "source": [ - "assert cmd in ipylab.app.commands.connections # noqa: S101" + "assert cmd in app.commands.connections # noqa: S101" ] }, { @@ -285,7 +287,7 @@ }, "outputs": [], "source": [ - "t = ipylab.app.command_pallet.add(cmd, \"All My Commands\", rank=100)" + "t = app.command_pallet.add(cmd, \"All My Commands\", rank=100)" ] }, { @@ -321,7 +323,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/examples/ipytree.ipynb b/examples/ipytree.ipynb index f5c4f522..8ab3658e 100644 --- a/examples/ipytree.ipynb +++ b/examples/ipytree.ipynb @@ -35,7 +35,7 @@ "\n", "import ipylab\n", "\n", - "app = ipylab.app" + "app = ipylab.App()" ] }, { diff --git a/examples/menu.ipynb b/examples/menu.ipynb index 8348fe98..2fc38fb3 100644 --- a/examples/menu.ipynb +++ b/examples/menu.ipynb @@ -30,7 +30,7 @@ "\n", "import ipylab\n", "\n", - "app = ipylab.app" + "app = ipylab.App()" ] }, { diff --git a/examples/notifications.ipynb b/examples/notifications.ipynb index f8150634..6d57bebb 100644 --- a/examples/notifications.ipynb +++ b/examples/notifications.ipynb @@ -34,7 +34,7 @@ "\n", "import ipylab\n", "\n", - "app = ipylab.app" + "app = ipylab.App()" ] }, { diff --git a/examples/plugins.ipynb b/examples/plugins.ipynb index 376634c8..31622734 100644 --- a/examples/plugins.ipynb +++ b/examples/plugins.ipynb @@ -42,7 +42,7 @@ "\n", "import ipylab.hookspecs\n", "\n", - "app = ipylab.app\n", + "app = ipylab.App()\n", "\n", "display(ipd.Markdown(\"## Plugins\\n\\nThe following plugins (*hookspecs*) are available.\"))\n", "for n in dir(ipylab.hookspecs):\n", diff --git a/examples/resize_box.ipynb b/examples/resize_box.ipynb index a06bce1a..f7aeadcb 100644 --- a/examples/resize_box.ipynb +++ b/examples/resize_box.ipynb @@ -141,6 +141,8 @@ "import ipylab\n", "from ipylab.widgets import ResizeBox\n", "\n", + "app = ipylab.App()\n", + "\n", "box = ResizeBox([fig.canvas])\n", "fig.canvas.resizable = False\n", "\n", @@ -174,7 +176,7 @@ "metadata": {}, "outputs": [], "source": [ - "ipylab.app.shell.add(box)" + "ipylab.App().shell.add(box)" ] } ], diff --git a/examples/sessions.ipynb b/examples/sessions.ipynb index 3558fa50..0f793f97 100644 --- a/examples/sessions.ipynb +++ b/examples/sessions.ipynb @@ -31,7 +31,7 @@ "source": [ "import ipylab\n", "\n", - "app = ipylab.app" + "app = ipylab.App()" ] }, { diff --git a/examples/simple_output.ipynb b/examples/simple_output.ipynb index db4d1964..3303cf06 100644 --- a/examples/simple_output.ipynb +++ b/examples/simple_output.ipynb @@ -54,7 +54,9 @@ "outputs": [], "source": [ "import ipylab\n", - "from ipylab.simple_output import SimpleOutput" + "from ipylab.simple_output import SimpleOutput\n", + "\n", + "app = ipylab.App()" ] }, { @@ -297,7 +299,6 @@ "metadata": {}, "outputs": [], "source": [ - "app = ipylab.app\n", "app.log_level = \"DEBUG\"\n", "app.commands.execute(\"Show log viewer\")" ] @@ -348,7 +349,9 @@ "import ipywidgets as ipw\n", "\n", "import ipylab\n", - "from ipylab.simple_output import AutoScroll" + "from ipylab.simple_output import AutoScroll\n", + "\n", + "app = ipylab.App()" ] }, { @@ -382,7 +385,7 @@ " vb.children = (*vb.children, ipw.HTML(f\"It is now {datetime.now().isoformat()}\")) # noqa: DTZ005\n", " await asyncio.sleep(sleep.value)\n", "\n", - " b.task = ipylab.app.to_task(generate_output())\n", + " b.task = app.to_task(generate_output())\n", " b.description = \"Stop\"\n", " else:\n", " b.task.cancel()\n", @@ -426,7 +429,7 @@ "* coroutines are awaited automatically\n", "* Type hints\n", "* Execution (Shift Enter)\n", - "* stdio captured during execution\n", + "* stdio captured during execution, but only output once execution completes\n", "\n", "## Not implemented\n", "* Ipython magic\n", @@ -470,7 +473,7 @@ " dynamic=[\"children\"],\n", " )\n", " output = Fixed(SimpleOutput)\n", - " scroll = Fixed(AutoScroll, content=lambda parent: parent.output, dynamic=[\"content\"])\n", + " scroll = Fixed(AutoScroll, create=lambda config: AutoScroll(content=config[\"owner\"].output))\n", "\n", " def __init__(self, namespace_id: str, **kwgs):\n", " self.prompt.namespace_id = namespace_id\n", @@ -495,7 +498,7 @@ " else:\n", " self.output.push(result)\n", " except Exception:\n", - " text = ipylab.app.logging_handler.formatter.formatException(sys.exc_info()) # type: ignore\n", + " text = app.logging_handler.formatter.formatException(sys.exc_info()) # type: ignore\n", " self.output.push({\"output_type\": \"stream\", \"name\": \"stderr\", \"text\": text})" ] }, diff --git a/ipylab/__init__.py b/ipylab/__init__.py index bf4f7e24..0c91b5d3 100644 --- a/ipylab/__init__.py +++ b/ipylab/__init__.py @@ -61,4 +61,3 @@ def _get_plugin_manager(): plugin_manager = _get_plugin_manager() del _get_plugin_manager -app = App() diff --git a/ipylab/code_editor.py b/ipylab/code_editor.py index 0aa8f509..c7bbf758 100644 --- a/ipylab/code_editor.py +++ b/ipylab/code_editor.py @@ -7,7 +7,7 @@ import inspect import typing from asyncio import Task -from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, override +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast, override from IPython.core import completer as IPC # noqa: N812 from IPython.utils.tokenutil import token_at_cursor @@ -42,6 +42,7 @@ class IpylabCompleter(IPC.IPCompleter): code_editor: Instance[CodeEditor] = Instance("ipylab.CodeEditor") + app = Fixed(cast(type["ipylab.App"], "ipylab.App")) if TYPE_CHECKING: shell: InteractiveShell # Set in IPV.IPCompleter.__init__ namespace: LastUpdatedDict @@ -61,7 +62,7 @@ def _default_disable_matchers(self): ] def update_namespace(self): - self.namespace = ipylab.app.get_namespace(self.code_editor.namespace_id) + self.namespace = self.app.get_namespace(self.code_editor.namespace_id) def do_complete(self, code: str, cursor_pos: int): """Completions provided by IPython completer, using Jedi for different namespaces.""" @@ -152,7 +153,7 @@ async def evaluate(self, code: str): if wait or inspect.iscoroutine(result): result = await result if not self.code_editor.namespace_id: - ipylab.app.shell.add_objects_to_ipython_namespace(ns) + self.app.shell.add_objects_to_ipython_namespace(ns) except SyntaxError: exec(code, ns, ns) # noqa: S102 return next(reversed(ns.values())) @@ -230,7 +231,7 @@ def _default_key_bindings(self): "evaluate": ["Shift Enter"], "undo": ["Ctrl Z"], "redo": ["Ctrl Shift Z"], - } | ipylab.plugin_manager.hook.default_editor_key_bindings(app=ipylab.app, obj=self) + } | ipylab.plugin_manager.hook.default_editor_key_bindings(app=self.app, obj=self) @default("evaluate") def _default_evaluate(self): diff --git a/ipylab/commands.py b/ipylab/commands.py index 8ef1fe03..2dd97f28 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -13,7 +13,7 @@ from traitlets import Container, Dict, Instance, Tuple, Unicode import ipylab -from ipylab.common import IpylabKwgs, Obj, TaskHooks, TaskHookType, TransformType, pack +from ipylab.common import IpylabKwgs, Obj, Singular, TaskHooks, TaskHookType, TransformType, pack from ipylab.connection import InfoConnection, ShellConnection from ipylab.ipylab import Ipylab, IpylabBase, Transform, register from ipylab.widgets import Icon @@ -98,7 +98,7 @@ async def add_key_binding(): args_ = args | { "keys": keys, "preventDefault": prevent_default, - "selector": selector or ipylab.app.selector, + "selector": selector or self.app.selector, "command": str(self), } cid = KeybindingConnection.to_cid(self) @@ -124,14 +124,12 @@ def to_cid(cls, command: CommandConnection, category: str): return super().to_cid(str(command), category) -class CommandPalette(Ipylab): +class CommandPalette(Singular, Ipylab): """ https://jupyterlab.readthedocs.io/en/latest/api/interfaces/apputils.ICommandPalette.html """ - SINGLE = True - ipylab_base = IpylabBase(Obj.IpylabModel, "palette").tag(sync=True) info = Dict(help="info about the item") @@ -181,9 +179,7 @@ def add( @register -class CommandRegistry(Ipylab): - SINGLE = True - +class CommandRegistry(Singular, Ipylab): _model_name = Unicode("CommandRegistryModel").tag(sync=True) ipylab_base = IpylabBase(Obj.IpylabModel, "").tag(sync=True) name = Unicode(APP_COMMANDS_NAME, read_only=True).tag(sync=True) @@ -192,8 +188,8 @@ class CommandRegistry(Ipylab): @classmethod @override - def _single_key(cls, kwgs: dict): - return cls, kwgs["name"] + def get_single_key(cls, name: str, **kwgs): + return name @classmethod def _check_belongs_to_application_registry(cls, cid: str): @@ -221,14 +217,15 @@ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffer return await super()._do_operation_for_frontend(operation, payload, buffers) async def _execute_for_frontend(self, payload: dict, buffers: list): - conn = InfoConnection.get_existing_connection(payload["id"], quiet=True) - if not isinstance(conn, CommandConnection): - msg = f'Invalid command "{payload["id"]} {conn=}"' + cmd_cid = payload["id"] + if not CommandConnection.exists(cmd_cid): + msg = f'Invalid command "{cmd_cid}"' raise TypeError(msg) + conn = CommandConnection(cmd_cid) cmd = conn.python_command args = conn.args | (payload.get("args") or {}) - ns = ipylab.app.get_namespace(conn.namespace_id) + ns = self.app.get_namespace(conn.namespace_id) kwgs = {} for n, p in inspect.signature(cmd).parameters.items(): if n == "ref": @@ -295,9 +292,9 @@ def add_command( """ async def add_command(): - cid = CommandConnection.to_cid(self.name, ipylab.app.vpath, name) - if cmd := CommandConnection.get_existing_connection(cid, quiet=True): - await cmd.ready() + cid = CommandConnection.to_cid(self.name, self.app.vpath, name) + if CommandConnection.exists(cid): + cmd = await CommandConnection(cid).ready() cmd.close() kwgs_ = kwgs | { "id": cid, @@ -348,7 +345,7 @@ def execute(self, command_id: str | CommandConnection, args: dict | None = None, async def execute_command(): id_ = str(command_id) if id_ not in self.all_commands: - id_ = CommandConnection.to_cid(self.name, ipylab.app.vpath, id_) + id_ = CommandConnection.to_cid(self.name, self.app.vpath, id_) if id_ not in self.all_commands: msg = f"Command '{command_id}' not registered!" raise ValueError(msg) diff --git a/ipylab/common.py b/ipylab/common.py index ec89ea2d..c2e354d2 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -3,17 +3,30 @@ from __future__ import annotations +import importlib import inspect import typing import weakref from collections import OrderedDict from collections.abc import Awaitable, Callable from enum import StrEnum -from typing import TYPE_CHECKING, Any, Generic, Literal, NotRequired, TypedDict, TypeVar, override +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Generic, + Literal, + NotRequired, + Self, + TypedDict, + TypeVar, + override, +) import pluggy from ipywidgets import Widget, widget_serialization -from traitlets import HasTraits +from traitlets import Any as AnyTrait +from traitlets import Bool, HasTraits import ipylab @@ -40,11 +53,9 @@ T = TypeVar("T") if TYPE_CHECKING: - from collections.abc import Awaitable, Callable + from collections.abc import Awaitable, Callable, Hashable from typing import overload - from traitlets import HasTraits - from ipylab.ipylab import Ipylab @overload @@ -85,6 +96,16 @@ def to_selector(*args, prefix="ipylab"): return f".{prefix}-{suffix}" +def import_item(dottedname: str): + """Import an item from a module, given its dotted name. + + For example: + >>> import_item("os.path.join") + """ + modulename, objname = dottedname.rsplit(".", maxsplit=1) + return getattr(importlib.import_module(modulename), objname) + + class Obj(StrEnum): "The objects available to use as 'obj' in the frontend." @@ -208,9 +229,7 @@ def transform_payload(cls, transform: TransformType, payload): mappings = typing.cast(TransformDictAdvanced, transform)["mappings"] return {key: cls.transform_payload(mappings[key], payload[key]) for key in mappings} case Transform.connection | Transform.auto if isinstance(payload, dict) and (cid := payload.get("cid")): - conn = ipylab.Connection(cid) - conn._check_closed() # noqa: SLF001 - return conn + return ipylab.Connection.get_connection(cid) return payload @@ -298,6 +317,76 @@ def update(self, m, **kwargs): self._updating = False +class Singular(HasTraits): + """A base class that ensures only one instance of a class exists for each unique key. + + This class uses a class-level dictionary `_single_instances` to store instances, + keyed by a value obtained from the `get_single_key` method. Subsequent calls to + the constructor with the same key will return the existing instance. + + Attributes: + _limited_init_complete (bool): A flag to prevent multiple initializations. + _single_instances (dict[Hashable, Self]): A class-level dictionary storing the single instances. + _single_key (AnyTrait): A read-only trait storing the key for the instance. + closed (Bool): A read-only trait indicating whether the instance has been closed. + + Methods: + get_single_key(*args, **kwgs) -> Hashable: + A class method that returns the key used to identify the single instance. + Defaults to returning the class itself. Subclasses should override this + method to provide a key based on the constructor arguments. + + __new__(cls, /, *args, **kwgs) -> Self: + Overrides the default `__new__` method to implement the singleton behavior. + It retrieves the key using `get_single_key`, and either returns an existing + instance from `_single_instances` or creates a new instance and stores it. + + __init__(self, /, *args, **kwgs): + Overrides the default `__init__` method to prevent multiple initializations + of the same instance. It only calls the superclass's `__init__` method once. + + __init_subclass__(cls) -> None: + Overrides the default `__init_subclass__` method to reset the `_single_instances` + dictionary for each subclass. + + close(self): + Removes the instance from the `_single_instances` dictionary and calls the + `close` method of the superclass, if it exists. Sets the `closed` trait to True. + """ + + _limited_init_complete = False + _single_instances: ClassVar[dict[Hashable, Self]] = {} + _single_key = AnyTrait(read_only=True) + closed = Bool(read_only=True) + + @classmethod + def get_single_key(cls, *args, **kwgs) -> Hashable: # noqa: ARG003 + return cls + + def __new__(cls, /, *args, **kwgs) -> Self: + key = cls.get_single_key(*args, **kwgs) + if key not in cls._single_instances: + new = super().__new__ + cls._single_instances[key] = inst = new(cls) if new is object.__new__ else new(cls, *args, **kwgs) + inst.set_trait("_single_key", key) + return cls._single_instances[key] + + def __init__(self, /, *args, **kwgs): + if self._limited_init_complete: + return + super().__init__(*args, **kwgs) + self._limited_init_complete = True + + def __init_subclass__(cls) -> None: + cls._single_instances = {} + + def close(self): + self._single_instances.pop(self._single_key, None) + if callable(close := getattr(super(), "close", None)): + close() + self.set_trait("closed", True) + + class FixedCreate(Generic[T], TypedDict): "A TypedDict relevant to Fixed" @@ -321,7 +410,7 @@ class Fixed(Generic[T]): def __init__( self, - klass: type[T], + klass: type[T] | str, *args, dynamic: list[str] | None = None, create: Callable[[FixedCreate[T]], T] | str = "", @@ -375,6 +464,7 @@ def __get__(self, obj, objtype=None) -> T: if obj is None: return self # type: ignore if obj not in self.instances: + klass = import_item(self.klass) if isinstance(self.klass, str) else self.klass kwgs = self.kwgs if self.dynamic: kwgs = kwgs.copy() @@ -382,13 +472,13 @@ def __get__(self, obj, objtype=None) -> T: kwgs[k] = kwgs[k](obj) if self.create: create = getattr(obj, self.create) if isinstance(self.create, str) else self.create - kw = FixedCreate(name=self.name, klass=self.klass, owner=obj, args=self.args, kwgs=kwgs) - instance = create(kw) - if not isinstance(instance, self.klass): + kw = FixedCreate(name=self.name, klass=klass, owner=obj, args=self.args, kwgs=kwgs) + instance = create(kw) # type: ignore + if not isinstance(instance, klass): msg = f"Expected {self.klass} but {create=} returned {type(instance)}" raise TypeError(msg) else: - instance = self.klass(*self.args, **kwgs) + instance = klass(*self.args, **kwgs) self.instances[obj] = instance try: if self.created: diff --git a/ipylab/connection.py b/ipylab/connection.py index 13513c2e..ff276cd9 100644 --- a/ipylab/connection.py +++ b/ipylab/connection.py @@ -4,22 +4,22 @@ from __future__ import annotations import uuid -import weakref -from typing import TYPE_CHECKING, Any, ClassVar, override +from typing import TYPE_CHECKING, ClassVar, override from ipywidgets import Widget, register -from traitlets import Bool, Dict, Instance, Unicode, observe +from traitlets import Bool, Dict, Instance, Unicode +from ipylab.common import Singular from ipylab.ipylab import Ipylab if TYPE_CHECKING: from asyncio import Task - from collections.abc import Generator - from typing import Literal, Self, overload + from collections.abc import Hashable + from typing import Self @register -class Connection(Ipylab): +class Connection(Singular, Ipylab): """This class provides a connection to an object in the frontend. `Connection` and subclasses of `Connection` are used extensiviely in ipylab @@ -46,7 +46,6 @@ class Connection(Ipylab): _SEP = "|" prefix: ClassVar = f"{_PREFIX}Connection{_SEP}" - _connections: weakref.WeakValueDictionary[str, Self] = weakref.WeakValueDictionary() _model_name = Unicode("ConnectionModel").tag(sync=True) cid = Unicode(read_only=True, help="connection id").tag(sync=True) _dispose = Bool(read_only=True).tag(sync=True) @@ -54,29 +53,26 @@ class Connection(Ipylab): auto_dispose = Bool(False, read_only=True, help="Dispose of the object in frontend when closed.").tag(sync=True) + @override + @classmethod + def get_single_key(cls, cid: str, **kwgs) -> Hashable: + return cid + + @classmethod + def exists(cls, cid: str) -> bool: + return cid in cls._single_instances + def __init_subclass__(cls, **kwargs) -> None: cls.prefix = f"{cls._PREFIX}{cls.__name__}{cls._SEP}" cls._CLASS_DEFINITIONS[cls.prefix.strip(cls._SEP)] = cls super().__init_subclass__(**kwargs) - def __new__(cls, cid: str, **kwgs): - inst = cls._connections.get(cid) - if not inst: - cls = cls._CLASS_DEFINITIONS[cid.split(cls._SEP, maxsplit=1)[0]] - cls._connections[cid] = inst = super().__new__(cls, **kwgs) - return inst - def __init__(self, cid: str, **kwgs): super().__init__(cid=cid, **kwgs) def __str__(self): return self.cid - @property - @override - def repr_info(self): - return {"cid": self.cid} - @classmethod def to_cid(cls, *args: str) -> str: """Generate a cid.""" @@ -90,18 +86,12 @@ def to_cid(cls, *args: str) -> str: args = (str(uuid.uuid4()),) return cls.prefix + cls._SEP.join(args) - @classmethod - def get_instances(cls) -> Generator[Self, Any, None]: - "Get all instances of this class (including subclasses)." - for item in cls._connections.values(): - if isinstance(item, cls): - yield item - - @observe("comm") - def _connection_observe_comm(self, _): - if not self.comm: - self._connections.pop(self.cid, None) + @property + @override + def repr_info(self): + return {"cid": self.cid} + @override def close(self, *, dispose=True): """Permanently close the widget. @@ -110,31 +100,20 @@ def close(self, *, dispose=True): self.set_trait("auto_dispose", dispose) super().close() - if TYPE_CHECKING: - - @overload - @classmethod - def get_existing_connection(cls, cid: str, *, quiet: Literal[False]) -> Self: ... - @overload - @classmethod - def get_existing_connection(cls, cid: str, *, quiet: Literal[True]) -> Self | None: ... - @overload - @classmethod - def get_existing_connection(cls, cid: str) -> Self: ... - @classmethod - def get_existing_connection(cls, cid: str, *, quiet=False): - """Get an existing connection. + def get_connection(cls, cid: str) -> Self: + """Get a connection object from a connection id. + + The connection id is a string that identifies the connection. + It is composed of the connection type and the connection name, separated by a separator. + The connection type is the name of the class that implements the connection. + The connection name is a unique name for the connection. - quiet: bool - True: Raise a value error if the connection does not exist. - False: Return None. + :param cid: The connection id. + :return: The connection object. """ - conn = cls._connections.get(cid) - if not conn and not quiet: - msg = f"A connection does not exist with '{cid=}'" - raise ValueError(msg) - return conn + cls_ = cls._CLASS_DEFINITIONS[cid.split(cls._SEP, maxsplit=1)[0]] + return cls_(cid) Connection._CLASS_DEFINITIONS[Connection.prefix.strip(Connection._SEP)] = Connection # noqa: SLF001 diff --git a/ipylab/css_stylesheet.py b/ipylab/css_stylesheet.py index 9d42a3ce..64c2eeed 100644 --- a/ipylab/css_stylesheet.py +++ b/ipylab/css_stylesheet.py @@ -27,7 +27,7 @@ class CSSStyleSheet(Ipylab): ) def __init__(self, **kwgs): - if self._async_widget_base_init_complete: + if self._limited_init_complete: return super().__init__(**kwgs) self.on_ready(self._restore) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index bf14325d..a403a91a 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -9,7 +9,7 @@ import json import uuid import weakref -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar, cast import traitlets from ipywidgets import Widget, register @@ -45,8 +45,8 @@ if TYPE_CHECKING: from asyncio import Task - from collections.abc import Awaitable, Callable, Hashable - from typing import ClassVar, Self, Unpack + from collections.abc import Awaitable, Callable + from typing import Self, Unpack __all__ = ["Ipylab", "WidgetBase"] @@ -83,33 +83,22 @@ class WidgetBase(Widget): class Ipylab(WidgetBase): """The base class for Ipylab which has a corresponding frontend.""" - SINGLE = False - _model_name = Unicode("IpylabModel", help="Name of the model.", read_only=True).tag(sync=True) _python_class = Unicode().tag(sync=True) ipylab_base = IpylabBase(Obj.this, "").tag(sync=True) _ready = Bool(read_only=True, help="Set to by frontend when ready").tag(sync=True) - _on_ready_callbacks: Container[list[Callable[[], None | Awaitable] | Callable[[Self], None | Awaitable]]] = List( trait=traitlets.Callable() ) - - _async_widget_base_init_complete = False - _single_map: ClassVar[dict[Hashable, str]] = {} # single_key : model_id - _single_models: ClassVar[dict[str, Self]] = {} # model_id : Widget _ready_event: asyncio.Event | None = None _comm = None - + _ipylab_init_complete = False _pending_operations: Dict[str, asyncio.Future] = Dict() _has_attrs_mappings: Container[set[tuple[HasTraits, str]]] = Set() ipylab_tasks: Container[set[asyncio.Task]] = Set() close_extras: Fixed[weakref.WeakSet[Widget]] = Fixed(weakref.WeakSet) log = Instance(IpylabLoggerAdapter, read_only=True) - - @classmethod - def _single_key(cls, kwgs: dict) -> Hashable: # noqa: ARG003 - """The key used for finding instances when SINGLE is enabled.""" - return cls + app = Fixed(cast(type["ipylab.App"], "ipylab.App")) @property def repr_info(self) -> dict[str, Any] | str: @@ -120,31 +109,16 @@ def repr_info(self) -> dict[str, Any] | str: def _default_log(self): return IpylabLoggerAdapter(self.__module__, owner=self) - def __new__(cls, **kwgs) -> Self: - model_id = kwgs.get("model_id") or cls._single_map.get(cls._single_key(kwgs)) if cls.SINGLE else None - if model_id and model_id in cls._single_models: - return cls._single_models[model_id] - return super().__new__(cls) - def __init__(self, **kwgs): - if self._async_widget_base_init_complete: + if self._ipylab_init_complete: return - # set traits, including read only traits. - model_id = kwgs.pop("model_id", None) for k in kwgs: if self.has_trait(k): self.set_trait(k, kwgs[k]) self.set_trait("_python_class", self.__class__.__name__) - super().__init__(model_id=model_id) if model_id else super().__init__() - model_id = self.model_id - if not model_id: - msg = "Failed to init comms" - raise RuntimeError(msg) - if key := self._single_key(kwgs) if self.SINGLE else None: - self._single_map[key] = model_id - self._single_models[model_id] = self + super().__init__() + self._ipylab_init_complete = True self.on_msg(self._on_custom_msg) - self._async_widget_base_init_complete = True def __repr__(self): if not self._repr_mimebundle_: @@ -165,21 +139,7 @@ def __repr__(self): @observe("comm", "_ready") def _observe_comm(self, change: dict): if not self.comm: - for task in self.ipylab_tasks: - task.cancel() - self.ipylab_tasks.clear() - for item in list(self.close_extras): - item.close() - for obj, name in list(self._has_attrs_mappings): - if val := getattr(obj, name, None): - if val is self: - with contextlib.suppress(TraitError): - obj.set_trait(name, None) - elif isinstance(val, tuple): - obj.set_trait(name, tuple(v for v in val if v.comm)) - self._on_ready_callbacks.clear() - if self.SINGLE: - self._single_models.pop(change["old"].comm_id, None) # type: ignore + self.close() if change["name"] == "_ready": if self._ready: if self._ready_event: @@ -191,6 +151,25 @@ def _observe_comm(self, change: dict): elif self._ready_event: self._ready_event.clear() + def close(self): + if self.comm: + self._ipylab_send({"close": True}) + super().close() + for task in self.ipylab_tasks: + task.cancel() + self.ipylab_tasks.clear() + for item in list(self.close_extras): + item.close() + for obj, name in list(self._has_attrs_mappings): + if val := getattr(obj, name, None): + if val is self: + with contextlib.suppress(TraitError): + obj.set_trait(name, None) + elif isinstance(val, tuple): + obj.set_trait(name, tuple(v for v in val if v.comm)) + self._on_ready_callbacks.clear() + self.set_trait("closed", True) + def _check_closed(self): if not self._repr_mimebundle_: msg = f"This widget is closed {self!r}" @@ -200,12 +179,16 @@ async def _wrap_awaitable(self, aw: Awaitable[T], hooks: TaskHookType) -> T: await self.ready() try: result = await aw - if hooks: - self._task_result(result, hooks) except Exception: - self.log.exception("Task error", obj={"result": result, "hooks": hooks, "aw": aw}) + self.log.exception("Awaiting %s", aw, obj={"hooks": hooks, "aw": aw}) raise else: + if hooks: + try: + self._task_result(result, hooks) + except Exception: + self.log.exception("Running hooks", obj={"result": result, "hooks": hooks, "aw": aw}) + raise return result def _task_result(self: Ipylab, result: Any, hooks: TaskHooks): @@ -313,10 +296,6 @@ def _obj_operation(self, base: Obj, subpath: str, operation: str, kwgs, kwargs: kwgs |= {"genericOperation": operation, "basename": base, "subpath": subpath} return self.operation("genericOperation", kwgs, **kwargs) - def close(self): - self._ipylab_send({"close": True}) - super().close() - def ensure_run(self, aw: Callable | Awaitable | None) -> None: """Ensure aw is run. @@ -334,14 +313,15 @@ def ensure_run(self, aw: Callable | Awaitable | None) -> None: self.log.exception("Ensure run", obj=aw) raise - async def ready(self): + async def ready(self) -> Self: """Wait for the application to be ready. If this is not the main application instance, it waits for the main application instance to be ready first. """ - if self is not ipylab.app and not ipylab.app._ready: # noqa: SLF001 - await ipylab.app.ready() + app = self.app + if app is not self and not app._ready: # noqa: SLF001 + await app.ready() if not self._ready: # type: ignore if self._ready_event: try: @@ -351,9 +331,10 @@ async def ready(self): except RuntimeError: pass else: - return + return self self._ready_event = asyncio.Event() await self._ready_event.wait() + return self def on_ready(self, callback, remove=False): # noqa: FBT002 """Register a callback to execute when the application is ready. diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 4a0f0826..47fad4b7 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -14,7 +14,7 @@ import ipylab from ipylab import Ipylab from ipylab.commands import APP_COMMANDS_NAME, CommandPalette, CommandRegistry -from ipylab.common import Fixed, IpylabKwgs, LastUpdatedDict, Obj, to_selector +from ipylab.common import Fixed, IpylabKwgs, LastUpdatedDict, Obj, Singular, to_selector from ipylab.dialog import Dialog from ipylab.ipylab import IpylabBase from ipylab.launcher import Launcher @@ -30,13 +30,12 @@ @register -class App(Ipylab): +class App(Singular, Ipylab): """A connection to the 'app' in the frontend. A singleton (per kernel) not to be subclassed or closed. """ - SINGLE = True DEFAULT_COMMANDS: ClassVar = {"Open console", "Show log viewer"} _model_name = Unicode("JupyterFrontEndModel").tag(sync=True) ipylab_base = IpylabBase(Obj.IpylabModel, "app").tag(sync=True) @@ -59,13 +58,10 @@ class App(Ipylab): namespaces: Container[dict[str, LastUpdatedDict]] = Dict(read_only=True) # type: ignore - @classmethod @override - def _single_key(cls, kwgs: dict): - return "app" - - def close(self): - "Cannot close" + def close(self, *, force=False): + if force: + super().close() @default("logging_handler") def _default_logging_handler(self): @@ -169,7 +165,7 @@ def get_namespace(self, namespace_id="", **objects) -> LastUpdatedDict: `default_namespace_objects`. Note: - To remove a namespace call `ipylab.app.namespaces.pop()`. + To remove a namespace call `app.namespaces.pop()`. The default namespace `""` will also load objects from `shell.user_ns` if the kernel is an ipykernel (the default kernel provided in Jupyterlab). @@ -298,7 +294,7 @@ def evaluate( simple: ``` python task = app.evaluate( - "ipylab.app.shell.open_console", + "app.shell.open_console", vpath="test", kwgs={"mode": ipylab.InsertMode.split_right, "activate": False}, ) diff --git a/ipylab/launcher.py b/ipylab/launcher.py index 616d9f52..43cd75e9 100644 --- a/ipylab/launcher.py +++ b/ipylab/launcher.py @@ -9,7 +9,7 @@ from traitlets import Container, Instance from ipylab.commands import CommandConnection, CommandPalletItemConnection, CommandRegistry -from ipylab.common import Obj, TaskHooks +from ipylab.common import Obj, Singular, TaskHooks from ipylab.ipylab import Ipylab, IpylabBase, Transform if TYPE_CHECKING: @@ -26,12 +26,10 @@ class LauncherConnection(CommandPalletItemConnection): cid: str -class Launcher(Ipylab): +class Launcher(Singular, Ipylab): """ ref: https://jupyterlab.readthedocs.io/en/latest/api/interfaces/launcher.ILauncher-1.html""" - SINGLE = True - ipylab_base = IpylabBase(Obj.IpylabModel, "launcher").tag(sync=True) connections: Container[tuple[LauncherConnection, ...]] = TypedTuple(trait=Instance(LauncherConnection)) diff --git a/ipylab/log.py b/ipylab/log.py index f7f2a5ba..88153a8a 100644 --- a/ipylab/log.py +++ b/ipylab/log.py @@ -6,16 +6,18 @@ import logging import weakref from enum import IntEnum, StrEnum -from typing import TYPE_CHECKING, Any, ClassVar, override +from typing import TYPE_CHECKING, Any, ClassVar, cast, override from IPython.core.ultratb import FormattedTB from ipywidgets import CallbackDispatcher -import ipylab +from ipylab.common import Fixed if TYPE_CHECKING: from asyncio import Task + import ipylab + __all__ = ["LogLevel", "IpylabLogHandler"] @@ -71,9 +73,11 @@ def truncated_repr(obj: Any, maxlen=120, tail="…") -> str: class IpylabLoggerAdapter(logging.LoggerAdapter): + app = Fixed(cast(type["ipylab.App"], "ipylab.App")) + def __init__(self, name: str, owner: Any) -> None: logger = logging.getLogger(name) - if handler := ipylab.app.logging_handler: + if handler := self.app.logging_handler: handler._add_logger(logger) # noqa: SLF001 super().__init__(logger) self.owner_ref = weakref.ref(owner) @@ -125,6 +129,8 @@ def register_callback(self, callback, *, remove=False): class IpylabLogFormatter(logging.Formatter): + app = Fixed(cast(type["ipylab.App"], "ipylab.App")) + def __init__(self, *, colors: dict[LogLevel, ANSIColors] = COLORS, reset=ANSIColors.reset, **kwargs) -> None: """Initialize the formatter with specified format strings.""" self.colors = colors @@ -148,8 +154,8 @@ def get_ref(self, record, key): def formatException(self, ei) -> str: # noqa: N802 if not ei[0]: return "" - tbf = self.tb_formatter - if ipylab.app.logging_handler: - tbf.verbose if ipylab.app.logging_handler.level == LogLevel.DEBUG else tbf.minimal # noqa: B018 - return tbf.stb2text(tbf.structured_traceback(*ei)) + if self.app.logging_handler: + tbf = self.tb_formatter + tbf.verbose if self.app.logging_handler.level == LogLevel.DEBUG else tbf.minimal # noqa: B018 + return tbf.stb2text(tbf.structured_traceback(*ei)) # type: ignore return super().formatException(ei) diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py index c587b2ea..d2d1bfe2 100644 --- a/ipylab/log_viewer.py +++ b/ipylab/log_viewer.py @@ -4,7 +4,7 @@ from __future__ import annotations import collections -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast, override from ipywidgets import HTML, BoundedIntText, Button, Checkbox, Combobox, Dropdown, HBox, Select, VBox from traitlets import directional_link, link, observe @@ -30,6 +30,7 @@ class LogViewer(Panel): _log_notify_task: None | Task = None _updating = False info = Fixed(HTML, layout={"flex": "1 0 auto", "margin": "0px 20px 0px 20px"}) + app = Fixed(cast(type["ipylab.App"], "ipylab.App")) log_level = Fixed( Dropdown, description="Level", @@ -87,7 +88,7 @@ def __init__(self, buffersize=100): self.title.icon = Icon(name="ipylab-test_tube", svgstr=SVGSTR_TEST_TUBE) super().__init__(children=[self.header, self.autoscroll_widget]) self.buffer_size.value = buffersize - app = ipylab.app + app = self.app link((self.autoscroll_widget, "enabled"), (self.autoscroll_enabled, "value")) link((app, "log_level"), (self.log_level, "value")) link((self.buffer_size, "value"), (self.output, "max_outputs")) @@ -99,21 +100,24 @@ def __init__(self, buffersize=100): self.button_show_send_dialog.on_click(self._button_on_click) self.button_clear.on_click(self._button_on_click) - def close(self): - "Cannot close" + @override + def close(self, *, force=False): + if force: + super().close() @observe("connections") def _observe_connections(self, _): + app = self.app if self.connections and len(self.connections) == 1: self.output.push(*(rec.output for rec in self._records), clear=True) - self.info.value = f"Vpath: {ipylab.app._vpath}" # noqa: SLF001 - self.title.label = f"Log: {ipylab.app._vpath}" # noqa: SLF001 + self.info.value = f"Vpath: {app._vpath}" # noqa: SLF001 + self.title.label = f"Log: {app._vpath}" # noqa: SLF001 def _add_record(self, record: logging.LogRecord): self._records.append(record) if self.connections: self.output.push(record.output) # type: ignore - if record.levelno >= LogLevel.ERROR and ipylab.app._ready: # noqa: SLF001 + if record.levelno >= LogLevel.ERROR and self.app._ready: # noqa: SLF001 self._notify_exception(record) def _notify_exception(self, record: logging.LogRecord): @@ -123,7 +127,7 @@ def _notify_exception(self, record: logging.LogRecord): if not self._log_notify_task.done(): return self._log_notify_task.result().close() - self._log_notify_task = ipylab.app.notification.notify( + self._log_notify_task = self.app.notification.notify( message=f"Error: {record.msg}", type=ipylab.NotificationType.error, actions=[ @@ -138,7 +142,7 @@ def _observe_buffer_size(self, change): def _button_on_click(self, b): if b is self.button_show_send_dialog: self.button_show_send_dialog.disabled = True - ipylab.app.dialog.to_task( + self.app.dialog.to_task( self._show_send_dialog(), hooks={"callbacks": [lambda _: self.button_show_send_dialog.set_trait("disabled", False)]}, ) @@ -172,9 +176,9 @@ def observe(change: dict): select.observe(observe, "value") search.observe(observe, "value") try: - result = await ipylab.app.dialog.show_dialog("Send record to console", body=body) + result = await self.app.dialog.show_dialog("Send record to console", body=body) if result["value"] and select.value: - console = await ipylab.app.shell.open_console(objects={"record": select.value}) + console = await self.app.shell.open_console(objects={"record": select.value}) await console.set_property("console.promptCell.model.sharedModel.source", "record") await console.execute_method("console.execute") except Exception: diff --git a/ipylab/menu.py b/ipylab/menu.py index 3fd055df..bcf687d9 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -8,9 +8,8 @@ from ipywidgets import TypedTuple from traitlets import Container, Instance, Union -import ipylab from ipylab.commands import APP_COMMANDS_NAME, CommandRegistry -from ipylab.common import Fixed, Obj +from ipylab.common import Fixed, Obj, Singular from ipylab.connection import InfoConnection from ipylab.ipylab import Ipylab, IpylabBase, Transform @@ -96,8 +95,8 @@ def _add_item( def activate(self): async def activate(): - await ipylab.app.main_menu.set_property("activeMenu", self, toObject=["value"]) - await ipylab.app.main_menu.execute_method("openActiveMenu") + await self.app.main_menu.set_property("activeMenu", self, toObject=["value"]) + await self.app.main_menu.execute_method("openActiveMenu") return self.to_task(activate()) @@ -106,7 +105,7 @@ class BuiltinMenu(RankedMenu): @override def activate(self): name = self.ipylab_base[-1].removeprefix("mainMenu.").lower() - return ipylab.app.commands.execute(f"{name}:open") + return self.app.commands.execute(f"{name}:open") class MenuConnection(InfoConnection, RankedMenu): @@ -115,9 +114,7 @@ class MenuConnection(InfoConnection, RankedMenu): commands = Instance(CommandRegistry) -class Menu(RankedMenu): - SINGLE = True - +class Menu(Singular, RankedMenu): ipylab_base = IpylabBase(Obj.IpylabModel, "palette").tag(sync=True) commands = Instance(CommandRegistry) @@ -127,8 +124,8 @@ class Menu(RankedMenu): @classmethod @override - def _single_key(cls, kwgs: dict): - return cls, kwgs["commands"] + def get_single_key(cls, commands: str, **kwgs): + return commands def __init__(self, *, commands: CommandRegistry, **kwgs): commands.close_extras.add(self) @@ -141,8 +138,6 @@ class MainMenu(Menu): ref: https://jupyterlab.readthedocs.io/en/4.0.x/api/classes/mainmenu.MainMenu.html """ - SINGLE = True - ipylab_base = IpylabBase(Obj.IpylabModel, "mainMenu").tag(sync=True) file_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.fileMenu")) @@ -156,7 +151,7 @@ class MainMenu(Menu): @classmethod @override - def _single_key(cls, kwgs: dict): + def get_single_key(cls, **kwgs): return cls def __init__(self): @@ -178,7 +173,6 @@ def activate(self): class ContextMenu(Menu): """Menu available on mouse right click.""" - SINGLE = True # TODO: Support custom context menus. # This would require a model similar to CommandRegistryModel. @@ -202,7 +196,7 @@ def add_item( """ async def add_item_(): - return await self._add_item(command, submenu, rank, type, args, selector or ipylab.app.selector) + return await self._add_item(command, submenu, rank, type, args, selector or self.app.selector) return self.to_task(add_item_()) diff --git a/ipylab/notification.py b/ipylab/notification.py index cef27ca0..176365cc 100644 --- a/ipylab/notification.py +++ b/ipylab/notification.py @@ -11,9 +11,8 @@ from ipywidgets import TypedTuple, register from traitlets import Container, Instance, Unicode -import ipylab from ipylab import Transform, pack -from ipylab.common import Obj, TaskHooks, TransformType +from ipylab.common import Obj, Singular, TaskHooks, TransformType from ipylab.connection import InfoConnection from ipylab.ipylab import Ipylab, IpylabBase @@ -67,26 +66,24 @@ def update( to_object = ["args.id"] async def update(): - actions_ = [await ipylab.app.notification._ensure_action(v) for v in actions] # noqa: SLF001 + actions_ = [await self.app.notification._ensure_action(v) for v in actions] # noqa: SLF001 if actions_: args["actions"] = list(map(pack, actions_)) # type: ignore to_object.extend(f"options.actions.{i}" for i in range(len(actions_))) for action in actions_: self.close_extras.add(action) - return await ipylab.app.notification.operation("update", {"args": args}, toObject=to_object) + return await self.app.notification.operation("update", {"args": args}, toObject=to_object) return self.to_task(update()) @register -class NotificationManager(Ipylab): +class NotificationManager(Singular, Ipylab): """Create new notifications with access to the notification manager as base. ref: https://jupyterlab.readthedocs.io/en/stable/extension/ui_helpers.html#notifications """ - SINGLE = True - _model_name = Unicode("NotificationManagerModel").tag(sync=True) ipylab_base = IpylabBase(Obj.IpylabModel, "Notification.manager").tag(sync=True) @@ -97,9 +94,12 @@ class NotificationManager(Ipylab): @override async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list): """Overload this function as required.""" + action = ActionConnection(payload["cid"]) match operation: case "action_callback": - callback = ActionConnection.get_existing_connection(payload["cid"]).callback + action = ActionConnection(payload["cid"]) + await action.ready() + callback = action.callback result = callback() while inspect.isawaitable(result): result = await result diff --git a/ipylab/sessions.py b/ipylab/sessions.py index 4fb86576..0101e72d 100644 --- a/ipylab/sessions.py +++ b/ipylab/sessions.py @@ -7,20 +7,18 @@ from traitlets import Unicode -from ipylab.common import Obj +from ipylab.common import Obj, Singular from ipylab.ipylab import Ipylab, IpylabBase if TYPE_CHECKING: from asyncio import Task -class SessionManager(Ipylab): +class SessionManager(Singular, Ipylab): """ https://jupyterlab.readthedocs.io/en/latest/api/interfaces/services.Session.IManager.html """ - SINGLE = True - _model_name = Unicode("SessionManagerModel", help="Name of the model.", read_only=True).tag(sync=True) ipylab_base = IpylabBase(Obj.IpylabModel, "app.serviceManager.sessions").tag(sync=True) diff --git a/ipylab/shell.py b/ipylab/shell.py index 3c7da52c..63c31b35 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -12,7 +12,7 @@ import ipylab from ipylab import Area, InsertMode, Ipylab, ShellConnection, Transform, pack -from ipylab.common import Fixed, IpylabKwgs, Obj, TaskHookType +from ipylab.common import Fixed, IpylabKwgs, Obj, Singular, TaskHookType from ipylab.ipylab import IpylabBase from ipylab.log_viewer import LogViewer @@ -32,11 +32,9 @@ class ConsoleConnection(ShellConnection): # TODO: add methods -class Shell(Ipylab): +class Shell(Singular, Ipylab): """Provides access to the shell.""" - SINGLE = True - _model_name = Unicode("ShellModel", help="Name of the model.", read_only=True).tag(sync=True) ipylab_base = IpylabBase(Obj.IpylabModel, "app.shell").tag(sync=True) current_widget_id = Unicode(read_only=True).tag(sync=True) @@ -137,22 +135,17 @@ def add( args["evaluate"] = pack(obj) async def add_to_shell() -> ShellConnection: - vpath_ = ipylab.app.vpath + vpath_ = vpath or self.app.vpath if isinstance(obj, DOMWidget): - obj.add_class(ipylab.app.selector.removeprefix(".")) - if "evaluate" in args: - if isinstance(vpath, dict): - result = ipylab.plugin_manager.hook.vpath_getter(app=ipylab.app, kwgs=vpath) - while inspect.isawaitable(result): - result = await result - args["vpath"] = result - else: - args["vpath"] = vpath or vpath_ - if args["vpath"] != vpath_: - hooks_["trait_add_fwd"] = [("auto_dispose", False)] - else: - args["vpath"] = vpath_ - + obj.add_class(self.app.selector.removeprefix(".")) + if "evaluate" in args and isinstance(vpath, dict): + result = ipylab.plugin_manager.hook.vpath_getter(app=self.app, kwgs=vpath) + while inspect.isawaitable(result): + result = await result + vpath_ = result + args["vpath"] = vpath_ + if vpath_ != self.app.vpath: + hooks_["trait_add_fwd"] = [("auto_dispose", False)] return await self.operation("addToShell", {"args": args}, transform=Transform.connection, hooks=hooks_) return self.to_task(add_to_shell(), "Add to shell", hooks=hooks) @@ -192,7 +185,7 @@ async def open_console(): if not isinstance(ref_, ShellConnection): ref_ = await self.connect_to_widget(ref_) objects_ = {"ref": ref_} | (objects or {}) - vpath = ipylab.app.vpath + vpath = self.app.vpath args = { "path": vpath, "insertMode": InsertMode(mode), @@ -208,7 +201,7 @@ async def open_console(): "callbacks": [lambda _: self.add_objects_to_ipython_namespace(objects_, reset=reset_shell)], }, ) - return await ipylab.app.commands.execute("console:open", args, **kwgs) + return await self.app.commands.execute("console:open", args, **kwgs) return self.to_task(open_console(), "Open console", hooks=hooks) diff --git a/ipylab/widgets.py b/ipylab/widgets.py index e61b87db..689761c2 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from ipywidgets import Box, DOMWidget, Layout, TypedTuple, register, widget_serialization from ipywidgets.widgets.trait_types import InstanceDict @@ -12,7 +12,7 @@ import ipylab import ipylab._frontend as _fe -from ipylab.common import Area, InsertMode +from ipylab.common import Area, Fixed, InsertMode from ipylab.connection import ShellConnection from ipylab.ipylab import WidgetBase @@ -53,6 +53,7 @@ class Panel(Box): _view_module_version = Unicode(_fe.module_version, read_only=True).tag(sync=True) title: Instance[Title] = InstanceDict(Title, ()).tag(sync=True, **widget_serialization) + app = Fixed(cast(type["ipylab.App"], "ipylab.App")) connections: Container[tuple[ShellConnection, ...]] = TypedTuple(trait=Instance(ShellConnection)) def add_to_shell( @@ -67,7 +68,7 @@ def add_to_shell( **kwgs, ) -> Task[ShellConnection]: """Add this panel to the shell.""" - return ipylab.app.shell.add( + return self.app.shell.add( self, area=area, mode=mode, @@ -108,7 +109,7 @@ async def force_refresh(children): await asyncio.sleep(0.001) self.orientation = orientation - return ipylab.app.to_task(force_refresh(self.children)) + return self.app.to_task(force_refresh(self.children)) # ============== End temp fix ============= @@ -136,4 +137,6 @@ class ResizeBox(Box): _view_module = Unicode(_fe.module_name, read_only=True).tag(sync=True) _view_module_version = Unicode(_fe.module_version, read_only=True).tag(sync=True) - size: Container[tuple[int, int]] = Tuple(readonly=True, help="(clientWidth, clientHeight) in pixels").tag(sync=True) + size: Container[tuple[int, int]] = Tuple(read_only=True, help="(clientWidth, clientHeight) in pixels").tag( + sync=True + ) diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts index 42b394fd..2629036a 100644 --- a/src/widgets/frontend.ts +++ b/src/widgets/frontend.ts @@ -122,8 +122,9 @@ export class JupyterFrontEndModel extends IpylabModel { // Relies on per-kernel widget manager. const getManager = (KernelWidgetManager as any).getManager; const widget_manager: KernelWidgetManager = await getManager(kernel); + const code = 'import ipylab;ipylab.App()'; if (!Private.jfems.has(kernel.id)) { - widget_manager.kernel.requestExecute({ code: 'import ipylab' }, true); + widget_manager.kernel.requestExecute({ code }, true); } } return await new Promise((resolve, reject) => { diff --git a/tests/conftest.py b/tests/conftest.py index 1b84d3e6..81df36ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,6 @@ async def anyio_backend_autouse(anyio_backend): @pytest.fixture async def app(mocker): - app = ipylab.app + app = ipylab.App() mocker.patch.object(app, "ready") return app diff --git a/tests/test_common.py b/tests/test_common.py index 875e09a1..9ac99862 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -3,6 +3,8 @@ from __future__ import annotations +from typing import override + import pytest from ipywidgets import TypedTuple from traitlets import HasTraits, Unicode @@ -12,6 +14,7 @@ FixedCreate, FixedCreated, LastUpdatedDict, + Singular, Transform, TransformDictAdvanced, TransformDictConnection, @@ -177,6 +180,36 @@ def test_transform_payload_no_transform(self): assert result == payload +class TestLimited: + async def test_limited_new_single(self): + obj1 = Singular() + obj2 = Singular() + assert obj1 is obj2 + obj1.close() + assert obj1 not in obj1._single_instances + assert obj1.closed + + async def test_limited_newget_single_keyed(self): + # Test that the get_single_key method and arguments are passed + class KeyedSingle(Singular): + def __init__(self, /, key: str, *args, **kwgs): + self.key = key + super().__init__(*args, **kwgs) + + @override + @classmethod + def get_single_key(cls, key: str, **kwgs): + return key + + obj1 = KeyedSingle(key="key1") + obj2 = KeyedSingle(key="key1") + obj3 = KeyedSingle(key="key2") + obj4 = KeyedSingle("key2") + assert obj1 is obj2 + assert obj1 is not obj3 + assert obj4 is obj3 + + class TestFixed: def test_readonly_basic(self): class TestOwner: diff --git a/tests/test_jupyterfrontend.py b/tests/test_jupyterfrontend.py index 4555de73..8a1c1220 100644 --- a/tests/test_jupyterfrontend.py +++ b/tests/test_jupyterfrontend.py @@ -7,11 +7,12 @@ import contextlib import json import uuid -from typing import Any +from typing import TYPE_CHECKING, Any import pytest -import ipylab +if TYPE_CHECKING: + import ipylab def example_callable(a=None): @@ -78,11 +79,10 @@ async def f(): ), ], ) -async def test_app_evaluate(kw: dict[str, Any], result, mocker): +async def test_app_evaluate(app: ipylab.App, kw: dict[str, Any], result, mocker): "Tests for app.evaluate" import asyncio - app = ipylab.app ready = mocker.patch.object(app, "ready") send = mocker.patch.object(app, "send") @@ -120,14 +120,13 @@ async def test_app_evaluate(kw: dict[str, Any], result, mocker): @pytest.mark.parametrize("n", [1, 2]) -async def test_ready(n): +async def test_ready(n, app: ipylab.App): "Paramatised tests must be run consecutively." # Normally not an issue, but when testing, it is possible for asyncio to # use different loops. Running this test consecutively should use separate # event loops. - loops.add(asyncio.get_running_loop()) assert len(loops) == n, "A new event loop should be provided per test." with contextlib.suppress(asyncio.TimeoutError): async with asyncio.timeout(1): - await ipylab.app.ready() + await app.ready() diff --git a/tests/test_log.py b/tests/test_log.py index 3caebd22..d0acd980 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -4,33 +4,33 @@ import ipylab.log -def test_log(): +def test_log(app: ipylab.App): records = [] def on_record(record): records.append(record) - assert ipylab.app.logging_handler - ipylab.app.logging_handler.register_callback(on_record) - ipylab.app.log_level = ipylab.log.LogLevel.ERROR + assert app.logging_handler + app.logging_handler.register_callback(on_record) + app.log_level = ipylab.log.LogLevel.ERROR # With objects via IpylabLoggerAdapter obj = object() - ipylab.app.log.error("An error", obj=obj) + app.log.error("An error", obj=obj) assert len(records) == 1 record = records[0] - assert record.owner() is ipylab.app, "Via weakref" + assert record.owner() is app, "Via weakref" assert record.obj is obj, "Direct ref" # No objects direct log - ipylab.app.log.logger.error("No objects") + app.log.logger.error("No objects") assert len(records) == 2 record = records[1] assert not hasattr(record, "owner"), "logging directly won't attach owner" assert not hasattr(record, "obj"), "logging directly won't attach obj" -def test_log_level_sync(): +def test_log_level_sync(app: ipylab.App): for level in ipylab.log.LogLevel: - ipylab.app.log_level = level - assert ipylab.app.log.getEffectiveLevel() == level + app.log_level = level + assert app.log.getEffectiveLevel() == level From 8f703da282e11be399bfb807bc79dae764de4b82 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 16 Mar 2025 20:12:01 +1100 Subject: [PATCH 02/47] Remove TODO comments from log_viewer, menu, and shell modules --- ipylab/log_viewer.py | 1 - ipylab/menu.py | 3 --- ipylab/shell.py | 2 -- 3 files changed, 6 deletions(-) diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py index d2d1bfe2..527b4fa2 100644 --- a/ipylab/log_viewer.py +++ b/ipylab/log_viewer.py @@ -151,7 +151,6 @@ def _button_on_click(self, b): self.output.push(clear=True) async def _show_send_dialog(self): - # TODO: make a formatter to simplify the message with obj and owner) options = {r.msg: r for r in reversed(self._records)} # type: ignore select = Select( tooltip="Most recent exception is first", diff --git a/ipylab/menu.py b/ipylab/menu.py index bcf687d9..6b25240f 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -173,9 +173,6 @@ def activate(self): class ContextMenu(Menu): """Menu available on mouse right click.""" - # TODO: Support custom context menus. - # This would require a model similar to CommandRegistryModel. - ipylab_base = IpylabBase(Obj.IpylabModel, "app.contextMenu").tag(sync=True) @override diff --git a/ipylab/shell.py b/ipylab/shell.py index 63c31b35..a262e610 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -29,8 +29,6 @@ class ConsoleConnection(ShellConnection): "A connection intended for a JupyterConsole" - # TODO: add methods - class Shell(Singular, Ipylab): """Provides access to the shell.""" From af902391c4b8baadcc6e4316521d44624d7adfd9 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 16 Mar 2025 20:12:35 +1100 Subject: [PATCH 03/47] Update ruff pre-commit hook to version 0.11.0 --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ed4fc5d9..30faab28 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ repos: hooks: - id: taplo-format - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.10 + rev: v0.11.0 hooks: - id: ruff types_or: [python, jupyter] From b1f1102d52c4025d91aa0ff05c6dd8c6ed24bb27 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 16 Mar 2025 20:15:02 +1100 Subject: [PATCH 04/47] Update ruff_defaults.toml to modify selected rules for improved linting --- ruff_defaults.toml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/ruff_defaults.toml b/ruff_defaults.toml index 98860d34..3f21460d 100644 --- a/ruff_defaults.toml +++ b/ruff_defaults.toml @@ -238,7 +238,7 @@ select = [ "PLR0133", "PLR0206", "PLR0402", - "PLR1701", + "SIM101", "PLR1711", "PLR1714", "PLR1722", @@ -446,12 +446,12 @@ select = [ "T100", "T201", "T203", - "TCH001", - "TCH002", - "TCH003", - "TCH004", - "TCH005", - "TCH010", + "TC001", + "TC002", + "TC003", + "TC004", + "TC005", + "TC010", "TD004", "TD005", "TD006", @@ -459,18 +459,18 @@ select = [ "TID251", "TID252", "TID253", - "TRIO100", - "TRIO105", - "TRIO109", - "TRIO110", - "TRIO115", + "ASYNC100", + "ASYNC105", + "ASYNC109", + "ASYNC110", + "ASYNC115", "TRY002", "TRY003", "TRY004", "TRY201", "TRY300", "TRY301", - "TRY302", + "TRY203", "TRY400", "TRY401", "UP001", From 9a8729522b3dcffc7fa668d5fe898b138ed3f3ee Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 16 Mar 2025 20:23:38 +1100 Subject: [PATCH 05/47] Enhance Ipylab class docstring to provide detailed attribute descriptions and clarify widget functionality --- ipylab/ipylab.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index a403a91a..8ad9c959 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -81,7 +81,28 @@ class WidgetBase(Widget): @register class Ipylab(WidgetBase): - """The base class for Ipylab which has a corresponding frontend.""" + """A base class for creating ipylab widgets. + + Ipylab widgets are Jupyter widgets that are designed to interact with the + JupyterLab application. They provide a way to extend the functionality + of JupyterLab with custom Python code. + + Attributes: + _model_name (Unicode): The name of the model. + _python_class (Unicode): The name of the Python class. + ipylab_base (IpylabBase): The base ipylab object. + _ready (Bool): Whether the widget is ready. + _on_ready_callbacks (List): A list of callbacks to execute when the widget is ready. + _ready_event (asyncio.Event): An event that is set when the widget is ready. + _comm: The comm object. + _ipylab_init_complete (bool): Whether the ipylab initialization is complete. + _pending_operations (Dict): A dictionary of pending operations. + _has_attrs_mappings (Set): A set of attribute mappings. + ipylab_tasks (Set): A set of ipylab tasks. + close_extras (Fixed): A set of extra widgets to close. + log (Instance): A logger instance. + app (Fixed): A reference to the ipylab App instance. + """ _model_name = Unicode("IpylabModel", help="Name of the model.", read_only=True).tag(sync=True) _python_class = Unicode().tag(sync=True) From 72247c329662659c55df927cda62077180469675 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 16 Mar 2025 20:30:53 +1100 Subject: [PATCH 06/47] Refactor imports in __init__.py to include common module and update __all__ exports. Add Singular to __all__ in common.py. --- ipylab/__init__.py | 6 +++--- ipylab/common.py | 1 + ipylab/ipylab.py | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ipylab/__init__.py b/ipylab/__init__.py index 0c91b5d3..21b9d97c 100644 --- a/ipylab/__init__.py +++ b/ipylab/__init__.py @@ -3,10 +3,10 @@ from __future__ import annotations -from ipylab import menu +from ipylab import common, menu from ipylab._frontend import module_version as __version__ from ipylab.code_editor import CodeEditor -from ipylab.common import Area, Fixed, InsertMode, Obj, Transform, hookimpl, pack, to_selector +from ipylab.common import Area, InsertMode, Obj, Transform, hookimpl, pack, to_selector from ipylab.connection import Connection, ShellConnection from ipylab.ipylab import Ipylab from ipylab.jupyterfrontend import App, JupyterFrontEnd @@ -16,6 +16,7 @@ __all__ = [ "__version__", + "common", "CodeEditor", "Connection", "ShellConnection", @@ -24,7 +25,6 @@ "SplitPanel", "Icon", "Area", - "Fixed", "NotificationType", "NotifyAction", "InsertMode", diff --git a/ipylab/common.py b/ipylab/common.py index c2e354d2..f84f3af6 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -44,6 +44,7 @@ "Fixed", "FixedCreate", "FixedCreated", + "Singular", ] hookimpl = pluggy.HookimplMarker("ipylab") # Used for plugins diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 8ad9c959..4b1388df 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -189,7 +189,6 @@ def close(self): elif isinstance(val, tuple): obj.set_trait(name, tuple(v for v in val if v.comm)) self._on_ready_callbacks.clear() - self.set_trait("closed", True) def _check_closed(self): if not self._repr_mimebundle_: From 9c6ef97e0fd789f57bff6fe27f2eefe3f627eaf1 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 16 Mar 2025 20:39:03 +1100 Subject: [PATCH 07/47] Refactor initialization checks in CSSStyleSheet and Menu classes for consistency. Update KeyedSingle class in tests to improve argument handling. --- ipylab/css_stylesheet.py | 2 +- ipylab/menu.py | 2 ++ tests/test_common.py | 7 ++++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ipylab/css_stylesheet.py b/ipylab/css_stylesheet.py index 64c2eeed..0ec9bf2b 100644 --- a/ipylab/css_stylesheet.py +++ b/ipylab/css_stylesheet.py @@ -27,7 +27,7 @@ class CSSStyleSheet(Ipylab): ) def __init__(self, **kwgs): - if self._limited_init_complete: + if self._ipylab_init_complete: return super().__init__(**kwgs) self.on_ready(self._restore) diff --git a/ipylab/menu.py b/ipylab/menu.py index 6b25240f..b6f1f99a 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -128,6 +128,8 @@ def get_single_key(cls, commands: str, **kwgs): return commands def __init__(self, *, commands: CommandRegistry, **kwgs): + if self._ipylab_init_complete: + return commands.close_extras.add(self) super().__init__(commands=commands, **kwgs) diff --git a/tests/test_common.py b/tests/test_common.py index 9ac99862..8a810e0c 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -192,9 +192,10 @@ async def test_limited_new_single(self): async def test_limited_newget_single_keyed(self): # Test that the get_single_key method and arguments are passed class KeyedSingle(Singular): - def __init__(self, /, key: str, *args, **kwgs): - self.key = key - super().__init__(*args, **kwgs) + key = Unicode() + + def __init__(self, /, key: str, **kwgs): + super().__init__(key=key, **kwgs) @override @classmethod From 3f8a7f088e27dfd3dad0b1660d3d41e5764c4af3 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 16 Mar 2025 21:23:11 +1100 Subject: [PATCH 08/47] Add autostart_once hook for initial app readiness and update autostart logic --- ipylab/hookspecs.py | 12 ++++++++++++ ipylab/jupyterfrontend.py | 4 ++++ ipylab/lib.py | 5 +++++ 3 files changed, 21 insertions(+) diff --git a/ipylab/hookspecs.py b/ipylab/hookspecs.py index 9a6598ab..2598912f 100644 --- a/ipylab/hookspecs.py +++ b/ipylab/hookspecs.py @@ -28,6 +28,18 @@ async def ready(obj: ipylab.Ipylab) -> None | Awaitable[None]: """A hook that is called by `obj` when it is ready.""" +@hookspec(historic=True) +async def autostart_once(app: ipylab.App) -> None | Awaitable[None]: + """A hook that is called when the `app` is ready for the first time. + + Historic + -------- + + This plugin is historic so will be called when a plugin is registered if the + app is already ready. + """ + + @hookspec(historic=True) async def autostart(app: ipylab.App) -> None | Awaitable[None]: """A hook that is called when the `app` is ready. diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 47fad4b7..5033e519 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -77,6 +77,10 @@ def _app_observe_ready(self, change): self._selector = to_selector(self._vpath) ipylab.plugin_manager.hook.autostart._call_history.clear() # type: ignore # noqa: SLF001 try: + if not ipylab.plugin_manager.hook.autostart_once._call_history: # noqa: SLF001 + ipylab.plugin_manager.hook.autostart_once.call_historic( + kwargs={"app": self}, result_callback=self._autostart_callback + ) ipylab.plugin_manager.hook.autostart.call_historic( kwargs={"app": self}, result_callback=self._autostart_callback ) diff --git a/ipylab/lib.py b/ipylab/lib.py index 374a0996..ede05d8b 100644 --- a/ipylab/lib.py +++ b/ipylab/lib.py @@ -40,6 +40,11 @@ async def autostart(app: ipylab.App) -> None | Awaitable[None]: await app.context_menu.add_item(command=cmd, rank=71) +@hookimpl +async def autostart_once(app: ipylab.App) -> None | Awaitable[None]: + pass + + @hookimpl def vpath_getter(app: App, kwgs: dict) -> Awaitable[str] | str: return app.dialog.get_text(**kwgs) From 055227fd4bd93ac70b149dbd459ce6ba54fa2815 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 16 Mar 2025 22:55:00 +1100 Subject: [PATCH 09/47] Enhance Singular class to allow None as a key for instance creation. Update tests to validate new behavior with None keys. --- ipylab/common.py | 13 ++++++++----- tests/test_common.py | 10 ++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/ipylab/common.py b/ipylab/common.py index f84f3af6..d5955c28 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -323,7 +323,8 @@ class Singular(HasTraits): This class uses a class-level dictionary `_single_instances` to store instances, keyed by a value obtained from the `get_single_key` method. Subsequent calls to - the constructor with the same key will return the existing instance. + the constructor with the same key will return the existing instance. If key is + None, a new instance is always created and a reference is not kept to the object. Attributes: _limited_init_complete (bool): A flag to prevent multiple initializations. @@ -366,11 +367,13 @@ def get_single_key(cls, *args, **kwgs) -> Hashable: # noqa: ARG003 def __new__(cls, /, *args, **kwgs) -> Self: key = cls.get_single_key(*args, **kwgs) - if key not in cls._single_instances: + if key is None or not (inst := cls._single_instances.get(key)): new = super().__new__ - cls._single_instances[key] = inst = new(cls) if new is object.__new__ else new(cls, *args, **kwgs) - inst.set_trait("_single_key", key) - return cls._single_instances[key] + inst = new(cls) if new is object.__new__ else new(cls, *args, **kwgs) + if key: + cls._single_instances[key] = inst + inst.set_trait("_single_key", key) + return inst def __init__(self, /, *args, **kwgs): if self._limited_init_complete: diff --git a/tests/test_common.py b/tests/test_common.py index 8a810e0c..51351454 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -192,9 +192,9 @@ async def test_limited_new_single(self): async def test_limited_newget_single_keyed(self): # Test that the get_single_key method and arguments are passed class KeyedSingle(Singular): - key = Unicode() + key = Unicode(allow_none=True) - def __init__(self, /, key: str, **kwgs): + def __init__(self, /, key: str | None, **kwgs): super().__init__(key=key, **kwgs) @override @@ -206,9 +206,15 @@ def get_single_key(cls, key: str, **kwgs): obj2 = KeyedSingle(key="key1") obj3 = KeyedSingle(key="key2") obj4 = KeyedSingle("key2") + obj5 = KeyedSingle(None) + obj6 = KeyedSingle(None) + + assert obj1 in KeyedSingle._single_instances.values() assert obj1 is obj2 assert obj1 is not obj3 assert obj4 is obj3 + assert obj5 is not obj6 + assert obj5 not in KeyedSingle._single_instances.values() class TestFixed: From 37478a0f3efc938e88c76503ae6d3d9abb347727 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Mon, 17 Mar 2025 13:58:08 +1100 Subject: [PATCH 10/47] Refactor Fixed usage in menu and widget classes to utilize lambda functions for dynamic initialization. Update type hints for better clarity in examples and code editor. --- .vscode/settings.json | 2 +- examples/menu.ipynb | 4 +- examples/resize_box.ipynb | 4 +- examples/simple_output.ipynb | 32 +++++++----- ipylab/code_editor.py | 17 +++---- ipylab/commands.py | 6 +-- ipylab/common.py | 99 ++++++++++++------------------------ ipylab/ipylab.py | 6 +-- ipylab/jupyterfrontend.py | 6 +-- ipylab/log.py | 15 +++--- ipylab/log_viewer.py | 85 ++++++++++++++++--------------- ipylab/menu.py | 42 ++++++++++----- ipylab/widgets.py | 4 +- tests/test_common.py | 61 ++++++++-------------- 14 files changed, 177 insertions(+), 206 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 5e55950f..34f69655 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,7 @@ "editor.formatOnSave": true, "python.terminal.activateEnvInCurrentTerminal": true, "python.createEnvironment.trigger": "prompt", - "python.analysis.typeCheckingMode": "basic", + "python.analysis.typeCheckingMode": "standard", "python.testing.pytestArgs": ["tests"], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true diff --git a/examples/menu.ipynb b/examples/menu.ipynb index 2fc38fb3..82aeb747 100644 --- a/examples/menu.ipynb +++ b/examples/menu.ipynb @@ -238,7 +238,9 @@ "metadata": {}, "outputs": [], "source": [ - "cr = ipylab.commands.CommandRegistry(name=\"My command registry\")\n", + "from ipylab.commands import CommandRegistry\n", + "\n", + "cr = CommandRegistry(name=\"My command registry\")\n", "t = cr.create_menu(\"Extra commands\")" ] }, diff --git a/examples/resize_box.ipynb b/examples/resize_box.ipynb index f7aeadcb..33e8f5bf 100644 --- a/examples/resize_box.ipynb +++ b/examples/resize_box.ipynb @@ -144,14 +144,14 @@ "app = ipylab.App()\n", "\n", "box = ResizeBox([fig.canvas])\n", - "fig.canvas.resizable = False\n", + "fig.canvas.resizable = False # type: ignore\n", "\n", "\n", "def _observe_resizebox_dimensions(change):\n", " box: ResizeBox = change[\"owner\"] # type: ignore\n", " canvas = box.children[0] # type: ignore\n", " width, height = box.size\n", - " dpi = canvas.figure.dpi\n", + " dpi = canvas.figure.dpi # type: ignore\n", " fig.set_size_inches(max((width) // dpi, 1), max((height) // dpi, 1))\n", " fig.canvas.draw_idle()\n", "\n", diff --git a/examples/simple_output.ipynb b/examples/simple_output.ipynb index 3303cf06..0ddd8df5 100644 --- a/examples/simple_output.ipynb +++ b/examples/simple_output.ipynb @@ -447,6 +447,7 @@ "import io\n", "import sys\n", "from contextlib import redirect_stdout\n", + "from typing import Self\n", "\n", "import ipywidgets as ipw\n", "\n", @@ -458,22 +459,25 @@ "\n", "\n", "class SimpleConsole(Panel):\n", - " prompt = Fixed(\n", - " CodeEditor,\n", - " editor_options={\"lineNumbers\": False, \"autoClosingBrackets\": True, \"highlightActiveLine\": True},\n", - " mime_type=\"text/x-python\",\n", - " layout={\"flex\": \"0 0 auto\"},\n", + " prompt: Fixed[Self, CodeEditor] = Fixed(\n", + " lambda _: CodeEditor(\n", + " editor_options={\"lineNumbers\": False, \"autoClosingBrackets\": True, \"highlightActiveLine\": True},\n", + " mime_type=\"text/x-python\",\n", + " layout={\"flex\": \"0 0 auto\"},\n", + " ),\n", " )\n", - " button_clear = Fixed(ipw.Button, description=\"Clear\", layout={\"width\": \"auto\"})\n", - " autoscroll = Fixed(ipw.Checkbox, description=\"Auto scroll\", layout={\"width\": \"auto\"})\n", - " header = Fixed(\n", - " ipw.HBox,\n", - " children=lambda parent: (parent.button_clear, parent.autoscroll),\n", - " layout={\"flex\": \"0 0 auto\"},\n", - " dynamic=[\"children\"],\n", + " header: Fixed[Self, ipw.HBox] = Fixed(\n", + " lambda c: ipw.HBox(\n", + " children=(c[\"owner\"].button_clear, c[\"owner\"].autoscroll),\n", + " layout={\"flex\": \"0 0 auto\"},\n", + " ),\n", " )\n", - " output = Fixed(SimpleOutput)\n", - " scroll = Fixed(AutoScroll, create=lambda config: AutoScroll(content=config[\"owner\"].output))\n", + " button_clear: Fixed[Self, ipw.Button] = Fixed(lambda _: ipw.Button(description=\"Clear\", layout={\"width\": \"auto\"}))\n", + " autoscroll: Fixed[Self, ipw.Checkbox] = Fixed(\n", + " lambda _: ipw.Checkbox(description=\"Auto scroll\", layout={\"width\": \"auto\"})\n", + " )\n", + " output: Fixed[Self, SimpleOutput] = Fixed(SimpleOutput)\n", + " scroll: Fixed[Self, AutoScroll] = Fixed(lambda c: AutoScroll(content=c[\"owner\"].output))\n", "\n", " def __init__(self, namespace_id: str, **kwgs):\n", " self.prompt.namespace_id = namespace_id\n", diff --git a/ipylab/code_editor.py b/ipylab/code_editor.py index c7bbf758..db550e81 100644 --- a/ipylab/code_editor.py +++ b/ipylab/code_editor.py @@ -7,7 +7,7 @@ import inspect import typing from asyncio import Task -from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast, override +from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict, override from IPython.core import completer as IPC # noqa: N812 from IPython.utils.tokenutil import token_at_cursor @@ -42,7 +42,7 @@ class IpylabCompleter(IPC.IPCompleter): code_editor: Instance[CodeEditor] = Instance("ipylab.CodeEditor") - app = Fixed(cast(type["ipylab.App"], "ipylab.App")) + app = Fixed(lambda _: ipylab.App()) if TYPE_CHECKING: shell: InteractiveShell # Set in IPV.IPCompleter.__init__ namespace: LastUpdatedDict @@ -211,14 +211,13 @@ class CodeEditor(Ipylab, _String): value = Unicode() _update_task: None | Task = None _setting_value = False - - completer = Fixed( - IpylabCompleter, - code_editor=lambda c: c, - shell=lambda c: getattr(getattr(c.comm, "kernel", None), "shell", None), - dynamic=["code_editor", "shell"], + completer: Fixed[Self, IpylabCompleter] = Fixed( + lambda c: IpylabCompleter( + code_editor=c["owner"], + shell=getattr(getattr(c["owner"].comm, "kernel", None), "shell", None), + dynamic=["code_editor", "shell"], + ), ) - namespace_id = Unicode("") evaluate: Container[typing.Callable[[str], typing.Coroutine]] = Callable() # type: ignore load_value: Container[typing.Callable[[str], None]] = Callable() # type: ignore diff --git a/ipylab/commands.py b/ipylab/commands.py index 2dd97f28..e6c2aa8e 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -51,7 +51,7 @@ class KeybindingConnection(InfoConnection): @override @classmethod - def to_cid(cls, command: CommandConnection): + def to_cid(cls, command: CommandConnection): # type: ignore return super().to_cid(str(command), str(uuid.uuid4())) @@ -68,7 +68,7 @@ class CommandConnection(InfoConnection): @override @classmethod - def to_cid(cls, command_registry: str, vpath: str, name: str): + def to_cid(cls, command_registry: str, vpath: str, name: str): # type: ignore return super().to_cid(command_registry, vpath, name) @property @@ -120,7 +120,7 @@ class CommandPalletItemConnection(InfoConnection): @override @classmethod - def to_cid(cls, command: CommandConnection, category: str): + def to_cid(cls, command: CommandConnection, category: str): # type: ignore return super().to_cid(str(command), category) diff --git a/ipylab/common.py b/ipylab/common.py index d5955c28..fc93266c 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -52,6 +52,8 @@ SVGSTR_TEST_TUBE = ' ' T = TypeVar("T") +S = TypeVar("S") + if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Hashable @@ -310,7 +312,7 @@ def __setitem__(self, key, value): self.move_to_end(key, self._last) @override - def update(self, m, **kwargs): + def update(self, m, /, **kwargs): # type: ignore self._updating = True try: super().update(m, **kwargs) @@ -391,103 +393,66 @@ def close(self): self.set_trait("closed", True) -class FixedCreate(Generic[T], TypedDict): +class FixedCreate(Generic[S], TypedDict): "A TypedDict relevant to Fixed" name: str - klass: type[T] - owner: Any - args: tuple - kwgs: dict + owner: S -class FixedCreated(Generic[T], TypedDict): +class FixedCreated(Generic[S, T], TypedDict): "A TypedDict relevant to Fixed" name: str + owner: S obj: T - owner: Any -class Fixed(Generic[T]): - __slots__ = ["name", "instances", "klass", "args", "kwgs", "dynamic", "create", "created"] +class Fixed(Generic[S, T]): + __slots__ = ["name", "instances", "create", "created"] def __init__( self, - klass: type[T] | str, - *args, - dynamic: list[str] | None = None, - create: Callable[[FixedCreate[T]], T] | str = "", - created: Callable[[FixedCreated[T]]] | str = "", - **kwgs, + create: type[T] | Callable[[FixedCreate[S]], T] | str, + /, + *, + created: Callable[[FixedCreated[S, T]]] | None = None, ): - """Define an instance of `klass` as a cached read only property. - `args` and `kwgs` are used to instantiate `klass`. - - Parameters: - ---------- - - dynamic: list[str]: - A list of argument names to call during creation. It is called with obj (owner) - as an argument. - - create: Callable[[FixedCreated], T] | str - A function or method name to call to create the instance of klass. - - created: Callable[[FixedCreatedDict], None] | str - A function or method name to call after the instance is created. + if inspect.isclass(create): + self.create = lambda _: create() # type: ignore + elif callable(create): + match len(inspect.signature(create).parameters): + case 0: + self.create = lambda _: create() # type: ignore + case 1: + self.create = create + case _: + msg = "'create' must be a callable the accepts None or one argument." + raise ValueError(msg) - **kwgs: - `kwgs` to pass when instantiating `klass`. Arguments listed in dynamic - are first called with obj as an argument to obtain the value to - substitute in place of the dynamic function. - """ - if callable(create) and len(inspect.signature(create).parameters) != 1: - msg = "'create' must be a callable the accepts one argument." - raise ValueError(msg) + elif isinstance(create, str): + self.create = lambda _: import_item(create)() + else: + msg = "Unsure how to create" + raise TypeError(msg) if callable(created) and len(inspect.signature(created).parameters) != 1: msg = "'created' must be a callable the accepts one argument." raise ValueError(msg) - if dynamic: - for k in dynamic: - if not callable(kwgs[k]) or len(inspect.signature(kwgs[k]).parameters) != 1: - msg = f"Argument'{k}' must a callable that accepts one argument." - raise ValueError(msg) self.created = created - self.create = create - self.dynamic = dynamic - self.args = args - self.klass = klass - self.kwgs = kwgs self.instances = weakref.WeakKeyDictionary() def __set_name__(self, owner_cls, name: str): self.name = name - def __get__(self, obj, objtype=None) -> T: + def __get__(self, obj: S, objtype=None) -> T: if obj is None: return self # type: ignore if obj not in self.instances: - klass = import_item(self.klass) if isinstance(self.klass, str) else self.klass - kwgs = self.kwgs - if self.dynamic: - kwgs = kwgs.copy() - for k in self.dynamic: - kwgs[k] = kwgs[k](obj) - if self.create: - create = getattr(obj, self.create) if isinstance(self.create, str) else self.create - kw = FixedCreate(name=self.name, klass=klass, owner=obj, args=self.args, kwgs=kwgs) - instance = create(kw) # type: ignore - if not isinstance(instance, klass): - msg = f"Expected {self.klass} but {create=} returned {type(instance)}" - raise TypeError(msg) - else: - instance = klass(*self.args, **kwgs) + instance: T = self.create(FixedCreate(name=self.name, owner=obj)) # type: ignore self.instances[obj] = instance try: if self.created: - created = getattr(obj, self.created) if isinstance(self.created, str) else self.created - created(FixedCreated(owner=obj, obj=instance, name=self.name)) + self.created(FixedCreated(owner=obj, obj=instance, name=self.name)) except Exception: if log := getattr(obj, "log", None): log.exception("Callback `created` failed", obj=self.created) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 4b1388df..fbe3f412 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -9,7 +9,7 @@ import json import uuid import weakref -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar import traitlets from ipywidgets import Widget, register @@ -117,9 +117,9 @@ class Ipylab(WidgetBase): _pending_operations: Dict[str, asyncio.Future] = Dict() _has_attrs_mappings: Container[set[tuple[HasTraits, str]]] = Set() ipylab_tasks: Container[set[asyncio.Task]] = Set() - close_extras: Fixed[weakref.WeakSet[Widget]] = Fixed(weakref.WeakSet) + close_extras: Fixed[Self, weakref.WeakSet[Widget]] = Fixed(weakref.WeakSet) log = Instance(IpylabLoggerAdapter, read_only=True) - app = Fixed(cast(type["ipylab.App"], "ipylab.App")) + app = Fixed(lambda _: ipylab.App()) @property def repr_info(self) -> dict[str, Any] | str: diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 5033e519..3afa95fe 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -6,7 +6,7 @@ import contextlib import functools import inspect -from typing import TYPE_CHECKING, Any, Unpack, override +from typing import TYPE_CHECKING, Any, Self, Unpack, override from ipywidgets import Widget, register from traitlets import Bool, Container, Dict, Instance, Unicode, UseEnum, default, observe @@ -46,11 +46,11 @@ class App(Singular, Ipylab): shell = Fixed(Shell) dialog = Fixed(Dialog) notification = Fixed(NotificationManager) - commands = Fixed(CommandRegistry, name=APP_COMMANDS_NAME) + commands = Fixed(lambda _: CommandRegistry(name=APP_COMMANDS_NAME)) launcher = Fixed(Launcher) main_menu = Fixed(MainMenu) command_pallet = Fixed(CommandPalette) - context_menu = Fixed(ContextMenu, commands=lambda app: app.commands, dynamic=["commands"]) + context_menu: Fixed[Self, ContextMenu] = Fixed(lambda c: ContextMenu(commands=c["owner"].commands)) sessions = Fixed(SessionManager) logging_handler: Instance[IpylabLogHandler | None] = Instance(IpylabLogHandler, allow_none=True) # type: ignore diff --git a/ipylab/log.py b/ipylab/log.py index 88153a8a..c3197f2b 100644 --- a/ipylab/log.py +++ b/ipylab/log.py @@ -6,17 +6,18 @@ import logging import weakref from enum import IntEnum, StrEnum -from typing import TYPE_CHECKING, Any, ClassVar, cast, override +from typing import TYPE_CHECKING, Any, ClassVar, override from IPython.core.ultratb import FormattedTB from ipywidgets import CallbackDispatcher +import ipylab from ipylab.common import Fixed if TYPE_CHECKING: from asyncio import Task + from collections.abc import MutableMapping - import ipylab __all__ = ["LogLevel", "IpylabLogHandler"] @@ -73,7 +74,7 @@ def truncated_repr(obj: Any, maxlen=120, tail="…") -> str: class IpylabLoggerAdapter(logging.LoggerAdapter): - app = Fixed(cast(type["ipylab.App"], "ipylab.App")) + app = Fixed(lambda _: ipylab.App()) def __init__(self, name: str, owner: Any) -> None: logger = logging.getLogger(name) @@ -82,7 +83,7 @@ def __init__(self, name: str, owner: Any) -> None: super().__init__(logger) self.owner_ref = weakref.ref(owner) - def process(self, msg: Any, kwargs: dict[str, Any]) -> tuple[Any, dict[str, Any]]: + def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, MutableMapping[str, Any]]: obj = kwargs.pop("obj", None) kwargs["extra"] = {"owner": self.owner_ref, "obj": obj} return msg, kwargs @@ -91,7 +92,7 @@ def process(self, msg: Any, kwargs: dict[str, Any]) -> tuple[Any, dict[str, Any] class IpylabLogHandler(logging.Handler): _log_notify_task: Task | None = None _loggers: ClassVar[weakref.WeakSet[logging.Logger]] = weakref.WeakSet() - formatter: IpylabLogFormatter + formatter: IpylabLogFormatter # type: ignore def __init__(self, level: LogLevel) -> None: super().__init__(level) @@ -104,7 +105,7 @@ def _add_logger(self, logger: logging.Logger): logger.addHandler(self) @override - def setLevel(self, level: LogLevel) -> None: + def setLevel(self, level: LogLevel) -> None: # type: ignore level = LogLevel(level) super().setLevel(level) for logger in self._loggers: @@ -129,7 +130,7 @@ def register_callback(self, callback, *, remove=False): class IpylabLogFormatter(logging.Formatter): - app = Fixed(cast(type["ipylab.App"], "ipylab.App")) + app = Fixed(lambda _: ipylab.App()) def __init__(self, *, colors: dict[LogLevel, ANSIColors] = COLORS, reset=ANSIColors.reset, **kwargs) -> None: """Initialize the formatter with specified format strings.""" diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py index 527b4fa2..652baf63 100644 --- a/ipylab/log_viewer.py +++ b/ipylab/log_viewer.py @@ -4,7 +4,7 @@ from __future__ import annotations import collections -from typing import TYPE_CHECKING, cast, override +from typing import TYPE_CHECKING, Self, override from ipywidgets import HTML, BoundedIntText, Button, Checkbox, Combobox, Dropdown, HBox, Select, VBox from traitlets import directional_link, link, observe @@ -29,59 +29,60 @@ class LogViewer(Panel): _log_notify_task: None | Task = None _updating = False - info = Fixed(HTML, layout={"flex": "1 0 auto", "margin": "0px 20px 0px 20px"}) - app = Fixed(cast(type["ipylab.App"], "ipylab.App")) + info = Fixed(lambda _: HTML(layout={"flex": "1 0 auto", "margin": "0px 20px 0px 20px"})) + app = Fixed(lambda _: ipylab.App()) log_level = Fixed( - Dropdown, - description="Level", - options=[(v.name.capitalize(), v) for v in LogLevel], - layout={"width": "max-content"}, + lambda _: Dropdown( + description="Level", + options=[(v.name.capitalize(), v) for v in LogLevel], + layout={"width": "max-content"}, + ), ) - buffer_size = Fixed( - BoundedIntText, - description="Buffer size", - min=1, - max=1e6, - layout={"width": "max-content", "flex": "0 0 auto"}, + buffer_size: Fixed[Self, BoundedIntText] = Fixed( + lambda _: BoundedIntText( + description="Buffer size", min=1, max=1e6, layout={"width": "max-content", "flex": "0 0 auto"} + ), created=lambda c: c["obj"].observe(c["owner"]._observe_buffer_size, "value"), # noqa: SLF001 ) button_show_send_dialog = Fixed( - Button, - description="📪", - tooltip="Send the record to the console.\n" - "The record has the properties 'owner' and 'obj'attached " - "which may be of interest for debugging purposes.", - layout={"width": "auto", "flex": "0 0 auto"}, + lambda _: Button( + description="📪", + tooltip="Send the record to the console.\n" + "The record has the properties 'owner' and 'obj'attached " + "which may be of interest for debugging purposes.", + layout={"width": "auto", "flex": "0 0 auto"}, + ), ) button_clear = Fixed( - Button, - description="⌧", - tooltip="Clear log", - layout={"width": "auto", "flex": "0 0 auto"}, + lambda _: Button( + description="⌧", + tooltip="Clear log", + layout={"width": "auto", "flex": "0 0 auto"}, + ), ) autoscroll_enabled = Fixed( - Checkbox, - description="Auto scroll", - indent=False, - tooltip="Automatically scroll to the most recent logs.", - layout={"width": "auto", "flex": "0 0 auto"}, + lambda _: Checkbox( + description="Auto scroll", + indent=False, + tooltip="Automatically scroll to the most recent logs.", + layout={"width": "auto", "flex": "0 0 auto"}, + ), ) - _default_header_children = ( - "info", - "autoscroll_enabled", - "log_level", - "buffer_size", - "button_clear", - "button_show_send_dialog", - ) - header = Fixed( - HBox, - children=lambda owner: [w for v in owner._default_header_children if (w := getattr(owner, v, None))], # noqa: SLF001 - layout={"justify_content": "space-between", "flex": "0 0 auto"}, - dynamic=["children"], + header: Fixed[Self, HBox] = Fixed( + lambda c: HBox( + children=( + c["owner"].info, + c["owner"].autoscroll_enabled, + c["owner"].log_level, + c["owner"].buffer_size, + c["owner"].button_clear, + c["owner"].button_show_send_dialog, + ), + layout={"justify_content": "space-between", "flex": "0 0 auto"}, + ), ) output = Fixed(SimpleOutput) - autoscroll_widget = Fixed(AutoScroll, content=lambda v: v.output, dynamic=["content"]) + autoscroll_widget: Fixed[Self, AutoScroll] = Fixed(lambda c: AutoScroll(content=c["owner"].output)) def __init__(self, buffersize=100): self._records = collections.deque(maxlen=buffersize) diff --git a/ipylab/menu.py b/ipylab/menu.py index b6f1f99a..34718cbd 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, override +from typing import TYPE_CHECKING, Self, override from ipywidgets import TypedTuple from traitlets import Container, Instance, Union @@ -118,7 +118,7 @@ class Menu(Singular, RankedMenu): ipylab_base = IpylabBase(Obj.IpylabModel, "palette").tag(sync=True) commands = Instance(CommandRegistry) - connections: Container[tuple[MenuConnection, ...]] = TypedTuple( + connections: Container[tuple[MenuConnection, ...]] = TypedTuple( # type: ignore trait=Union([Instance(MenuConnection), Instance(MenuItemConnection)]) ) @@ -142,18 +142,34 @@ class MainMenu(Menu): ipylab_base = IpylabBase(Obj.IpylabModel, "mainMenu").tag(sync=True) - file_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.fileMenu")) - edit_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.editMenu")) - view_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.viewMenu")) - run_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.runMenu")) - kernel_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.kernelMenu")) - tabs_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.tabsMenu")) - settings_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.settingsMenu")) - help_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.helpMenu")) + file_menu: Fixed[Self, BuiltinMenu] = Fixed( + lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.fileMenu")) + ) + edit_menu: Fixed[Self, BuiltinMenu] = Fixed( + lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.editMenu")) + ) + view_menu: Fixed[Self, BuiltinMenu] = Fixed( + lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.viewMenu")) + ) + run_menu: Fixed[Self, BuiltinMenu] = Fixed( + lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.runMenu")), + ) + kernel_menu: Fixed[Self, BuiltinMenu] = Fixed( + lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.kernelMenu")) + ) + tabs_menu: Fixed[Self, BuiltinMenu] = Fixed( + lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.tabsMenu")) + ) + help_menu: Fixed[Self, BuiltinMenu] = Fixed( + lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.helpMenu")) + ) + settings_menu: Fixed[Self, BuiltinMenu] = Fixed( + lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.settingsMenu")) + ) @classmethod @override - def get_single_key(cls, **kwgs): + def get_single_key(cls, **kwgs): # type: ignore return cls def __init__(self): @@ -168,7 +184,7 @@ def add_menu(self, menu: MenuConnection, *, update=True, rank: int = 500) -> Tas return self.execute_method("addMenu", menu, update, options, toObject=["args[0]"]) @override - def activate(self): + def activate(self): # type: ignore "Does nothing. Instead you should activate a submenu." @@ -200,5 +216,5 @@ async def add_item_(): return self.to_task(add_item_()) @override - def activate(self): + def activate(self): # type: ignore "Does nothing for a context menu" diff --git a/ipylab/widgets.py b/ipylab/widgets.py index 689761c2..3bb22ee1 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from ipywidgets import Box, DOMWidget, Layout, TypedTuple, register, widget_serialization from ipywidgets.widgets.trait_types import InstanceDict @@ -53,7 +53,7 @@ class Panel(Box): _view_module_version = Unicode(_fe.module_version, read_only=True).tag(sync=True) title: Instance[Title] = InstanceDict(Title, ()).tag(sync=True, **widget_serialization) - app = Fixed(cast(type["ipylab.App"], "ipylab.App")) + app = Fixed(lambda _: ipylab.App()) connections: Container[tuple[ShellConnection, ...]] = TypedTuple(trait=Instance(ShellConnection)) def add_to_shell( diff --git a/tests/test_common.py b/tests/test_common.py index 51351454..ad38418c 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -3,15 +3,15 @@ from __future__ import annotations -from typing import override +from typing import Self, override import pytest from ipywidgets import TypedTuple from traitlets import HasTraits, Unicode +import ipylab from ipylab.common import ( Fixed, - FixedCreate, FixedCreated, LastUpdatedDict, Singular, @@ -25,7 +25,7 @@ class CommonTestClass: - def __init__(self, value): + def __init__(self, value=1): self.value = value @@ -220,50 +220,25 @@ def get_single_key(cls, key: str, **kwgs): class TestFixed: def test_readonly_basic(self): class TestOwner: - test_instance = Fixed(CommonTestClass, 42) + test_instance = Fixed(CommonTestClass) owner = TestOwner() - instance = owner.test_instance - assert isinstance(instance, CommonTestClass) - assert instance.value == 42 - - def test_readonly_dynamic(self): - class TestOwner: - value: int - test_instance = Fixed(CommonTestClass, value=lambda obj: obj.value, dynamic=["value"]) - - owner = TestOwner() - owner.value = 100 assert isinstance(owner.test_instance, CommonTestClass) - assert owner.test_instance.value == 100 + assert owner.test_instance.value == 1 - def test_readonly_create_function(self): + def test_readonly_create_function(self, app: ipylab.App): class TestOwner: - test_instance = Fixed(CommonTestClass, create=lambda info: CommonTestClass(**info["kwgs"]), value=200) + app = Fixed(lambda _: ipylab.App()) owner = TestOwner() - instance = owner.test_instance - assert isinstance(instance, CommonTestClass) - assert instance.value == 200 - - def test_readonly_create_method(self): - class TestOwner: - test_instance = Fixed(CommonTestClass, create="_create_callback", value=200) - - def _create_callback(self, info: FixedCreate): - assert info["owner"] is self - assert info["klass"] is CommonTestClass - assert info["kwgs"] == {"value": 200} - return CommonTestClass(*info["args"], **info["kwgs"]) - - owner = TestOwner() - instance = owner.test_instance - assert isinstance(instance, CommonTestClass) - assert instance.value == 200 + assert owner.app is app def test_readonly_created_callback_method(self): class TestOwner: - test_instance = Fixed(CommonTestClass, created="instance_created", value=300) + test_instance: Fixed[Self, CommonTestClass] = Fixed( + lambda _: CommonTestClass(value=300), + created=lambda c: c["owner"].instance_created(c), + ) def instance_created(self, info: FixedCreated): assert isinstance(info["obj"], CommonTestClass) @@ -276,8 +251,16 @@ def instance_created(self, info: FixedCreated): def test_readonly_forbidden_set(self): class TestOwner: - test_instance = Fixed(CommonTestClass, 42) + test_instance = Fixed(CommonTestClass) + + owner = TestOwner() + with pytest.raises(AttributeError, match="Setting TestOwner.test_instance is forbidden!"): + owner.test_instance = CommonTestClass() + + def test_readonly_lambda(self): + class TestOwner: + test_instance = Fixed(lambda _: CommonTestClass()) owner = TestOwner() with pytest.raises(AttributeError, match="Setting TestOwner.test_instance is forbidden!"): - owner.test_instance = CommonTestClass(100) + owner.test_instance = CommonTestClass() From c407783adfbe2f5effb0cfa2a1c52feee9cfdba7 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Mon, 17 Mar 2025 14:38:50 +1100 Subject: [PATCH 11/47] Update VS Code settings and README for Pyright integration; adjust type checking mode in pyproject.toml --- .vscode/settings.json | 1 - README.md | 4 ++++ pyproject.toml | 4 ++++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 34f69655..1798bf64 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,6 @@ "editor.formatOnSave": true, "python.terminal.activateEnvInCurrentTerminal": true, "python.createEnvironment.trigger": "prompt", - "python.analysis.typeCheckingMode": "standard", "python.testing.pytestArgs": ["tests"], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true diff --git a/README.md b/README.md index 92d6274a..3c94b09d 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,10 @@ jlpm lint #or jlpm lint:check +# Pyright + +pip install pyright[nodejs] +pyright ``` ### VS code debugging diff --git a/pyproject.toml b/pyproject.toml index 4508aafb..111d3354 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,3 +114,7 @@ docstring-code-format = true [tool.ruff.lint.per-file-ignores] "tests*" = ['ARG002', 'SLF001', 'S101', 'PLR2004'] + +[tool.pyright] +include = ["ipylab", 'examples', 'tests'] +typeCheckingMode = 'standard' From 064f804021d35c9de068f2f16846b8e493b0b099 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Tue, 18 Mar 2025 08:01:22 +1100 Subject: [PATCH 12/47] Add docstring to Fixed descriptor class for improved clarity and usage examples --- ipylab/common.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ipylab/common.py b/ipylab/common.py index fc93266c..e2795e5d 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -409,6 +409,26 @@ class FixedCreated(Generic[S, T], TypedDict): class Fixed(Generic[S, T]): + """Descriptor for creating and caching a fixed instance of a class. + + The ``Fixed`` descriptor provisions for each instance of the owner class + to dynamically load or import the managed class. The managed instance + is created on first access and then cached for subsequent access. + + Type Hints: + ``S``: Type of the owner class. + ``T``: Type of the managed class. + + Examples: + >>> class MyClass: + ... fixed_instance = Fixed(ManagedClass) + >>> my_object = MyClass() + >>> instance1 = my_object.fixed_instance + >>> instance2 = my_object.fixed_instance + >>> instance1 is instance2 + True + """ + __slots__ = ["name", "instances", "create", "created"] def __init__( @@ -429,7 +449,6 @@ def __init__( case _: msg = "'create' must be a callable the accepts None or one argument." raise ValueError(msg) - elif isinstance(create, str): self.create = lambda _: import_item(create)() else: From 51b81dbdcc22c0818fcbd9573e2d9fa032edfa08 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Tue, 18 Mar 2025 08:12:46 +1100 Subject: [PATCH 13/47] Clarify error message for forbidden attribute setting in Fixed class --- ipylab/common.py | 2 +- tests/test_common.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ipylab/common.py b/ipylab/common.py index e2795e5d..56778b66 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -478,5 +478,5 @@ def __get__(self, obj: S, objtype=None) -> T: return self.instances[obj] def __set__(self, obj, value): - msg = f"Setting {obj.__class__.__name__}.{self.name} is forbidden!" + msg = f"Setting `Fixed` parameter {obj.__class__.__name__}.{self.name} is forbidden!" raise AttributeError(msg) diff --git a/tests/test_common.py b/tests/test_common.py index ad38418c..f9c46e1c 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -254,7 +254,7 @@ class TestOwner: test_instance = Fixed(CommonTestClass) owner = TestOwner() - with pytest.raises(AttributeError, match="Setting TestOwner.test_instance is forbidden!"): + with pytest.raises(AttributeError, match="Setting `Fixed` parameter TestOwner.test_instance is forbidden!"): owner.test_instance = CommonTestClass() def test_readonly_lambda(self): @@ -262,5 +262,5 @@ class TestOwner: test_instance = Fixed(lambda _: CommonTestClass()) owner = TestOwner() - with pytest.raises(AttributeError, match="Setting TestOwner.test_instance is forbidden!"): + with pytest.raises(AttributeError, match="Setting `Fixed` parameter TestOwner.test_instance is forbidden!"): owner.test_instance = CommonTestClass() From 8cb9d70402d05420f98253e132aa92e0aef246b9 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Tue, 18 Mar 2025 12:56:18 +1100 Subject: [PATCH 14/47] Refactor Fixed for faster loading. --- ipylab/common.py | 48 +++++++++++++++++++------------------------- tests/test_common.py | 6 ++++++ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/ipylab/common.py b/ipylab/common.py index 56778b66..aa3877b0 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -360,7 +360,7 @@ class Singular(HasTraits): _limited_init_complete = False _single_instances: ClassVar[dict[Hashable, Self]] = {} - _single_key = AnyTrait(read_only=True) + _single_key = AnyTrait(default_value=None, allow_none=True, read_only=True) closed = Bool(read_only=True) @classmethod @@ -387,7 +387,8 @@ def __init_subclass__(cls) -> None: cls._single_instances = {} def close(self): - self._single_instances.pop(self._single_key, None) + if self._single_key is not None: + self._single_instances.pop(self._single_key, None) if callable(close := getattr(super(), "close", None)): close() self.set_trait("closed", True) @@ -433,30 +434,20 @@ class Fixed(Generic[S, T]): def __init__( self, - create: type[T] | Callable[[FixedCreate[S]], T] | str, + obj: type[T] | Callable[[FixedCreate[S]], T] | str, /, *, created: Callable[[FixedCreated[S, T]]] | None = None, ): - if inspect.isclass(create): - self.create = lambda _: create() # type: ignore - elif callable(create): - match len(inspect.signature(create).parameters): - case 0: - self.create = lambda _: create() # type: ignore - case 1: - self.create = create - case _: - msg = "'create' must be a callable the accepts None or one argument." - raise ValueError(msg) - elif isinstance(create, str): - self.create = lambda _: import_item(create)() + if inspect.isclass(obj): + self.create = lambda _: obj() # type: ignore + elif callable(obj): + self.create = obj + elif isinstance(obj, str): + self.create = lambda _: import_item(obj)() else: - msg = "Unsure how to create" + msg = f"{obj=} is invalid. Wrap it with a lambda to make it 'constant'. Eg. lambda _: {obj}" raise TypeError(msg) - if callable(created) and len(inspect.signature(created).parameters) != 1: - msg = "'created' must be a callable the accepts one argument." - raise ValueError(msg) self.created = created self.instances = weakref.WeakKeyDictionary() @@ -466,16 +457,19 @@ def __set_name__(self, owner_cls, name: str): def __get__(self, obj: S, objtype=None) -> T: if obj is None: return self # type: ignore - if obj not in self.instances: + try: + return self.instances[obj] + except KeyError: instance: T = self.create(FixedCreate(name=self.name, owner=obj)) # type: ignore self.instances[obj] = instance - try: - if self.created: + if self.created: + try: self.created(FixedCreated(owner=obj, obj=instance, name=self.name)) - except Exception: - if log := getattr(obj, "log", None): - log.exception("Callback `created` failed", obj=self.created) - return self.instances[obj] + except Exception: + if log := getattr(obj, "log", None): + msg = f"Callback `created` failed for {obj.__class__}.{self.name}" + log.exception(msg, obj=self.created) + return instance def __set__(self, obj, value): msg = f"Setting `Fixed` parameter {obj.__class__.__name__}.{self.name} is forbidden!" diff --git a/tests/test_common.py b/tests/test_common.py index f9c46e1c..8b107749 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -229,9 +229,15 @@ class TestOwner: def test_readonly_create_function(self, app: ipylab.App): class TestOwner: app = Fixed(lambda _: ipylab.App()) + app1: Fixed[Self, ipylab.App] = Fixed("ipylab.App") owner = TestOwner() assert owner.app is app + assert owner.app1 is app + + def test_readonly_create_invalid(self, app): + with pytest.raises(TypeError): + assert Fixed(123) # type: ignore def test_readonly_created_callback_method(self): class TestOwner: From 32fd5036a0bccfb238fbb6c6c4991602a6cee0cf Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Tue, 18 Mar 2025 22:11:00 +1100 Subject: [PATCH 15/47] Add Fixed to module exports for improved accessibility --- ipylab/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ipylab/__init__.py b/ipylab/__init__.py index 21b9d97c..bd9c344f 100644 --- a/ipylab/__init__.py +++ b/ipylab/__init__.py @@ -6,7 +6,7 @@ from ipylab import common, menu from ipylab._frontend import module_version as __version__ from ipylab.code_editor import CodeEditor -from ipylab.common import Area, InsertMode, Obj, Transform, hookimpl, pack, to_selector +from ipylab.common import Area, Fixed, InsertMode, Obj, Transform, hookimpl, pack, to_selector from ipylab.connection import Connection, ShellConnection from ipylab.ipylab import Ipylab from ipylab.jupyterfrontend import App, JupyterFrontEnd @@ -19,6 +19,7 @@ "common", "CodeEditor", "Connection", + "Fixed", "ShellConnection", "SimpleOutput", "Panel", From 15b4255d66bbfea0c09d9da8d110e90948642679 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 20 Mar 2025 11:43:40 +1100 Subject: [PATCH 16/47] Remove unused ready hook specification and related callback execution in Ipylab class --- ipylab/hookspecs.py | 5 ----- ipylab/ipylab.py | 2 -- ipylab/lib.py | 6 ------ 3 files changed, 13 deletions(-) diff --git a/ipylab/hookspecs.py b/ipylab/hookspecs.py index 2598912f..67325c47 100644 --- a/ipylab/hookspecs.py +++ b/ipylab/hookspecs.py @@ -23,11 +23,6 @@ def launch_jupyterlab(): """ -@hookspec() -async def ready(obj: ipylab.Ipylab) -> None | Awaitable[None]: - """A hook that is called by `obj` when it is ready.""" - - @hookspec(historic=True) async def autostart_once(app: ipylab.App) -> None | Awaitable[None]: """A hook that is called when the `app` is ready for the first time. diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index fbe3f412..1c83ecce 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -165,8 +165,6 @@ def _observe_comm(self, change: dict): if self._ready: if self._ready_event: self._ready_event.set() - for cb in ipylab.plugin_manager.hook.ready(obj=self): - self.ensure_run(cb) for cb in self._on_ready_callbacks: self.ensure_run(cb) elif self._ready_event: diff --git a/ipylab/lib.py b/ipylab/lib.py index ede05d8b..5cfa25ff 100644 --- a/ipylab/lib.py +++ b/ipylab/lib.py @@ -14,7 +14,6 @@ from collections.abc import Awaitable from ipylab import App - from ipylab.ipylab import Ipylab @hookimpl @@ -50,11 +49,6 @@ def vpath_getter(app: App, kwgs: dict) -> Awaitable[str] | str: return app.dialog.get_text(**kwgs) -@hookimpl -def ready(obj: Ipylab): - "Pass through" - - @hookimpl def default_editor_key_bindings(app: ipylab.App, obj: ipylab.CodeEditor): # noqa: ARG001 return {} From 8900681e9d24328993d0a5f6a78d7a72683a4d56 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 20 Mar 2025 14:31:25 +1100 Subject: [PATCH 17/47] Refactor readiness handling in Ipylab class to use for futures instead of events. --- ipylab/ipylab.py | 32 ++++++++++++-------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 1c83ecce..4c304fc6 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -111,7 +111,7 @@ class Ipylab(WidgetBase): _on_ready_callbacks: Container[list[Callable[[], None | Awaitable] | Callable[[Self], None | Awaitable]]] = List( trait=traitlets.Callable() ) - _ready_event: asyncio.Event | None = None + _ready_futures: Fixed[Self, set[asyncio.Future]] = Fixed(lambda _: set()) _comm = None _ipylab_init_complete = False _pending_operations: Dict[str, asyncio.Future] = Dict() @@ -161,14 +161,12 @@ def __repr__(self): def _observe_comm(self, change: dict): if not self.comm: self.close() - if change["name"] == "_ready": - if self._ready: - if self._ready_event: - self._ready_event.set() - for cb in self._on_ready_callbacks: - self.ensure_run(cb) - elif self._ready_event: - self._ready_event.clear() + if change["name"] == "_ready" and self._ready: + for f in self._ready_futures: + f.set_result(True) + self._ready_futures.clear() + for cb in self._on_ready_callbacks: + self.ensure_run(cb) def close(self): if self.comm: @@ -341,17 +339,11 @@ async def ready(self) -> Self: if app is not self and not app._ready: # noqa: SLF001 await app.ready() if not self._ready: # type: ignore - if self._ready_event: - try: - await self._ready_event.wait() - # Event.wait is pinned to the event loop in which Event was created. - # A Runtime error will occur when called from a different event loop. - except RuntimeError: - pass - else: - return self - self._ready_event = asyncio.Event() - await self._ready_event.wait() + future = asyncio.get_running_loop().create_future() + self._ready_futures.add(future) + if not self._ready: + await future + self._ready_futures.discard(future) return self def on_ready(self, callback, remove=False): # noqa: FBT002 From 9fc005147b36225abdcb1b4aed6b6e344c60de1d Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 20 Mar 2025 18:33:42 +1100 Subject: [PATCH 18/47] Remove default_editor_key_bindings hook and clean up related code in CodeEditor and lib --- ipylab/code_editor.py | 2 +- ipylab/hookspecs.py | 5 ----- ipylab/lib.py | 5 ----- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/ipylab/code_editor.py b/ipylab/code_editor.py index db550e81..a75207f2 100644 --- a/ipylab/code_editor.py +++ b/ipylab/code_editor.py @@ -230,7 +230,7 @@ def _default_key_bindings(self): "evaluate": ["Shift Enter"], "undo": ["Ctrl Z"], "redo": ["Ctrl Shift Z"], - } | ipylab.plugin_manager.hook.default_editor_key_bindings(app=self.app, obj=self) + } @default("evaluate") def _default_evaluate(self): diff --git a/ipylab/hookspecs.py b/ipylab/hookspecs.py index 67325c47..9b7dc559 100644 --- a/ipylab/hookspecs.py +++ b/ipylab/hookspecs.py @@ -60,8 +60,3 @@ def vpath_getter(app: ipylab.App, kwgs: dict) -> Awaitable[str] | str: # type: This hook provides for dynamic determination of the vpath/kernel to use when adding 'evaluate' code to the shell. The default behaviour is prompt the user for a path.""" - - -@hookspec(firstresult=True) -def default_editor_key_bindings(app: ipylab.App, obj: ipylab.CodeEditor): - """Get the key bindings to use for the editor.""" diff --git a/ipylab/lib.py b/ipylab/lib.py index 5cfa25ff..4429e317 100644 --- a/ipylab/lib.py +++ b/ipylab/lib.py @@ -49,11 +49,6 @@ def vpath_getter(app: App, kwgs: dict) -> Awaitable[str] | str: return app.dialog.get_text(**kwgs) -@hookimpl -def default_editor_key_bindings(app: ipylab.App, obj: ipylab.CodeEditor): # noqa: ARG001 - return {} - - @hookimpl def default_namespace_objects(namespace_id: str, app: ipylab.App): return {"ipylab": ipylab, "ipw": ipywidgets, "app": app, "namespace_id": namespace_id} From 28b5d4d89ec526a4c129ec4ff1bbf6a8e78a3e95 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Fri, 21 Mar 2025 10:43:06 +1100 Subject: [PATCH 19/47] Improve logging format in Ipylab class for better clarity during exception handling --- ipylab/ipylab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 4c304fc6..fe718be9 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -196,7 +196,7 @@ async def _wrap_awaitable(self, aw: Awaitable[T], hooks: TaskHookType) -> T: try: result = await aw except Exception: - self.log.exception("Awaiting %s", aw, obj={"hooks": hooks, "aw": aw}) + self.log.exception(f"Awaiting {aw}", obj={"hooks": hooks, "aw": aw}) # noqa: G004 raise else: if hooks: From bc8b1e93f7cdd9720af05b02803b949b65a20558 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Fri, 21 Mar 2025 17:25:33 +1100 Subject: [PATCH 20/47] Update Python version in conda environment setup to 3.12 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3c94b09d..6c4534e1 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ jupyter lab ```bash # create a new conda environment -mamba create -n ipylab -c conda-forge nodejs python=3.11 -y +mamba create -n ipylab -c conda-forge nodejs python=3.12 -y # activate the environment conda activate ipylab From 1baaed8493e7962946cea566cb2a66c52de84b88 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 27 Mar 2025 17:29:56 +1100 Subject: [PATCH 21/47] Mark App as final. --- ipylab/jupyterfrontend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 3afa95fe..78017a69 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -6,7 +6,7 @@ import contextlib import functools import inspect -from typing import TYPE_CHECKING, Any, Self, Unpack, override +from typing import TYPE_CHECKING, Any, Self, Unpack, final, override from ipywidgets import Widget, register from traitlets import Bool, Container, Dict, Instance, Unicode, UseEnum, default, observe @@ -29,6 +29,7 @@ from typing import ClassVar +@final @register class App(Singular, Ipylab): """A connection to the 'app' in the frontend. From c690e108b9b27b338d6af7564d9f15d84d1fbd2e Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 27 Mar 2025 17:30:09 +1100 Subject: [PATCH 22/47] Fix type hint in Fixed class __get__ method for better clarity --- ipylab/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ipylab/common.py b/ipylab/common.py index aa3877b0..27933c9c 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -454,7 +454,7 @@ def __init__( def __set_name__(self, owner_cls, name: str): self.name = name - def __get__(self, obj: S, objtype=None) -> T: + def __get__(self, obj: Any, objtype=None) -> T: if obj is None: return self # type: ignore try: @@ -469,7 +469,7 @@ def __get__(self, obj: S, objtype=None) -> T: if log := getattr(obj, "log", None): msg = f"Callback `created` failed for {obj.__class__}.{self.name}" log.exception(msg, obj=self.created) - return instance + return instance # type: ignore def __set__(self, obj, value): msg = f"Setting `Fixed` parameter {obj.__class__.__name__}.{self.name} is forbidden!" From 2d6531d27ab710332783f2003f34ca9ea1dbdc7b Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 2 Apr 2025 16:09:17 +1100 Subject: [PATCH 23/47] Make threadsafe --- ipylab/hookspecs.py | 5 +++++ ipylab/ipylab.py | 17 ++++++++++------- tests/conftest.py | 3 +++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/ipylab/hookspecs.py b/ipylab/hookspecs.py index 9b7dc559..a263cdf4 100644 --- a/ipylab/hookspecs.py +++ b/ipylab/hookspecs.py @@ -3,6 +3,7 @@ from __future__ import annotations +import asyncio from typing import TYPE_CHECKING, Any import pluggy @@ -60,3 +61,7 @@ def vpath_getter(app: ipylab.App, kwgs: dict) -> Awaitable[str] | str: # type: This hook provides for dynamic determination of the vpath/kernel to use when adding 'evaluate' code to the shell. The default behaviour is prompt the user for a path.""" + +@hookspec(firstresult=True) +def get_asyncio_loop(app: ipylab.App) -> asyncio.AbstractEventLoop: # type: ignore + "Get the asyncio event loop." diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index fe718be9..1b9d17c7 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -121,6 +121,7 @@ class Ipylab(WidgetBase): log = Instance(IpylabLoggerAdapter, read_only=True) app = Fixed(lambda _: ipylab.App()) + @property def repr_info(self) -> dict[str, Any] | str: "Extra info to provide for __repr__." @@ -162,8 +163,9 @@ def _observe_comm(self, change: dict): if not self.comm: self.close() if change["name"] == "_ready" and self._ready: - for f in self._ready_futures: - f.set_result(True) + for f in tuple(self._ready_futures): + loop = f.get_loop() + loop.call_soon_threadsafe(f.set_result, True) self._ready_futures.clear() for cb in self._on_ready_callbacks: self.ensure_run(cb) @@ -266,10 +268,11 @@ def _on_custom_msg(self, _, msg: dict, buffers: list): c = json.loads(content) if "ipylab_PY" in c: op = self._pending_operations.pop(c["ipylab_PY"]) + loop = op.get_loop() if "error" in c: - op.set_exception(self._to_frontend_error(c)) + loop.call_soon_threadsafe(op.set_exception, self._to_frontend_error(c)) else: - op.set_result(c.get("payload")) + loop.call_soon_threadsafe(op.set_result, c.get("payload")) elif "ipylab_FE" in c: return self.to_task(self._do_operation_for_fe(c["ipylab_FE"], c["operation"], c["payload"], buffers)) elif "closed" in c: @@ -339,7 +342,7 @@ async def ready(self) -> Self: if app is not self and not app._ready: # noqa: SLF001 await app.ready() if not self._ready: # type: ignore - future = asyncio.get_running_loop().create_future() + future = self.app.asyncio_loop.create_future() self._ready_futures.add(future) if not self._ready: await future @@ -403,7 +406,7 @@ def to_task(self, aw: Awaitable[T], name: str | None = None, *, hooks: TaskHookT """ self._check_closed() - task = asyncio.eager_task_factory(asyncio.get_running_loop(), self._wrap_awaitable(aw, hooks), name=name) + task = asyncio.eager_task_factory(self.app.asyncio_loop, self._wrap_awaitable(aw, hooks), name=name) if not task.done(): self.ipylab_tasks.add(task) task.add_done_callback(self._task_done_callback) @@ -456,7 +459,7 @@ def operation( if toObject: content["toObject"] = toObject - self._pending_operations[ipylab_PY] = op = asyncio.get_running_loop().create_future() + self._pending_operations[ipylab_PY] = op = self.app.asyncio_loop.create_future() async def _operation(content: dict): self._ipylab_send(content) diff --git a/tests/conftest.py b/tests/conftest.py index 81df36ff..72b40be9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +import asyncio + import pytest import ipylab @@ -17,4 +19,5 @@ async def anyio_backend_autouse(anyio_backend): async def app(mocker): app = ipylab.App() mocker.patch.object(app, "ready") + app.asyncio_loop = asyncio.get_running_loop() return app From a4fd53e9afc045c9eb3bf428a533f4ea2e2b6d62 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 2 Apr 2025 16:10:22 +1100 Subject: [PATCH 24/47] temp --- examples/widgets.ipynb | 249 +++++++++++++++++++++++++++++------------ 1 file changed, 179 insertions(+), 70 deletions(-) diff --git a/examples/widgets.ipynb b/examples/widgets.ipynb index 1161c99a..ca3d2f5f 100644 --- a/examples/widgets.ipynb +++ b/examples/widgets.ipynb @@ -38,15 +38,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ "import ipywidgets as ipw\n", "\n", "import ipylab\n", + "import asyncio\n", "\n", - "app = ipylab.App()" + "app = await ipylab.App().ready()" ] }, { @@ -60,7 +61,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -80,32 +81,45 @@ "metadata": {}, "outputs": [], "source": [ - "t = panel.add_to_shell(mode=ipylab.InsertMode.split_right, activate=False)" + "sc = await panel.add_to_shell(mode=ipylab.InsertMode.split_right, activate=False, as_coro=True)" ] }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "sc = t.result()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "sc in panel.connections" ] }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "sc in app.shell.connections" ] @@ -120,9 +134,20 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " wait_for= cb=[Ipylab._task_done_callback()]>" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "sc.activate() # Will activate before returning to this notebook" ] @@ -136,7 +161,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -152,25 +177,16 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t = panel.add_to_shell(mode=ipylab.InsertMode.split_right, activate=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, + "execution_count": 15, "metadata": {}, "outputs": [], "source": [ - "sc = t.result()" + "sc = await panel.add_to_shell(mode=ipylab.InsertMode.split_right, activate=False)" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "metadata": {}, "outputs": [], "source": [ @@ -187,7 +203,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "metadata": {}, "outputs": [], "source": [ @@ -203,7 +219,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 18, "metadata": {}, "outputs": [], "source": [ @@ -219,9 +235,20 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "< Not ready: ShellConnection(cid='ipylab-ShellConnection|50c7eed6-9fbd-4069-a300-757e02e89ff2') >" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "sc" ] @@ -235,9 +262,20 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " wait_for= wait_for= cb=[Ipylab._task_done_callback(), Task.task_wakeup()]> cb=[Ipylab._task_done_callback()]>" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "slider = ipw.IntSlider()\n", "app.shell.add(slider, area=ipylab.Area.top)" @@ -252,7 +290,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 21, "metadata": {}, "outputs": [], "source": [ @@ -270,9 +308,25 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "560500a795184e6c81f5c7672ba0a6f7", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "Box(children=(SplitPanel(children=(IntProgress(value=7, bar_style='info', description='Loading:', layout=Layou…" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "split_panel = ipylab.SplitPanel()\n", "progress = ipw.IntProgress(\n", @@ -302,7 +356,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 23, "metadata": {}, "outputs": [], "source": [ @@ -319,9 +373,20 @@ }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + " wait_for= wait_for= cb=[Ipylab._task_done_callback(), Task.task_wakeup()]> cb=[Ipylab._task_done_callback()]>" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "split_panel.add_to_shell(area=ipylab.Area.main, mode=ipylab.InsertMode.split_bottom)" ] @@ -335,7 +400,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 25, "metadata": {}, "outputs": [], "source": [ @@ -351,7 +416,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 26, "metadata": {}, "outputs": [], "source": [ @@ -367,7 +432,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 27, "metadata": {}, "outputs": [], "source": [ @@ -383,7 +448,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 28, "metadata": {}, "outputs": [], "source": [ @@ -403,12 +468,12 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 29, "metadata": {}, "outputs": [], "source": [ "split_panel.add_to_shell(area=ipylab.Area.left, rank=1000)\n", - "split_panel.connections[0].activate()" + "await split_panel.connections[0].activate()" ] }, { @@ -420,21 +485,21 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 30, "metadata": {}, "outputs": [], "source": [ "split_panel.add_to_shell(area=ipylab.Area.right, rank=1000)\n", - "split_panel.connections[0].activate()" + "await split_panel.connections[0].activate()" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 31, "metadata": {}, "outputs": [], "source": [ - "t = app.shell.collapse_right()" + "await app.shell.collapse_right()" ] }, { @@ -450,18 +515,41 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 32, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "< Not ready: ShellConnection(cid='ipylab-ShellConnection|ba333e5f-5d65-4bdb-8d47-4ab6df532677') >" + ] + }, + "execution_count": 32, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "split_panel.add_to_shell(cid=ipylab.ShellConnection.to_cid(), mode=ipylab.InsertMode.split_right)" + "await split_panel.add_to_shell(cid=ipylab.ShellConnection.to_cid(), mode=ipylab.InsertMode.split_right)" ] }, { "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], + "execution_count": 33, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(ShellConnection(cid='ipylab-ShellConnection|d3aa35a1-f0a0-4617-9fb8-0e77cc48f996'),\n", + " < Not ready: ShellConnection(cid='ipylab-ShellConnection|ba333e5f-5d65-4bdb-8d47-4ab6df532677') >)" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "split_panel.connections[0].activate()\n", "split_panel.connections" @@ -469,11 +557,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 34, "metadata": {}, - "outputs": [], + "outputs": [ + { + "ename": "NameError", + "evalue": "name 'fail' is not defined", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mNameError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[34]\u001b[39m\u001b[32m, line 2\u001b[39m\n\u001b[32m 1\u001b[39m split_panel.close()\n\u001b[32m----> \u001b[39m\u001b[32m2\u001b[39m \u001b[43mfail\u001b[49m\n\u001b[32m 3\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m asyncio.sleep(\u001b[32m1\u001b[39m)\n", + "\u001b[31mNameError\u001b[39m: name 'fail' is not defined" + ] + } + ], "source": [ - "split_panel.close()" + "split_panel.close()\n", + "fail\n", + "await asyncio.sleep(1)" ] }, { @@ -484,6 +586,13 @@ "source": [ "split_panel.connections" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { From df462c2b5746b0e9a585087b6d768af6bde63f14 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 2 Apr 2025 16:32:19 +1100 Subject: [PATCH 25/47] Asyncio loop in app --- ipylab/jupyterfrontend.py | 6 ++++++ ipylab/lib.py | 12 ++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 78017a69..f8ee6f92 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -3,6 +3,7 @@ from __future__ import annotations +import asyncio import contextlib import functools import inspect @@ -56,6 +57,7 @@ class App(Singular, Ipylab): logging_handler: Instance[IpylabLogHandler | None] = Instance(IpylabLogHandler, allow_none=True) # type: ignore log_level = UseEnum(LogLevel, LogLevel.ERROR) + asyncio_loop = Instance(asyncio.AbstractEventLoop, help="The asyncio loop to use for scheduling tasks") namespaces: Container[dict[str, LastUpdatedDict]] = Dict(read_only=True) # type: ignore @@ -71,6 +73,10 @@ def _default_logging_handler(self): handler.setFormatter(IpylabLogFormatter(fmt=fmt, style="%", datefmt="%H:%M:%S")) return handler + @default("asyncio_loop") + def _default_comm(self): + return ipylab.plugin_manager.hook.get_asyncio_loop(app=self) + @observe("_ready", "log_level") def _app_observe_ready(self, change): if change["name"] == "_ready" and self._ready: diff --git a/ipylab/lib.py b/ipylab/lib.py index 4429e317..9f3d8b23 100644 --- a/ipylab/lib.py +++ b/ipylab/lib.py @@ -3,6 +3,7 @@ from __future__ import annotations +import asyncio from typing import TYPE_CHECKING import ipywidgets @@ -32,10 +33,10 @@ async def autostart(app: ipylab.App) -> None | Awaitable[None]: # Register some default context menu items for Ipylab # To prevent registering the command use app.DEFAULT_COMMANDS.discard() in another autostart hookimpl. if "Open console" in app.DEFAULT_COMMANDS: - cmd = await app.commands.add_command("Open console", app.shell.open_console) + cmd = await app.commands.add_command("Open console", app.shell.open_console, as_coro=True) await app.context_menu.add_item(command=cmd, rank=70) if "Show log viewer" in app.DEFAULT_COMMANDS: - cmd = await app.commands.add_command("Show log viewer", app.shell.log_viewer.add_to_shell) + cmd = await app.commands.add_command("Show log viewer", app.shell.log_viewer.add_to_shell, as_coro=True) await app.context_menu.add_item(command=cmd, rank=71) @@ -52,3 +53,10 @@ def vpath_getter(app: App, kwgs: dict) -> Awaitable[str] | str: @hookimpl def default_namespace_objects(namespace_id: str, app: ipylab.App): return {"ipylab": ipylab, "ipw": ipywidgets, "app": app, "namespace_id": namespace_id} + + +@hookimpl +def get_asyncio_loop(app: ipylab.App): + if (kernel := getattr(app.comm, "kernel", None)) and (loop := getattr(kernel, "asyncio_event_loop", None)): + return loop + return asyncio.get_running_loop() From 48045a25226ddba89df83189b6bf8b725502c29c Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 6 Apr 2025 16:20:54 +1000 Subject: [PATCH 26/47] Make most functions async instead of tasks. Make anyio compatible and threadsafe. Add functions to run coroutines. --- examples/code_editor.ipynb | 32 ++-- examples/commands.ipynb | 57 +++--- examples/icons.ipynb | 36 ++-- examples/menu.ipynb | 93 ++------- examples/notifications.ipynb | 30 ++- examples/plugins.ipynb | 38 ++-- examples/resize_box.ipynb | 22 ++- examples/sessions.ipynb | 26 +-- examples/simple_output.ipynb | 112 +++++------ examples/widgets.ipynb | 235 ++++++----------------- ipylab/__main__.py | 2 +- ipylab/code_editor.py | 27 +-- ipylab/commands.py | 202 +++++++++----------- ipylab/common.py | 108 ++++++----- ipylab/connection.py | 14 +- ipylab/css_stylesheet.py | 44 ++--- ipylab/dialog.py | 67 +++---- ipylab/hookspecs.py | 8 +- ipylab/ipylab.py | 347 ++++++++++++++-------------------- ipylab/jupyterfrontend.py | 26 +-- ipylab/launcher.py | 31 ++- ipylab/lib.py | 26 +-- ipylab/log.py | 2 - ipylab/log_viewer.py | 155 +++++++++------ ipylab/menu.py | 57 +++--- ipylab/notification.py | 82 ++++---- ipylab/sessions.py | 17 +- ipylab/shell.py | 127 ++++++------- ipylab/simple_output.py | 12 +- ipylab/widgets.py | 49 ++--- pyproject.toml | 1 + ruff_defaults.toml | 2 - style/widget.css | 4 +- tests/conftest.py | 4 +- tests/test_common.py | 38 ++-- tests/test_ipylab.py | 6 +- tests/test_jupyterfrontend.py | 48 +---- 37 files changed, 907 insertions(+), 1280 deletions(-) diff --git a/examples/code_editor.ipynb b/examples/code_editor.ipynb index cd0a009f..3cb0d39c 100644 --- a/examples/code_editor.ipynb +++ b/examples/code_editor.ipynb @@ -33,7 +33,7 @@ "metadata": {}, "outputs": [], "source": [ - "import asyncio\n", + "import anyio\n", "\n", "import ipylab\n", "from ipylab.code_editor import CodeEditorOptions" @@ -55,8 +55,9 @@ " layout={\"height\": \"120px\", \"overflow\": \"hidden\"},\n", " description_allow_html=True,\n", ")\n", - "asyncio.get_event_loop().call_later(0.5, ce.focus)\n", - "ce" + "display(ce)\n", + "await ce.ready()\n", + "ce.focus()" ] }, { @@ -123,12 +124,11 @@ "\n", "\n", "async def test():\n", - " import asyncio\n", " import random\n", "\n", " for _ in range(20):\n", " ce.value = random.choice(values) # noqa: S311\n", - " await asyncio.sleep(random.randint(10, 300) / 1e3) # noqa: S311" + " await anyio.sleep(random.randint(10, 300) / 1e3) # noqa: S311" ] }, { @@ -148,7 +148,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = ce.to_task(test())" + "await test()" ] }, { @@ -157,16 +157,6 @@ "id": "11", "metadata": {}, "outputs": [], - "source": [ - "t.cancel()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "12", - "metadata": {}, - "outputs": [], "source": [ "# Place the label above\n", "ce.layout.flex_flow = \"column\"" @@ -175,17 +165,17 @@ { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "12", "metadata": {}, "outputs": [], "source": [ "# Add the same editor to the shell.\n", - "ce.app.shell.add(ce)" + "await ce.app.shell.add(ce)" ] }, { "cell_type": "markdown", - "id": "14", + "id": "13", "metadata": {}, "source": [ "### Other mime_types\n", @@ -196,7 +186,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -206,7 +196,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "15", "metadata": {}, "outputs": [], "source": [ diff --git a/examples/commands.ipynb b/examples/commands.ipynb index 27059ecc..d7452385 100644 --- a/examples/commands.ipynb +++ b/examples/commands.ipynb @@ -24,8 +24,8 @@ "source": [ "import ipylab\n", "\n", - "app = ipylab.App()\n", - "app.commands" + "app = await ipylab.App().ready()\n", + "await app.commands.ready()" ] }, { @@ -50,7 +50,8 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.all_commands" + "out = ipylab.SimpleOutput(layout={\"height\": \"200px\", \"overflow\": \"auto\"}).add_class(\"ipylab-ResizeBox\")\n", + "out.push(app.commands.all_commands)" ] }, { @@ -66,7 +67,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute(\n", + "await app.commands.execute(\n", " \"console:create\",\n", " {\n", " \"insertMode\": \"split-right\",\n", @@ -90,7 +91,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute(\"apputils:change-theme\", {\"theme\": \"JupyterLab Dark\"})" + "await app.commands.execute(\"apputils:change-theme\", {\"theme\": \"JupyterLab Dark\"})" ] }, { @@ -99,7 +100,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute(\"apputils:change-theme\", {\"theme\": \"JupyterLab Light\"})" + "await app.commands.execute(\"apputils:change-theme\", {\"theme\": \"JupyterLab Light\"})" ] }, { @@ -115,7 +116,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute(\"terminal:create-new\")" + "await app.commands.execute(\"terminal:create-new\")" ] }, { @@ -128,7 +129,7 @@ "\n", "See https://github.com/bqplot/bqplot/blob/master/examples/Advanced%20Plotting/Animations.ipynb for more details.\n", "\n", - "Note: This requires bqplot to be installed, which may require Jupyterlab to be restarted if it hasn't already been installed." + "Note: This requires bqplot and numpy to be installed, which may require Jupyterlab to be restarted if it hasn't already been installed." ] }, { @@ -168,7 +169,7 @@ "\n", "fig = Figure(marks=[bar, line], axes=[xax, yax1, yax2], animation_duration=1000)\n", "panel = ipylab.Panel([fig])\n", - "panel.add_to_shell(mode=ipylab.InsertMode.split_right)" + "await panel.add_to_shell(mode=ipylab.InsertMode.split_right)" ] }, { @@ -213,7 +214,9 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.commands.add_command(\"update_data\", execute=update_data, label=\"Update Data\", icon_class=\"jp-PythonIcon\")" + "cmd = await app.commands.add_command(\n", + " \"update_data\", execute=update_data, label=\"Update Data\", icon_class=\"jp-PythonIcon\"\n", + ")" ] }, { @@ -222,7 +225,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute(\"update_data\")" + "await app.commands.execute(\"update_data\")" ] }, { @@ -249,7 +252,6 @@ "metadata": {}, "outputs": [], "source": [ - "cmd = t.result()\n", "command_id = str(cmd)" ] }, @@ -282,7 +284,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.command_pallet.add(command=cmd, category=\"Python Commands\")" + "await app.command_pallet.add(command=cmd, category=\"Python Commands\")" ] }, { @@ -305,7 +307,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.context_menu.add_item(command=cmd)" + "cm = await app.context_menu.add_item(command=cmd)" ] }, { @@ -315,15 +317,6 @@ "Right click on the plot to open the context menu." ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "cm = t.result()" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -340,9 +333,7 @@ "outputs": [], "source": [ "# Use uppercase\n", - "t = cmd.add_key_binding(\n", - " [\"U\"],\n", - ")" + "kb = await cmd.add_key_binding([\"U\"])" ] }, { @@ -351,7 +342,6 @@ "metadata": {}, "outputs": [], "source": [ - "kb = t.result()\n", "kb in cmd.key_bindings" ] }, @@ -379,7 +369,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = cmd.add_key_binding([\"Ctrl 1\"], selector=\".jp-ThemedContainer\")" + "await cmd.add_key_binding([\"Ctrl 1\"], selector=\".jp-ThemedContainer\")" ] }, { @@ -417,6 +407,15 @@ "\n", "See [menu->limiting scope](menu.ipynb#Limiting-scope) for an example using one." ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "panel.close()" + ] } ], "metadata": { @@ -435,7 +434,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/examples/icons.ipynb b/examples/icons.ipynb index 2bbcefa9..ceb031fa 100644 --- a/examples/icons.ipynb +++ b/examples/icons.ipynb @@ -50,7 +50,7 @@ "\n", "import ipylab\n", "\n", - "app = ipylab.App()" + "app = await ipylab.App().ready()" ] }, { @@ -158,7 +158,7 @@ "panel = ipylab.Panel([icon_controls])\n", "panel.title.icon = icon\n", "traitlets.dlink((background, \"value\"), (panel.title, \"label\"))\n", - "panel.add_to_shell(mode=ipylab.InsertMode.split_right)" + "await panel.add_to_shell(mode=ipylab.InsertMode.split_right)" ] }, { @@ -223,14 +223,15 @@ }, "outputs": [], "source": [ - "import asyncio\n", "import random\n", "\n", + "import anyio\n", + "\n", "\n", "async def randomize_icon(count=10):\n", " for _ in range(count):\n", " background.value = random.choice(options) # noqa: S311\n", - " await asyncio.sleep(0.1)" + " await anyio.sleep(0.1)" ] }, { @@ -242,12 +243,7 @@ }, "outputs": [], "source": [ - "t = app.commands.add_command(\n", - " \"randomize\",\n", - " randomize_icon,\n", - " label=\"Randomize My Icon\",\n", - " icon=icon,\n", - ")" + "cmd = await app.commands.add_command(\"randomize\", randomize_icon, label=\"Randomize My Icon\", icon=icon)" ] }, { @@ -256,23 +252,13 @@ "id": "18", "metadata": {}, "outputs": [], - "source": [ - "cmd = t.result()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "19", - "metadata": {}, - "outputs": [], "source": [ "assert cmd in app.commands.connections # noqa: S101" ] }, { "cell_type": "markdown", - "id": "20", + "id": "19", "metadata": {}, "source": [ "We can use methods on `cmd` (Connection for the cmd registered in the frontend) to add it to the command pallet, and create a launcher." @@ -281,18 +267,18 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "20", "metadata": { "tags": [] }, "outputs": [], "source": [ - "t = app.command_pallet.add(cmd, \"All My Commands\", rank=100)" + "await app.command_pallet.add(cmd, \"All My Commands\", rank=100)" ] }, { "cell_type": "markdown", - "id": "22", + "id": "21", "metadata": {}, "source": [ "Then open the _Command Palette_ (keyboard shortcut is `CTRL + SHIFT + C`)." @@ -300,7 +286,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "22", "metadata": {}, "source": [ "And run 'Randomize my icon'" diff --git a/examples/menu.ipynb b/examples/menu.ipynb index 82aeb747..1e67ff3c 100644 --- a/examples/menu.ipynb +++ b/examples/menu.ipynb @@ -30,7 +30,7 @@ "\n", "import ipylab\n", "\n", - "app = ipylab.App()" + "app = await ipylab.App().ready()" ] }, { @@ -39,7 +39,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.commands.create_menu(\"🌈 MY CUSTOM MENU 🎌\")" + "menu = await app.commands.create_menu(\"🌈 MY CUSTOM MENU 🎌\")" ] }, { @@ -55,8 +55,7 @@ "metadata": {}, "outputs": [], "source": [ - "menu = t.result()\n", - "app.main_menu.add_menu(menu)" + "await app.main_menu.add_menu(menu)" ] }, { @@ -88,7 +87,7 @@ " await menu.add_item(command=\"logconsole:open\")\n", "\n", " # Open it\n", - " menu.activate()" + " await menu.activate()" ] }, { @@ -97,7 +96,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.to_task(populate_menu(menu))" + "await populate_menu(menu)" ] }, { @@ -115,16 +114,8 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.main_menu.file_menu.add_item(command=\"logconsole:open\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "app.main_menu.file_menu.activate()" + "mc = await app.main_menu.file_menu.add_item(command=\"logconsole:open\")\n", + "await app.main_menu.file_menu.activate()" ] }, { @@ -134,8 +125,8 @@ "outputs": [], "source": [ "# Remove the menu item.\n", - "mc = t.result()\n", - "mc.close()" + "mc.close()\n", + "await app.main_menu.file_menu.activate()" ] }, { @@ -153,7 +144,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.context_menu.add_item(submenu=menu, type=\"submenu\")" + "submenu = await app.context_menu.add_item(submenu=menu, type=\"submenu\")" ] }, { @@ -171,7 +162,7 @@ "metadata": {}, "outputs": [], "source": [ - "panel.add_to_shell(mode=ipylab.InsertMode.split_right)" + "await panel.add_to_shell(mode=ipylab.InsertMode.split_right)" ] }, { @@ -209,16 +200,8 @@ " await app.dialog.show_dialog(\"Show id\", f\"Widget id is {id_}\")\n", "\n", "\n", - "t = app.commands.add_command(\"Show id\", show_id)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t = app.context_menu.add_item(command=t.result(), rank=1000, selector=\".jp-Notebook\")" + "cmd = await app.commands.add_command(\"Show id\", show_id)\n", + "mc = await app.context_menu.add_item(command=cmd, rank=1000, selector=\".jp-Notebook\")" ] }, { @@ -241,7 +224,7 @@ "from ipylab.commands import CommandRegistry\n", "\n", "cr = CommandRegistry(name=\"My command registry\")\n", - "t = cr.create_menu(\"Extra commands\")" + "mc = await cr.create_menu(\"Extra commands\")" ] }, { @@ -260,47 +243,11 @@ "metadata": {}, "outputs": [], "source": [ - "# MenuConnection\n", - "mc = t.result()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t = cr.add_command(\n", + "cmd = await cr.add_command(\n", " \"Open a dialog\", lambda app: app.dialog.show_dialog(\"Custom\", \"This is called from a custom registry\")\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "cmd = t.result()\n", - "mc.add_item(command=cmd)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t = app.context_menu.add_item(submenu=mc, type=\"submenu\", selector=\".WithExtraCommands\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" + ")\n", + "await mc.add_item(command=cmd)\n", + "await app.context_menu.add_item(submenu=mc, type=\"submenu\", selector=\".WithExtraCommands\")" ] }, { @@ -313,7 +260,7 @@ "b2 = ipw.HTML(\"

Context WITH extra commands

\", layout={\"border\": \"solid 3px green\"})\n", "b2.add_class(\"WithExtraCommands\")\n", "panel = ipylab.Panel([b1, b2])\n", - "panel.add_to_shell()" + "await panel.add_to_shell()" ] }, { @@ -384,7 +331,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/examples/notifications.ipynb b/examples/notifications.ipynb index 6d57bebb..d4680984 100644 --- a/examples/notifications.ipynb +++ b/examples/notifications.ipynb @@ -30,11 +30,11 @@ "metadata": {}, "outputs": [], "source": [ - "import asyncio\n", + "import anyio\n", "\n", "import ipylab\n", "\n", - "app = ipylab.App()" + "app = await ipylab.App().ready()" ] }, { @@ -43,16 +43,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.notification.notify(\"Updating soon\", ipylab.NotificationType.progress, auto_close=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "nc = t.result()" + "nc = await app.notification.notify(\"Updating soon\", ipylab.NotificationType.progress, auto_close=False)" ] }, { @@ -66,11 +57,11 @@ " for i in range(1, n):\n", " await nc.update(f\"Updating {n - i}\")\n", " await nc.update(\"All done\", type=ipylab.NotificationType.success)\n", - " await asyncio.sleep(1)\n", + " await anyio.sleep(1)\n", " nc.close()\n", "\n", "\n", - "t = nc.to_task(update())" + "await update()" ] }, { @@ -97,7 +88,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.notification.notify(\n", + "await app.notification.notify(\n", " \"These buttons are linked to the Python callback.\",\n", " actions=[\n", " {\"label\": \"About\", \"caption\": \"Show help\", \"callback\": lambda: app.commands.execute(\"help:about\")},\n", @@ -119,6 +110,13 @@ " auto_close=False,\n", ")" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { @@ -137,7 +135,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/examples/plugins.ipynb b/examples/plugins.ipynb index 31622734..22f85081 100644 --- a/examples/plugins.ipynb +++ b/examples/plugins.ipynb @@ -28,7 +28,9 @@ "* Manually registered with the `plugin_manager` or\n", "* defined as an `entrypoint` for modules.\n", "\n", - "The advantage of using entry points is that the plugin is registered automatically and can run in the always running `iyplab` kernel. But requires the extra effort installing a module with the defined entry point." + "The advantage of using entry points is that the plugin is registered automatically and can run in the always running `iyplab` kernel. But requires the extra effort installing a module with the defined entry point.\n", + "\n", + "The following plugins (*hookspecs*) are available." ] }, { @@ -40,22 +42,25 @@ "# Existing hook specs\n", "from IPython import display as ipd\n", "\n", + "import ipylab\n", "import ipylab.hookspecs\n", "\n", "app = ipylab.App()\n", - "\n", - "display(ipd.Markdown(\"## Plugins\\n\\nThe following plugins (*hookspecs*) are available.\"))\n", + "out = ipylab.SimpleOutput(layout={\"height\": \"300px\"}).add_class(\"ipylab-ResizeBox\")\n", + "out.push(ipd.Markdown(\"## Plugins\\n\\nThe following plugins (*hookspecs*) are available.\"))\n", "for n in dir(ipylab.hookspecs):\n", " f = getattr(ipylab.hookspecs, n)\n", " if not hasattr(f, \"ipylab_spec\"):\n", " continue\n", - " display(ipd.Markdown(f\"### `{f.__name__}`\"))\n", - " display(ipd.Markdown(f.__doc__))" + " out.push(ipd.Markdown(f\"### `{f.__name__}`\"), ipd.Markdown(f.__doc__))\n", + "out" ] }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, "source": [ "## Autostart\n", "\n", @@ -122,7 +127,7 @@ " notify_type = ipw.Dropdown(description=\"Notify type\", options=ipylab.NotificationType)\n", " notify_message = ipw.Combobox(placeholder=\"Enter message\")\n", " notify_button = ipw.Button(description=\"Notify\")\n", - " notify_button.on_click(lambda _: app.notification.notify(notify_message.value, notify_type.value)) # type: ignore\n", + " notify_button.on_click(lambda _: app.start_coro(app.notification.notify(notify_message.value, notify_type.value))) # type: ignore\n", " box = ipw.HBox([notify_type, notify_message, notify_button], layout={\"align_content\": \"center\", \"flex\": \"1 0 auto\"})\n", "\n", " out = ipw.Output()\n", @@ -138,9 +143,9 @@ " result = await app.dialog.show_dialog(\"Shutdown kernel?\")\n", " if result[\"value\"]:\n", " await app.notification.notify(\"Shutting down kernel\", type=ipylab.NotificationType.info)\n", - " app.shutdown_kernel()\n", + " await app.shutdown_kernel()\n", "\n", - " app.to_task(shutdown())\n", + " app.start_coro(shutdown())\n", "\n", " # Add a plugin in this kernel. Instead of defining a class, you can also define a module eg: 'ipylab.lib.py'\n", " class MyLocalPlugin:\n", @@ -194,14 +199,8 @@ "outputs": [], "source": [ "# Register the plugin\n", - "ipylab.plugin_manager.register(pluginmodule, \"demo plugin\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We should now have a launcher" + "ipylab.plugin_manager.register(pluginmodule, \"demo plugin\")\n", + "ipylab.SimpleOutput(layout={\"height\": \"200px\"}).add_class(\"ipylab-ResizeBox\").push(app.commands.all_commands)" ] }, { @@ -210,7 +209,8 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.commands.execute(\"launcher:create\")" + "# Show the button in the launcher\n", + "await app.commands.execute(\"launcher:create\")" ] }, { @@ -286,7 +286,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/examples/resize_box.ipynb b/examples/resize_box.ipynb index 33e8f5bf..c9328ab2 100644 --- a/examples/resize_box.ipynb +++ b/examples/resize_box.ipynb @@ -13,11 +13,11 @@ "source": [ "# ResizeBox\n", "\n", - "The `ResizeBox` is a Box which is resizeable and reports its client size to the `size` trait. \n", + "`ResizeBox` is a Box which is resizeable and reports its *client size* to the `size` trait. \n", "\n", "A resize box is useful for wrapping a widget which is not dynamically resizable, for example: the [Matplotlib ipympl widget](https://github.com/matplotlib/ipympl).\n", "\n", - "All views of the resize box are resizeable and have the same size.\n", + "All views of the resize box are resizeable and synchronise to be the same size.\n", "\n", "Tip: Only use a `ResizeBox` if enabling the resize style ([resize css example](css_stylesheet.ipynb#Resize-example))) doesn't work, or if you want all views to be the same size." ] @@ -176,8 +176,24 @@ "metadata": {}, "outputs": [], "source": [ - "ipylab.App().shell.add(box)" + "sc = await ipylab.App().shell.add(box)" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sc.close()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/examples/sessions.ipynb b/examples/sessions.ipynb index 0f793f97..595eeec4 100644 --- a/examples/sessions.ipynb +++ b/examples/sessions.ipynb @@ -31,7 +31,7 @@ "source": [ "import ipylab\n", "\n", - "app = ipylab.App()" + "app = await ipylab.App().ready()" ] }, { @@ -47,16 +47,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.sessions.get_running()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" + "await app.sessions.get_running()" ] }, { @@ -72,16 +63,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.sessions.get_current()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "session = t.result()\n", + "session = await app.sessions.get_current()\n", "session" ] }, @@ -119,7 +101,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/examples/simple_output.ipynb b/examples/simple_output.ipynb index 0ddd8df5..b78a49d4 100644 --- a/examples/simple_output.ipynb +++ b/examples/simple_output.ipynb @@ -53,6 +53,8 @@ "metadata": {}, "outputs": [], "source": [ + "import anyio\n", + "\n", "import ipylab\n", "from ipylab.simple_output import SimpleOutput\n", "\n", @@ -168,33 +170,25 @@ "outputs": [], "source": [ "so = SimpleOutput()\n", - "t = so.set(\"Line one\\n\", \"Line two\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "16", - "metadata": {}, - "outputs": [], - "source": [ + "res = await so.set(\"Line one\\n\", \"Line two\")\n", "so" ] }, { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "16", "metadata": {}, "outputs": [], "source": [ - "assert so.length == t.result() # noqa: S101\n", + "await anyio.sleep(0.1)\n", + "assert so.length == res # noqa: S101\n", "so.length" ] }, { "cell_type": "markdown", - "id": "18", + "id": "17", "metadata": {}, "source": [ "## max_continuous_streams and max_outputs\n", @@ -209,38 +203,21 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "18", "metadata": {}, "outputs": [], "source": [ "# Make each stream go into a new output.\n", - "so.max_continuous_streams = 0" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "20", - "metadata": {}, - "outputs": [], - "source": [ - "t = so.set(\"Line one\\n\", \"Line two\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "21", - "metadata": {}, - "outputs": [], - "source": [ - "assert so.length == t.result() # noqa: S101\n", + "so.max_continuous_streams = 0\n", + "res = await so.set(\"Line one\\n\", \"Line two\")\n", + "await anyio.sleep(0.1)\n", + "assert so.length == res # noqa: S101\n", "so.length" ] }, { "cell_type": "markdown", - "id": "22", + "id": "19", "metadata": {}, "source": [ "`max_outputs` limits the total number of outputs." @@ -249,7 +226,7 @@ { "cell_type": "code", "execution_count": null, - "id": "23", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -260,17 +237,18 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "21", "metadata": {}, "outputs": [], "source": [ "for i in range(100):\n", + " await anyio.sleep(0.001)\n", " so.push(i)" ] }, { "cell_type": "markdown", - "id": "25", + "id": "22", "metadata": {}, "source": [ "# AutoScroll\n", @@ -284,7 +262,7 @@ }, { "cell_type": "markdown", - "id": "26", + "id": "23", "metadata": {}, "source": [ "## Ipylab log viewer\n", @@ -295,34 +273,33 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "24", "metadata": {}, "outputs": [], "source": [ "app.log_level = \"DEBUG\"\n", - "app.commands.execute(\"Show log viewer\")" + "await app.commands.execute(\"Show log viewer\")" ] }, { "cell_type": "code", "execution_count": null, - "id": "28", + "id": "25", "metadata": {}, "outputs": [], "source": [ - "for _ in range(10):\n", - " app.log.debug(\"Debug\")\n", - " app.log.info(\"Info\")\n", - " app.log.warning(\"Warning\")\n", - " app.log.error(\"Error\")\n", - " app.log.exception(\"Exception\")\n", - " app.log.critical(\"Critical\")" + "app.log.debug(\"Debug\")\n", + "app.log.info(\"Info\")\n", + "app.log.warning(\"Warning\")\n", + "app.log.error(\"Error\")\n", + "app.log.exception(\"Exception\")\n", + "app.log.critical(\"Critical\")" ] }, { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -331,7 +308,7 @@ }, { "cell_type": "markdown", - "id": "30", + "id": "27", "metadata": {}, "source": [ "## Example usage" @@ -340,24 +317,25 @@ { "cell_type": "code", "execution_count": null, - "id": "31", + "id": "28", "metadata": {}, "outputs": [], "source": [ "from datetime import datetime\n", "\n", + "import anyio\n", "import ipywidgets as ipw\n", "\n", "import ipylab\n", "from ipylab.simple_output import AutoScroll\n", "\n", - "app = ipylab.App()" + "app = await ipylab.App().ready()" ] }, { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -378,17 +356,15 @@ "def on_click(b):\n", " if b is b_start:\n", " if b.description == \"Start\":\n", - " import asyncio\n", + " b.description = \"Stop\"\n", "\n", " async def generate_output():\n", - " while True:\n", + " while b.description == \"Stop\":\n", " vb.children = (*vb.children, ipw.HTML(f\"It is now {datetime.now().isoformat()}\")) # noqa: DTZ005\n", - " await asyncio.sleep(sleep.value)\n", + " await anyio.sleep(sleep.value)\n", "\n", - " b.task = app.to_task(generate_output())\n", - " b.description = \"Stop\"\n", + " app.start_coro(generate_output())\n", " else:\n", - " b.task.cancel()\n", " b.description = \"Start\"\n", " if b is b_clear:\n", " vb.children = ()\n", @@ -412,12 +388,12 @@ "p = ipylab.Panel(\n", " [ipw.HBox([enabled, sleep, direction, b_start, b_clear], layout={\"justify_content\": \"center\"}), sw_holder]\n", ")\n", - "p.add_to_shell(mode=ipylab.InsertMode.split_right)" + "await p.add_to_shell(mode=ipylab.InsertMode.split_right)" ] }, { "cell_type": "markdown", - "id": "33", + "id": "30", "metadata": {}, "source": [ "# Basic console example\n", @@ -440,7 +416,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -509,23 +485,23 @@ { "cell_type": "code", "execution_count": null, - "id": "35", + "id": "32", "metadata": {}, "outputs": [], "source": [ "sc = SimpleConsole(\"My namespace\")\n", - "sc.add_to_shell(mode=ipylab.InsertMode.split_bottom)" + "await sc.add_to_shell(mode=ipylab.InsertMode.split_bottom)" ] }, { "cell_type": "code", "execution_count": null, - "id": "36", + "id": "33", "metadata": {}, "outputs": [], "source": [ "sc2 = SimpleConsole(\"A separate namespace\")\n", - "sc2.add_to_shell(mode=ipylab.InsertMode.split_bottom)" + "await sc2.add_to_shell(mode=ipylab.InsertMode.split_bottom)" ] } ], diff --git a/examples/widgets.ipynb b/examples/widgets.ipynb index ca3d2f5f..89724e22 100644 --- a/examples/widgets.ipynb +++ b/examples/widgets.ipynb @@ -38,14 +38,14 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ + "import anyio\n", "import ipywidgets as ipw\n", "\n", "import ipylab\n", - "import asyncio\n", "\n", "app = await ipylab.App().ready()" ] @@ -61,7 +61,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -81,45 +81,23 @@ "metadata": {}, "outputs": [], "source": [ - "sc = await panel.add_to_shell(mode=ipylab.InsertMode.split_right, activate=False, as_coro=True)" + "sc = await panel.add_to_shell(mode=ipylab.InsertMode.split_right, activate=False)" ] }, { "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "sc in panel.connections" ] }, { "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "sc in app.shell.connections" ] @@ -134,22 +112,11 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - " wait_for= cb=[Ipylab._task_done_callback()]>" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "sc.activate() # Will activate before returning to this notebook" + "await sc.activate() # Will activate before returning to this notebook" ] }, { @@ -161,7 +128,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -177,7 +144,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -186,12 +153,12 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# closable is on the widget in the shell rather than the panel, but we can set it using set_property.\n", - "t = sc.set_property(\"title.closable\", False)" + "await sc.set_property(\"title.closable\", False)" ] }, { @@ -203,7 +170,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -219,7 +186,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -235,20 +202,9 @@ }, { "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "< Not ready: ShellConnection(cid='ipylab-ShellConnection|50c7eed6-9fbd-4069-a300-757e02e89ff2') >" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "sc" ] @@ -262,23 +218,12 @@ }, { "cell_type": "code", - "execution_count": 20, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - " wait_for= wait_for= cb=[Ipylab._task_done_callback(), Task.task_wakeup()]> cb=[Ipylab._task_done_callback()]>" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "slider = ipw.IntSlider()\n", - "app.shell.add(slider, area=ipylab.Area.top)" + "await app.shell.add(slider, area=ipylab.Area.top)" ] }, { @@ -290,7 +235,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -308,25 +253,9 @@ }, { "cell_type": "code", - "execution_count": 22, - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "560500a795184e6c81f5c7672ba0a6f7", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "Box(children=(SplitPanel(children=(IntProgress(value=7, bar_style='info', description='Loading:', layout=Layou…" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "split_panel = ipylab.SplitPanel()\n", "progress = ipw.IntProgress(\n", @@ -356,7 +285,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -373,22 +302,11 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - " wait_for= wait_for= cb=[Ipylab._task_done_callback(), Task.task_wakeup()]> cb=[Ipylab._task_done_callback()]>" - ] - }, - "execution_count": 24, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ - "split_panel.add_to_shell(area=ipylab.Area.main, mode=ipylab.InsertMode.split_bottom)" + "await split_panel.add_to_shell(area=ipylab.Area.main, mode=ipylab.InsertMode.split_bottom)" ] }, { @@ -400,7 +318,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -416,7 +334,7 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -432,7 +350,7 @@ }, { "cell_type": "code", - "execution_count": 27, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -448,7 +366,7 @@ }, { "cell_type": "code", - "execution_count": 28, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -468,11 +386,11 @@ }, { "cell_type": "code", - "execution_count": 29, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "split_panel.add_to_shell(area=ipylab.Area.left, rank=1000)\n", + "await split_panel.add_to_shell(area=ipylab.Area.left, rank=1000)\n", "await split_panel.connections[0].activate()" ] }, @@ -485,17 +403,17 @@ }, { "cell_type": "code", - "execution_count": 30, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "split_panel.add_to_shell(area=ipylab.Area.right, rank=1000)\n", + "await split_panel.add_to_shell(area=ipylab.Area.right, rank=1000)\n", "await split_panel.connections[0].activate()" ] }, { "cell_type": "code", - "execution_count": 31, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -515,67 +433,31 @@ }, { "cell_type": "code", - "execution_count": 32, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "< Not ready: ShellConnection(cid='ipylab-ShellConnection|ba333e5f-5d65-4bdb-8d47-4ab6df532677') >" - ] - }, - "execution_count": 32, - "metadata": {}, - "output_type": "execute_result" - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "await split_panel.add_to_shell(cid=ipylab.ShellConnection.to_cid(), mode=ipylab.InsertMode.split_right)" ] }, { "cell_type": "code", - "execution_count": 33, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "(ShellConnection(cid='ipylab-ShellConnection|d3aa35a1-f0a0-4617-9fb8-0e77cc48f996'),\n", - " < Not ready: ShellConnection(cid='ipylab-ShellConnection|ba333e5f-5d65-4bdb-8d47-4ab6df532677') >)" - ] - }, - "execution_count": 33, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "split_panel.connections[0].activate()\n", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await split_panel.connections[0].activate()\n", "split_panel.connections" ] }, { "cell_type": "code", - "execution_count": 34, - "metadata": {}, - "outputs": [ - { - "ename": "NameError", - "evalue": "name 'fail' is not defined", - "output_type": "error", - "traceback": [ - "\u001b[31m---------------------------------------------------------------------------\u001b[39m", - "\u001b[31mNameError\u001b[39m Traceback (most recent call last)", - "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[34]\u001b[39m\u001b[32m, line 2\u001b[39m\n\u001b[32m 1\u001b[39m split_panel.close()\n\u001b[32m----> \u001b[39m\u001b[32m2\u001b[39m \u001b[43mfail\u001b[49m\n\u001b[32m 3\u001b[39m \u001b[38;5;28;01mawait\u001b[39;00m asyncio.sleep(\u001b[32m1\u001b[39m)\n", - "\u001b[31mNameError\u001b[39m: name 'fail' is not defined" - ] - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "split_panel.close()\n", - "fail\n", - "await asyncio.sleep(1)" + "await anyio.sleep(0.1)" ] }, { @@ -586,13 +468,6 @@ "source": [ "split_panel.connections" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/ipylab/__main__.py b/ipylab/__main__.py index f30e1a9b..a4ada4b3 100644 --- a/ipylab/__main__.py +++ b/ipylab/__main__.py @@ -6,4 +6,4 @@ import ipylab if __name__ == "__main__": - ipylab.plugin_manager.hook.launch_jupyterlab() + ipylab.plugin_manager.hook.launch_ipylab() diff --git a/ipylab/code_editor.py b/ipylab/code_editor.py index a75207f2..35954344 100644 --- a/ipylab/code_editor.py +++ b/ipylab/code_editor.py @@ -6,7 +6,6 @@ import asyncio import inspect import typing -from asyncio import Task from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict, override from IPython.core import completer as IPC # noqa: N812 @@ -209,7 +208,6 @@ class CodeEditor(Ipylab, _String): placeholder = None # Presently not available value = Unicode() - _update_task: None | Task = None _setting_value = False completer: Fixed[Self, IpylabCompleter] = Fixed( lambda c: IpylabCompleter( @@ -242,22 +240,19 @@ def _default_load_value(self): @observe("value") def _observe_value(self, _): - if not self._setting_value and not self._update_task: + if not self._setting_value: # We use throttling to ensure there isn't a backlog of changes to synchronise. # When the value is set in Python, we the shared model in the frontend should exactly reflect it. async def send_value(): - try: - while True: - value = self.value - await self.operation("setValue", {"value": value}) - self._sync = self._sync + 1 - await asyncio.sleep(self.update_throttle_ms / 1e3) - if self.value == value: - return - finally: - self._update_task = None - - self._update_task = self.to_task(send_value(), "Send value to frontend") + while True: + value = self.value + await self.operation("setValue", {"value": value}) + self._sync = self._sync + 1 + await asyncio.sleep(self.update_throttle_ms / 1e3) + if self.value == value: + return + + self.start_coro(send_value()) @override async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list): @@ -270,8 +265,6 @@ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffer await self.evaluate(payload["code"]) return True case "setValue": - if self._update_task: - await self._update_task # Only set the value when a valid sync is provided # sync is done if payload["sync"] == self._sync: diff --git a/ipylab/commands.py b/ipylab/commands.py index e6c2aa8e..9175b080 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -13,13 +13,12 @@ from traitlets import Container, Dict, Instance, Tuple, Unicode import ipylab -from ipylab.common import IpylabKwgs, Obj, Singular, TaskHooks, TaskHookType, TransformType, pack +from ipylab.common import IpylabKwgs, Obj, Singular, TransformType, pack from ipylab.connection import InfoConnection, ShellConnection from ipylab.ipylab import Ipylab, IpylabBase, Transform, register from ipylab.widgets import Icon if TYPE_CHECKING: - from asyncio import Task from collections.abc import Callable, Coroutine from ipylab.menu import MenuConnection @@ -75,42 +74,37 @@ def to_cid(cls, command_registry: str, vpath: str, name: str): # type: ignore def repr_info(self): return {"name": self.commands.name} | {"info": self.info} - def configure(self, *, emit=True, **kwgs: Unpack[CommandOptions]) -> Task[CommandOptions]: + async def configure(self, *, emit=True, **kwgs: Unpack[CommandOptions]) -> CommandOptions: + await self.ready() if diff := set(kwgs).difference(self._config_options): msg = f"The following useless configuration options were detected for {diff} in {self}" raise KeyError(msg) - async def configure(): - config: CommandOptions = await self.update_property("config", kwgs) # type: ignore - if emit: - await self.commands.execute_method("commandChanged.emit", {"id": self.cid}) - return config + config: CommandOptions = await self.update_property("config", kwgs) # type: ignore + if emit: + await self.commands.execute_method("commandChanged.emit", ({"id": self.cid},)) + return config - return self.to_task(configure()) - - def add_key_binding( + async def add_key_binding( self, keys: list, selector="", args: dict | None = None, *, prevent_default=True - ) -> Task[KeybindingConnection]: + ) -> KeybindingConnection: "Add a key binding for this command and selector." - args = args or {} - - async def add_key_binding(): - args_ = args | { - "keys": keys, - "preventDefault": prevent_default, - "selector": selector or self.app.selector, - "command": str(self), - } - cid = KeybindingConnection.to_cid(self) - transform: TransformType = {"transform": Transform.connection, "cid": cid} - hooks: TaskHooks = { - "add_to_tuple_fwd": [(self, "key_bindings")], - "trait_add_fwd": [("info", args_), ("command", self)], - "close_with_fwd": [self], - } - return await self.commands.execute_method("addKeyBinding", args_, transform=transform, hooks=hooks) - - return self.to_task(add_key_binding()) + await self.ready() + args = args or {} | { + "keys": keys, + "preventDefault": prevent_default, + "selector": selector or self.app.selector, + "command": str(self), + } + cid = KeybindingConnection.to_cid(self) + KeybindingConnection.close_if_exists(cid) + transform: TransformType = {"transform": Transform.connection, "cid": cid} + kb: KeybindingConnection = await self.commands.execute_method("addKeyBinding", (args,), transform=transform) + kb.add_to_tuple(self, "key_bindings") + kb.info = args + kb.command = self + self.close_with_self(kb) + return kb class CommandPalletItemConnection(InfoConnection): @@ -137,9 +131,9 @@ class CommandPalette(Singular, Ipylab): trait=Instance("ipylab.commands.CommandPalletItemConnection") ) - def add( + async def add( self, command: CommandConnection, category: str, *, rank=None, args: dict | None = None - ) -> Task[CommandPalletItemConnection]: + ) -> CommandPalletItemConnection: """Add a command to the command pallet (must be registered in this kernel). **args are used when calling the command. @@ -166,16 +160,21 @@ def add( If the ShellConnection relates to an Ipylab widget. The associated widget/panel is accessible as `ref.widget`. """ + await self.ready() + await command.ready() + if str(command) not in self.app.commands.all_commands: + msg = f"{command=} is not registered in app command registry app.commands!" + raise RuntimeError(msg) cid = CommandPalletItemConnection.to_cid(command, category) - CommandRegistry._check_belongs_to_application_registry(cid) # noqa: SLF001 + CommandPalletItemConnection.close_if_exists(cid) info = {"args": args, "category": category, "command": str(command), "rank": rank} transform: TransformType = {"transform": Transform.connection, "cid": cid} - hooks: TaskHooks = { - "add_to_tuple_fwd": [(self, "connections")], - "trait_add_fwd": [("info", info), ("command", command)], - "close_with_fwd": [command], - } - return self.execute_method("addItem", info, transform=transform, hooks=hooks) + cpc: CommandPalletItemConnection = await self.execute_method("addItem", (info,), transform=transform) + self.close_with_self(cpc) + cpc.add_to_tuple(self, "connections") + cpc.info = info + cpc.command = command + return cpc @register @@ -191,17 +190,6 @@ class CommandRegistry(Singular, Ipylab): def get_single_key(cls, name: str, **kwgs): return name - @classmethod - def _check_belongs_to_application_registry(cls, cid: str): - "Check the cid belongs to the application command registry." - if APP_COMMANDS_NAME not in cid: - msg = ( - f"{cid=} doesn't correspond to an ipylab CommandConnection " - f'for the application command registry "{APP_COMMANDS_NAME}". ' - "Use a command registered with `app.commands.add_command` instead." - ) - raise ValueError(msg) - @property def repr_info(self): return {"name": self.name} @@ -221,7 +209,7 @@ async def _execute_for_frontend(self, payload: dict, buffers: list): if not CommandConnection.exists(cmd_cid): msg = f'Invalid command "{cmd_cid}"' raise TypeError(msg) - conn = CommandConnection(cmd_cid) + conn = await CommandConnection(cmd_cid).ready() cmd = conn.python_command args = conn.args | (payload.get("args") or {}) @@ -251,7 +239,7 @@ async def _execute_for_frontend(self, payload: dict, buffers: list): result = await result return result - def add_command( + async def add_command( self, name: str, execute: Callable[..., Coroutine | Any], @@ -262,9 +250,8 @@ def add_command( icon: Icon | None = None, args: dict | None = None, namespace_id="", - hooks: TaskHookType = None, **kwgs, - ) -> Task[CommandConnection]: + ) -> CommandConnection: """Add a python command that can be executed by Jupyterlab. The `cid` of the CommnandConnection is used as the `id` in the App @@ -291,42 +278,36 @@ def add_command( ref: https://lumino.readthedocs.io/en/latest/api/interfaces/commands.CommandRegistry.ICommandOptions.html """ - async def add_command(): - cid = CommandConnection.to_cid(self.name, self.app.vpath, name) - if CommandConnection.exists(cid): - cmd = await CommandConnection(cid).ready() - cmd.close() - kwgs_ = kwgs | { - "id": cid, - "cid": cid, - "caption": caption, - "label": label or name, - "iconClass": icon_class, - "icon": f"{pack(icon)}.labIcon" if isinstance(icon, Icon) else None, - } - hooks: TaskHooks = { - "close_with_fwd": [self], - "add_to_tuple_fwd": [(self, "connections")], - "trait_add_fwd": [ - ("commands", self), - ("namespace_id", namespace_id), - ("python_command", execute), - ("args", args or {}), - ("info", kwgs_), - ], - } - - return await self.operation( - "addCommand", - kwgs_, - hooks=hooks, - transform={"transform": Transform.connection, "cid": cid}, - toObject=["icon"] if isinstance(icon, Icon) else [], - ) - - return self.to_task(add_command(), hooks=hooks) - - def execute(self, command_id: str | CommandConnection, args: dict | None = None, **kwargs: Unpack[IpylabKwgs]): + await self.ready() + app = await self.app.ready() + cid = CommandConnection.to_cid(self.name, app.vpath, name) + CommandConnection.close_if_exists(cid) + kwgs = kwgs | { + "id": cid, + "cid": cid, + "caption": caption, + "label": label or name, + "iconClass": icon_class, + "icon": f"{pack(icon)}.labIcon" if isinstance(icon, Icon) else None, + } + cc: CommandConnection = await self.operation( + "addCommand", + kwgs, + transform={"transform": Transform.connection, "cid": cid}, + toObject=["icon"] if isinstance(icon, Icon) else [], + ) + self.close_with_self(cc) + cc.commands = self + cc.namespace_id = namespace_id + cc.python_command = execute + cc.args = args or {} + cc.info = kwgs + cc.add_to_tuple(self, "connections") + return cc + + async def execute( + self, command_id: str | CommandConnection, args: dict | None = None, **kwargs: Unpack[IpylabKwgs] + ): """Execute a command registered in the frontend command registry returning the result. @@ -341,34 +322,31 @@ def execute(self, command_id: str | CommandConnection, args: dict | None = None, see https://github.com/jtpio/ipylab/issues/128#issuecomment-1683097383 for hints on how to determine what args can be used. """ - - async def execute_command(): - id_ = str(command_id) + await self.ready() + app = await self.app.ready() + id_ = str(command_id) + if id_ not in self.all_commands: + id_ = CommandConnection.to_cid(self.name, app.vpath, id_) if id_ not in self.all_commands: - id_ = CommandConnection.to_cid(self.name, self.app.vpath, id_) - if id_ not in self.all_commands: - msg = f"Command '{command_id}' not registered!" - raise ValueError(msg) - return await self.operation("execute", {"id": id_, "args": args or {}}, **kwargs) - - return self.to_task(execute_command()) + msg = f"Command '{command_id}' not registered!" + raise ValueError(msg) + return await self.operation("execute", {"id": id_, "args": args or {}}, **kwargs) - def create_menu(self, label: str, rank: int = 500) -> Task[MenuConnection]: + async def create_menu(self, label: str, rank: int = 500) -> MenuConnection: "Make a new menu that can be used where a menu is required." + await self.ready() cid = ipylab.menu.MenuConnection.to_cid() + ipylab.menu.MenuConnection.close_if_exists(cid) options = {"id": cid, "label": label, "rank": int(rank)} - hooks: TaskHooks = { - "trait_add_fwd": [("info", options), ("commands", self)], - "add_to_tuple_fwd": [(self, "connections")], - "close_with_fwd": [self], - } - return self.execute_method( + mc: MenuConnection = await self.execute_method( "generateMenu", - f"{pack(self)}.base", - options, - (Obj.this, "translator"), + (f"{pack(self)}.base", options, (Obj.this, "translator")), obj=Obj.MainMenu, toObject=["args[0]", "args[2]"], transform={"transform": Transform.connection, "cid": cid}, - hooks=hooks, ) + self.close_with_self(mc) + mc.info = options + mc.commands = self + mc.add_to_tuple(self, "connections") + return mc diff --git a/ipylab/common.py b/ipylab/common.py index 27933c9c..b50fb7be 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -8,18 +8,23 @@ import typing import weakref from collections import OrderedDict -from collections.abc import Awaitable, Callable +from collections.abc import Callable from enum import StrEnum from typing import ( TYPE_CHECKING, Any, ClassVar, + Concatenate, Generic, Literal, NotRequired, + ParamSpec, Self, TypedDict, TypeVar, + TypeVarTuple, + final, + overload, override, ) @@ -30,6 +35,13 @@ import ipylab +if TYPE_CHECKING: + from collections.abc import Callable, Hashable + from types import CoroutineType + from typing import overload + + from ipylab.ipylab import Ipylab + __all__ = [ "Area", "Obj", @@ -39,7 +51,6 @@ "hookimpl", "pack", "IpylabKwgs", - "TaskHookType", "LastUpdatedDict", "Fixed", "FixedCreate", @@ -47,19 +58,59 @@ "Singular", ] + +T = TypeVar("T") +S = TypeVar("S") +R = TypeVar("R") +B = TypeVar("B", bound=object) +L = TypeVar("L", bound="Ipylab") +P = ParamSpec("P") +PosArgsT = TypeVarTuple("PosArgsT") + + hookimpl = pluggy.HookimplMarker("ipylab") # Used for plugins SVGSTR_TEST_TUBE = ' ' -T = TypeVar("T") -S = TypeVar("S") +def autorun(f: Callable[Concatenate[B, P], CoroutineType[None, None, R]]): + """Decorator to automatically start a coroutine when a method is called. -if TYPE_CHECKING: - from collections.abc import Awaitable, Callable, Hashable - from typing import overload + The decorated method will be called with the same arguments as the original method. But with + start prepended. + If `start` is True (default), the coroutine will be started automatically using + `ipylab.App().start_coro` or `self.start_coro` if the class is an instance of `ipylab.Ipylab`. + If `start` is False, the coroutine will be returned without being started. - from ipylab.ipylab import Ipylab + Args: + f: The coroutine function to decorate. The first argument must be `self`. + + Returns: + The decorated function. + """ + if TYPE_CHECKING: + + @overload + def inner(self: B, start: Literal[True], /, *args: P.args, **kwargs: P.kwargs) -> None: ... + @overload + def inner( + self: B, start: Literal[False], /, *args: P.args, **kwargs: P.kwargs + ) -> CoroutineType[None, None, R]: ... + @overload + def inner(self: B, start: Literal[True] = ..., /, *args: P.args, **kwargs: P.kwargs) -> None: ... + + def inner(self: B, start: bool = True, /, *args: P.args, **kwargs: P.kwargs) -> CoroutineType[None, None, R] | None: # noqa: FBT001, FBT002 + coro = f(self, *args, **kwargs) + if not start: + return coro + start_coro = self.start_coro if isinstance(self, ipylab.Ipylab) else ipylab.App().start_coro + start_coro(coro) + return None + + return inner + + +if TYPE_CHECKING: @overload def pack(obj: Widget) -> str: ... @@ -145,6 +196,7 @@ class InsertMode(StrEnum): tab_after = "tab-after" +@final class Transform(StrEnum): """An eumeration of transformations to apply to the result of an operation performed on the frontend prior to returning to Python and transformation @@ -224,15 +276,15 @@ def validate(cls, transform: TransformType): return transform_ @classmethod - def transform_payload(cls, transform: TransformType, payload): + async def transform_payload(cls, transform: TransformType, payload): """Transform the payload according to the transform.""" transform_ = transform["transform"] if isinstance(transform, dict) else transform match transform_: case Transform.advanced: mappings = typing.cast(TransformDictAdvanced, transform)["mappings"] - return {key: cls.transform_payload(mappings[key], payload[key]) for key in mappings} + return {key: await cls.transform_payload(mappings[key], payload[key]) for key in mappings} # type: ignore case Transform.connection | Transform.auto if isinstance(payload, dict) and (cid := payload.get("cid")): - return ipylab.Connection.get_connection(cid) + return await ipylab.Connection.get_connection(cid).ready() return payload @@ -258,38 +310,6 @@ class IpylabKwgs(TypedDict): transform: NotRequired[TransformType] toLuminoWidget: NotRequired[list[str] | None] toObject: NotRequired[list[str] | None] - hooks: NotRequired[TaskHookType] - - -class TaskHooks(TypedDict): - """Hooks to run after successful completion of 'aw' passed to the method "to_task" - and prior to returning. - - This provides a convenient means to set traits of the returned result. - - see: `Hookspec.task_result` - """ - - close_with_fwd: NotRequired[list[Ipylab]] # result is closed when any item in list is closed - close_with_rev: NotRequired[list[Widget]] # - - trait_add_fwd: NotRequired[list[tuple[str, Any]]] - trait_add_rev: NotRequired[list[tuple[HasTraits, str]]] - - add_to_tuple_fwd: NotRequired[list[tuple[HasTraits, str]]] - add_to_tuple_rev: NotRequired[list[tuple[str, Ipylab]]] - - callbacks: NotRequired[list[Callable[[Any], None | Awaitable[None]]]] - - -TaskHookType = TaskHooks | None - - -def trait_tuple_add(owner: HasTraits, name: str, value: Any): - "Add value to a tuple trait of owner if it already isn't in the tuple." - items = getattr(owner, name) - if value not in items: - owner.set_trait(name, (*items, value)) class LastUpdatedDict(OrderedDict): @@ -468,7 +488,7 @@ def __get__(self, obj: Any, objtype=None) -> T: except Exception: if log := getattr(obj, "log", None): msg = f"Callback `created` failed for {obj.__class__}.{self.name}" - log.exception(msg, obj=self.created) + log.exception(msg, extra={"obj": self.created}) return instance # type: ignore def __set__(self, obj, value): diff --git a/ipylab/connection.py b/ipylab/connection.py index ff276cd9..8b101a99 100644 --- a/ipylab/connection.py +++ b/ipylab/connection.py @@ -13,7 +13,6 @@ from ipylab.ipylab import Ipylab if TYPE_CHECKING: - from asyncio import Task from collections.abc import Hashable from typing import Self @@ -62,6 +61,11 @@ def get_single_key(cls, cid: str, **kwgs) -> Hashable: def exists(cls, cid: str) -> bool: return cid in cls._single_instances + @classmethod + def close_if_exists(cls, cid: str): + if inst := cls._single_instances.pop(cid, None): + inst.close() + def __init_subclass__(cls, **kwargs) -> None: cls.prefix = f"{cls._PREFIX}{cls.__name__}{cls._SEP}" cls._CLASS_DEFINITIONS[cls.prefix.strip(cls._SEP)] = cls @@ -144,11 +148,11 @@ def __del__(self): # Losing strong references doesn't mean the widget should be closed. self.close(dispose=False) - def activate(self): + async def activate(self): "Activate the connected widget in the shell." - return self.operation("activate") + return await self.operation("activate") - def get_session(self) -> Task[dict]: + async def get_session(self) -> dict: """Get the session of the connected widget.""" - return self.operation("getSession") + return await self.operation("getSession") diff --git a/ipylab/css_stylesheet.py b/ipylab/css_stylesheet.py index 0ec9bf2b..7a9c4ad1 100644 --- a/ipylab/css_stylesheet.py +++ b/ipylab/css_stylesheet.py @@ -3,17 +3,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ipywidgets import TypedTuple from traitlets import Dict, Unicode from ipylab.common import Obj from ipylab.ipylab import Ipylab -if TYPE_CHECKING: - from asyncio import Task - __all__ = ["CSSStyleSheet"] @@ -32,49 +27,50 @@ def __init__(self, **kwgs): super().__init__(**kwgs) self.on_ready(self._restore) - def _restore(self, _): + async def _restore(self, _): # Restore rules and variables if self.variables: - self.set_variables(self.variables) + await self.set_variables(self.variables) if self.css_rules: - self.replace("\n".join(self.css_rules)) + await self.replace("\n".join(self.css_rules)) - def _css_operation(self, operation: str, kwgs: dict | None = None): + async def _css_operation(self, operation: str, kwgs: dict | None = None) -> tuple[str, ...]: # Updates css_rules once operation is done - return self.operation(operation, kwgs, hooks={"trait_add_rev": [(self, "css_rules")]}) + self.css_rules = await self.operation(operation, kwgs=kwgs) + return self.css_rules - def delete_rule(self, item: int | str): + async def delete_rule(self, item: int | str): """Delete a rule by index or pass the exact string of the rule.""" if isinstance(item, str): item = list(self.css_rules).index(item) - return self._css_operation("deleteRule", {"index": item}) + return await self._css_operation("deleteRule", {"index": item}) - def insert_rule(self, rule: str, index=None): + async def insert_rule(self, rule: str, index=None): "" - return self._css_operation("insertRule", {"rule": rule, "index": index}) + return await self._css_operation("insertRule", {"rule": rule, "index": index}) - def get_css_rules(self): + async def get_css_rules(self): """Get a list of the css_text specified for this instance.""" - return self._css_operation("listCSSRules") + return await self._css_operation("listCSSRules") - def replace(self, text: str): + async def replace(self, text: str): """Replace all css rules for this instance. ref: https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/replace""" - return self._css_operation("replace", {"text": text}) + return await self._css_operation("replace", {"text": text}) - def get_variables(self) -> Task[dict[str, str]]: + async def get_variables(self) -> dict[str, str]: """Get a dict mapping **all** variable names to values in Jupyterlab. Variables set via this object can be found from the property 'variables'. """ - return self.execute_method("listVariables", obj=Obj.this) + return await self.execute_method("listVariables", obj=Obj.this) - def set_variables(self, variables: dict[str, str]) -> Task[dict[str, str]]: + async def set_variables(self, variables: dict[str, str]) -> dict[str, str]: "Set a css variable." if invalid_names := [n for n in variables if not n.startswith("--")]: msg = f'Variable names must start with "--"! {invalid_names=}' raise ValueError(msg) - return self.execute_method( - "setVariables", variables, obj=Obj.this, hooks={"callbacks": [self.variables.update]} - ) + v: dict[str, str] = await self.execute_method("setVariables", (variables,), obj=Obj.this) + self.set_trait("variables", self.variables | v) + return self.variables diff --git a/ipylab/dialog.py b/ipylab/dialog.py index 40df763b..9aa53252 100644 --- a/ipylab/dialog.py +++ b/ipylab/dialog.py @@ -3,7 +3,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Unpack +from typing import TYPE_CHECKING from ipywidgets import Widget from traitlets import Unicode @@ -11,11 +11,8 @@ from ipylab import Ipylab if TYPE_CHECKING: - from asyncio import Task from typing import Any - from ipylab.common import IpylabKwgs - def _combine(options: dict | None, **kwgs): if options: @@ -26,49 +23,45 @@ def _combine(options: dict | None, **kwgs): class Dialog(Ipylab): _model_name = Unicode("DialogModel", help="Name of the model.", read_only=True).tag(sync=True) - def get_boolean(self, title: str, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[bool]: + async def get_boolean(self, title: str, options: dict | None = None): """Open a Jupyterlab dialog to get a boolean value. see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.InputDialog.getBoolean.html """ - return self.operation("getBoolean", _combine(options, title=title), **kwgs) + return await self.operation("getBoolean", kwgs=_combine(options, title=title)) - def get_item( - self, title: str, items: tuple | list, *, options: dict | None = None, **kwgs: Unpack[IpylabKwgs] - ) -> Task[str]: + async def get_item(self, title: str, items: tuple | list, *, options: dict | None = None) -> str: """Open a Jupyterlab dialog to get an item from a list value. note: will always return a string representation of the selected item. see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.InputDialog.getItem.html """ - return self.operation("getItem", _combine(options, title=title, items=tuple(items)), **kwgs) + return await self.operation("getItem", kwgs=_combine(options, title=title, items=tuple(items))) + # type: ignore - def get_number(self, title: str, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[float]: + async def get_number(self, title: str, options: dict | None = None) -> float: """Open a Jupyterlab dialog to get a number. see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.InputDialog.getNumber.html """ - return self.operation("getNumber", _combine(options, title=title), **kwgs) + return await self.operation("getNumber", kwgs=_combine(options, title=title)) + # type: ignore - def get_text(self, title: str, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[str]: + async def get_text(self, title: str, options: dict | None = None) -> str: """Open a Jupyterlab dialog to get a string. see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.InputDialog.getText.html """ - return self.operation("getText", _combine(options, title=title), **kwgs) + return await self.operation("getText", kwgs=_combine(options, title=title)) + # type: ignore - def get_password(self, title: str, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[str]: + async def get_password(self, title: str, options: dict | None = None) -> str: """Open a Jupyterlab dialog to get a number. see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.InputDialog.getPassword.html """ - return self.operation("getPassword", _combine(options, title=title), **kwgs) - - def show_dialog( - self, - title: str = "", - body: str | Widget = "", - options: dict | None = None, - *, - has_close=True, - **kwgs: Unpack[IpylabKwgs], - ) -> Task[dict[str, Any]]: + return await self.operation("getPassword", kwgs=_combine(options, title=title)) + # type: ignore + + async def show_dialog( + self, title: str = "", body: str | Widget = "", options: dict | None = None, *, has_close=True + ) -> dict[str, Any]: """Open a Jupyterlab dialog to get user response with custom buttons and checkbox. @@ -84,7 +77,7 @@ def show_dialog( a widget or a react element has_close: bool [True] - By default (True), clicking outside the dialog will close it. + By default (), clicking outside the dialog will close it. When `False`, the user must specifically cancel or accept a result. options: @@ -129,13 +122,11 @@ def show_dialog( see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.showDialog.html source: https://jupyterlab.readthedocs.io/en/stable/extension/ui_helpers.html#generic-dialog """ - if isinstance(body, Widget) and "toLuminoWidget" not in kwgs: - kwgs["toLuminoWidget"] = ["body"] - return self.operation("showDialog", _combine(options, title=title, body=body, hasClose=has_close), **kwgs) + kwgs = _combine(options, title=title, body=body, hasClose=has_close) + to_lumino_widget = ["body"] if isinstance(body, Widget) else None + return await self.operation("showDialog", kwgs=kwgs, toLuminoWidget=to_lumino_widget) - def show_error_message( - self, title: str, error: str, options: dict | None = None, **kwgs: Unpack[IpylabKwgs] - ) -> Task[None]: + async def show_error_message(self, title: str, error: str, options: dict | None = None): """Open a Jupyterlab error message dialog. buttons = [ @@ -154,19 +145,19 @@ def show_error_message( https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.showErrorMessage.html#showErrorMessage """ - return self.operation("showErrorMessage", _combine(options, title=title, error=error), **kwgs) + return await self.operation("showErrorMessage", kwgs=_combine(options, title=title, error=error)) - def get_open_files(self, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[list[str]]: + async def get_open_files(self, options: dict | None = None): """Get a list of files using a Jupyterlab dialog relative to the current path in Jupyterlab. https://jupyterlab.readthedocs.io/en/latest/api/functions/filebrowser.FileDialog.getOpenFiles.html#getOpenFiles """ - return self.operation("getOpenFiles", options, **kwgs) + return await self.operation("getOpenFiles", kwgs=options) - def get_existing_directory(self, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[str]: + async def get_existing_directory(self, options: dict | None = None) -> str: """Get an existing directory using a Jupyterlab dialog relative to the current path in Jupyterlab. https://jupyterlab.readthedocs.io/en/latest/api/functions/filebrowser.FileDialog.getExistingDirectory.html#getExistingDirectory """ - return self.operation("getExistingDirectory", options, **kwgs) + return await self.operation("getExistingDirectory", kwgs=options) diff --git a/ipylab/hookspecs.py b/ipylab/hookspecs.py index a263cdf4..3b2ade13 100644 --- a/ipylab/hookspecs.py +++ b/ipylab/hookspecs.py @@ -3,7 +3,6 @@ from __future__ import annotations -import asyncio from typing import TYPE_CHECKING, Any import pluggy @@ -17,7 +16,7 @@ @hookspec(firstresult=True) -def launch_jupyterlab(): +def launch_ipylab(): """A hook called to start Jupyterlab. This is called by with the shell command `ipylab`. @@ -54,7 +53,7 @@ def default_namespace_objects(namespace_id: str, app: ipylab.App) -> dict[str, A @hookspec(firstresult=True) -def vpath_getter(app: ipylab.App, kwgs: dict) -> Awaitable[str] | str: # type: ignore +async def vpath_getter(app: ipylab.App, kwgs: dict) -> str: # type: ignore """A hook called during `app.shell.add` when `evaluate` is code and `vpath` is passed as a dict. @@ -62,6 +61,7 @@ def vpath_getter(app: ipylab.App, kwgs: dict) -> Awaitable[str] | str: # type: adding 'evaluate' code to the shell. The default behaviour is prompt the user for a path.""" + @hookspec(firstresult=True) -def get_asyncio_loop(app: ipylab.App) -> asyncio.AbstractEventLoop: # type: ignore +def get_asyncio_event_loop(app: ipylab.App) -> asyncio.AbstractEventLoop: # type: ignore # noqa: F821 "Get the asyncio event loop." diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 1b9d17c7..b190f1fa 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -9,9 +9,12 @@ import json import uuid import weakref -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, cast +import anyio +import anyio.to_thread import traitlets +from anyio import Event, create_memory_object_stream from ipywidgets import Widget, register from traitlets import ( Bool, @@ -30,30 +33,18 @@ import ipylab import ipylab._frontend as _fe -from ipylab.common import ( - Fixed, - IpylabKwgs, - Obj, - TaskHooks, - TaskHookType, - Transform, - TransformType, - pack, - trait_tuple_add, -) -from ipylab.log import IpylabLoggerAdapter +from ipylab.common import Fixed, IpylabKwgs, Obj, PosArgsT, T, Transform, TransformType, autorun, pack +from ipylab.log import IpylabLoggerAdapter, LogLevel if TYPE_CHECKING: - from asyncio import Task from collections.abc import Awaitable, Callable + from types import CoroutineType from typing import Self, Unpack + from anyio.streams.memory import MemoryObjectSendStream __all__ = ["Ipylab", "WidgetBase"] -T = TypeVar("T") -L = TypeVar("L", bound="Ipylab") - class IpylabBase(TraitType[tuple[str, str], None]): info_text = "A mapping to the base in the frontend." @@ -86,42 +77,23 @@ class Ipylab(WidgetBase): Ipylab widgets are Jupyter widgets that are designed to interact with the JupyterLab application. They provide a way to extend the functionality of JupyterLab with custom Python code. - - Attributes: - _model_name (Unicode): The name of the model. - _python_class (Unicode): The name of the Python class. - ipylab_base (IpylabBase): The base ipylab object. - _ready (Bool): Whether the widget is ready. - _on_ready_callbacks (List): A list of callbacks to execute when the widget is ready. - _ready_event (asyncio.Event): An event that is set when the widget is ready. - _comm: The comm object. - _ipylab_init_complete (bool): Whether the ipylab initialization is complete. - _pending_operations (Dict): A dictionary of pending operations. - _has_attrs_mappings (Set): A set of attribute mappings. - ipylab_tasks (Set): A set of ipylab tasks. - close_extras (Fixed): A set of extra widgets to close. - log (Instance): A logger instance. - app (Fixed): A reference to the ipylab App instance. """ _model_name = Unicode("IpylabModel", help="Name of the model.", read_only=True).tag(sync=True) _python_class = Unicode().tag(sync=True) ipylab_base = IpylabBase(Obj.this, "").tag(sync=True) _ready = Bool(read_only=True, help="Set to by frontend when ready").tag(sync=True) - _on_ready_callbacks: Container[list[Callable[[], None | Awaitable] | Callable[[Self], None | Awaitable]]] = List( - trait=traitlets.Callable() - ) - _ready_futures: Fixed[Self, set[asyncio.Future]] = Fixed(lambda _: set()) + _on_ready_callbacks: Container[list[Callable[[Self], None | CoroutineType]]] = List(trait=traitlets.Callable()) + _ready_event = Instance(Event, ()) _comm = None _ipylab_init_complete = False - _pending_operations: Dict[str, asyncio.Future] = Dict() + _pending_operations: Dict[str, MemoryObjectSendStream] = Dict() _has_attrs_mappings: Container[set[tuple[HasTraits, str]]] = Set() - ipylab_tasks: Container[set[asyncio.Task]] = Set() - close_extras: Fixed[Self, weakref.WeakSet[Widget]] = Fixed(weakref.WeakSet) + _close_extras: Fixed[Self, weakref.WeakSet[Widget]] = Fixed(weakref.WeakSet) + log = Instance(IpylabLoggerAdapter, read_only=True) app = Fixed(lambda _: ipylab.App()) - @property def repr_info(self) -> dict[str, Any] | str: "Extra info to provide for __repr__." @@ -163,21 +135,18 @@ def _observe_comm(self, change: dict): if not self.comm: self.close() if change["name"] == "_ready" and self._ready: - for f in tuple(self._ready_futures): - loop = f.get_loop() - loop.call_soon_threadsafe(f.set_result, True) - self._ready_futures.clear() + self._ready_event.set() + self._ready_event = Event() for cb in self._on_ready_callbacks: - self.ensure_run(cb) + result = cb(self) + if inspect.iscoroutine(result): + self.start_coro(result) def close(self): if self.comm: self._ipylab_send({"close": True}) super().close() - for task in self.ipylab_tasks: - task.cancel() - self.ipylab_tasks.clear() - for item in list(self.close_extras): + for item in list(self._close_extras): item.close() for obj, name in list(self._has_attrs_mappings): if val := getattr(obj, name, None): @@ -193,105 +162,53 @@ def _check_closed(self): msg = f"This widget is closed {self!r}" raise RuntimeError(msg) - async def _wrap_awaitable(self, aw: Awaitable[T], hooks: TaskHookType) -> T: - await self.ready() - try: - result = await aw - except Exception: - self.log.exception(f"Awaiting {aw}", obj={"hooks": hooks, "aw": aw}) # noqa: G004 - raise - else: - if hooks: - try: - self._task_result(result, hooks) - except Exception: - self.log.exception("Running hooks", obj={"result": result, "hooks": hooks, "aw": aw}) - raise - return result - - def _task_result(self: Ipylab, result: Any, hooks: TaskHooks): - # close with - for owner in hooks.pop("close_with_fwd", ()): - # Close result with each item. - if isinstance(owner, Ipylab) and isinstance(result, Widget): - if not owner.comm: - result.close() - raise RuntimeError(str(owner)) - owner.close_extras.add(result) - for obj_ in hooks.pop("close_with_rev", ()): - # Close each item with the result. - if isinstance(result, Ipylab): - result.close_extras.add(obj_) - # tuple add - for owner, name in hooks.pop("add_to_tuple_fwd", ()): - # Add each item of to tuple of result. - if isinstance(result, Ipylab): - result.add_to_tuple(owner, name) - else: - trait_tuple_add(owner, name, result) - for name, value in hooks.pop("add_to_tuple_rev", ()): - # Add the result the the tuple with 'name' for each item. - if isinstance(value, Ipylab): - value.add_to_tuple(result, name) - else: - trait_tuple_add(result, name, value) - # trait add - for name, value in hooks.pop("trait_add_fwd", ()): - # Set each trait of result with value. - if isinstance(value, Ipylab): - value.add_as_trait(result, name) - else: - result.set_trait(name, value) - for owner, name in hooks.pop("trait_add_rev", ()): - # Set set trait of each value with result. - if isinstance(result, Ipylab): - result.add_as_trait(owner, name) - else: - owner.set_trait(name, result) - for cb in hooks.pop("callbacks", ()): - self.ensure_run(cb(result)) - if hooks: - msg = f"Invalid hooks detected: {hooks}" - raise ValueError(msg) + async def catch_exceptions(self, aw: Awaitable) -> None: + """Catches exceptions that occur when awaiting an awaitable. + + The exception is logged, but otherwise ignored. - def _task_done_callback(self, task: Task): - self.ipylab_tasks.discard(task) - # TODO: It'd be great if we could cancel in the frontend. - # Unfortunately it looks like Javascript Promises can't be cancelled. - # https://stackoverflow.com/questions/30233302/promise-is-it-possible-to-force-cancel-a-promise#30235261 + Args: + aw: The awaitable to await. + """ + try: + await aw + except BaseException as e: + self.log.exception(f"Calling {aw}", obj={"aw": aw}, exc_info=e) # noqa: G004 + if self.app.log_level == LogLevel.DEBUG: + raise def _on_custom_msg(self, _, msg: dict, buffers: list): content = msg.get("ipylab") if not content: return try: - c = json.loads(content) + c: dict[str, Any] = json.loads(content) if "ipylab_PY" in c: - op = self._pending_operations.pop(c["ipylab_PY"]) - loop = op.get_loop() - if "error" in c: - loop.call_soon_threadsafe(op.set_exception, self._to_frontend_error(c)) - else: - loop.call_soon_threadsafe(op.set_result, c.get("payload")) + self._set_result(content=c) elif "ipylab_FE" in c: - return self.to_task(self._do_operation_for_fe(c["ipylab_FE"], c["operation"], c["payload"], buffers)) + self._do_operation_for_fe(True, c["ipylab_FE"], c["operation"], c["payload"], buffers) elif "closed" in c: self.close() else: raise NotImplementedError(msg) # noqa: TRY301 - except Exception: - self.log.exception("Message processing error", obj=msg) - - def _to_frontend_error(self, content): - error = content["error"] - operation = content.get("operation") - if operation: - msg = f'Operation "{operation}" failed with the message "{error}"' - return IpylabFrontendError(msg) - return IpylabFrontendError(error) + except Exception as e: + self.log.exception("Message processing error", obj=msg, exc_info=e) + + @autorun + async def _set_result(self, content: dict[str, Any]): + send_stream = self._pending_operations.pop(content["ipylab_PY"]) + if error := content.get("error"): + e = IpylabFrontendError(error) + e.add_note(f"{content=}") + value = e + else: + value = content.get("payload") + await send_stream.send(value) + @autorun async def _do_operation_for_fe(self, ipylab_FE: str, operation: str, payload: dict, buffers: list | None): """Handle operation requests from the frontend and reply with a result.""" + await self.ready() content: dict[str, Any] = {"ipylab_FE": ipylab_FE} buffers = [] try: @@ -302,51 +219,30 @@ async def _do_operation_for_fe(self, ipylab_FE: str, operation: str, payload: di content["payload"] = result except asyncio.CancelledError: content["error"] = "Cancelled" - except Exception: - self.log.exception("Operation for frontend error", obj={"operation": operation, "payload": payload}) + except Exception as e: + self.log.exception("Frontend operation", obj={"operation": operation, "payload": payload}, exc_info=e) finally: self._ipylab_send(content, buffers) - async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list): - """Perform an operation for a custom message with an ipylab_FE uuid.""" - raise NotImplementedError(operation) - - def _obj_operation(self, base: Obj, subpath: str, operation: str, kwgs, kwargs: IpylabKwgs): + async def _obj_operation(self, base: Obj, subpath: str, operation: str, kwgs, kwargs: IpylabKwgs): + await self.ready() kwgs |= {"genericOperation": operation, "basename": base, "subpath": subpath} - return self.operation("genericOperation", kwgs, **kwargs) - - def ensure_run(self, aw: Callable | Awaitable | None) -> None: - """Ensure aw is run. + return await self.operation("genericOperation", kwgs=kwgs, **kwargs) - Parameters - ---------- - aw: Callable | Awaitable | None - `aw` can be a function that accepts either no arguments or one keyword argument 'obj'. - """ - try: - if callable(aw): - aw = aw(self) if len(inspect.signature(len).parameters) == 1 else aw() - if inspect.iscoroutine(aw): - self.to_task(aw, f"Ensure run {aw}") - except Exception: - self.log.exception("Ensure run", obj=aw) - raise + async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list) -> Any: + """Perform an operation for a custom message with an ipylab_FE uuid.""" + # Overload as required + raise NotImplementedError(operation) async def ready(self) -> Self: - """Wait for the application to be ready. + """Wait for the instance to be ready. If this is not the main application instance, it waits for the main application instance to be ready first. """ - app = self.app - if app is not self and not app._ready: # noqa: SLF001 - await app.ready() - if not self._ready: # type: ignore - future = self.app.asyncio_loop.create_future() - self._ready_futures.add(future) - if not self._ready: - await future - self._ready_futures.discard(future) + self._check_closed() + if not self._ready: + await self._ready_event.wait() return self def on_ready(self, callback, remove=False): # noqa: FBT002 @@ -383,36 +279,72 @@ def add_as_trait(self, obj: HasTraits, name: str): # see: _observe_comm for removal self._has_attrs_mappings.add((obj, name)) + def close_with_self(self, obj: Widget): + "Close the widget when self closes. If self is already closed, object will be closed immediately." + if not self.comm: + obj.close() + msg = f"{self} is closed" + raise anyio.ClosedResourceError(msg) + self._close_extras.add(obj) + def _ipylab_send(self, content, buffers: list | None = None): try: self.send({"ipylab": json.dumps(content, default=pack)}, buffers) - except Exception: - self.log.exception("Send error", obj=content) + except Exception as e: + self.log.exception("Send error", obj=content, exc_info=e) raise - def to_task(self, aw: Awaitable[T], name: str | None = None, *, hooks: TaskHookType = None) -> Task[T]: - """Run aw in an eager task. - - If the task is running when this object is closed the task will be cancel. - Noting the corresponding promise in the frontend will run to completion. + def start_coro(self, coro: CoroutineType[None, None, T]) -> None: + """Start a coroutine in the main event loop. - aw: An awaitable to run in the task. + If the kernel has a `start_soon` method, use it to start the coroutine. + Otherwise, if the application has an asyncio loop, use + `asyncio.run_coroutine_threadsafe` to start the coroutine in the loop. + If neither of these is available, raise a RuntimeError. - name: str - The name of the task. + Tip: Use anyio primiatives in the coroutine to ensure it will run in + the chosen backend of the kernel. - hooks: TaskHookType + Parameters + ---------- + coro : CoroutineType[None, None, T] + The coroutine to start. + Raises + ------ + RuntimeError + If there is no running loop to start the task. """ self._check_closed() - task = asyncio.eager_task_factory(self.app.asyncio_loop, self._wrap_awaitable(aw, hooks), name=name) - if not task.done(): - self.ipylab_tasks.add(task) - task.add_done_callback(self._task_done_callback) - return task + self.start_soon(self.catch_exceptions, coro) + + def start_soon(self, func: Callable[[Unpack[PosArgsT]], CoroutineType], *args: Unpack[PosArgsT]): + """Start a function soon in the main event loop. - def operation( + If the kernel has a start_soon method, use it. + Otherwise, if the app has an asyncio loop, run the function in that loop. + Otherwise, raise a RuntimeError. + + This is a simple wrapper to ensure the function is called in the main + event loop. No error reporting is done. + + Consider using start_coro which performs additional checks and automatically + logs exceptions. + """ + try: + start_soon = self.comm.kernel.start_soon # type: ignore + except AttributeError: + if loop := self.app.asyncio_loop: + coro = func(*args) + asyncio.run_coroutine_threadsafe(coro, loop) + else: + msg = f"We don't have a running loop to run {func}" + raise RuntimeError(msg) from None + else: + start_soon(func, *args) + + async def operation( self, operation: str, kwgs: dict | None = None, @@ -420,9 +352,8 @@ def operation( transform: TransformType = Transform.auto, toLuminoWidget: list[str] | None = None, toObject: list[str] | None = None, - hooks: TaskHookType = None, - ) -> Task[Any]: - """Create a new task requesting an operation to be performed in the frontend. + ) -> Any: + """Perform an operation in the frontend. operation: str Name corresponding to operation in JS frontend. @@ -438,12 +369,9 @@ def operation( toObject: List[str] | None A list of item name mappings to convert to objects in the frontend prior to performing the operation. - - hooks: TaskHookType - see: TaskHooks """ # validation - self._check_closed() + await self.ready() if not operation or not isinstance(operation, str): msg = f"Invalid {operation=}" raise ValueError(msg) @@ -459,28 +387,29 @@ def operation( if toObject: content["toObject"] = toObject - self._pending_operations[ipylab_PY] = op = self.app.asyncio_loop.create_future() - - async def _operation(content: dict): - self._ipylab_send(content) - payload = await op - return Transform.transform_payload(content["transform"], payload) - - return self.to_task(_operation(content), name=ipylab_PY, hooks=hooks) + send_stream, receive_stream = create_memory_object_stream() + self._pending_operations[ipylab_PY] = send_stream + self._ipylab_send(content) + result = await receive_stream.receive() + if isinstance(result, Exception): + raise result + result = await Transform.transform_payload(content["transform"], result) + return cast(Any, result) - def execute_method(self, subpath: str, *args, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]): - return self._obj_operation(obj, subpath, "executeMethod", {"args": args}, kwargs) + async def execute_method(self, subpath: str, args: tuple = (), obj=Obj.base, **kwargs: Unpack[IpylabKwgs]) -> Any: + return await self._obj_operation(obj, subpath, "executeMethod", {"args": args}, kwargs) - def get_property(self, subpath: str, *, obj=Obj.base, null_if_missing=False, **kwargs: Unpack[IpylabKwgs]): + async def get_property(self, subpath: str, *, obj=Obj.base, null_if_missing=False, **kwargs: Unpack[IpylabKwgs]): return self._obj_operation(obj, subpath, "getProperty", {"null_if_missing": null_if_missing}, kwargs) - def set_property(self, subpath: str, value, *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]): - return self._obj_operation(obj, subpath, "setProperty", {"value": value}, kwargs) + async def set_property(self, subpath: str, value, *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]): + return await self._obj_operation(obj, subpath, "setProperty", {"value": value}, kwargs) - def update_property(self, subpath: str, value: dict[str, Any], *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]): - return self._obj_operation(obj, subpath, "updateProperty", {"value": value}, kwargs) + async def update_property(self, subpath: str, value: dict[str, Any], *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]): + return await self._obj_operation(obj, subpath, "updateProperty", {"value": value}, kwargs) - def list_properties( + async def list_properties( self, subpath="", *, obj=Obj.base, depth=3, skip_hidden=True, **kwargs: Unpack[IpylabKwgs] - ) -> Task[dict]: - return self._obj_operation(obj, subpath, "listProperties", {"depth": depth, "omitHidden": skip_hidden}, kwargs) + ) -> dict: + kwgs = {"depth": depth, "omitHidden": skip_hidden} + return await self._obj_operation(obj, subpath, "listProperties", kwgs, kwargs) diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index f8ee6f92..f38d36dd 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -57,7 +57,7 @@ class App(Singular, Ipylab): logging_handler: Instance[IpylabLogHandler | None] = Instance(IpylabLogHandler, allow_none=True) # type: ignore log_level = UseEnum(LogLevel, LogLevel.ERROR) - asyncio_loop = Instance(asyncio.AbstractEventLoop, help="The asyncio loop to use for scheduling tasks") + asyncio_loop: Instance[asyncio.AbstractEventLoop | None] = Instance(asyncio.AbstractEventLoop, allow_none=True) # type: ignore namespaces: Container[dict[str, LastUpdatedDict]] = Dict(read_only=True) # type: ignore @@ -74,8 +74,8 @@ def _default_logging_handler(self): return handler @default("asyncio_loop") - def _default_comm(self): - return ipylab.plugin_manager.hook.get_asyncio_loop(app=self) + def _default_asyncio_loop(self): + return ipylab.plugin_manager.hook.get_asyncio_event_loop(app=self) @observe("_ready", "log_level") def _app_observe_ready(self, change): @@ -91,13 +91,14 @@ def _app_observe_ready(self, change): ipylab.plugin_manager.hook.autostart.call_historic( kwargs={"app": self}, result_callback=self._autostart_callback ) - except Exception: - self.log.exception("Error with autostart") + except Exception as e: + self.log.exception("Error with autostart", exc_info=e) if self.logging_handler: self.logging_handler.setLevel(self.log_level) def _autostart_callback(self, result): - self.ensure_run(result) + if inspect.iscoroutine(result): + self.start_coro(result) @property def repr_info(self): @@ -144,7 +145,7 @@ def selector(self): return self._selector @override - async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list) -> Any: + async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list): match operation: case "evaluate": return await self._evaluate(payload, buffers) @@ -158,9 +159,9 @@ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffer return await super()._do_operation_for_frontend(operation, payload, buffers) - def shutdown_kernel(self, vpath: str | None = None): + async def shutdown_kernel(self, vpath: str | None = None): "Shutdown the kernel" - return self.operation("shutdownKernel", {"vpath": vpath}) + await self.operation("shutdownKernel", {"vpath": vpath}) def start_iyplab_python_kernel(self, *, restart=False): "Start the 'ipylab' Python kernel." @@ -244,7 +245,7 @@ async def _evaluate(self, options: dict[str, Any], buffers: list): self.shell.add_objects_to_ipython_namespace(ns) return {"payload": payload, "buffers": buffers} - def evaluate( + async def evaluate( self, evaluate: str | inspect._SourceObjectType | Iterable[str | tuple[str, str | inspect._SourceObjectType]], *, @@ -328,8 +329,9 @@ async def do_something(widget, area): # Task result should be a ShellConnection ``` """ - kwgs = (kwgs or {}) | {"evaluate": evaluate, "vpath": vpath, "namespace_id": namespace_id} - return self.operation("evaluate", kwgs, **kwargs) + await self.ready() + kwgs = (kwgs or {}) | {"evaluate": evaluate, "vpath": vpath or self.vpath, "namespace_id": namespace_id} + return await self.operation("evaluate", kwgs=kwgs, **kwargs) JupyterFrontEnd = App diff --git a/ipylab/launcher.py b/ipylab/launcher.py index 43cd75e9..c7d5f07c 100644 --- a/ipylab/launcher.py +++ b/ipylab/launcher.py @@ -3,19 +3,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ipywidgets import TypedTuple from traitlets import Container, Instance -from ipylab.commands import CommandConnection, CommandPalletItemConnection, CommandRegistry -from ipylab.common import Obj, Singular, TaskHooks +from ipylab.commands import CommandConnection, CommandPalletItemConnection +from ipylab.common import Obj, Singular, TransformType from ipylab.ipylab import Ipylab, IpylabBase, Transform -if TYPE_CHECKING: - from asyncio import Task - - __all__ = ["LauncherConnection"] @@ -23,9 +17,6 @@ class LauncherConnection(CommandPalletItemConnection): """An Ipylab launcher item.""" -cid: str - - class Launcher(Singular, Ipylab): """ ref: https://jupyterlab.readthedocs.io/en/latest/api/interfaces/launcher.ILauncher-1.html""" @@ -34,13 +25,21 @@ class Launcher(Singular, Ipylab): connections: Container[tuple[LauncherConnection, ...]] = TypedTuple(trait=Instance(LauncherConnection)) - def add(self, cmd: CommandConnection, category: str, *, rank=None, **args) -> Task[LauncherConnection]: - """Add a launcher for the command (must be registered in this kernel). + async def add(self, cmd: CommandConnection, category: str, *, rank=None, **args) -> LauncherConnection: + """Add a launcher for the command (must be registered in app.commands in this kernel). ref: https://jupyterlab.readthedocs.io/en/latest/api/interfaces/launcher.ILauncher.IItemOptions.html """ + await self.ready() + await cmd.ready() + commands = await self.app.commands.ready() + if str(cmd) not in commands.all_commands: + msg = f"{cmd=} is not registered in app command registry app.commands!" + raise RuntimeError(msg) cid = LauncherConnection.to_cid(cmd, category) - CommandRegistry._check_belongs_to_application_registry(cid) # noqa: SLF001 - hooks: TaskHooks = {"close_with_fwd": [cmd], "add_to_tuple_fwd": [(self, "connections")]} args = {"command": str(cmd), "category": category, "rank": rank, "args": args} - return self.execute_method("add", args, transform={"transform": Transform.connection, "cid": cid}, hooks=hooks) + transform: TransformType = {"transform": Transform.connection, "cid": cid} + lc: LauncherConnection = await self.execute_method("add", (args,), transform=transform) + cmd.close_with_self(lc) + lc.add_to_tuple(self, "connections") + return lc diff --git a/ipylab/lib.py b/ipylab/lib.py index 9f3d8b23..8f080594 100644 --- a/ipylab/lib.py +++ b/ipylab/lib.py @@ -3,7 +3,6 @@ from __future__ import annotations -import asyncio from typing import TYPE_CHECKING import ipywidgets @@ -18,7 +17,7 @@ @hookimpl -def launch_jupyterlab(): +def launch_ipylab(): import sys from jupyterlab.labapp import LabApp @@ -33,30 +32,33 @@ async def autostart(app: ipylab.App) -> None | Awaitable[None]: # Register some default context menu items for Ipylab # To prevent registering the command use app.DEFAULT_COMMANDS.discard() in another autostart hookimpl. if "Open console" in app.DEFAULT_COMMANDS: - cmd = await app.commands.add_command("Open console", app.shell.open_console, as_coro=True) + cmd = await app.commands.add_command("Open console", app.shell.open_console) await app.context_menu.add_item(command=cmd, rank=70) if "Show log viewer" in app.DEFAULT_COMMANDS: - cmd = await app.commands.add_command("Show log viewer", app.shell.log_viewer.add_to_shell, as_coro=True) + cmd = await app.commands.add_command("Show log viewer", app.shell.log_viewer.add_to_shell) await app.context_menu.add_item(command=cmd, rank=71) @hookimpl -async def autostart_once(app: ipylab.App) -> None | Awaitable[None]: +async def autostart_once(app: ipylab.App) -> None: pass @hookimpl -def vpath_getter(app: App, kwgs: dict) -> Awaitable[str] | str: - return app.dialog.get_text(**kwgs) +async def vpath_getter(app: App, kwgs: dict) -> str: + return await app.dialog.get_text(**kwgs) @hookimpl -def default_namespace_objects(namespace_id: str, app: ipylab.App): +def default_namespace_objects(namespace_id: str, app: ipylab.App) -> dict: return {"ipylab": ipylab, "ipw": ipywidgets, "app": app, "namespace_id": namespace_id} @hookimpl -def get_asyncio_loop(app: ipylab.App): - if (kernel := getattr(app.comm, "kernel", None)) and (loop := getattr(kernel, "asyncio_event_loop", None)): - return loop - return asyncio.get_running_loop() +def get_asyncio_event_loop(app: ipylab.App): + try: + return app.comm.kernel.asyncio_event_loop # type: ignore + except AttributeError: + import asyncio + + return asyncio.get_running_loop() diff --git a/ipylab/log.py b/ipylab/log.py index c3197f2b..d2428232 100644 --- a/ipylab/log.py +++ b/ipylab/log.py @@ -15,7 +15,6 @@ from ipylab.common import Fixed if TYPE_CHECKING: - from asyncio import Task from collections.abc import MutableMapping @@ -90,7 +89,6 @@ def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, Muta class IpylabLogHandler(logging.Handler): - _log_notify_task: Task | None = None _loggers: ClassVar[weakref.WeakSet[logging.Logger]] = weakref.WeakSet() formatter: IpylabLogFormatter # type: ignore diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py index 652baf63..45e2761e 100644 --- a/ipylab/log_viewer.py +++ b/ipylab/log_viewer.py @@ -6,18 +6,18 @@ import collections from typing import TYPE_CHECKING, Self, override +from IPython.display import Markdown from ipywidgets import HTML, BoundedIntText, Button, Checkbox, Combobox, Dropdown, HBox, Select, VBox from traitlets import directional_link, link, observe import ipylab -from ipylab.common import SVGSTR_TEST_TUBE, Area, Fixed, InsertMode +from ipylab.common import SVGSTR_TEST_TUBE, Area, Fixed, InsertMode, autorun from ipylab.log import LogLevel from ipylab.simple_output import AutoScroll, SimpleOutput from ipylab.widgets import Icon, Panel if TYPE_CHECKING: import logging - from asyncio import Task from ipylab.connection import ShellConnection @@ -27,24 +27,42 @@ class LogViewer(Panel): "A log viewer and an object viewer combined." - _log_notify_task: None | Task = None _updating = False info = Fixed(lambda _: HTML(layout={"flex": "1 0 auto", "margin": "0px 20px 0px 20px"})) - app = Fixed(lambda _: ipylab.App()) - log_level = Fixed( + app = Fixed[Self, "ipylab.App"](lambda _: ipylab.App()) + log_level = Fixed[Self, Dropdown]( lambda _: Dropdown( description="Level", options=[(v.name.capitalize(), v) for v in LogLevel], layout={"width": "max-content"}, ), + created=lambda c: link( + source=(c["owner"].app, "log_level"), + target=(c["obj"], "value"), + ), ) buffer_size: Fixed[Self, BoundedIntText] = Fixed( lambda _: BoundedIntText( - description="Buffer size", min=1, max=1e6, layout={"width": "max-content", "flex": "0 0 auto"} + value=100, + description="Buffer size", + min=1, + max=1e6, + layout={"width": "max-content", "flex": "0 0 auto"}, + ), + created=lambda c: ( + c["obj"].observe(c["owner"]._observe_buffer_size, "value"), # noqa: SLF001 + link( + source=(c["obj"], "value"), + target=(c["owner"].output, "max_outputs"), + ), + directional_link( + source=(c["owner"].output, "length"), + target=(c["obj"], "tooltip"), + transform=lambda size: f"Current size: {size}", + ), ), - created=lambda c: c["obj"].observe(c["owner"]._observe_buffer_size, "value"), # noqa: SLF001 ) - button_show_send_dialog = Fixed( + button_show_send_dialog = Fixed[Self, Button]( lambda _: Button( description="📪", tooltip="Send the record to the console.\n" @@ -52,13 +70,15 @@ class LogViewer(Panel): "which may be of interest for debugging purposes.", layout={"width": "auto", "flex": "0 0 auto"}, ), + created=lambda c: c["obj"].on_click(c["owner"]._button_on_click), # noqa: SLF001 ) - button_clear = Fixed( + button_clear = Fixed[Self, Button]( lambda _: Button( description="⌧", tooltip="Clear log", layout={"width": "auto", "flex": "0 0 auto"}, ), + created=lambda c: c["obj"].on_click(c["owner"]._button_on_click), # noqa: SLF001 ) autoscroll_enabled = Fixed( lambda _: Checkbox( @@ -82,24 +102,20 @@ class LogViewer(Panel): ), ) output = Fixed(SimpleOutput) - autoscroll_widget: Fixed[Self, AutoScroll] = Fixed(lambda c: AutoScroll(content=c["owner"].output)) + autoscroll_widget: Fixed[Self, AutoScroll] = Fixed( + lambda c: AutoScroll(content=c["owner"].output), + created=lambda c: link( + source=(c["owner"].autoscroll_enabled, "value"), + target=(c["obj"], "enabled"), + ), + ) - def __init__(self, buffersize=100): - self._records = collections.deque(maxlen=buffersize) + def __init__(self): + self._records = collections.deque(maxlen=100) self.title.icon = Icon(name="ipylab-test_tube", svgstr=SVGSTR_TEST_TUBE) super().__init__(children=[self.header, self.autoscroll_widget]) - self.buffer_size.value = buffersize - app = self.app - link((self.autoscroll_widget, "enabled"), (self.autoscroll_enabled, "value")) - link((app, "log_level"), (self.log_level, "value")) - link((self.buffer_size, "value"), (self.output, "max_outputs")) - directional_link( - (self.output, "length"), (self.buffer_size, "tooltip"), transform=lambda size: f"Current size: {size}" - ) - if app.logging_handler: - app.logging_handler.register_callback(self._add_record) - self.button_show_send_dialog.on_click(self._button_on_click) - self.button_clear.on_click(self._button_on_click) + if self.app.logging_handler: + self.app.logging_handler.register_callback(self._add_record) @override def close(self, *, force=False): @@ -108,31 +124,31 @@ def close(self, *, force=False): @observe("connections") def _observe_connections(self, _): - app = self.app if self.connections and len(self.connections) == 1: - self.output.push(*(rec.output for rec in self._records), clear=True) - self.info.value = f"Vpath: {app._vpath}" # noqa: SLF001 - self.title.label = f"Log: {app._vpath}" # noqa: SLF001 + self.output.push(*(rec.output for rec in self._records), clear=True) # type: ignore + self.info.value = f"Vpath: {self.app._vpath}" # noqa: SLF001 + self.title.label = f"Log: {self.app._vpath}" # noqa: SLF001 def _add_record(self, record: logging.LogRecord): self._records.append(record) if self.connections: self.output.push(record.output) # type: ignore if record.levelno >= LogLevel.ERROR and self.app._ready: # noqa: SLF001 - self._notify_exception(record) + self._notify_exception(True, record) - def _notify_exception(self, record: logging.LogRecord): + @autorun + async def _notify_exception(self, record: logging.LogRecord): "Create a notification that an error occurred." - if self._log_notify_task: - # Limit to one notification. - if not self._log_notify_task.done(): - return - self._log_notify_task.result().close() - self._log_notify_task = self.app.notification.notify( + await self.app.notification.notify( message=f"Error: {record.msg}", type=ipylab.NotificationType.error, actions=[ - ipylab.NotifyAction(label="📄", caption="Show log viewer.", callback=self.add_to_shell, keep_open=True) + ipylab.NotifyAction( + label="📄", + caption="Show log viewer.", + callback=lambda: self._show_error(record=record), + keep_open=True, + ) ], ) @@ -142,22 +158,32 @@ def _observe_buffer_size(self, change): def _button_on_click(self, b): if b is self.button_show_send_dialog: - self.button_show_send_dialog.disabled = True - self.app.dialog.to_task( - self._show_send_dialog(), - hooks={"callbacks": [lambda _: self.button_show_send_dialog.set_trait("disabled", False)]}, - ) + b.disabled = True + self._show_send_dialog(True, b) elif b is self.button_clear: self._records.clear() self.output.push(clear=True) - async def _show_send_dialog(self): - options = {r.msg: r for r in reversed(self._records)} # type: ignore - select = Select( - tooltip="Most recent exception is first", - layout={"flex": "2 1 auto", "width": "auto", "height": "max-content"}, - options=options, - ) + @autorun + async def _show_error(self, record: logging.LogRecord): + out = SimpleOutput().push(Markdown(f"### record.levelname.capitalize(): {record.message}")) + try: + out.push(record.output) # type: ignore + except Exception: + out.push(record.message) + objects = { + "record": record, + "owner": (owner := getattr(record, "owner", None)) and owner(), + "obj": getattr(record, "obj", None), + } + b = Button(description="Send to console", tooltip="Send record, owner and obj to the console.") + b.on_click(lambda _: self.app.shell.start_coro(self.app.shell.open_console(objects=objects))) + out.push(b) + await self.app.shell.add(out, mode=InsertMode.split_right) + + @autorun + async def _show_send_dialog(self, b: Button): + options = {f"{r.asctime}: {r.msg}": r for r in reversed(self._records)} # type: ignore search = Combobox( placeholder="Search", tooltip="Search for a log entry or object.", @@ -165,11 +191,20 @@ async def _show_send_dialog(self): layout={"width": "auto"}, options=tuple(options), ) - body = VBox([select, search]) + select = Select( + value=None, + tooltip="Most recent exception is first", + layout={"flex": "2 1 auto", "width": "auto", "height": "max-content"}, + options=options, + ) + record_out = SimpleOutput() + body = VBox([search, select, record_out]) def observe(change: dict): if change["owner"] is select: - body.children = [select, search, select.value] if select.value else [select, search] + record = select.value + items = (record.output,) if record else () + record_out.push(*items, clear=True) elif change["owner"] is search and change["new"] in options: select.value = options[change["new"]] @@ -177,17 +212,21 @@ def observe(change: dict): search.observe(observe, "value") try: result = await self.app.dialog.show_dialog("Send record to console", body=body) - if result["value"] and select.value: - console = await self.app.shell.open_console(objects={"record": select.value}) - await console.set_property("console.promptCell.model.sharedModel.source", "record") - await console.execute_method("console.execute") + if record := result["value"] and select.value: + objects = { + "record": record, + "owner": (owner := getattr(record, "owner", None)) and owner(), + "obj": getattr(record, "obj", None), + } + await self.app.shell.open_console(objects=objects) except Exception: return finally: + b.disabled = False for w in [search, body, select]: w.close() - def add_to_shell( + async def add_to_shell( self, *, area=Area.main, @@ -197,7 +236,7 @@ def add_to_shell( ref: ipylab.ShellConnection | None = None, options: dict | None = None, **kwgs, - ) -> Task[ShellConnection]: - return super().add_to_shell( + ) -> ShellConnection: + return await super().add_to_shell( area=area, activate=activate, mode=mode, rank=rank, ref=ref, options=options, **kwgs ) diff --git a/ipylab/menu.py b/ipylab/menu.py index 34718cbd..3044626d 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -14,11 +14,9 @@ from ipylab.ipylab import Ipylab, IpylabBase, Transform if TYPE_CHECKING: - from asyncio import Task from typing import Literal from ipylab.commands import CommandConnection - from ipylab.common import TaskHooks, TransformType __all__ = ["MenuItemConnection", "MenuConnection", "MainMenu", "ContextMenu"] @@ -38,7 +36,7 @@ class RankedMenu(Ipylab): connections: Container[tuple[MenuItemConnection, ...]] = TypedTuple(trait=Instance(MenuItemConnection)) - def add_item( + async def add_item( self, *, command: str | CommandConnection = "", @@ -46,15 +44,15 @@ def add_item( rank: float | None = None, type: Literal["command", "submenu", "separator"] = "command", # noqa: A002 args: dict | None = None, - ) -> Task[MenuItemConnection]: + ) -> MenuItemConnection: """Add command, subitem or separator. **args are 'defaults' used with command only. ref: https://jupyterlab.readthedocs.io/en/4.0.x/api/classes/ui_components.RankedMenu.html#addItem.addItem-1 """ - return self._add_item(command, submenu, rank, type, args) + return await self._add_item(command, submenu, rank, type, args) - def _add_item( + async def _add_item( self, command: str | CommandConnection, submenu: MenuConnection | None, @@ -85,27 +83,29 @@ def _add_item( case _: msg = f"Invalid type {type}" raise ValueError(msg) - hooks: TaskHooks = { - "trait_add_fwd": [("info", info), ("menu", self)], - "close_with_fwd": [self], - "add_to_tuple_fwd": [(self, "connections")], - } - transform: TransformType = {"transform": Transform.connection, "cid": MenuItemConnection.to_cid()} - return self.execute_method("addItem", info, hooks=hooks, transform=transform, toObject=to_object) - def activate(self): - async def activate(): - await self.app.main_menu.set_property("activeMenu", self, toObject=["value"]) - await self.app.main_menu.execute_method("openActiveMenu") + mic: MenuItemConnection = await self.execute_method( + subpath="addItem", + args=(info,), + transform={"transform": Transform.connection, "cid": MenuItemConnection.to_cid()}, + toObject=to_object, + ) + self.close_with_self(mic) + mic.info = info + mic.menu = self + mic.add_to_tuple(self, "connections") + return mic - return self.to_task(activate()) + async def activate(self): + await self.app.main_menu.set_property("activeMenu", self, toObject=["value"]) + await self.app.main_menu.execute_method("openActiveMenu") class BuiltinMenu(RankedMenu): @override - def activate(self): + async def activate(self): name = self.ipylab_base[-1].removeprefix("mainMenu.").lower() - return self.app.commands.execute(f"{name}:open") + await self.app.commands.execute(f"{name}:open") class MenuConnection(InfoConnection, RankedMenu): @@ -130,7 +130,7 @@ def get_single_key(cls, commands: str, **kwgs): def __init__(self, *, commands: CommandRegistry, **kwgs): if self._ipylab_init_complete: return - commands.close_extras.add(self) + commands.close_with_self(self) super().__init__(commands=commands, **kwgs) @@ -175,13 +175,13 @@ def get_single_key(cls, **kwgs): # type: ignore def __init__(self): super().__init__(commands=CommandRegistry(name=APP_COMMANDS_NAME)) - def add_menu(self, menu: MenuConnection, *, update=True, rank: int = 500) -> Task[None]: + async def add_menu(self, menu: MenuConnection, *, update=True, rank: int = 500) -> None: """Add a top level menu to the shell. ref: https://jupyterlab.readthedocs.io/en/4.0.x/api/classes/mainmenu.MainMenu.html#addMenu """ options = {"rank": rank} - return self.execute_method("addMenu", menu, update, options, toObject=["args[0]"]) + return await self.execute_method("addMenu", (menu, update, options), toObject=["args[0]"]) @override def activate(self): # type: ignore @@ -194,7 +194,7 @@ class ContextMenu(Menu): ipylab_base = IpylabBase(Obj.IpylabModel, "app.contextMenu").tag(sync=True) @override - def add_item( + async def add_item( # type: ignore self, *, command: str | CommandConnection = "", @@ -203,17 +203,14 @@ def add_item( rank: float | None = None, type: Literal["command", "submenu", "separator"] = "command", args: dict | None = None, - ) -> Task[MenuItemConnection]: + ) -> MenuItemConnection: """Add command, subitem or separator. args are used when calling the command only. ref: https://jupyterlab.readthedocs.io/en/stable/extension/extension_points.html#context-menu """ - - async def add_item_(): - return await self._add_item(command, submenu, rank, type, args, selector or self.app.selector) - - return self.to_task(add_item_()) + app = await self.app.ready() + return await self._add_item(command, submenu, rank, type, args, selector or app.selector) @override def activate(self): # type: ignore diff --git a/ipylab/notification.py b/ipylab/notification.py index 176365cc..2e39c4f9 100644 --- a/ipylab/notification.py +++ b/ipylab/notification.py @@ -12,12 +12,11 @@ from traitlets import Container, Instance, Unicode from ipylab import Transform, pack -from ipylab.common import Obj, Singular, TaskHooks, TransformType +from ipylab.common import Obj, Singular, TransformType from ipylab.connection import InfoConnection from ipylab.ipylab import Ipylab, IpylabBase if TYPE_CHECKING: - from asyncio import Task from collections.abc import Callable, Iterable from typing import Any @@ -49,14 +48,15 @@ class ActionConnection(InfoConnection): class NotificationConnection(InfoConnection): actions: Container[tuple[ActionConnection, ...]] = TypedTuple(trait=Instance(ActionConnection)) - def update( + async def update( self, message: str, type: NotificationType | None = None, # noqa: A002 *, auto_close: float | Literal[False] | None = None, actions: Iterable[NotifyAction | ActionConnection] = (), - ) -> Task[bool]: + ) -> bool: + await self.ready() args = { "id": f"{pack(self)}.id", "message": message, @@ -65,16 +65,13 @@ def update( } to_object = ["args.id"] - async def update(): - actions_ = [await self.app.notification._ensure_action(v) for v in actions] # noqa: SLF001 - if actions_: - args["actions"] = list(map(pack, actions_)) # type: ignore - to_object.extend(f"options.actions.{i}" for i in range(len(actions_))) - for action in actions_: - self.close_extras.add(action) - return await self.app.notification.operation("update", {"args": args}, toObject=to_object) - - return self.to_task(update()) + actions_ = [await self.app.notification._ensure_action(v) for v in actions] # noqa: SLF001 + if actions_: + args["actions"] = list(map(pack, actions_)) # type: ignore + to_object.extend(f"options.actions.{i}" for i in range(len(actions_))) + for action in actions_: + self.close_with_self(action) + return await self.app.notification.operation("update", {"args": args}, toObject=to_object) @register @@ -113,14 +110,14 @@ async def _ensure_action(self, value: ActionConnection | NotifyAction) -> Action return value return await self.new_action(**value) # type: ignore - def notify( + async def notify( self, message: str, type: NotificationType = NotificationType.default, # noqa: A002 *, auto_close: float | Literal[False] | None = None, actions: Iterable[NotifyAction | ActionConnection] = (), - ) -> Task[NotificationConnection]: + ) -> NotificationConnection: """Create a new notification. To update a notification use the update method of the returned `NotificationConnection`. @@ -132,30 +129,24 @@ def notify( keep_open: NotRequired[bool] caption: NotRequired[str] """ - + await self.ready() options = {"autoClose": auto_close} kwgs = {"type": NotificationType(type), "message": message, "options": options} - hooks: TaskHooks = { - "add_to_tuple_fwd": [(self, "connections")], - "trait_add_fwd": [("info", kwgs)], - } - - async def notify(): - actions_ = [await self._ensure_action(v) for v in actions] - if actions_: - options["actions"] = actions_ # type: ignore - cid = NotificationConnection.to_cid() - notification: NotificationConnection = await self.operation( - "notification", - kwgs, - transform={"transform": Transform.connection, "cid": cid}, - toObject=[f"options.actions[{i}]" for i in range(len(actions_))] if actions_ else [], - ) - return notification - - return self.to_task(notify(), hooks=hooks) - - def new_action( + actions_ = [await self._ensure_action(v) for v in actions] + if actions_: + options["actions"] = actions_ # type: ignore + cid = NotificationConnection.to_cid() + notification: NotificationConnection = await self.operation( + operation="notification", + kwgs=kwgs, + transform={"transform": Transform.connection, "cid": cid}, + toObject=[f"options.actions[{i}]" for i in range(len(actions_))] if actions_ else [], + ) + notification.add_to_tuple(self, "connections") + notification.info = kwgs + return notification + + async def new_action( self, label: str, callback: Callable[[], Any], @@ -163,14 +154,15 @@ def new_action( *, keep_open: bool = False, caption: str = "", - ) -> Task[ActionConnection]: + ) -> ActionConnection: "Create an action to use in a notification." + await self.ready() cid = ActionConnection.to_cid() kwgs = {"label": label, "displayType": display_type, "keep_open": keep_open, "caption": caption, "cid": cid} transform: TransformType = {"transform": Transform.connection, "cid": cid} - hooks: TaskHooks = { - "trait_add_fwd": [("callback", callback), ("info", kwgs)], - "add_to_tuple_fwd": [(self, "connections")], - "close_with_fwd": [self], - } - return self.operation("createAction", kwgs, transform=transform, hooks=hooks) + ac: ActionConnection = await self.operation("createAction", kwgs, transform=transform) + self.close_with_self(ac) + ac.callback = callback + ac.info = kwgs + ac.add_to_tuple(self, "connections") + return ac diff --git a/ipylab/sessions.py b/ipylab/sessions.py index 0101e72d..1ac4a747 100644 --- a/ipylab/sessions.py +++ b/ipylab/sessions.py @@ -3,16 +3,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from traitlets import Unicode from ipylab.common import Obj, Singular from ipylab.ipylab import Ipylab, IpylabBase -if TYPE_CHECKING: - from asyncio import Task - class SessionManager(Singular, Ipylab): """ @@ -22,16 +17,16 @@ class SessionManager(Singular, Ipylab): _model_name = Unicode("SessionManagerModel", help="Name of the model.", read_only=True).tag(sync=True) ipylab_base = IpylabBase(Obj.IpylabModel, "app.serviceManager.sessions").tag(sync=True) - def get_running(self, *, refresh=True) -> Task[dict]: + async def get_running(self, *, refresh=True) -> dict: "Get a dict of running sessions." - return self.operation("getRunning", {"refresh": refresh}) + return await self.operation("getRunning", {"refresh": refresh}) - def get_current(self): + async def get_current(self): "Get the session of the current widget in the shell." - return self.operation("getCurrentSession") + return await self.operation("getCurrentSession") - def stop_if_needed(self, path): + async def stop_if_needed(self, *, path: str): """ https://jupyterlab.readthedocs.io/en/latest/api/interfaces/services.Session.IManager.html#stopIfNeeded """ - return self.execute_method("stopIfNeeded", path) + return await self.execute_method("stopIfNeeded", (path,)) diff --git a/ipylab/shell.py b/ipylab/shell.py index a262e610..4def3015 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -12,16 +12,13 @@ import ipylab from ipylab import Area, InsertMode, Ipylab, ShellConnection, Transform, pack -from ipylab.common import Fixed, IpylabKwgs, Obj, Singular, TaskHookType +from ipylab.common import Fixed, IpylabKwgs, Obj, Singular, TransformType from ipylab.ipylab import IpylabBase from ipylab.log_viewer import LogViewer if TYPE_CHECKING: - from asyncio import Task from typing import Literal - from ipylab.common import TaskHooks - __all__ = ["Shell", "ConsoleConnection"] @@ -42,7 +39,7 @@ class Shell(Singular, Ipylab): connections: Container[tuple[ShellConnection, ...]] = TypedTuple(trait=Instance(ShellConnection)) console: Instance[ConsoleConnection | None] = Instance(ConsoleConnection, default_value=None, allow_none=True) # type: ignore - def add( + async def add( self, obj: Widget | inspect._SourceObjectType, *, @@ -53,9 +50,8 @@ def add( ref: ShellConnection | None = None, options: dict | None = None, vpath: str | dict[Literal["title"], str] = "", - hooks: TaskHookType = None, **args, - ) -> Task[ShellConnection]: + ) -> ShellConnection: """Add a widget to the shell. If the widget is already in the shell, it may be moved or activated. @@ -101,7 +97,8 @@ def add( app.shell.add("ipylab.Panel([ipw.HTML('

Test')])", vpath="test") ``` """ - hooks_: TaskHooks = {"add_to_tuple_fwd": [(self, "connections")]} + app = await self.app.ready() + vpath = vpath or app.vpath args["options"] = { "activate": activate, "mode": InsertMode(mode), @@ -125,28 +122,27 @@ def add( if c.widget is obj: args["cid"] = c.cid break - hooks_["trait_add_fwd"] = [("widget", obj)] - if isinstance(obj, ipylab.Panel): - hooks_["add_to_tuple_fwd"].append((obj, "connections")) args["ipy_model"] = obj.model_id else: args["evaluate"] = pack(obj) - - async def add_to_shell() -> ShellConnection: - vpath_ = vpath or self.app.vpath - if isinstance(obj, DOMWidget): - obj.add_class(self.app.selector.removeprefix(".")) - if "evaluate" in args and isinstance(vpath, dict): - result = ipylab.plugin_manager.hook.vpath_getter(app=self.app, kwgs=vpath) - while inspect.isawaitable(result): - result = await result - vpath_ = result - args["vpath"] = vpath_ - if vpath_ != self.app.vpath: - hooks_["trait_add_fwd"] = [("auto_dispose", False)] - return await self.operation("addToShell", {"args": args}, transform=Transform.connection, hooks=hooks_) - - return self.to_task(add_to_shell(), "Add to shell", hooks=hooks) + if isinstance(obj, DOMWidget): + obj.add_class(app.selector.removeprefix(".")) + if "evaluate" in args and isinstance(vpath, dict): + val = ipylab.plugin_manager.hook.vpath_getter(app=app, kwgs=vpath) + while inspect.isawaitable(val): + val = await val + vpath = val + args["vpath"] = vpath + + sc: ShellConnection = await self.operation("addToShell", {"args": args}, transform=Transform.connection) + sc.add_to_tuple(self, "connections") + if vpath != app.vpath: + sc.auto_dispose = False + if isinstance(obj, Widget): + sc.widget = obj + if isinstance(obj, ipylab.Panel): + sc.add_to_tuple(obj, "connections") + return sc def add_objects_to_ipython_namespace(self, objects: dict, *, reset=False): "Load objects into the IPython/console namespace." @@ -155,7 +151,7 @@ def add_objects_to_ipython_namespace(self, objects: dict, *, reset=False): self.comm.kernel.shell.reset() # type: ignore self.comm.kernel.shell.push(objects) # type: ignore - def open_console( + async def open_console( self, *, mode=InsertMode.split_bottom, @@ -163,8 +159,7 @@ def open_console( ref: ShellConnection | str = "", objects: dict | None = None, reset_shell=False, - hooks: TaskHookType = None, - ) -> Task[ConsoleConnection]: + ) -> ConsoleConnection: """Open/activate a Jupyterlab console for this python kernel shell (path=app.vpath). Parameters @@ -177,49 +172,37 @@ def open_console( reset_shell: Set true to reset the shell (clear the namespace). """ - - async def open_console(): - ref_ = ref or self.current_widget_id - if not isinstance(ref_, ShellConnection): - ref_ = await self.connect_to_widget(ref_) - objects_ = {"ref": ref_} | (objects or {}) - vpath = self.app.vpath - args = { - "path": vpath, - "insertMode": InsertMode(mode), - "activate": activate, - "ref": f"{pack(ref_)}.id", - } - kwgs = IpylabKwgs( - transform={"transform": Transform.connection, "cid": ConsoleConnection.to_cid(vpath)}, - toObject=["args[ref]"], - hooks={ - "trait_add_rev": [(self, "console")], - "add_to_tuple_fwd": [(self, "connections")], - "callbacks": [lambda _: self.add_objects_to_ipython_namespace(objects_, reset=reset_shell)], - }, - ) - return await self.app.commands.execute("console:open", args, **kwgs) - - return self.to_task(open_console(), "Open console", hooks=hooks) - - def expand_left(self): - return self.execute_method("expandLeft") - - def expand_right(self): - return self.execute_method("expandRight") - - def collapse_left(self): - return self.execute_method("collapseLeft") - - def collapse_right(self): - return self.execute_method("collapseRight") - - def connect_to_widget(self, widget_id="", **kwgs: Unpack[IpylabKwgs]) -> Task[ShellConnection]: + await self.ready() + app = await self.app.ready() + ref_ = ref or self.current_widget_id + if not isinstance(ref_, ShellConnection): + ref_ = await self.connect_to_widget(ref_) + objects_ = {"ref": ref_} | (objects or {}) + args = {"path": app.vpath, "insertMode": InsertMode(mode), "activate": activate, "ref": f"{pack(ref_)}.id"} + tf: TransformType = {"transform": Transform.connection, "cid": ConsoleConnection.to_cid(app.vpath)} + cc: ConsoleConnection = await app.commands.execute("console:open", args, toObject=["args[ref]"], transform=tf) + self.console = cc + cc.add_to_tuple(self, "connections") + self.add_objects_to_ipython_namespace(objects_, reset=reset_shell) + return cc + + async def expand_left(self): + await self.execute_method("expandLeft") + + async def expand_right(self): + await self.execute_method("expandRight") + + async def collapse_left(self): + await self.execute_method("collapseLeft") + + async def collapse_right(self): + await self.execute_method("collapseRight") + + async def connect_to_widget(self, widget_id="", **kwgs: Unpack[IpylabKwgs]) -> ShellConnection: "Make a connection to a widget in the shell (see also `get_widget_ids`)." kwgs["transform"] = Transform.connection - return self.operation("getWidget", {"id": widget_id}, **kwgs) + return await self.operation("getWidget", {"id": widget_id}, **kwgs) - def list_widget_ids(self, **kwgs: Unpack[IpylabKwgs]) -> Task[dict[Area, list[str]]]: + async def list_widget_ids(self, **kwgs: Unpack[IpylabKwgs]) -> dict[Area, list[str]]: "Get a mapping of Areas to a list of widget ids in that area in the shell." - return self.operation("getWidgetIds", **kwgs) + return await self.operation("getWidgetIds", **kwgs) diff --git a/ipylab/simple_output.py b/ipylab/simple_output.py index 49a69da0..a6ea8403 100644 --- a/ipylab/simple_output.py +++ b/ipylab/simple_output.py @@ -11,7 +11,6 @@ from ipylab.ipylab import Ipylab if TYPE_CHECKING: - from asyncio import Task from typing import Any, Unpack from IPython.display import TextDisplayObject @@ -25,7 +24,6 @@ from ipylab.ipylab import WidgetBase if TYPE_CHECKING: - from asyncio import Task from typing import Unpack @@ -53,7 +51,7 @@ def _default_format(self): def _pack_outputs(self, outputs: tuple[dict[str, str] | Widget | str | TextDisplayObject | Any, ...]): fmt = self.format for output in outputs: - if isinstance(output, dict): + if isinstance(output, dict) and "output_type" in output: yield output elif isinstance(output, str): yield {"output_type": "stream", "name": "stdout", "text": output} @@ -69,7 +67,7 @@ def push(self, *outputs: dict[str, str] | Widget | str | TextDisplayObject | Any """Add one or more items to the output. Consecutive `streams` of the same type are placed in the same 'output' up to `max_outputs`. - Outputs passed as dicts are assumed to be correctly packed as `repr_mime` data. + Outputs passed as dicts with a key "output_type" are assumed to be correctly packed as `repr_mime` data. Parameters ---------- @@ -84,9 +82,9 @@ def push(self, *outputs: dict[str, str] | Widget | str | TextDisplayObject | Any self.send({"add": items, "clear": clear}) return self - def set( + async def set( self, *outputs: dict[str, str] | Widget | str | TextDisplayObject | Any, **kwgs: Unpack[IpylabKwgs] - ) -> Task[int]: + ) -> int: """Set the output explicitly by first clearing and then adding the outputs. Compared to `push`, this is performed asynchronously and will wait for @@ -98,7 +96,7 @@ def set( outputs: Items to be displayed. """ - return self.operation("setOutputs", {"items": list(self._pack_outputs(outputs))}, **kwgs) + return await self.operation("setOutputs", {"items": list(self._pack_outputs(outputs))}, **kwgs) @register diff --git a/ipylab/widgets.py b/ipylab/widgets.py index 3bb22ee1..ee42d011 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -3,22 +3,17 @@ from __future__ import annotations -import asyncio -from typing import TYPE_CHECKING - -from ipywidgets import Box, DOMWidget, Layout, TypedTuple, register, widget_serialization +import anyio +from ipywidgets import Box, DOMWidget, Layout, TypedTuple, Widget, register, widget_serialization from ipywidgets.widgets.trait_types import InstanceDict from traitlets import Container, Dict, Instance, Tuple, Unicode, observe import ipylab import ipylab._frontend as _fe -from ipylab.common import Area, Fixed, InsertMode +from ipylab.common import Area, Fixed, InsertMode, autorun from ipylab.connection import ShellConnection from ipylab.ipylab import WidgetBase -if TYPE_CHECKING: - from asyncio import Task - @register class Icon(WidgetBase, DOMWidget): @@ -56,7 +51,7 @@ class Panel(Box): app = Fixed(lambda _: ipylab.App()) connections: Container[tuple[ShellConnection, ...]] = TypedTuple(trait=Instance(ShellConnection)) - def add_to_shell( + async def add_to_shell( self, *, area: Area = Area.main, @@ -66,17 +61,10 @@ def add_to_shell( ref: ShellConnection | None = None, options: dict | None = None, **kwgs, - ) -> Task[ShellConnection]: + ) -> ShellConnection: """Add this panel to the shell.""" - return self.app.shell.add( - self, - area=area, - mode=mode, - activate=activate, - rank=rank, - ref=ref, - options=options, - **kwgs, + return await self.app.shell.add( + self, area=area, mode=mode, activate=activate, rank=rank, ref=ref, options=options, **kwgs ) @@ -95,21 +83,18 @@ class SplitPanel(Panel): @observe("children", "connections") def _observer(self, _): - self._rerender() + self._toggle_orientation(children=self.children) - def _rerender(self): + @autorun + async def _toggle_orientation(self, children: tuple[Widget, ...]): """Toggle the orientation to cause lumino_widget.parent to re-render content.""" - - async def force_refresh(children): - if children != self.children: - return - await asyncio.sleep(0.1) - orientation = self.orientation - self.orientation = "horizontal" if orientation == "vertical" else "vertical" - await asyncio.sleep(0.001) - self.orientation = orientation - - return self.app.to_task(force_refresh(self.children)) + if children != self.children: + return + await anyio.sleep(0.1) + orientation = self.orientation + self.orientation = "horizontal" if orientation == "vertical" else "vertical" + await anyio.sleep(0.001) + self.orientation = orientation # ============== End temp fix ============= diff --git a/pyproject.toml b/pyproject.toml index 111d3354..0c9c2543 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ ] dependencies = [ + "anyio", "jupyterlab>=4.3", "ipywidgets>=8.1.5", "ipython>=8.32", diff --git a/ruff_defaults.toml b/ruff_defaults.toml index 3f21460d..8ed05cbb 100644 --- a/ruff_defaults.toml +++ b/ruff_defaults.toml @@ -386,7 +386,6 @@ select = [ "S317", "S318", "S319", - "S320", "S321", "S323", "S324", @@ -507,7 +506,6 @@ select = [ "UP035", "UP036", "UP037", - "UP038", "UP039", "UP040", "UP041", diff --git a/style/widget.css b/style/widget.css index 02b976fc..ca214caf 100644 --- a/style/widget.css +++ b/style/widget.css @@ -36,12 +36,12 @@ .ipylab-SimpleOutput { box-sizing: border-box; - display: inline block; + flex-direction: column; overflow: auto; } .ipylab-ResizeBox { box-sizing: border-box; - display: inline block; + display: inline-block; resize: both; overflow: hidden; } diff --git a/tests/conftest.py b/tests/conftest.py index 72b40be9..9d5dd54a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -import asyncio - import pytest import ipylab @@ -18,6 +16,6 @@ async def anyio_backend_autouse(anyio_backend): @pytest.fixture async def app(mocker): app = ipylab.App() + app._trait_values.pop("asyncio_loop", None) mocker.patch.object(app, "ready") - app.asyncio_loop = asyncio.get_running_loop() return app diff --git a/tests/test_common.py b/tests/test_common.py index 8b107749..25f3a11b 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -6,8 +6,7 @@ from typing import Self, override import pytest -from ipywidgets import TypedTuple -from traitlets import HasTraits, Unicode +from traitlets import Unicode import ipylab from ipylab.common import ( @@ -19,7 +18,6 @@ TransformDictAdvanced, TransformDictConnection, TransformDictFunction, - trait_tuple_add, ) from ipylab.connection import Connection @@ -29,19 +27,6 @@ def __init__(self, value=1): self.value = value -def test_trait_tuple_add(): - class TestHasTraits(HasTraits): - test_tuple = TypedTuple(trait=Unicode(), default_value=()) - - owner = TestHasTraits() - trait_tuple_add(owner, "test_tuple", "value1") - assert owner.test_tuple == ("value1",) - trait_tuple_add(owner, "test_tuple", "value2") - assert owner.test_tuple == ("value1", "value2") - trait_tuple_add(owner, "test_tuple", "value1") # Should not add duplicate - assert owner.test_tuple == ("value1", "value2") - - def test_last_updated_dict(): d = LastUpdatedDict() d["a"] = 1 @@ -134,8 +119,13 @@ def test_validate_invalid_non_dict_transform(self): Transform.validate(transform) +@pytest.fixture +async def mock_connection(mocker): + mocker.patch.object(Connection, "_ready") + + class TestTransformPayload: - def test_transform_payload_advanced(self): + async def test_transform_payload_advanced(self, mock_connection): transform: TransformDictAdvanced = { "transform": Transform.advanced, "mappings": { @@ -153,30 +143,30 @@ def test_transform_payload_advanced(self): "key1": {"id": "test_id"}, "key2": {"cid": "ipylab-Connection"}, } - result = Transform.transform_payload(transform, payload) + result = await Transform.transform_payload(transform, payload) assert isinstance(result, dict) assert "key1" in result assert "key2" in result - def test_transform_payload_connection(self): + async def test_transform_payload_connection(self, mock_connection): transform: TransformDictConnection = { "transform": Transform.connection, "cid": "ipylab-Connection", } payload = {"cid": "ipylab-Connection"} - result = Transform.transform_payload(transform, payload) + result = await Transform.transform_payload(transform, payload) assert isinstance(result, Connection) - def test_transform_payload_auto(self): + async def test_transform_payload_auto(self, mock_connection): transform = Transform.auto payload = {"cid": "ipylab-Connection"} - result = Transform.transform_payload(transform, payload) + result = await Transform.transform_payload(transform, payload) assert isinstance(result, Connection) - def test_transform_payload_no_transform(self): + async def test_transform_payload_no_transform(self, mock_connection): transform = Transform.null payload = {"key": "value"} - result = Transform.transform_payload(transform, payload) + result = await Transform.transform_payload(transform, payload) assert result == payload diff --git a/tests/test_ipylab.py b/tests/test_ipylab.py index 72202375..aa9d58cb 100644 --- a/tests/test_ipylab.py +++ b/tests/test_ipylab.py @@ -1,5 +1,7 @@ from unittest.mock import AsyncMock, MagicMock +import anyio + from ipylab.ipylab import Ipylab from ipylab.jupyterfrontend import App @@ -45,6 +47,6 @@ async def test_on_ready_async(self, app: App): # Simulate the ready event obj.set_trait("_ready", True) callback.assert_called() - assert callback.await_count == 1 # With eager task factory this should already be called. - assert callback.call_args[0][0] is obj + await anyio.sleep(0.1) + assert callback.await_count == 1 obj.close() diff --git a/tests/test_jupyterfrontend.py b/tests/test_jupyterfrontend.py index 8a1c1220..684d3f26 100644 --- a/tests/test_jupyterfrontend.py +++ b/tests/test_jupyterfrontend.py @@ -6,9 +6,9 @@ import asyncio import contextlib import json -import uuid from typing import TYPE_CHECKING, Any +import anyio import pytest if TYPE_CHECKING: @@ -19,14 +19,7 @@ def example_callable(a=None): return a -async def example_async_callable(c, *, return_task=False): - if return_task: - import asyncio - - async def f(): - return "return task" - - return asyncio.create_task(f()) +async def example_async_callable(c): return c @@ -70,50 +63,25 @@ async def f(): }, "async callable", ), - ( - { - "evaluate": example_async_callable, - "kwgs": {"c": 123, "return_task": True}, - }, - "return task", - ), ], ) async def test_app_evaluate(app: ipylab.App, kw: dict[str, Any], result, mocker): "Tests for app.evaluate" - import asyncio ready = mocker.patch.object(app, "ready") send = mocker.patch.object(app, "send") - task1 = app.evaluate(**kw, vpath="irrelevant") - await asyncio.sleep(0) - assert ready.call_count == 1 + app.start_coro(app.evaluate(**kw, vpath="irrelevant")) + await anyio.sleep(0.01) + assert ready.call_count == 2 assert send.call_count == 1 # Simulate relaying the message from the frontend to a kernel (this kernel). be_msg = json.loads(send.call_args[0][0]["ipylab"]) - data = { - "ipylab_FE": str(uuid.uuid4()), - "operation": be_msg["operation"], - "payload": be_msg["kwgs"], - } - fe_msg = {"ipylab": json.dumps(data)} - - # Simulate the message arriving in kernel and being processed - task2 = app._on_custom_msg(None, fe_msg, []) - assert isinstance(task2, asyncio.Task) - async with asyncio.timeout(1): - await task2 - assert send.call_count == 2 - be_msg2 = json.loads(send.call_args[0][0]["ipylab"]) - assert be_msg2["ipylab_FE"] == data["ipylab_FE"] - + assert list(be_msg) == ["ipylab_PY", "operation", "kwgs", "transform"] + result_ = await app._evaluate(be_msg["kwgs"], []) # Check expected result - assert be_msg2["payload"] == result - - # Don't attempt to relay the result back - task1.cancel() + assert result_["payload"] == result loops = set() From 94b79cf7c56d1364152f886aa6a28c637e188ebc Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sun, 6 Apr 2025 16:22:22 +1000 Subject: [PATCH 27/47] Update pre-commit hook versions --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30faab28..b64a4ea7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: hooks: - id: check-json5 - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.31.3 + rev: 0.32.1 hooks: - id: check-github-workflows - repo: https://github.com/ComPWA/taplo-pre-commit @@ -38,7 +38,7 @@ repos: hooks: - id: taplo-format - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.0 + rev: v0.11.4 hooks: - id: ruff types_or: [python, jupyter] From 09cd99e130e40a458261a4d342b3eda7ed629b07 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Mon, 7 Apr 2025 09:25:05 +1000 Subject: [PATCH 28/47] Update README and pyproject.toml to simplify installation and remove per-kernel-widget-manager option (now required). --- README.md | 6 ++---- ipylab/ipylab.py | 1 - pkg/ipykernel-7.0.0a1-py3-none-any.whl | Bin 0 -> 114851 bytes pyproject.toml | 9 ++++----- 4 files changed, 6 insertions(+), 10 deletions(-) create mode 100644 pkg/ipykernel-7.0.0a1-py3-none-any.whl diff --git a/README.md b/README.md index 6c4534e1..4b522cef 100644 --- a/README.md +++ b/README.md @@ -87,9 +87,7 @@ These versions enable: - Viewing widgets from kernels inside from other kernels. ```bash -# For per-kernel-widget-manager support (Install modified version of ipywidgets, jupyterlab_widgets & widgetsnbextension) - -pip install --no-binary --force-reinstall ipylab[per-kernel-widget-manager] +pip install --no-binary --force-reinstall ipylab ``` ## Running the examples locally @@ -121,7 +119,7 @@ mamba create -n ipylab -c conda-forge nodejs python=3.12 -y conda activate ipylab # install the Python package -pip install -e .[dev,per-kernel-widget-manager,test] # (with per-kernel-widget-manager) +pip install -e .[dev,test] # link the extension files jupyter labextension develop . --overwrite diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index b190f1fa..94be3cf7 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -12,7 +12,6 @@ from typing import TYPE_CHECKING, Any, cast import anyio -import anyio.to_thread import traitlets from anyio import Event, create_memory_object_stream from ipywidgets import Widget, register diff --git a/pkg/ipykernel-7.0.0a1-py3-none-any.whl b/pkg/ipykernel-7.0.0a1-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..5c7c1e04d1cc8f25601f2f0e20582590b05da1ab GIT binary patch literal 114851 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�avY#@Q!3n5ddF9?@FtvnyKS7O{! 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

J?t)h{$vj%*BO^v3TzzmF})@3d1NQL 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 0c9c2543..456ffd45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,17 +31,16 @@ dependencies = [ "ipython>=8.32", "jupyterlab_widgets>=3.0.11", "pluggy~=1.5", + "ipykernel @ {root:uri}/pkg/ipykernel-7.0.0a1-py3-none-any.whl", + "widgetsnbextension @ {root:uri}/pkg/widgetsnbextension-4.0.13-py3-none-any.whl", + "jupyterlab_widgets @ {root:uri}/pkg/jupyterlab_widgets-3.0.13-py3-none-any.whl", + "ipywidgets @ {root:uri}/pkg/ipywidgets-8.1.5-py3-none-any.whl", ] dynamic = ["version", "description", "authors", "urls", "keywords"] [project.optional-dependencies] dev = ["hatch", "ruff", "pre-commit"] test = ["pytest", "anyio", "pytest-cov", "pytest-mock"] -per-kernel-widget-manager = [ - "widgetsnbextension @ {root:uri}/pkg/widgetsnbextension-4.0.13-py3-none-any.whl", - "jupyterlab_widgets @ {root:uri}/pkg/jupyterlab_widgets-3.0.13-py3-none-any.whl", - "ipywidgets @ {root:uri}/pkg/ipywidgets-8.1.5-py3-none-any.whl", -] [project.scripts] ipylab = "ipylab:plugin_manager.hook.launch_jupyterlab" From 1edc755e1ab65fdb216a7bb2784e86b55ecb8b3f Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Mon, 7 Apr 2025 09:52:07 +1000 Subject: [PATCH 29/47] Refactor CSSStyleSheet to update css_rules using set_trait and improve error message formatting in LogViewer --- ipylab/css_stylesheet.py | 3 ++- ipylab/log_viewer.py | 2 +- pkg/traitlets-5.14.3-py3-none-any.whl | Bin 0 -> 85656 bytes pyproject.toml | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 pkg/traitlets-5.14.3-py3-none-any.whl diff --git a/ipylab/css_stylesheet.py b/ipylab/css_stylesheet.py index 7a9c4ad1..b5d492bd 100644 --- a/ipylab/css_stylesheet.py +++ b/ipylab/css_stylesheet.py @@ -36,7 +36,8 @@ async def _restore(self, _): async def _css_operation(self, operation: str, kwgs: dict | None = None) -> tuple[str, ...]: # Updates css_rules once operation is done - self.css_rules = await self.operation(operation, kwgs=kwgs) + css_rules = await self.operation(operation, kwgs=kwgs) + self.set_trait("css_rules", css_rules) return self.css_rules async def delete_rule(self, item: int | str): diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py index 45e2761e..5717e9fe 100644 --- a/ipylab/log_viewer.py +++ b/ipylab/log_viewer.py @@ -166,7 +166,7 @@ def _button_on_click(self, b): @autorun async def _show_error(self, record: logging.LogRecord): - out = SimpleOutput().push(Markdown(f"### record.levelname.capitalize(): {record.message}")) + out = SimpleOutput().push(Markdown(f"**record.levelname.capitalize():\n\n{record.message}")) try: out.push(record.output) # type: ignore except Exception: diff --git a/pkg/traitlets-5.14.3-py3-none-any.whl b/pkg/traitlets-5.14.3-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..dea4d8e650d1bcbc3c6f97e3f9056b501d7f766f GIT binary patch literal 85656 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 M4GkGLV715q0KO`YVE_OC literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 456ffd45..3c45a42e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "ipython>=8.32", "jupyterlab_widgets>=3.0.11", "pluggy~=1.5", + "traitlets @ {root:uri}/pkg/traitlets-5.14.3-py3-none-any.whl", "ipykernel @ {root:uri}/pkg/ipykernel-7.0.0a1-py3-none-any.whl", "widgetsnbextension @ {root:uri}/pkg/widgetsnbextension-4.0.13-py3-none-any.whl", "jupyterlab_widgets @ {root:uri}/pkg/jupyterlab_widgets-3.0.13-py3-none-any.whl", From 6cd8fde49a31bc3946911d9fe660a7a39cc2434a Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Mon, 7 Apr 2025 13:41:38 +1000 Subject: [PATCH 30/47] Reinstate support for Python 3.11 iaw. https://numpy.org/neps/nep-0029-deprecation_policy.html#support-table. --- .github/workflows/build.yml | 2 +- .github/workflows/packaging.yml | 6 ++---- README.md | 2 +- ipylab/code_editor.py | 3 ++- ipylab/commands.py | 3 ++- ipylab/common.py | 2 +- ipylab/connection.py | 3 ++- ipylab/jupyterfrontend.py | 3 ++- ipylab/log.py | 3 ++- ipylab/log_viewer.py | 3 ++- ipylab/menu.py | 3 ++- ipylab/notification.py | 3 ++- pyproject.toml | 6 ++++-- tests/test_common.py | 3 ++- 14 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5e2abbf..5be6c44e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,7 +50,7 @@ jobs: - name: Install Python uses: actions/setup-python@v4 with: - python-version: '3.12' + python-version: '3.11' architecture: 'x64' - uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 491fb9e1..c6a19a2c 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -50,12 +50,10 @@ jobs: fail-fast: false matrix: os: [ubuntu, macos, windows] - python: ['3.12', '3.13'] + python: ['3.11', '3.13'] include: - - python: '3.12' + - python: '3.11' dist: 'ipylab*.tar.gz' - - python: '3.13' - dist: 'ipylab*.whl' - os: windows py_cmd: python pip_cmd: python -m pip diff --git a/README.md b/README.md index 4b522cef..5e07866e 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ jupyter lab ```bash # create a new conda environment -mamba create -n ipylab -c conda-forge nodejs python=3.12 -y +conda create -n ipylab -c conda-forge nodejs python=3.11 -y # activate the environment conda activate ipylab diff --git a/ipylab/code_editor.py b/ipylab/code_editor.py index 35954344..1b91c491 100644 --- a/ipylab/code_editor.py +++ b/ipylab/code_editor.py @@ -6,7 +6,7 @@ import asyncio import inspect import typing -from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict, override +from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict from IPython.core import completer as IPC # noqa: N812 from IPython.utils.tokenutil import token_at_cursor @@ -15,6 +15,7 @@ from ipywidgets.widgets.widget_description import DescriptionStyle from ipywidgets.widgets.widget_string import _String from traitlets import Callable, Container, Dict, Instance, Int, Unicode, default, observe +from typing_extensions import override import ipylab from ipylab.common import Fixed, LastUpdatedDict diff --git a/ipylab/commands.py b/ipylab/commands.py index 9175b080..bd1cd632 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -6,11 +6,12 @@ import functools import inspect import uuid -from typing import TYPE_CHECKING, Any, ClassVar, NotRequired, TypedDict, Unpack, override +from typing import TYPE_CHECKING, Any, ClassVar, NotRequired, TypedDict, Unpack from ipywidgets import TypedTuple from traitlets import Callable as CallableTrait from traitlets import Container, Dict, Instance, Tuple, Unicode +from typing_extensions import override import ipylab from ipylab.common import IpylabKwgs, Obj, Singular, TransformType, pack diff --git a/ipylab/common.py b/ipylab/common.py index b50fb7be..fed6e08e 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -25,13 +25,13 @@ TypeVarTuple, final, overload, - override, ) import pluggy from ipywidgets import Widget, widget_serialization from traitlets import Any as AnyTrait from traitlets import Bool, HasTraits +from typing_extensions import override import ipylab diff --git a/ipylab/connection.py b/ipylab/connection.py index 8b101a99..b7b3ca2c 100644 --- a/ipylab/connection.py +++ b/ipylab/connection.py @@ -4,10 +4,11 @@ from __future__ import annotations import uuid -from typing import TYPE_CHECKING, ClassVar, override +from typing import TYPE_CHECKING, ClassVar from ipywidgets import Widget, register from traitlets import Bool, Dict, Instance, Unicode +from typing_extensions import override from ipylab.common import Singular from ipylab.ipylab import Ipylab diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index f38d36dd..2ca6816f 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -7,10 +7,11 @@ import contextlib import functools import inspect -from typing import TYPE_CHECKING, Any, Self, Unpack, final, override +from typing import TYPE_CHECKING, Any, Self, Unpack, final from ipywidgets import Widget, register from traitlets import Bool, Container, Dict, Instance, Unicode, UseEnum, default, observe +from typing_extensions import override import ipylab from ipylab import Ipylab diff --git a/ipylab/log.py b/ipylab/log.py index d2428232..99be7e49 100644 --- a/ipylab/log.py +++ b/ipylab/log.py @@ -6,10 +6,11 @@ import logging import weakref from enum import IntEnum, StrEnum -from typing import TYPE_CHECKING, Any, ClassVar, override +from typing import TYPE_CHECKING, Any, ClassVar from IPython.core.ultratb import FormattedTB from ipywidgets import CallbackDispatcher +from typing_extensions import override import ipylab from ipylab.common import Fixed diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py index 5717e9fe..8e3973d2 100644 --- a/ipylab/log_viewer.py +++ b/ipylab/log_viewer.py @@ -4,11 +4,12 @@ from __future__ import annotations import collections -from typing import TYPE_CHECKING, Self, override +from typing import TYPE_CHECKING, Self from IPython.display import Markdown from ipywidgets import HTML, BoundedIntText, Button, Checkbox, Combobox, Dropdown, HBox, Select, VBox from traitlets import directional_link, link, observe +from typing_extensions import override import ipylab from ipylab.common import SVGSTR_TEST_TUBE, Area, Fixed, InsertMode, autorun diff --git a/ipylab/menu.py b/ipylab/menu.py index 3044626d..3b3a91a8 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -3,10 +3,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Self, override +from typing import TYPE_CHECKING, Self from ipywidgets import TypedTuple from traitlets import Container, Instance, Union +from typing_extensions import override from ipylab.commands import APP_COMMANDS_NAME, CommandRegistry from ipylab.common import Fixed, Obj, Singular diff --git a/ipylab/notification.py b/ipylab/notification.py index 2e39c4f9..ba0f4036 100644 --- a/ipylab/notification.py +++ b/ipylab/notification.py @@ -5,11 +5,12 @@ import inspect from enum import StrEnum -from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, override +from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict import traitlets from ipywidgets import TypedTuple, register from traitlets import Container, Instance, Unicode +from typing_extensions import override from ipylab import Transform, pack from ipylab.common import Obj, Singular, TransformType diff --git a/pyproject.toml b/pyproject.toml index 3c45a42e..fc57248c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ build-backend = "hatchling.build" name = "ipylab" readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.12" +requires-python = ">=3.11" classifiers = [ "Framework :: Jupyter", "Framework :: Jupyter :: JupyterLab", @@ -20,6 +20,7 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] @@ -31,6 +32,7 @@ dependencies = [ "ipython>=8.32", "jupyterlab_widgets>=3.0.11", "pluggy~=1.5", + "typing_extensions", "traitlets @ {root:uri}/pkg/traitlets-5.14.3-py3-none-any.whl", "ipykernel @ {root:uri}/pkg/ipykernel-7.0.0a1-py3-none-any.whl", "widgetsnbextension @ {root:uri}/pkg/widgetsnbextension-4.0.13-py3-none-any.whl", @@ -44,7 +46,7 @@ dev = ["hatch", "ruff", "pre-commit"] test = ["pytest", "anyio", "pytest-cov", "pytest-mock"] [project.scripts] -ipylab = "ipylab:plugin_manager.hook.launch_jupyterlab" +ipylab = "ipylab:plugin_manager.hook.launch_ipylab" [tool.hatch.version] source = "nodejs" diff --git a/tests/test_common.py b/tests/test_common.py index 25f3a11b..6adef575 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -3,10 +3,11 @@ from __future__ import annotations -from typing import Self, override +from typing import Self import pytest from traitlets import Unicode +from typing_extensions import override import ipylab from ipylab.common import ( From 13dc861febf41e7f93ddf81589832e9d2faf8f41 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Mon, 7 Apr 2025 14:45:40 +1000 Subject: [PATCH 31/47] Update example notebooks to for async function calls --- README.md | 2 +- examples/commands.ipynb | 2 +- examples/css_stylesheet.ipynb | 57 +++------------- examples/dialogs.ipynb | 125 ++++------------------------------ examples/generic.ipynb | 31 +++------ examples/icons.ipynb | 52 ++++++-------- examples/ipytree.ipynb | 4 +- examples/menu.ipynb | 9 +-- examples/notifications.ipynb | 16 +---- examples/plugins.ipynb | 9 +-- examples/resize_box.ipynb | 20 +----- examples/sessions.ipynb | 11 +-- examples/widgets.ipynb | 22 +----- pyproject.toml | 1 + 14 files changed, 63 insertions(+), 298 deletions(-) diff --git a/README.md b/README.md index 5e07866e..d8736998 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ conda create -n ipylab -c conda-forge nodejs python=3.11 -y conda activate ipylab # install the Python package -pip install -e .[dev,test] +pip install -e .[dev,test,examples] # link the extension files jupyter labextension develop . --overwrite diff --git a/examples/commands.ipynb b/examples/commands.ipynb index d7452385..953a2a80 100644 --- a/examples/commands.ipynb +++ b/examples/commands.ipynb @@ -434,7 +434,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/css_stylesheet.ipynb b/examples/css_stylesheet.ipynb index 9a9a50ec..c1151079 100644 --- a/examples/css_stylesheet.ipynb +++ b/examples/css_stylesheet.ipynb @@ -1,12 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." - ] - }, { "cell_type": "code", "execution_count": null, @@ -51,10 +44,10 @@ "ss = CSSStyleSheet()\n", "\n", "# Set a css variable\n", - "t = ss.set_variables({\"--ipylab-custom\": \"orange\"}) # Demonstrate setting a variable\n", + "await ss.set_variables({\"--ipylab-custom\": \"orange\"}) # Demonstrate setting a variable\n", "\n", "# Define some new css\n", - "t = ss.replace(\"\"\"\n", + "await ss.replace(\"\"\"\n", ".resize-both { resize: both; border: solid 2px var(--ipylab-custom);}\n", ".resize-horizontal { resize: horizontal; border: solid 2px blue;}\n", "\"\"\") # Define the stylesheet" @@ -83,16 +76,6 @@ "ipw.Box([b, bb])" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Rules are updated as the operations complete.\n", - "ss.css_rules" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -106,16 +89,7 @@ "metadata": {}, "outputs": [], "source": [ - "ss.insert_rule(\".jp-MainAreaWidget { border: 2px double blue; }\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "ss.css_rules" + "await ss.insert_rule(\".jp-MainAreaWidget { border: 2px double blue; }\")" ] }, { @@ -124,7 +98,7 @@ "metadata": {}, "outputs": [], "source": [ - "ss.delete_rule(0)" + "await ss.delete_rule(0)" ] }, { @@ -161,16 +135,8 @@ "metadata": {}, "outputs": [], "source": [ - "t = ss.get_variables()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" + "variables = await ss.get_variables()\n", + "ipylab.SimpleOutput(layout={\"max_height\": \"200px\"}).push(variables)" ] }, { @@ -190,7 +156,7 @@ "outputs": [], "source": [ "ss = CSSStyleSheet()\n", - "ss.replace(\"\"\"\n", + "await ss.replace(\"\"\"\n", "/* Modify Jupyter Styles */\n", "\n", ".lm-BoxPanel-child,\n", @@ -236,13 +202,6 @@ "# Revert\n", "ss.close()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -261,7 +220,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/dialogs.ipynb b/examples/dialogs.ipynb index 03de1ff3..0db24f0f 100644 --- a/examples/dialogs.ipynb +++ b/examples/dialogs.ipynb @@ -1,12 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." - ] - }, { "cell_type": "code", "execution_count": null, @@ -23,19 +16,6 @@ "# Panels and Widgets" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Warning for notebooks and consoles\n", - "\n", - "**Do not try to await tasks returned from any ipylab methods, doing so block forever preventing further execution.**\n", - "\n", - "This happens because Ipylab employs custom messages over widget comms and widget comms is blocked during cell execution (in the default kernel and server).\n", - "\n", - "see [Plugins](plugins.ipynb#Example-launching-a-small-app) or [Actions](widgets.ipynb#Notification-Actions) for an example of awaiting the tasks in a coroutine." - ] - }, { "cell_type": "code", "execution_count": null, @@ -46,7 +26,7 @@ "\n", "import ipylab\n", "\n", - "app = ipylab.App()" + "app = await ipylab.App().ready()" ] }, { @@ -75,16 +55,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.dialog.get_item(\"Select an item\", [1, 2, 3])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" + "await app.dialog.get_item(\"Select an item\", [1, 2, 3])" ] }, { @@ -100,16 +71,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.dialog.get_boolean(\"Select boolean value\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" + "await app.dialog.get_boolean(\"Select boolean value\")" ] }, { @@ -125,16 +87,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.dialog.get_number(\"Provide a numeric value\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" + "await app.dialog.get_number(\"Provide a numeric value\")" ] }, { @@ -150,16 +103,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.dialog.get_text(\"Enter text\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" + "await app.dialog.get_text(\"Enter text\")" ] }, { @@ -177,16 +121,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.dialog.get_password(\"Provide a password\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" + "await app.dialog.get_password(\"Provide a password\")" ] }, { @@ -195,7 +130,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.dialog.show_dialog(\n", + "await app.dialog.show_dialog(\n", " \"A custom dialog\",\n", " \"It returns the raw result, and there is no cancellation\",\n", " options={\n", @@ -234,15 +169,6 @@ ")" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -256,7 +182,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.dialog.show_dialog(body=ipw.VBox([ipw.HTML(\"SomeTitle\"), ipw.FloatSlider()]))" + "await app.dialog.show_dialog(body=ipw.VBox([ipw.HTML(\"SomeTitle\"), ipw.FloatSlider()]))" ] }, { @@ -265,7 +191,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.dialog.show_error_message(\n", + "await app.dialog.show_error_message(\n", " \"My error\",\n", " \"Please acknowledge\",\n", " options={\n", @@ -286,15 +212,6 @@ ")" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -308,25 +225,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.dialog.get_open_files()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t.result()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "t = app.dialog.get_existing_directory()" + "await app.dialog.get_open_files()" ] }, { @@ -335,7 +234,7 @@ "metadata": {}, "outputs": [], "source": [ - "t.result()" + "await app.dialog.get_existing_directory()" ] } ], @@ -355,7 +254,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/generic.ipynb b/examples/generic.ipynb index 7b5d0b8a..aa8160ef 100644 --- a/examples/generic.ipynb +++ b/examples/generic.ipynb @@ -1,12 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." - ] - }, { "cell_type": "code", "execution_count": null, @@ -18,7 +11,9 @@ }, { "cell_type": "markdown", - "metadata": {}, + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, "source": [ "# Ipylab\n", "\n", @@ -98,19 +93,9 @@ "source": [ "import ipylab\n", "\n", - "app = ipylab.App()\n", + "app = await ipylab.App().ready()\n", "\n", - "t = app.list_properties(\"shell\", depth=2)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "methods = t.result()\n", - "methods" + "await app.list_properties(\"shell\", depth=2)" ] }, { @@ -154,7 +139,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.shell.expand_right() # Built in" + "await app.shell.expand_right() # Built in" ] }, { @@ -163,7 +148,7 @@ "metadata": {}, "outputs": [], "source": [ - "t = app.shell.execute_method(\"collapseRight\") # Generic" + "await app.shell.execute_method(\"collapseRight\") # Generic" ] }, { @@ -190,7 +175,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.7" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/icons.ipynb b/examples/icons.ipynb index ceb031fa..b1ef3cd7 100644 --- a/examples/icons.ipynb +++ b/examples/icons.ipynb @@ -4,21 +4,13 @@ "cell_type": "markdown", "id": "0", "metadata": {}, - "source": [ - "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." - ] - }, - { - "cell_type": "markdown", - "id": "1", - "metadata": {}, "source": [ "# Icons" ] }, { "cell_type": "markdown", - "id": "2", + "id": "1", "metadata": {}, "source": [ "Icons can be applied to both the `Title` of a `Panel` [widgets](./widgets.ipynb) and [commands](./commands.ipynb), providing more customization than `icon_class`." @@ -27,7 +19,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3", + "id": "2", "metadata": {}, "outputs": [], "source": [ @@ -37,7 +29,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4", + "id": "3", "metadata": { "tags": [] }, @@ -55,7 +47,7 @@ }, { "cell_type": "markdown", - "id": "5", + "id": "4", "metadata": {}, "source": [ "## SVG\n", @@ -72,7 +64,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "5", "metadata": { "tags": [] }, @@ -86,7 +78,7 @@ }, { "cell_type": "markdown", - "id": "7", + "id": "6", "metadata": {}, "source": [ "Icons can be displayed directly, and sized with the `layout` member inherited from `ipywidgets.DOMWidget`." @@ -95,7 +87,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "7", "metadata": { "tags": [] }, @@ -107,7 +99,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "8", "metadata": {}, "source": [ "### More about `jp-icon` classes\n", @@ -117,7 +109,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "9", "metadata": { "tags": [] }, @@ -138,7 +130,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "10", "metadata": {}, "source": [ "## Icons on Panel Titles\n", @@ -149,7 +141,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "11", "metadata": { "tags": [] }, @@ -163,7 +155,7 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "12", "metadata": {}, "source": [ "### More Title Options\n", @@ -174,7 +166,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "13", "metadata": { "tags": [] }, @@ -206,7 +198,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "14", "metadata": {}, "source": [ "## Icons on Commands\n", @@ -217,7 +209,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "15", "metadata": { "tags": [] }, @@ -237,7 +229,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "16", "metadata": { "tags": [] }, @@ -249,7 +241,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -258,7 +250,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "18", "metadata": {}, "source": [ "We can use methods on `cmd` (Connection for the cmd registered in the frontend) to add it to the command pallet, and create a launcher." @@ -267,7 +259,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "19", "metadata": { "tags": [] }, @@ -278,7 +270,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "20", "metadata": {}, "source": [ "Then open the _Command Palette_ (keyboard shortcut is `CTRL + SHIFT + C`)." @@ -286,7 +278,7 @@ }, { "cell_type": "markdown", - "id": "22", + "id": "21", "metadata": {}, "source": [ "And run 'Randomize my icon'" @@ -309,7 +301,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/ipytree.ipynb b/examples/ipytree.ipynb index 8ab3658e..5eab5244 100644 --- a/examples/ipytree.ipynb +++ b/examples/ipytree.ipynb @@ -377,7 +377,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -391,7 +391,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.2" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/menu.ipynb b/examples/menu.ipynb index 1e67ff3c..1e06b9ce 100644 --- a/examples/menu.ipynb +++ b/examples/menu.ipynb @@ -1,12 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -331,7 +324,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/notifications.ipynb b/examples/notifications.ipynb index d4680984..46b279a0 100644 --- a/examples/notifications.ipynb +++ b/examples/notifications.ipynb @@ -1,12 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." - ] - }, { "cell_type": "code", "execution_count": null, @@ -110,13 +103,6 @@ " auto_close=False,\n", ")" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { @@ -135,7 +121,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/plugins.ipynb b/examples/plugins.ipynb index 22f85081..68602294 100644 --- a/examples/plugins.ipynb +++ b/examples/plugins.ipynb @@ -1,12 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." - ] - }, { "cell_type": "code", "execution_count": null, @@ -286,7 +279,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/resize_box.ipynb b/examples/resize_box.ipynb index c9328ab2..19875c77 100644 --- a/examples/resize_box.ipynb +++ b/examples/resize_box.ipynb @@ -1,12 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -87,15 +80,6 @@ "`ipympl` provides a resizeable figure, but it isn't dynamically resizeable." ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%pip install -q ipympl numpy" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -113,7 +97,7 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "\n", - "mpl.use(\"module://ipympl.backend_nbagg\")\n", + "mpl.use(\"module://ipympl.backend_nbagg\") # ipympl\n", "\n", "x = np.linspace(0, 2 * np.pi, 200)\n", "y = np.sin(x)\n", @@ -212,7 +196,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/sessions.ipynb b/examples/sessions.ipynb index 595eeec4..2df873c0 100644 --- a/examples/sessions.ipynb +++ b/examples/sessions.ipynb @@ -1,12 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -81,7 +74,7 @@ "metadata": {}, "outputs": [], "source": [ - "app.commands.execute(\"console:create\", session)" + "await app.commands.execute(\"console:create\", session)" ] } ], @@ -101,7 +94,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/examples/widgets.ipynb b/examples/widgets.ipynb index 89724e22..80769f3a 100644 --- a/examples/widgets.ipynb +++ b/examples/widgets.ipynb @@ -1,12 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." - ] - }, { "cell_type": "code", "execution_count": null, @@ -23,19 +16,6 @@ "# Panels and Widgets" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Warning for notebooks and consoles\n", - "\n", - "**Do not try to await tasks returned from any ipylab methods, doing so block forever preventing further execution.**\n", - "\n", - "This happens because Ipylab employs custom messages over widget comms and widget comms is blocked during cell execution (in the default kernel and server).\n", - "\n", - "see [Plugins](plugins.ipynb#Example-launching-a-small-app) or [Actions](widgets.ipynb#Notification-Actions) for an example of awaiting the tasks in a coroutine." - ] - }, { "cell_type": "code", "execution_count": null, @@ -486,7 +466,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.11.11" } }, "nbformat": 4, diff --git a/pyproject.toml b/pyproject.toml index fc57248c..c9686082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dynamic = ["version", "description", "authors", "urls", "keywords"] [project.optional-dependencies] dev = ["hatch", "ruff", "pre-commit"] test = ["pytest", "anyio", "pytest-cov", "pytest-mock"] +examples = ['bqlot', "matplotlib", 'numpy', 'ipympl'] [project.scripts] ipylab = "ipylab:plugin_manager.hook.launch_ipylab" From 75b492dcde655671307f472d57a1bd6e47ee30d2 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 9 Apr 2025 08:45:18 +1000 Subject: [PATCH 32/47] Make on_ready a historic type callback. Plus a few minor bugfixes. --- examples/menu.ipynb | 2 +- ipylab/ipylab.py | 25 +++++++++++++++++++------ ipylab/menu.py | 6 +++--- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/examples/menu.ipynb b/examples/menu.ipynb index 1e06b9ce..7a67a998 100644 --- a/examples/menu.ipynb +++ b/examples/menu.ipynb @@ -286,7 +286,7 @@ "metadata": {}, "outputs": [], "source": [ - "async def create_menus(app: ipylab.App):\n", + "async def create_menus(app:ipylab.App):\n", " menu = await app.commands.create_menu(\"🌈 MY CUSTOM MENU 🎌\")\n", " await app.main_menu.add_menu(menu)\n", " await populate_menu(menu)\n", diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 94be3cf7..13fc5a38 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -137,9 +137,7 @@ def _observe_comm(self, change: dict): self._ready_event.set() self._ready_event = Event() for cb in self._on_ready_callbacks: - result = cb(self) - if inspect.iscoroutine(result): - self.start_coro(result) + self._call_ready_callback(cb) def close(self): if self.comm: @@ -233,6 +231,11 @@ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffer # Overload as required raise NotImplementedError(operation) + def _call_ready_callback(self, callback: Callable[[Self], None | CoroutineType]): + result = callback(self) + if inspect.iscoroutine(result): + self.start_coro(result) + async def ready(self) -> Self: """Wait for the instance to be ready. @@ -244,8 +247,16 @@ async def ready(self) -> Self: await self._ready_event.wait() return self - def on_ready(self, callback, remove=False): # noqa: FBT002 - """Register a callback to execute when the application is ready. + def on_ready(self, callback: Callable[[Self], None | CoroutineType], remove=False): # noqa: FBT002 + """Register a historic callback to execute when the frontend indicates + it is ready. + + `historic` meaning that the callback will be called immediately if the + instance is already ready. + + It will be called when the instance is first created, and subsequently + when the fronted is reloaded, such as when the page is refreshed or the + workspace is reloaded. The callback will be executed only once. @@ -257,8 +268,10 @@ def on_ready(self, callback, remove=False): # noqa: FBT002 If True, remove the callback from the list of callbacks. By default, False. """ - if not remove: + if not remove and callback not in self._on_ready_callbacks: self._on_ready_callbacks.append(callback) + if self._ready: + self._call_ready_callback(callback) elif callback in self._on_ready_callbacks: self._on_ready_callbacks.remove(callback) diff --git a/ipylab/menu.py b/ipylab/menu.py index 3b3a91a8..1f083cfc 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -125,7 +125,7 @@ class Menu(Singular, RankedMenu): @classmethod @override - def get_single_key(cls, commands: str, **kwgs): + def get_single_key(cls, commands: CommandRegistry, **kwgs): return commands def __init__(self, *, commands: CommandRegistry, **kwgs): @@ -185,7 +185,7 @@ async def add_menu(self, menu: MenuConnection, *, update=True, rank: int = 500) return await self.execute_method("addMenu", (menu, update, options), toObject=["args[0]"]) @override - def activate(self): # type: ignore + async def activate(self): "Does nothing. Instead you should activate a submenu." @@ -214,5 +214,5 @@ async def add_item( # type: ignore return await self._add_item(command, submenu, rank, type, args, selector or app.selector) @override - def activate(self): # type: ignore + async def activate(self): "Does nothing for a context menu" From 1c6e2056c9997aa03d475e96a9c45a3bd1b40f8d Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 9 Apr 2025 08:45:46 +1000 Subject: [PATCH 33/47] Enhance method documentation for Ipylab class, adding detailed docstrings for execute_method, get_property, set_property, update_property, and list_properties using copilot. --- ipylab/ipylab.py | 78 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 3 deletions(-) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 13fc5a38..d12dd258 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -409,19 +409,91 @@ async def operation( return cast(Any, result) async def execute_method(self, subpath: str, args: tuple = (), obj=Obj.base, **kwargs: Unpack[IpylabKwgs]) -> Any: + """Execute a method on a remote object in the frontend. + + Parameters + ---------- + subpath : str + The path to the method to execute, relative to the object. + args : tuple, optional + The positional arguments to pass to the method, by default (). + obj : Obj, optional + The object on which to execute the method, by default Obj.base. + **kwargs : Unpack[IpylabKwgs] + The keyword arguments to pass to the method. + + Returns + ------- + Any + The result of the method call. + """ return await self._obj_operation(obj, subpath, "executeMethod", {"args": args}, kwargs) async def get_property(self, subpath: str, *, obj=Obj.base, null_if_missing=False, **kwargs: Unpack[IpylabKwgs]): + """Get a property from an object in the frontend. + + Parameters + ---------- + subpath: str + The path to the property to get, e.g. "foo.bar". + obj: Obj + The object to get the property from. + null_if_missing: bool + If True, return None if the property is missing. + **kwargs: Unpack[IpylabKwgs] + Keyword arguments to pass to the Javascript function. + + Returns + ------- + Any + The value of the property. + """ return self._obj_operation(obj, subpath, "getProperty", {"null_if_missing": null_if_missing}, kwargs) - async def set_property(self, subpath: str, value, *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]): + async def set_property(self, subpath: str, value, *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]) -> None: + """Set a property of an object in the frontend. + + Args: + subpath: The path to the property to set. + value: The value to set the property to. + obj: The JavaScript object to set the property on. Defaults to Obj.base. + **kwargs: Keyword arguments to pass to the JavaScript function. + + Returns: + None + """ return await self._obj_operation(obj, subpath, "setProperty", {"value": value}, kwargs) - async def update_property(self, subpath: str, value: dict[str, Any], *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]): + async def update_property( + self, subpath: str, value: dict[str, Any], *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs] + ) -> dict[str, Any]: + """Update a property of an object in the frontend equivalent to a `dict.update` call. + + Args: + subpath: The path to the property to update. + value: A mapping of the items to override (existing non-mapped values remain). + obj: The object to update. Defaults to Obj.base. + **kwargs: Keyword arguments to pass to the _obj_operation method. + + Returns: + The updated property. + """ return await self._obj_operation(obj, subpath, "updateProperty", {"value": value}, kwargs) async def list_properties( self, subpath="", *, obj=Obj.base, depth=3, skip_hidden=True, **kwargs: Unpack[IpylabKwgs] - ) -> dict: + ) -> dict[str, Any]: + """List properties of a given object in the frontend. + + Args: + subpath (str, optional): Subpath to the object. Defaults to "". + obj (Obj, optional): Object to list properties from. Defaults to Obj.base. + depth (int, optional): Depth of the listing. Defaults to 3. + skip_hidden (bool, optional): Whether to skip hidden properties. Defaults to True. + **kwargs (Unpack[IpylabKwgs]): Additional keyword arguments. + + Returns: + dict[str, Any]: Dictionary of properties. + """ kwgs = {"depth": depth, "omitHidden": skip_hidden} return await self._obj_operation(obj, subpath, "listProperties", kwgs, kwargs) From 3c4774508434c43c06cc22ba79568918bb5d0c67 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 9 Apr 2025 09:38:30 +1000 Subject: [PATCH 34/47] Refactor LogViewer and widgets to introduce AddToShellType for improved shell integration and simplify add_to_shell method parameters. --- ipylab/log_viewer.py | 25 +++++-------------------- ipylab/widgets.py | 28 ++++++++++++++-------------- 2 files changed, 19 insertions(+), 34 deletions(-) diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py index 8e3973d2..befc1700 100644 --- a/ipylab/log_viewer.py +++ b/ipylab/log_viewer.py @@ -12,15 +12,14 @@ from typing_extensions import override import ipylab -from ipylab.common import SVGSTR_TEST_TUBE, Area, Fixed, InsertMode, autorun +from ipylab.common import SVGSTR_TEST_TUBE, Fixed, InsertMode, autorun from ipylab.log import LogLevel from ipylab.simple_output import AutoScroll, SimpleOutput -from ipylab.widgets import Icon, Panel +from ipylab.widgets import AddToShellType, Icon, Panel if TYPE_CHECKING: import logging - from ipylab.connection import ShellConnection __all__ = ["LogViewer"] @@ -30,7 +29,8 @@ class LogViewer(Panel): _updating = False info = Fixed(lambda _: HTML(layout={"flex": "1 0 auto", "margin": "0px 20px 0px 20px"})) - app = Fixed[Self, "ipylab.App"](lambda _: ipylab.App()) + add_to_shell_defaults = AddToShellType(mode=InsertMode.split_bottom) + log_level = Fixed[Self, Dropdown]( lambda _: Dropdown( description="Level", @@ -167,7 +167,7 @@ def _button_on_click(self, b): @autorun async def _show_error(self, record: logging.LogRecord): - out = SimpleOutput().push(Markdown(f"**record.levelname.capitalize():\n\n{record.message}")) + out = SimpleOutput().push(Markdown(f"**{record.levelname.capitalize()}**:\n\n{record.message}")) try: out.push(record.output) # type: ignore except Exception: @@ -226,18 +226,3 @@ def observe(change: dict): b.disabled = False for w in [search, body, select]: w.close() - - async def add_to_shell( - self, - *, - area=Area.main, - activate: bool = True, - mode=InsertMode.split_bottom, - rank: int | None = None, - ref: ipylab.ShellConnection | None = None, - options: dict | None = None, - **kwgs, - ) -> ShellConnection: - return await super().add_to_shell( - area=area, activate=activate, mode=mode, rank=rank, ref=ref, options=options, **kwgs - ) diff --git a/ipylab/widgets.py b/ipylab/widgets.py index ee42d011..0ec45686 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -3,6 +3,8 @@ from __future__ import annotations +from typing import ClassVar, NotRequired, TypedDict, Unpack + import anyio from ipywidgets import Box, DOMWidget, Layout, TypedTuple, Widget, register, widget_serialization from ipywidgets.widgets.trait_types import InstanceDict @@ -15,6 +17,15 @@ from ipylab.ipylab import WidgetBase +class AddToShellType(TypedDict): + area: NotRequired[Area] + activate: NotRequired[bool] + mode: NotRequired[InsertMode] + rank: NotRequired[int | None] + ref: NotRequired[ShellConnection | None] + options: NotRequired[dict | None] + + @register class Icon(WidgetBase, DOMWidget): _model_name = Unicode("IconModel").tag(sync=True) @@ -50,22 +61,11 @@ class Panel(Box): app = Fixed(lambda _: ipylab.App()) connections: Container[tuple[ShellConnection, ...]] = TypedTuple(trait=Instance(ShellConnection)) + add_to_shell_defaults: ClassVar = AddToShellType(mode=InsertMode.tab_after) - async def add_to_shell( - self, - *, - area: Area = Area.main, - activate: bool = True, - mode: InsertMode = InsertMode.tab_after, - rank: int | None = None, - ref: ShellConnection | None = None, - options: dict | None = None, - **kwgs, - ) -> ShellConnection: + async def add_to_shell(self, **kwgs: Unpack[AddToShellType]) -> ShellConnection: """Add this panel to the shell.""" - return await self.app.shell.add( - self, area=area, mode=mode, activate=activate, rank=rank, ref=ref, options=options, **kwgs - ) + return await self.app.shell.add(self, **self.add_to_shell_defaults | kwgs) @register From 13ebc4d15f63bc40ecc6999e0430eee8cad488bc Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 9 Apr 2025 14:19:31 +1000 Subject: [PATCH 35/47] Improve error reporting when evaluating code in another shell. --- examples/menu.ipynb | 2 +- ipylab/hookspecs.py | 9 +++- ipylab/ipylab.py | 1 + ipylab/jupyterfrontend.py | 96 ++++++++++++++++++++------------------- ipylab/lib.py | 9 ++++ ipylab/log_viewer.py | 8 ++-- 6 files changed, 73 insertions(+), 52 deletions(-) diff --git a/examples/menu.ipynb b/examples/menu.ipynb index 7a67a998..1e06b9ce 100644 --- a/examples/menu.ipynb +++ b/examples/menu.ipynb @@ -286,7 +286,7 @@ "metadata": {}, "outputs": [], "source": [ - "async def create_menus(app:ipylab.App):\n", + "async def create_menus(app: ipylab.App):\n", " menu = await app.commands.create_menu(\"🌈 MY CUSTOM MENU 🎌\")\n", " await app.main_menu.add_menu(menu)\n", " await populate_menu(menu)\n", diff --git a/ipylab/hookspecs.py b/ipylab/hookspecs.py index 3b2ade13..126cdb6d 100644 --- a/ipylab/hookspecs.py +++ b/ipylab/hookspecs.py @@ -10,9 +10,11 @@ hookspec = pluggy.HookspecMarker("ipylab") if TYPE_CHECKING: + import asyncio from collections.abc import Awaitable import ipylab + from ipylab.log import IpylabLogHandler @hookspec(firstresult=True) @@ -63,5 +65,10 @@ async def vpath_getter(app: ipylab.App, kwgs: dict) -> str: # type: ignore @hookspec(firstresult=True) -def get_asyncio_event_loop(app: ipylab.App) -> asyncio.AbstractEventLoop: # type: ignore # noqa: F821 +def get_asyncio_event_loop(app: ipylab.App) -> asyncio.AbstractEventLoop: # type: ignore + "Get the asyncio event loop." + + +@hookspec(firstresult=True) +def get_logging_handler(app: ipylab.App) -> IpylabLogHandler: # type: ignore "Get the asyncio event loop." diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index d12dd258..133b3d4c 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -217,6 +217,7 @@ async def _do_operation_for_fe(self, ipylab_FE: str, operation: str, payload: di except asyncio.CancelledError: content["error"] = "Cancelled" except Exception as e: + content["error"] = f"{e.__class__.__name__}: {e}" self.log.exception("Frontend operation", obj={"operation": operation, "payload": payload}, exc_info=e) finally: self._ipylab_send(content, buffers) diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 2ca6816f..b5d973ce 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -20,7 +20,7 @@ from ipylab.dialog import Dialog from ipylab.ipylab import IpylabBase from ipylab.launcher import Launcher -from ipylab.log import IpylabLogFormatter, IpylabLogHandler, LogLevel +from ipylab.log import IpylabLogHandler, LogLevel from ipylab.menu import ContextMenu, MainMenu from ipylab.notification import NotificationManager from ipylab.sessions import SessionManager @@ -56,7 +56,10 @@ class App(Singular, Ipylab): context_menu: Fixed[Self, ContextMenu] = Fixed(lambda c: ContextMenu(commands=c["owner"].commands)) sessions = Fixed(SessionManager) - logging_handler: Instance[IpylabLogHandler | None] = Instance(IpylabLogHandler, allow_none=True) # type: ignore + logging_handler: Fixed[Self, IpylabLogHandler] = Fixed( + lambda c: ipylab.plugin_manager.hook.get_logging_handler(app=c["owner"]), + created=lambda c: c["owner"].shell.log_viewer, + ) log_level = UseEnum(LogLevel, LogLevel.ERROR) asyncio_loop: Instance[asyncio.AbstractEventLoop | None] = Instance(asyncio.AbstractEventLoop, allow_none=True) # type: ignore @@ -67,13 +70,6 @@ def close(self, *, force=False): if force: super().close() - @default("logging_handler") - def _default_logging_handler(self): - fmt = "%(color)s%(level_symbol)s %(asctime)s.%(msecs)d %(name)s %(owner_rep)s: %(message)s %(reset)s\n" - handler = IpylabLogHandler(self.log_level) - handler.setFormatter(IpylabLogFormatter(fmt=fmt, style="%", datefmt="%H:%M:%S")) - return handler - @default("asyncio_loop") def _default_asyncio_loop(self): return ipylab.plugin_manager.hook.get_asyncio_event_loop(app=self) @@ -207,44 +203,50 @@ async def _evaluate(self, options: dict[str, Any], buffers: list): A call to this method should originate from a call to `evaluate` from app in another kernel. The call is sent as a message via the frontend.""" - evaluate = options["evaluate"] - if isinstance(evaluate, str): - evaluate = (evaluate,) - namespace_id = options.get("namespace_id", "") - ns = self.get_namespace(namespace_id, buffers=buffers) - for row in evaluate: - name, expression = ("payload", row) if isinstance(row, str) else row - try: - result = eval(expression, ns) # noqa: S307 - except SyntaxError: - exec(expression, ns) # noqa: S102 - result = next(reversed(ns.values())) # Requires: LastUpdatedDict - if not name: - continue - while callable(result) or inspect.isawaitable(result): - if callable(result): - kwgs = {} - for p in inspect.signature(result).parameters: - if p in options: - kwgs[p] = options[p] - elif p in ns: - kwgs[p] = ns[p] - # We use a partial so that we can evaluate with the same namespace. - ns["_partial_call"] = functools.partial(result, **kwgs) - result = eval("_partial_call()", ns) # type: ignore # noqa: S307 - ns.pop("_partial_call") - if inspect.isawaitable(result): - result = await result - if name: - ns[name] = result - buffers = ns.pop("buffers", []) - payload = ns.pop("payload", None) - if payload is not None: - ns["_call_count"] = n = ns.get("_call_count", 0) + 1 - ns[f"payload_{n}"] = payload - if namespace_id == "": - self.shell.add_objects_to_ipython_namespace(ns) - return {"payload": payload, "buffers": buffers} + try: + evaluate = options["evaluate"] + if isinstance(evaluate, str): + evaluate = (evaluate,) + namespace_id = options.get("namespace_id", "") + ns = self.get_namespace(namespace_id, buffers=buffers) + for row in evaluate: + name, expression = ("payload", row) if isinstance(row, str) else row + try: + result = eval(expression, ns) # noqa: S307 + except SyntaxError: + exec(expression, ns) # noqa: S102 + result = next(reversed(ns.values())) # Requires: LastUpdatedDict + if not name: + continue + while callable(result) or inspect.isawaitable(result): + if callable(result): + kwgs = {} + for p in inspect.signature(result).parameters: + if p in options: + kwgs[p] = options[p] + elif p in ns: + kwgs[p] = ns[p] + # We use a partial so that we can evaluate with the same namespace. + ns["_partial_call"] = functools.partial(result, **kwgs) + result = eval("_partial_call()", ns) # type: ignore # noqa: S307 + ns.pop("_partial_call") + if inspect.isawaitable(result): + result = await result + if name: + ns[name] = result + buffers = ns.pop("buffers", []) + payload = ns.pop("payload", None) + if payload is not None: + ns["_call_count"] = n = ns.get("_call_count", 0) + 1 + ns[f"payload_{n}"] = payload + if namespace_id == "": + self.shell.add_objects_to_ipython_namespace(ns) + except BaseException as e: + if isinstance(e, NameError): + e.add_note("Tip: Check for missing an imports?") + raise + else: + return {"payload": payload, "buffers": buffers} async def evaluate( self, diff --git a/ipylab/lib.py b/ipylab/lib.py index 8f080594..0f250aa1 100644 --- a/ipylab/lib.py +++ b/ipylab/lib.py @@ -9,6 +9,7 @@ import ipylab from ipylab.common import hookimpl +from ipylab.log import IpylabLogFormatter, IpylabLogHandler if TYPE_CHECKING: from collections.abc import Awaitable @@ -62,3 +63,11 @@ def get_asyncio_event_loop(app: ipylab.App): import asyncio return asyncio.get_running_loop() + + +@hookimpl +def get_logging_handler(app: ipylab.App) -> IpylabLogHandler: + fmt = "%(color)s%(level_symbol)s %(asctime)s.%(msecs)d %(name)s %(owner_rep)s: %(message)s %(reset)s\n" + handler = IpylabLogHandler(app.log_level) + handler.setFormatter(IpylabLogFormatter(fmt=fmt, style="%", datefmt="%H:%M:%S")) + return handler diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py index befc1700..76f503c7 100644 --- a/ipylab/log_viewer.py +++ b/ipylab/log_viewer.py @@ -141,12 +141,12 @@ def _add_record(self, record: logging.LogRecord): async def _notify_exception(self, record: logging.LogRecord): "Create a notification that an error occurred." await self.app.notification.notify( - message=f"Error: {record.msg}", + message=f"vpath:'{self.app.vpath}' Error: {record.msg}", type=ipylab.NotificationType.error, actions=[ ipylab.NotifyAction( label="📄", - caption="Show log viewer.", + caption="Show exception.", callback=lambda: self._show_error(record=record), keep_open=True, ) @@ -167,7 +167,9 @@ def _button_on_click(self, b): @autorun async def _show_error(self, record: logging.LogRecord): - out = SimpleOutput().push(Markdown(f"**{record.levelname.capitalize()}**:\n\n{record.message}")) + out = SimpleOutput().push( + Markdown(f"vpath='{self.app.vpath}': **{record.levelname.capitalize()}**:\n\n{record.message}") + ) try: out.push(record.output) # type: ignore except Exception: From c6f720f78c7c8b443b4dfc3df04421507a11c135 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 9 Apr 2025 16:17:44 +1000 Subject: [PATCH 36/47] Refactor expression evaluation in App class to use compile for better error handling and clarity; update Shell class documentation to specify additional keyword arguments. --- ipylab/jupyterfrontend.py | 10 +++++++--- ipylab/shell.py | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index b5d973ce..ee08c625 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -212,10 +212,13 @@ async def _evaluate(self, options: dict[str, Any], buffers: list): for row in evaluate: name, expression = ("payload", row) if isinstance(row, str) else row try: - result = eval(expression, ns) # noqa: S307 + source = compile(expression, "-- Evaluate --", "eval") except SyntaxError: - exec(expression, ns) # noqa: S102 + source = compile(expression, "-- Expression --", "exec") + exec(source, ns) # noqa: S102 result = next(reversed(ns.values())) # Requires: LastUpdatedDict + else: + result = eval(source, ns) # noqa: S307 if not name: continue while callable(result) or inspect.isawaitable(result): @@ -228,7 +231,8 @@ async def _evaluate(self, options: dict[str, Any], buffers: list): kwgs[p] = ns[p] # We use a partial so that we can evaluate with the same namespace. ns["_partial_call"] = functools.partial(result, **kwgs) - result = eval("_partial_call()", ns) # type: ignore # noqa: S307 + source = compile("_partial_call()", "-- Result call --", "eval") + result = eval(source, ns) # type: ignore # noqa: S307 ns.pop("_partial_call") if inspect.isawaitable(result): result = await result diff --git a/ipylab/shell.py b/ipylab/shell.py index 4def3015..ccfd0991 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -64,6 +64,7 @@ async def add( obj: When `obj` is NOT a Widget it is assumed `obj` should be evaluated in a python kernel. + specify additional keyword arguments directly in **args area: Area The area in the shell where to put obj. activate: bool From a5b771131f64d59a03a08ff51689011460739148 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 9 Apr 2025 18:29:33 +1000 Subject: [PATCH 37/47] Add type annotation for module_version in _frontend.py; remove unused variable _force_update_in_progress in SplitPanel class --- ipylab/_frontend.py | 2 +- ipylab/widgets.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/ipylab/_frontend.py b/ipylab/_frontend.py index c179f1a6..26e7229c 100644 --- a/ipylab/_frontend.py +++ b/ipylab/_frontend.py @@ -12,4 +12,4 @@ path = pathlib.Path(__file__).parent.joinpath("labextension", "package.json") with path.open("rb") as f: data = json.load(f) -module_version = data["version"] +module_version: str = data["version"] diff --git a/ipylab/widgets.py b/ipylab/widgets.py index 0ec45686..4815d4a3 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -76,7 +76,6 @@ class SplitPanel(Panel): layout = InstanceDict(Layout, kw={"width": "100%", "height": "100%", "overflow": "hidden"}).tag( sync=True, **widget_serialization ) - _force_update_in_progress = False # ============== Start temp fix ============= # Below here is added as a temporary fix to address issue https://github.com/jtpio/ipylab/issues/129 From aa0292d67475b556df976750b0a3f72969b94950 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Wed, 9 Apr 2025 20:50:27 +1000 Subject: [PATCH 38/47] Add module_obj_to_import_string function for converting module objects to import strings; enhance expression evaluation in App class to handle import_item expressions; update tests for new functionality. --- ipylab/common.py | 35 ++++++++++++++++++++++++++++++++++- ipylab/jupyterfrontend.py | 17 ++++++++++------- tests/test_common.py | 6 ++++++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/ipylab/common.py b/ipylab/common.py index fed6e08e..d8b67c41 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -3,8 +3,10 @@ from __future__ import annotations +import contextlib import importlib import inspect +import textwrap import typing import weakref from collections import OrderedDict @@ -136,7 +138,9 @@ def pack(obj): if isinstance(obj, Widget): return widget_serialization["to_json"](obj, None) if inspect.isfunction(obj) or inspect.ismodule(obj): - return inspect.getsource(obj) + with contextlib.suppress(BaseException): + return module_obj_to_import_string(obj) + return textwrap.dedent(inspect.getsource(obj)) return obj @@ -160,6 +164,35 @@ def import_item(dottedname: str): return getattr(importlib.import_module(modulename), objname) +def module_obj_to_import_string(obj): + """Convert a module object to an import string compatible with `app.evaluate`. + + Parameters + ---------- + obj : object + The module object to convert. + + Returns + ------- + str + The import string for the module object. + + Raises + ------ + TypeError + If the module object cannot be imported correctly. + """ + dottedname = f"{obj.__module__}.{obj.__qualname__}" + if dottedname.startswith("__main__"): + msg = f"{obj=} is not in a module" + raise TypeError(msg) + item = import_item(dottedname) + if item is not obj: + msg = "Failed to import item correctly" + raise TypeError(msg) + return f"import_item({dottedname=})" + + class Obj(StrEnum): "The objects available to use as 'obj' in the frontend." diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index ee08c625..b669aea4 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -211,14 +211,17 @@ async def _evaluate(self, options: dict[str, Any], buffers: list): ns = self.get_namespace(namespace_id, buffers=buffers) for row in evaluate: name, expression = ("payload", row) if isinstance(row, str) else row - try: - source = compile(expression, "-- Evaluate --", "eval") - except SyntaxError: - source = compile(expression, "-- Expression --", "exec") - exec(source, ns) # noqa: S102 - result = next(reversed(ns.values())) # Requires: LastUpdatedDict + if expression.startswith("import_item(dottedname="): + result = eval(expression, {"import_item": ipylab.common.import_item}) # noqa: S307 else: - result = eval(source, ns) # noqa: S307 + try: + source = compile(expression, "-- Evaluate --", "eval") + except SyntaxError: + source = compile(expression, "-- Expression --", "exec") + exec(source, ns) # noqa: S102 + result = next(reversed(ns.values())) # Requires: LastUpdatedDict + else: + result = eval(source, ns) # noqa: S307 if not name: continue while callable(result) or inspect.isawaitable(result): diff --git a/tests/test_common.py b/tests/test_common.py index 6adef575..0c1b6d6a 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -10,6 +10,7 @@ from typing_extensions import override import ipylab +import ipylab.common from ipylab.common import ( Fixed, FixedCreated, @@ -261,3 +262,8 @@ class TestOwner: owner = TestOwner() with pytest.raises(AttributeError, match="Setting `Fixed` parameter TestOwner.test_instance is forbidden!"): owner.test_instance = CommonTestClass() + + def test_function_to_eval(self): + eval_str = ipylab.common.module_obj_to_import_string(test_last_updated_dict) + obj = eval(eval_str, {"import_item": ipylab.common.import_item}) # noqa: S307 + assert obj is test_last_updated_dict From 1039df4c0f14f3525e99c46ad9a8cdb5a0ef2307 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 10 Apr 2025 10:35:23 +1000 Subject: [PATCH 39/47] ShellConnection activate will now expand the sidebar if the widget is in the sidebar. --- ipylab/common.py | 2 +- ipylab/connection.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/ipylab/common.py b/ipylab/common.py index d8b67c41..14e08e45 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -184,7 +184,7 @@ def module_obj_to_import_string(obj): """ dottedname = f"{obj.__module__}.{obj.__qualname__}" if dottedname.startswith("__main__"): - msg = f"{obj=} is not in a module" + msg = f"{obj=} won't be importable from a new kernel" raise TypeError(msg) item = import_item(dottedname) if item is not obj: diff --git a/ipylab/connection.py b/ipylab/connection.py index b7b3ca2c..48a5e8ad 100644 --- a/ipylab/connection.py +++ b/ipylab/connection.py @@ -10,7 +10,7 @@ from traitlets import Bool, Dict, Instance, Unicode from typing_extensions import override -from ipylab.common import Singular +from ipylab.common import Area, Singular from ipylab.ipylab import Ipylab if TYPE_CHECKING: @@ -152,6 +152,11 @@ def __del__(self): async def activate(self): "Activate the connected widget in the shell." + ids = await self.app.shell.list_widget_ids() + if self.cid in ids[Area.left]: + await self.app.shell.expand_left() + elif self.cid in ids[Area.right]: + await self.app.shell.expand_right() return await self.operation("activate") async def get_session(self) -> dict: From a28da7e4a50f674548280731d6c0bf88cff28765 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 10 Apr 2025 10:42:02 +1000 Subject: [PATCH 40/47] Uses shell connection for activate when adding to the shell instead of built in activate. --- ipylab/shell.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ipylab/shell.py b/ipylab/shell.py index ccfd0991..b3cf3ff7 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -101,7 +101,7 @@ async def add( app = await self.app.ready() vpath = vpath or app.vpath args["options"] = { - "activate": activate, + "activate": False, "mode": InsertMode(mode), "rank": int(rank) if rank else None, "ref": f"{pack(ref)}.id" if isinstance(ref, ShellConnection) else None, @@ -143,6 +143,8 @@ async def add( sc.widget = obj if isinstance(obj, ipylab.Panel): sc.add_to_tuple(obj, "connections") + if activate: + await sc.activate() return sc def add_objects_to_ipython_namespace(self, objects: dict, *, reset=False): From 7b511ecb31bdddee7a57770df8d2797f7c0ec266 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Fri, 11 Apr 2025 11:36:43 +1000 Subject: [PATCH 41/47] Add open_somewhere methods to RankedMenu --- ipylab/menu.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ipylab/menu.py b/ipylab/menu.py index 1f083cfc..8012c880 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -98,9 +98,14 @@ async def _add_item( return mic async def activate(self): + "Open this menu assuming it is in the main menu" await self.app.main_menu.set_property("activeMenu", self, toObject=["value"]) await self.app.main_menu.execute_method("openActiveMenu") + async def open_somewhere(self): + "Open this menu somewhere" + await self.execute_method("open") + class BuiltinMenu(RankedMenu): @override From ac2b872cce7b7cef91726cadd0efff799d043556 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sat, 12 Apr 2025 09:19:31 +1000 Subject: [PATCH 42/47] RankedMenu - add extra close_with_self features for better removal of closed items. --- ipylab/menu.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ipylab/menu.py b/ipylab/menu.py index 8012c880..18e89d1b 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -9,7 +9,7 @@ from traitlets import Container, Instance, Union from typing_extensions import override -from ipylab.commands import APP_COMMANDS_NAME, CommandRegistry +from ipylab.commands import APP_COMMANDS_NAME, CommandConnection, CommandRegistry from ipylab.common import Fixed, Obj, Singular from ipylab.connection import InfoConnection from ipylab.ipylab import Ipylab, IpylabBase, Transform @@ -17,8 +17,6 @@ if TYPE_CHECKING: from typing import Literal - from ipylab.commands import CommandConnection - __all__ = ["MenuItemConnection", "MenuConnection", "MainMenu", "ContextMenu"] @@ -92,6 +90,10 @@ async def _add_item( toObject=to_object, ) self.close_with_self(mic) + if isinstance(command, CommandConnection): + command.close_with_self(mic) + if submenu: + submenu.close_with_self(mic) mic.info = info mic.menu = self mic.add_to_tuple(self, "connections") From e5822001b8152221f16d78d3e2f45172e6d466fb Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sat, 12 Apr 2025 14:56:12 +1000 Subject: [PATCH 43/47] Split out useful functionality from Ipylab into HasApp. --- ipylab/__init__.py | 3 +- ipylab/common.py | 264 ++++++++++++++++++++++++++++++++------------- ipylab/ipylab.py | 173 ++++++----------------------- ipylab/widgets.py | 14 +-- package.json | 2 +- 5 files changed, 225 insertions(+), 231 deletions(-) diff --git a/ipylab/__init__.py b/ipylab/__init__.py index bd9c344f..6de13672 100644 --- a/ipylab/__init__.py +++ b/ipylab/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations -from ipylab import common, menu +from ipylab import common, log, menu from ipylab._frontend import module_version as __version__ from ipylab.code_editor import CodeEditor from ipylab.common import Area, Fixed, InsertMode, Obj, Transform, hookimpl, pack, to_selector @@ -36,6 +36,7 @@ "Ipylab", "App", "Obj", + "log", "menu", "JupyterFrontEnd", "to_selector", diff --git a/ipylab/common.py b/ipylab/common.py index 14e08e45..8a957091 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -3,6 +3,7 @@ from __future__ import annotations +import asyncio import contextlib import importlib import inspect @@ -12,6 +13,7 @@ from collections import OrderedDict from collections.abc import Callable from enum import StrEnum +from types import CoroutineType from typing import ( TYPE_CHECKING, Any, @@ -25,24 +27,27 @@ TypedDict, TypeVar, TypeVarTuple, + Unpack, final, overload, ) +import anyio import pluggy from ipywidgets import Widget, widget_serialization from traitlets import Any as AnyTrait -from traitlets import Bool, HasTraits +from traitlets import Bool, HasTraits, Instance, default, observe from typing_extensions import override import ipylab if TYPE_CHECKING: - from collections.abc import Callable, Hashable + from collections.abc import Awaitable, Callable, Hashable from types import CoroutineType from typing import overload from ipylab.ipylab import Ipylab + from ipylab.log import IpylabLoggerAdapter __all__ = [ "Area", @@ -57,6 +62,7 @@ "Fixed", "FixedCreate", "FixedCreated", + "HasApp", "Singular", ] @@ -373,80 +379,6 @@ def update(self, m, /, **kwargs): # type: ignore self._updating = False -class Singular(HasTraits): - """A base class that ensures only one instance of a class exists for each unique key. - - This class uses a class-level dictionary `_single_instances` to store instances, - keyed by a value obtained from the `get_single_key` method. Subsequent calls to - the constructor with the same key will return the existing instance. If key is - None, a new instance is always created and a reference is not kept to the object. - - Attributes: - _limited_init_complete (bool): A flag to prevent multiple initializations. - _single_instances (dict[Hashable, Self]): A class-level dictionary storing the single instances. - _single_key (AnyTrait): A read-only trait storing the key for the instance. - closed (Bool): A read-only trait indicating whether the instance has been closed. - - Methods: - get_single_key(*args, **kwgs) -> Hashable: - A class method that returns the key used to identify the single instance. - Defaults to returning the class itself. Subclasses should override this - method to provide a key based on the constructor arguments. - - __new__(cls, /, *args, **kwgs) -> Self: - Overrides the default `__new__` method to implement the singleton behavior. - It retrieves the key using `get_single_key`, and either returns an existing - instance from `_single_instances` or creates a new instance and stores it. - - __init__(self, /, *args, **kwgs): - Overrides the default `__init__` method to prevent multiple initializations - of the same instance. It only calls the superclass's `__init__` method once. - - __init_subclass__(cls) -> None: - Overrides the default `__init_subclass__` method to reset the `_single_instances` - dictionary for each subclass. - - close(self): - Removes the instance from the `_single_instances` dictionary and calls the - `close` method of the superclass, if it exists. Sets the `closed` trait to True. - """ - - _limited_init_complete = False - _single_instances: ClassVar[dict[Hashable, Self]] = {} - _single_key = AnyTrait(default_value=None, allow_none=True, read_only=True) - closed = Bool(read_only=True) - - @classmethod - def get_single_key(cls, *args, **kwgs) -> Hashable: # noqa: ARG003 - return cls - - def __new__(cls, /, *args, **kwgs) -> Self: - key = cls.get_single_key(*args, **kwgs) - if key is None or not (inst := cls._single_instances.get(key)): - new = super().__new__ - inst = new(cls) if new is object.__new__ else new(cls, *args, **kwgs) - if key: - cls._single_instances[key] = inst - inst.set_trait("_single_key", key) - return inst - - def __init__(self, /, *args, **kwgs): - if self._limited_init_complete: - return - super().__init__(*args, **kwgs) - self._limited_init_complete = True - - def __init_subclass__(cls) -> None: - cls._single_instances = {} - - def close(self): - if self._single_key is not None: - self._single_instances.pop(self._single_key, None) - if callable(close := getattr(super(), "close", None)): - close() - self.set_trait("closed", True) - - class FixedCreate(Generic[S], TypedDict): "A TypedDict relevant to Fixed" @@ -527,3 +459,183 @@ def __get__(self, obj: Any, objtype=None) -> T: def __set__(self, obj, value): msg = f"Setting `Fixed` parameter {obj.__class__.__name__}.{self.name} is forbidden!" raise AttributeError(msg) + + +class HasApp(HasTraits): + """A mixin class that provides access to the ipylab application. + + It provides methods for: + + - Closing other widgets when the widget is closed. + - Adding the widget to a tuple of widgets owned by another object. + - Starting coroutines in the main event loop. + - Logging exceptions that occur when awaiting an awaitable. + """ + + _tuple_owners: Fixed[Self, set[tuple[HasTraits, str]]] = Fixed(set) + _close_extras: Fixed[Self, weakref.WeakSet[Widget | HasApp]] = Fixed(weakref.WeakSet) + + closed = Bool(read_only=True) + log: Instance[IpylabLoggerAdapter] = Instance("ipylab.log.IpylabLoggerAdapter") + app = Fixed(lambda _: ipylab.App()) + add_traits = None # type: ignore # Don't support the method HasTraits.add_traits as it creates a new type that isn't a subclass of its origin) + + @default("log") + def _default_log(self): + return ipylab.log.IpylabLoggerAdapter(self.__module__, owner=self) + + @observe("closed") + def _observe_closed(self, _): + if self.closed: + self.log.debug("closed") + for item in list(self._close_extras): + item.close() + for obj, name in list(self._tuple_owners): + if val := getattr(obj, name, None): + if (isinstance(obj, HasApp) and obj.closed) or (isinstance(obj, Widget) and not obj.comm): + return + obj.set_trait(name, tuple(v for v in val if v is not self)) + + def _check_closed(self): + if self.closed: + msg = f"This instance is closed {self!r}" + raise RuntimeError(msg) + + def close_with_self(self, obj: Widget | HasApp): + """Register an object to be closed when this object is closed. + + Parameters + ---------- + obj : Widget | HasApp + Object to close. + + Raises + ------ + anyio.ClosedResourceError + If this object is already closed. + """ + if self.closed: + obj.close() + msg = f"{self} is closed" + raise anyio.ClosedResourceError(msg) + self._close_extras.add(obj) + + def add_to_tuple(self, owner: HasTraits, name: str): + """Add self to the tuple of obj and remove self when closed.""" + + items = getattr(owner, name) + if not self.closed and self not in items: + owner.set_trait(name, (*items, self)) + self._tuple_owners.add((owner, name)) + + def close(self): + if close := getattr(super(), "close", None): + close() + self.set_trait("closed", True) + + async def _catch_exceptions(self, aw: Awaitable) -> None: + """Catches exceptions that occur when awaiting an awaitable. + + The exception is logged, but otherwise ignored. + + Args: + aw: The awaitable to await. + """ + try: + await aw + except BaseException as e: + self.log.exception(f"Calling {aw}", obj={"aw": aw}, exc_info=e) # noqa: G004 + if self.app.log_level == ipylab.log.LogLevel.DEBUG: + raise + + def start_coro(self, coro: CoroutineType[None, None, T]) -> None: + """Start a coroutine in the main event loop. + + If the kernel has a `start_soon` method, use it to start the coroutine. + Otherwise, if the application has an asyncio loop, use + `asyncio.run_coroutine_threadsafe` to start the coroutine in the loop. + If neither of these is available, raise a RuntimeError. + + Tip: Use anyio primiatives in the coroutine to ensure it will run in + the chosen backend of the kernel. + + Parameters + ---------- + coro : CoroutineType[None, None, T] + The coroutine to start. + + Raises + ------ + RuntimeError + If there is no running loop to start the task. + """ + + self._check_closed() + self.start_soon(self._catch_exceptions, coro) + + def start_soon(self, func: Callable[[Unpack[PosArgsT]], CoroutineType], *args: Unpack[PosArgsT]): + """Start a function soon in the main event loop. + + If the kernel has a start_soon method, use it. + Otherwise, if the app has an asyncio loop, run the function in that loop. + Otherwise, raise a RuntimeError. + + This is a simple wrapper to ensure the function is called in the main + event loop. No error reporting is done. + + Consider using start_coro which performs additional checks and automatically + logs exceptions. + """ + try: + start_soon = self.app.kernel.start_soon # type: ignore + except AttributeError: + if loop := self.app.asyncio_loop: + coro = func(*args) + asyncio.run_coroutine_threadsafe(coro, loop) + else: + msg = f"We don't have a running loop to run {func}" + raise RuntimeError(msg) from None + else: + start_soon(func, *args) + + +class Singular(HasApp): + """A base class that ensures only one instance of a class exists for each unique key. + + This class uses a class-level dictionary `_single_instances` to store instances, + keyed by a value obtained from the `get_single_key` method. Subsequent calls to + the constructor with the same key will return the existing instance. If key is + None, a new instance is always created and a reference is not kept to the object. + """ + + _limited_init_complete = False + _single_instances: ClassVar[dict[Hashable, Self]] = {} + _single_key = AnyTrait(default_value=None, allow_none=True, read_only=True) + + @classmethod + def get_single_key(cls, *args, **kwgs) -> Hashable: # noqa: ARG003 + return cls + + def __new__(cls, /, *args, **kwgs) -> Self: + key = cls.get_single_key(*args, **kwgs) + if key is None or not (inst := cls._single_instances.get(key)): + new = super().__new__ + inst = new(cls) if new is object.__new__ else new(cls, *args, **kwgs) + if key: + cls._single_instances[key] = inst + inst.set_trait("_single_key", key) + return inst + + def __init__(self, /, *args, **kwgs): + if self._limited_init_complete: + return + super().__init__(*args, **kwgs) + self._limited_init_complete = True + + def __init_subclass__(cls) -> None: + cls._single_instances = {} + + def close(self): + if self._single_key is not None: + self._single_instances.pop(self._single_key, None) + super().close() diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 133b3d4c..7d5b0a4e 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -4,45 +4,27 @@ from __future__ import annotations import asyncio -import contextlib import inspect import json import uuid -import weakref from typing import TYPE_CHECKING, Any, cast -import anyio import traitlets from anyio import Event, create_memory_object_stream from ipywidgets import Widget, register -from traitlets import ( - Bool, - Container, - Dict, - HasTraits, - Instance, - List, - Set, - TraitError, - TraitType, - Unicode, - default, - observe, -) - -import ipylab +from traitlets import Bool, Container, Dict, Instance, List, TraitType, Unicode, observe + import ipylab._frontend as _fe -from ipylab.common import Fixed, IpylabKwgs, Obj, PosArgsT, T, Transform, TransformType, autorun, pack -from ipylab.log import IpylabLoggerAdapter, LogLevel +from ipylab.common import HasApp, IpylabKwgs, Obj, Transform, TransformType, autorun, pack if TYPE_CHECKING: - from collections.abc import Awaitable, Callable + from collections.abc import Callable from types import CoroutineType from typing import Self, Unpack from anyio.streams.memory import MemoryObjectSendStream -__all__ = ["Ipylab", "WidgetBase"] +__all__ = ["Ipylab", "IpylabBase", "WidgetBase"] class IpylabBase(TraitType[tuple[str, str], None]): @@ -60,18 +42,37 @@ class IpylabFrontendError(IOError): class WidgetBase(Widget): + """Base class for ipylab widgets. + + Inherits from HasApp and Widget. + + Attributes: + _model_name (Unicode): The name of the model. Must be overloaded. + _model_module (Unicode): The module name of the model. + _model_module_version (Unicode): The module version of the model. + _view_module (Unicode): The module name of the view. + _view_module_version (Unicode): The module version of the view. + _comm (Comm): The comm object. + + """ + _model_name = None # Ensure this gets overloaded _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) _view_module_version = Unicode(_fe.module_version, read_only=True).tag(sync=True) _comm = None - add_traits = None # type: ignore # Don't support the method HasTraits.add_traits as it creates a new type that isn't a subclass of its origin) + + @observe("comm") + def _observe_comm(self, _: dict): + if not self.comm: + self.close() @register -class Ipylab(WidgetBase): - """A base class for creating ipylab widgets. +class Ipylab(HasApp, WidgetBase): + """A base class for creating ipylab widgets that inherit from an IpylabModel + in the frontend. Ipylab widgets are Jupyter widgets that are designed to interact with the JupyterLab application. They provide a way to extend the functionality @@ -87,21 +88,12 @@ class Ipylab(WidgetBase): _comm = None _ipylab_init_complete = False _pending_operations: Dict[str, MemoryObjectSendStream] = Dict() - _has_attrs_mappings: Container[set[tuple[HasTraits, str]]] = Set() - _close_extras: Fixed[Self, weakref.WeakSet[Widget]] = Fixed(weakref.WeakSet) - - log = Instance(IpylabLoggerAdapter, read_only=True) - app = Fixed(lambda _: ipylab.App()) @property def repr_info(self) -> dict[str, Any] | str: "Extra info to provide for __repr__." return {} - @default("log") - def _default_log(self): - return IpylabLoggerAdapter(self.__module__, owner=self) - def __init__(self, **kwgs): if self._ipylab_init_complete: return @@ -129,11 +121,9 @@ def __repr__(self): return f"< {status}: {self.__class__.__name__}({info}) >" return f"{status}{self.__class__.__name__}({info})" - @observe("comm", "_ready") - def _observe_comm(self, change: dict): - if not self.comm: - self.close() - if change["name"] == "_ready" and self._ready: + @observe("_ready") + def _observe_ready(self, _: dict): + if self._ready: self._ready_event.set() self._ready_event = Event() for cb in self._on_ready_callbacks: @@ -143,37 +133,8 @@ def close(self): if self.comm: self._ipylab_send({"close": True}) super().close() - for item in list(self._close_extras): - item.close() - for obj, name in list(self._has_attrs_mappings): - if val := getattr(obj, name, None): - if val is self: - with contextlib.suppress(TraitError): - obj.set_trait(name, None) - elif isinstance(val, tuple): - obj.set_trait(name, tuple(v for v in val if v.comm)) self._on_ready_callbacks.clear() - def _check_closed(self): - if not self._repr_mimebundle_: - msg = f"This widget is closed {self!r}" - raise RuntimeError(msg) - - async def catch_exceptions(self, aw: Awaitable) -> None: - """Catches exceptions that occur when awaiting an awaitable. - - The exception is logged, but otherwise ignored. - - Args: - aw: The awaitable to await. - """ - try: - await aw - except BaseException as e: - self.log.exception(f"Calling {aw}", obj={"aw": aw}, exc_info=e) # noqa: G004 - if self.app.log_level == LogLevel.DEBUG: - raise - def _on_custom_msg(self, _, msg: dict, buffers: list): content = msg.get("ipylab") if not content: @@ -276,30 +237,6 @@ def on_ready(self, callback: Callable[[Self], None | CoroutineType], remove=Fals elif callback in self._on_ready_callbacks: self._on_ready_callbacks.remove(callback) - def add_to_tuple(self, owner: HasTraits, name: str): - """Add self to the tuple of obj.""" - - items = getattr(owner, name) - if self.comm and self not in items: - owner.set_trait(name, (*items, self)) - # see: _observe_comm for removal - self._has_attrs_mappings.add((owner, name)) - - def add_as_trait(self, obj: HasTraits, name: str): - "Add self as a trait to obj." - self._check_closed() - obj.set_trait(name, self) - # see: _observe_comm for removal - self._has_attrs_mappings.add((obj, name)) - - def close_with_self(self, obj: Widget): - "Close the widget when self closes. If self is already closed, object will be closed immediately." - if not self.comm: - obj.close() - msg = f"{self} is closed" - raise anyio.ClosedResourceError(msg) - self._close_extras.add(obj) - def _ipylab_send(self, content, buffers: list | None = None): try: self.send({"ipylab": json.dumps(content, default=pack)}, buffers) @@ -307,56 +244,6 @@ def _ipylab_send(self, content, buffers: list | None = None): self.log.exception("Send error", obj=content, exc_info=e) raise - def start_coro(self, coro: CoroutineType[None, None, T]) -> None: - """Start a coroutine in the main event loop. - - If the kernel has a `start_soon` method, use it to start the coroutine. - Otherwise, if the application has an asyncio loop, use - `asyncio.run_coroutine_threadsafe` to start the coroutine in the loop. - If neither of these is available, raise a RuntimeError. - - Tip: Use anyio primiatives in the coroutine to ensure it will run in - the chosen backend of the kernel. - - Parameters - ---------- - coro : CoroutineType[None, None, T] - The coroutine to start. - - Raises - ------ - RuntimeError - If there is no running loop to start the task. - """ - - self._check_closed() - self.start_soon(self.catch_exceptions, coro) - - def start_soon(self, func: Callable[[Unpack[PosArgsT]], CoroutineType], *args: Unpack[PosArgsT]): - """Start a function soon in the main event loop. - - If the kernel has a start_soon method, use it. - Otherwise, if the app has an asyncio loop, run the function in that loop. - Otherwise, raise a RuntimeError. - - This is a simple wrapper to ensure the function is called in the main - event loop. No error reporting is done. - - Consider using start_coro which performs additional checks and automatically - logs exceptions. - """ - try: - start_soon = self.comm.kernel.start_soon # type: ignore - except AttributeError: - if loop := self.app.asyncio_loop: - coro = func(*args) - asyncio.run_coroutine_threadsafe(coro, loop) - else: - msg = f"We don't have a running loop to run {func}" - raise RuntimeError(msg) from None - else: - start_soon(func, *args) - async def operation( self, operation: str, diff --git a/ipylab/widgets.py b/ipylab/widgets.py index 4815d4a3..d13648bf 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -10,10 +10,9 @@ from ipywidgets.widgets.trait_types import InstanceDict from traitlets import Container, Dict, Instance, Tuple, Unicode, observe -import ipylab import ipylab._frontend as _fe -from ipylab.common import Area, Fixed, InsertMode, autorun -from ipylab.connection import ShellConnection +from ipylab.common import Area, HasApp, InsertMode, autorun +from ipylab.connection import Connection, ShellConnection from ipylab.ipylab import WidgetBase @@ -50,17 +49,12 @@ class Title(WidgetBase): @register -class Panel(Box): +class Panel(HasApp, WidgetBase, Box): _model_name = Unicode("PanelModel").tag(sync=True) _view_name = Unicode("PanelView").tag(sync=True) - _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) - _view_module_version = Unicode(_fe.module_version, read_only=True).tag(sync=True) title: Instance[Title] = InstanceDict(Title, ()).tag(sync=True, **widget_serialization) - app = Fixed(lambda _: ipylab.App()) - connections: Container[tuple[ShellConnection, ...]] = TypedTuple(trait=Instance(ShellConnection)) + connections: Container[tuple[Connection, ...]] = TypedTuple(trait=Instance(Connection)) add_to_shell_defaults: ClassVar = AddToShellType(mode=InsertMode.tab_after) async def add_to_shell(self, **kwgs: Unpack[AddToShellType]) -> ShellConnection: diff --git a/package.json b/package.json index 27e5a86f..1a68d6ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ipylab", - "version": "2.0.0-b3", + "version": "2.0.0-b4", "description": "Control JupyterLab from Python notebooks", "keywords": [ "jupyter", From 380a27ebbacbd5e6432fc678b0e81e3a101eb4b1 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sat, 12 Apr 2025 18:05:52 +1000 Subject: [PATCH 44/47] Shell.add will now close a launcher if it is the active widget when adding to the main area. --- ipylab/ipylab.py | 2 +- ipylab/shell.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 7d5b0a4e..25222dac 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -336,7 +336,7 @@ async def get_property(self, subpath: str, *, obj=Obj.base, null_if_missing=Fals Any The value of the property. """ - return self._obj_operation(obj, subpath, "getProperty", {"null_if_missing": null_if_missing}, kwargs) + return await self._obj_operation(obj, subpath, "getProperty", {"null_if_missing": null_if_missing}, kwargs) async def set_property(self, subpath: str, value, *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]) -> None: """Set a property of an object in the frontend. diff --git a/ipylab/shell.py b/ipylab/shell.py index b3cf3ff7..b4eff22e 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -101,7 +101,7 @@ async def add( app = await self.app.ready() vpath = vpath or app.vpath args["options"] = { - "activate": False, + "activate": activate, "mode": InsertMode(mode), "rank": int(rank) if rank else None, "ref": f"{pack(ref)}.id" if isinstance(ref, ShellConnection) else None, @@ -134,7 +134,11 @@ async def add( val = await val vpath = val args["vpath"] = vpath - + sc_current = None + if activate and area == Area.main: + current_widget_id: str | None = await self.get_property("currentWidget.id") + if current_widget_id and current_widget_id.startswith("launcher"): + sc_current = await self.connect_to_widget(current_widget_id) sc: ShellConnection = await self.operation("addToShell", {"args": args}, transform=Transform.connection) sc.add_to_tuple(self, "connections") if vpath != app.vpath: @@ -143,6 +147,8 @@ async def add( sc.widget = obj if isinstance(obj, ipylab.Panel): sc.add_to_tuple(obj, "connections") + if sc_current: + sc_current.close() if activate: await sc.activate() return sc From ebd7547edb44bd28d0422cc128b35b72eaa5cf21 Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Thu, 17 Apr 2025 12:02:09 +1000 Subject: [PATCH 45/47] Add singular attribute for Singular to enable tracking of the instances in a subclass. --- ipylab/common.py | 49 ++++++++++++++++++++++++++++++-------------- tests/test_common.py | 7 +++++-- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/ipylab/common.py b/ipylab/common.py index 8a957091..7ddd1412 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -34,9 +34,10 @@ import anyio import pluggy -from ipywidgets import Widget, widget_serialization +import traitlets +from ipywidgets import TypedTuple, Widget, widget_serialization from traitlets import Any as AnyTrait -from traitlets import Bool, HasTraits, Instance, default, observe +from traitlets import Bool, Container, HasTraits, Instance, default, observe from typing_extensions import override import ipylab @@ -485,7 +486,7 @@ def _default_log(self): return ipylab.log.IpylabLoggerAdapter(self.__module__, owner=self) @observe("closed") - def _observe_closed(self, _): + def _hasapp_observe_closed(self, _): if self.closed: self.log.debug("closed") for item in list(self._close_extras): @@ -599,18 +600,32 @@ def start_soon(self, func: Callable[[Unpack[PosArgsT]], CoroutineType], *args: U start_soon(func, *args) -class Singular(HasApp): - """A base class that ensures only one instance of a class exists for each unique key. +class _SingularInstances(HasTraits, Generic[T]): + instances: Container[tuple[T, ...]] = TypedTuple(trait=traitlets.Any(), read_only=True) + + +class Singular(HasTraits): + """A base class that ensures only one instance of a class exists for each unique + key (except for None). This class uses a class-level dictionary `_single_instances` to store instances, keyed by a value obtained from the `get_single_key` method. Subsequent calls to the constructor with the same key will return the existing instance. If key is None, a new instance is always created and a reference is not kept to the object. + + The class attribute `singular` maintains a tuple of the instances on a per-subclass basis + (only instances with a `single_key` that is not None are included). """ - _limited_init_complete = False + singular_init_started = traitlets.Bool(read_only=True) _single_instances: ClassVar[dict[Hashable, Self]] = {} - _single_key = AnyTrait(default_value=None, allow_none=True, read_only=True) + single_key = AnyTrait(default_value=None, allow_none=True, read_only=True) + closed = Bool(read_only=True) + singular: ClassVar[_SingularInstances[Self]] + + def __init_subclass__(cls) -> None: + cls._single_instances = {} + cls.singular = _SingularInstances() @classmethod def get_single_key(cls, *args, **kwgs) -> Hashable: # noqa: ARG003 @@ -623,19 +638,23 @@ def __new__(cls, /, *args, **kwgs) -> Self: inst = new(cls) if new is object.__new__ else new(cls, *args, **kwgs) if key: cls._single_instances[key] = inst - inst.set_trait("_single_key", key) + inst.set_trait("single_key", key) return inst def __init__(self, /, *args, **kwgs): - if self._limited_init_complete: + if self.singular_init_started: return + self.set_trait("singular_init_started", True) super().__init__(*args, **kwgs) - self._limited_init_complete = True + self.singular.set_trait("instances", tuple(self._single_instances.values())) - def __init_subclass__(cls) -> None: - cls._single_instances = {} + @observe("closed") + def _singular_observe_closed(self, _): + if self.closed and self.single_key is not None: + self._single_instances.pop(self.single_key, None) + self.singular.set_trait("instances", tuple(self._single_instances.values())) def close(self): - if self._single_key is not None: - self._single_instances.pop(self._single_key, None) - super().close() + if close := getattr(super(), "close", None): + close() + self.set_trait("closed", True) diff --git a/tests/test_common.py b/tests/test_common.py index 0c1b6d6a..d0d97ad6 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -174,8 +174,11 @@ async def test_transform_payload_no_transform(self, mock_connection): class TestLimited: async def test_limited_new_single(self): - obj1 = Singular() - obj2 = Singular() + class MySingular(Singular): + pass + + obj1 = MySingular() + obj2 = MySingular() assert obj1 is obj2 obj1.close() assert obj1 not in obj1._single_instances From 1c7206195a9c0f4586e808650b4da6c62684b43f Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Fri, 18 Apr 2025 15:32:04 +1000 Subject: [PATCH 46/47] Enhance pack function to handle class objects and improve error messaging for unsupported types --- ipylab/common.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ipylab/common.py b/ipylab/common.py index 7ddd1412..785e08e0 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -144,11 +144,12 @@ def pack(obj): if isinstance(obj, Widget): return widget_serialization["to_json"](obj, None) - if inspect.isfunction(obj) or inspect.ismodule(obj): + if inspect.isfunction(obj) or inspect.ismodule(obj) or inspect.isclass(obj): with contextlib.suppress(BaseException): return module_obj_to_import_string(obj) return textwrap.dedent(inspect.getsource(obj)) - return obj + msg = f"Unable pack this type of object {type(obj)}: {obj!r}" + raise TypeError(msg) def to_selector(*args, prefix="ipylab"): From 2ce11823ebfacdb0fc137dff461a0e260bd448fe Mon Sep 17 00:00:00 2001 From: Alan Fleming Date: Sat, 19 Apr 2025 08:27:51 +1000 Subject: [PATCH 47/47] Rename `_single_instances` to `_singular_instances` in Singular class and update related references --- ipylab/common.py | 18 +++++++++--------- ipylab/connection.py | 4 ++-- tests/test_common.py | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ipylab/common.py b/ipylab/common.py index 785e08e0..89827a37 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -609,8 +609,8 @@ class Singular(HasTraits): """A base class that ensures only one instance of a class exists for each unique key (except for None). - This class uses a class-level dictionary `_single_instances` to store instances, - keyed by a value obtained from the `get_single_key` method. Subsequent calls to + This class uses a class-level dictionary `_singular_instances` to store instances, + keyed by a value obtained from the `get_single_key` classmethod. Subsequent calls to the constructor with the same key will return the existing instance. If key is None, a new instance is always created and a reference is not kept to the object. @@ -619,13 +619,13 @@ class Singular(HasTraits): """ singular_init_started = traitlets.Bool(read_only=True) - _single_instances: ClassVar[dict[Hashable, Self]] = {} + _singular_instances: ClassVar[dict[Hashable, Self]] = {} single_key = AnyTrait(default_value=None, allow_none=True, read_only=True) closed = Bool(read_only=True) singular: ClassVar[_SingularInstances[Self]] def __init_subclass__(cls) -> None: - cls._single_instances = {} + cls._singular_instances = {} cls.singular = _SingularInstances() @classmethod @@ -634,11 +634,11 @@ def get_single_key(cls, *args, **kwgs) -> Hashable: # noqa: ARG003 def __new__(cls, /, *args, **kwgs) -> Self: key = cls.get_single_key(*args, **kwgs) - if key is None or not (inst := cls._single_instances.get(key)): + if key is None or not (inst := cls._singular_instances.get(key)): new = super().__new__ inst = new(cls) if new is object.__new__ else new(cls, *args, **kwgs) if key: - cls._single_instances[key] = inst + cls._singular_instances[key] = inst inst.set_trait("single_key", key) return inst @@ -647,13 +647,13 @@ def __init__(self, /, *args, **kwgs): return self.set_trait("singular_init_started", True) super().__init__(*args, **kwgs) - self.singular.set_trait("instances", tuple(self._single_instances.values())) + self.singular.set_trait("instances", tuple(self._singular_instances.values())) @observe("closed") def _singular_observe_closed(self, _): if self.closed and self.single_key is not None: - self._single_instances.pop(self.single_key, None) - self.singular.set_trait("instances", tuple(self._single_instances.values())) + self._singular_instances.pop(self.single_key, None) + self.singular.set_trait("instances", tuple(self._singular_instances.values())) def close(self): if close := getattr(super(), "close", None): diff --git a/ipylab/connection.py b/ipylab/connection.py index 48a5e8ad..c90a4175 100644 --- a/ipylab/connection.py +++ b/ipylab/connection.py @@ -60,11 +60,11 @@ def get_single_key(cls, cid: str, **kwgs) -> Hashable: @classmethod def exists(cls, cid: str) -> bool: - return cid in cls._single_instances + return cid in cls._singular_instances @classmethod def close_if_exists(cls, cid: str): - if inst := cls._single_instances.pop(cid, None): + if inst := cls._singular_instances.pop(cid, None): inst.close() def __init_subclass__(cls, **kwargs) -> None: diff --git a/tests/test_common.py b/tests/test_common.py index d0d97ad6..5ee638dc 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -181,7 +181,7 @@ class MySingular(Singular): obj2 = MySingular() assert obj1 is obj2 obj1.close() - assert obj1 not in obj1._single_instances + assert obj1 not in obj1._singular_instances assert obj1.closed async def test_limited_newget_single_keyed(self): @@ -204,12 +204,12 @@ def get_single_key(cls, key: str, **kwgs): obj5 = KeyedSingle(None) obj6 = KeyedSingle(None) - assert obj1 in KeyedSingle._single_instances.values() + assert obj1 in KeyedSingle._singular_instances.values() assert obj1 is obj2 assert obj1 is not obj3 assert obj4 is obj3 assert obj5 is not obj6 - assert obj5 not in KeyedSingle._single_instances.values() + assert obj5 not in KeyedSingle._singular_instances.values() class TestFixed: