From 4ffc86fc1daa81293db5a4de9d9efb7531996aab Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 31 May 2024 13:29:07 -0400 Subject: [PATCH] Ensure ParamRef resolves synchronous generators asynchronously (#6885) --- panel/chat/feed.py | 20 ++---------------- panel/param.py | 14 +++---------- panel/tests/test_param.py | 44 ++++++++++++++++++--------------------- panel/util/__init__.py | 19 +++++++++++++++++ 4 files changed, 44 insertions(+), 53 deletions(-) diff --git a/panel/chat/feed.py b/panel/chat/feed.py index f0fd89ac9e..855c808cfc 100644 --- a/panel/chat/feed.py +++ b/panel/chat/feed.py @@ -26,6 +26,7 @@ from ..layout.card import Card from ..layout.spacer import VSpacer from ..pane.image import SVG +from ..util import to_async_gen from .message import ChatMessage if TYPE_CHECKING: @@ -457,23 +458,6 @@ async def _schedule_placeholder( return await asyncio.sleep(0.1) - async def _to_async_gen(self, sync_gen): - done = object() - - def safe_next(): - # Converts StopIteration to a sentinel value to avoid: - # TypeError: StopIteration interacts badly with generators and cannot be raised into a Future - try: - return next(sync_gen) - except StopIteration: - return done - - while True: - value = await asyncio.to_thread(safe_next) - if value is done: - break - yield value - async def _handle_callback(self, message, loop: asyncio.BaseEventLoop): callback_args = self._gather_callback_args(message) if iscoroutinefunction(self.callback): @@ -481,7 +465,7 @@ async def _handle_callback(self, message, loop: asyncio.BaseEventLoop): elif isasyncgenfunction(self.callback): response = self.callback(*callback_args) elif isgeneratorfunction(self.callback): - response = self._to_async_gen(self.callback(*callback_args)) + response = to_async_gen(self.callback(*callback_args)) # printing type(response) -> else: response = await asyncio.to_thread(self.callback, *callback_args) diff --git a/panel/param.py b/panel/param.py index 96a775775c..45493c427d 100644 --- a/panel/param.py +++ b/panel/param.py @@ -54,7 +54,7 @@ class Skip(RuntimeError): from .reactive import Reactive from .util import ( abbreviated_repr, flatten, full_groupby, fullpath, is_parameterized, - param_name, recursive_parameterized, + param_name, recursive_parameterized, to_async_gen, ) from .util.checks import is_dataframe, is_mpl_axes, is_series from .viewable import Layoutable, Viewable @@ -888,19 +888,11 @@ def _replace_pane(self, *args, force=False): param.DEBUG, 'Skip event was raised, skipping update.' ) return + if isinstance(new_object, Generator): + new_object = to_async_gen(new_object) if inspect.isawaitable(new_object) or isinstance(new_object, types.AsyncGeneratorType): param.parameterized.async_executor(partial(self._eval_async, new_object)) return - elif isinstance(new_object, Generator): - append_mode = self.generator_mode == 'append' - if append_mode: - self._inner_layout[:] = [] - for new_obj in new_object: - if append_mode: - self._inner_layout.append(new_obj) - self._pane = self._inner_layout[-1] - else: - self._update_inner(new_obj) else: self._update_inner(new_object) finally: diff --git a/panel/tests/test_param.py b/panel/tests/test_param.py index ffb23b045e..3e370c3397 100644 --- a/panel/tests/test_param.py +++ b/panel/tests/test_param.py @@ -24,7 +24,9 @@ from panel.param import ( JSONInit, Param, ParamFunction, ParamMethod, Skip, ) -from panel.tests.util import mpl_available, mpl_figure +from panel.tests.util import ( + async_wait_until, mpl_available, mpl_figure, wait_until, +) from panel.widgets import ( AutocompleteInput, Button, Checkbox, DatePicker, DatetimeInput, EditableFloatSlider, EditableRangeSlider, LiteralInput, NumberInput, @@ -1789,11 +1791,11 @@ def function(value): root = pane.get_root(document, comm) - assert root.children[0].text == '<p>False</p>\n' + wait_until(lambda: root.children[0].text == '<p>False</p>\n') checkbox.value = True - assert root.children[0].text == '<p>True</p>\n' + wait_until(lambda: root.children[0].text == '<p>True</p>\n') def test_param_generator_append(document, comm): @@ -1807,15 +1809,17 @@ def function(value): root = pane.get_root(document, comm) - assert len(root.children) == 2 + wait_until(lambda: len(root.children) == 2) assert root.children[0].text == '<p>False</p>\n' assert root.children[1].text == '<p>True</p>\n' checkbox.value = True - assert len(root.children) == 2 - assert root.children[0].text == '<p>True</p>\n' - assert root.children[1].text == '<p>False</p>\n' + wait_until(lambda: len(root.children) == 2) + wait_until(lambda: ( + (root.children[0].text == '<p>True</p>\n') and + (root.children[1].text == '<p>False</p>\n') + )) async def test_param_async_generator(document, comm): checkbox = Checkbox(value=False) @@ -1827,15 +1831,11 @@ async def function(value): root = pane.get_root(document, comm) - await asyncio.sleep(0.01) - - assert root.children[0].text == '<p>False</p>\n' + await async_wait_until(lambda: root.children[0].text == '<p>False</p>\n') checkbox.value = True - await asyncio.sleep(0.01) - - assert root.children[0].text == '<p>True</p>\n' + await async_wait_until(lambda: root.children[0].text == '<p>True</p>\n') async def test_param_async_generator_append(document, comm): checkbox = Checkbox(value=False) @@ -1849,21 +1849,17 @@ async def function(value): root = pane.get_root(document, comm) - await asyncio.sleep(0.01) - assert len(root.children) == 1 - assert root.children[0].text == '<p>False</p>\n' - await asyncio.sleep(0.01) - assert len(root.children) == 2 + await async_wait_until(lambda: len(root.children) == 1, interval=10) + await async_wait_until(lambda: root.children[0].text == '<p>False</p>\n') + await async_wait_until(lambda: len(root.children) == 2, interval=10) assert root.children[0].text == '<p>False</p>\n' assert root.children[1].text == '<p>True</p>\n' checkbox.value = True - await asyncio.sleep(0.01) - assert len(root.children) == 1 + await async_wait_until(lambda: len(root.children) == 1, interval=10) assert root.children[0].text == '<p>True</p>\n' - await asyncio.sleep(0.01) - assert len(root.children) == 2 + await async_wait_until(lambda: len(root.children) == 2, interval=10) assert root.children[0].text == '<p>True</p>\n' assert root.children[1].text == '<p>False</p>\n' @@ -1879,11 +1875,11 @@ def function(value): root = pane.get_root(document, comm) - assert root.children[0].text == '<p>True</p>\n' + wait_until(lambda: root.children[0].text == '<p>True</p>\n') checkbox.value = True - assert root.children[0].text == '<p>False</p>\n' + wait_until(lambda: root.children[0].text == '<p>False</p>\n') async def test_param_async_generator_multiple(document, comm): checkbox = Checkbox(value=False) diff --git a/panel/util/__init__.py b/panel/util/__init__.py index f9b97441de..76d348d216 100644 --- a/panel/util/__init__.py +++ b/panel/util/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import ast +import asyncio import base64 import datetime as dt import json @@ -484,3 +485,21 @@ def try_datetime64_to_datetime(value): if isinstance(value, np.datetime64): value = value.astype('datetime64[ms]').astype(datetime) return value + + +async def to_async_gen(sync_gen): + done = object() + + def safe_next(): + # Converts StopIteration to a sentinel value to avoid: + # TypeError: StopIteration interacts badly with generators and cannot be raised into a Future + try: + return next(sync_gen) + except StopIteration: + return done + + while True: + value = await asyncio.to_thread(safe_next) + if value is done: + break + yield value