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