From 6b20884fc9162be231ba6c5f69263cd7ad170d3c Mon Sep 17 00:00:00 2001 From: Alan Fleming <> Date: Mon, 19 Aug 2024 17:26:55 +1000 Subject: [PATCH] Switch from using CallbackType to 'start' as an argument making it easier to sequence multiple calls. --- ipylab/asyncwidget.py | 95 +++++++++++++--------------- ipylab/commands.py | 22 ++++--- ipylab/dialog.py | 49 +++++++------- ipylab/jupyterfrontend.py | 16 ++--- ipylab/jupyterfrontend_subsection.py | 29 ++++----- ipylab/main_area.py | 13 ++-- ipylab/shell.py | 22 +++---- ipylab/widgets.py | 14 +++- src/widgets/commands.ts | 3 +- 9 files changed, 132 insertions(+), 131 deletions(-) diff --git a/ipylab/asyncwidget.py b/ipylab/asyncwidget.py index 019794be..e92e35ee 100644 --- a/ipylab/asyncwidget.py +++ b/ipylab/asyncwidget.py @@ -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 @@ -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 @@ -103,7 +102,6 @@ class JavascriptType(StrEnum): function = "function" -CallbackType = Callable[[dict[str, Any], Any], Any] TransformType = TransformMode | dict[str, str] @@ -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 = {} @@ -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 @@ -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: @@ -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={} @@ -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. @@ -371,11 +360,6 @@ 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={ @@ -383,28 +367,29 @@ def 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, @@ -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": @@ -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 {}, + ) diff --git a/ipylab/commands.py b/ipylab/commands.py index b158a633..1e36d804 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -11,7 +11,6 @@ from ipylab.hookspecs import pm if TYPE_CHECKING: - import asyncio from collections.abc import Callable from ipylab.widgets import Icon @@ -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) @@ -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...) @@ -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_()) diff --git a/ipylab/dialog.py b/ipylab/dialog.py index 389fd4a1..5b23b584 100644 --- a/ipylab/dialog.py +++ b/ipylab/dialog.py @@ -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} @@ -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 = [ @@ -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 diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index ff1b56d3..bcd138c0 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -58,7 +58,7 @@ def sessionManager(self) -> SessionManager: self._sessionManger = SessionManager() return self._sessionManger - async def wait_ready(self, timeout=5): + async def wait_ready(self, timeout=5): # noqa: ASYNC109 """Wait until connected to app indicates it is ready.""" if not self._ready_response.is_set(): future = asyncio.gather( @@ -91,7 +91,7 @@ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffer pm.hook.unhandled_frontend_operation_message(obj=self, operation=operation) raise NotImplementedError - def shutdownKernel(self, kernelId: str | None = None) -> asyncio.Task: + def shutdownKernel(self, kernelId: str | None = None): """Shutdown the kernel""" return self.schedule_operation("shutdownKernel", kernelId=kernelId) @@ -104,7 +104,7 @@ def newSession( kernelName="python3", code: str | types.ModuleType = "", type="ipylab", # noqa: A002 - ) -> asyncio.Task: + ): """ Create a new kernel and execute code in it or execute code in an existing kernel. @@ -132,7 +132,7 @@ def newSession( def newNotebook( self, path: str = "", *, name: str = "", kernelId="", kernelName="python3", code: str | types.ModuleType = "" - ) -> asyncio.Task: + ): """Create a new notebook.""" return self.schedule_operation( "newNotebook", @@ -146,7 +146,7 @@ def newNotebook( def injectCode( self, kernelId: str, code: str | types.ModuleType, user_expressions: dict[str, str | types.ModuleType] | None - ) -> asyncio.Task: + ): """ Inject code into a running kernel using the Jupyter builtin `requestExecute`. @@ -178,7 +178,7 @@ def execEval( user_expressions: dict[str, str | types.ModuleType] | None, kernelId="", **kwgs, - ) -> asyncio.Task: + ): """Execute and evaluate code in the Python kernel corresponding to kerenelId, or create a new kernel if `kernelId` isn't provided. @@ -195,7 +195,7 @@ def execEval( The code as a script or function to pass to the builtin `exec`, returns `None`. ref: https://docs.python.org/3/library/functions.html#exec eval: - An expression to evalate using the builtin `eval`. + An expression to evaluate using the builtin `eval`. If the evaluation returns an executable, it will be executed. I the result is awaitable, the result will be awaited. The serialized result or result of the awaitable will be returned via the frontend. @@ -234,6 +234,6 @@ async def _execEval(self, payload: dict, buffers: list) -> Any: return results return None - def startIyplabPythonBackend(self) -> asyncio.Task: + def startIyplabPythonBackend(self): """Checks backend is running and starts it if it isn't, returning the session model.""" return self.schedule_operation("startIyplabPythonBackend") diff --git a/ipylab/jupyterfrontend_subsection.py b/ipylab/jupyterfrontend_subsection.py index 0e467fce..50830b6d 100644 --- a/ipylab/jupyterfrontend_subsection.py +++ b/ipylab/jupyterfrontend_subsection.py @@ -8,10 +8,9 @@ from ipylab.hasapp import HasApp if TYPE_CHECKING: - import asyncio from collections.abc import Iterable - from ipylab.asyncwidget import CallbackType, TransformType + from ipylab.asyncwidget import TransformType class JupyterFrontEndSubsection(HasApp): @@ -26,33 +25,29 @@ def execute_method( self, method: str, *args, - callback: CallbackType | None = None, transform: TransformType = TransformMode.raw, toLuminoWidget: Iterable[str] | None = None, - ) -> asyncio.Task: + start=True, + ): """Execute a nested method on this objects JFE_SUB_PATH relative to the instance of the JupyterFrontEndModel in the JS frontend. """ return self.app.execute_method( f"{self.JFE_JS_SUB_PATH}.{method}", *args, - callback=callback, transform=transform, toLuminoWidget=toLuminoWidget, + start=start, ) - def get_attribute( - self, path: str, *, callback: CallbackType | None = None, transform: TransformType = TransformMode.raw - ) -> asyncio.Task: + def get_attribute(self, path: str, *, transform: TransformType = TransformMode.raw, start=True): """Get an attribute by name from the front end.""" - return self.app.get_attribute(f"{self.JFE_JS_SUB_PATH}.{path}", callback=callback, transform=transform) + return self.app.get_attribute(f"{self.JFE_JS_SUB_PATH}.{path}", transform=transform, start=start) - def list_attributes( - self, - path: str = "", - *, - callback: CallbackType | None = None, - transform: TransformType = TransformMode.raw, - ) -> asyncio.Task: + def list_attributes(self, path: str = "", *, transform: TransformType = TransformMode.raw, start=True): """Get a list of all attributes.""" - return self.app.list_attributes(f"{self.JFE_JS_SUB_PATH}.{path}", callback=callback, transform=transform) + return self.app.list_attributes( + f"{self.JFE_JS_SUB_PATH}.{path}", + transform=transform, + start=start, + ) diff --git a/ipylab/main_area.py b/ipylab/main_area.py index c35d482d..a178a870 100644 --- a/ipylab/main_area.py +++ b/ipylab/main_area.py @@ -5,7 +5,7 @@ import pathlib import sys -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar from ipywidgets import register from traitlets import Instance, TraitType, Unicode, UseEnum, observe, validate @@ -20,9 +20,6 @@ else: from backports.strenum import StrEnum -if TYPE_CHECKING: - import asyncio - class ViewStatus(StrEnum): unloaded = "unloaded" @@ -100,7 +97,7 @@ def load( rank: int | None = None, ref: str = "", class_name="ipylab-main-area", - ) -> asyncio.Task: + ): """Load into the shell. Only one main_area_widget (view) can exist at a time, any existing widget will be disposed @@ -131,12 +128,12 @@ def load( } return self.schedule_operation("load", area=area, options=options, className=class_name) - def unload(self) -> asyncio.Task: + def unload(self): "Remove from the shell" self.set_trait("status", ViewStatus.unloading) return self.schedule_operation("unload") - def load_console(self, *, mode: InsertMode = InsertMode.split_bottom, **kwgs) -> asyncio.Task: + def load_console(self, *, mode: InsertMode = InsertMode.split_bottom, **kwgs): """Load a console using for the same kernel. Opening the console will close any existing consoles. @@ -145,7 +142,7 @@ def load_console(self, *, mode: InsertMode = InsertMode.split_bottom, **kwgs) -> kwgs = {"name": self.name, "path": self.path} | kwgs return self.schedule_operation("open_console", insertMode=InsertMode(mode), **kwgs) # type: ignore - def unload_console(self) -> asyncio.Task: + def unload_console(self): """Unload the console.""" self.set_trait("console_status", ViewStatus.unloading) return self.schedule_operation("close_console") diff --git a/ipylab/shell.py b/ipylab/shell.py index c95770d8..aa046ae0 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -16,8 +16,6 @@ from backports.strenum import StrEnum if t.TYPE_CHECKING: - import asyncio - from ipywidgets import Widget @@ -72,8 +70,9 @@ def addToShell( mode: InsertMode = InsertMode.split_right, rank: int | None = None, ref: LuminoWidgetConnection | str = "", + start=True, **options, - ) -> asyncio.Task[LuminoWidgetConnection]: + ): """ Add the widget to the shell. @@ -96,16 +95,17 @@ def addToShell( transform=TransformMode.connection, options=options_ | options, toLuminoWidget=["widget", "options.ref"], + start=start, ) - def expandLeft(self) -> asyncio.Task: - return self.execute_method("expandLeft") + def expandLeft(self, *, start=True): + return self.execute_method("expandLeft", start=start) - def expandRight(self) -> asyncio.Task: - return self.execute_method("expandRight") + def expandRight(self, *, start=True): + return self.execute_method("expandRight", start=start) - def collapseLeft(self) -> asyncio.Task: - return self.execute_method("collapseLeft") + def collapseLeft(self, *, start=True): + return self.execute_method("collapseLeft", start=start) - def collapseRight(self) -> asyncio.Task: - return self.execute_method("collapseRight") + def collapseRight(self, *, start=True): + return self.execute_method("collapseRight", start=start) diff --git a/ipylab/widgets.py b/ipylab/widgets.py index afd38c0d..3555f47f 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -65,10 +65,20 @@ def addToShell( mode: InsertMode = InsertMode.split_right, rank: int | None = None, ref: LuminoWidgetConnection | str = "", + start=True, **options, - ) -> asyncio.Task[LuminoWidgetConnection]: + ) -> asyncio._AwaitableLike[LuminoWidgetConnection]: """Add this panel to the shell.""" - return self.app.shell.addToShell(self, area=area, mode=mode, activate=activate, rank=rank, ref=ref, **options) + return self.app.shell.addToShell( + self, + area=area, + mode=mode, + activate=activate, + rank=rank, + ref=ref, + start=start, + **options, + ) @register diff --git a/src/widgets/commands.ts b/src/widgets/commands.ts index 245c5af1..fae079b7 100644 --- a/src/widgets/commands.ts +++ b/src/widgets/commands.ts @@ -141,7 +141,8 @@ export class CommandRegistryModel extends IpylabModel { isVisible: () => commandEnabled(command) }); Private.customCommands.set(id, command); - return { id: id }; + command.id = id; + return command; } /**