Skip to content

Commit

Permalink
Switch from using CallbackType to 'start' as an argument making it ea…
Browse files Browse the repository at this point in the history
…sier to sequence multiple calls.
  • Loading branch information
Alan Fleming committed Aug 19, 2024
1 parent 3a27391 commit 6b20884
Show file tree
Hide file tree
Showing 9 changed files with 132 additions and 131 deletions.
95 changes: 43 additions & 52 deletions ipylab/asyncwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import sys
import traceback
import uuid
from collections.abc import Callable
from typing import TYPE_CHECKING, Any

from ipywidgets import Widget, register, widget_serialization
Expand All @@ -24,7 +23,7 @@

if TYPE_CHECKING:
import types
from collections.abc import Iterable
from collections.abc import Callable, Iterable
from typing import ClassVar

from ipylab.luminowidget_connection import LuminoWidgetConnection
Expand Down Expand Up @@ -103,7 +102,6 @@ class JavascriptType(StrEnum):
function = "function"


CallbackType = Callable[[dict[str, Any], Any], Any]
TransformType = TransformMode | dict[str, str]


Expand Down Expand Up @@ -187,6 +185,13 @@ def _check_closed(self):
msg = f"This widget is closed {self!r}"
raise RuntimeError(msg)

def new_task(self, coro: asyncio._CoroutineLike):
"""Start a task"""
task = asyncio.create_task(coro)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return task

def _check_get_error(self, content: dict | None = None) -> IpylabFrontendError | None:
if content is None:
content = {}
Expand Down Expand Up @@ -216,24 +221,20 @@ def send(self, content, buffers=None):
except Exception as error:
pm.hook.on_frontend_error(obj=self, error=error, content=content, buffers=buffers)

async def _send_receive(self, content: dict, callback: CallbackType | None):
async def _send_receive(self, content: dict):
async with self:
self._pending_operations[content["ipylab_BE"]] = response = Response()
self.send(content)
try:
return await self._wait_response_check_error(response, content, callback)
return await self._wait_response_check_error(response, content)
except asyncio.CancelledError:
if not self.comm:
msg = f"This widget is closed {self!r}"
raise asyncio.CancelledError(msg) from None
raise

async def _wait_response_check_error(self, response: Response, content: dict, callback: CallbackType | None) -> Any:
async def _wait_response_check_error(self, response: Response, content: dict) -> Any:
payload = await response.wait()
if callback:
payload = callback(content, payload)
if asyncio.iscoroutine(payload):
payload = await payload
if content["transform"] is TransformMode.connection:
from ipylab.luminowidget_connection import LuminoWidgetConnection

