Repository: zauberzeug/nicegui
Pull request #1880 (2023-10-25 14:32:29)

Improve robustness and documentation for AwaitableResponse
This PR follows up on an internal discussion about the current implementation of the new `AwaitableResponse`.

- It removes `AwaitableResponse` from the public API in nicegui/\_\_init\_\_.py, because it is an advanced concept designed and intended for internal use, especially for the use case of calling JavaScript code on the client. Implications for other applications are hard for foresee.
- It adds unit tests to ensure the correct behavior.
- It avoids the need for `Optional[Callable]` parameters by inheriting `NullResponse` with special behavior (basically for when there is no event loop).
- It raises a `RuntimeError` if one tries to await a response after the `fire_and_forget` function has been executed.
- It uses a more symmetric implementation without canceling any tasks.


Patch:
diff --git a/nicegui/__init__.py b/nicegui/__init__.py
index 329c182e..710da857 100644
--- a/nicegui/__init__.py
+++ b/nicegui/__init__.py
@@ -1,7 +1,6 @@
 from . import context, elements, run, ui
 from .api_router import APIRouter
 from .app.app import App
-from .awaitable_response import AwaitableResponse
 from .client import Client
 from .nicegui import app
 from .tailwind import Tailwind
@@ -11,7 +10,6 @@ __all__ = [
     'APIRouter',
     'app',
     'App',
-    'AwaitableResponse',
     'Client',
     'context',
     'elements',
diff --git a/nicegui/awaitable_response.py b/nicegui/awaitable_response.py
index f78decf5..3e01e60c 100644
--- a/nicegui/awaitable_response.py
+++ b/nicegui/awaitable_response.py
@@ -1,31 +1,48 @@
 from __future__ import annotations
 
-from asyncio import Task
-from typing import Callable, Optional
+from typing import Callable
 
 from . import background_tasks
 
 
 class AwaitableResponse:
 
-    def __init__(self, fire_and_forget: Optional[Callable], wait_for_result: Optional[Callable]) -> None:
+    def __init__(self, fire_and_forget: Callable, wait_for_result: Callable) -> None:
         """Awaitable Response
 
         This class can be used to run one of two different callables, depending on whether the response is awaited or not.
+        It must be awaited immediately after creation or not at all.
 
         :param fire_and_forget: The callable to run if the response is not awaited.
         :param wait_for_result: The callable to run if the response is awaited.
         """
-        self.fire_and_forget_task: Optional[Task] = \
-            background_tasks.create(self._start(fire_and_forget), name='fire and forget') if fire_and_forget else None
+        self.fire_and_forget = fire_and_forget
         self.wait_for_result = wait_for_result
+        self._is_fired = False
+        self._is_awaited = False
+        background_tasks.create(self._fire(), name='fire')
 
-    async def _start(self, command: Callable) -> None:
-        command()
+    async def _fire(self) -> None:
+        if self._is_awaited:
+            return
+        self._is_fired = True
+        self.fire_and_forget()
 
     def __await__(self):
-        if self.fire_and_forget_task is not None:
-            self.fire_and_forget_task.cancel()
-        if self.wait_for_result is None:
-            raise ValueError('AwaitableResponse has no result to await')
+        if self._is_fired:
+            raise RuntimeError('AwaitableResponse must be awaited immediately after creation or not at all')
+        self._is_awaited = True
         return self.wait_for_result().__await__()
+
+
+class NullResponse(AwaitableResponse):
+
+    def __init__(self) -> None:  # pylint: disable=super-init-not-called
+        """Null Response
+
+        This class can be used to create an AwaitableResponse that does nothing.
+        In contrast to AwaitableResponse, it can be created without a running event loop.
+        """
+
+    def __await__(self):
+        yield from []
diff --git a/nicegui/client.py b/nicegui/client.py
index 429cadf6..51de4b3c 100644
--- a/nicegui/client.py
+++ b/nicegui/client.py
@@ -151,6 +151,15 @@ class Client:
 
         The client connection must be established before this method is called.
         You can do this by `await client.connected()` or register a callback with `client.on_connect(...)`.
+
+        If the function is awaited, the result of the JavaScript code is returned.
+        Otherwise, the JavaScript code is executed without waiting for a response.
+
+        :param code: JavaScript code to run
+        :param timeout: timeout in seconds (default: `1.0`)
+        :param check_interval: interval in seconds to check for a response (default: `0.01`)
+
+        :return: AwaitableResponse that can be awaited to get the result of the JavaScript code
         """
         if respond is True:
             log.warning('The "respond" argument of run_javascript() has been removed. '
diff --git a/nicegui/element.py b/nicegui/element.py
index 354936de..e4fef3ad 100644
--- a/nicegui/element.py
+++ b/nicegui/element.py
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional,
 from typing_extensions import Self
 
 from . import context, core, events, json, outbox, storage
-from .awaitable_response import AwaitableResponse
+from .awaitable_response import AwaitableResponse, NullResponse
 from .dependencies import Component, Library, register_library, register_vue_component
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
@@ -406,11 +406,14 @@ class Element(Visibility):
     def run_method(self, name: str, *args: Any) -> AwaitableResponse:
         """Run a method on the client side.
 
+        If the function is awaited, the result of the method call is returned.
+        Otherwise, the method is executed without waiting for a response.
+
         :param name: name of the method
         :param args: arguments to pass to the method
         """
         if not core.loop:
-            return AwaitableResponse(None, None)
+            return NullResponse()
         return self.client.run_javascript(f'return runMethod({self.id}, "{name}", {json.dumps(args)})')
 
     def _collect_descendants(self, *, include_self: bool = False) -> List[Element]:
diff --git a/nicegui/elements/aggrid.py b/nicegui/elements/aggrid.py
index 2f364495..c45a2454 100644
--- a/nicegui/elements/aggrid.py
+++ b/nicegui/elements/aggrid.py
@@ -25,7 +25,7 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
 
         An element to create a grid using `AG Grid <https://www.ag-grid.com/>`_.
 
-        The `call_api_method` method can be used to call an AG Grid API method.
+        The methods `call_api_method` and `call_column_api_method` can be used to interact with the AG Grid instance on the client.
 
         :param options: dictionary of AG Grid options
         :param html_columns: list of columns that should be rendered as HTML (default: `[]`)
@@ -89,8 +89,13 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
 
         See `AG Grid API <https://www.ag-grid.com/javascript-data-grid/grid-api/>`_ for a list of methods.
 
+        If the function is awaited, the result of the method call is returned.
+        Otherwise, the method is executed without waiting for a response.
+
         :param name: name of the method
         :param args: arguments to pass to the method
+
+        :return: AwaitableResponse that can be awaited to get the result of the method call
         """
         return self.run_method('call_api_method', name, *args)
 
@@ -99,8 +104,13 @@ class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-comm
 
         See `AG Grid Column API <https://www.ag-grid.com/javascript-data-grid/column-api/>`_ for a list of methods.
 
+        If the function is awaited, the result of the method call is returned.
+        Otherwise, the method is executed without waiting for a response.
+
         :param name: name of the method
         :param args: arguments to pass to the method
+
+        :return: AwaitableResponse that can be awaited to get the result of the method call
         """
         return self.run_method('call_column_api_method', name, *args)
 
diff --git a/nicegui/elements/scene.py b/nicegui/elements/scene.py
index b1e9fa81..2fd5f65f 100644
--- a/nicegui/elements/scene.py
+++ b/nicegui/elements/scene.py
@@ -4,7 +4,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
 from typing_extensions import Self
 
 from .. import binding
-from ..awaitable_response import AwaitableResponse
+from ..awaitable_response import AwaitableResponse, NullResponse
 from ..dataclasses import KWONLY_SLOTS
 from ..element import Element
 from ..events import (GenericEventArguments, SceneClickEventArguments, SceneClickHit, SceneDragEventArguments,
@@ -124,7 +124,7 @@ class Scene(Element,
         :param args: arguments to pass to the method
         """
         if not self.is_initialized:
-            return AwaitableResponse(None, None)
+            return NullResponse()
         return super().run_method(name, *args)
 
     def _handle_click(self, e: GenericEventArguments) -> None:
diff --git a/nicegui/functions/javascript.py b/nicegui/functions/javascript.py
index c01229a1..6b04c88d 100644
--- a/nicegui/functions/javascript.py
+++ b/nicegui/functions/javascript.py
@@ -11,15 +11,17 @@ def run_javascript(code: str, *,
     """Run JavaScript
 
     This function runs arbitrary JavaScript code on a page that is executed in the browser.
-    The asynchronous function will return after the command(s) are executed.
     The client must be connected before this function is called.
     To access a client-side object by ID, use the JavaScript function `getElement()`.
 
+    If the function is awaited, the result of the JavaScript code is returned.
+    Otherwise, the JavaScript code is executed without waiting for a response.
+
     :param code: JavaScript code to run
     :param timeout: timeout in seconds (default: `1.0`)
     :param check_interval: interval in seconds to check for a response (default: `0.01`)
 
-    :return: response from the browser, or `None` if `respond` is `False`
+    :return: AwaitableResponse that can be awaited to get the result of the JavaScript code
     """
     if respond is True:
         log.warning('The "respond" argument of run_javascript() has been removed. '


Test patch:
diff --git a/tests/test_awaitable_response.py b/tests/test_awaitable_response.py
new file mode 100644
index 00000000..8bde3b5d
--- /dev/null
+++ b/tests/test_awaitable_response.py
@@ -0,0 +1,45 @@
+import asyncio
+
+import pytest
+
+from nicegui import core
+from nicegui.awaitable_response import AwaitableResponse, NullResponse
+
+
+async def test_awaitable_response():
+    core.loop = asyncio.get_event_loop()
+    actions = []
+
+    def do_something() -> AwaitableResponse:
+        def fire_and_forget():
+            actions.append('fire_and_forget')
+
+        async def wait_for_result() -> str:
+            actions.append('wait_for_result')
+            return 'result'
+
+        return AwaitableResponse(fire_and_forget, wait_for_result)
+
+    actions.clear()
+    do_something()
+    await asyncio.sleep(0.1)
+    assert actions == ['fire_and_forget']
+
+    actions.clear()
+    result = await do_something()
+    assert result == 'result'
+    assert actions == ['wait_for_result']
+
+    actions.clear()
+    result = do_something()
+    await asyncio.sleep(0.1)
+    with pytest.raises(RuntimeError, match='AwaitableResponse must be awaited immediately after creation or not at all'):
+        await result
+    assert actions == ['fire_and_forget']
+
+
+async def test_null_response():
+    NullResponse()
+
+    core.loop = asyncio.get_event_loop()
+    await NullResponse()