diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml index 5f7a5e8c..491fb9e1 100644 --- a/.github/workflows/packaging.yml +++ b/.github/workflows/packaging.yml @@ -50,9 +50,9 @@ jobs: fail-fast: false matrix: os: [ubuntu, macos, windows] - python: ['3.11', '3.13'] + python: ['3.12', '3.13'] include: - - python: '3.11' + - python: '3.12' dist: 'ipylab*.tar.gz' - python: '3.13' dist: 'ipylab*.whl' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 13816634..ed4fc5d9 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.1 + rev: 0.31.3 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.9.4 + rev: v0.9.10 hooks: - id: ruff types_or: [python, jupyter] diff --git a/.vscode/settings.json b/.vscode/settings.json index ef88f0f7..5e55950f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,9 +14,7 @@ "python.terminal.activateEnvInCurrentTerminal": true, "python.createEnvironment.trigger": "prompt", "python.analysis.typeCheckingMode": "basic", - "python.testing.pytestArgs": [ - "tests" - ], + "python.testing.pytestArgs": ["tests"], "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true } diff --git a/README.md b/README.md index 802762de..92d6274a 100644 --- a/README.md +++ b/README.md @@ -80,10 +80,11 @@ combining [Per-kernel-widget-manager](https://github.com/jupyter-widgets/ipywidg and [weakref](https://github.com/fleming79/ipywidgets/tree/weakref). These versions enable: -* Widget restoration when the page is reloaded. -* Starting new kernels and opening widgets from those kernels. -* autostart plugins - Run code when Jupyterlab is started. -* Viewing widgets from kernels inside from other kernels. + +- Widget restoration when the page is reloaded. +- Starting new kernels and opening widgets from those kernels. +- autostart plugins - Run code when Jupyterlab is started. +- Viewing widgets from kernels inside from other kernels. ```bash # For per-kernel-widget-manager support (Install modified version of ipywidgets, jupyterlab_widgets & widgetsnbextension) diff --git a/examples/css_stylesheet.ipynb b/examples/css_stylesheet.ipynb index d4c07a80..df732f29 100644 --- a/examples/css_stylesheet.ipynb +++ b/examples/css_stylesheet.ipynb @@ -36,6 +36,7 @@ "import ipywidgets as ipw\n", "\n", "import ipylab\n", + "from ipylab.css_stylesheet import CSSStyleSheet\n", "\n", "app = ipylab.app" ] @@ -47,7 +48,7 @@ "outputs": [], "source": [ "# Create a new CSSStyleSheet object.\n", - "ss = ipylab.CSSStyleSheet()\n", + "ss = CSSStyleSheet()\n", "\n", "# Set a css variable\n", "t = ss.set_variables({\"--ipylab-custom\": \"orange\"}) # Demonstrate setting a variable\n", @@ -59,6 +60,13 @@ "\"\"\") # Define the stylesheet" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Resize example" + ] + }, { "cell_type": "code", "execution_count": null, @@ -144,7 +152,7 @@ "metadata": {}, "outputs": [], "source": [ - "ss = ipylab.CSSStyleSheet()" + "ss = CSSStyleSheet()" ] }, { @@ -181,7 +189,7 @@ "metadata": {}, "outputs": [], "source": [ - "ss = ipylab.CSSStyleSheet()\n", + "ss = CSSStyleSheet()\n", "ss.replace(\"\"\"\n", "/* Modify Jupyter Styles */\n", "\n", @@ -253,7 +261,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 new file mode 100644 index 00000000..a06bce1a --- /dev/null +++ b/examples/resize_box.ipynb @@ -0,0 +1,202 @@ +{ + "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": {}, + "source": [ + "# ResizeBox\n", + "\n", + "The `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", + "\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." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as ipw\n", + "\n", + "import ipylab.widgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "button = ipw.Button()\n", + "label = ipw.HTML(\"Test\")\n", + "resize_box = ipylab.widgets.ResizeBox([label], layout={\"border\": \"solid 1px black\"})\n", + "\n", + "\n", + "def observe(_):\n", + " label.value = f\"Size: {resize_box.size}px\"\n", + "\n", + "\n", + "resize_box.observe(observe, names=(\"size\"))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "resize_box" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "resize_box" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ipw.VBox([resize_box, resize_box])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Matplotlib example\n", + "\n", + "`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": {}, + "source": [ + "**Restart jupyterlab if you installed ipympl**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib as mpl\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "mpl.use(\"module://ipympl.backend_nbagg\")\n", + "\n", + "x = np.linspace(0, 2 * np.pi, 200)\n", + "y = np.sin(x)\n", + "\n", + "fig, ax = plt.subplots()\n", + "ax.plot(x, y)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Make the plot resize dynamically\n", + "\n", + "We can dynamically update the size of the plot by using `ResizeBox`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ipylab\n", + "from ipylab.widgets import ResizeBox\n", + "\n", + "box = ResizeBox([fig.canvas])\n", + "fig.canvas.resizable = False\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", + " fig.set_size_inches(max((width) // dpi, 1), max((height) // dpi, 1))\n", + " fig.canvas.draw_idle()\n", + "\n", + "\n", + "box.observe(_observe_resizebox_dimensions, names=(\"size\"))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets add the box to the shell.\n", + "\n", + "Try the following:\n", + "- Resize the browser and watch the figure update.\n", + "- Use the resize handle to resize the plot." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ipylab.app.shell.add(box)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/examples/simple_output.ipynb b/examples/simple_output.ipynb index ff7bf354..db4d1964 100644 --- a/examples/simple_output.ipynb +++ b/examples/simple_output.ipynb @@ -111,7 +111,9 @@ "id": "10", "metadata": {}, "source": [ - "### Other formats are also supported" + "### Other formats are also supported\n", + "\n", + "#### Ipython" ] }, { @@ -130,6 +132,26 @@ "cell_type": "markdown", "id": "12", "metadata": {}, + "source": [ + "#### Ipywidgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13", + "metadata": {}, + "outputs": [], + "source": [ + "import ipywidgets as ipw\n", + "\n", + "SimpleOutput().push(ipw.Button(description=\"ipywidgets button\"))" + ] + }, + { + "cell_type": "markdown", + "id": "14", + "metadata": {}, "source": [ "### set\n", "\n", @@ -139,7 +161,7 @@ { "cell_type": "code", "execution_count": null, - "id": "13", + "id": "15", "metadata": {}, "outputs": [], "source": [ @@ -150,7 +172,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -160,7 +182,7 @@ { "cell_type": "code", "execution_count": null, - "id": "15", + "id": "17", "metadata": {}, "outputs": [], "source": [ @@ -170,7 +192,7 @@ }, { "cell_type": "markdown", - "id": "16", + "id": "18", "metadata": {}, "source": [ "## max_continuous_streams and max_outputs\n", @@ -185,7 +207,7 @@ { "cell_type": "code", "execution_count": null, - "id": "17", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -196,7 +218,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -206,7 +228,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -216,7 +238,7 @@ }, { "cell_type": "markdown", - "id": "20", + "id": "22", "metadata": {}, "source": [ "`max_outputs` limits the total number of outputs." @@ -225,7 +247,7 @@ { "cell_type": "code", "execution_count": null, - "id": "21", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -236,7 +258,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -246,7 +268,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "25", "metadata": {}, "source": [ "# AutoScroll\n", @@ -260,7 +282,7 @@ }, { "cell_type": "markdown", - "id": "24", + "id": "26", "metadata": {}, "source": [ "## Ipylab log viewer\n", @@ -271,7 +293,7 @@ { "cell_type": "code", "execution_count": null, - "id": "25", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -283,7 +305,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -299,7 +321,7 @@ { "cell_type": "code", "execution_count": null, - "id": "27", + "id": "29", "metadata": {}, "outputs": [], "source": [ @@ -308,7 +330,7 @@ }, { "cell_type": "markdown", - "id": "28", + "id": "30", "metadata": {}, "source": [ "## Example usage" @@ -317,7 +339,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -332,7 +354,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -392,7 +414,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "33", "metadata": {}, "source": [ "# Basic console example\n", @@ -415,7 +437,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -480,7 +502,7 @@ { "cell_type": "code", "execution_count": null, - "id": "33", + "id": "35", "metadata": {}, "outputs": [], "source": [ @@ -491,7 +513,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -516,7 +538,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.11" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/examples/widgets.ipynb b/examples/widgets.ipynb index c9f7678f..1161c99a 100644 --- a/examples/widgets.ipynb +++ b/examples/widgets.ipynb @@ -502,7 +502,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.10" + "version": "3.12.9" } }, "nbformat": 4, diff --git a/ipylab/__init__.py b/ipylab/__init__.py index 50728617..bf4f7e24 100644 --- a/ipylab/__init__.py +++ b/ipylab/__init__.py @@ -8,7 +8,6 @@ from ipylab.code_editor import CodeEditor from ipylab.common import Area, Fixed, InsertMode, Obj, Transform, hookimpl, pack, to_selector from ipylab.connection import Connection, ShellConnection -from ipylab.css_stylesheet import CSSStyleSheet from ipylab.ipylab import Ipylab from ipylab.jupyterfrontend import App, JupyterFrontEnd from ipylab.notification import NotificationType, NotifyAction @@ -19,7 +18,6 @@ "__version__", "CodeEditor", "Connection", - "CSSStyleSheet", "ShellConnection", "SimpleOutput", "Panel", diff --git a/ipylab/_compat/__init__.py b/ipylab/_compat/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/ipylab/_compat/typing.py b/ipylab/_compat/typing.py deleted file mode 100644 index 1196b434..00000000 --- a/ipylab/_compat/typing.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright (c) ipylab contributors. -# Distributed under the terms of the Modified BSD License. - -from __future__ import annotations - -import sys - -if sys.version_info < (3, 12): - from typing_extensions import override -else: - from typing import override - -__all__ = ["override"] - - -def __dir__() -> list[str]: - return __all__ diff --git a/ipylab/code_editor.py b/ipylab/code_editor.py index b705c2f8..0aa8f509 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 +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, override from IPython.core import completer as IPC # noqa: N812 from IPython.utils.tokenutil import token_at_cursor @@ -18,7 +18,6 @@ from traitlets import Callable, Container, Dict, Instance, Int, Unicode, default, observe import ipylab -from ipylab._compat.typing import override from ipylab.common import Fixed, LastUpdatedDict from ipylab.ipylab import Ipylab diff --git a/ipylab/commands.py b/ipylab/commands.py index 1a2f0bf3..8ef1fe03 100644 --- a/ipylab/commands.py +++ b/ipylab/commands.py @@ -6,14 +6,13 @@ import functools import inspect import uuid -from typing import TYPE_CHECKING, Any, ClassVar, NotRequired, TypedDict, Unpack +from typing import TYPE_CHECKING, Any, ClassVar, NotRequired, TypedDict, Unpack, override from ipywidgets import TypedTuple 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, TaskHookType, TransformType, pack from ipylab.connection import InfoConnection, ShellConnection from ipylab.ipylab import Ipylab, IpylabBase, Transform, register @@ -93,20 +92,25 @@ def add_key_binding( self, keys: list, selector="", args: dict | None = None, *, prevent_default=True ) -> Task[KeybindingConnection]: "Add a key binding for this command and selector." - if not self.comm: - msg = f"Closed: {self}" - raise RuntimeError(msg) args = args or {} - selector = selector or ipylab.app.selector - args |= {"keys": keys, "preventDefault": prevent_default, "selector": 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 self.commands.execute_method("addKeyBinding", args, transform=transform, hooks=hooks) + + async def add_key_binding(): + args_ = args | { + "keys": keys, + "preventDefault": prevent_default, + "selector": selector or ipylab.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()) class CommandPalletItemConnection(InfoConnection): diff --git a/ipylab/common.py b/ipylab/common.py index 5e40631d..ec89ea2d 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -9,14 +9,13 @@ 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 +from typing import TYPE_CHECKING, Any, Generic, Literal, NotRequired, TypedDict, TypeVar, override import pluggy from ipywidgets import Widget, widget_serialization from traitlets import HasTraits import ipylab -from ipylab._compat.typing import override __all__ = [ "Area", diff --git a/ipylab/connection.py b/ipylab/connection.py index de1e33d7..13513c2e 100644 --- a/ipylab/connection.py +++ b/ipylab/connection.py @@ -5,12 +5,11 @@ import uuid import weakref -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, override from ipywidgets import Widget, register from traitlets import Bool, Dict, Instance, Unicode, observe -from ipylab._compat.typing import override from ipylab.ipylab import Ipylab if TYPE_CHECKING: diff --git a/ipylab/css_stylesheet.py b/ipylab/css_stylesheet.py index 6906d3cb..9d42a3ce 100644 --- a/ipylab/css_stylesheet.py +++ b/ipylab/css_stylesheet.py @@ -27,6 +27,8 @@ class CSSStyleSheet(Ipylab): ) def __init__(self, **kwgs): + if self._async_widget_base_init_complete: + return super().__init__(**kwgs) self.on_ready(self._restore) diff --git a/ipylab/dialog.py b/ipylab/dialog.py index b05ef98b..40df763b 100644 --- a/ipylab/dialog.py +++ b/ipylab/dialog.py @@ -98,9 +98,9 @@ def show_dialog( "iconLabel": "", "caption": "Accept the result", "className": "", - "accept": False, + "accept": True, "actions": [], - "displayType": "warn", + "displayType": "default", }, { "ariaLabel": "Cancel", @@ -111,7 +111,7 @@ def show_dialog( "className": "", "accept": False, "actions": [], - "displayType": "default", + "displayType": "warn", }, ], ], diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 54c20644..bf14325d 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -11,8 +11,22 @@ import weakref from typing import TYPE_CHECKING, Any, TypeVar +import traitlets from ipywidgets import Widget, register -from traitlets import Bool, Container, Dict, HasTraits, Instance, Set, TraitError, TraitType, Unicode, default, observe +from traitlets import ( + Bool, + Container, + Dict, + HasTraits, + Instance, + List, + Set, + TraitError, + TraitType, + Unicode, + default, + observe, +) import ipylab import ipylab._frontend as _fe @@ -51,23 +65,6 @@ def __init__(self, base: Obj, subpath: str): super().__init__((base, subpath)) -class Response(asyncio.Event): - def set(self, payload, error: Exception | None = None) -> None: - if getattr(self, "_value", False): - msg = "Already set!" - raise RuntimeError(msg) - self.payload = payload - self.error = error - super().set() - - async def wait(self) -> Any: - """Wait for a message and return the response.""" - await super().wait() - if self.error: - raise self.error - return self.payload - - class IpylabFrontendError(IOError): pass @@ -93,7 +90,9 @@ class Ipylab(WidgetBase): 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() + _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 @@ -101,7 +100,7 @@ class Ipylab(WidgetBase): _ready_event: asyncio.Event | None = None _comm = None - _pending_operations: Dict[str, Response] = Dict() + _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) @@ -200,16 +199,11 @@ def _check_closed(self): async def _wrap_awaitable(self, aw: Awaitable[T], hooks: TaskHookType) -> T: await self.ready() try: - if not hooks: - return await aw result = await aw - try: + if hooks: self._task_result(result, hooks) - except Exception: - self.log.exception("TaskHook error", obj={"result": result, "hooks": hooks, "aw": aw}) - raise except Exception: - self.log.exception("Task error", obj=aw) + self.log.exception("Task error", obj={"result": result, "hooks": hooks, "aw": aw}) raise else: return result @@ -272,8 +266,11 @@ def _on_custom_msg(self, _, msg: dict, buffers: list): try: c = json.loads(content) if "ipylab_PY" in c: - error = self._to_frontend_error(c) if "error" in c else None - self._pending_operations.pop(c["ipylab_PY"]).set(c.get("payload"), error) + op = self._pending_operations.pop(c["ipylab_PY"]) + if "error" in c: + op.set_exception(self._to_frontend_error(c)) + else: + 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: @@ -338,6 +335,11 @@ def ensure_run(self, aw: Callable | Awaitable | None) -> None: raise async def ready(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() if not self._ready: # type: ignore @@ -354,10 +356,22 @@ async def ready(self): await self._ready_event.wait() def on_ready(self, callback, remove=False): # noqa: FBT002 - if remove: - self._on_ready_callbacks.discard(callback) - else: - self._on_ready_callbacks.add(callback) + """Register a callback to execute when the application is ready. + + The callback will be executed only once. + + Parameters + ---------- + callback : callable + The callback to execute when the application is ready. + remove : bool, optional + If True, remove the callback from the list of callbacks. + By default, False. + """ + if not remove: + self._on_ready_callbacks.append(callback) + 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.""" @@ -383,7 +397,7 @@ def _ipylab_send(self, content, buffers: list | None = None): raise def to_task(self, aw: Awaitable[T], name: str | None = None, *, hooks: TaskHookType = None) -> Task[T]: - """Run aw in a task. + """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. @@ -398,9 +412,10 @@ def to_task(self, aw: Awaitable[T], name: str | None = None, *, hooks: TaskHookT """ self._check_closed() - task = asyncio.create_task(self._wrap_awaitable(aw, hooks), name=name) - self.ipylab_tasks.add(task) - task.add_done_callback(self._task_done_callback) + task = asyncio.eager_task_factory(asyncio.get_running_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 def operation( @@ -450,11 +465,11 @@ def operation( if toObject: content["toObject"] = toObject - self._pending_operations[ipylab_PY] = response = Response() + self._pending_operations[ipylab_PY] = op = asyncio.get_running_loop().create_future() async def _operation(content: dict): self._ipylab_send(content) - payload = await response.wait() + payload = await op return Transform.transform_payload(content["transform"], payload) return self.to_task(_operation(content), name=ipylab_PY, hooks=hooks) diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 25bb8996..4a0f0826 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -6,14 +6,13 @@ import contextlib import functools import inspect -from typing import TYPE_CHECKING, Any, Unpack +from typing import TYPE_CHECKING, Any, Unpack, override from ipywidgets import Widget, register from traitlets import Bool, Container, Dict, Instance, Unicode, UseEnum, default, observe import ipylab from ipylab import Ipylab -from ipylab._compat.typing import override from ipylab.commands import APP_COMMANDS_NAME, CommandPalette, CommandRegistry from ipylab.common import Fixed, IpylabKwgs, LastUpdatedDict, Obj, to_selector from ipylab.dialog import Dialog @@ -42,7 +41,7 @@ class App(Ipylab): _model_name = Unicode("JupyterFrontEndModel").tag(sync=True) ipylab_base = IpylabBase(Obj.IpylabModel, "app").tag(sync=True) version = Unicode(read_only=True).tag(sync=True) - vpath = Unicode(read_only=True).tag(sync=True) + _vpath = Unicode(read_only=True).tag(sync=True) per_kernel_widget_manager_detected = Bool(read_only=True).tag(sync=True) shell = Fixed(Shell) @@ -62,7 +61,7 @@ class App(Ipylab): @classmethod @override - def _single_key(cls, kwgs: dict): # noqa: ARG003 + def _single_key(cls, kwgs: dict): return "app" def close(self): @@ -78,8 +77,8 @@ def _default_logging_handler(self): @observe("_ready", "log_level") def _app_observe_ready(self, change): if change["name"] == "_ready" and self._ready: - assert self.vpath, "Vpath should always before '_ready'." # noqa: S101 - self._selector = to_selector(self.vpath) + assert self._vpath, "Vpath should always before '_ready'." # noqa: S101 + self._selector = to_selector(self._vpath) ipylab.plugin_manager.hook.autostart._call_history.clear() # type: ignore # noqa: SLF001 try: ipylab.plugin_manager.hook.autostart.call_historic( @@ -95,16 +94,46 @@ def _autostart_callback(self, result): @property def repr_info(self): - return {"vpath": self.vpath} + return {"vpath": self._vpath} @property def repr_log(self): "A representation to use when logging" return self.__class__.__name__ + @property + def vpath(self): + """The virtual path for this kernel. + + `vpath` is equivalent to the session `path` in the frontend. + + Raises + ------ + RuntimeError + If App is not ready. + + Returns + ------- + str + Virtual path to the application. + """ + if not self._ready: + msg = "`vpath` cannot not be accessed until app is ready." + raise RuntimeError(msg) + return self._vpath + @property def selector(self): - # Calling this before `_ready` is set will raise an attribute error. + """The default selector based on the `vpath` for this kernel. + + Raises + ------ + RuntimeError + If the application is not ready. + """ + if not self._ready: + msg = "`vpath` cannot not be accessed until app is ready." + raise RuntimeError(msg) return self._selector @override diff --git a/ipylab/log.py b/ipylab/log.py index b9f732d7..f7f2a5ba 100644 --- a/ipylab/log.py +++ b/ipylab/log.py @@ -6,13 +6,12 @@ import logging import weakref from enum import IntEnum, StrEnum -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, override from IPython.core.ultratb import FormattedTB from ipywidgets import CallbackDispatcher import ipylab -from ipylab._compat.typing import override if TYPE_CHECKING: from asyncio import Task @@ -101,7 +100,7 @@ def _add_logger(self, logger: logging.Logger): logger.addHandler(self) @override - def setLevel(self, level: LogLevel) -> None: # noqa: N802 + def setLevel(self, level: LogLevel) -> None: level = LogLevel(level) super().setLevel(level) for logger in self._loggers: diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py index 45c5aa31..c587b2ea 100644 --- a/ipylab/log_viewer.py +++ b/ipylab/log_viewer.py @@ -106,8 +106,8 @@ def close(self): def _observe_connections(self, _): 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}" - self.title.label = f"Log: {ipylab.app.vpath}" + self.info.value = f"Vpath: {ipylab.app._vpath}" # noqa: SLF001 + self.title.label = f"Log: {ipylab.app._vpath}" # noqa: SLF001 def _add_record(self, record: logging.LogRecord): self._records.append(record) diff --git a/ipylab/menu.py b/ipylab/menu.py index a08cfe33..3fd055df 100644 --- a/ipylab/menu.py +++ b/ipylab/menu.py @@ -3,13 +3,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, override from ipywidgets import TypedTuple from traitlets import Container, Instance, Union import ipylab -from ipylab._compat.typing import override from ipylab.commands import APP_COMMANDS_NAME, CommandRegistry from ipylab.common import Fixed, Obj from ipylab.connection import InfoConnection @@ -157,7 +156,7 @@ class MainMenu(Menu): @classmethod @override - def _single_key(cls, kwgs: dict): # noqa: ARG003 + def _single_key(cls, kwgs: dict): return cls def __init__(self): @@ -193,7 +192,7 @@ def add_item( selector="", submenu: MenuConnection | None = None, rank: float | None = None, - type: Literal["command", "submenu", "separator"] = "command", # noqa: A002 + type: Literal["command", "submenu", "separator"] = "command", args: dict | None = None, ) -> Task[MenuItemConnection]: """Add command, subitem or separator. @@ -201,7 +200,11 @@ def add_item( ref: https://jupyterlab.readthedocs.io/en/stable/extension/extension_points.html#context-menu """ - return self._add_item(command, submenu, rank, type, args, selector or ipylab.app.selector) + + async def add_item_(): + return await self._add_item(command, submenu, rank, type, args, selector or ipylab.app.selector) + + return self.to_task(add_item_()) @override def activate(self): diff --git a/ipylab/notification.py b/ipylab/notification.py index 576c39a8..cef27ca0 100644 --- a/ipylab/notification.py +++ b/ipylab/notification.py @@ -5,7 +5,7 @@ import inspect from enum import StrEnum -from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict +from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, override import traitlets from ipywidgets import TypedTuple, register @@ -13,7 +13,6 @@ import ipylab from ipylab import 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 diff --git a/ipylab/shell.py b/ipylab/shell.py index 96816073..3c7da52c 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -133,12 +133,13 @@ def add( if isinstance(obj, ipylab.Panel): hooks_["add_to_tuple_fwd"].append((obj, "connections")) args["ipy_model"] = obj.model_id - if isinstance(obj, DOMWidget): - obj.add_class(ipylab.app.selector.removeprefix(".")) else: args["evaluate"] = pack(obj) async def add_to_shell() -> ShellConnection: + vpath_ = ipylab.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) @@ -146,11 +147,11 @@ async def add_to_shell() -> ShellConnection: result = await result args["vpath"] = result else: - args["vpath"] = vpath or ipylab.app.vpath - if args["vpath"] != ipylab.app.vpath: + args["vpath"] = vpath or vpath_ + if args["vpath"] != vpath_: hooks_["trait_add_fwd"] = [("auto_dispose", False)] else: - args["vpath"] = ipylab.app.vpath + args["vpath"] = vpath_ return await self.operation("addToShell", {"args": args}, transform=Transform.connection, hooks=hooks_) @@ -191,14 +192,15 @@ 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 args = { - "path": ipylab.app.vpath, + "path": vpath, "insertMode": InsertMode(mode), "activate": activate, "ref": f"{pack(ref_)}.id", } kwgs = IpylabKwgs( - transform={"transform": Transform.connection, "cid": ConsoleConnection.to_cid(ipylab.app.vpath)}, + transform={"transform": Transform.connection, "cid": ConsoleConnection.to_cid(vpath)}, toObject=["args[ref]"], hooks={ "trait_add_rev": [(self, "console")], diff --git a/ipylab/simple_output.py b/ipylab/simple_output.py index ff6e2bd2..49a69da0 100644 --- a/ipylab/simple_output.py +++ b/ipylab/simple_output.py @@ -57,11 +57,11 @@ def _pack_outputs(self, outputs: tuple[dict[str, str] | Widget | str | TextDispl yield output elif isinstance(output, str): yield {"output_type": "stream", "name": "stdout", "text": output} - elif hasattr(output, "_repr_mimebundle_") and callable(output._repr_mimebundle_): # type: ignore - yield output._repr_mimebundle_() # type: ignore elif fmt: data, metadata = fmt(output) yield {"output_type": "display_data", "data": data, "metadata": metadata} + elif hasattr(output, "_repr_mimebundle_") and callable(output._repr_mimebundle_): # type: ignore + yield {"output_type": "display_data", "data": output._repr_mimebundle_()} # type: ignore else: yield {"output_type": "display_data", "data": repr(output)} diff --git a/ipylab/widgets.py b/ipylab/widgets.py index 09919c35..e61b87db 100644 --- a/ipylab/widgets.py +++ b/ipylab/widgets.py @@ -8,7 +8,7 @@ from ipywidgets import Box, DOMWidget, Layout, TypedTuple, register, widget_serialization from ipywidgets.widgets.trait_types import InstanceDict -from traitlets import Container, Dict, Instance, Unicode, observe +from traitlets import Container, Dict, Instance, Tuple, Unicode, observe import ipylab import ipylab._frontend as _fe @@ -111,3 +111,29 @@ async def force_refresh(children): return ipylab.app.to_task(force_refresh(self.children)) # ============== End temp fix ============= + + +@register +class ResizeBox(Box): + """A box that can be resized. + + All views of the box are resizeable via the handle on the bottom right corner. + When a view is resized the other views are also resized to the same width and height. + The `size` trait of this object provides the size in pixels as (client width, client height). + + Reference + --------- + * [width](https://developer.mozilla.org/en-US/docs/Web/CSS/width) + * [height](https://developer.mozilla.org/en-US/docs/Web/CSS/height) + * [client width](https://developer.mozilla.org/en-US/docs/Web/API/Element/clientWidth) + * [client height](https://developer.mozilla.org/en-US/docs/Web/API/Element/clientHeight) + """ + + _model_name = Unicode("ResizeBoxModel").tag(sync=True) + _view_name = Unicode("ResizeBoxView").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) + + size: Container[tuple[int, int]] = Tuple(readonly=True, help="(clientWidth, clientHeight) in pixels").tag(sync=True) diff --git a/pyproject.toml b/pyproject.toml index e19467bc..4508aafb 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.11" +requires-python = ">=3.12" classifiers = [ "Framework :: Jupyter", "Framework :: Jupyter :: JupyterLab", @@ -20,18 +20,16 @@ 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", ] dependencies = [ - "jupyterlab>=4.1", + "jupyterlab>=4.3", "ipywidgets>=8.1.5", - "ipython>=8.2", + "ipython>=8.32", "jupyterlab_widgets>=3.0.11", - "pluggy~=1.1", - "typing_extensions; python_version < '3.12'", + "pluggy~=1.5", ] dynamic = ["version", "description", "authors", "urls", "keywords"] @@ -113,3 +111,6 @@ write = true ignore = ["BLE001", "N803"] [tool.ruff.format] docstring-code-format = true + +[tool.ruff.lint.per-file-ignores] +"tests*" = ['ARG002', 'SLF001', 'S101', 'PLR2004'] diff --git a/src/widget.ts b/src/widget.ts index 40329b9e..d3884790 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -12,6 +12,7 @@ import { IconModel, IconView } from './widgets/icon'; import { IpylabModel } from './widgets/ipylab'; import { NotificationManagerModel } from './widgets/notification'; import { PanelModel, PanelView } from './widgets/panel'; +import { ResizeBoxModel, ResizeBoxView } from './widgets/resize_box'; import { SessionManagerModel } from './widgets/sessions'; import { ShellModel } from './widgets/shell'; import { SimpleOutputModel, SimpleOutputView } from './widgets/simple_output'; @@ -33,6 +34,8 @@ export { NotificationManagerModel, PanelModel, PanelView, + ResizeBoxModel, + ResizeBoxView, SessionManagerModel, ShellConnectionModel, ShellModel, diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts index d82e5cfc..42b394fd 100644 --- a/src/widgets/frontend.ts +++ b/src/widgets/frontend.ts @@ -7,6 +7,7 @@ import { Kernel } from '@jupyterlab/services'; import { PromiseDelegate } from '@lumino/coreutils'; import { IpylabModel } from './ipylab'; +const VPATH = '_vpath'; /** * JupyterFrontEndModel (JFEM) is a SINGLETON per kernel. */ @@ -26,7 +27,7 @@ export class JupyterFrontEndModel extends IpylabModel { async ipylabInit(base: any = null) { const vpath = await JFEM.getVpath(this.kernelId); - this.set('vpath', vpath); + this.set(VPATH, vpath); this.set('version', JFEM.app.version); this.set('per_kernel_widget_manager_detected', JFEM.PER_KERNEL_WM); await super.ipylabInit(base); @@ -38,7 +39,7 @@ export class JupyterFrontEndModel extends IpylabModel { close(comm_closed?: boolean): Promise { Private.jfems.delete(this.kernelId); - Private.vpathTojfem.delete(this.get('vpath')); + Private.vpathTojfem.delete(this.get(VPATH)); return super.close(comm_closed); } @@ -133,8 +134,16 @@ export class JupyterFrontEndModel extends IpylabModel { reject(msg); }, 10000); Private.vpathTojfem.get(vpath).promise.then(jfem => { - clearTimeout(timeoutID); - resolve(jfem); + if (!jfem.commAvailable) { + jfem.close(); + JupyterFrontEndModel.getModelByVpath(vpath).then(jfem => { + clearTimeout(timeoutID); + resolve(jfem); + }); + } else { + clearTimeout(timeoutID); + resolve(jfem); + } }); }); } diff --git a/src/widgets/resize_box.ts b/src/widgets/resize_box.ts new file mode 100644 index 00000000..6a6a6e21 --- /dev/null +++ b/src/widgets/resize_box.ts @@ -0,0 +1,91 @@ +// Copyright (c) ipylab contributors +// Distributed under the terms of the Modified BSD License. + +import { BoxModel, BoxView } from '@jupyter-widgets/controls'; +import { MODULE_NAME, MODULE_VERSION } from '../version'; + +/** + * The model for a Resizeable box. + */ +export class ResizeBoxModel extends BoxModel { + /** + * The default attributes. + */ + defaults(): Backbone.ObjectHash { + return { + ...super.defaults(), + _model_name: 'ResizeBoxModel', + _model_module: MODULE_NAME, + _model_module_version: MODULE_VERSION, + _view_name: 'ResizeBoxView', + _view_module: MODULE_NAME, + _view_module_version: MODULE_VERSION + }; + } + _resizing = false; +} + +/** + * The view for a Resizeable box. + */ +export class ResizeBoxView extends BoxView { + initialize(parameters: any): void { + super.initialize(parameters); + this.luminoWidget.removeClass('widget-box'); + this.luminoWidget.removeClass('jupyter-widgets'); + this.luminoWidget.addClass('ipylab-ResizeBox'); + this.resize(); + this.sizeObserver = new ResizeObserver(() => { + if (!this.model._resizing && !this._resizing) { + const clientWidth = this.el.clientWidth; + const clientHeight = this.el.clientHeight; + const width = this.el.style.width; + const height = this.el.style.height; + try { + this.model._resizing = true; + this._resizing = true; + if (clientWidth && clientHeight) { + this.model.set('size', [clientWidth, clientHeight]); + } + if (width && height) { + this.model.set('width_height', [width, height]); + } + if ((width && height) || (clientWidth && clientHeight)) { + this.model.save_changes(); + } + } finally { + this._resizing = false; + this.model._resizing = false; + } + if (!width && !height) { + this.resize(); + } + } + }); + this.sizeObserver.observe(this.el); + this.listenTo(this.model, 'change:width_height', this.resize); + } + + resize() { + if (this._resizing) { + return; + } + const [width, height] = this.model.get('width_height') ?? [null, null]; + if (width && height) { + this._resizing = true; + this.el.style.width = width; + this.el.style.height = height; + this._resizing = false; + } + } + + remove(): any { + this?.sizeObserver?.disconnect(); + this.stopListening(this.model, 'change:size', this.resize); + super.remove(); + } + + sizeObserver: ResizeObserver; + model: ResizeBoxModel; + _resizing = false; +} diff --git a/src/widgets/simple_output.ts b/src/widgets/simple_output.ts index aca75a38..a2c246ac 100644 --- a/src/widgets/simple_output.ts +++ b/src/widgets/simple_output.ts @@ -130,6 +130,11 @@ export class SimpleOutputModel extends IpylabModel { } export class SimpleOutputView extends DOMWidgetView { + initialize(parameters: any): void { + super.initialize(parameters); + this.luminoWidget.removeClass('jupyter-widgets'); + this.luminoWidget.addClass('ipylab-SimpleOutput'); + } _createElement(tagName: string): HTMLElement { this.luminoWidget = new IpylabSimplifiedOutputArea({ view: this, diff --git a/style/widget.css b/style/widget.css index 00832162..02b976fc 100644 --- a/style/widget.css +++ b/style/widget.css @@ -32,7 +32,16 @@ width: 100%; height: 100%; overflow: auto; +} - /* overflow-x: auto; - overflow-y: auto; */ +.ipylab-SimpleOutput { + box-sizing: border-box; + display: inline block; + overflow: auto; +} +.ipylab-ResizeBox { + box-sizing: border-box; + display: inline block; + resize: both; + overflow: hidden; } diff --git a/tests/conftest.py b/tests/conftest.py index 21f26fb2..1b84d3e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ import pytest +import ipylab + @pytest.fixture(scope="session") def anyio_backend(): @@ -9,3 +11,10 @@ def anyio_backend(): @pytest.fixture(autouse=True) async def anyio_backend_autouse(anyio_backend): return anyio_backend + + +@pytest.fixture +async def app(mocker): + app = ipylab.app + mocker.patch.object(app, "ready") + return app diff --git a/tests/test_ipylab.py b/tests/test_ipylab.py new file mode 100644 index 00000000..72202375 --- /dev/null +++ b/tests/test_ipylab.py @@ -0,0 +1,50 @@ +from unittest.mock import AsyncMock, MagicMock + +from ipylab.ipylab import Ipylab +from ipylab.jupyterfrontend import App + + +class TestOnReady: + async def test_on_ready_add_and_remove(self): + obj = Ipylab() + callback = MagicMock() + + # Add the callback + obj.on_ready(callback) + assert callback in obj._on_ready_callbacks + + # Simulate the ready event + obj.set_trait("_ready", True) + callback.assert_called() + + callback.reset_mock() + obj.set_trait("_ready", False) + obj.set_trait("_ready", True) + callback.assert_called() + + # Reset the mock and remove the callback + callback.reset_mock() + obj.on_ready(callback, remove=True) + assert callback not in obj._on_ready_callbacks + + # Simulate the ready event again, callback should not be called + obj.set_trait("_ready", False) + obj.set_trait("_ready", True) + callback.assert_not_called() + + obj.close() + + async def test_on_ready_async(self, app: App): + obj = Ipylab() + callback = AsyncMock() + + # Add the callback + obj.on_ready(callback) + assert callback in obj._on_ready_callbacks + + # 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 + obj.close() diff --git a/tests/test_jupyterfrontend.py b/tests/test_jupyterfrontend.py index 1e7164cb..4555de73 100644 --- a/tests/test_jupyterfrontend.py +++ b/tests/test_jupyterfrontend.py @@ -101,7 +101,7 @@ async def test_app_evaluate(kw: dict[str, Any], result, mocker): fe_msg = {"ipylab": json.dumps(data)} # Simulate the message arriving in kernel and being processed - task2 = app._on_custom_msg(None, fe_msg, []) # noqa: SLF001 + task2 = app._on_custom_msg(None, fe_msg, []) assert isinstance(task2, asyncio.Task) async with asyncio.timeout(1): await task2