diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a5e2abbf..5be6c44e 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -50,7 +50,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v4
with:
- python-version: '3.12'
+ python-version: '3.11'
architecture: 'x64'
- uses: actions/download-artifact@v4
with:
diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml
index 491fb9e1..c6a19a2c 100644
--- a/.github/workflows/packaging.yml
+++ b/.github/workflows/packaging.yml
@@ -50,12 +50,10 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu, macos, windows]
- python: ['3.12', '3.13']
+ python: ['3.11', '3.13']
include:
- - python: '3.12'
+ - python: '3.11'
dist: 'ipylab*.tar.gz'
- - python: '3.13'
- dist: 'ipylab*.whl'
- os: windows
py_cmd: python
pip_cmd: python -m pip
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ed4fc5d9..b64a4ea7 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.3
+ rev: 0.32.1
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.10
+ rev: v0.11.4
hooks:
- id: ruff
types_or: [python, jupyter]
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 5e55950f..1798bf64 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -13,7 +13,6 @@
"editor.formatOnSave": true,
"python.terminal.activateEnvInCurrentTerminal": true,
"python.createEnvironment.trigger": "prompt",
- "python.analysis.typeCheckingMode": "basic",
"python.testing.pytestArgs": ["tests"],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
diff --git a/README.md b/README.md
index 92d6274a..d8736998 100644
--- a/README.md
+++ b/README.md
@@ -87,9 +87,7 @@ These versions enable:
- Viewing widgets from kernels inside from other kernels.
```bash
-# For per-kernel-widget-manager support (Install modified version of ipywidgets, jupyterlab_widgets & widgetsnbextension)
-
-pip install --no-binary --force-reinstall ipylab[per-kernel-widget-manager]
+pip install --no-binary --force-reinstall ipylab
```
## Running the examples locally
@@ -115,13 +113,13 @@ jupyter lab
```bash
# create a new conda environment
-mamba create -n ipylab -c conda-forge nodejs python=3.11 -y
+conda create -n ipylab -c conda-forge nodejs python=3.11 -y
# activate the environment
conda activate ipylab
# install the Python package
-pip install -e .[dev,per-kernel-widget-manager,test] # (with per-kernel-widget-manager)
+pip install -e .[dev,test,examples]
# link the extension files
jupyter labextension develop . --overwrite
@@ -142,6 +140,10 @@ jlpm lint
#or
jlpm lint:check
+# Pyright
+
+pip install pyright[nodejs]
+pyright
```
### VS code debugging
diff --git a/examples/code_editor.ipynb b/examples/code_editor.ipynb
index 45bd1916..3cb0d39c 100644
--- a/examples/code_editor.ipynb
+++ b/examples/code_editor.ipynb
@@ -33,7 +33,7 @@
"metadata": {},
"outputs": [],
"source": [
- "import asyncio\n",
+ "import anyio\n",
"\n",
"import ipylab\n",
"from ipylab.code_editor import CodeEditorOptions"
@@ -51,12 +51,13 @@
" mime_type=\"text/x-python\",\n",
" description=\"Code editor\",\n",
" tooltip=\"This is a code editor. Code completion is provided for Python\",\n",
- " value=\"def test():\\n ipylab.app.notification.notify('CodeEditor evaluation')\\n\\n# Place the cursor in the CodeEditor and press `Shift Enter`\\ntest()\",\n",
+ " value=\"def test():\\n app.notification.notify('CodeEditor evaluation')\\n\\n# Place the cursor in the CodeEditor and press `Shift Enter`\\ntest()\",\n",
" layout={\"height\": \"120px\", \"overflow\": \"hidden\"},\n",
" description_allow_html=True,\n",
")\n",
- "asyncio.get_event_loop().call_later(0.5, ce.focus)\n",
- "ce"
+ "display(ce)\n",
+ "await ce.ready()\n",
+ "ce.focus()"
]
},
{
@@ -123,12 +124,11 @@
"\n",
"\n",
"async def test():\n",
- " import asyncio\n",
" import random\n",
"\n",
" for _ in range(20):\n",
" ce.value = random.choice(values) # noqa: S311\n",
- " await asyncio.sleep(random.randint(10, 300) / 1e3) # noqa: S311"
+ " await anyio.sleep(random.randint(10, 300) / 1e3) # noqa: S311"
]
},
{
@@ -148,7 +148,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = ce.to_task(test())"
+ "await test()"
]
},
{
@@ -157,16 +157,6 @@
"id": "11",
"metadata": {},
"outputs": [],
- "source": [
- "t.cancel()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "12",
- "metadata": {},
- "outputs": [],
"source": [
"# Place the label above\n",
"ce.layout.flex_flow = \"column\""
@@ -175,17 +165,17 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "13",
+ "id": "12",
"metadata": {},
"outputs": [],
"source": [
"# Add the same editor to the shell.\n",
- "ipylab.app.shell.add(ce)"
+ "await ce.app.shell.add(ce)"
]
},
{
"cell_type": "markdown",
- "id": "14",
+ "id": "13",
"metadata": {},
"source": [
"### Other mime_types\n",
@@ -196,7 +186,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "15",
+ "id": "14",
"metadata": {},
"outputs": [],
"source": [
@@ -206,7 +196,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "16",
+ "id": "15",
"metadata": {},
"outputs": [],
"source": [
@@ -230,7 +220,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.10"
+ "version": "3.12.9"
}
},
"nbformat": 4,
diff --git a/examples/commands.ipynb b/examples/commands.ipynb
index a4d181e4..953a2a80 100644
--- a/examples/commands.ipynb
+++ b/examples/commands.ipynb
@@ -24,8 +24,8 @@
"source": [
"import ipylab\n",
"\n",
- "app = ipylab.app\n",
- "app.commands"
+ "app = await ipylab.App().ready()\n",
+ "await app.commands.ready()"
]
},
{
@@ -50,7 +50,8 @@
"metadata": {},
"outputs": [],
"source": [
- "app.commands.all_commands"
+ "out = ipylab.SimpleOutput(layout={\"height\": \"200px\", \"overflow\": \"auto\"}).add_class(\"ipylab-ResizeBox\")\n",
+ "out.push(app.commands.all_commands)"
]
},
{
@@ -66,7 +67,7 @@
"metadata": {},
"outputs": [],
"source": [
- "app.commands.execute(\n",
+ "await app.commands.execute(\n",
" \"console:create\",\n",
" {\n",
" \"insertMode\": \"split-right\",\n",
@@ -90,7 +91,7 @@
"metadata": {},
"outputs": [],
"source": [
- "app.commands.execute(\"apputils:change-theme\", {\"theme\": \"JupyterLab Dark\"})"
+ "await app.commands.execute(\"apputils:change-theme\", {\"theme\": \"JupyterLab Dark\"})"
]
},
{
@@ -99,7 +100,7 @@
"metadata": {},
"outputs": [],
"source": [
- "app.commands.execute(\"apputils:change-theme\", {\"theme\": \"JupyterLab Light\"})"
+ "await app.commands.execute(\"apputils:change-theme\", {\"theme\": \"JupyterLab Light\"})"
]
},
{
@@ -115,7 +116,7 @@
"metadata": {},
"outputs": [],
"source": [
- "app.commands.execute(\"terminal:create-new\")"
+ "await app.commands.execute(\"terminal:create-new\")"
]
},
{
@@ -128,7 +129,7 @@
"\n",
"See https://github.com/bqplot/bqplot/blob/master/examples/Advanced%20Plotting/Animations.ipynb for more details.\n",
"\n",
- "Note: This requires bqplot to be installed, which may require Jupyterlab to be restarted if it hasn't already been installed."
+ "Note: This requires bqplot and numpy to be installed, which may require Jupyterlab to be restarted if it hasn't already been installed."
]
},
{
@@ -142,7 +143,7 @@
"\n",
"import ipylab\n",
"\n",
- "app = ipylab.app"
+ "app = ipylab.App()"
]
},
{
@@ -168,7 +169,7 @@
"\n",
"fig = Figure(marks=[bar, line], axes=[xax, yax1, yax2], animation_duration=1000)\n",
"panel = ipylab.Panel([fig])\n",
- "panel.add_to_shell(mode=ipylab.InsertMode.split_right)"
+ "await panel.add_to_shell(mode=ipylab.InsertMode.split_right)"
]
},
{
@@ -213,7 +214,9 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.commands.add_command(\"update_data\", execute=update_data, label=\"Update Data\", icon_class=\"jp-PythonIcon\")"
+ "cmd = await app.commands.add_command(\n",
+ " \"update_data\", execute=update_data, label=\"Update Data\", icon_class=\"jp-PythonIcon\"\n",
+ ")"
]
},
{
@@ -222,7 +225,7 @@
"metadata": {},
"outputs": [],
"source": [
- "app.commands.execute(\"update_data\")"
+ "await app.commands.execute(\"update_data\")"
]
},
{
@@ -249,7 +252,6 @@
"metadata": {},
"outputs": [],
"source": [
- "cmd = t.result()\n",
"command_id = str(cmd)"
]
},
@@ -282,7 +284,7 @@
"metadata": {},
"outputs": [],
"source": [
- "app.command_pallet.add(command=cmd, category=\"Python Commands\")"
+ "await app.command_pallet.add(command=cmd, category=\"Python Commands\")"
]
},
{
@@ -305,7 +307,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.context_menu.add_item(command=cmd)"
+ "cm = await app.context_menu.add_item(command=cmd)"
]
},
{
@@ -315,15 +317,6 @@
"Right click on the plot to open the context menu."
]
},
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "cm = t.result()"
- ]
- },
{
"cell_type": "markdown",
"metadata": {},
@@ -340,9 +333,7 @@
"outputs": [],
"source": [
"# Use uppercase\n",
- "t = cmd.add_key_binding(\n",
- " [\"U\"],\n",
- ")"
+ "kb = await cmd.add_key_binding([\"U\"])"
]
},
{
@@ -351,7 +342,6 @@
"metadata": {},
"outputs": [],
"source": [
- "kb = t.result()\n",
"kb in cmd.key_bindings"
]
},
@@ -379,7 +369,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = cmd.add_key_binding([\"Ctrl 1\"], selector=\".jp-ThemedContainer\")"
+ "await cmd.add_key_binding([\"Ctrl 1\"], selector=\".jp-ThemedContainer\")"
]
},
{
@@ -417,6 +407,15 @@
"\n",
"See [menu->limiting scope](menu.ipynb#Limiting-scope) for an example using one."
]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "panel.close()"
+ ]
}
],
"metadata": {
@@ -435,7 +434,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.10"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/css_stylesheet.ipynb b/examples/css_stylesheet.ipynb
index df732f29..c1151079 100644
--- a/examples/css_stylesheet.ipynb
+++ b/examples/css_stylesheet.ipynb
@@ -1,12 +1,5 @@
{
"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": "code",
"execution_count": null,
@@ -38,7 +31,7 @@
"import ipylab\n",
"from ipylab.css_stylesheet import CSSStyleSheet\n",
"\n",
- "app = ipylab.app"
+ "app = ipylab.App()"
]
},
{
@@ -51,10 +44,10 @@
"ss = CSSStyleSheet()\n",
"\n",
"# Set a css variable\n",
- "t = ss.set_variables({\"--ipylab-custom\": \"orange\"}) # Demonstrate setting a variable\n",
+ "await ss.set_variables({\"--ipylab-custom\": \"orange\"}) # Demonstrate setting a variable\n",
"\n",
"# Define some new css\n",
- "t = ss.replace(\"\"\"\n",
+ "await ss.replace(\"\"\"\n",
".resize-both { resize: both; border: solid 2px var(--ipylab-custom);}\n",
".resize-horizontal { resize: horizontal; border: solid 2px blue;}\n",
"\"\"\") # Define the stylesheet"
@@ -83,16 +76,6 @@
"ipw.Box([b, bb])"
]
},
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "# Rules are updated as the operations complete.\n",
- "ss.css_rules"
- ]
- },
{
"cell_type": "markdown",
"metadata": {},
@@ -106,16 +89,7 @@
"metadata": {},
"outputs": [],
"source": [
- "ss.insert_rule(\".jp-MainAreaWidget { border: 2px double blue; }\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "ss.css_rules"
+ "await ss.insert_rule(\".jp-MainAreaWidget { border: 2px double blue; }\")"
]
},
{
@@ -124,7 +98,7 @@
"metadata": {},
"outputs": [],
"source": [
- "ss.delete_rule(0)"
+ "await ss.delete_rule(0)"
]
},
{
@@ -161,16 +135,8 @@
"metadata": {},
"outputs": [],
"source": [
- "t = ss.get_variables()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
+ "variables = await ss.get_variables()\n",
+ "ipylab.SimpleOutput(layout={\"max_height\": \"200px\"}).push(variables)"
]
},
{
@@ -190,7 +156,7 @@
"outputs": [],
"source": [
"ss = CSSStyleSheet()\n",
- "ss.replace(\"\"\"\n",
+ "await ss.replace(\"\"\"\n",
"/* Modify Jupyter Styles */\n",
"\n",
".lm-BoxPanel-child,\n",
@@ -236,13 +202,6 @@
"# Revert\n",
"ss.close()"
]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": []
}
],
"metadata": {
@@ -261,7 +220,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.12.9"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/dialogs.ipynb b/examples/dialogs.ipynb
index aca38339..0db24f0f 100644
--- a/examples/dialogs.ipynb
+++ b/examples/dialogs.ipynb
@@ -1,12 +1,5 @@
{
"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": "code",
"execution_count": null,
@@ -23,19 +16,6 @@
"# Panels and Widgets"
]
},
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Warning for notebooks and consoles\n",
- "\n",
- "**Do not try to await tasks returned from any ipylab methods, doing so block forever preventing further execution.**\n",
- "\n",
- "This happens because Ipylab employs custom messages over widget comms and widget comms is blocked during cell execution (in the default kernel and server).\n",
- "\n",
- "see [Plugins](plugins.ipynb#Example-launching-a-small-app) or [Actions](widgets.ipynb#Notification-Actions) for an example of awaiting the tasks in a coroutine."
- ]
- },
{
"cell_type": "code",
"execution_count": null,
@@ -46,7 +26,7 @@
"\n",
"import ipylab\n",
"\n",
- "app = ipylab.app"
+ "app = await ipylab.App().ready()"
]
},
{
@@ -75,16 +55,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.dialog.get_item(\"Select an item\", [1, 2, 3])"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
+ "await app.dialog.get_item(\"Select an item\", [1, 2, 3])"
]
},
{
@@ -100,16 +71,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.dialog.get_boolean(\"Select boolean value\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
+ "await app.dialog.get_boolean(\"Select boolean value\")"
]
},
{
@@ -125,16 +87,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.dialog.get_number(\"Provide a numeric value\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
+ "await app.dialog.get_number(\"Provide a numeric value\")"
]
},
{
@@ -150,16 +103,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.dialog.get_text(\"Enter text\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
+ "await app.dialog.get_text(\"Enter text\")"
]
},
{
@@ -177,16 +121,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.dialog.get_password(\"Provide a password\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
+ "await app.dialog.get_password(\"Provide a password\")"
]
},
{
@@ -195,7 +130,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.dialog.show_dialog(\n",
+ "await app.dialog.show_dialog(\n",
" \"A custom dialog\",\n",
" \"It returns the raw result, and there is no cancellation\",\n",
" options={\n",
@@ -234,15 +169,6 @@
")"
]
},
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
- ]
- },
{
"cell_type": "markdown",
"metadata": {},
@@ -256,7 +182,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.dialog.show_dialog(body=ipw.VBox([ipw.HTML(\"SomeTitle\"), ipw.FloatSlider()]))"
+ "await app.dialog.show_dialog(body=ipw.VBox([ipw.HTML(\"SomeTitle\"), ipw.FloatSlider()]))"
]
},
{
@@ -265,7 +191,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.dialog.show_error_message(\n",
+ "await app.dialog.show_error_message(\n",
" \"My error\",\n",
" \"Please acknowledge\",\n",
" options={\n",
@@ -286,15 +212,6 @@
")"
]
},
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
- ]
- },
{
"cell_type": "markdown",
"metadata": {},
@@ -308,25 +225,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.dialog.get_open_files()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t = app.dialog.get_existing_directory()"
+ "await app.dialog.get_open_files()"
]
},
{
@@ -335,7 +234,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t.result()"
+ "await app.dialog.get_existing_directory()"
]
}
],
@@ -355,7 +254,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.10"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/generic.ipynb b/examples/generic.ipynb
index b4a058a0..aa8160ef 100644
--- a/examples/generic.ipynb
+++ b/examples/generic.ipynb
@@ -1,12 +1,5 @@
{
"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": "code",
"execution_count": null,
@@ -18,7 +11,9 @@
},
{
"cell_type": "markdown",
- "metadata": {},
+ "metadata": {
+ "jp-MarkdownHeadingCollapsed": true
+ },
"source": [
"# Ipylab\n",
"\n",
@@ -98,19 +93,9 @@
"source": [
"import ipylab\n",
"\n",
- "app = ipylab.app\n",
+ "app = await ipylab.App().ready()\n",
"\n",
- "t = app.list_properties(\"shell\", depth=2)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "methods = t.result()\n",
- "methods"
+ "await app.list_properties(\"shell\", depth=2)"
]
},
{
@@ -154,7 +139,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.shell.expand_right() # Built in"
+ "await app.shell.expand_right() # Built in"
]
},
{
@@ -163,7 +148,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.shell.execute_method(\"collapseRight\") # Generic"
+ "await app.shell.execute_method(\"collapseRight\") # Generic"
]
},
{
@@ -190,7 +175,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.12.7"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/icons.ipynb b/examples/icons.ipynb
index 89ad91ba..b1ef3cd7 100644
--- a/examples/icons.ipynb
+++ b/examples/icons.ipynb
@@ -4,21 +4,13 @@
"cell_type": "markdown",
"id": "0",
"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",
- "id": "1",
- "metadata": {},
"source": [
"# Icons"
]
},
{
"cell_type": "markdown",
- "id": "2",
+ "id": "1",
"metadata": {},
"source": [
"Icons can be applied to both the `Title` of a `Panel` [widgets](./widgets.ipynb) and [commands](./commands.ipynb), providing more customization than `icon_class`."
@@ -27,7 +19,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "3",
+ "id": "2",
"metadata": {},
"outputs": [],
"source": [
@@ -37,7 +29,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "4",
+ "id": "3",
"metadata": {
"tags": []
},
@@ -48,12 +40,14 @@
"import ipywidgets as ipw\n",
"import traitlets\n",
"\n",
- "import ipylab"
+ "import ipylab\n",
+ "\n",
+ "app = await ipylab.App().ready()"
]
},
{
"cell_type": "markdown",
- "id": "5",
+ "id": "4",
"metadata": {},
"source": [
"## SVG\n",
@@ -70,7 +64,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "6",
+ "id": "5",
"metadata": {
"tags": []
},
@@ -84,7 +78,7 @@
},
{
"cell_type": "markdown",
- "id": "7",
+ "id": "6",
"metadata": {},
"source": [
"Icons can be displayed directly, and sized with the `layout` member inherited from `ipywidgets.DOMWidget`."
@@ -93,7 +87,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8",
+ "id": "7",
"metadata": {
"tags": []
},
@@ -105,7 +99,7 @@
},
{
"cell_type": "markdown",
- "id": "9",
+ "id": "8",
"metadata": {},
"source": [
"### More about `jp-icon` classes\n",
@@ -115,7 +109,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "10",
+ "id": "9",
"metadata": {
"tags": []
},
@@ -136,7 +130,7 @@
},
{
"cell_type": "markdown",
- "id": "11",
+ "id": "10",
"metadata": {},
"source": [
"## Icons on Panel Titles\n",
@@ -147,7 +141,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "12",
+ "id": "11",
"metadata": {
"tags": []
},
@@ -156,12 +150,12 @@
"panel = ipylab.Panel([icon_controls])\n",
"panel.title.icon = icon\n",
"traitlets.dlink((background, \"value\"), (panel.title, \"label\"))\n",
- "panel.add_to_shell(mode=ipylab.InsertMode.split_right)"
+ "await panel.add_to_shell(mode=ipylab.InsertMode.split_right)"
]
},
{
"cell_type": "markdown",
- "id": "13",
+ "id": "12",
"metadata": {},
"source": [
"### More Title Options\n",
@@ -172,7 +166,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "14",
+ "id": "13",
"metadata": {
"tags": []
},
@@ -204,7 +198,7 @@
},
{
"cell_type": "markdown",
- "id": "15",
+ "id": "14",
"metadata": {},
"source": [
"## Icons on Commands\n",
@@ -215,62 +209,48 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "16",
+ "id": "15",
"metadata": {
"tags": []
},
"outputs": [],
"source": [
- "import asyncio\n",
"import random\n",
"\n",
+ "import anyio\n",
+ "\n",
"\n",
"async def randomize_icon(count=10):\n",
" for _ in range(count):\n",
" background.value = random.choice(options) # noqa: S311\n",
- " await asyncio.sleep(0.1)"
+ " await anyio.sleep(0.1)"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "17",
+ "id": "16",
"metadata": {
"tags": []
},
"outputs": [],
"source": [
- "t = ipylab.app.commands.add_command(\n",
- " \"randomize\",\n",
- " randomize_icon,\n",
- " label=\"Randomize My Icon\",\n",
- " icon=icon,\n",
- ")"
+ "cmd = await app.commands.add_command(\"randomize\", randomize_icon, label=\"Randomize My Icon\", icon=icon)"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "18",
- "metadata": {},
- "outputs": [],
- "source": [
- "cmd = t.result()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "19",
+ "id": "17",
"metadata": {},
"outputs": [],
"source": [
- "assert cmd in ipylab.app.commands.connections # noqa: S101"
+ "assert cmd in app.commands.connections # noqa: S101"
]
},
{
"cell_type": "markdown",
- "id": "20",
+ "id": "18",
"metadata": {},
"source": [
"We can use methods on `cmd` (Connection for the cmd registered in the frontend) to add it to the command pallet, and create a launcher."
@@ -279,18 +259,18 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "21",
+ "id": "19",
"metadata": {
"tags": []
},
"outputs": [],
"source": [
- "t = ipylab.app.command_pallet.add(cmd, \"All My Commands\", rank=100)"
+ "await app.command_pallet.add(cmd, \"All My Commands\", rank=100)"
]
},
{
"cell_type": "markdown",
- "id": "22",
+ "id": "20",
"metadata": {},
"source": [
"Then open the _Command Palette_ (keyboard shortcut is `CTRL + SHIFT + C`)."
@@ -298,7 +278,7 @@
},
{
"cell_type": "markdown",
- "id": "23",
+ "id": "21",
"metadata": {},
"source": [
"And run 'Randomize my icon'"
@@ -321,7 +301,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.10"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/ipytree.ipynb b/examples/ipytree.ipynb
index f5c4f522..5eab5244 100644
--- a/examples/ipytree.ipynb
+++ b/examples/ipytree.ipynb
@@ -35,7 +35,7 @@
"\n",
"import ipylab\n",
"\n",
- "app = ipylab.app"
+ "app = ipylab.App()"
]
},
{
@@ -377,7 +377,7 @@
],
"metadata": {
"kernelspec": {
- "display_name": "Python 3",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
@@ -391,7 +391,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.8.2"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/menu.ipynb b/examples/menu.ipynb
index 8348fe98..1e06b9ce 100644
--- a/examples/menu.ipynb
+++ b/examples/menu.ipynb
@@ -1,12 +1,5 @@
{
"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": {},
@@ -30,7 +23,7 @@
"\n",
"import ipylab\n",
"\n",
- "app = ipylab.app"
+ "app = await ipylab.App().ready()"
]
},
{
@@ -39,7 +32,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.commands.create_menu(\"🌈 MY CUSTOM MENU 🎌\")"
+ "menu = await app.commands.create_menu(\"🌈 MY CUSTOM MENU 🎌\")"
]
},
{
@@ -55,8 +48,7 @@
"metadata": {},
"outputs": [],
"source": [
- "menu = t.result()\n",
- "app.main_menu.add_menu(menu)"
+ "await app.main_menu.add_menu(menu)"
]
},
{
@@ -88,7 +80,7 @@
" await menu.add_item(command=\"logconsole:open\")\n",
"\n",
" # Open it\n",
- " menu.activate()"
+ " await menu.activate()"
]
},
{
@@ -97,7 +89,7 @@
"metadata": {},
"outputs": [],
"source": [
- "app.to_task(populate_menu(menu))"
+ "await populate_menu(menu)"
]
},
{
@@ -115,16 +107,8 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.main_menu.file_menu.add_item(command=\"logconsole:open\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "app.main_menu.file_menu.activate()"
+ "mc = await app.main_menu.file_menu.add_item(command=\"logconsole:open\")\n",
+ "await app.main_menu.file_menu.activate()"
]
},
{
@@ -134,8 +118,8 @@
"outputs": [],
"source": [
"# Remove the menu item.\n",
- "mc = t.result()\n",
- "mc.close()"
+ "mc.close()\n",
+ "await app.main_menu.file_menu.activate()"
]
},
{
@@ -153,7 +137,7 @@
"metadata": {},
"outputs": [],
"source": [
- "app.context_menu.add_item(submenu=menu, type=\"submenu\")"
+ "submenu = await app.context_menu.add_item(submenu=menu, type=\"submenu\")"
]
},
{
@@ -171,7 +155,7 @@
"metadata": {},
"outputs": [],
"source": [
- "panel.add_to_shell(mode=ipylab.InsertMode.split_right)"
+ "await panel.add_to_shell(mode=ipylab.InsertMode.split_right)"
]
},
{
@@ -209,16 +193,8 @@
" await app.dialog.show_dialog(\"Show id\", f\"Widget id is {id_}\")\n",
"\n",
"\n",
- "t = app.commands.add_command(\"Show id\", show_id)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t = app.context_menu.add_item(command=t.result(), rank=1000, selector=\".jp-Notebook\")"
+ "cmd = await app.commands.add_command(\"Show id\", show_id)\n",
+ "mc = await app.context_menu.add_item(command=cmd, rank=1000, selector=\".jp-Notebook\")"
]
},
{
@@ -238,8 +214,10 @@
"metadata": {},
"outputs": [],
"source": [
- "cr = ipylab.commands.CommandRegistry(name=\"My command registry\")\n",
- "t = cr.create_menu(\"Extra commands\")"
+ "from ipylab.commands import CommandRegistry\n",
+ "\n",
+ "cr = CommandRegistry(name=\"My command registry\")\n",
+ "mc = await cr.create_menu(\"Extra commands\")"
]
},
{
@@ -258,47 +236,11 @@
"metadata": {},
"outputs": [],
"source": [
- "# MenuConnection\n",
- "mc = t.result()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t = cr.add_command(\n",
+ "cmd = await cr.add_command(\n",
" \"Open a dialog\", lambda app: app.dialog.show_dialog(\"Custom\", \"This is called from a custom registry\")\n",
- ")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "cmd = t.result()\n",
- "mc.add_item(command=cmd)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t = app.context_menu.add_item(submenu=mc, type=\"submenu\", selector=\".WithExtraCommands\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
+ ")\n",
+ "await mc.add_item(command=cmd)\n",
+ "await app.context_menu.add_item(submenu=mc, type=\"submenu\", selector=\".WithExtraCommands\")"
]
},
{
@@ -311,7 +253,7 @@
"b2 = ipw.HTML(\"
Context WITH extra commands
\", layout={\"border\": \"solid 3px green\"})\n",
"b2.add_class(\"WithExtraCommands\")\n",
"panel = ipylab.Panel([b1, b2])\n",
- "panel.add_to_shell()"
+ "await panel.add_to_shell()"
]
},
{
@@ -382,7 +324,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.10"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/notifications.ipynb b/examples/notifications.ipynb
index f8150634..46b279a0 100644
--- a/examples/notifications.ipynb
+++ b/examples/notifications.ipynb
@@ -1,12 +1,5 @@
{
"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": "code",
"execution_count": null,
@@ -30,20 +23,11 @@
"metadata": {},
"outputs": [],
"source": [
- "import asyncio\n",
+ "import anyio\n",
"\n",
"import ipylab\n",
"\n",
- "app = ipylab.app"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t = app.notification.notify(\"Updating soon\", ipylab.NotificationType.progress, auto_close=False)"
+ "app = await ipylab.App().ready()"
]
},
{
@@ -52,7 +36,7 @@
"metadata": {},
"outputs": [],
"source": [
- "nc = t.result()"
+ "nc = await app.notification.notify(\"Updating soon\", ipylab.NotificationType.progress, auto_close=False)"
]
},
{
@@ -66,11 +50,11 @@
" for i in range(1, n):\n",
" await nc.update(f\"Updating {n - i}\")\n",
" await nc.update(\"All done\", type=ipylab.NotificationType.success)\n",
- " await asyncio.sleep(1)\n",
+ " await anyio.sleep(1)\n",
" nc.close()\n",
"\n",
"\n",
- "t = nc.to_task(update())"
+ "await update()"
]
},
{
@@ -97,7 +81,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.notification.notify(\n",
+ "await app.notification.notify(\n",
" \"These buttons are linked to the Python callback.\",\n",
" actions=[\n",
" {\"label\": \"About\", \"caption\": \"Show help\", \"callback\": lambda: app.commands.execute(\"help:about\")},\n",
@@ -137,7 +121,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.10"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/plugins.ipynb b/examples/plugins.ipynb
index 376634c8..68602294 100644
--- a/examples/plugins.ipynb
+++ b/examples/plugins.ipynb
@@ -1,12 +1,5 @@
{
"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": "code",
"execution_count": null,
@@ -28,7 +21,9 @@
"* Manually registered with the `plugin_manager` or\n",
"* defined as an `entrypoint` for modules.\n",
"\n",
- "The advantage of using entry points is that the plugin is registered automatically and can run in the always running `iyplab` kernel. But requires the extra effort installing a module with the defined entry point."
+ "The advantage of using entry points is that the plugin is registered automatically and can run in the always running `iyplab` kernel. But requires the extra effort installing a module with the defined entry point.\n",
+ "\n",
+ "The following plugins (*hookspecs*) are available."
]
},
{
@@ -40,22 +35,25 @@
"# Existing hook specs\n",
"from IPython import display as ipd\n",
"\n",
+ "import ipylab\n",
"import ipylab.hookspecs\n",
"\n",
- "app = ipylab.app\n",
- "\n",
- "display(ipd.Markdown(\"## Plugins\\n\\nThe following plugins (*hookspecs*) are available.\"))\n",
+ "app = ipylab.App()\n",
+ "out = ipylab.SimpleOutput(layout={\"height\": \"300px\"}).add_class(\"ipylab-ResizeBox\")\n",
+ "out.push(ipd.Markdown(\"## Plugins\\n\\nThe following plugins (*hookspecs*) are available.\"))\n",
"for n in dir(ipylab.hookspecs):\n",
" f = getattr(ipylab.hookspecs, n)\n",
" if not hasattr(f, \"ipylab_spec\"):\n",
" continue\n",
- " display(ipd.Markdown(f\"### `{f.__name__}`\"))\n",
- " display(ipd.Markdown(f.__doc__))"
+ " out.push(ipd.Markdown(f\"### `{f.__name__}`\"), ipd.Markdown(f.__doc__))\n",
+ "out"
]
},
{
"cell_type": "markdown",
- "metadata": {},
+ "metadata": {
+ "jp-MarkdownHeadingCollapsed": true
+ },
"source": [
"## Autostart\n",
"\n",
@@ -122,7 +120,7 @@
" notify_type = ipw.Dropdown(description=\"Notify type\", options=ipylab.NotificationType)\n",
" notify_message = ipw.Combobox(placeholder=\"Enter message\")\n",
" notify_button = ipw.Button(description=\"Notify\")\n",
- " notify_button.on_click(lambda _: app.notification.notify(notify_message.value, notify_type.value)) # type: ignore\n",
+ " notify_button.on_click(lambda _: app.start_coro(app.notification.notify(notify_message.value, notify_type.value))) # type: ignore\n",
" box = ipw.HBox([notify_type, notify_message, notify_button], layout={\"align_content\": \"center\", \"flex\": \"1 0 auto\"})\n",
"\n",
" out = ipw.Output()\n",
@@ -138,9 +136,9 @@
" result = await app.dialog.show_dialog(\"Shutdown kernel?\")\n",
" if result[\"value\"]:\n",
" await app.notification.notify(\"Shutting down kernel\", type=ipylab.NotificationType.info)\n",
- " app.shutdown_kernel()\n",
+ " await app.shutdown_kernel()\n",
"\n",
- " app.to_task(shutdown())\n",
+ " app.start_coro(shutdown())\n",
"\n",
" # Add a plugin in this kernel. Instead of defining a class, you can also define a module eg: 'ipylab.lib.py'\n",
" class MyLocalPlugin:\n",
@@ -194,14 +192,8 @@
"outputs": [],
"source": [
"# Register the plugin\n",
- "ipylab.plugin_manager.register(pluginmodule, \"demo plugin\")"
- ]
- },
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "We should now have a launcher"
+ "ipylab.plugin_manager.register(pluginmodule, \"demo plugin\")\n",
+ "ipylab.SimpleOutput(layout={\"height\": \"200px\"}).add_class(\"ipylab-ResizeBox\").push(app.commands.all_commands)"
]
},
{
@@ -210,7 +202,8 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.commands.execute(\"launcher:create\")"
+ "# Show the button in the launcher\n",
+ "await app.commands.execute(\"launcher:create\")"
]
},
{
@@ -286,7 +279,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.10"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/resize_box.ipynb b/examples/resize_box.ipynb
index a06bce1a..19875c77 100644
--- a/examples/resize_box.ipynb
+++ b/examples/resize_box.ipynb
@@ -1,23 +1,16 @@
{
"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",
+ "`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",
+ "All views of the resize box are resizeable and synchronise to be 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."
]
@@ -87,15 +80,6 @@
"`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": {},
@@ -113,7 +97,7 @@
"import matplotlib.pyplot as plt\n",
"import numpy as np\n",
"\n",
- "mpl.use(\"module://ipympl.backend_nbagg\")\n",
+ "mpl.use(\"module://ipympl.backend_nbagg\") # ipympl\n",
"\n",
"x = np.linspace(0, 2 * np.pi, 200)\n",
"y = np.sin(x)\n",
@@ -141,15 +125,17 @@
"import ipylab\n",
"from ipylab.widgets import ResizeBox\n",
"\n",
+ "app = ipylab.App()\n",
+ "\n",
"box = ResizeBox([fig.canvas])\n",
- "fig.canvas.resizable = False\n",
+ "fig.canvas.resizable = False # type: ignore\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",
+ " dpi = canvas.figure.dpi # type: ignore\n",
" fig.set_size_inches(max((width) // dpi, 1), max((height) // dpi, 1))\n",
" fig.canvas.draw_idle()\n",
"\n",
@@ -174,8 +160,24 @@
"metadata": {},
"outputs": [],
"source": [
- "ipylab.app.shell.add(box)"
+ "sc = await ipylab.App().shell.add(box)"
]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "sc.close()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": []
}
],
"metadata": {
@@ -194,7 +196,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.12.9"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/sessions.ipynb b/examples/sessions.ipynb
index 3558fa50..2df873c0 100644
--- a/examples/sessions.ipynb
+++ b/examples/sessions.ipynb
@@ -1,12 +1,5 @@
{
"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": {},
@@ -31,7 +24,7 @@
"source": [
"import ipylab\n",
"\n",
- "app = ipylab.app"
+ "app = await ipylab.App().ready()"
]
},
{
@@ -47,16 +40,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.sessions.get_running()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "t.result()"
+ "await app.sessions.get_running()"
]
},
{
@@ -72,16 +56,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.sessions.get_current()"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "session = t.result()\n",
+ "session = await app.sessions.get_current()\n",
"session"
]
},
@@ -99,7 +74,7 @@
"metadata": {},
"outputs": [],
"source": [
- "app.commands.execute(\"console:create\", session)"
+ "await app.commands.execute(\"console:create\", session)"
]
}
],
@@ -119,7 +94,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.10"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/examples/simple_output.ipynb b/examples/simple_output.ipynb
index db4d1964..b78a49d4 100644
--- a/examples/simple_output.ipynb
+++ b/examples/simple_output.ipynb
@@ -53,8 +53,12 @@
"metadata": {},
"outputs": [],
"source": [
+ "import anyio\n",
+ "\n",
"import ipylab\n",
- "from ipylab.simple_output import SimpleOutput"
+ "from ipylab.simple_output import SimpleOutput\n",
+ "\n",
+ "app = ipylab.App()"
]
},
{
@@ -166,33 +170,25 @@
"outputs": [],
"source": [
"so = SimpleOutput()\n",
- "t = so.set(\"Line one\\n\", \"Line two\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "16",
- "metadata": {},
- "outputs": [],
- "source": [
+ "res = await so.set(\"Line one\\n\", \"Line two\")\n",
"so"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "17",
+ "id": "16",
"metadata": {},
"outputs": [],
"source": [
- "assert so.length == t.result() # noqa: S101\n",
+ "await anyio.sleep(0.1)\n",
+ "assert so.length == res # noqa: S101\n",
"so.length"
]
},
{
"cell_type": "markdown",
- "id": "18",
+ "id": "17",
"metadata": {},
"source": [
"## max_continuous_streams and max_outputs\n",
@@ -207,38 +203,21 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "19",
+ "id": "18",
"metadata": {},
"outputs": [],
"source": [
"# Make each stream go into a new output.\n",
- "so.max_continuous_streams = 0"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "20",
- "metadata": {},
- "outputs": [],
- "source": [
- "t = so.set(\"Line one\\n\", \"Line two\")"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "21",
- "metadata": {},
- "outputs": [],
- "source": [
- "assert so.length == t.result() # noqa: S101\n",
+ "so.max_continuous_streams = 0\n",
+ "res = await so.set(\"Line one\\n\", \"Line two\")\n",
+ "await anyio.sleep(0.1)\n",
+ "assert so.length == res # noqa: S101\n",
"so.length"
]
},
{
"cell_type": "markdown",
- "id": "22",
+ "id": "19",
"metadata": {},
"source": [
"`max_outputs` limits the total number of outputs."
@@ -247,7 +226,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "23",
+ "id": "20",
"metadata": {},
"outputs": [],
"source": [
@@ -258,17 +237,18 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "24",
+ "id": "21",
"metadata": {},
"outputs": [],
"source": [
"for i in range(100):\n",
+ " await anyio.sleep(0.001)\n",
" so.push(i)"
]
},
{
"cell_type": "markdown",
- "id": "25",
+ "id": "22",
"metadata": {},
"source": [
"# AutoScroll\n",
@@ -282,7 +262,7 @@
},
{
"cell_type": "markdown",
- "id": "26",
+ "id": "23",
"metadata": {},
"source": [
"## Ipylab log viewer\n",
@@ -293,35 +273,33 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "27",
+ "id": "24",
"metadata": {},
"outputs": [],
"source": [
- "app = ipylab.app\n",
"app.log_level = \"DEBUG\"\n",
- "app.commands.execute(\"Show log viewer\")"
+ "await app.commands.execute(\"Show log viewer\")"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "28",
+ "id": "25",
"metadata": {},
"outputs": [],
"source": [
- "for _ in range(10):\n",
- " app.log.debug(\"Debug\")\n",
- " app.log.info(\"Info\")\n",
- " app.log.warning(\"Warning\")\n",
- " app.log.error(\"Error\")\n",
- " app.log.exception(\"Exception\")\n",
- " app.log.critical(\"Critical\")"
+ "app.log.debug(\"Debug\")\n",
+ "app.log.info(\"Info\")\n",
+ "app.log.warning(\"Warning\")\n",
+ "app.log.error(\"Error\")\n",
+ "app.log.exception(\"Exception\")\n",
+ "app.log.critical(\"Critical\")"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "29",
+ "id": "26",
"metadata": {},
"outputs": [],
"source": [
@@ -330,7 +308,7 @@
},
{
"cell_type": "markdown",
- "id": "30",
+ "id": "27",
"metadata": {},
"source": [
"## Example usage"
@@ -339,22 +317,25 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "31",
+ "id": "28",
"metadata": {},
"outputs": [],
"source": [
"from datetime import datetime\n",
"\n",
+ "import anyio\n",
"import ipywidgets as ipw\n",
"\n",
"import ipylab\n",
- "from ipylab.simple_output import AutoScroll"
+ "from ipylab.simple_output import AutoScroll\n",
+ "\n",
+ "app = await ipylab.App().ready()"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "32",
+ "id": "29",
"metadata": {},
"outputs": [],
"source": [
@@ -375,17 +356,15 @@
"def on_click(b):\n",
" if b is b_start:\n",
" if b.description == \"Start\":\n",
- " import asyncio\n",
+ " b.description = \"Stop\"\n",
"\n",
" async def generate_output():\n",
- " while True:\n",
+ " while b.description == \"Stop\":\n",
" vb.children = (*vb.children, ipw.HTML(f\"It is now {datetime.now().isoformat()}\")) # noqa: DTZ005\n",
- " await asyncio.sleep(sleep.value)\n",
+ " await anyio.sleep(sleep.value)\n",
"\n",
- " b.task = ipylab.app.to_task(generate_output())\n",
- " b.description = \"Stop\"\n",
+ " app.start_coro(generate_output())\n",
" else:\n",
- " b.task.cancel()\n",
" b.description = \"Start\"\n",
" if b is b_clear:\n",
" vb.children = ()\n",
@@ -409,12 +388,12 @@
"p = ipylab.Panel(\n",
" [ipw.HBox([enabled, sleep, direction, b_start, b_clear], layout={\"justify_content\": \"center\"}), sw_holder]\n",
")\n",
- "p.add_to_shell(mode=ipylab.InsertMode.split_right)"
+ "await p.add_to_shell(mode=ipylab.InsertMode.split_right)"
]
},
{
"cell_type": "markdown",
- "id": "33",
+ "id": "30",
"metadata": {},
"source": [
"# Basic console example\n",
@@ -426,7 +405,7 @@
"* coroutines are awaited automatically\n",
"* Type hints\n",
"* Execution (Shift Enter)\n",
- "* stdio captured during execution\n",
+ "* stdio captured during execution, but only output once execution completes\n",
"\n",
"## Not implemented\n",
"* Ipython magic\n",
@@ -437,13 +416,14 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "34",
+ "id": "31",
"metadata": {},
"outputs": [],
"source": [
"import io\n",
"import sys\n",
"from contextlib import redirect_stdout\n",
+ "from typing import Self\n",
"\n",
"import ipywidgets as ipw\n",
"\n",
@@ -455,22 +435,25 @@
"\n",
"\n",
"class SimpleConsole(Panel):\n",
- " prompt = Fixed(\n",
- " CodeEditor,\n",
- " editor_options={\"lineNumbers\": False, \"autoClosingBrackets\": True, \"highlightActiveLine\": True},\n",
- " mime_type=\"text/x-python\",\n",
- " layout={\"flex\": \"0 0 auto\"},\n",
+ " prompt: Fixed[Self, CodeEditor] = Fixed(\n",
+ " lambda _: CodeEditor(\n",
+ " editor_options={\"lineNumbers\": False, \"autoClosingBrackets\": True, \"highlightActiveLine\": True},\n",
+ " mime_type=\"text/x-python\",\n",
+ " layout={\"flex\": \"0 0 auto\"},\n",
+ " ),\n",
+ " )\n",
+ " header: Fixed[Self, ipw.HBox] = Fixed(\n",
+ " lambda c: ipw.HBox(\n",
+ " children=(c[\"owner\"].button_clear, c[\"owner\"].autoscroll),\n",
+ " layout={\"flex\": \"0 0 auto\"},\n",
+ " ),\n",
" )\n",
- " button_clear = Fixed(ipw.Button, description=\"Clear\", layout={\"width\": \"auto\"})\n",
- " autoscroll = Fixed(ipw.Checkbox, description=\"Auto scroll\", layout={\"width\": \"auto\"})\n",
- " header = Fixed(\n",
- " ipw.HBox,\n",
- " children=lambda parent: (parent.button_clear, parent.autoscroll),\n",
- " layout={\"flex\": \"0 0 auto\"},\n",
- " dynamic=[\"children\"],\n",
+ " button_clear: Fixed[Self, ipw.Button] = Fixed(lambda _: ipw.Button(description=\"Clear\", layout={\"width\": \"auto\"}))\n",
+ " autoscroll: Fixed[Self, ipw.Checkbox] = Fixed(\n",
+ " lambda _: ipw.Checkbox(description=\"Auto scroll\", layout={\"width\": \"auto\"})\n",
" )\n",
- " output = Fixed(SimpleOutput)\n",
- " scroll = Fixed(AutoScroll, content=lambda parent: parent.output, dynamic=[\"content\"])\n",
+ " output: Fixed[Self, SimpleOutput] = Fixed(SimpleOutput)\n",
+ " scroll: Fixed[Self, AutoScroll] = Fixed(lambda c: AutoScroll(content=c[\"owner\"].output))\n",
"\n",
" def __init__(self, namespace_id: str, **kwgs):\n",
" self.prompt.namespace_id = namespace_id\n",
@@ -495,30 +478,30 @@
" else:\n",
" self.output.push(result)\n",
" except Exception:\n",
- " text = ipylab.app.logging_handler.formatter.formatException(sys.exc_info()) # type: ignore\n",
+ " text = app.logging_handler.formatter.formatException(sys.exc_info()) # type: ignore\n",
" self.output.push({\"output_type\": \"stream\", \"name\": \"stderr\", \"text\": text})"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "35",
+ "id": "32",
"metadata": {},
"outputs": [],
"source": [
"sc = SimpleConsole(\"My namespace\")\n",
- "sc.add_to_shell(mode=ipylab.InsertMode.split_bottom)"
+ "await sc.add_to_shell(mode=ipylab.InsertMode.split_bottom)"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "36",
+ "id": "33",
"metadata": {},
"outputs": [],
"source": [
"sc2 = SimpleConsole(\"A separate namespace\")\n",
- "sc2.add_to_shell(mode=ipylab.InsertMode.split_bottom)"
+ "await sc2.add_to_shell(mode=ipylab.InsertMode.split_bottom)"
]
}
],
diff --git a/examples/widgets.ipynb b/examples/widgets.ipynb
index 1161c99a..80769f3a 100644
--- a/examples/widgets.ipynb
+++ b/examples/widgets.ipynb
@@ -1,12 +1,5 @@
{
"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": "code",
"execution_count": null,
@@ -23,30 +16,18 @@
"# Panels and Widgets"
]
},
- {
- "cell_type": "markdown",
- "metadata": {},
- "source": [
- "### Warning for notebooks and consoles\n",
- "\n",
- "**Do not try to await tasks returned from any ipylab methods, doing so block forever preventing further execution.**\n",
- "\n",
- "This happens because Ipylab employs custom messages over widget comms and widget comms is blocked during cell execution (in the default kernel and server).\n",
- "\n",
- "see [Plugins](plugins.ipynb#Example-launching-a-small-app) or [Actions](widgets.ipynb#Notification-Actions) for an example of awaiting the tasks in a coroutine."
- ]
- },
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
+ "import anyio\n",
"import ipywidgets as ipw\n",
"\n",
"import ipylab\n",
"\n",
- "app = ipylab.App()"
+ "app = await ipylab.App().ready()"
]
},
{
@@ -80,16 +61,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = panel.add_to_shell(mode=ipylab.InsertMode.split_right, activate=False)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "sc = t.result()"
+ "sc = await panel.add_to_shell(mode=ipylab.InsertMode.split_right, activate=False)"
]
},
{
@@ -124,7 +96,7 @@
"metadata": {},
"outputs": [],
"source": [
- "sc.activate() # Will activate before returning to this notebook"
+ "await sc.activate() # Will activate before returning to this notebook"
]
},
{
@@ -156,16 +128,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = panel.add_to_shell(mode=ipylab.InsertMode.split_right, activate=False)"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": [
- "sc = t.result()"
+ "sc = await panel.add_to_shell(mode=ipylab.InsertMode.split_right, activate=False)"
]
},
{
@@ -175,7 +138,7 @@
"outputs": [],
"source": [
"# closable is on the widget in the shell rather than the panel, but we can set it using set_property.\n",
- "t = sc.set_property(\"title.closable\", False)"
+ "await sc.set_property(\"title.closable\", False)"
]
},
{
@@ -240,7 +203,7 @@
"outputs": [],
"source": [
"slider = ipw.IntSlider()\n",
- "app.shell.add(slider, area=ipylab.Area.top)"
+ "await app.shell.add(slider, area=ipylab.Area.top)"
]
},
{
@@ -323,7 +286,7 @@
"metadata": {},
"outputs": [],
"source": [
- "split_panel.add_to_shell(area=ipylab.Area.main, mode=ipylab.InsertMode.split_bottom)"
+ "await split_panel.add_to_shell(area=ipylab.Area.main, mode=ipylab.InsertMode.split_bottom)"
]
},
{
@@ -407,8 +370,8 @@
"metadata": {},
"outputs": [],
"source": [
- "split_panel.add_to_shell(area=ipylab.Area.left, rank=1000)\n",
- "split_panel.connections[0].activate()"
+ "await split_panel.add_to_shell(area=ipylab.Area.left, rank=1000)\n",
+ "await split_panel.connections[0].activate()"
]
},
{
@@ -424,8 +387,8 @@
"metadata": {},
"outputs": [],
"source": [
- "split_panel.add_to_shell(area=ipylab.Area.right, rank=1000)\n",
- "split_panel.connections[0].activate()"
+ "await split_panel.add_to_shell(area=ipylab.Area.right, rank=1000)\n",
+ "await split_panel.connections[0].activate()"
]
},
{
@@ -434,7 +397,7 @@
"metadata": {},
"outputs": [],
"source": [
- "t = app.shell.collapse_right()"
+ "await app.shell.collapse_right()"
]
},
{
@@ -454,7 +417,7 @@
"metadata": {},
"outputs": [],
"source": [
- "split_panel.add_to_shell(cid=ipylab.ShellConnection.to_cid(), mode=ipylab.InsertMode.split_right)"
+ "await split_panel.add_to_shell(cid=ipylab.ShellConnection.to_cid(), mode=ipylab.InsertMode.split_right)"
]
},
{
@@ -463,7 +426,7 @@
"metadata": {},
"outputs": [],
"source": [
- "split_panel.connections[0].activate()\n",
+ "await split_panel.connections[0].activate()\n",
"split_panel.connections"
]
},
@@ -473,7 +436,8 @@
"metadata": {},
"outputs": [],
"source": [
- "split_panel.close()"
+ "split_panel.close()\n",
+ "await anyio.sleep(0.1)"
]
},
{
@@ -502,7 +466,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.12.9"
+ "version": "3.11.11"
}
},
"nbformat": 4,
diff --git a/ipylab/__init__.py b/ipylab/__init__.py
index bf4f7e24..6de13672 100644
--- a/ipylab/__init__.py
+++ b/ipylab/__init__.py
@@ -3,7 +3,7 @@
from __future__ import annotations
-from ipylab import menu
+from ipylab import common, log, menu
from ipylab._frontend import module_version as __version__
from ipylab.code_editor import CodeEditor
from ipylab.common import Area, Fixed, InsertMode, Obj, Transform, hookimpl, pack, to_selector
@@ -16,15 +16,16 @@
__all__ = [
"__version__",
+ "common",
"CodeEditor",
"Connection",
+ "Fixed",
"ShellConnection",
"SimpleOutput",
"Panel",
"SplitPanel",
"Icon",
"Area",
- "Fixed",
"NotificationType",
"NotifyAction",
"InsertMode",
@@ -35,6 +36,7 @@
"Ipylab",
"App",
"Obj",
+ "log",
"menu",
"JupyterFrontEnd",
"to_selector",
@@ -61,4 +63,3 @@ def _get_plugin_manager():
plugin_manager = _get_plugin_manager()
del _get_plugin_manager
-app = App()
diff --git a/ipylab/__main__.py b/ipylab/__main__.py
index f30e1a9b..a4ada4b3 100644
--- a/ipylab/__main__.py
+++ b/ipylab/__main__.py
@@ -6,4 +6,4 @@
import ipylab
if __name__ == "__main__":
- ipylab.plugin_manager.hook.launch_jupyterlab()
+ ipylab.plugin_manager.hook.launch_ipylab()
diff --git a/ipylab/_frontend.py b/ipylab/_frontend.py
index c179f1a6..26e7229c 100644
--- a/ipylab/_frontend.py
+++ b/ipylab/_frontend.py
@@ -12,4 +12,4 @@
path = pathlib.Path(__file__).parent.joinpath("labextension", "package.json")
with path.open("rb") as f:
data = json.load(f)
-module_version = data["version"]
+module_version: str = data["version"]
diff --git a/ipylab/code_editor.py b/ipylab/code_editor.py
index 0aa8f509..1b91c491 100644
--- a/ipylab/code_editor.py
+++ b/ipylab/code_editor.py
@@ -6,8 +6,7 @@
import asyncio
import inspect
import typing
-from asyncio import Task
-from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, override
+from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict
from IPython.core import completer as IPC # noqa: N812
from IPython.utils.tokenutil import token_at_cursor
@@ -16,6 +15,7 @@
from ipywidgets.widgets.widget_description import DescriptionStyle
from ipywidgets.widgets.widget_string import _String
from traitlets import Callable, Container, Dict, Instance, Int, Unicode, default, observe
+from typing_extensions import override
import ipylab
from ipylab.common import Fixed, LastUpdatedDict
@@ -42,6 +42,7 @@
class IpylabCompleter(IPC.IPCompleter):
code_editor: Instance[CodeEditor] = Instance("ipylab.CodeEditor")
+ app = Fixed(lambda _: ipylab.App())
if TYPE_CHECKING:
shell: InteractiveShell # Set in IPV.IPCompleter.__init__
namespace: LastUpdatedDict
@@ -61,7 +62,7 @@ def _default_disable_matchers(self):
]
def update_namespace(self):
- self.namespace = ipylab.app.get_namespace(self.code_editor.namespace_id)
+ self.namespace = self.app.get_namespace(self.code_editor.namespace_id)
def do_complete(self, code: str, cursor_pos: int):
"""Completions provided by IPython completer, using Jedi for different namespaces."""
@@ -152,7 +153,7 @@ async def evaluate(self, code: str):
if wait or inspect.iscoroutine(result):
result = await result
if not self.code_editor.namespace_id:
- ipylab.app.shell.add_objects_to_ipython_namespace(ns)
+ self.app.shell.add_objects_to_ipython_namespace(ns)
except SyntaxError:
exec(code, ns, ns) # noqa: S102
return next(reversed(ns.values()))
@@ -208,16 +209,14 @@ class CodeEditor(Ipylab, _String):
placeholder = None # Presently not available
value = Unicode()
- _update_task: None | Task = None
_setting_value = False
-
- completer = Fixed(
- IpylabCompleter,
- code_editor=lambda c: c,
- shell=lambda c: getattr(getattr(c.comm, "kernel", None), "shell", None),
- dynamic=["code_editor", "shell"],
+ completer: Fixed[Self, IpylabCompleter] = Fixed(
+ lambda c: IpylabCompleter(
+ code_editor=c["owner"],
+ shell=getattr(getattr(c["owner"].comm, "kernel", None), "shell", None),
+ dynamic=["code_editor", "shell"],
+ ),
)
-
namespace_id = Unicode("")
evaluate: Container[typing.Callable[[str], typing.Coroutine]] = Callable() # type: ignore
load_value: Container[typing.Callable[[str], None]] = Callable() # type: ignore
@@ -230,7 +229,7 @@ def _default_key_bindings(self):
"evaluate": ["Shift Enter"],
"undo": ["Ctrl Z"],
"redo": ["Ctrl Shift Z"],
- } | ipylab.plugin_manager.hook.default_editor_key_bindings(app=ipylab.app, obj=self)
+ }
@default("evaluate")
def _default_evaluate(self):
@@ -242,22 +241,19 @@ def _default_load_value(self):
@observe("value")
def _observe_value(self, _):
- if not self._setting_value and not self._update_task:
+ if not self._setting_value:
# We use throttling to ensure there isn't a backlog of changes to synchronise.
# When the value is set in Python, we the shared model in the frontend should exactly reflect it.
async def send_value():
- try:
- while True:
- value = self.value
- await self.operation("setValue", {"value": value})
- self._sync = self._sync + 1
- await asyncio.sleep(self.update_throttle_ms / 1e3)
- if self.value == value:
- return
- finally:
- self._update_task = None
-
- self._update_task = self.to_task(send_value(), "Send value to frontend")
+ while True:
+ value = self.value
+ await self.operation("setValue", {"value": value})
+ self._sync = self._sync + 1
+ await asyncio.sleep(self.update_throttle_ms / 1e3)
+ if self.value == value:
+ return
+
+ self.start_coro(send_value())
@override
async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list):
@@ -270,8 +266,6 @@ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffer
await self.evaluate(payload["code"])
return True
case "setValue":
- if self._update_task:
- await self._update_task
# Only set the value when a valid sync is provided
# sync is done
if payload["sync"] == self._sync:
diff --git a/ipylab/commands.py b/ipylab/commands.py
index 8ef1fe03..bd1cd632 100644
--- a/ipylab/commands.py
+++ b/ipylab/commands.py
@@ -6,20 +6,20 @@
import functools
import inspect
import uuid
-from typing import TYPE_CHECKING, Any, ClassVar, NotRequired, TypedDict, Unpack, override
+from typing import TYPE_CHECKING, Any, ClassVar, NotRequired, TypedDict, Unpack
from ipywidgets import TypedTuple
from traitlets import Callable as CallableTrait
from traitlets import Container, Dict, Instance, Tuple, Unicode
+from typing_extensions import override
import ipylab
-from ipylab.common import IpylabKwgs, Obj, TaskHooks, TaskHookType, TransformType, pack
+from ipylab.common import IpylabKwgs, Obj, Singular, TransformType, pack
from ipylab.connection import InfoConnection, ShellConnection
from ipylab.ipylab import Ipylab, IpylabBase, Transform, register
from ipylab.widgets import Icon
if TYPE_CHECKING:
- from asyncio import Task
from collections.abc import Callable, Coroutine
from ipylab.menu import MenuConnection
@@ -51,7 +51,7 @@ class KeybindingConnection(InfoConnection):
@override
@classmethod
- def to_cid(cls, command: CommandConnection):
+ def to_cid(cls, command: CommandConnection): # type: ignore
return super().to_cid(str(command), str(uuid.uuid4()))
@@ -68,49 +68,44 @@ class CommandConnection(InfoConnection):
@override
@classmethod
- def to_cid(cls, command_registry: str, vpath: str, name: str):
+ def to_cid(cls, command_registry: str, vpath: str, name: str): # type: ignore
return super().to_cid(command_registry, vpath, name)
@property
def repr_info(self):
return {"name": self.commands.name} | {"info": self.info}
- def configure(self, *, emit=True, **kwgs: Unpack[CommandOptions]) -> Task[CommandOptions]:
+ async def configure(self, *, emit=True, **kwgs: Unpack[CommandOptions]) -> CommandOptions:
+ await self.ready()
if diff := set(kwgs).difference(self._config_options):
msg = f"The following useless configuration options were detected for {diff} in {self}"
raise KeyError(msg)
- async def configure():
- config: CommandOptions = await self.update_property("config", kwgs) # type: ignore
- if emit:
- await self.commands.execute_method("commandChanged.emit", {"id": self.cid})
- return config
+ config: CommandOptions = await self.update_property("config", kwgs) # type: ignore
+ if emit:
+ await self.commands.execute_method("commandChanged.emit", ({"id": self.cid},))
+ return config
- return self.to_task(configure())
-
- def add_key_binding(
+ async def add_key_binding(
self, keys: list, selector="", args: dict | None = None, *, prevent_default=True
- ) -> Task[KeybindingConnection]:
+ ) -> KeybindingConnection:
"Add a key binding for this command and selector."
- args = args or {}
-
- 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())
+ await self.ready()
+ args = args or {} | {
+ "keys": keys,
+ "preventDefault": prevent_default,
+ "selector": selector or self.app.selector,
+ "command": str(self),
+ }
+ cid = KeybindingConnection.to_cid(self)
+ KeybindingConnection.close_if_exists(cid)
+ transform: TransformType = {"transform": Transform.connection, "cid": cid}
+ kb: KeybindingConnection = await self.commands.execute_method("addKeyBinding", (args,), transform=transform)
+ kb.add_to_tuple(self, "key_bindings")
+ kb.info = args
+ kb.command = self
+ self.close_with_self(kb)
+ return kb
class CommandPalletItemConnection(InfoConnection):
@@ -120,18 +115,16 @@ class CommandPalletItemConnection(InfoConnection):
@override
@classmethod
- def to_cid(cls, command: CommandConnection, category: str):
+ def to_cid(cls, command: CommandConnection, category: str): # type: ignore
return super().to_cid(str(command), category)
-class CommandPalette(Ipylab):
+class CommandPalette(Singular, Ipylab):
"""
https://jupyterlab.readthedocs.io/en/latest/api/interfaces/apputils.ICommandPalette.html
"""
- SINGLE = True
-
ipylab_base = IpylabBase(Obj.IpylabModel, "palette").tag(sync=True)
info = Dict(help="info about the item")
@@ -139,9 +132,9 @@ class CommandPalette(Ipylab):
trait=Instance("ipylab.commands.CommandPalletItemConnection")
)
- def add(
+ async def add(
self, command: CommandConnection, category: str, *, rank=None, args: dict | None = None
- ) -> Task[CommandPalletItemConnection]:
+ ) -> CommandPalletItemConnection:
"""Add a command to the command pallet (must be registered in this kernel).
**args are used when calling the command.
@@ -168,22 +161,25 @@ def add(
If the ShellConnection relates to an Ipylab widget. The associated
widget/panel is accessible as `ref.widget`.
"""
+ await self.ready()
+ await command.ready()
+ if str(command) not in self.app.commands.all_commands:
+ msg = f"{command=} is not registered in app command registry app.commands!"
+ raise RuntimeError(msg)
cid = CommandPalletItemConnection.to_cid(command, category)
- CommandRegistry._check_belongs_to_application_registry(cid) # noqa: SLF001
+ CommandPalletItemConnection.close_if_exists(cid)
info = {"args": args, "category": category, "command": str(command), "rank": rank}
transform: TransformType = {"transform": Transform.connection, "cid": cid}
- hooks: TaskHooks = {
- "add_to_tuple_fwd": [(self, "connections")],
- "trait_add_fwd": [("info", info), ("command", command)],
- "close_with_fwd": [command],
- }
- return self.execute_method("addItem", info, transform=transform, hooks=hooks)
+ cpc: CommandPalletItemConnection = await self.execute_method("addItem", (info,), transform=transform)
+ self.close_with_self(cpc)
+ cpc.add_to_tuple(self, "connections")
+ cpc.info = info
+ cpc.command = command
+ return cpc
@register
-class CommandRegistry(Ipylab):
- SINGLE = True
-
+class CommandRegistry(Singular, Ipylab):
_model_name = Unicode("CommandRegistryModel").tag(sync=True)
ipylab_base = IpylabBase(Obj.IpylabModel, "").tag(sync=True)
name = Unicode(APP_COMMANDS_NAME, read_only=True).tag(sync=True)
@@ -192,19 +188,8 @@ class CommandRegistry(Ipylab):
@classmethod
@override
- def _single_key(cls, kwgs: dict):
- return cls, kwgs["name"]
-
- @classmethod
- def _check_belongs_to_application_registry(cls, cid: str):
- "Check the cid belongs to the application command registry."
- if APP_COMMANDS_NAME not in cid:
- msg = (
- f"{cid=} doesn't correspond to an ipylab CommandConnection "
- f'for the application command registry "{APP_COMMANDS_NAME}". '
- "Use a command registered with `app.commands.add_command` instead."
- )
- raise ValueError(msg)
+ def get_single_key(cls, name: str, **kwgs):
+ return name
@property
def repr_info(self):
@@ -221,14 +206,15 @@ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffer
return await super()._do_operation_for_frontend(operation, payload, buffers)
async def _execute_for_frontend(self, payload: dict, buffers: list):
- conn = InfoConnection.get_existing_connection(payload["id"], quiet=True)
- if not isinstance(conn, CommandConnection):
- msg = f'Invalid command "{payload["id"]} {conn=}"'
+ cmd_cid = payload["id"]
+ if not CommandConnection.exists(cmd_cid):
+ msg = f'Invalid command "{cmd_cid}"'
raise TypeError(msg)
+ conn = await CommandConnection(cmd_cid).ready()
cmd = conn.python_command
args = conn.args | (payload.get("args") or {})
- ns = ipylab.app.get_namespace(conn.namespace_id)
+ ns = self.app.get_namespace(conn.namespace_id)
kwgs = {}
for n, p in inspect.signature(cmd).parameters.items():
if n == "ref":
@@ -254,7 +240,7 @@ async def _execute_for_frontend(self, payload: dict, buffers: list):
result = await result
return result
- def add_command(
+ async def add_command(
self,
name: str,
execute: Callable[..., Coroutine | Any],
@@ -265,9 +251,8 @@ def add_command(
icon: Icon | None = None,
args: dict | None = None,
namespace_id="",
- hooks: TaskHookType = None,
**kwgs,
- ) -> Task[CommandConnection]:
+ ) -> CommandConnection:
"""Add a python command that can be executed by Jupyterlab.
The `cid` of the CommnandConnection is used as the `id` in the App
@@ -294,42 +279,36 @@ def add_command(
ref: https://lumino.readthedocs.io/en/latest/api/interfaces/commands.CommandRegistry.ICommandOptions.html
"""
- async def add_command():
- cid = CommandConnection.to_cid(self.name, ipylab.app.vpath, name)
- if cmd := CommandConnection.get_existing_connection(cid, quiet=True):
- await cmd.ready()
- cmd.close()
- kwgs_ = kwgs | {
- "id": cid,
- "cid": cid,
- "caption": caption,
- "label": label or name,
- "iconClass": icon_class,
- "icon": f"{pack(icon)}.labIcon" if isinstance(icon, Icon) else None,
- }
- hooks: TaskHooks = {
- "close_with_fwd": [self],
- "add_to_tuple_fwd": [(self, "connections")],
- "trait_add_fwd": [
- ("commands", self),
- ("namespace_id", namespace_id),
- ("python_command", execute),
- ("args", args or {}),
- ("info", kwgs_),
- ],
- }
-
- return await self.operation(
- "addCommand",
- kwgs_,
- hooks=hooks,
- transform={"transform": Transform.connection, "cid": cid},
- toObject=["icon"] if isinstance(icon, Icon) else [],
- )
-
- return self.to_task(add_command(), hooks=hooks)
-
- def execute(self, command_id: str | CommandConnection, args: dict | None = None, **kwargs: Unpack[IpylabKwgs]):
+ await self.ready()
+ app = await self.app.ready()
+ cid = CommandConnection.to_cid(self.name, app.vpath, name)
+ CommandConnection.close_if_exists(cid)
+ kwgs = kwgs | {
+ "id": cid,
+ "cid": cid,
+ "caption": caption,
+ "label": label or name,
+ "iconClass": icon_class,
+ "icon": f"{pack(icon)}.labIcon" if isinstance(icon, Icon) else None,
+ }
+ cc: CommandConnection = await self.operation(
+ "addCommand",
+ kwgs,
+ transform={"transform": Transform.connection, "cid": cid},
+ toObject=["icon"] if isinstance(icon, Icon) else [],
+ )
+ self.close_with_self(cc)
+ cc.commands = self
+ cc.namespace_id = namespace_id
+ cc.python_command = execute
+ cc.args = args or {}
+ cc.info = kwgs
+ cc.add_to_tuple(self, "connections")
+ return cc
+
+ async def execute(
+ self, command_id: str | CommandConnection, args: dict | None = None, **kwargs: Unpack[IpylabKwgs]
+ ):
"""Execute a command registered in the frontend command registry returning
the result.
@@ -344,34 +323,31 @@ def execute(self, command_id: str | CommandConnection, args: dict | None = None,
see https://github.com/jtpio/ipylab/issues/128#issuecomment-1683097383
for hints on how to determine what args can be used.
"""
-
- async def execute_command():
- id_ = str(command_id)
+ await self.ready()
+ app = await self.app.ready()
+ id_ = str(command_id)
+ if id_ not in self.all_commands:
+ id_ = CommandConnection.to_cid(self.name, app.vpath, id_)
if id_ not in self.all_commands:
- id_ = CommandConnection.to_cid(self.name, ipylab.app.vpath, id_)
- if id_ not in self.all_commands:
- msg = f"Command '{command_id}' not registered!"
- raise ValueError(msg)
- return await self.operation("execute", {"id": id_, "args": args or {}}, **kwargs)
-
- return self.to_task(execute_command())
+ msg = f"Command '{command_id}' not registered!"
+ raise ValueError(msg)
+ return await self.operation("execute", {"id": id_, "args": args or {}}, **kwargs)
- def create_menu(self, label: str, rank: int = 500) -> Task[MenuConnection]:
+ async def create_menu(self, label: str, rank: int = 500) -> MenuConnection:
"Make a new menu that can be used where a menu is required."
+ await self.ready()
cid = ipylab.menu.MenuConnection.to_cid()
+ ipylab.menu.MenuConnection.close_if_exists(cid)
options = {"id": cid, "label": label, "rank": int(rank)}
- hooks: TaskHooks = {
- "trait_add_fwd": [("info", options), ("commands", self)],
- "add_to_tuple_fwd": [(self, "connections")],
- "close_with_fwd": [self],
- }
- return self.execute_method(
+ mc: MenuConnection = await self.execute_method(
"generateMenu",
- f"{pack(self)}.base",
- options,
- (Obj.this, "translator"),
+ (f"{pack(self)}.base", options, (Obj.this, "translator")),
obj=Obj.MainMenu,
toObject=["args[0]", "args[2]"],
transform={"transform": Transform.connection, "cid": cid},
- hooks=hooks,
)
+ self.close_with_self(mc)
+ mc.info = options
+ mc.commands = self
+ mc.add_to_tuple(self, "connections")
+ return mc
diff --git a/ipylab/common.py b/ipylab/common.py
index ec89ea2d..89827a37 100644
--- a/ipylab/common.py
+++ b/ipylab/common.py
@@ -3,20 +3,53 @@
from __future__ import annotations
+import asyncio
+import contextlib
+import importlib
import inspect
+import textwrap
import typing
import weakref
from collections import OrderedDict
-from collections.abc import Awaitable, Callable
+from collections.abc import Callable
from enum import StrEnum
-from typing import TYPE_CHECKING, Any, Generic, Literal, NotRequired, TypedDict, TypeVar, override
-
+from types import CoroutineType
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ ClassVar,
+ Concatenate,
+ Generic,
+ Literal,
+ NotRequired,
+ ParamSpec,
+ Self,
+ TypedDict,
+ TypeVar,
+ TypeVarTuple,
+ Unpack,
+ final,
+ overload,
+)
+
+import anyio
import pluggy
-from ipywidgets import Widget, widget_serialization
-from traitlets import HasTraits
+import traitlets
+from ipywidgets import TypedTuple, Widget, widget_serialization
+from traitlets import Any as AnyTrait
+from traitlets import Bool, Container, HasTraits, Instance, default, observe
+from typing_extensions import override
import ipylab
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable, Hashable
+ from types import CoroutineType
+ from typing import overload
+
+ from ipylab.ipylab import Ipylab
+ from ipylab.log import IpylabLoggerAdapter
+
__all__ = [
"Area",
"Obj",
@@ -26,26 +59,67 @@
"hookimpl",
"pack",
"IpylabKwgs",
- "TaskHookType",
"LastUpdatedDict",
"Fixed",
"FixedCreate",
"FixedCreated",
+ "HasApp",
+ "Singular",
]
+
+T = TypeVar("T")
+S = TypeVar("S")
+R = TypeVar("R")
+B = TypeVar("B", bound=object)
+L = TypeVar("L", bound="Ipylab")
+P = ParamSpec("P")
+PosArgsT = TypeVarTuple("PosArgsT")
+
+
hookimpl = pluggy.HookimplMarker("ipylab") # Used for plugins
SVGSTR_TEST_TUBE = ''
-T = TypeVar("T")
-if TYPE_CHECKING:
- from collections.abc import Awaitable, Callable
- from typing import overload
+def autorun(f: Callable[Concatenate[B, P], CoroutineType[None, None, R]]):
+ """Decorator to automatically start a coroutine when a method is called.
- from traitlets import HasTraits
+ The decorated method will be called with the same arguments as the original method. But with
+ start prepended.
+ If `start` is True (default), the coroutine will be started automatically using
+ `ipylab.App().start_coro` or `self.start_coro` if the class is an instance of `ipylab.Ipylab`.
+ If `start` is False, the coroutine will be returned without being started.
+
+ Args:
+ f: The coroutine function to decorate. The first argument must be `self`.
+
+ Returns:
+ The decorated function.
+ """
+ if TYPE_CHECKING:
+
+ @overload
+ def inner(self: B, start: Literal[True], /, *args: P.args, **kwargs: P.kwargs) -> None: ...
+ @overload
+ def inner(
+ self: B, start: Literal[False], /, *args: P.args, **kwargs: P.kwargs
+ ) -> CoroutineType[None, None, R]: ...
+ @overload
+ def inner(self: B, start: Literal[True] = ..., /, *args: P.args, **kwargs: P.kwargs) -> None: ...
+
+ def inner(self: B, start: bool = True, /, *args: P.args, **kwargs: P.kwargs) -> CoroutineType[None, None, R] | None: # noqa: FBT001, FBT002
+ coro = f(self, *args, **kwargs)
+ if not start:
+ return coro
+ start_coro = self.start_coro if isinstance(self, ipylab.Ipylab) else ipylab.App().start_coro
+ start_coro(coro)
+ return None
+
+ return inner
- from ipylab.ipylab import Ipylab
+
+if TYPE_CHECKING:
@overload
def pack(obj: Widget) -> str: ...
@@ -70,9 +144,12 @@ def pack(obj):
if isinstance(obj, Widget):
return widget_serialization["to_json"](obj, None)
- if inspect.isfunction(obj) or inspect.ismodule(obj):
- return inspect.getsource(obj)
- return obj
+ if inspect.isfunction(obj) or inspect.ismodule(obj) or inspect.isclass(obj):
+ with contextlib.suppress(BaseException):
+ return module_obj_to_import_string(obj)
+ return textwrap.dedent(inspect.getsource(obj))
+ msg = f"Unable pack this type of object {type(obj)}: {obj!r}"
+ raise TypeError(msg)
def to_selector(*args, prefix="ipylab"):
@@ -85,6 +162,45 @@ def to_selector(*args, prefix="ipylab"):
return f".{prefix}-{suffix}"
+def import_item(dottedname: str):
+ """Import an item from a module, given its dotted name.
+
+ For example:
+ >>> import_item("os.path.join")
+ """
+ modulename, objname = dottedname.rsplit(".", maxsplit=1)
+ return getattr(importlib.import_module(modulename), objname)
+
+
+def module_obj_to_import_string(obj):
+ """Convert a module object to an import string compatible with `app.evaluate`.
+
+ Parameters
+ ----------
+ obj : object
+ The module object to convert.
+
+ Returns
+ -------
+ str
+ The import string for the module object.
+
+ Raises
+ ------
+ TypeError
+ If the module object cannot be imported correctly.
+ """
+ dottedname = f"{obj.__module__}.{obj.__qualname__}"
+ if dottedname.startswith("__main__"):
+ msg = f"{obj=} won't be importable from a new kernel"
+ raise TypeError(msg)
+ item = import_item(dottedname)
+ if item is not obj:
+ msg = "Failed to import item correctly"
+ raise TypeError(msg)
+ return f"import_item({dottedname=})"
+
+
class Obj(StrEnum):
"The objects available to use as 'obj' in the frontend."
@@ -121,6 +237,7 @@ class InsertMode(StrEnum):
tab_after = "tab-after"
+@final
class Transform(StrEnum):
"""An eumeration of transformations to apply to the result of an operation
performed on the frontend prior to returning to Python and transformation
@@ -200,17 +317,15 @@ def validate(cls, transform: TransformType):
return transform_
@classmethod
- def transform_payload(cls, transform: TransformType, payload):
+ async def transform_payload(cls, transform: TransformType, payload):
"""Transform the payload according to the transform."""
transform_ = transform["transform"] if isinstance(transform, dict) else transform
match transform_:
case Transform.advanced:
mappings = typing.cast(TransformDictAdvanced, transform)["mappings"]
- return {key: cls.transform_payload(mappings[key], payload[key]) for key in mappings}
+ return {key: await cls.transform_payload(mappings[key], payload[key]) for key in mappings} # type: ignore
case Transform.connection | Transform.auto if isinstance(payload, dict) and (cid := payload.get("cid")):
- conn = ipylab.Connection(cid)
- conn._check_closed() # noqa: SLF001
- return conn
+ return await ipylab.Connection.get_connection(cid).ready()
return payload
@@ -236,38 +351,6 @@ class IpylabKwgs(TypedDict):
transform: NotRequired[TransformType]
toLuminoWidget: NotRequired[list[str] | None]
toObject: NotRequired[list[str] | None]
- hooks: NotRequired[TaskHookType]
-
-
-class TaskHooks(TypedDict):
- """Hooks to run after successful completion of 'aw' passed to the method "to_task"
- and prior to returning.
-
- This provides a convenient means to set traits of the returned result.
-
- see: `Hookspec.task_result`
- """
-
- close_with_fwd: NotRequired[list[Ipylab]] # result is closed when any item in list is closed
- close_with_rev: NotRequired[list[Widget]] #
-
- trait_add_fwd: NotRequired[list[tuple[str, Any]]]
- trait_add_rev: NotRequired[list[tuple[HasTraits, str]]]
-
- add_to_tuple_fwd: NotRequired[list[tuple[HasTraits, str]]]
- add_to_tuple_rev: NotRequired[list[tuple[str, Ipylab]]]
-
- callbacks: NotRequired[list[Callable[[Any], None | Awaitable[None]]]]
-
-
-TaskHookType = TaskHooks | None
-
-
-def trait_tuple_add(owner: HasTraits, name: str, value: Any):
- "Add value to a tuple trait of owner if it already isn't in the tuple."
- items = getattr(owner, name)
- if value not in items:
- owner.set_trait(name, (*items, value))
class LastUpdatedDict(OrderedDict):
@@ -290,7 +373,7 @@ def __setitem__(self, key, value):
self.move_to_end(key, self._last)
@override
- def update(self, m, **kwargs):
+ def update(self, m, /, **kwargs): # type: ignore
self._updating = True
try:
super().update(m, **kwargs)
@@ -298,107 +381,281 @@ def update(self, m, **kwargs):
self._updating = False
-class FixedCreate(Generic[T], TypedDict):
+class FixedCreate(Generic[S], TypedDict):
"A TypedDict relevant to Fixed"
name: str
- klass: type[T]
- owner: Any
- args: tuple
- kwgs: dict
+ owner: S
-class FixedCreated(Generic[T], TypedDict):
+class FixedCreated(Generic[S, T], TypedDict):
"A TypedDict relevant to Fixed"
name: str
+ owner: S
obj: T
- owner: Any
-class Fixed(Generic[T]):
- __slots__ = ["name", "instances", "klass", "args", "kwgs", "dynamic", "create", "created"]
+class Fixed(Generic[S, T]):
+ """Descriptor for creating and caching a fixed instance of a class.
- def __init__(
- self,
- klass: type[T],
- *args,
- dynamic: list[str] | None = None,
- create: Callable[[FixedCreate[T]], T] | str = "",
- created: Callable[[FixedCreated[T]]] | str = "",
- **kwgs,
- ):
- """Define an instance of `klass` as a cached read only property.
- `args` and `kwgs` are used to instantiate `klass`.
+ The ``Fixed`` descriptor provisions for each instance of the owner class
+ to dynamically load or import the managed class. The managed instance
+ is created on first access and then cached for subsequent access.
- Parameters:
- ----------
-
- dynamic: list[str]:
- A list of argument names to call during creation. It is called with obj (owner)
- as an argument.
+ Type Hints:
+ ``S``: Type of the owner class.
+ ``T``: Type of the managed class.
- create: Callable[[FixedCreated], T] | str
- A function or method name to call to create the instance of klass.
+ Examples:
+ >>> class MyClass:
+ ... fixed_instance = Fixed(ManagedClass)
+ >>> my_object = MyClass()
+ >>> instance1 = my_object.fixed_instance
+ >>> instance2 = my_object.fixed_instance
+ >>> instance1 is instance2
+ True
+ """
- created: Callable[[FixedCreatedDict], None] | str
- A function or method name to call after the instance is created.
+ __slots__ = ["name", "instances", "create", "created"]
- **kwgs:
- `kwgs` to pass when instantiating `klass`. Arguments listed in dynamic
- are first called with obj as an argument to obtain the value to
- substitute in place of the dynamic function.
- """
- if callable(create) and len(inspect.signature(create).parameters) != 1:
- msg = "'create' must be a callable the accepts one argument."
- raise ValueError(msg)
- if callable(created) and len(inspect.signature(created).parameters) != 1:
- msg = "'created' must be a callable the accepts one argument."
- raise ValueError(msg)
- if dynamic:
- for k in dynamic:
- if not callable(kwgs[k]) or len(inspect.signature(kwgs[k]).parameters) != 1:
- msg = f"Argument'{k}' must a callable that accepts one argument."
- raise ValueError(msg)
+ def __init__(
+ self,
+ obj: type[T] | Callable[[FixedCreate[S]], T] | str,
+ /,
+ *,
+ created: Callable[[FixedCreated[S, T]]] | None = None,
+ ):
+ if inspect.isclass(obj):
+ self.create = lambda _: obj() # type: ignore
+ elif callable(obj):
+ self.create = obj
+ elif isinstance(obj, str):
+ self.create = lambda _: import_item(obj)()
+ else:
+ msg = f"{obj=} is invalid. Wrap it with a lambda to make it 'constant'. Eg. lambda _: {obj}"
+ raise TypeError(msg)
self.created = created
- self.create = create
- self.dynamic = dynamic
- self.args = args
- self.klass = klass
- self.kwgs = kwgs
self.instances = weakref.WeakKeyDictionary()
def __set_name__(self, owner_cls, name: str):
self.name = name
- def __get__(self, obj, objtype=None) -> T:
+ def __get__(self, obj: Any, objtype=None) -> T:
if obj is None:
return self # type: ignore
- if obj not in self.instances:
- kwgs = self.kwgs
- if self.dynamic:
- kwgs = kwgs.copy()
- for k in self.dynamic:
- kwgs[k] = kwgs[k](obj)
- if self.create:
- create = getattr(obj, self.create) if isinstance(self.create, str) else self.create
- kw = FixedCreate(name=self.name, klass=self.klass, owner=obj, args=self.args, kwgs=kwgs)
- instance = create(kw)
- if not isinstance(instance, self.klass):
- msg = f"Expected {self.klass} but {create=} returned {type(instance)}"
- raise TypeError(msg)
- else:
- instance = self.klass(*self.args, **kwgs)
+ try:
+ return self.instances[obj]
+ except KeyError:
+ instance: T = self.create(FixedCreate(name=self.name, owner=obj)) # type: ignore
self.instances[obj] = instance
- try:
- if self.created:
- created = getattr(obj, self.created) if isinstance(self.created, str) else self.created
- created(FixedCreated(owner=obj, obj=instance, name=self.name))
- except Exception:
- if log := getattr(obj, "log", None):
- log.exception("Callback `created` failed", obj=self.created)
- return self.instances[obj]
+ if self.created:
+ try:
+ self.created(FixedCreated(owner=obj, obj=instance, name=self.name))
+ except Exception:
+ if log := getattr(obj, "log", None):
+ msg = f"Callback `created` failed for {obj.__class__}.{self.name}"
+ log.exception(msg, extra={"obj": self.created})
+ return instance # type: ignore
def __set__(self, obj, value):
- msg = f"Setting {obj.__class__.__name__}.{self.name} is forbidden!"
+ msg = f"Setting `Fixed` parameter {obj.__class__.__name__}.{self.name} is forbidden!"
raise AttributeError(msg)
+
+
+class HasApp(HasTraits):
+ """A mixin class that provides access to the ipylab application.
+
+ It provides methods for:
+
+ - Closing other widgets when the widget is closed.
+ - Adding the widget to a tuple of widgets owned by another object.
+ - Starting coroutines in the main event loop.
+ - Logging exceptions that occur when awaiting an awaitable.
+ """
+
+ _tuple_owners: Fixed[Self, set[tuple[HasTraits, str]]] = Fixed(set)
+ _close_extras: Fixed[Self, weakref.WeakSet[Widget | HasApp]] = Fixed(weakref.WeakSet)
+
+ closed = Bool(read_only=True)
+ log: Instance[IpylabLoggerAdapter] = Instance("ipylab.log.IpylabLoggerAdapter")
+ app = Fixed(lambda _: ipylab.App())
+ add_traits = None # type: ignore # Don't support the method HasTraits.add_traits as it creates a new type that isn't a subclass of its origin)
+
+ @default("log")
+ def _default_log(self):
+ return ipylab.log.IpylabLoggerAdapter(self.__module__, owner=self)
+
+ @observe("closed")
+ def _hasapp_observe_closed(self, _):
+ if self.closed:
+ self.log.debug("closed")
+ for item in list(self._close_extras):
+ item.close()
+ for obj, name in list(self._tuple_owners):
+ if val := getattr(obj, name, None):
+ if (isinstance(obj, HasApp) and obj.closed) or (isinstance(obj, Widget) and not obj.comm):
+ return
+ obj.set_trait(name, tuple(v for v in val if v is not self))
+
+ def _check_closed(self):
+ if self.closed:
+ msg = f"This instance is closed {self!r}"
+ raise RuntimeError(msg)
+
+ def close_with_self(self, obj: Widget | HasApp):
+ """Register an object to be closed when this object is closed.
+
+ Parameters
+ ----------
+ obj : Widget | HasApp
+ Object to close.
+
+ Raises
+ ------
+ anyio.ClosedResourceError
+ If this object is already closed.
+ """
+ if self.closed:
+ obj.close()
+ msg = f"{self} is closed"
+ raise anyio.ClosedResourceError(msg)
+ self._close_extras.add(obj)
+
+ def add_to_tuple(self, owner: HasTraits, name: str):
+ """Add self to the tuple of obj and remove self when closed."""
+
+ items = getattr(owner, name)
+ if not self.closed and self not in items:
+ owner.set_trait(name, (*items, self))
+ self._tuple_owners.add((owner, name))
+
+ def close(self):
+ if close := getattr(super(), "close", None):
+ close()
+ self.set_trait("closed", True)
+
+ async def _catch_exceptions(self, aw: Awaitable) -> None:
+ """Catches exceptions that occur when awaiting an awaitable.
+
+ The exception is logged, but otherwise ignored.
+
+ Args:
+ aw: The awaitable to await.
+ """
+ try:
+ await aw
+ except BaseException as e:
+ self.log.exception(f"Calling {aw}", obj={"aw": aw}, exc_info=e) # noqa: G004
+ if self.app.log_level == ipylab.log.LogLevel.DEBUG:
+ raise
+
+ def start_coro(self, coro: CoroutineType[None, None, T]) -> None:
+ """Start a coroutine in the main event loop.
+
+ If the kernel has a `start_soon` method, use it to start the coroutine.
+ Otherwise, if the application has an asyncio loop, use
+ `asyncio.run_coroutine_threadsafe` to start the coroutine in the loop.
+ If neither of these is available, raise a RuntimeError.
+
+ Tip: Use anyio primiatives in the coroutine to ensure it will run in
+ the chosen backend of the kernel.
+
+ Parameters
+ ----------
+ coro : CoroutineType[None, None, T]
+ The coroutine to start.
+
+ Raises
+ ------
+ RuntimeError
+ If there is no running loop to start the task.
+ """
+
+ self._check_closed()
+ self.start_soon(self._catch_exceptions, coro)
+
+ def start_soon(self, func: Callable[[Unpack[PosArgsT]], CoroutineType], *args: Unpack[PosArgsT]):
+ """Start a function soon in the main event loop.
+
+ If the kernel has a start_soon method, use it.
+ Otherwise, if the app has an asyncio loop, run the function in that loop.
+ Otherwise, raise a RuntimeError.
+
+ This is a simple wrapper to ensure the function is called in the main
+ event loop. No error reporting is done.
+
+ Consider using start_coro which performs additional checks and automatically
+ logs exceptions.
+ """
+ try:
+ start_soon = self.app.kernel.start_soon # type: ignore
+ except AttributeError:
+ if loop := self.app.asyncio_loop:
+ coro = func(*args)
+ asyncio.run_coroutine_threadsafe(coro, loop)
+ else:
+ msg = f"We don't have a running loop to run {func}"
+ raise RuntimeError(msg) from None
+ else:
+ start_soon(func, *args)
+
+
+class _SingularInstances(HasTraits, Generic[T]):
+ instances: Container[tuple[T, ...]] = TypedTuple(trait=traitlets.Any(), read_only=True)
+
+
+class Singular(HasTraits):
+ """A base class that ensures only one instance of a class exists for each unique
+ key (except for None).
+
+ This class uses a class-level dictionary `_singular_instances` to store instances,
+ keyed by a value obtained from the `get_single_key` classmethod. Subsequent calls to
+ the constructor with the same key will return the existing instance. If key is
+ None, a new instance is always created and a reference is not kept to the object.
+
+ The class attribute `singular` maintains a tuple of the instances on a per-subclass basis
+ (only instances with a `single_key` that is not None are included).
+ """
+
+ singular_init_started = traitlets.Bool(read_only=True)
+ _singular_instances: ClassVar[dict[Hashable, Self]] = {}
+ single_key = AnyTrait(default_value=None, allow_none=True, read_only=True)
+ closed = Bool(read_only=True)
+ singular: ClassVar[_SingularInstances[Self]]
+
+ def __init_subclass__(cls) -> None:
+ cls._singular_instances = {}
+ cls.singular = _SingularInstances()
+
+ @classmethod
+ def get_single_key(cls, *args, **kwgs) -> Hashable: # noqa: ARG003
+ return cls
+
+ def __new__(cls, /, *args, **kwgs) -> Self:
+ key = cls.get_single_key(*args, **kwgs)
+ if key is None or not (inst := cls._singular_instances.get(key)):
+ new = super().__new__
+ inst = new(cls) if new is object.__new__ else new(cls, *args, **kwgs)
+ if key:
+ cls._singular_instances[key] = inst
+ inst.set_trait("single_key", key)
+ return inst
+
+ def __init__(self, /, *args, **kwgs):
+ if self.singular_init_started:
+ return
+ self.set_trait("singular_init_started", True)
+ super().__init__(*args, **kwgs)
+ self.singular.set_trait("instances", tuple(self._singular_instances.values()))
+
+ @observe("closed")
+ def _singular_observe_closed(self, _):
+ if self.closed and self.single_key is not None:
+ self._singular_instances.pop(self.single_key, None)
+ self.singular.set_trait("instances", tuple(self._singular_instances.values()))
+
+ def close(self):
+ if close := getattr(super(), "close", None):
+ close()
+ self.set_trait("closed", True)
diff --git a/ipylab/connection.py b/ipylab/connection.py
index 13513c2e..c90a4175 100644
--- a/ipylab/connection.py
+++ b/ipylab/connection.py
@@ -4,22 +4,22 @@
from __future__ import annotations
import uuid
-import weakref
-from typing import TYPE_CHECKING, Any, ClassVar, override
+from typing import TYPE_CHECKING, ClassVar
from ipywidgets import Widget, register
-from traitlets import Bool, Dict, Instance, Unicode, observe
+from traitlets import Bool, Dict, Instance, Unicode
+from typing_extensions import override
+from ipylab.common import Area, Singular
from ipylab.ipylab import Ipylab
if TYPE_CHECKING:
- from asyncio import Task
- from collections.abc import Generator
- from typing import Literal, Self, overload
+ from collections.abc import Hashable
+ from typing import Self
@register
-class Connection(Ipylab):
+class Connection(Singular, Ipylab):
"""This class provides a connection to an object in the frontend.
`Connection` and subclasses of `Connection` are used extensiviely in ipylab
@@ -46,7 +46,6 @@ class Connection(Ipylab):
_SEP = "|"
prefix: ClassVar = f"{_PREFIX}Connection{_SEP}"
- _connections: weakref.WeakValueDictionary[str, Self] = weakref.WeakValueDictionary()
_model_name = Unicode("ConnectionModel").tag(sync=True)
cid = Unicode(read_only=True, help="connection id").tag(sync=True)
_dispose = Bool(read_only=True).tag(sync=True)
@@ -54,29 +53,31 @@ class Connection(Ipylab):
auto_dispose = Bool(False, read_only=True, help="Dispose of the object in frontend when closed.").tag(sync=True)
+ @override
+ @classmethod
+ def get_single_key(cls, cid: str, **kwgs) -> Hashable:
+ return cid
+
+ @classmethod
+ def exists(cls, cid: str) -> bool:
+ return cid in cls._singular_instances
+
+ @classmethod
+ def close_if_exists(cls, cid: str):
+ if inst := cls._singular_instances.pop(cid, None):
+ inst.close()
+
def __init_subclass__(cls, **kwargs) -> None:
cls.prefix = f"{cls._PREFIX}{cls.__name__}{cls._SEP}"
cls._CLASS_DEFINITIONS[cls.prefix.strip(cls._SEP)] = cls
super().__init_subclass__(**kwargs)
- def __new__(cls, cid: str, **kwgs):
- inst = cls._connections.get(cid)
- if not inst:
- cls = cls._CLASS_DEFINITIONS[cid.split(cls._SEP, maxsplit=1)[0]]
- cls._connections[cid] = inst = super().__new__(cls, **kwgs)
- return inst
-
def __init__(self, cid: str, **kwgs):
super().__init__(cid=cid, **kwgs)
def __str__(self):
return self.cid
- @property
- @override
- def repr_info(self):
- return {"cid": self.cid}
-
@classmethod
def to_cid(cls, *args: str) -> str:
"""Generate a cid."""
@@ -90,18 +91,12 @@ def to_cid(cls, *args: str) -> str:
args = (str(uuid.uuid4()),)
return cls.prefix + cls._SEP.join(args)
- @classmethod
- def get_instances(cls) -> Generator[Self, Any, None]:
- "Get all instances of this class (including subclasses)."
- for item in cls._connections.values():
- if isinstance(item, cls):
- yield item
-
- @observe("comm")
- def _connection_observe_comm(self, _):
- if not self.comm:
- self._connections.pop(self.cid, None)
+ @property
+ @override
+ def repr_info(self):
+ return {"cid": self.cid}
+ @override
def close(self, *, dispose=True):
"""Permanently close the widget.
@@ -110,31 +105,20 @@ def close(self, *, dispose=True):
self.set_trait("auto_dispose", dispose)
super().close()
- if TYPE_CHECKING:
-
- @overload
- @classmethod
- def get_existing_connection(cls, cid: str, *, quiet: Literal[False]) -> Self: ...
- @overload
- @classmethod
- def get_existing_connection(cls, cid: str, *, quiet: Literal[True]) -> Self | None: ...
- @overload
- @classmethod
- def get_existing_connection(cls, cid: str) -> Self: ...
-
@classmethod
- def get_existing_connection(cls, cid: str, *, quiet=False):
- """Get an existing connection.
+ def get_connection(cls, cid: str) -> Self:
+ """Get a connection object from a connection id.
+
+ The connection id is a string that identifies the connection.
+ It is composed of the connection type and the connection name, separated by a separator.
+ The connection type is the name of the class that implements the connection.
+ The connection name is a unique name for the connection.
- quiet: bool
- True: Raise a value error if the connection does not exist.
- False: Return None.
+ :param cid: The connection id.
+ :return: The connection object.
"""
- conn = cls._connections.get(cid)
- if not conn and not quiet:
- msg = f"A connection does not exist with '{cid=}'"
- raise ValueError(msg)
- return conn
+ cls_ = cls._CLASS_DEFINITIONS[cid.split(cls._SEP, maxsplit=1)[0]]
+ return cls_(cid)
Connection._CLASS_DEFINITIONS[Connection.prefix.strip(Connection._SEP)] = Connection # noqa: SLF001
@@ -165,11 +149,16 @@ def __del__(self):
# Losing strong references doesn't mean the widget should be closed.
self.close(dispose=False)
- def activate(self):
+ async def activate(self):
"Activate the connected widget in the shell."
- return self.operation("activate")
+ ids = await self.app.shell.list_widget_ids()
+ if self.cid in ids[Area.left]:
+ await self.app.shell.expand_left()
+ elif self.cid in ids[Area.right]:
+ await self.app.shell.expand_right()
+ return await self.operation("activate")
- def get_session(self) -> Task[dict]:
+ async def get_session(self) -> dict:
"""Get the session of the connected widget."""
- return self.operation("getSession")
+ return await self.operation("getSession")
diff --git a/ipylab/css_stylesheet.py b/ipylab/css_stylesheet.py
index 9d42a3ce..b5d492bd 100644
--- a/ipylab/css_stylesheet.py
+++ b/ipylab/css_stylesheet.py
@@ -3,17 +3,12 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
-
from ipywidgets import TypedTuple
from traitlets import Dict, Unicode
from ipylab.common import Obj
from ipylab.ipylab import Ipylab
-if TYPE_CHECKING:
- from asyncio import Task
-
__all__ = ["CSSStyleSheet"]
@@ -27,54 +22,56 @@ class CSSStyleSheet(Ipylab):
)
def __init__(self, **kwgs):
- if self._async_widget_base_init_complete:
+ if self._ipylab_init_complete:
return
super().__init__(**kwgs)
self.on_ready(self._restore)
- def _restore(self, _):
+ async def _restore(self, _):
# Restore rules and variables
if self.variables:
- self.set_variables(self.variables)
+ await self.set_variables(self.variables)
if self.css_rules:
- self.replace("\n".join(self.css_rules))
+ await self.replace("\n".join(self.css_rules))
- def _css_operation(self, operation: str, kwgs: dict | None = None):
+ async def _css_operation(self, operation: str, kwgs: dict | None = None) -> tuple[str, ...]:
# Updates css_rules once operation is done
- return self.operation(operation, kwgs, hooks={"trait_add_rev": [(self, "css_rules")]})
+ css_rules = await self.operation(operation, kwgs=kwgs)
+ self.set_trait("css_rules", css_rules)
+ return self.css_rules
- def delete_rule(self, item: int | str):
+ async def delete_rule(self, item: int | str):
"""Delete a rule by index or pass the exact string of the rule."""
if isinstance(item, str):
item = list(self.css_rules).index(item)
- return self._css_operation("deleteRule", {"index": item})
+ return await self._css_operation("deleteRule", {"index": item})
- def insert_rule(self, rule: str, index=None):
+ async def insert_rule(self, rule: str, index=None):
""
- return self._css_operation("insertRule", {"rule": rule, "index": index})
+ return await self._css_operation("insertRule", {"rule": rule, "index": index})
- def get_css_rules(self):
+ async def get_css_rules(self):
"""Get a list of the css_text specified for this instance."""
- return self._css_operation("listCSSRules")
+ return await self._css_operation("listCSSRules")
- def replace(self, text: str):
+ async def replace(self, text: str):
"""Replace all css rules for this instance.
ref: https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/replace"""
- return self._css_operation("replace", {"text": text})
+ return await self._css_operation("replace", {"text": text})
- def get_variables(self) -> Task[dict[str, str]]:
+ async def get_variables(self) -> dict[str, str]:
"""Get a dict mapping **all** variable names to values in Jupyterlab.
Variables set via this object can be found from the property 'variables'.
"""
- return self.execute_method("listVariables", obj=Obj.this)
+ return await self.execute_method("listVariables", obj=Obj.this)
- def set_variables(self, variables: dict[str, str]) -> Task[dict[str, str]]:
+ async def set_variables(self, variables: dict[str, str]) -> dict[str, str]:
"Set a css variable."
if invalid_names := [n for n in variables if not n.startswith("--")]:
msg = f'Variable names must start with "--"! {invalid_names=}'
raise ValueError(msg)
- return self.execute_method(
- "setVariables", variables, obj=Obj.this, hooks={"callbacks": [self.variables.update]}
- )
+ v: dict[str, str] = await self.execute_method("setVariables", (variables,), obj=Obj.this)
+ self.set_trait("variables", self.variables | v)
+ return self.variables
diff --git a/ipylab/dialog.py b/ipylab/dialog.py
index 40df763b..9aa53252 100644
--- a/ipylab/dialog.py
+++ b/ipylab/dialog.py
@@ -3,7 +3,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Unpack
+from typing import TYPE_CHECKING
from ipywidgets import Widget
from traitlets import Unicode
@@ -11,11 +11,8 @@
from ipylab import Ipylab
if TYPE_CHECKING:
- from asyncio import Task
from typing import Any
- from ipylab.common import IpylabKwgs
-
def _combine(options: dict | None, **kwgs):
if options:
@@ -26,49 +23,45 @@ def _combine(options: dict | None, **kwgs):
class Dialog(Ipylab):
_model_name = Unicode("DialogModel", help="Name of the model.", read_only=True).tag(sync=True)
- def get_boolean(self, title: str, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[bool]:
+ async def get_boolean(self, title: str, options: dict | None = None):
"""Open a Jupyterlab dialog to get a boolean value.
see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.InputDialog.getBoolean.html
"""
- return self.operation("getBoolean", _combine(options, title=title), **kwgs)
+ return await self.operation("getBoolean", kwgs=_combine(options, title=title))
- def get_item(
- self, title: str, items: tuple | list, *, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]
- ) -> Task[str]:
+ async def get_item(self, title: str, items: tuple | list, *, options: dict | None = None) -> str:
"""Open a Jupyterlab 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/api/functions/apputils.InputDialog.getItem.html
"""
- return self.operation("getItem", _combine(options, title=title, items=tuple(items)), **kwgs)
+ return await self.operation("getItem", kwgs=_combine(options, title=title, items=tuple(items)))
+ # type: ignore
- def get_number(self, title: str, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[float]:
+ async def get_number(self, title: str, options: dict | None = None) -> float:
"""Open a Jupyterlab dialog to get a number.
see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.InputDialog.getNumber.html
"""
- return self.operation("getNumber", _combine(options, title=title), **kwgs)
+ return await self.operation("getNumber", kwgs=_combine(options, title=title))
+ # type: ignore
- def get_text(self, title: str, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[str]:
+ async def get_text(self, title: str, options: dict | None = None) -> str:
"""Open a Jupyterlab dialog to get a string.
see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.InputDialog.getText.html
"""
- return self.operation("getText", _combine(options, title=title), **kwgs)
+ return await self.operation("getText", kwgs=_combine(options, title=title))
+ # type: ignore
- def get_password(self, title: str, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[str]:
+ async def get_password(self, title: str, options: dict | None = None) -> str:
"""Open a Jupyterlab dialog to get a number.
see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.InputDialog.getPassword.html
"""
- return self.operation("getPassword", _combine(options, title=title), **kwgs)
-
- def show_dialog(
- self,
- title: str = "",
- body: str | Widget = "",
- options: dict | None = None,
- *,
- has_close=True,
- **kwgs: Unpack[IpylabKwgs],
- ) -> Task[dict[str, Any]]:
+ return await self.operation("getPassword", kwgs=_combine(options, title=title))
+ # type: ignore
+
+ async def show_dialog(
+ self, title: str = "", body: str | Widget = "", options: dict | None = None, *, has_close=True
+ ) -> dict[str, Any]:
"""Open a Jupyterlab dialog to get user response with custom buttons and
checkbox.
@@ -84,7 +77,7 @@ def show_dialog(
a widget or a react element
has_close: bool [True]
- By default (True), clicking outside the dialog will close it.
+ By default (), clicking outside the dialog will close it.
When `False`, the user must specifically cancel or accept a result.
options:
@@ -129,13 +122,11 @@ def show_dialog(
see: https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.showDialog.html
source: https://jupyterlab.readthedocs.io/en/stable/extension/ui_helpers.html#generic-dialog
"""
- if isinstance(body, Widget) and "toLuminoWidget" not in kwgs:
- kwgs["toLuminoWidget"] = ["body"]
- return self.operation("showDialog", _combine(options, title=title, body=body, hasClose=has_close), **kwgs)
+ kwgs = _combine(options, title=title, body=body, hasClose=has_close)
+ to_lumino_widget = ["body"] if isinstance(body, Widget) else None
+ return await self.operation("showDialog", kwgs=kwgs, toLuminoWidget=to_lumino_widget)
- def show_error_message(
- self, title: str, error: str, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]
- ) -> Task[None]:
+ async def show_error_message(self, title: str, error: str, options: dict | None = None):
"""Open a Jupyterlab error message dialog.
buttons = [
@@ -154,19 +145,19 @@ def show_error_message(
https://jupyterlab.readthedocs.io/en/stable/api/functions/apputils.showErrorMessage.html#showErrorMessage
"""
- return self.operation("showErrorMessage", _combine(options, title=title, error=error), **kwgs)
+ return await self.operation("showErrorMessage", kwgs=_combine(options, title=title, error=error))
- def get_open_files(self, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[list[str]]:
+ async def get_open_files(self, options: dict | None = None):
"""Get a list of files using a Jupyterlab dialog relative to the current
path in Jupyterlab.
https://jupyterlab.readthedocs.io/en/latest/api/functions/filebrowser.FileDialog.getOpenFiles.html#getOpenFiles
"""
- return self.operation("getOpenFiles", options, **kwgs)
+ return await self.operation("getOpenFiles", kwgs=options)
- def get_existing_directory(self, options: dict | None = None, **kwgs: Unpack[IpylabKwgs]) -> Task[str]:
+ async def get_existing_directory(self, options: dict | None = None) -> str:
"""Get an existing directory using a Jupyterlab dialog relative to the
current path in Jupyterlab.
https://jupyterlab.readthedocs.io/en/latest/api/functions/filebrowser.FileDialog.getExistingDirectory.html#getExistingDirectory
"""
- return self.operation("getExistingDirectory", options, **kwgs)
+ return await self.operation("getExistingDirectory", kwgs=options)
diff --git a/ipylab/hookspecs.py b/ipylab/hookspecs.py
index 9a6598ab..126cdb6d 100644
--- a/ipylab/hookspecs.py
+++ b/ipylab/hookspecs.py
@@ -10,22 +10,31 @@
hookspec = pluggy.HookspecMarker("ipylab")
if TYPE_CHECKING:
+ import asyncio
from collections.abc import Awaitable
import ipylab
+ from ipylab.log import IpylabLogHandler
@hookspec(firstresult=True)
-def launch_jupyterlab():
+def launch_ipylab():
"""A hook called to start Jupyterlab.
This is called by with the shell command `ipylab`.
"""
-@hookspec()
-async def ready(obj: ipylab.Ipylab) -> None | Awaitable[None]:
- """A hook that is called by `obj` when it is ready."""
+@hookspec(historic=True)
+async def autostart_once(app: ipylab.App) -> None | Awaitable[None]:
+ """A hook that is called when the `app` is ready for the first time.
+
+ Historic
+ --------
+
+ This plugin is historic so will be called when a plugin is registered if the
+ app is already ready.
+ """
@hookspec(historic=True)
@@ -46,7 +55,7 @@ def default_namespace_objects(namespace_id: str, app: ipylab.App) -> dict[str, A
@hookspec(firstresult=True)
-def vpath_getter(app: ipylab.App, kwgs: dict) -> Awaitable[str] | str: # type: ignore
+async def vpath_getter(app: ipylab.App, kwgs: dict) -> str: # type: ignore
"""A hook called during `app.shell.add` when `evaluate` is code and `vpath`
is passed as a dict.
@@ -56,5 +65,10 @@ def vpath_getter(app: ipylab.App, kwgs: dict) -> Awaitable[str] | str: # type:
@hookspec(firstresult=True)
-def default_editor_key_bindings(app: ipylab.App, obj: ipylab.CodeEditor):
- """Get the key bindings to use for the editor."""
+def get_asyncio_event_loop(app: ipylab.App) -> asyncio.AbstractEventLoop: # type: ignore
+ "Get the asyncio event loop."
+
+
+@hookspec(firstresult=True)
+def get_logging_handler(app: ipylab.App) -> IpylabLogHandler: # type: ignore
+ "Get the asyncio event loop."
diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py
index bf14325d..25222dac 100644
--- a/ipylab/ipylab.py
+++ b/ipylab/ipylab.py
@@ -4,55 +4,27 @@
from __future__ import annotations
import asyncio
-import contextlib
import inspect
import json
import uuid
-import weakref
-from typing import TYPE_CHECKING, Any, TypeVar
+from typing import TYPE_CHECKING, Any, cast
import traitlets
+from anyio import Event, create_memory_object_stream
from ipywidgets import Widget, register
-from traitlets import (
- Bool,
- Container,
- Dict,
- HasTraits,
- Instance,
- List,
- Set,
- TraitError,
- TraitType,
- Unicode,
- default,
- observe,
-)
-
-import ipylab
+from traitlets import Bool, Container, Dict, Instance, List, TraitType, Unicode, observe
+
import ipylab._frontend as _fe
-from ipylab.common import (
- Fixed,
- IpylabKwgs,
- Obj,
- TaskHooks,
- TaskHookType,
- Transform,
- TransformType,
- pack,
- trait_tuple_add,
-)
-from ipylab.log import IpylabLoggerAdapter
+from ipylab.common import HasApp, IpylabKwgs, Obj, Transform, TransformType, autorun, pack
if TYPE_CHECKING:
- from asyncio import Task
- from collections.abc import Awaitable, Callable, Hashable
- from typing import ClassVar, Self, Unpack
-
+ from collections.abc import Callable
+ from types import CoroutineType
+ from typing import Self, Unpack
-__all__ = ["Ipylab", "WidgetBase"]
+ from anyio.streams.memory import MemoryObjectSendStream
-T = TypeVar("T")
-L = TypeVar("L", bound="Ipylab")
+__all__ = ["Ipylab", "IpylabBase", "WidgetBase"]
class IpylabBase(TraitType[tuple[str, str], None]):
@@ -70,81 +42,68 @@ class IpylabFrontendError(IOError):
class WidgetBase(Widget):
+ """Base class for ipylab widgets.
+
+ Inherits from HasApp and Widget.
+
+ Attributes:
+ _model_name (Unicode): The name of the model. Must be overloaded.
+ _model_module (Unicode): The module name of the model.
+ _model_module_version (Unicode): The module version of the model.
+ _view_module (Unicode): The module name of the view.
+ _view_module_version (Unicode): The module version of the view.
+ _comm (Comm): The comm object.
+
+ """
+
_model_name = None # Ensure this gets overloaded
_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)
_comm = None
- add_traits = None # type: ignore # Don't support the method HasTraits.add_traits as it creates a new type that isn't a subclass of its origin)
+
+ @observe("comm")
+ def _observe_comm(self, _: dict):
+ if not self.comm:
+ self.close()
@register
-class Ipylab(WidgetBase):
- """The base class for Ipylab which has a corresponding frontend."""
+class Ipylab(HasApp, WidgetBase):
+ """A base class for creating ipylab widgets that inherit from an IpylabModel
+ in the frontend.
- SINGLE = False
+ Ipylab widgets are Jupyter widgets that are designed to interact with the
+ JupyterLab application. They provide a way to extend the functionality
+ of JupyterLab with custom Python code.
+ """
_model_name = Unicode("IpylabModel", help="Name of the model.", read_only=True).tag(sync=True)
_python_class = Unicode().tag(sync=True)
ipylab_base = IpylabBase(Obj.this, "").tag(sync=True)
_ready = Bool(read_only=True, help="Set to by frontend when ready").tag(sync=True)
-
- _on_ready_callbacks: Container[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
- _single_models: ClassVar[dict[str, Self]] = {} # model_id : Widget
- _ready_event: asyncio.Event | None = None
+ _on_ready_callbacks: Container[list[Callable[[Self], None | CoroutineType]]] = List(trait=traitlets.Callable())
+ _ready_event = Instance(Event, ())
_comm = None
-
- _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)
- log = Instance(IpylabLoggerAdapter, read_only=True)
-
- @classmethod
- def _single_key(cls, kwgs: dict) -> Hashable: # noqa: ARG003
- """The key used for finding instances when SINGLE is enabled."""
- return cls
+ _ipylab_init_complete = False
+ _pending_operations: Dict[str, MemoryObjectSendStream] = Dict()
@property
def repr_info(self) -> dict[str, Any] | str:
"Extra info to provide for __repr__."
return {}
- @default("log")
- def _default_log(self):
- return IpylabLoggerAdapter(self.__module__, owner=self)
-
- def __new__(cls, **kwgs) -> Self:
- model_id = kwgs.get("model_id") or cls._single_map.get(cls._single_key(kwgs)) if cls.SINGLE else None
- if model_id and model_id in cls._single_models:
- return cls._single_models[model_id]
- return super().__new__(cls)
-
def __init__(self, **kwgs):
- if self._async_widget_base_init_complete:
+ if self._ipylab_init_complete:
return
- # set traits, including read only traits.
- model_id = kwgs.pop("model_id", None)
for k in kwgs:
if self.has_trait(k):
self.set_trait(k, kwgs[k])
self.set_trait("_python_class", self.__class__.__name__)
- super().__init__(model_id=model_id) if model_id else super().__init__()
- model_id = self.model_id
- if not model_id:
- msg = "Failed to init comms"
- raise RuntimeError(msg)
- if key := self._single_key(kwgs) if self.SINGLE else None:
- self._single_map[key] = model_id
- self._single_models[model_id] = self
+ super().__init__()
+ self._ipylab_init_complete = True
self.on_msg(self._on_custom_msg)
- self._async_widget_base_init_complete = True
def __repr__(self):
if not self._repr_mimebundle_:
@@ -162,134 +121,52 @@ def __repr__(self):
return f"< {status}: {self.__class__.__name__}({info}) >"
return f"{status}{self.__class__.__name__}({info})"
- @observe("comm", "_ready")
- def _observe_comm(self, change: dict):
- if not self.comm:
- for task in self.ipylab_tasks:
- task.cancel()
- self.ipylab_tasks.clear()
- for item in list(self.close_extras):
- item.close()
- for obj, name in list(self._has_attrs_mappings):
- if val := getattr(obj, name, None):
- if val is self:
- with contextlib.suppress(TraitError):
- obj.set_trait(name, None)
- elif isinstance(val, tuple):
- obj.set_trait(name, tuple(v for v in val if v.comm))
- self._on_ready_callbacks.clear()
- if self.SINGLE:
- self._single_models.pop(change["old"].comm_id, None) # type: ignore
- if change["name"] == "_ready":
- if self._ready:
- if self._ready_event:
- self._ready_event.set()
- for cb in ipylab.plugin_manager.hook.ready(obj=self):
- self.ensure_run(cb)
- for cb in self._on_ready_callbacks:
- self.ensure_run(cb)
- elif self._ready_event:
- self._ready_event.clear()
-
- def _check_closed(self):
- if not self._repr_mimebundle_:
- msg = f"This widget is closed {self!r}"
- raise RuntimeError(msg)
-
- async def _wrap_awaitable(self, aw: Awaitable[T], hooks: TaskHookType) -> T:
- await self.ready()
- try:
- result = await aw
- if hooks:
- self._task_result(result, hooks)
- except Exception:
- self.log.exception("Task error", obj={"result": result, "hooks": hooks, "aw": aw})
- raise
- else:
- return result
-
- def _task_result(self: Ipylab, result: Any, hooks: TaskHooks):
- # close with
- for owner in hooks.pop("close_with_fwd", ()):
- # Close result with each item.
- if isinstance(owner, Ipylab) and isinstance(result, Widget):
- if not owner.comm:
- result.close()
- raise RuntimeError(str(owner))
- owner.close_extras.add(result)
- for obj_ in hooks.pop("close_with_rev", ()):
- # Close each item with the result.
- if isinstance(result, Ipylab):
- result.close_extras.add(obj_)
- # tuple add
- for owner, name in hooks.pop("add_to_tuple_fwd", ()):
- # Add each item of to tuple of result.
- if isinstance(result, Ipylab):
- result.add_to_tuple(owner, name)
- else:
- trait_tuple_add(owner, name, result)
- for name, value in hooks.pop("add_to_tuple_rev", ()):
- # Add the result the the tuple with 'name' for each item.
- if isinstance(value, Ipylab):
- value.add_to_tuple(result, name)
- else:
- trait_tuple_add(result, name, value)
- # trait add
- for name, value in hooks.pop("trait_add_fwd", ()):
- # Set each trait of result with value.
- if isinstance(value, Ipylab):
- value.add_as_trait(result, name)
- else:
- result.set_trait(name, value)
- for owner, name in hooks.pop("trait_add_rev", ()):
- # Set set trait of each value with result.
- if isinstance(result, Ipylab):
- result.add_as_trait(owner, name)
- else:
- owner.set_trait(name, result)
- for cb in hooks.pop("callbacks", ()):
- self.ensure_run(cb(result))
- if hooks:
- msg = f"Invalid hooks detected: {hooks}"
- raise ValueError(msg)
+ @observe("_ready")
+ def _observe_ready(self, _: dict):
+ if self._ready:
+ self._ready_event.set()
+ self._ready_event = Event()
+ for cb in self._on_ready_callbacks:
+ self._call_ready_callback(cb)
- def _task_done_callback(self, task: Task):
- self.ipylab_tasks.discard(task)
- # TODO: It'd be great if we could cancel in the frontend.
- # Unfortunately it looks like Javascript Promises can't be cancelled.
- # https://stackoverflow.com/questions/30233302/promise-is-it-possible-to-force-cancel-a-promise#30235261
+ def close(self):
+ if self.comm:
+ self._ipylab_send({"close": True})
+ super().close()
+ self._on_ready_callbacks.clear()
def _on_custom_msg(self, _, msg: dict, buffers: list):
content = msg.get("ipylab")
if not content:
return
try:
- c = json.loads(content)
+ c: dict[str, Any] = json.loads(content)
if "ipylab_PY" in c:
- 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"))
+ self._set_result(content=c)
elif "ipylab_FE" in c:
- return self.to_task(self._do_operation_for_fe(c["ipylab_FE"], c["operation"], c["payload"], buffers))
+ self._do_operation_for_fe(True, c["ipylab_FE"], c["operation"], c["payload"], buffers)
elif "closed" in c:
self.close()
else:
raise NotImplementedError(msg) # noqa: TRY301
- except Exception:
- self.log.exception("Message processing error", obj=msg)
-
- def _to_frontend_error(self, content):
- error = content["error"]
- operation = content.get("operation")
- if operation:
- msg = f'Operation "{operation}" failed with the message "{error}"'
- return IpylabFrontendError(msg)
- return IpylabFrontendError(error)
+ except Exception as e:
+ self.log.exception("Message processing error", obj=msg, exc_info=e)
+
+ @autorun
+ async def _set_result(self, content: dict[str, Any]):
+ send_stream = self._pending_operations.pop(content["ipylab_PY"])
+ if error := content.get("error"):
+ e = IpylabFrontendError(error)
+ e.add_note(f"{content=}")
+ value = e
+ else:
+ value = content.get("payload")
+ await send_stream.send(value)
+ @autorun
async def _do_operation_for_fe(self, ipylab_FE: str, operation: str, payload: dict, buffers: list | None):
"""Handle operation requests from the frontend and reply with a result."""
+ await self.ready()
content: dict[str, Any] = {"ipylab_FE": ipylab_FE}
buffers = []
try:
@@ -300,63 +177,48 @@ async def _do_operation_for_fe(self, ipylab_FE: str, operation: str, payload: di
content["payload"] = result
except asyncio.CancelledError:
content["error"] = "Cancelled"
- except Exception:
- self.log.exception("Operation for frontend error", obj={"operation": operation, "payload": payload})
+ except Exception as e:
+ content["error"] = f"{e.__class__.__name__}: {e}"
+ self.log.exception("Frontend operation", obj={"operation": operation, "payload": payload}, exc_info=e)
finally:
self._ipylab_send(content, buffers)
- async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list):
- """Perform an operation for a custom message with an ipylab_FE uuid."""
- raise NotImplementedError(operation)
-
- def _obj_operation(self, base: Obj, subpath: str, operation: str, kwgs, kwargs: IpylabKwgs):
+ async def _obj_operation(self, base: Obj, subpath: str, operation: str, kwgs, kwargs: IpylabKwgs):
+ await self.ready()
kwgs |= {"genericOperation": operation, "basename": base, "subpath": subpath}
- return self.operation("genericOperation", kwgs, **kwargs)
+ return await self.operation("genericOperation", kwgs=kwgs, **kwargs)
- def close(self):
- self._ipylab_send({"close": True})
- super().close()
-
- def ensure_run(self, aw: Callable | Awaitable | None) -> None:
- """Ensure aw is run.
+ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list) -> Any:
+ """Perform an operation for a custom message with an ipylab_FE uuid."""
+ # Overload as required
+ raise NotImplementedError(operation)
- Parameters
- ----------
- aw: Callable | Awaitable | None
- `aw` can be a function that accepts either no arguments or one keyword argument 'obj'.
- """
- try:
- if callable(aw):
- aw = aw(self) if len(inspect.signature(len).parameters) == 1 else aw()
- if inspect.iscoroutine(aw):
- self.to_task(aw, f"Ensure run {aw}")
- except Exception:
- self.log.exception("Ensure run", obj=aw)
- raise
+ def _call_ready_callback(self, callback: Callable[[Self], None | CoroutineType]):
+ result = callback(self)
+ if inspect.iscoroutine(result):
+ self.start_coro(result)
- async def ready(self):
- """Wait for the application to be ready.
+ async def ready(self) -> Self:
+ """Wait for the instance 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
- if self._ready_event:
- try:
- await self._ready_event.wait()
- # Event.wait is pinned to the event loop in which Event was created.
- # A Runtime error will occur when called from a different event loop.
- except RuntimeError:
- pass
- else:
- return
- self._ready_event = asyncio.Event()
+ self._check_closed()
+ if not self._ready:
await self._ready_event.wait()
+ return self
+
+ def on_ready(self, callback: Callable[[Self], None | CoroutineType], remove=False): # noqa: FBT002
+ """Register a historic callback to execute when the frontend indicates
+ it is ready.
- def on_ready(self, callback, remove=False): # noqa: FBT002
- """Register a callback to execute when the application is ready.
+ `historic` meaning that the callback will be called immediately if the
+ instance is already ready.
+
+ It will be called when the instance is first created, and subsequently
+ when the fronted is reloaded, such as when the page is refreshed or the
+ workspace is reloaded.
The callback will be executed only once.
@@ -368,57 +230,21 @@ def on_ready(self, callback, remove=False): # noqa: FBT002
If True, remove the callback from the list of callbacks.
By default, False.
"""
- if not remove:
+ if not remove and callback not in self._on_ready_callbacks:
self._on_ready_callbacks.append(callback)
+ if self._ready:
+ self._call_ready_callback(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."""
-
- items = getattr(owner, name)
- if self.comm and self not in items:
- owner.set_trait(name, (*items, self))
- # see: _observe_comm for removal
- self._has_attrs_mappings.add((owner, name))
-
- def add_as_trait(self, obj: HasTraits, name: str):
- "Add self as a trait to obj."
- self._check_closed()
- obj.set_trait(name, self)
- # see: _observe_comm for removal
- self._has_attrs_mappings.add((obj, name))
-
def _ipylab_send(self, content, buffers: list | None = None):
try:
self.send({"ipylab": json.dumps(content, default=pack)}, buffers)
- except Exception:
- self.log.exception("Send error", obj=content)
+ except Exception as e:
+ self.log.exception("Send error", obj=content, exc_info=e)
raise
- def to_task(self, aw: Awaitable[T], name: str | None = None, *, hooks: TaskHookType = None) -> Task[T]:
- """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.
-
- aw: An awaitable to run in the task.
-
- name: str
- The name of the task.
-
- hooks: TaskHookType
-
- """
-
- self._check_closed()
- 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(
+ async def operation(
self,
operation: str,
kwgs: dict | None = None,
@@ -426,9 +252,8 @@ def operation(
transform: TransformType = Transform.auto,
toLuminoWidget: list[str] | None = None,
toObject: list[str] | None = None,
- hooks: TaskHookType = None,
- ) -> Task[Any]:
- """Create a new task requesting an operation to be performed in the frontend.
+ ) -> Any:
+ """Perform an operation in the frontend.
operation: str
Name corresponding to operation in JS frontend.
@@ -444,12 +269,9 @@ def operation(
toObject: List[str] | None
A list of item name mappings to convert to objects in the frontend prior
to performing the operation.
-
- hooks: TaskHookType
- see: TaskHooks
"""
# validation
- self._check_closed()
+ await self.ready()
if not operation or not isinstance(operation, str):
msg = f"Invalid {operation=}"
raise ValueError(msg)
@@ -465,28 +287,101 @@ def operation(
if toObject:
content["toObject"] = toObject
- self._pending_operations[ipylab_PY] = op = asyncio.get_running_loop().create_future()
+ send_stream, receive_stream = create_memory_object_stream()
+ self._pending_operations[ipylab_PY] = send_stream
+ self._ipylab_send(content)
+ result = await receive_stream.receive()
+ if isinstance(result, Exception):
+ raise result
+ result = await Transform.transform_payload(content["transform"], result)
+ return cast(Any, result)
- async def _operation(content: dict):
- self._ipylab_send(content)
- payload = await op
- return Transform.transform_payload(content["transform"], payload)
+ async def execute_method(self, subpath: str, args: tuple = (), obj=Obj.base, **kwargs: Unpack[IpylabKwgs]) -> Any:
+ """Execute a method on a remote object in the frontend.
- return self.to_task(_operation(content), name=ipylab_PY, hooks=hooks)
+ Parameters
+ ----------
+ subpath : str
+ The path to the method to execute, relative to the object.
+ args : tuple, optional
+ The positional arguments to pass to the method, by default ().
+ obj : Obj, optional
+ The object on which to execute the method, by default Obj.base.
+ **kwargs : Unpack[IpylabKwgs]
+ The keyword arguments to pass to the method.
+
+ Returns
+ -------
+ Any
+ The result of the method call.
+ """
+ return await self._obj_operation(obj, subpath, "executeMethod", {"args": args}, kwargs)
- def execute_method(self, subpath: str, *args, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]):
- return self._obj_operation(obj, subpath, "executeMethod", {"args": args}, kwargs)
+ async def get_property(self, subpath: str, *, obj=Obj.base, null_if_missing=False, **kwargs: Unpack[IpylabKwgs]):
+ """Get a property from an object in the frontend.
- def get_property(self, subpath: str, *, obj=Obj.base, null_if_missing=False, **kwargs: Unpack[IpylabKwgs]):
- return self._obj_operation(obj, subpath, "getProperty", {"null_if_missing": null_if_missing}, kwargs)
+ Parameters
+ ----------
+ subpath: str
+ The path to the property to get, e.g. "foo.bar".
+ obj: Obj
+ The object to get the property from.
+ null_if_missing: bool
+ If True, return None if the property is missing.
+ **kwargs: Unpack[IpylabKwgs]
+ Keyword arguments to pass to the Javascript function.
+
+ Returns
+ -------
+ Any
+ The value of the property.
+ """
+ return await self._obj_operation(obj, subpath, "getProperty", {"null_if_missing": null_if_missing}, kwargs)
+
+ async def set_property(self, subpath: str, value, *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]) -> None:
+ """Set a property of an object in the frontend.
- def set_property(self, subpath: str, value, *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]):
- return self._obj_operation(obj, subpath, "setProperty", {"value": value}, kwargs)
+ Args:
+ subpath: The path to the property to set.
+ value: The value to set the property to.
+ obj: The JavaScript object to set the property on. Defaults to Obj.base.
+ **kwargs: Keyword arguments to pass to the JavaScript function.
+
+ Returns:
+ None
+ """
+ return await self._obj_operation(obj, subpath, "setProperty", {"value": value}, kwargs)
- def update_property(self, subpath: str, value: dict[str, Any], *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]):
- return self._obj_operation(obj, subpath, "updateProperty", {"value": value}, kwargs)
+ async def update_property(
+ self, subpath: str, value: dict[str, Any], *, obj=Obj.base, **kwargs: Unpack[IpylabKwgs]
+ ) -> dict[str, Any]:
+ """Update a property of an object in the frontend equivalent to a `dict.update` call.
- def list_properties(
+ Args:
+ subpath: The path to the property to update.
+ value: A mapping of the items to override (existing non-mapped values remain).
+ obj: The object to update. Defaults to Obj.base.
+ **kwargs: Keyword arguments to pass to the _obj_operation method.
+
+ Returns:
+ The updated property.
+ """
+ return await self._obj_operation(obj, subpath, "updateProperty", {"value": value}, kwargs)
+
+ async def list_properties(
self, subpath="", *, obj=Obj.base, depth=3, skip_hidden=True, **kwargs: Unpack[IpylabKwgs]
- ) -> Task[dict]:
- return self._obj_operation(obj, subpath, "listProperties", {"depth": depth, "omitHidden": skip_hidden}, kwargs)
+ ) -> dict[str, Any]:
+ """List properties of a given object in the frontend.
+
+ Args:
+ subpath (str, optional): Subpath to the object. Defaults to "".
+ obj (Obj, optional): Object to list properties from. Defaults to Obj.base.
+ depth (int, optional): Depth of the listing. Defaults to 3.
+ skip_hidden (bool, optional): Whether to skip hidden properties. Defaults to True.
+ **kwargs (Unpack[IpylabKwgs]): Additional keyword arguments.
+
+ Returns:
+ dict[str, Any]: Dictionary of properties.
+ """
+ kwgs = {"depth": depth, "omitHidden": skip_hidden}
+ return await self._obj_operation(obj, subpath, "listProperties", kwgs, kwargs)
diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py
index 4a0f0826..b669aea4 100644
--- a/ipylab/jupyterfrontend.py
+++ b/ipylab/jupyterfrontend.py
@@ -3,22 +3,24 @@
from __future__ import annotations
+import asyncio
import contextlib
import functools
import inspect
-from typing import TYPE_CHECKING, Any, Unpack, override
+from typing import TYPE_CHECKING, Any, Self, Unpack, final
from ipywidgets import Widget, register
from traitlets import Bool, Container, Dict, Instance, Unicode, UseEnum, default, observe
+from typing_extensions import override
import ipylab
from ipylab import Ipylab
from ipylab.commands import APP_COMMANDS_NAME, CommandPalette, CommandRegistry
-from ipylab.common import Fixed, IpylabKwgs, LastUpdatedDict, Obj, to_selector
+from ipylab.common import Fixed, IpylabKwgs, LastUpdatedDict, Obj, Singular, to_selector
from ipylab.dialog import Dialog
from ipylab.ipylab import IpylabBase
from ipylab.launcher import Launcher
-from ipylab.log import IpylabLogFormatter, IpylabLogHandler, LogLevel
+from ipylab.log import IpylabLogHandler, LogLevel
from ipylab.menu import ContextMenu, MainMenu
from ipylab.notification import NotificationManager
from ipylab.sessions import SessionManager
@@ -29,14 +31,14 @@
from typing import ClassVar
+@final
@register
-class App(Ipylab):
+class App(Singular, Ipylab):
"""A connection to the 'app' in the frontend.
A singleton (per kernel) not to be subclassed or closed.
"""
- SINGLE = True
DEFAULT_COMMANDS: ClassVar = {"Open console", "Show log viewer"}
_model_name = Unicode("JupyterFrontEndModel").tag(sync=True)
ipylab_base = IpylabBase(Obj.IpylabModel, "app").tag(sync=True)
@@ -47,32 +49,30 @@ class App(Ipylab):
shell = Fixed(Shell)
dialog = Fixed(Dialog)
notification = Fixed(NotificationManager)
- commands = Fixed(CommandRegistry, name=APP_COMMANDS_NAME)
+ commands = Fixed(lambda _: CommandRegistry(name=APP_COMMANDS_NAME))
launcher = Fixed(Launcher)
main_menu = Fixed(MainMenu)
command_pallet = Fixed(CommandPalette)
- context_menu = Fixed(ContextMenu, commands=lambda app: app.commands, dynamic=["commands"])
+ context_menu: Fixed[Self, ContextMenu] = Fixed(lambda c: ContextMenu(commands=c["owner"].commands))
sessions = Fixed(SessionManager)
- logging_handler: Instance[IpylabLogHandler | None] = Instance(IpylabLogHandler, allow_none=True) # type: ignore
+ logging_handler: Fixed[Self, IpylabLogHandler] = Fixed(
+ lambda c: ipylab.plugin_manager.hook.get_logging_handler(app=c["owner"]),
+ created=lambda c: c["owner"].shell.log_viewer,
+ )
log_level = UseEnum(LogLevel, LogLevel.ERROR)
+ asyncio_loop: Instance[asyncio.AbstractEventLoop | None] = Instance(asyncio.AbstractEventLoop, allow_none=True) # type: ignore
namespaces: Container[dict[str, LastUpdatedDict]] = Dict(read_only=True) # type: ignore
- @classmethod
@override
- def _single_key(cls, kwgs: dict):
- return "app"
+ def close(self, *, force=False):
+ if force:
+ super().close()
- def close(self):
- "Cannot close"
-
- @default("logging_handler")
- def _default_logging_handler(self):
- fmt = "%(color)s%(level_symbol)s %(asctime)s.%(msecs)d %(name)s %(owner_rep)s: %(message)s %(reset)s\n"
- handler = IpylabLogHandler(self.log_level)
- handler.setFormatter(IpylabLogFormatter(fmt=fmt, style="%", datefmt="%H:%M:%S"))
- return handler
+ @default("asyncio_loop")
+ def _default_asyncio_loop(self):
+ return ipylab.plugin_manager.hook.get_asyncio_event_loop(app=self)
@observe("_ready", "log_level")
def _app_observe_ready(self, change):
@@ -81,16 +81,21 @@ def _app_observe_ready(self, change):
self._selector = to_selector(self._vpath)
ipylab.plugin_manager.hook.autostart._call_history.clear() # type: ignore # noqa: SLF001
try:
+ if not ipylab.plugin_manager.hook.autostart_once._call_history: # noqa: SLF001
+ ipylab.plugin_manager.hook.autostart_once.call_historic(
+ kwargs={"app": self}, result_callback=self._autostart_callback
+ )
ipylab.plugin_manager.hook.autostart.call_historic(
kwargs={"app": self}, result_callback=self._autostart_callback
)
- except Exception:
- self.log.exception("Error with autostart")
+ except Exception as e:
+ self.log.exception("Error with autostart", exc_info=e)
if self.logging_handler:
self.logging_handler.setLevel(self.log_level)
def _autostart_callback(self, result):
- self.ensure_run(result)
+ if inspect.iscoroutine(result):
+ self.start_coro(result)
@property
def repr_info(self):
@@ -137,7 +142,7 @@ def selector(self):
return self._selector
@override
- async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list) -> Any:
+ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list):
match operation:
case "evaluate":
return await self._evaluate(payload, buffers)
@@ -151,9 +156,9 @@ async def _do_operation_for_frontend(self, operation: str, payload: dict, buffer
return await super()._do_operation_for_frontend(operation, payload, buffers)
- def shutdown_kernel(self, vpath: str | None = None):
+ async def shutdown_kernel(self, vpath: str | None = None):
"Shutdown the kernel"
- return self.operation("shutdownKernel", {"vpath": vpath})
+ await self.operation("shutdownKernel", {"vpath": vpath})
def start_iyplab_python_kernel(self, *, restart=False):
"Start the 'ipylab' Python kernel."
@@ -169,7 +174,7 @@ def get_namespace(self, namespace_id="", **objects) -> LastUpdatedDict:
`default_namespace_objects`.
Note:
- To remove a namespace call `ipylab.app.namespaces.pop()`.
+ To remove a namespace call `app.namespaces.pop()`.
The default namespace `""` will also load objects from `shell.user_ns` if
the kernel is an ipykernel (the default kernel provided in Jupyterlab).
@@ -198,46 +203,59 @@ async def _evaluate(self, options: dict[str, Any], buffers: list):
A call to this method should originate from a call to `evaluate` from
app in another kernel. The call is sent as a message via the frontend."""
- evaluate = options["evaluate"]
- if isinstance(evaluate, str):
- evaluate = (evaluate,)
- namespace_id = options.get("namespace_id", "")
- ns = self.get_namespace(namespace_id, buffers=buffers)
- for row in evaluate:
- name, expression = ("payload", row) if isinstance(row, str) else row
- try:
- result = eval(expression, ns) # noqa: S307
- except SyntaxError:
- exec(expression, ns) # noqa: S102
- result = next(reversed(ns.values())) # Requires: LastUpdatedDict
- if not name:
- continue
- while callable(result) or inspect.isawaitable(result):
- if callable(result):
- kwgs = {}
- for p in inspect.signature(result).parameters:
- if p in options:
- kwgs[p] = options[p]
- elif p in ns:
- kwgs[p] = ns[p]
- # We use a partial so that we can evaluate with the same namespace.
- ns["_partial_call"] = functools.partial(result, **kwgs)
- result = eval("_partial_call()", ns) # type: ignore # noqa: S307
- ns.pop("_partial_call")
- if inspect.isawaitable(result):
- result = await result
- if name:
- ns[name] = result
- buffers = ns.pop("buffers", [])
- payload = ns.pop("payload", None)
- if payload is not None:
- ns["_call_count"] = n = ns.get("_call_count", 0) + 1
- ns[f"payload_{n}"] = payload
- if namespace_id == "":
- self.shell.add_objects_to_ipython_namespace(ns)
- return {"payload": payload, "buffers": buffers}
-
- def evaluate(
+ try:
+ evaluate = options["evaluate"]
+ if isinstance(evaluate, str):
+ evaluate = (evaluate,)
+ namespace_id = options.get("namespace_id", "")
+ ns = self.get_namespace(namespace_id, buffers=buffers)
+ for row in evaluate:
+ name, expression = ("payload", row) if isinstance(row, str) else row
+ if expression.startswith("import_item(dottedname="):
+ result = eval(expression, {"import_item": ipylab.common.import_item}) # noqa: S307
+ else:
+ try:
+ source = compile(expression, "-- Evaluate --", "eval")
+ except SyntaxError:
+ source = compile(expression, "-- Expression --", "exec")
+ exec(source, ns) # noqa: S102
+ result = next(reversed(ns.values())) # Requires: LastUpdatedDict
+ else:
+ result = eval(source, ns) # noqa: S307
+ if not name:
+ continue
+ while callable(result) or inspect.isawaitable(result):
+ if callable(result):
+ kwgs = {}
+ for p in inspect.signature(result).parameters:
+ if p in options:
+ kwgs[p] = options[p]
+ elif p in ns:
+ kwgs[p] = ns[p]
+ # We use a partial so that we can evaluate with the same namespace.
+ ns["_partial_call"] = functools.partial(result, **kwgs)
+ source = compile("_partial_call()", "-- Result call --", "eval")
+ result = eval(source, ns) # type: ignore # noqa: S307
+ ns.pop("_partial_call")
+ if inspect.isawaitable(result):
+ result = await result
+ if name:
+ ns[name] = result
+ buffers = ns.pop("buffers", [])
+ payload = ns.pop("payload", None)
+ if payload is not None:
+ ns["_call_count"] = n = ns.get("_call_count", 0) + 1
+ ns[f"payload_{n}"] = payload
+ if namespace_id == "":
+ self.shell.add_objects_to_ipython_namespace(ns)
+ except BaseException as e:
+ if isinstance(e, NameError):
+ e.add_note("Tip: Check for missing an imports?")
+ raise
+ else:
+ return {"payload": payload, "buffers": buffers}
+
+ async def evaluate(
self,
evaluate: str | inspect._SourceObjectType | Iterable[str | tuple[str, str | inspect._SourceObjectType]],
*,
@@ -298,7 +316,7 @@ def evaluate(
simple:
``` python
task = app.evaluate(
- "ipylab.app.shell.open_console",
+ "app.shell.open_console",
vpath="test",
kwgs={"mode": ipylab.InsertMode.split_right, "activate": False},
)
@@ -321,8 +339,9 @@ async def do_something(widget, area):
# Task result should be a ShellConnection
```
"""
- kwgs = (kwgs or {}) | {"evaluate": evaluate, "vpath": vpath, "namespace_id": namespace_id}
- return self.operation("evaluate", kwgs, **kwargs)
+ await self.ready()
+ kwgs = (kwgs or {}) | {"evaluate": evaluate, "vpath": vpath or self.vpath, "namespace_id": namespace_id}
+ return await self.operation("evaluate", kwgs=kwgs, **kwargs)
JupyterFrontEnd = App
diff --git a/ipylab/launcher.py b/ipylab/launcher.py
index 616d9f52..c7d5f07c 100644
--- a/ipylab/launcher.py
+++ b/ipylab/launcher.py
@@ -3,19 +3,13 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
-
from ipywidgets import TypedTuple
from traitlets import Container, Instance
-from ipylab.commands import CommandConnection, CommandPalletItemConnection, CommandRegistry
-from ipylab.common import Obj, TaskHooks
+from ipylab.commands import CommandConnection, CommandPalletItemConnection
+from ipylab.common import Obj, Singular, TransformType
from ipylab.ipylab import Ipylab, IpylabBase, Transform
-if TYPE_CHECKING:
- from asyncio import Task
-
-
__all__ = ["LauncherConnection"]
@@ -23,26 +17,29 @@ class LauncherConnection(CommandPalletItemConnection):
"""An Ipylab launcher item."""
-cid: str
-
-
-class Launcher(Ipylab):
+class Launcher(Singular, Ipylab):
"""
ref: https://jupyterlab.readthedocs.io/en/latest/api/interfaces/launcher.ILauncher-1.html"""
- SINGLE = True
-
ipylab_base = IpylabBase(Obj.IpylabModel, "launcher").tag(sync=True)
connections: Container[tuple[LauncherConnection, ...]] = TypedTuple(trait=Instance(LauncherConnection))
- def add(self, cmd: CommandConnection, category: str, *, rank=None, **args) -> Task[LauncherConnection]:
- """Add a launcher for the command (must be registered in this kernel).
+ async def add(self, cmd: CommandConnection, category: str, *, rank=None, **args) -> LauncherConnection:
+ """Add a launcher for the command (must be registered in app.commands in this kernel).
ref: https://jupyterlab.readthedocs.io/en/latest/api/interfaces/launcher.ILauncher.IItemOptions.html
"""
+ await self.ready()
+ await cmd.ready()
+ commands = await self.app.commands.ready()
+ if str(cmd) not in commands.all_commands:
+ msg = f"{cmd=} is not registered in app command registry app.commands!"
+ raise RuntimeError(msg)
cid = LauncherConnection.to_cid(cmd, category)
- CommandRegistry._check_belongs_to_application_registry(cid) # noqa: SLF001
- hooks: TaskHooks = {"close_with_fwd": [cmd], "add_to_tuple_fwd": [(self, "connections")]}
args = {"command": str(cmd), "category": category, "rank": rank, "args": args}
- return self.execute_method("add", args, transform={"transform": Transform.connection, "cid": cid}, hooks=hooks)
+ transform: TransformType = {"transform": Transform.connection, "cid": cid}
+ lc: LauncherConnection = await self.execute_method("add", (args,), transform=transform)
+ cmd.close_with_self(lc)
+ lc.add_to_tuple(self, "connections")
+ return lc
diff --git a/ipylab/lib.py b/ipylab/lib.py
index 374a0996..0f250aa1 100644
--- a/ipylab/lib.py
+++ b/ipylab/lib.py
@@ -9,16 +9,16 @@
import ipylab
from ipylab.common import hookimpl
+from ipylab.log import IpylabLogFormatter, IpylabLogHandler
if TYPE_CHECKING:
from collections.abc import Awaitable
from ipylab import App
- from ipylab.ipylab import Ipylab
@hookimpl
-def launch_jupyterlab():
+def launch_ipylab():
import sys
from jupyterlab.labapp import LabApp
@@ -41,20 +41,33 @@ async def autostart(app: ipylab.App) -> None | Awaitable[None]:
@hookimpl
-def vpath_getter(app: App, kwgs: dict) -> Awaitable[str] | str:
- return app.dialog.get_text(**kwgs)
+async def autostart_once(app: ipylab.App) -> None:
+ pass
@hookimpl
-def ready(obj: Ipylab):
- "Pass through"
+async def vpath_getter(app: App, kwgs: dict) -> str:
+ return await app.dialog.get_text(**kwgs)
@hookimpl
-def default_editor_key_bindings(app: ipylab.App, obj: ipylab.CodeEditor): # noqa: ARG001
- return {}
+def default_namespace_objects(namespace_id: str, app: ipylab.App) -> dict:
+ return {"ipylab": ipylab, "ipw": ipywidgets, "app": app, "namespace_id": namespace_id}
@hookimpl
-def default_namespace_objects(namespace_id: str, app: ipylab.App):
- return {"ipylab": ipylab, "ipw": ipywidgets, "app": app, "namespace_id": namespace_id}
+def get_asyncio_event_loop(app: ipylab.App):
+ try:
+ return app.comm.kernel.asyncio_event_loop # type: ignore
+ except AttributeError:
+ import asyncio
+
+ return asyncio.get_running_loop()
+
+
+@hookimpl
+def get_logging_handler(app: ipylab.App) -> IpylabLogHandler:
+ fmt = "%(color)s%(level_symbol)s %(asctime)s.%(msecs)d %(name)s %(owner_rep)s: %(message)s %(reset)s\n"
+ handler = IpylabLogHandler(app.log_level)
+ handler.setFormatter(IpylabLogFormatter(fmt=fmt, style="%", datefmt="%H:%M:%S"))
+ return handler
diff --git a/ipylab/log.py b/ipylab/log.py
index f7f2a5ba..99be7e49 100644
--- a/ipylab/log.py
+++ b/ipylab/log.py
@@ -6,15 +6,18 @@
import logging
import weakref
from enum import IntEnum, StrEnum
-from typing import TYPE_CHECKING, Any, ClassVar, override
+from typing import TYPE_CHECKING, Any, ClassVar
from IPython.core.ultratb import FormattedTB
from ipywidgets import CallbackDispatcher
+from typing_extensions import override
import ipylab
+from ipylab.common import Fixed
if TYPE_CHECKING:
- from asyncio import Task
+ from collections.abc import MutableMapping
+
__all__ = ["LogLevel", "IpylabLogHandler"]
@@ -71,23 +74,24 @@ def truncated_repr(obj: Any, maxlen=120, tail="…") -> str:
class IpylabLoggerAdapter(logging.LoggerAdapter):
+ app = Fixed(lambda _: ipylab.App())
+
def __init__(self, name: str, owner: Any) -> None:
logger = logging.getLogger(name)
- if handler := ipylab.app.logging_handler:
+ if handler := self.app.logging_handler:
handler._add_logger(logger) # noqa: SLF001
super().__init__(logger)
self.owner_ref = weakref.ref(owner)
- def process(self, msg: Any, kwargs: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
+ def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, MutableMapping[str, Any]]:
obj = kwargs.pop("obj", None)
kwargs["extra"] = {"owner": self.owner_ref, "obj": obj}
return msg, kwargs
class IpylabLogHandler(logging.Handler):
- _log_notify_task: Task | None = None
_loggers: ClassVar[weakref.WeakSet[logging.Logger]] = weakref.WeakSet()
- formatter: IpylabLogFormatter
+ formatter: IpylabLogFormatter # type: ignore
def __init__(self, level: LogLevel) -> None:
super().__init__(level)
@@ -100,7 +104,7 @@ def _add_logger(self, logger: logging.Logger):
logger.addHandler(self)
@override
- def setLevel(self, level: LogLevel) -> None:
+ def setLevel(self, level: LogLevel) -> None: # type: ignore
level = LogLevel(level)
super().setLevel(level)
for logger in self._loggers:
@@ -125,6 +129,8 @@ def register_callback(self, callback, *, remove=False):
class IpylabLogFormatter(logging.Formatter):
+ app = Fixed(lambda _: ipylab.App())
+
def __init__(self, *, colors: dict[LogLevel, ANSIColors] = COLORS, reset=ANSIColors.reset, **kwargs) -> None:
"""Initialize the formatter with specified format strings."""
self.colors = colors
@@ -148,8 +154,8 @@ def get_ref(self, record, key):
def formatException(self, ei) -> str: # noqa: N802
if not ei[0]:
return ""
- tbf = self.tb_formatter
- if ipylab.app.logging_handler:
- tbf.verbose if ipylab.app.logging_handler.level == LogLevel.DEBUG else tbf.minimal # noqa: B018
- return tbf.stb2text(tbf.structured_traceback(*ei))
+ if self.app.logging_handler:
+ tbf = self.tb_formatter
+ tbf.verbose if self.app.logging_handler.level == LogLevel.DEBUG else tbf.minimal # noqa: B018
+ return tbf.stb2text(tbf.structured_traceback(*ei)) # type: ignore
return super().formatException(ei)
diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py
index c587b2ea..76f503c7 100644
--- a/ipylab/log_viewer.py
+++ b/ipylab/log_viewer.py
@@ -4,22 +4,22 @@
from __future__ import annotations
import collections
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Self
+from IPython.display import Markdown
from ipywidgets import HTML, BoundedIntText, Button, Checkbox, Combobox, Dropdown, HBox, Select, VBox
from traitlets import directional_link, link, observe
+from typing_extensions import override
import ipylab
-from ipylab.common import SVGSTR_TEST_TUBE, Area, Fixed, InsertMode
+from ipylab.common import SVGSTR_TEST_TUBE, Fixed, InsertMode, autorun
from ipylab.log import LogLevel
from ipylab.simple_output import AutoScroll, SimpleOutput
-from ipylab.widgets import Icon, Panel
+from ipylab.widgets import AddToShellType, Icon, Panel
if TYPE_CHECKING:
import logging
- from asyncio import Task
- from ipylab.connection import ShellConnection
__all__ = ["LogViewer"]
@@ -27,107 +27,129 @@
class LogViewer(Panel):
"A log viewer and an object viewer combined."
- _log_notify_task: None | Task = None
_updating = False
- info = Fixed(HTML, layout={"flex": "1 0 auto", "margin": "0px 20px 0px 20px"})
- log_level = Fixed(
- Dropdown,
- description="Level",
- options=[(v.name.capitalize(), v) for v in LogLevel],
- layout={"width": "max-content"},
+ info = Fixed(lambda _: HTML(layout={"flex": "1 0 auto", "margin": "0px 20px 0px 20px"}))
+ add_to_shell_defaults = AddToShellType(mode=InsertMode.split_bottom)
+
+ log_level = Fixed[Self, Dropdown](
+ lambda _: Dropdown(
+ description="Level",
+ options=[(v.name.capitalize(), v) for v in LogLevel],
+ layout={"width": "max-content"},
+ ),
+ created=lambda c: link(
+ source=(c["owner"].app, "log_level"),
+ target=(c["obj"], "value"),
+ ),
)
- buffer_size = Fixed(
- BoundedIntText,
- description="Buffer size",
- min=1,
- max=1e6,
- layout={"width": "max-content", "flex": "0 0 auto"},
- created=lambda c: c["obj"].observe(c["owner"]._observe_buffer_size, "value"), # noqa: SLF001
+ buffer_size: Fixed[Self, BoundedIntText] = Fixed(
+ lambda _: BoundedIntText(
+ value=100,
+ description="Buffer size",
+ min=1,
+ max=1e6,
+ layout={"width": "max-content", "flex": "0 0 auto"},
+ ),
+ created=lambda c: (
+ c["obj"].observe(c["owner"]._observe_buffer_size, "value"), # noqa: SLF001
+ link(
+ source=(c["obj"], "value"),
+ target=(c["owner"].output, "max_outputs"),
+ ),
+ directional_link(
+ source=(c["owner"].output, "length"),
+ target=(c["obj"], "tooltip"),
+ transform=lambda size: f"Current size: {size}",
+ ),
+ ),
)
- button_show_send_dialog = Fixed(
- Button,
- description="📪",
- tooltip="Send the record to the console.\n"
- "The record has the properties 'owner' and 'obj'attached "
- "which may be of interest for debugging purposes.",
- layout={"width": "auto", "flex": "0 0 auto"},
+ button_show_send_dialog = Fixed[Self, Button](
+ lambda _: Button(
+ description="📪",
+ tooltip="Send the record to the console.\n"
+ "The record has the properties 'owner' and 'obj'attached "
+ "which may be of interest for debugging purposes.",
+ layout={"width": "auto", "flex": "0 0 auto"},
+ ),
+ created=lambda c: c["obj"].on_click(c["owner"]._button_on_click), # noqa: SLF001
)
- button_clear = Fixed(
- Button,
- description="⌧",
- tooltip="Clear log",
- layout={"width": "auto", "flex": "0 0 auto"},
+ button_clear = Fixed[Self, Button](
+ lambda _: Button(
+ description="⌧",
+ tooltip="Clear log",
+ layout={"width": "auto", "flex": "0 0 auto"},
+ ),
+ created=lambda c: c["obj"].on_click(c["owner"]._button_on_click), # noqa: SLF001
)
autoscroll_enabled = Fixed(
- Checkbox,
- description="Auto scroll",
- indent=False,
- tooltip="Automatically scroll to the most recent logs.",
- layout={"width": "auto", "flex": "0 0 auto"},
+ lambda _: Checkbox(
+ description="Auto scroll",
+ indent=False,
+ tooltip="Automatically scroll to the most recent logs.",
+ layout={"width": "auto", "flex": "0 0 auto"},
+ ),
)
- _default_header_children = (
- "info",
- "autoscroll_enabled",
- "log_level",
- "buffer_size",
- "button_clear",
- "button_show_send_dialog",
- )
- header = Fixed(
- HBox,
- children=lambda owner: [w for v in owner._default_header_children if (w := getattr(owner, v, None))], # noqa: SLF001
- layout={"justify_content": "space-between", "flex": "0 0 auto"},
- dynamic=["children"],
+ header: Fixed[Self, HBox] = Fixed(
+ lambda c: HBox(
+ children=(
+ c["owner"].info,
+ c["owner"].autoscroll_enabled,
+ c["owner"].log_level,
+ c["owner"].buffer_size,
+ c["owner"].button_clear,
+ c["owner"].button_show_send_dialog,
+ ),
+ layout={"justify_content": "space-between", "flex": "0 0 auto"},
+ ),
)
output = Fixed(SimpleOutput)
- autoscroll_widget = Fixed(AutoScroll, content=lambda v: v.output, dynamic=["content"])
+ autoscroll_widget: Fixed[Self, AutoScroll] = Fixed(
+ lambda c: AutoScroll(content=c["owner"].output),
+ created=lambda c: link(
+ source=(c["owner"].autoscroll_enabled, "value"),
+ target=(c["obj"], "enabled"),
+ ),
+ )
- def __init__(self, buffersize=100):
- self._records = collections.deque(maxlen=buffersize)
+ def __init__(self):
+ self._records = collections.deque(maxlen=100)
self.title.icon = Icon(name="ipylab-test_tube", svgstr=SVGSTR_TEST_TUBE)
super().__init__(children=[self.header, self.autoscroll_widget])
- self.buffer_size.value = buffersize
- app = ipylab.app
- link((self.autoscroll_widget, "enabled"), (self.autoscroll_enabled, "value"))
- link((app, "log_level"), (self.log_level, "value"))
- link((self.buffer_size, "value"), (self.output, "max_outputs"))
- directional_link(
- (self.output, "length"), (self.buffer_size, "tooltip"), transform=lambda size: f"Current size: {size}"
- )
- if app.logging_handler:
- app.logging_handler.register_callback(self._add_record)
- self.button_show_send_dialog.on_click(self._button_on_click)
- self.button_clear.on_click(self._button_on_click)
+ if self.app.logging_handler:
+ self.app.logging_handler.register_callback(self._add_record)
- def close(self):
- "Cannot close"
+ @override
+ def close(self, *, force=False):
+ if force:
+ super().close()
@observe("connections")
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}" # noqa: SLF001
- self.title.label = f"Log: {ipylab.app._vpath}" # noqa: SLF001
+ self.output.push(*(rec.output for rec in self._records), clear=True) # type: ignore
+ self.info.value = f"Vpath: {self.app._vpath}" # noqa: SLF001
+ self.title.label = f"Log: {self.app._vpath}" # noqa: SLF001
def _add_record(self, record: logging.LogRecord):
self._records.append(record)
if self.connections:
self.output.push(record.output) # type: ignore
- if record.levelno >= LogLevel.ERROR and ipylab.app._ready: # noqa: SLF001
- self._notify_exception(record)
+ if record.levelno >= LogLevel.ERROR and self.app._ready: # noqa: SLF001
+ self._notify_exception(True, record)
- def _notify_exception(self, record: logging.LogRecord):
+ @autorun
+ async def _notify_exception(self, record: logging.LogRecord):
"Create a notification that an error occurred."
- if self._log_notify_task:
- # Limit to one notification.
- if not self._log_notify_task.done():
- return
- self._log_notify_task.result().close()
- self._log_notify_task = ipylab.app.notification.notify(
- message=f"Error: {record.msg}",
+ await self.app.notification.notify(
+ message=f"vpath:'{self.app.vpath}' Error: {record.msg}",
type=ipylab.NotificationType.error,
actions=[
- ipylab.NotifyAction(label="📄", caption="Show log viewer.", callback=self.add_to_shell, keep_open=True)
+ ipylab.NotifyAction(
+ label="📄",
+ caption="Show exception.",
+ callback=lambda: self._show_error(record=record),
+ keep_open=True,
+ )
],
)
@@ -137,23 +159,34 @@ def _observe_buffer_size(self, change):
def _button_on_click(self, b):
if b is self.button_show_send_dialog:
- self.button_show_send_dialog.disabled = True
- ipylab.app.dialog.to_task(
- self._show_send_dialog(),
- hooks={"callbacks": [lambda _: self.button_show_send_dialog.set_trait("disabled", False)]},
- )
+ b.disabled = True
+ self._show_send_dialog(True, b)
elif b is self.button_clear:
self._records.clear()
self.output.push(clear=True)
- async def _show_send_dialog(self):
- # TODO: make a formatter to simplify the message with obj and owner)
- options = {r.msg: r for r in reversed(self._records)} # type: ignore
- select = Select(
- tooltip="Most recent exception is first",
- layout={"flex": "2 1 auto", "width": "auto", "height": "max-content"},
- options=options,
+ @autorun
+ async def _show_error(self, record: logging.LogRecord):
+ out = SimpleOutput().push(
+ Markdown(f"vpath='{self.app.vpath}': **{record.levelname.capitalize()}**:\n\n{record.message}")
)
+ try:
+ out.push(record.output) # type: ignore
+ except Exception:
+ out.push(record.message)
+ objects = {
+ "record": record,
+ "owner": (owner := getattr(record, "owner", None)) and owner(),
+ "obj": getattr(record, "obj", None),
+ }
+ b = Button(description="Send to console", tooltip="Send record, owner and obj to the console.")
+ b.on_click(lambda _: self.app.shell.start_coro(self.app.shell.open_console(objects=objects)))
+ out.push(b)
+ await self.app.shell.add(out, mode=InsertMode.split_right)
+
+ @autorun
+ async def _show_send_dialog(self, b: Button):
+ options = {f"{r.asctime}: {r.msg}": r for r in reversed(self._records)} # type: ignore
search = Combobox(
placeholder="Search",
tooltip="Search for a log entry or object.",
@@ -161,39 +194,37 @@ async def _show_send_dialog(self):
layout={"width": "auto"},
options=tuple(options),
)
- body = VBox([select, search])
+ select = Select(
+ value=None,
+ tooltip="Most recent exception is first",
+ layout={"flex": "2 1 auto", "width": "auto", "height": "max-content"},
+ options=options,
+ )
+ record_out = SimpleOutput()
+ body = VBox([search, select, record_out])
def observe(change: dict):
if change["owner"] is select:
- body.children = [select, search, select.value] if select.value else [select, search]
+ record = select.value
+ items = (record.output,) if record else ()
+ record_out.push(*items, clear=True)
elif change["owner"] is search and change["new"] in options:
select.value = options[change["new"]]
select.observe(observe, "value")
search.observe(observe, "value")
try:
- result = await ipylab.app.dialog.show_dialog("Send record to console", body=body)
- if result["value"] and select.value:
- console = await ipylab.app.shell.open_console(objects={"record": select.value})
- await console.set_property("console.promptCell.model.sharedModel.source", "record")
- await console.execute_method("console.execute")
+ result = await self.app.dialog.show_dialog("Send record to console", body=body)
+ if record := result["value"] and select.value:
+ objects = {
+ "record": record,
+ "owner": (owner := getattr(record, "owner", None)) and owner(),
+ "obj": getattr(record, "obj", None),
+ }
+ await self.app.shell.open_console(objects=objects)
except Exception:
return
finally:
+ b.disabled = False
for w in [search, body, select]:
w.close()
-
- def add_to_shell(
- self,
- *,
- area=Area.main,
- activate: bool = True,
- mode=InsertMode.split_bottom,
- rank: int | None = None,
- ref: ipylab.ShellConnection | None = None,
- options: dict | None = None,
- **kwgs,
- ) -> Task[ShellConnection]:
- return super().add_to_shell(
- area=area, activate=activate, mode=mode, rank=rank, ref=ref, options=options, **kwgs
- )
diff --git a/ipylab/menu.py b/ipylab/menu.py
index 3fd055df..18e89d1b 100644
--- a/ipylab/menu.py
+++ b/ipylab/menu.py
@@ -3,24 +3,20 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, override
+from typing import TYPE_CHECKING, Self
from ipywidgets import TypedTuple
from traitlets import Container, Instance, Union
+from typing_extensions import override
-import ipylab
-from ipylab.commands import APP_COMMANDS_NAME, CommandRegistry
-from ipylab.common import Fixed, Obj
+from ipylab.commands import APP_COMMANDS_NAME, CommandConnection, CommandRegistry
+from ipylab.common import Fixed, Obj, Singular
from ipylab.connection import InfoConnection
from ipylab.ipylab import Ipylab, IpylabBase, Transform
if TYPE_CHECKING:
- from asyncio import Task
from typing import Literal
- from ipylab.commands import CommandConnection
- from ipylab.common import TaskHooks, TransformType
-
__all__ = ["MenuItemConnection", "MenuConnection", "MainMenu", "ContextMenu"]
@@ -39,7 +35,7 @@ class RankedMenu(Ipylab):
connections: Container[tuple[MenuItemConnection, ...]] = TypedTuple(trait=Instance(MenuItemConnection))
- def add_item(
+ async def add_item(
self,
*,
command: str | CommandConnection = "",
@@ -47,15 +43,15 @@ def add_item(
rank: float | None = None,
type: Literal["command", "submenu", "separator"] = "command", # noqa: A002
args: dict | None = None,
- ) -> Task[MenuItemConnection]:
+ ) -> MenuItemConnection:
"""Add command, subitem or separator.
**args are 'defaults' used with command only.
ref: https://jupyterlab.readthedocs.io/en/4.0.x/api/classes/ui_components.RankedMenu.html#addItem.addItem-1
"""
- return self._add_item(command, submenu, rank, type, args)
+ return await self._add_item(command, submenu, rank, type, args)
- def _add_item(
+ async def _add_item(
self,
command: str | CommandConnection,
submenu: MenuConnection | None,
@@ -86,27 +82,38 @@ def _add_item(
case _:
msg = f"Invalid type {type}"
raise ValueError(msg)
- hooks: TaskHooks = {
- "trait_add_fwd": [("info", info), ("menu", self)],
- "close_with_fwd": [self],
- "add_to_tuple_fwd": [(self, "connections")],
- }
- transform: TransformType = {"transform": Transform.connection, "cid": MenuItemConnection.to_cid()}
- return self.execute_method("addItem", info, hooks=hooks, transform=transform, toObject=to_object)
-
- def activate(self):
- async def activate():
- await ipylab.app.main_menu.set_property("activeMenu", self, toObject=["value"])
- await ipylab.app.main_menu.execute_method("openActiveMenu")
- return self.to_task(activate())
+ mic: MenuItemConnection = await self.execute_method(
+ subpath="addItem",
+ args=(info,),
+ transform={"transform": Transform.connection, "cid": MenuItemConnection.to_cid()},
+ toObject=to_object,
+ )
+ self.close_with_self(mic)
+ if isinstance(command, CommandConnection):
+ command.close_with_self(mic)
+ if submenu:
+ submenu.close_with_self(mic)
+ mic.info = info
+ mic.menu = self
+ mic.add_to_tuple(self, "connections")
+ return mic
+
+ async def activate(self):
+ "Open this menu assuming it is in the main menu"
+ await self.app.main_menu.set_property("activeMenu", self, toObject=["value"])
+ await self.app.main_menu.execute_method("openActiveMenu")
+
+ async def open_somewhere(self):
+ "Open this menu somewhere"
+ await self.execute_method("open")
class BuiltinMenu(RankedMenu):
@override
- def activate(self):
+ async def activate(self):
name = self.ipylab_base[-1].removeprefix("mainMenu.").lower()
- return ipylab.app.commands.execute(f"{name}:open")
+ await self.app.commands.execute(f"{name}:open")
class MenuConnection(InfoConnection, RankedMenu):
@@ -115,23 +122,23 @@ class MenuConnection(InfoConnection, RankedMenu):
commands = Instance(CommandRegistry)
-class Menu(RankedMenu):
- SINGLE = True
-
+class Menu(Singular, RankedMenu):
ipylab_base = IpylabBase(Obj.IpylabModel, "palette").tag(sync=True)
commands = Instance(CommandRegistry)
- connections: Container[tuple[MenuConnection, ...]] = TypedTuple(
+ connections: Container[tuple[MenuConnection, ...]] = TypedTuple( # type: ignore
trait=Union([Instance(MenuConnection), Instance(MenuItemConnection)])
)
@classmethod
@override
- def _single_key(cls, kwgs: dict):
- return cls, kwgs["commands"]
+ def get_single_key(cls, commands: CommandRegistry, **kwgs):
+ return commands
def __init__(self, *, commands: CommandRegistry, **kwgs):
- commands.close_extras.add(self)
+ if self._ipylab_init_complete:
+ return
+ commands.close_with_self(self)
super().__init__(commands=commands, **kwgs)
@@ -141,51 +148,61 @@ class MainMenu(Menu):
ref: https://jupyterlab.readthedocs.io/en/4.0.x/api/classes/mainmenu.MainMenu.html
"""
- SINGLE = True
-
ipylab_base = IpylabBase(Obj.IpylabModel, "mainMenu").tag(sync=True)
- file_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.fileMenu"))
- edit_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.editMenu"))
- view_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.viewMenu"))
- run_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.runMenu"))
- kernel_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.kernelMenu"))
- tabs_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.tabsMenu"))
- settings_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.settingsMenu"))
- help_menu = Fixed(BuiltinMenu, ipylab_base=(Obj.IpylabModel, "mainMenu.helpMenu"))
+ file_menu: Fixed[Self, BuiltinMenu] = Fixed(
+ lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.fileMenu"))
+ )
+ edit_menu: Fixed[Self, BuiltinMenu] = Fixed(
+ lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.editMenu"))
+ )
+ view_menu: Fixed[Self, BuiltinMenu] = Fixed(
+ lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.viewMenu"))
+ )
+ run_menu: Fixed[Self, BuiltinMenu] = Fixed(
+ lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.runMenu")),
+ )
+ kernel_menu: Fixed[Self, BuiltinMenu] = Fixed(
+ lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.kernelMenu"))
+ )
+ tabs_menu: Fixed[Self, BuiltinMenu] = Fixed(
+ lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.tabsMenu"))
+ )
+ help_menu: Fixed[Self, BuiltinMenu] = Fixed(
+ lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.helpMenu"))
+ )
+ settings_menu: Fixed[Self, BuiltinMenu] = Fixed(
+ lambda _: BuiltinMenu(ipylab_base=(Obj.IpylabModel, "mainMenu.settingsMenu"))
+ )
@classmethod
@override
- def _single_key(cls, kwgs: dict):
+ def get_single_key(cls, **kwgs): # type: ignore
return cls
def __init__(self):
super().__init__(commands=CommandRegistry(name=APP_COMMANDS_NAME))
- def add_menu(self, menu: MenuConnection, *, update=True, rank: int = 500) -> Task[None]:
+ async def add_menu(self, menu: MenuConnection, *, update=True, rank: int = 500) -> None:
"""Add a top level menu to the shell.
ref: https://jupyterlab.readthedocs.io/en/4.0.x/api/classes/mainmenu.MainMenu.html#addMenu
"""
options = {"rank": rank}
- return self.execute_method("addMenu", menu, update, options, toObject=["args[0]"])
+ return await self.execute_method("addMenu", (menu, update, options), toObject=["args[0]"])
@override
- def activate(self):
+ async def activate(self):
"Does nothing. Instead you should activate a submenu."
class ContextMenu(Menu):
"""Menu available on mouse right click."""
- SINGLE = True
- # TODO: Support custom context menus.
- # This would require a model similar to CommandRegistryModel.
-
ipylab_base = IpylabBase(Obj.IpylabModel, "app.contextMenu").tag(sync=True)
@override
- def add_item(
+ async def add_item( # type: ignore
self,
*,
command: str | CommandConnection = "",
@@ -194,18 +211,15 @@ def add_item(
rank: float | None = None,
type: Literal["command", "submenu", "separator"] = "command",
args: dict | None = None,
- ) -> Task[MenuItemConnection]:
+ ) -> MenuItemConnection:
"""Add command, subitem or separator.
args are used when calling the command only.
ref: https://jupyterlab.readthedocs.io/en/stable/extension/extension_points.html#context-menu
"""
-
- 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_())
+ app = await self.app.ready()
+ return await self._add_item(command, submenu, rank, type, args, selector or app.selector)
@override
- def activate(self):
+ async def activate(self):
"Does nothing for a context menu"
diff --git a/ipylab/notification.py b/ipylab/notification.py
index cef27ca0..ba0f4036 100644
--- a/ipylab/notification.py
+++ b/ipylab/notification.py
@@ -5,20 +5,19 @@
import inspect
from enum import StrEnum
-from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, override
+from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict
import traitlets
from ipywidgets import TypedTuple, register
from traitlets import Container, Instance, Unicode
+from typing_extensions import override
-import ipylab
from ipylab import Transform, pack
-from ipylab.common import Obj, TaskHooks, TransformType
+from ipylab.common import Obj, Singular, TransformType
from ipylab.connection import InfoConnection
from ipylab.ipylab import Ipylab, IpylabBase
if TYPE_CHECKING:
- from asyncio import Task
from collections.abc import Callable, Iterable
from typing import Any
@@ -50,14 +49,15 @@ class ActionConnection(InfoConnection):
class NotificationConnection(InfoConnection):
actions: Container[tuple[ActionConnection, ...]] = TypedTuple(trait=Instance(ActionConnection))
- def update(
+ async def update(
self,
message: str,
type: NotificationType | None = None, # noqa: A002
*,
auto_close: float | Literal[False] | None = None,
actions: Iterable[NotifyAction | ActionConnection] = (),
- ) -> Task[bool]:
+ ) -> bool:
+ await self.ready()
args = {
"id": f"{pack(self)}.id",
"message": message,
@@ -66,27 +66,22 @@ def update(
}
to_object = ["args.id"]
- async def update():
- actions_ = [await ipylab.app.notification._ensure_action(v) for v in actions] # noqa: SLF001
- if actions_:
- args["actions"] = list(map(pack, actions_)) # type: ignore
- to_object.extend(f"options.actions.{i}" for i in range(len(actions_)))
- for action in actions_:
- self.close_extras.add(action)
- return await ipylab.app.notification.operation("update", {"args": args}, toObject=to_object)
-
- return self.to_task(update())
+ actions_ = [await self.app.notification._ensure_action(v) for v in actions] # noqa: SLF001
+ if actions_:
+ args["actions"] = list(map(pack, actions_)) # type: ignore
+ to_object.extend(f"options.actions.{i}" for i in range(len(actions_)))
+ for action in actions_:
+ self.close_with_self(action)
+ return await self.app.notification.operation("update", {"args": args}, toObject=to_object)
@register
-class NotificationManager(Ipylab):
+class NotificationManager(Singular, Ipylab):
"""Create new notifications with access to the notification manager as base.
ref: https://jupyterlab.readthedocs.io/en/stable/extension/ui_helpers.html#notifications
"""
- SINGLE = True
-
_model_name = Unicode("NotificationManagerModel").tag(sync=True)
ipylab_base = IpylabBase(Obj.IpylabModel, "Notification.manager").tag(sync=True)
@@ -97,9 +92,12 @@ class NotificationManager(Ipylab):
@override
async def _do_operation_for_frontend(self, operation: str, payload: dict, buffers: list):
"""Overload this function as required."""
+ action = ActionConnection(payload["cid"])
match operation:
case "action_callback":
- callback = ActionConnection.get_existing_connection(payload["cid"]).callback
+ action = ActionConnection(payload["cid"])
+ await action.ready()
+ callback = action.callback
result = callback()
while inspect.isawaitable(result):
result = await result
@@ -113,14 +111,14 @@ async def _ensure_action(self, value: ActionConnection | NotifyAction) -> Action
return value
return await self.new_action(**value) # type: ignore
- def notify(
+ async def notify(
self,
message: str,
type: NotificationType = NotificationType.default, # noqa: A002
*,
auto_close: float | Literal[False] | None = None,
actions: Iterable[NotifyAction | ActionConnection] = (),
- ) -> Task[NotificationConnection]:
+ ) -> NotificationConnection:
"""Create a new notification.
To update a notification use the update method of the returned `NotificationConnection`.
@@ -132,30 +130,24 @@ def notify(
keep_open: NotRequired[bool]
caption: NotRequired[str]
"""
-
+ await self.ready()
options = {"autoClose": auto_close}
kwgs = {"type": NotificationType(type), "message": message, "options": options}
- hooks: TaskHooks = {
- "add_to_tuple_fwd": [(self, "connections")],
- "trait_add_fwd": [("info", kwgs)],
- }
-
- async def notify():
- actions_ = [await self._ensure_action(v) for v in actions]
- if actions_:
- options["actions"] = actions_ # type: ignore
- cid = NotificationConnection.to_cid()
- notification: NotificationConnection = await self.operation(
- "notification",
- kwgs,
- transform={"transform": Transform.connection, "cid": cid},
- toObject=[f"options.actions[{i}]" for i in range(len(actions_))] if actions_ else [],
- )
- return notification
-
- return self.to_task(notify(), hooks=hooks)
-
- def new_action(
+ actions_ = [await self._ensure_action(v) for v in actions]
+ if actions_:
+ options["actions"] = actions_ # type: ignore
+ cid = NotificationConnection.to_cid()
+ notification: NotificationConnection = await self.operation(
+ operation="notification",
+ kwgs=kwgs,
+ transform={"transform": Transform.connection, "cid": cid},
+ toObject=[f"options.actions[{i}]" for i in range(len(actions_))] if actions_ else [],
+ )
+ notification.add_to_tuple(self, "connections")
+ notification.info = kwgs
+ return notification
+
+ async def new_action(
self,
label: str,
callback: Callable[[], Any],
@@ -163,14 +155,15 @@ def new_action(
*,
keep_open: bool = False,
caption: str = "",
- ) -> Task[ActionConnection]:
+ ) -> ActionConnection:
"Create an action to use in a notification."
+ await self.ready()
cid = ActionConnection.to_cid()
kwgs = {"label": label, "displayType": display_type, "keep_open": keep_open, "caption": caption, "cid": cid}
transform: TransformType = {"transform": Transform.connection, "cid": cid}
- hooks: TaskHooks = {
- "trait_add_fwd": [("callback", callback), ("info", kwgs)],
- "add_to_tuple_fwd": [(self, "connections")],
- "close_with_fwd": [self],
- }
- return self.operation("createAction", kwgs, transform=transform, hooks=hooks)
+ ac: ActionConnection = await self.operation("createAction", kwgs, transform=transform)
+ self.close_with_self(ac)
+ ac.callback = callback
+ ac.info = kwgs
+ ac.add_to_tuple(self, "connections")
+ return ac
diff --git a/ipylab/sessions.py b/ipylab/sessions.py
index 4fb86576..1ac4a747 100644
--- a/ipylab/sessions.py
+++ b/ipylab/sessions.py
@@ -3,37 +3,30 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
-
from traitlets import Unicode
-from ipylab.common import Obj
+from ipylab.common import Obj, Singular
from ipylab.ipylab import Ipylab, IpylabBase
-if TYPE_CHECKING:
- from asyncio import Task
-
-class SessionManager(Ipylab):
+class SessionManager(Singular, Ipylab):
"""
https://jupyterlab.readthedocs.io/en/latest/api/interfaces/services.Session.IManager.html
"""
- SINGLE = True
-
_model_name = Unicode("SessionManagerModel", help="Name of the model.", read_only=True).tag(sync=True)
ipylab_base = IpylabBase(Obj.IpylabModel, "app.serviceManager.sessions").tag(sync=True)
- def get_running(self, *, refresh=True) -> Task[dict]:
+ async def get_running(self, *, refresh=True) -> dict:
"Get a dict of running sessions."
- return self.operation("getRunning", {"refresh": refresh})
+ return await self.operation("getRunning", {"refresh": refresh})
- def get_current(self):
+ async def get_current(self):
"Get the session of the current widget in the shell."
- return self.operation("getCurrentSession")
+ return await self.operation("getCurrentSession")
- def stop_if_needed(self, path):
+ async def stop_if_needed(self, *, path: str):
"""
https://jupyterlab.readthedocs.io/en/latest/api/interfaces/services.Session.IManager.html#stopIfNeeded
"""
- return self.execute_method("stopIfNeeded", path)
+ return await self.execute_method("stopIfNeeded", (path,))
diff --git a/ipylab/shell.py b/ipylab/shell.py
index 3c7da52c..b4eff22e 100644
--- a/ipylab/shell.py
+++ b/ipylab/shell.py
@@ -12,16 +12,13 @@
import ipylab
from ipylab import Area, InsertMode, Ipylab, ShellConnection, Transform, pack
-from ipylab.common import Fixed, IpylabKwgs, Obj, TaskHookType
+from ipylab.common import Fixed, IpylabKwgs, Obj, Singular, TransformType
from ipylab.ipylab import IpylabBase
from ipylab.log_viewer import LogViewer
if TYPE_CHECKING:
- from asyncio import Task
from typing import Literal
- from ipylab.common import TaskHooks
-
__all__ = ["Shell", "ConsoleConnection"]
@@ -29,14 +26,10 @@
class ConsoleConnection(ShellConnection):
"A connection intended for a JupyterConsole"
- # TODO: add methods
-
-class Shell(Ipylab):
+class Shell(Singular, Ipylab):
"""Provides access to the shell."""
- SINGLE = True
-
_model_name = Unicode("ShellModel", help="Name of the model.", read_only=True).tag(sync=True)
ipylab_base = IpylabBase(Obj.IpylabModel, "app.shell").tag(sync=True)
current_widget_id = Unicode(read_only=True).tag(sync=True)
@@ -46,7 +39,7 @@ class Shell(Ipylab):
connections: Container[tuple[ShellConnection, ...]] = TypedTuple(trait=Instance(ShellConnection))
console: Instance[ConsoleConnection | None] = Instance(ConsoleConnection, default_value=None, allow_none=True) # type: ignore
- def add(
+ async def add(
self,
obj: Widget | inspect._SourceObjectType,
*,
@@ -57,9 +50,8 @@ def add(
ref: ShellConnection | None = None,
options: dict | None = None,
vpath: str | dict[Literal["title"], str] = "",
- hooks: TaskHookType = None,
**args,
- ) -> Task[ShellConnection]:
+ ) -> ShellConnection:
"""Add a widget to the shell.
If the widget is already in the shell, it may be moved or activated.
@@ -72,6 +64,7 @@ def add(
obj:
When `obj` is NOT a Widget it is assumed `obj` should be evaluated
in a python kernel.
+ specify additional keyword arguments directly in **args
area: Area
The area in the shell where to put obj.
activate: bool
@@ -105,7 +98,8 @@ def add(
app.shell.add("ipylab.Panel([ipw.HTML('Test')])", vpath="test")
```
"""
- hooks_: TaskHooks = {"add_to_tuple_fwd": [(self, "connections")]}
+ app = await self.app.ready()
+ vpath = vpath or app.vpath
args["options"] = {
"activate": activate,
"mode": InsertMode(mode),
@@ -129,33 +123,35 @@ def add(
if c.widget is obj:
args["cid"] = c.cid
break
- hooks_["trait_add_fwd"] = [("widget", obj)]
- if isinstance(obj, ipylab.Panel):
- hooks_["add_to_tuple_fwd"].append((obj, "connections"))
args["ipy_model"] = obj.model_id
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)
- while inspect.isawaitable(result):
- result = await result
- args["vpath"] = result
- else:
- args["vpath"] = vpath or vpath_
- if args["vpath"] != vpath_:
- hooks_["trait_add_fwd"] = [("auto_dispose", False)]
- else:
- args["vpath"] = vpath_
-
- return await self.operation("addToShell", {"args": args}, transform=Transform.connection, hooks=hooks_)
-
- return self.to_task(add_to_shell(), "Add to shell", hooks=hooks)
+ if isinstance(obj, DOMWidget):
+ obj.add_class(app.selector.removeprefix("."))
+ if "evaluate" in args and isinstance(vpath, dict):
+ val = ipylab.plugin_manager.hook.vpath_getter(app=app, kwgs=vpath)
+ while inspect.isawaitable(val):
+ val = await val
+ vpath = val
+ args["vpath"] = vpath
+ sc_current = None
+ if activate and area == Area.main:
+ current_widget_id: str | None = await self.get_property("currentWidget.id")
+ if current_widget_id and current_widget_id.startswith("launcher"):
+ sc_current = await self.connect_to_widget(current_widget_id)
+ sc: ShellConnection = await self.operation("addToShell", {"args": args}, transform=Transform.connection)
+ sc.add_to_tuple(self, "connections")
+ if vpath != app.vpath:
+ sc.auto_dispose = False
+ if isinstance(obj, Widget):
+ sc.widget = obj
+ if isinstance(obj, ipylab.Panel):
+ sc.add_to_tuple(obj, "connections")
+ if sc_current:
+ sc_current.close()
+ if activate:
+ await sc.activate()
+ return sc
def add_objects_to_ipython_namespace(self, objects: dict, *, reset=False):
"Load objects into the IPython/console namespace."
@@ -164,7 +160,7 @@ def add_objects_to_ipython_namespace(self, objects: dict, *, reset=False):
self.comm.kernel.shell.reset() # type: ignore
self.comm.kernel.shell.push(objects) # type: ignore
- def open_console(
+ async def open_console(
self,
*,
mode=InsertMode.split_bottom,
@@ -172,8 +168,7 @@ def open_console(
ref: ShellConnection | str = "",
objects: dict | None = None,
reset_shell=False,
- hooks: TaskHookType = None,
- ) -> Task[ConsoleConnection]:
+ ) -> ConsoleConnection:
"""Open/activate a Jupyterlab console for this python kernel shell (path=app.vpath).
Parameters
@@ -186,49 +181,37 @@ def open_console(
reset_shell:
Set true to reset the shell (clear the namespace).
"""
-
- async def open_console():
- ref_ = ref or self.current_widget_id
- if not isinstance(ref_, ShellConnection):
- ref_ = await self.connect_to_widget(ref_)
- objects_ = {"ref": ref_} | (objects or {})
- vpath = ipylab.app.vpath
- args = {
- "path": vpath,
- "insertMode": InsertMode(mode),
- "activate": activate,
- "ref": f"{pack(ref_)}.id",
- }
- kwgs = IpylabKwgs(
- transform={"transform": Transform.connection, "cid": ConsoleConnection.to_cid(vpath)},
- toObject=["args[ref]"],
- hooks={
- "trait_add_rev": [(self, "console")],
- "add_to_tuple_fwd": [(self, "connections")],
- "callbacks": [lambda _: self.add_objects_to_ipython_namespace(objects_, reset=reset_shell)],
- },
- )
- return await ipylab.app.commands.execute("console:open", args, **kwgs)
-
- return self.to_task(open_console(), "Open console", hooks=hooks)
-
- def expand_left(self):
- return self.execute_method("expandLeft")
-
- def expand_right(self):
- return self.execute_method("expandRight")
-
- def collapse_left(self):
- return self.execute_method("collapseLeft")
-
- def collapse_right(self):
- return self.execute_method("collapseRight")
-
- def connect_to_widget(self, widget_id="", **kwgs: Unpack[IpylabKwgs]) -> Task[ShellConnection]:
+ await self.ready()
+ app = await self.app.ready()
+ ref_ = ref or self.current_widget_id
+ if not isinstance(ref_, ShellConnection):
+ ref_ = await self.connect_to_widget(ref_)
+ objects_ = {"ref": ref_} | (objects or {})
+ args = {"path": app.vpath, "insertMode": InsertMode(mode), "activate": activate, "ref": f"{pack(ref_)}.id"}
+ tf: TransformType = {"transform": Transform.connection, "cid": ConsoleConnection.to_cid(app.vpath)}
+ cc: ConsoleConnection = await app.commands.execute("console:open", args, toObject=["args[ref]"], transform=tf)
+ self.console = cc
+ cc.add_to_tuple(self, "connections")
+ self.add_objects_to_ipython_namespace(objects_, reset=reset_shell)
+ return cc
+
+ async def expand_left(self):
+ await self.execute_method("expandLeft")
+
+ async def expand_right(self):
+ await self.execute_method("expandRight")
+
+ async def collapse_left(self):
+ await self.execute_method("collapseLeft")
+
+ async def collapse_right(self):
+ await self.execute_method("collapseRight")
+
+ async def connect_to_widget(self, widget_id="", **kwgs: Unpack[IpylabKwgs]) -> ShellConnection:
"Make a connection to a widget in the shell (see also `get_widget_ids`)."
kwgs["transform"] = Transform.connection
- return self.operation("getWidget", {"id": widget_id}, **kwgs)
+ return await self.operation("getWidget", {"id": widget_id}, **kwgs)
- def list_widget_ids(self, **kwgs: Unpack[IpylabKwgs]) -> Task[dict[Area, list[str]]]:
+ async def list_widget_ids(self, **kwgs: Unpack[IpylabKwgs]) -> dict[Area, list[str]]:
"Get a mapping of Areas to a list of widget ids in that area in the shell."
- return self.operation("getWidgetIds", **kwgs)
+ return await self.operation("getWidgetIds", **kwgs)
diff --git a/ipylab/simple_output.py b/ipylab/simple_output.py
index 49a69da0..a6ea8403 100644
--- a/ipylab/simple_output.py
+++ b/ipylab/simple_output.py
@@ -11,7 +11,6 @@
from ipylab.ipylab import Ipylab
if TYPE_CHECKING:
- from asyncio import Task
from typing import Any, Unpack
from IPython.display import TextDisplayObject
@@ -25,7 +24,6 @@
from ipylab.ipylab import WidgetBase
if TYPE_CHECKING:
- from asyncio import Task
from typing import Unpack
@@ -53,7 +51,7 @@ def _default_format(self):
def _pack_outputs(self, outputs: tuple[dict[str, str] | Widget | str | TextDisplayObject | Any, ...]):
fmt = self.format
for output in outputs:
- if isinstance(output, dict):
+ if isinstance(output, dict) and "output_type" in output:
yield output
elif isinstance(output, str):
yield {"output_type": "stream", "name": "stdout", "text": output}
@@ -69,7 +67,7 @@ def push(self, *outputs: dict[str, str] | Widget | str | TextDisplayObject | Any
"""Add one or more items to the output.
Consecutive `streams` of the same type are placed in the same 'output' up to `max_outputs`.
- Outputs passed as dicts are assumed to be correctly packed as `repr_mime` data.
+ Outputs passed as dicts with a key "output_type" are assumed to be correctly packed as `repr_mime` data.
Parameters
----------
@@ -84,9 +82,9 @@ def push(self, *outputs: dict[str, str] | Widget | str | TextDisplayObject | Any
self.send({"add": items, "clear": clear})
return self
- def set(
+ async def set(
self, *outputs: dict[str, str] | Widget | str | TextDisplayObject | Any, **kwgs: Unpack[IpylabKwgs]
- ) -> Task[int]:
+ ) -> int:
"""Set the output explicitly by first clearing and then adding the outputs.
Compared to `push`, this is performed asynchronously and will wait for
@@ -98,7 +96,7 @@ def set(
outputs:
Items to be displayed.
"""
- return self.operation("setOutputs", {"items": list(self._pack_outputs(outputs))}, **kwgs)
+ return await self.operation("setOutputs", {"items": list(self._pack_outputs(outputs))}, **kwgs)
@register
diff --git a/ipylab/widgets.py b/ipylab/widgets.py
index e61b87db..d13648bf 100644
--- a/ipylab/widgets.py
+++ b/ipylab/widgets.py
@@ -3,21 +3,26 @@
from __future__ import annotations
-import asyncio
-from typing import TYPE_CHECKING
+from typing import ClassVar, NotRequired, TypedDict, Unpack
-from ipywidgets import Box, DOMWidget, Layout, TypedTuple, register, widget_serialization
+import anyio
+from ipywidgets import Box, DOMWidget, Layout, TypedTuple, Widget, register, widget_serialization
from ipywidgets.widgets.trait_types import InstanceDict
from traitlets import Container, Dict, Instance, Tuple, Unicode, observe
-import ipylab
import ipylab._frontend as _fe
-from ipylab.common import Area, InsertMode
-from ipylab.connection import ShellConnection
+from ipylab.common import Area, HasApp, InsertMode, autorun
+from ipylab.connection import Connection, ShellConnection
from ipylab.ipylab import WidgetBase
-if TYPE_CHECKING:
- from asyncio import Task
+
+class AddToShellType(TypedDict):
+ area: NotRequired[Area]
+ activate: NotRequired[bool]
+ mode: NotRequired[InsertMode]
+ rank: NotRequired[int | None]
+ ref: NotRequired[ShellConnection | None]
+ options: NotRequired[dict | None]
@register
@@ -44,39 +49,17 @@ class Title(WidgetBase):
@register
-class Panel(Box):
+class Panel(HasApp, WidgetBase, Box):
_model_name = Unicode("PanelModel").tag(sync=True)
_view_name = Unicode("PanelView").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)
title: Instance[Title] = InstanceDict(Title, ()).tag(sync=True, **widget_serialization)
- connections: Container[tuple[ShellConnection, ...]] = TypedTuple(trait=Instance(ShellConnection))
-
- def add_to_shell(
- self,
- *,
- area: Area = Area.main,
- activate: bool = True,
- mode: InsertMode = InsertMode.tab_after,
- rank: int | None = None,
- ref: ShellConnection | None = None,
- options: dict | None = None,
- **kwgs,
- ) -> Task[ShellConnection]:
+ connections: Container[tuple[Connection, ...]] = TypedTuple(trait=Instance(Connection))
+ add_to_shell_defaults: ClassVar = AddToShellType(mode=InsertMode.tab_after)
+
+ async def add_to_shell(self, **kwgs: Unpack[AddToShellType]) -> ShellConnection:
"""Add this panel to the shell."""
- return ipylab.app.shell.add(
- self,
- area=area,
- mode=mode,
- activate=activate,
- rank=rank,
- ref=ref,
- options=options,
- **kwgs,
- )
+ return await self.app.shell.add(self, **self.add_to_shell_defaults | kwgs)
@register
@@ -87,28 +70,24 @@ class SplitPanel(Panel):
layout = InstanceDict(Layout, kw={"width": "100%", "height": "100%", "overflow": "hidden"}).tag(
sync=True, **widget_serialization
)
- _force_update_in_progress = False
# ============== Start temp fix =============
# Below here is added as a temporary fix to address issue https://github.com/jtpio/ipylab/issues/129
@observe("children", "connections")
def _observer(self, _):
- self._rerender()
+ self._toggle_orientation(children=self.children)
- def _rerender(self):
+ @autorun
+ async def _toggle_orientation(self, children: tuple[Widget, ...]):
"""Toggle the orientation to cause lumino_widget.parent to re-render content."""
-
- async def force_refresh(children):
- if children != self.children:
- return
- await asyncio.sleep(0.1)
- orientation = self.orientation
- self.orientation = "horizontal" if orientation == "vertical" else "vertical"
- await asyncio.sleep(0.001)
- self.orientation = orientation
-
- return ipylab.app.to_task(force_refresh(self.children))
+ if children != self.children:
+ return
+ await anyio.sleep(0.1)
+ orientation = self.orientation
+ self.orientation = "horizontal" if orientation == "vertical" else "vertical"
+ await anyio.sleep(0.001)
+ self.orientation = orientation
# ============== End temp fix =============
@@ -136,4 +115,6 @@ class ResizeBox(Box):
_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)
+ size: Container[tuple[int, int]] = Tuple(read_only=True, help="(clientWidth, clientHeight) in pixels").tag(
+ sync=True
+ )
diff --git a/package.json b/package.json
index 27e5a86f..1a68d6ae 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ipylab",
- "version": "2.0.0-b3",
+ "version": "2.0.0-b4",
"description": "Control JupyterLab from Python notebooks",
"keywords": [
"jupyter",
diff --git a/pkg/ipykernel-7.0.0a1-py3-none-any.whl b/pkg/ipykernel-7.0.0a1-py3-none-any.whl
new file mode 100644
index 00000000..5c7c1e04
Binary files /dev/null and b/pkg/ipykernel-7.0.0a1-py3-none-any.whl differ
diff --git a/pkg/traitlets-5.14.3-py3-none-any.whl b/pkg/traitlets-5.14.3-py3-none-any.whl
new file mode 100644
index 00000000..dea4d8e6
Binary files /dev/null and b/pkg/traitlets-5.14.3-py3-none-any.whl differ
diff --git a/pyproject.toml b/pyproject.toml
index 4508aafb..c9686082 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.12"
+requires-python = ">=3.11"
classifiers = [
"Framework :: Jupyter",
"Framework :: Jupyter :: JupyterLab",
@@ -20,30 +20,34 @@ 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 = [
+ "anyio",
"jupyterlab>=4.3",
"ipywidgets>=8.1.5",
"ipython>=8.32",
"jupyterlab_widgets>=3.0.11",
"pluggy~=1.5",
+ "typing_extensions",
+ "traitlets @ {root:uri}/pkg/traitlets-5.14.3-py3-none-any.whl",
+ "ipykernel @ {root:uri}/pkg/ipykernel-7.0.0a1-py3-none-any.whl",
+ "widgetsnbextension @ {root:uri}/pkg/widgetsnbextension-4.0.13-py3-none-any.whl",
+ "jupyterlab_widgets @ {root:uri}/pkg/jupyterlab_widgets-3.0.13-py3-none-any.whl",
+ "ipywidgets @ {root:uri}/pkg/ipywidgets-8.1.5-py3-none-any.whl",
]
dynamic = ["version", "description", "authors", "urls", "keywords"]
[project.optional-dependencies]
dev = ["hatch", "ruff", "pre-commit"]
test = ["pytest", "anyio", "pytest-cov", "pytest-mock"]
-per-kernel-widget-manager = [
- "widgetsnbextension @ {root:uri}/pkg/widgetsnbextension-4.0.13-py3-none-any.whl",
- "jupyterlab_widgets @ {root:uri}/pkg/jupyterlab_widgets-3.0.13-py3-none-any.whl",
- "ipywidgets @ {root:uri}/pkg/ipywidgets-8.1.5-py3-none-any.whl",
-]
+examples = ['bqlot', "matplotlib", 'numpy', 'ipympl']
[project.scripts]
-ipylab = "ipylab:plugin_manager.hook.launch_jupyterlab"
+ipylab = "ipylab:plugin_manager.hook.launch_ipylab"
[tool.hatch.version]
source = "nodejs"
@@ -114,3 +118,7 @@ docstring-code-format = true
[tool.ruff.lint.per-file-ignores]
"tests*" = ['ARG002', 'SLF001', 'S101', 'PLR2004']
+
+[tool.pyright]
+include = ["ipylab", 'examples', 'tests']
+typeCheckingMode = 'standard'
diff --git a/ruff_defaults.toml b/ruff_defaults.toml
index 98860d34..8ed05cbb 100644
--- a/ruff_defaults.toml
+++ b/ruff_defaults.toml
@@ -238,7 +238,7 @@ select = [
"PLR0133",
"PLR0206",
"PLR0402",
- "PLR1701",
+ "SIM101",
"PLR1711",
"PLR1714",
"PLR1722",
@@ -386,7 +386,6 @@ select = [
"S317",
"S318",
"S319",
- "S320",
"S321",
"S323",
"S324",
@@ -446,12 +445,12 @@ select = [
"T100",
"T201",
"T203",
- "TCH001",
- "TCH002",
- "TCH003",
- "TCH004",
- "TCH005",
- "TCH010",
+ "TC001",
+ "TC002",
+ "TC003",
+ "TC004",
+ "TC005",
+ "TC010",
"TD004",
"TD005",
"TD006",
@@ -459,18 +458,18 @@ select = [
"TID251",
"TID252",
"TID253",
- "TRIO100",
- "TRIO105",
- "TRIO109",
- "TRIO110",
- "TRIO115",
+ "ASYNC100",
+ "ASYNC105",
+ "ASYNC109",
+ "ASYNC110",
+ "ASYNC115",
"TRY002",
"TRY003",
"TRY004",
"TRY201",
"TRY300",
"TRY301",
- "TRY302",
+ "TRY203",
"TRY400",
"TRY401",
"UP001",
@@ -507,7 +506,6 @@ select = [
"UP035",
"UP036",
"UP037",
- "UP038",
"UP039",
"UP040",
"UP041",
diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts
index 42b394fd..2629036a 100644
--- a/src/widgets/frontend.ts
+++ b/src/widgets/frontend.ts
@@ -122,8 +122,9 @@ export class JupyterFrontEndModel extends IpylabModel {
// Relies on per-kernel widget manager.
const getManager = (KernelWidgetManager as any).getManager;
const widget_manager: KernelWidgetManager = await getManager(kernel);
+ const code = 'import ipylab;ipylab.App()';
if (!Private.jfems.has(kernel.id)) {
- widget_manager.kernel.requestExecute({ code: 'import ipylab' }, true);
+ widget_manager.kernel.requestExecute({ code }, true);
}
}
return await new Promise((resolve, reject) => {
diff --git a/style/widget.css b/style/widget.css
index 02b976fc..ca214caf 100644
--- a/style/widget.css
+++ b/style/widget.css
@@ -36,12 +36,12 @@
.ipylab-SimpleOutput {
box-sizing: border-box;
- display: inline block;
+ flex-direction: column;
overflow: auto;
}
.ipylab-ResizeBox {
box-sizing: border-box;
- display: inline block;
+ display: inline-block;
resize: both;
overflow: hidden;
}
diff --git a/tests/conftest.py b/tests/conftest.py
index 1b84d3e6..9d5dd54a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -15,6 +15,7 @@ async def anyio_backend_autouse(anyio_backend):
@pytest.fixture
async def app(mocker):
- app = ipylab.app
+ app = ipylab.App()
+ app._trait_values.pop("asyncio_loop", None)
mocker.patch.object(app, "ready")
return app
diff --git a/tests/test_common.py b/tests/test_common.py
index 875e09a1..5ee638dc 100644
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -3,42 +3,32 @@
from __future__ import annotations
+from typing import Self
+
import pytest
-from ipywidgets import TypedTuple
-from traitlets import HasTraits, Unicode
+from traitlets import Unicode
+from typing_extensions import override
+import ipylab
+import ipylab.common
from ipylab.common import (
Fixed,
- FixedCreate,
FixedCreated,
LastUpdatedDict,
+ Singular,
Transform,
TransformDictAdvanced,
TransformDictConnection,
TransformDictFunction,
- trait_tuple_add,
)
from ipylab.connection import Connection
class CommonTestClass:
- def __init__(self, value):
+ def __init__(self, value=1):
self.value = value
-def test_trait_tuple_add():
- class TestHasTraits(HasTraits):
- test_tuple = TypedTuple(trait=Unicode(), default_value=())
-
- owner = TestHasTraits()
- trait_tuple_add(owner, "test_tuple", "value1")
- assert owner.test_tuple == ("value1",)
- trait_tuple_add(owner, "test_tuple", "value2")
- assert owner.test_tuple == ("value1", "value2")
- trait_tuple_add(owner, "test_tuple", "value1") # Should not add duplicate
- assert owner.test_tuple == ("value1", "value2")
-
-
def test_last_updated_dict():
d = LastUpdatedDict()
d["a"] = 1
@@ -131,8 +121,13 @@ def test_validate_invalid_non_dict_transform(self):
Transform.validate(transform)
+@pytest.fixture
+async def mock_connection(mocker):
+ mocker.patch.object(Connection, "_ready")
+
+
class TestTransformPayload:
- def test_transform_payload_advanced(self):
+ async def test_transform_payload_advanced(self, mock_connection):
transform: TransformDictAdvanced = {
"transform": Transform.advanced,
"mappings": {
@@ -150,80 +145,101 @@ def test_transform_payload_advanced(self):
"key1": {"id": "test_id"},
"key2": {"cid": "ipylab-Connection"},
}
- result = Transform.transform_payload(transform, payload)
+ result = await Transform.transform_payload(transform, payload)
assert isinstance(result, dict)
assert "key1" in result
assert "key2" in result
- def test_transform_payload_connection(self):
+ async def test_transform_payload_connection(self, mock_connection):
transform: TransformDictConnection = {
"transform": Transform.connection,
"cid": "ipylab-Connection",
}
payload = {"cid": "ipylab-Connection"}
- result = Transform.transform_payload(transform, payload)
+ result = await Transform.transform_payload(transform, payload)
assert isinstance(result, Connection)
- def test_transform_payload_auto(self):
+ async def test_transform_payload_auto(self, mock_connection):
transform = Transform.auto
payload = {"cid": "ipylab-Connection"}
- result = Transform.transform_payload(transform, payload)
+ result = await Transform.transform_payload(transform, payload)
assert isinstance(result, Connection)
- def test_transform_payload_no_transform(self):
+ async def test_transform_payload_no_transform(self, mock_connection):
transform = Transform.null
payload = {"key": "value"}
- result = Transform.transform_payload(transform, payload)
+ result = await Transform.transform_payload(transform, payload)
assert result == payload
-class TestFixed:
- def test_readonly_basic(self):
- class TestOwner:
- test_instance = Fixed(CommonTestClass, 42)
+class TestLimited:
+ async def test_limited_new_single(self):
+ class MySingular(Singular):
+ pass
- owner = TestOwner()
- instance = owner.test_instance
- assert isinstance(instance, CommonTestClass)
- assert instance.value == 42
+ obj1 = MySingular()
+ obj2 = MySingular()
+ assert obj1 is obj2
+ obj1.close()
+ assert obj1 not in obj1._singular_instances
+ assert obj1.closed
- def test_readonly_dynamic(self):
- class TestOwner:
- value: int
- test_instance = Fixed(CommonTestClass, value=lambda obj: obj.value, dynamic=["value"])
+ async def test_limited_newget_single_keyed(self):
+ # Test that the get_single_key method and arguments are passed
+ class KeyedSingle(Singular):
+ key = Unicode(allow_none=True)
- owner = TestOwner()
- owner.value = 100
- assert isinstance(owner.test_instance, CommonTestClass)
- assert owner.test_instance.value == 100
+ def __init__(self, /, key: str | None, **kwgs):
+ super().__init__(key=key, **kwgs)
+
+ @override
+ @classmethod
+ def get_single_key(cls, key: str, **kwgs):
+ return key
+
+ obj1 = KeyedSingle(key="key1")
+ obj2 = KeyedSingle(key="key1")
+ obj3 = KeyedSingle(key="key2")
+ obj4 = KeyedSingle("key2")
+ obj5 = KeyedSingle(None)
+ obj6 = KeyedSingle(None)
- def test_readonly_create_function(self):
+ assert obj1 in KeyedSingle._singular_instances.values()
+ assert obj1 is obj2
+ assert obj1 is not obj3
+ assert obj4 is obj3
+ assert obj5 is not obj6
+ assert obj5 not in KeyedSingle._singular_instances.values()
+
+
+class TestFixed:
+ def test_readonly_basic(self):
class TestOwner:
- test_instance = Fixed(CommonTestClass, create=lambda info: CommonTestClass(**info["kwgs"]), value=200)
+ test_instance = Fixed(CommonTestClass)
owner = TestOwner()
- instance = owner.test_instance
- assert isinstance(instance, CommonTestClass)
- assert instance.value == 200
+ assert isinstance(owner.test_instance, CommonTestClass)
+ assert owner.test_instance.value == 1
- def test_readonly_create_method(self):
+ def test_readonly_create_function(self, app: ipylab.App):
class TestOwner:
- test_instance = Fixed(CommonTestClass, create="_create_callback", value=200)
-
- def _create_callback(self, info: FixedCreate):
- assert info["owner"] is self
- assert info["klass"] is CommonTestClass
- assert info["kwgs"] == {"value": 200}
- return CommonTestClass(*info["args"], **info["kwgs"])
+ app = Fixed(lambda _: ipylab.App())
+ app1: Fixed[Self, ipylab.App] = Fixed("ipylab.App")
owner = TestOwner()
- instance = owner.test_instance
- assert isinstance(instance, CommonTestClass)
- assert instance.value == 200
+ assert owner.app is app
+ assert owner.app1 is app
+
+ def test_readonly_create_invalid(self, app):
+ with pytest.raises(TypeError):
+ assert Fixed(123) # type: ignore
def test_readonly_created_callback_method(self):
class TestOwner:
- test_instance = Fixed(CommonTestClass, created="instance_created", value=300)
+ test_instance: Fixed[Self, CommonTestClass] = Fixed(
+ lambda _: CommonTestClass(value=300),
+ created=lambda c: c["owner"].instance_created(c),
+ )
def instance_created(self, info: FixedCreated):
assert isinstance(info["obj"], CommonTestClass)
@@ -236,8 +252,21 @@ def instance_created(self, info: FixedCreated):
def test_readonly_forbidden_set(self):
class TestOwner:
- test_instance = Fixed(CommonTestClass, 42)
+ test_instance = Fixed(CommonTestClass)
owner = TestOwner()
- with pytest.raises(AttributeError, match="Setting TestOwner.test_instance is forbidden!"):
- owner.test_instance = CommonTestClass(100)
+ with pytest.raises(AttributeError, match="Setting `Fixed` parameter TestOwner.test_instance is forbidden!"):
+ owner.test_instance = CommonTestClass()
+
+ def test_readonly_lambda(self):
+ class TestOwner:
+ test_instance = Fixed(lambda _: CommonTestClass())
+
+ owner = TestOwner()
+ with pytest.raises(AttributeError, match="Setting `Fixed` parameter TestOwner.test_instance is forbidden!"):
+ owner.test_instance = CommonTestClass()
+
+ def test_function_to_eval(self):
+ eval_str = ipylab.common.module_obj_to_import_string(test_last_updated_dict)
+ obj = eval(eval_str, {"import_item": ipylab.common.import_item}) # noqa: S307
+ assert obj is test_last_updated_dict
diff --git a/tests/test_ipylab.py b/tests/test_ipylab.py
index 72202375..aa9d58cb 100644
--- a/tests/test_ipylab.py
+++ b/tests/test_ipylab.py
@@ -1,5 +1,7 @@
from unittest.mock import AsyncMock, MagicMock
+import anyio
+
from ipylab.ipylab import Ipylab
from ipylab.jupyterfrontend import App
@@ -45,6 +47,6 @@ async def test_on_ready_async(self, app: App):
# 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
+ await anyio.sleep(0.1)
+ assert callback.await_count == 1
obj.close()
diff --git a/tests/test_jupyterfrontend.py b/tests/test_jupyterfrontend.py
index 4555de73..684d3f26 100644
--- a/tests/test_jupyterfrontend.py
+++ b/tests/test_jupyterfrontend.py
@@ -6,26 +6,20 @@
import asyncio
import contextlib
import json
-import uuid
-from typing import Any
+from typing import TYPE_CHECKING, Any
+import anyio
import pytest
-import ipylab
+if TYPE_CHECKING:
+ import ipylab
def example_callable(a=None):
return a
-async def example_async_callable(c, *, return_task=False):
- if return_task:
- import asyncio
-
- async def f():
- return "return task"
-
- return asyncio.create_task(f())
+async def example_async_callable(c):
return c
@@ -69,65 +63,38 @@ async def f():
},
"async callable",
),
- (
- {
- "evaluate": example_async_callable,
- "kwgs": {"c": 123, "return_task": True},
- },
- "return task",
- ),
],
)
-async def test_app_evaluate(kw: dict[str, Any], result, mocker):
+async def test_app_evaluate(app: ipylab.App, kw: dict[str, Any], result, mocker):
"Tests for app.evaluate"
- import asyncio
- app = ipylab.app
ready = mocker.patch.object(app, "ready")
send = mocker.patch.object(app, "send")
- task1 = app.evaluate(**kw, vpath="irrelevant")
- await asyncio.sleep(0)
- assert ready.call_count == 1
+ app.start_coro(app.evaluate(**kw, vpath="irrelevant"))
+ await anyio.sleep(0.01)
+ assert ready.call_count == 2
assert send.call_count == 1
# Simulate relaying the message from the frontend to a kernel (this kernel).
be_msg = json.loads(send.call_args[0][0]["ipylab"])
- data = {
- "ipylab_FE": str(uuid.uuid4()),
- "operation": be_msg["operation"],
- "payload": be_msg["kwgs"],
- }
- fe_msg = {"ipylab": json.dumps(data)}
-
- # Simulate the message arriving in kernel and being processed
- task2 = app._on_custom_msg(None, fe_msg, [])
- assert isinstance(task2, asyncio.Task)
- async with asyncio.timeout(1):
- await task2
- assert send.call_count == 2
- be_msg2 = json.loads(send.call_args[0][0]["ipylab"])
- assert be_msg2["ipylab_FE"] == data["ipylab_FE"]
-
+ assert list(be_msg) == ["ipylab_PY", "operation", "kwgs", "transform"]
+ result_ = await app._evaluate(be_msg["kwgs"], [])
# Check expected result
- assert be_msg2["payload"] == result
-
- # Don't attempt to relay the result back
- task1.cancel()
+ assert result_["payload"] == result
loops = set()
@pytest.mark.parametrize("n", [1, 2])
-async def test_ready(n):
+async def test_ready(n, app: ipylab.App):
"Paramatised tests must be run consecutively."
# Normally not an issue, but when testing, it is possible for asyncio to
# use different loops. Running this test consecutively should use separate
# event loops.
-
loops.add(asyncio.get_running_loop())
assert len(loops) == n, "A new event loop should be provided per test."
with contextlib.suppress(asyncio.TimeoutError):
async with asyncio.timeout(1):
- await ipylab.app.ready()
+ await app.ready()
diff --git a/tests/test_log.py b/tests/test_log.py
index 3caebd22..d0acd980 100644
--- a/tests/test_log.py
+++ b/tests/test_log.py
@@ -4,33 +4,33 @@
import ipylab.log
-def test_log():
+def test_log(app: ipylab.App):
records = []
def on_record(record):
records.append(record)
- assert ipylab.app.logging_handler
- ipylab.app.logging_handler.register_callback(on_record)
- ipylab.app.log_level = ipylab.log.LogLevel.ERROR
+ assert app.logging_handler
+ app.logging_handler.register_callback(on_record)
+ app.log_level = ipylab.log.LogLevel.ERROR
# With objects via IpylabLoggerAdapter
obj = object()
- ipylab.app.log.error("An error", obj=obj)
+ app.log.error("An error", obj=obj)
assert len(records) == 1
record = records[0]
- assert record.owner() is ipylab.app, "Via weakref"
+ assert record.owner() is app, "Via weakref"
assert record.obj is obj, "Direct ref"
# No objects direct log
- ipylab.app.log.logger.error("No objects")
+ app.log.logger.error("No objects")
assert len(records) == 2
record = records[1]
assert not hasattr(record, "owner"), "logging directly won't attach owner"
assert not hasattr(record, "obj"), "logging directly won't attach obj"
-def test_log_level_sync():
+def test_log_level_sync(app: ipylab.App):
for level in ipylab.log.LogLevel:
- ipylab.app.log_level = level
- assert ipylab.app.log.getEffectiveLevel() == level
+ app.log_level = level
+ assert app.log.getEffectiveLevel() == level