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