Expand All @@ -250,11 +251,7 @@ def _on_frontend_msg(self, _, content: dict, buffers: list):
if ipylab_backend:
self._pending_operations.pop(ipylab_backend).set(payload, error)
if "ipylab_FE" in content:
task = asyncio.create_task(
self._handle_frontend_operation(content["ipylab_FE"], operation, payload, buffers)
)
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
self.new_task(self._handle_frontend_operation(content["ipylab_FE"], operation, payload, buffers))
elif "init" in content:
self._ready_response.set(content)
elif "closed" in content:
Expand Down Expand Up @@ -301,19 +298,16 @@ def schedule_operation(
self,
operation: str,
*,
callback: CallbackType | None = None,
transform: TransformType = TransformMode.raw,
toLuminoWidget: Iterable[str] | None = None,
start=True,
**kwgs,
) -> asyncio.Task:
) -> asyncio._AwaitableLike[Dict | str | list | float | int | None | LuminoWidgetConnection]:
"""
operation: str
Name corresponding to operation in JS frontend.
callback: callable | coroutine function.
A callback to do additional processing on the response prior to returning a result.
The callback is passed (response, content).
transform : TransformMode | dict
see ipylab.TransformMode
note: If there is a name clash with the operation, use kwgs={}
Expand All @@ -340,22 +334,17 @@ def schedule_operation(
content = {"ipylab_BE": ipylab_BE, "operation": operation, "kwgs": kwgs, "transform": TransformMode(transform)}
if toLuminoWidget:
content["toLuminoWidget"] = list(map(str, toLuminoWidget))
if callback and not callable(callback):
msg = f"callback is not callable {callback!r}"
raise TypeError(msg)
task = asyncio.create_task(self._send_receive(content, callback))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
return task
coro = self._send_receive(content)
return self.new_task(coro) if start else coro

def execute_method(
self,
method: str,
*args,
callback: CallbackType | None = None,
transform: TransformType = TransformMode.raw,
toLuminoWidget: Iterable[str] | None = None,
) -> asyncio.Task:
start=True,
):
"""Call a method on the corresponding frontend object.
method: 'dotted.access.to.the.method' relative to the Frontend instance.
Expand All @@ -371,40 +360,36 @@ def execute_method(
"""

# This operation is sent to the frontend function _fe_execute in 'ipylab/src/widgets/ipylab.ts'

# validation
if callback is not None and not callable(callback):
msg = "callback must be a callable or None"
raise TypeError(msg)
return self.schedule_operation(
operation="FE_execute",
FE_execute={
"mode": "execute_method",
"kwgs": {"method": method},
},
transform=transform,
callback=callback,
args=args,
toLuminoWidget=toLuminoWidget,
start=start,
)

def get_attribute(
self, path: str, *, callback: CallbackType | None = None, transform: TransformType = TransformMode.raw
):
def get_attribute(self, path: str, *, transform: TransformType = TransformMode.raw, start=True):
"""A serialized version of the attribute relative to this object."""
return self.execute_method("getAttribute", path, callback=callback, transform=transform)
return self.execute_method("getAttribute", path, transform=transform, start=start)

def list_methods(self, path: str = "", depth=2, skip_hidden=True) -> asyncio.Task[list[str]]: # noqa: FBT002
def list_methods(self, path: str = "", *, depth=2, skip_hidden=True, start=True):
"""Get a list of methods belonging to the object 'path' of the Frontend instance.
depth: The depth in the object inheritance to search for methods.
"""
coro_ = self.list_attributes(path, JavascriptType.function, depth, how="names", start=False)

def callback(content: dict, payload: list): # noqa: ARG001
async def _list_methods():
payload: list = await coro_ # type: ignore
if skip_hidden:
return [n for n in payload if not n.startswith("_")]
return payload

return self.list_attributes(path, "function", depth, how="names", callback=callback) # type: ignore
coro = _list_methods()
return self.new_task(coro) if start else coro

def list_attributes(
self,
Expand All @@ -413,16 +398,18 @@ def list_attributes(
depth=2,
*,
how="group",
callback: CallbackType | None = None,
transform: TransformType = TransformMode.raw,
) -> asyncio.Task[dict | list]:
start=True,
) -> asyncio._AwaitableLike[list | dict]:
"""Get a mapping of attributes of the object at 'path' of the Frontend instance.
depth: The depth in the object inheritance to search for attributes.
how: ['names', 'group', 'raw'] (ignored if callback provided)
"""
coro_ = self.execute_method("listAttributes", path, type, depth, start=False, transform=transform)

def callback_(content: dict, payload: Any):
async def list_attributes_():
payload: list = await coro_ # type: ignore
if how == "names":
payload = [row["name"] for row in payload]
elif how == "group":
Expand All @@ -431,21 +418,25 @@ def callback_(content: dict, payload: Any):
st = groups.get(item["type"], [])
st.append(item["name"])
groups[item["type"]] = st
payload = groups
if callback:
payload = callback(content, payload)
return groups
return payload

return self.execute_method("listAttributes", path, type, depth, callback=callback_, transform=transform)
coro = list_attributes_()
return self.new_task(coro) if start else coro

def execute_command(self, command_id: str, transform: TransformType = TransformMode.done, **args) -> asyncio.Task:
def execute_command(self, command_id: str, *, execute_settings: dict | None = None, **kwgs):
"""Execute command_id.
`args` correspond to `args` in JupyterLab.
`kwgs` correspond to `args` in JupyterLab.
Finding what the `args` are remains an outstanding issue in JupyterLab.
see: https://github.com/jtpio/ipylab/issues/128#issuecomment-1683097383 for hints
about how args can be found.
"""
return self.execute_method("app.commands.execute", command_id, args, transform=transform)
return self.execute_method(
"app.commands.execute",
command_id,
kwgs, # -> used as 'args' in Jupyter
**execute_settings or {},
)
22 changes: 14 additions & 8 deletions ipylab/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from ipylab.hookspecs import pm

if TYPE_CHECKING:
import asyncio
from collections.abc import Callable

from ipylab.widgets import Icon
Expand All @@ -22,12 +21,12 @@ class CommandPalette(AsyncWidgetBase):
_model_name = Unicode("CommandPaletteModel").tag(sync=True)
items = Tuple(read_only=True).tag(sync=True)

def add_item(self, command_id: str, category: str, *, rank=None, args: dict | None = None, **kwgs) -> asyncio.Task:
def add_item(self, command_id: str, category: str, *, rank=None, args: dict | None = None, **kwgs):
return self.schedule_operation(
operation="addItem", id=command_id, category=category, rank=rank, args=args, **kwgs
)

def remove_item(self, command_id: str, category) -> asyncio.Task:
def remove_item(self, command_id: str, category):
return self.schedule_operation(operation="removeItem", id=command_id, category=category)


Expand Down Expand Up @@ -75,7 +74,6 @@ def addPythonCommand(
label="",
icon_class="",
icon: Icon | None = None,
command_result_transform: TransformMode = TransformMode.raw,
**kwgs,
):
# TODO: support other parameters (isEnabled, isVisible...)
Expand All @@ -87,17 +85,25 @@ def addPythonCommand(
label=label,
iconClass=icon_class,
icon=pack(icon),
command_result_transform=command_result_transform,
command_result_transform=TransformMode.done,
**kwgs,
)

def removePythonCommand(self, command_id: str, **kwgs) -> asyncio.Task:
def removePythonCommand(self, command_id: str):
# TODO: check whether to keep this method, or return disposables like in lab
if command_id not in self._execute_callbacks:
msg = f"{command_id=} is not a registered command!"
raise ValueError(msg)

def callback(content: dict, payload: list): # noqa: ARG001
coro_ = self.schedule_operation(
"removePythonCommand",
command_id=command_id,
start=False,
transform=TransformMode.done,
)

async def removePythonCommand_():
await coro_
self._execute_callbacks.pop(command_id, None)

return self.schedule_operation("removePythonCommand", command_id=command_id, callback=callback, **kwgs)
return self.new_task(removePythonCommand_())
49 changes: 25 additions & 24 deletions ipylab/dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,45 +12,39 @@


class Dialog(HasApp):
def get_boolean(self, title: str) -> asyncio.Task:
def get_boolean(self, title: str, *, start=True) -> asyncio._AwaitableLike[bool]:
"""Jupyter dialog to get a boolean value.
see: https://jupyterlab.readthedocs.io/en/stable/extension/ui_helpers.html#input-dialogs
"""
return self.app.schedule_operation("getBoolean", title=title)
return self.app.schedule_operation("getBoolean", title=title, start=start) # type: ignore

def get_item(self, title: str, items: tuple | list) -> asyncio.Task:
def get_item(self, title: str, items: tuple | list, *, start=True):
"""Jupyter 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/extension/ui_helpers.html#input-dialogs
"""
return self.app.schedule_operation("getItem", title=title, items=tuple(items))
return self.app.schedule_operation("getItem", title=title, items=tuple(items), start=start)

def get_number(self, title: str) -> asyncio.Task:
def get_number(self, title: str, *, start=True) -> asyncio._AwaitableLike[float]:
"""Jupyter dialog to get a number.
see: https://jupyterlab.readthedocs.io/en/stable/extension/ui_helpers.html#input-dialogs
"""
return self.app.schedule_operation("getNumber", title=title)
return self.app.schedule_operation("getNumber", title=title, start=start) # type: ignore

def get_text(self, title: str) -> asyncio.Task:
def get_text(self, title: str, *, start=True) -> asyncio._AwaitableLike[str]:
"""Jupyter dialog to get a string.
see: https://jupyterlab.readthedocs.io/en/stable/extension/ui_helpers.html#input-dialogs
"""
return self.app.schedule_operation("getText", title=title)
return self.app.schedule_operation("getText", title=title, start=start) # type: ignore

def get_password(self, title: str) -> asyncio.Task:
def get_password(self, title: str, *, start=True) -> asyncio._AwaitableLike[str]:
"""Jupyter dialog to get a number.
see: https://jupyterlab.readthedocs.io/en/stable/extension/ui_helpers.html#input-dialogs
"""
return self.app.schedule_operation("getPassword", title=title)

def show_dialog(
self,
title: str = "",
body: str | Widget = "",
host: None | Widget = None,
**kwgs,
):
return self.app.schedule_operation("getPassword", title=title, start=start) # type: ignore

def show_dialog(self, title: str = "", body: str | Widget = "", host: None | Widget = None, **kwgs):
"""Jupyter dialog to get user response with custom buttons and checkbox.
returns {'value':any, 'isChecked':bool|None}
Expand Down Expand Up @@ -103,9 +97,16 @@ def show_dialog(
source: https://jupyterlab.readthedocs.io/en/stable/extension/ui_helpers.html#generic-dialog
"""

return self.app.schedule_operation("showDialog", title=pack(title), body=pack(body), host=pack(host), **kwgs)
return self.app.schedule_operation(
"showDialog",
title=pack(title),
body=pack(body),
host=pack(host),
toLuminoWidget=["title", "body", "host"],
**kwgs,
)

def show_error_message(self, title: str, error: str, buttons: None | list[dict[str, str]] = None) -> asyncio.Task:
def show_error_message(self, title: str, error: str, buttons: None | list[dict[str, str]] = None):
"""Jupyter error message.
buttons = [
Expand All @@ -132,15 +133,15 @@ class FileDialog(HasApp):
https://jupyterlab.readthedocs.io/en/stable/extension/ui_helpers.html#file-dialogs
"""

def get_open_files(self, **kwgs) -> asyncio.Task:
def get_open_files(self, **kwgs) -> asyncio._AwaitableLike[list[str]]:
"""Get a list of files
https://jupyterlab.readthedocs.io/en/latest/api/functions/filebrowser.FileDialog.getOpenFiles.html#getOpenFiles
"""
return self.app.schedule_operation("getOpenFiles", **kwgs)
return self.app.schedule_operation("getOpenFiles", **kwgs) # type: ignore

def get_existing_directory(self, **kwgs) -> asyncio.Task:
def get_existing_directory(self, **kwgs) -> asyncio._AwaitableLike[str]:
"""
https://jupyterlab.readthedocs.io/en/latest/api/functions/filebrowser.FileDialog.getExistingDirectory.html#getExistingDirectory
"""
return self.app.schedule_operation("getExistingDirectory", **kwgs)
return self.app.schedule_operation("getExistingDirectory", **kwgs) # type: ignore
Loading

0 comments on commit 6b20884

Please sign in to comment.