From 12b742ee2b36998109103adb97e6cd6a1f429486 Mon Sep 17 00:00:00 2001 From: "anujkumar.singh" Date: Tue, 9 Jun 2026 03:17:33 +0530 Subject: [PATCH 1/5] Fix Output capture from background threads --- .../widgets/tests/test_widget_output.py | 26 +++++++++++++++++++ .../ipywidgets/widgets/widget_output.py | 15 +++++++---- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/python/ipywidgets/ipywidgets/widgets/tests/test_widget_output.py b/python/ipywidgets/ipywidgets/widgets/tests/test_widget_output.py index 9afcc7916e..f043f8441a 100644 --- a/python/ipywidgets/ipywidgets/widgets/tests/test_widget_output.py +++ b/python/ipywidgets/ipywidgets/widgets/tests/test_widget_output.py @@ -64,6 +64,32 @@ def test_set_msg_id_when_capturing(self): assert widget.msg_id == msg_id assert widget.msg_id == '' + def test_set_parent_when_capturing(self): + msg_id = 'msg-id' + parent = {'header': {'msg_id': msg_id}} + parent_calls = [] + kernel = type('mock_kernel', (object, ), {})() + + def get_parent(self_): + return parent + + def set_parent(self_, parent): + parent_calls.append(parent) + + ipython = type( + 'mock_ipython', + (object, ), + {'kernel': kernel, 'get_parent': get_parent, 'set_parent': set_parent} + ) + clear_output = self._mock_clear_output() + + with self._mocked_ipython(ipython, clear_output): + widget = widget_output.Output() + with widget: + assert widget.msg_id == msg_id + + assert parent_calls == [parent] + def test_clear_output(self): msg_id = 'msg-id' get_ipython = self._mock_get_ipython(msg_id) diff --git a/python/ipywidgets/ipywidgets/widgets/widget_output.py b/python/ipywidgets/ipywidgets/widgets/widget_output.py index 150ac93471..535329ecec 100644 --- a/python/ipywidgets/ipywidgets/widgets/widget_output.py +++ b/python/ipywidgets/ipywidgets/widgets/widget_output.py @@ -116,13 +116,18 @@ def __enter__(self): if kernel: parent = None - if hasattr(kernel, "get_parent"): - parent = kernel.get_parent() - elif hasattr(kernel, "_parent_header"): - # ipykernel < 6: kernel._parent_header is the parent *request* - parent = kernel._parent_header + if ip and hasattr(ip, "get_parent"): + parent = ip.get_parent() + if not parent: + if hasattr(kernel, "get_parent"): + parent = kernel.get_parent() + elif hasattr(kernel, "_parent_header"): + # ipykernel < 6: kernel._parent_header is the parent *request* + parent = kernel._parent_header if parent and parent.get("header"): + if ip and hasattr(ip, "set_parent"): + ip.set_parent(parent) self.msg_id = parent["header"]["msg_id"] self.__counter += 1 From 95a858b282b1e12fe80784c6f76090fc676f40c3 Mon Sep 17 00:00:00 2001 From: "anujkumar.singh" Date: Tue, 16 Jun 2026 21:04:11 +0530 Subject: [PATCH 2/5] Clarify Output parent request source naming --- .../ipywidgets/widgets/widget_output.py | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/python/ipywidgets/ipywidgets/widgets/widget_output.py b/python/ipywidgets/ipywidgets/widgets/widget_output.py index 535329ecec..ce1b601f8c 100644 --- a/python/ipywidgets/ipywidgets/widgets/widget_output.py +++ b/python/ipywidgets/ipywidgets/widgets/widget_output.py @@ -115,20 +115,30 @@ def __enter__(self): kernel = self.comm.kernel if kernel: - parent = None + parent_request = None if ip and hasattr(ip, "get_parent"): - parent = ip.get_parent() - if not parent: + # Prefer the shell parent request so display hooks and streams + # are refreshed from the same message below. + shell_parent = ip.get_parent() + if shell_parent: + parent_request = shell_parent + if not parent_request: if hasattr(kernel, "get_parent"): - parent = kernel.get_parent() + # Fallback for contexts without an active shell parent: + # ipykernel >= 6 keeps parent requests on the kernel. + kernel_parent = kernel.get_parent() elif hasattr(kernel, "_parent_header"): # ipykernel < 6: kernel._parent_header is the parent *request* - parent = kernel._parent_header + kernel_parent = kernel._parent_header + else: + kernel_parent = None + if kernel_parent: + parent_request = kernel_parent - if parent and parent.get("header"): + if parent_request and parent_request.get("header"): if ip and hasattr(ip, "set_parent"): - ip.set_parent(parent) - self.msg_id = parent["header"]["msg_id"] + ip.set_parent(parent_request) + self.msg_id = parent_request["header"]["msg_id"] self.__counter += 1 def __exit__(self, etype, evalue, tb): From 3513233c786df8aa9fa270a2f1d7a841395c75c8 Mon Sep 17 00:00:00 2001 From: "anujkumar.singh" Date: Wed, 17 Jun 2026 21:45:09 +0530 Subject: [PATCH 3/5] add e2e test --- .../notebooks/output-background-thread.ipynb | 51 +++++++++++++++++++ ui-tests/tests/widgets.test.ts | 22 ++++++++ 2 files changed, 73 insertions(+) create mode 100644 ui-tests/tests/notebooks/output-background-thread.ipynb diff --git a/ui-tests/tests/notebooks/output-background-thread.ipynb b/ui-tests/tests/notebooks/output-background-thread.ipynb new file mode 100644 index 0000000000..985c911bd1 --- /dev/null +++ b/ui-tests/tests/notebooks/output-background-thread.ipynb @@ -0,0 +1,51 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import threading\n", + "\n", + "import ipywidgets as widgets\n", + "\n", + "button = widgets.Button(description='Start background thread')\n", + "out = widgets.Output()\n", + "\n", + "def on_click(_):\n", + " def write_to_output():\n", + " with out:\n", + " print('captured from background thread')\n", + "\n", + " thread = threading.Thread(target=write_to_output)\n", + " thread.start()\n", + " thread.join()\n", + "\n", + "button.on_click(on_click)\n", + "widgets.VBox([button, out])\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.2" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/ui-tests/tests/widgets.test.ts b/ui-tests/tests/widgets.test.ts index fff3d44555..fdc858ca22 100644 --- a/ui-tests/tests/widgets.test.ts +++ b/ui-tests/tests/widgets.test.ts @@ -43,4 +43,26 @@ test.describe('Widget Visual Regression', () => { expect.soft(captures[i]).toMatchSnapshot(image); } }); + + test('captures Output widget output from background threads', async ({ + page, + tmpPath, + }) => { + const notebook = 'output-background-thread.ipynb'; + await page.notebook.openByPath(`${tmpPath}/${notebook}`); + await page.notebook.activate(notebook); + + await page.notebook.runCellByCell({ + onAfterCellRun: async (cellIndex: number) => { + if (cellIndex === 0) { + await page + .getByRole('button', { name: 'Start background thread' }) + .click(); + await expect( + page.locator('.jp-Notebook .widget-output') + ).toContainText('captured from background thread'); + } + }, + }); + }); }); From e6098a377f535ae29252483ed88204832954addc Mon Sep 17 00:00:00 2001 From: "anujkumar.singh" Date: Fri, 19 Jun 2026 05:15:18 +0530 Subject: [PATCH 4/5] update tests --- .../widgets/tests/test_widget_output.py | 17 +++++-- .../notebooks/output-background-thread.ipynb | 45 ++++++++++++++----- ui-tests/tests/widgets.test.ts | 21 ++++++--- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/python/ipywidgets/ipywidgets/widgets/tests/test_widget_output.py b/python/ipywidgets/ipywidgets/widgets/tests/test_widget_output.py index f043f8441a..fec3adc00b 100644 --- a/python/ipywidgets/ipywidgets/widgets/tests/test_widget_output.py +++ b/python/ipywidgets/ipywidgets/widgets/tests/test_widget_output.py @@ -66,12 +66,21 @@ def test_set_msg_id_when_capturing(self): def test_set_parent_when_capturing(self): msg_id = 'msg-id' - parent = {'header': {'msg_id': msg_id}} + shell_parent = {'header': {'msg_id': msg_id}} + kernel_parent = {'header': {'msg_id': 'kernel-msg-id'}} parent_calls = [] - kernel = type('mock_kernel', (object, ), {})() + + def get_kernel_parent(self_): + return kernel_parent + + kernel = type( + 'mock_kernel', + (object, ), + {'get_parent': get_kernel_parent} + )() def get_parent(self_): - return parent + return shell_parent def set_parent(self_, parent): parent_calls.append(parent) @@ -88,7 +97,7 @@ def set_parent(self_, parent): with widget: assert widget.msg_id == msg_id - assert parent_calls == [parent] + assert parent_calls == [shell_parent] def test_clear_output(self): msg_id = 'msg-id' diff --git a/ui-tests/tests/notebooks/output-background-thread.ipynb b/ui-tests/tests/notebooks/output-background-thread.ipynb index 985c911bd1..fe843a5d17 100644 --- a/ui-tests/tests/notebooks/output-background-thread.ipynb +++ b/ui-tests/tests/notebooks/output-background-thread.ipynb @@ -7,23 +7,46 @@ "outputs": [], "source": [ "import threading\n", + "from time import sleep\n", "\n", "import ipywidgets as widgets\n", "\n", - "button = widgets.Button(description='Start background thread')\n", "out = widgets.Output()\n", "\n", - "def on_click(_):\n", - " def write_to_output():\n", - " with out:\n", - " print('captured from background thread')\n", + "def write_to_output():\n", + " with out:\n", + " for i in range(10):\n", + " print(i)\n", + " sleep(0.5)\n", "\n", - " thread = threading.Thread(target=write_to_output)\n", - " thread.start()\n", - " thread.join()\n", - "\n", - "button.on_click(on_click)\n", - "widgets.VBox([button, out])\n" + "display(out)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "threading.Thread(target=write_to_output).start()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "'first foreground cell'\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "'second foreground cell'\n" ] } ], diff --git a/ui-tests/tests/widgets.test.ts b/ui-tests/tests/widgets.test.ts index fdc858ca22..2a19a9d973 100644 --- a/ui-tests/tests/widgets.test.ts +++ b/ui-tests/tests/widgets.test.ts @@ -52,17 +52,24 @@ test.describe('Widget Visual Regression', () => { await page.notebook.openByPath(`${tmpPath}/${notebook}`); await page.notebook.activate(notebook); + const widgetOutput = page.locator('.jp-Notebook .widget-output'); await page.notebook.runCellByCell({ onAfterCellRun: async (cellIndex: number) => { - if (cellIndex === 0) { - await page - .getByRole('button', { name: 'Start background thread' }) - .click(); - await expect( - page.locator('.jp-Notebook .widget-output') - ).toContainText('captured from background thread'); + if (cellIndex === 1) { + await expect(widgetOutput).toContainText('0'); } }, }); + + await expect(widgetOutput).toHaveText('0 1 2 3 4 5 6 7 8 9', { + timeout: 10_000, + }); + + expect(await page.notebook.getCellTextOutput(2)).toEqual([ + "'first foreground cell'", + ]); + expect(await page.notebook.getCellTextOutput(3)).toEqual([ + "'second foreground cell'", + ]); }); }); From ea703c6709fc72c3d8d1700456cecc1088223e08 Mon Sep 17 00:00:00 2001 From: "anujkumar.singh" Date: Fri, 26 Jun 2026 03:57:18 +0530 Subject: [PATCH 5/5] Pin jupyterlab version to 4.5.8 --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d5ba2a8cf9..077783b935 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -144,7 +144,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install -U jupyterlab jupyter-packaging~=0.10 + python -m pip install -U jupyterlab==4.5.8 jupyter-packaging~=0.10 - name: Build and Install ipywidgets run: |