diff --git a/examples/menu.ipynb b/examples/menu.ipynb index 346813b..d7c5d79 100644 --- a/examples/menu.ipynb +++ b/examples/menu.ipynb @@ -110,6 +110,33 @@ "menu.close()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Built in menus\n", + "\n", + "The built in menus are accessible under `app.main_menu` and can be manipulated in the same way." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "app.main_menu.file_menu.add_item(command=\"logconsole:open\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "t = app.main_menu.file_menu.list_properties(\"commands\")" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/ipylab/commands.py b/ipylab/commands.py index ed9a0b1..c20b252 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -8,13 +8,13 @@ from typing import TYPE_CHECKING, Any, ClassVar, NotRequired, TypedDict, Unpack from ipywidgets import TypedTuple -from traitlets import Bool, Container, Dict, Instance, Tuple, Unicode from traitlets import Callable as CallableTrait +from traitlets import Container, Dict, Instance, Tuple, Unicode import ipylab from ipylab._compat.typing import override from ipylab.common import IpylabKwgs, Obj, TaskHooks, TransformType, pack -from ipylab.connection import Connection +from ipylab.connection import InfoConnection from ipylab.ipylab import Ipylab, IpylabBase, Transform, register from ipylab.widgets import Icon @@ -44,12 +44,9 @@ class CommandOptions(TypedDict): usage: NotRequired[str] -class CommandConnection(Connection): +class CommandConnection(InfoConnection): """An Ipylab command registered in a command registry.""" - auto_dispose = Bool(True).tag(sync=True) - - info = Dict() args = Dict() python_command = CallableTrait(allow_none=False) namespace_name = Unicode("") @@ -110,11 +107,9 @@ def execute(self, args: dict | None = None, **kwgs: Unpack[IpylabKwgs]): return self.commands.execute(self, args, **kwgs) -class CommandPalletItemConnection(Connection): +class CommandPalletItemConnection(InfoConnection): """An Ipylab command palette item.""" - auto_dispose = Bool(True).tag(sync=True) - info = Dict() command = Instance(CommandConnection, ()) @override @@ -167,7 +162,7 @@ def remove(self, command: CommandConnection, category: str): class CommandRegistry(Ipylab): _model_name = Unicode("CommandRegistryModel").tag(sync=True) ipylab_base = IpylabBase(Obj.IpylabModel, "").tag(sync=True) - name = Unicode(APP_COMMANDS_NAME, read_only=True, help="Use the default registry").tag(sync=True) + name = Unicode(APP_COMMANDS_NAME, read_only=True).tag(sync=True) all_commands = Tuple(read_only=True).tag(sync=True) connections: Container[tuple[CommandConnection, ...]] = TypedTuple(trait=Instance(CommandConnection)) @@ -202,7 +197,7 @@ 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 = Connection.get_existing_connection(payload["id"], quiet=True) + conn = InfoConnection.get_existing_connection(payload["id"], quiet=True) if not isinstance(conn, CommandConnection): msg = f'Invalid command "{payload["id"]} {conn=}"' raise TypeError(msg) @@ -273,7 +268,7 @@ def add_command( def remove_command(self, command: str | CommandConnection): cid = command.cid if isinstance(command, CommandConnection) else CommandConnection.to_cid(self.name, command) - if conn := Connection.get_existing_connection(cid, quiet=True): + if conn := InfoConnection.get_existing_connection(cid, quiet=True): conn.close() return cid diff --git a/ipylab/connection.py b/ipylab/connection.py index e8d1302..c256a4a 100644 --- a/ipylab/connection.py +++ b/ipylab/connection.py @@ -53,7 +53,6 @@ class Connection(Ipylab): ipylab_base = None auto_dispose = Bool(False, read_only=True, help="Dispose of the object in frontend when closed.").tag(sync=True) - info: Dict | None = None def __init_subclass__(cls, **kwargs) -> None: cls.prefix = f"{cls._PREFIX}{cls.__name__}{cls._SEP}" @@ -76,8 +75,6 @@ def __str__(self): @property @override def repr_info(self): - if self.info: - return {"cid": self.cid, "info": self.info} return {"cid": self.cid} @classmethod @@ -143,13 +140,25 @@ def get_existing_connection(cls, cid: str, *, quiet=False): Connection._CLASS_DEFINITIONS[Connection.prefix.strip(Connection._SEP)] = Connection # noqa: SLF001 +class InfoConnection(Connection): + "A connection with info and auto_dispose enabled" + + info = Dict(help="info about the item") + auto_dispose = Bool(True).tag(sync=True) + + @property + @override + def repr_info(self): + return {"cid": self.cid, "info": self.info} + + class ShellConnection(Connection): "Provides a connection to a widget loaded in the shell" _model_name = Unicode("ShellConnectionModel").tag(sync=True) + auto_dispose = Bool(True).tag(sync=True) widget = Instance(Widget, allow_none=True, default_value=None, help="The widget that has the view") - auto_dispose = Bool(True).tag(sync=True) def __del__(self): """Object disposal""" diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 5f34c30..e77a622 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -9,7 +9,7 @@ import traceback import uuid import weakref -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Generic, TypeVar from ipywidgets import Widget, register from traitlets import Bool, Container, Dict, HasTraits, Instance, Set, TraitError, TraitType, Unicode, default, observe @@ -25,7 +25,7 @@ from typing import ClassVar, Self, Unpack -__all__ = ["Ipylab", "WidgetBase"] +__all__ = ["Ipylab", "WidgetBase", "Readonly"] T = TypeVar("T") L = TypeVar("L", bound="Ipylab") @@ -41,6 +41,22 @@ def __init__(self, base: Obj, subpath: str): super().__init__((base, subpath)) +class Readonly(Generic[T]): + __slots__ = ["_instances", "_klass", "_kwgs"] + + def __init__(self, klass: type[T], **kwgs): + self._klass = klass + self._kwgs = kwgs + self._instances = weakref.WeakKeyDictionary() + + def __get__(self, obj, objtype=None) -> T: + if obj is None: + return self # type: ignore + if obj not in self._instances: + self._instances[obj] = self._klass(**self._kwgs) + return self._instances[obj] + + class Response(asyncio.Event): def set(self, payload, error: Exception | None = None) -> None: if getattr(self, "_value", False): @@ -82,18 +98,19 @@ class Ipylab(WidgetBase): _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[set[Callable]] = Set() _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 = Instance(asyncio.Event, ()) + _ready_event = Readonly(asyncio.Event) _comm = None _pending_operations: Dict[str, Response] = Dict() _tasks: Container[set[asyncio.Task]] = Set() _has_attrs_mappings: Container[set[tuple[HasTraits, str]]] = Set() - close_extras: Container[weakref.WeakSet[Widget]] = Instance(weakref.WeakSet, (), help="extra items to close") # type: ignore + close_extras: Readonly[weakref.WeakSet[Widget]] = Readonly(weakref.WeakSet) log = Instance(logging.Logger) @classmethod @@ -158,9 +175,9 @@ def _observe_comm(self, change: dict): for task in self._tasks: task.cancel() self._tasks.clear() - for item in self.close_extras: + for item in list(self.close_extras): item.close() - for obj, name in self._has_attrs_mappings: + for obj, name in list(self._has_attrs_mappings): if val := getattr(obj, name, None): if val is self: with contextlib.suppress(TraitError): @@ -276,9 +293,9 @@ def close(self): super().close() async def ready(self): - if not ipylab.app._ready: # noqa: SLF001 + if not ipylab.app._ready_event._value: # type: ignore # noqa: SLF001 await ipylab.app.ready() - if not self._ready: + if not self._ready_event._value: # type: ignore # noqa: SLF001 await self._ready_event.wait() def on_ready(self, callback, remove=False): # noqa: FBT002 diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 4630d25..69fa544 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -21,7 +21,7 @@ from ipylab.commands import CommandPalette, CommandRegistry from ipylab.common import InsertMode, IpylabKwgs, Obj, pack from ipylab.dialog import Dialog -from ipylab.ipylab import IpylabBase +from ipylab.ipylab import IpylabBase, Readonly from ipylab.launcher import Launcher from ipylab.log import IpylabLogHandler, LogLevel from ipylab.menu import ContextMenu, MainMenu @@ -60,15 +60,16 @@ class App(Ipylab): vpath = Unicode(read_only=True).tag(sync=True) per_kernel_widget_manager_detected = Bool(read_only=True).tag(sync=True) - shell = Instance(Shell, (), read_only=True) - dialog = Instance(Dialog, (), read_only=True) - commands = Instance(CommandRegistry, (), read_only=True) - command_pallet = Instance(CommandPalette, (), read_only=True) - launcher = Instance(Launcher, (), read_only=True) - session_manager = Instance(SessionManager, (), read_only=True) - main_menu = Instance(MainMenu, (), read_only=True) - context_menu = Instance(ContextMenu, (), read_only=True) - notification = Instance(NotificationManager, (), read_only=True) + shell = Readonly(Shell) + dialog = Readonly(Dialog) + commands = Readonly(CommandRegistry) + command_pallet = Readonly(CommandPalette) + launcher = Readonly(Launcher) + session_manager = Readonly(SessionManager) + main_menu = Readonly(MainMenu) + context_menu = Readonly(ContextMenu) + notification = Readonly(NotificationManager) + console = Instance(ShellConnection, allow_none=True, read_only=True) logging_handler = Instance(logging.Handler, allow_none=True, read_only=True) @@ -114,7 +115,7 @@ def repr_info(self): @override async def ready(self): - if not self._ready: + if not self._ready_event._value: # type: ignore # noqa: SLF001 await self._ready_event.wait() @override diff --git a/ipylab/launcher.py b/ipylab/launcher.py index c9e2f07..7bade81 100644 --- a/ipylab/launcher.py +++ b/ipylab/launcher.py @@ -10,7 +10,7 @@ from ipylab.commands import CommandConnection, CommandPalletItemConnection, CommandRegistry from ipylab.common import Obj, TaskHooks -from ipylab.connection import Connection +from ipylab.connection import InfoConnection from ipylab.ipylab import Ipylab, IpylabBase, Transform if TYPE_CHECKING: @@ -50,6 +50,6 @@ def add(self, cmd: CommandConnection, category: str, *, rank=None, **args) -> Ta def remove(self, command: CommandConnection, category: str): cid = LauncherConnection.to_cid(command, category) - if conn := Connection.get_existing_connection(cid, quiet=True): + if conn := InfoConnection.get_existing_connection(cid, quiet=True): conn.close() return cid diff --git a/ipylab/lib.py b/ipylab/lib.py index 8b14b02..eee4094 100644 --- a/ipylab/lib.py +++ b/ipylab/lib.py @@ -40,8 +40,8 @@ def on_error(obj: Ipylab, source: ErrorSource, error: Exception): if not task.done(): return task.result().close() - a = NotifyAction(label="📝", caption="Toggle log console", callback=obj.app.toggle_log_console, keep_open=True) - objects["error_task"] = obj.app.notification.notify(msg, type=ipylab.NotificationType.error, actions=[a]) + a = NotifyAction(label="📝", caption="Toggle log console", callback=ipylab.app.toggle_log_console, keep_open=True) + objects["error_task"] = ipylab.app.notification.notify(msg, type=ipylab.NotificationType.error, actions=[a]) @hookimpl diff --git a/ipylab/menu.py b/ipylab/menu.py index 6ee464e..69443fc 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -6,14 +6,14 @@ from typing import TYPE_CHECKING from ipywidgets import TypedTuple -from traitlets import Bool, Container, Dict, Instance, Union +from traitlets import Container, Instance, Union import ipylab from ipylab._compat.typing import override from ipylab.commands import APP_COMMANDS_NAME, CommandConnection, CommandRegistry from ipylab.common import Obj, pack -from ipylab.connection import Connection -from ipylab.ipylab import Ipylab, IpylabBase, Transform +from ipylab.connection import InfoConnection +from ipylab.ipylab import Ipylab, IpylabBase, Readonly, Transform if TYPE_CHECKING: from asyncio import Task @@ -25,11 +25,9 @@ __all__ = ["MenuItemConnection", "MenuConnection", "MainMenu", "ContextMenu"] -class MenuItemConnection(Connection): +class MenuItemConnection(InfoConnection): """A connection to an ipylab menu item.""" - auto_dispose = Bool(True).tag(sync=True) - info = Dict() menu: Instance[RankedMenu] = Instance("ipylab.menu.RankedMenu") @@ -96,11 +94,9 @@ async def activate(): return self.to_task(activate()) -class MenuConnection(RankedMenu, Connection): +class MenuConnection(RankedMenu, InfoConnection): """A connection to a custom menu""" - auto_dispose = Bool(True).tag(sync=True) - info = Dict() commands = Instance(CommandRegistry) @@ -132,7 +128,7 @@ def create_menu(self, label: str, rank: int = 500) -> Task[MenuConnection]: "add_to_tuple_fwd": [(self, "connections")], "close_with_fwd": [self], } - return ipylab.app.execute_method( + return self.execute_method( "generateMenu", f"{pack(self.commands)}.base", options, @@ -154,13 +150,13 @@ class MainMenu(Menu): ipylab_base = IpylabBase(Obj.IpylabModel, "mainMenu").tag(sync=True) - edit_menu = Instance(RankedMenu, kw={"ipylab_base": (Obj.IpylabModel, "menu.editMenu")}) - file_menu = Instance(RankedMenu, kw={"basename": (Obj.IpylabModel, "menu.fileMenu")}) - kernel_menu = Instance(RankedMenu, kw={"basename": (Obj.IpylabModel, "menu.kernelMenu")}) - run_menu = Instance(RankedMenu, kw={"basename": (Obj.IpylabModel, "menu.runMenu")}) - settings_menu = Instance(RankedMenu, kw={"basename": (Obj.IpylabModel, "menu.settingsMenu")}) - view_menu = Instance(RankedMenu, kw={"basename": (Obj.IpylabModel, "menu.viewMenu")}) - tabs_menu = Instance(RankedMenu, kw={"basename": (Obj.IpylabModel, "menu.tabsMenu")}) + edit_menu = Readonly(RankedMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.editMenu")) + file_menu = Readonly(RankedMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.fileMenu")) + kernel_menu = Readonly(RankedMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.kernelMenu")) + run_menu = Readonly(RankedMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.runMenu")) + settings_menu = Readonly(RankedMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.settingsMenu")) + view_menu = Readonly(RankedMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.viewMenu")) + tabs_menu = Readonly(RankedMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.tabsMenu")) @classmethod @override diff --git a/ipylab/notification.py b/ipylab/notification.py index eed30fc..e411952 100644 --- a/ipylab/notification.py +++ b/ipylab/notification.py @@ -8,12 +8,13 @@ import traitlets from ipywidgets import TypedTuple, register -from traitlets import Bool, Container, Dict, Instance, Unicode +from traitlets import Container, Instance, Unicode import ipylab -from ipylab import Connection, NotificationType, Transform, pack +from ipylab import NotificationType, Transform, pack from ipylab._compat.typing import override from ipylab.common import Obj, TaskHooks, TransformType +from ipylab.connection import InfoConnection from ipylab.ipylab import Ipylab, IpylabBase if TYPE_CHECKING: @@ -33,17 +34,12 @@ class NotifyAction(TypedDict): caption: NotRequired[str] -class ActionConnection(Connection): +class ActionConnection(InfoConnection): callback = traitlets.Callable() - info = Dict(help="info about the item") - auto_dispose = Bool(True).tag(sync=True) - -class NotificationConnection(Connection): - info = Dict(help="info about the item") +class NotificationConnection(InfoConnection): actions: Container[tuple[ActionConnection, ...]] = TypedTuple(trait=Instance(ActionConnection)) - auto_dispose = Bool(True).tag(sync=True) def update( self, @@ -86,7 +82,7 @@ class NotificationManager(Ipylab): ipylab_base = IpylabBase(Obj.IpylabModel, "Notification.manager").tag(sync=True) connections: Container[tuple[NotificationConnection | ActionConnection, ...]] = TypedTuple( - trait=Instance(Connection) + trait=Instance(InfoConnection) ) @override diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts index 1ac786b..388d8e7 100644 --- a/src/widgets/frontend.ts +++ b/src/widgets/frontend.ts @@ -21,6 +21,7 @@ export class JupyterFrontEndModel extends IpylabModel { initialize(attributes: any, options: any): void { super.initialize(attributes, options); + this.kernelId = this.kernel.id; this.logger = JFEM.loggerRegistry.getLogger(this.vpath); this.logger.stateChanged.connect(this.loggerStateChanged as any, this); Private.jfems.set(this.kernel.id, this); @@ -44,7 +45,7 @@ export class JupyterFrontEndModel extends IpylabModel { } close(comm_closed?: boolean): Promise { - Private.jfems.delete(this.kernel.id); + Private.jfems.delete(this.kernelId); Private.vpathTojfem.delete(this.vpath); JFEM.labShell.currentChanged.disconnect(this.updateSessionInfo, this); JFEM.labShell.activeChanged.disconnect(this.updateSessionInfo, this); @@ -57,12 +58,12 @@ export class JupyterFrontEndModel extends IpylabModel { let vpath = this.get('vpath'); if (!vpath) { const cs = this.get('current_session'); - if (cs?.kernel?.id === this.kernel.id) { + if (cs?.kernel?.id === this.kernelId) { vpath = cs?.path; } if (!vpath) { for (const session of JFEM.sessionManager.running()) { - if (session.kernel.id === this.kernel.id) { + if (session.kernel.id === this.kernelId) { vpath = session.path; break; } @@ -171,11 +172,8 @@ export class JupyterFrontEndModel extends IpylabModel { const getManager = (KernelWidgetManager as any).getManager; const widget_manager: KernelWidgetManager = await getManager(kernel); if (!Private.jfems.has(kernel.id)) { - // getManager will restore widgets so we only need to do this if ipylab wasn't imported. - widget_manager.kernel.requestExecute( - { code: `import ipylab;ipylab.App()` }, - true - ); + const code = 'import ipylab;ipylab.App()'; + widget_manager.kernel.requestExecute({ code }, true); } } return await new Promise((resolve, reject) => { @@ -270,7 +268,7 @@ export class JupyterFrontEndModel extends IpylabModel { const source = ipylabSettings.vpath; JFEM.app.commands.execute('logconsole:open', { source }); } - + kernelId: string; logger: ILogger; static loggerRegistry: ILoggerRegistry; }