Skip to content

Commit

Permalink
Added Readonly - a lightweight descriptor style class to use instead …
Browse files Browse the repository at this point in the history
…of traitlets.Instance.

Added InfoConnection.
  • Loading branch information
Alan Fleming committed Nov 5, 2024
1 parent 38eb21b commit 16844bc
Show file tree
Hide file tree
Showing 10 changed files with 114 additions and 75 deletions.
27 changes: 27 additions & 0 deletions examples/menu.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down
19 changes: 7 additions & 12 deletions ipylab/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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("")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
17 changes: 13 additions & 4 deletions ipylab/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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
Expand Down Expand Up @@ -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"""
Expand Down
33 changes: 25 additions & 8 deletions ipylab/ipylab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
23 changes: 12 additions & 11 deletions ipylab/jupyterfrontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions ipylab/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions ipylab/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 13 additions & 17 deletions ipylab/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")


Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading

0 comments on commit 16844bc

Please sign in to comment.