Explorar o código

introduce awaitable response for ui.run_javascript

Falko Schindler hai 1 ano
pai
achega
bc5303db02

+ 3 - 3
examples/chat_app/main.py

@@ -9,10 +9,10 @@ messages: List[Tuple[str, str, str, str]] = []
 
 
 @ui.refreshable
-async def chat_messages(own_id: str) -> None:
+def chat_messages(own_id: str) -> None:
     for user_id, avatar, text, stamp in messages:
         ui.chat_message(text=text, stamp=stamp, avatar=avatar, sent=own_id == user_id)
-    await ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)', respond=False)
+    ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)')
 
 
 @ui.page('/')
@@ -39,6 +39,6 @@ async def main(client: Client):
 
     await client.connected()  # chat_messages(...) uses run_javascript which is only possible after connecting
     with ui.column().classes('w-full max-w-2xl mx-auto items-stretch'):
-        await chat_messages(user_id)
+        chat_messages(user_id)
 
 ui.run()

+ 3 - 3
examples/chat_with_ai/main.py

@@ -15,12 +15,12 @@ thinking: bool = False
 
 
 @ui.refreshable
-async def chat_messages() -> None:
+def chat_messages() -> None:
     for name, text in messages:
         ui.chat_message(text=text, name=name, sent=name == 'You')
     if thinking:
         ui.spinner(size='3rem').classes('self-center')
-    await ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)', respond=False)
+    ui.run_javascript('window.scrollTo(0, document.body.scrollHeight)')
 
 
 @ui.page('/')
@@ -43,7 +43,7 @@ async def main(client: Client):
     await client.connected()
 
     with ui.column().classes('w-full max-w-2xl mx-auto items-stretch'):
-        await chat_messages()
+        chat_messages()
 
     with ui.footer().classes('bg-white'), ui.column().classes('w-full max-w-3xl mx-auto my-6'):
         with ui.row().classes('w-full no-wrap items-center'):

+ 4 - 4
examples/descope_auth/user.py

@@ -31,7 +31,7 @@ def about() -> Dict[str, Any]:
 
 async def logout() -> None:
     """Logout the user."""
-    result = await ui.run_javascript('return await sdk.logout()', respond=True)
+    result = await ui.run_javascript('return await sdk.logout()')
     if result['code'] == 200:
         app.storage.user['descope'] = None
     else:
@@ -64,7 +64,7 @@ class page(ui.page):
             await client.connected()
             if await self._is_logged_in():
                 if self.path == self.LOGIN_PATH:
-                    await self._refresh()
+                    self._refresh()
                     ui.open('/')
                     return
             else:
@@ -96,8 +96,8 @@ class page(ui.page):
             return False
 
     @staticmethod
-    async def _refresh() -> None:
-        await ui.run_javascript('sdk.refresh()', respond=False)
+    def _refresh() -> None:
+        ui.run_javascript('sdk.refresh()')
 
 
 def login_page(func: Callable[..., Any]) -> Callable[..., Any]:

+ 2 - 2
examples/single_page_app/router.py

@@ -29,11 +29,11 @@ class Router():
 
         async def build() -> None:
             with self.content:
-                await ui.run_javascript(f'''
+                ui.run_javascript(f'''
                     if (window.location.pathname !== "{path}") {{
                         history.pushState({{page: "{path}"}}, "", "{path}");
                     }}
-                ''', respond=False)
+                ''')
                 result = builder()
                 if isinstance(result, Awaitable):
                     await result

+ 2 - 2
main.py

@@ -119,7 +119,7 @@ def add_header(menu: Optional[ui.left_drawer] = None) -> None:
             headers: {{'Content-Type': 'application/json'}},
             body: JSON.stringify({{value: {e.value}}}),
         }});
-    ''', respond=False))
+    '''))
     with ui.header() \
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
@@ -431,6 +431,6 @@ async def documentation_page_more(name: str, client: Client) -> None:
             ui.markdown('## Reference').classes('mt-16')
             generate_class_doc(api)
     await client.connected()
-    await ui.run_javascript(f'document.title = "{name} • NiceGUI";', respond=False)
+    ui.run_javascript(f'document.title = "{name} • NiceGUI";')
 
 ui.run(uvicorn_reload_includes='*.py, *.css, *.html', reconnect_timeout=3.0)

+ 2 - 0
nicegui/__init__.py

@@ -2,6 +2,7 @@ from . import ui  # pylint: disable=redefined-builtin
 from . import elements, globals  # pylint: disable=redefined-builtin
 from . import run_executor as run
 from .api_router import APIRouter
+from .awaitable_response import AwaitableResponse
 from .client import Client
 from .nicegui import app
 from .tailwind import Tailwind
@@ -9,6 +10,7 @@ from .version import __version__
 
 __all__ = [
     'APIRouter',
+    'AwaitableResponse',
     'app',
     'Client',
     'elements',

+ 24 - 0
nicegui/awaitable_response.py

@@ -0,0 +1,24 @@
+from typing import Callable
+
+from . import background_tasks
+
+
+class AwaitableResponse:
+
+    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.
+
+        :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.wait_for_result = wait_for_result
+        self.fire_and_forget_task = background_tasks.create(self._start(fire_and_forget), name='fire and forget')
+
+    async def _start(self, command: Callable) -> None:
+        command()
+
+    def __await__(self):
+        self.fire_and_forget_task.cancel()
+        return self.wait_for_result().__await__()

+ 16 - 16
nicegui/client.py

@@ -13,6 +13,7 @@ from fastapi.templating import Jinja2Templates
 from nicegui import json
 
 from . import binding, globals, outbox  # pylint: disable=redefined-builtin
+from .awaitable_response import AwaitableResponse
 from .dependencies import generate_resources
 from .element import Element
 from .favicon import get_favicon_url
@@ -125,28 +126,27 @@ class Client:
             await asyncio.sleep(check_interval)
         self.is_waiting_for_disconnect = False
 
-    async def run_javascript(self, code: str, *,
-                             respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[Any]:
+    def run_javascript(self, code: str, *, timeout: float = 1.0, check_interval: float = 0.01) -> AwaitableResponse:
         """Execute JavaScript on the 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 respond is True, the javascript code must return a string.
         """
         request_id = str(uuid.uuid4())
-        command = {
-            'code': code,
-            'request_id': request_id if respond else None,
-        }
-        outbox.enqueue_message('run_javascript', command, self.id)
-        if not respond:
-            return None
-        deadline = time.time() + timeout
-        while request_id not in self.waiting_javascript_commands:
-            if time.time() > deadline:
-                raise TimeoutError('JavaScript did not respond in time')
-            await asyncio.sleep(check_interval)
-        return self.waiting_javascript_commands.pop(request_id)
+
+        def send_and_forget():
+            outbox.enqueue_message('run_javascript', {'code': code}, self.id)
+
+        async def send_and_wait():
+            outbox.enqueue_message('run_javascript', {'code': code, 'request_id': request_id}, self.id)
+            deadline = time.time() + timeout
+            while request_id not in self.waiting_javascript_commands:
+                if time.time() > deadline:
+                    raise TimeoutError('JavaScript did not respond in time')
+                await asyncio.sleep(check_interval)
+            return self.waiting_javascript_commands.pop(request_id)
+
+        return AwaitableResponse(send_and_forget, send_and_wait)
 
     def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
         """Open a new page in the client."""

+ 1 - 1
nicegui/elements/code.py

@@ -30,7 +30,7 @@ class Code(Element):
 
     async def copy_to_clipboard(self) -> None:
         """Copy the code to the clipboard."""
-        await run_javascript('navigator.clipboard.writeText(`' + self.content + '`)', respond=False)
+        run_javascript('navigator.clipboard.writeText(`' + self.content + '`)')
         self.copy_button.props('icon=check')
         await asyncio.sleep(3.0)
         self.copy_button.props('icon=content_copy')

+ 5 - 8
nicegui/functions/javascript.py

@@ -1,10 +1,8 @@
-from typing import Any, Optional
-
 from .. import globals  # pylint: disable=redefined-builtin
+from ..awaitable_response import AwaitableResponse
 
 
-async def run_javascript(code: str, *,
-                         respond: bool = True, timeout: float = 1.0, check_interval: float = 0.01) -> Optional[Any]:
+def run_javascript(code: str, *, timeout: float = 1.0, check_interval: float = 0.01) -> AwaitableResponse:
     """Run JavaScript
 
     This function runs arbitrary JavaScript code on a page that is executed in the browser.
@@ -13,7 +11,6 @@ async def run_javascript(code: str, *,
     To access a client-side object by ID, use the JavaScript function `getElement()`.
 
     :param code: JavaScript code to run
-    :param respond: whether to wait for a response (default: `True`)
     :param timeout: timeout in seconds (default: `1.0`)
     :param check_interval: interval in seconds to check for a response (default: `0.01`)
 
@@ -21,7 +18,7 @@ async def run_javascript(code: str, *,
     """
     client = globals.get_client()
     if not client.has_socket_connection:
-        raise RuntimeError(
-            'Cannot run JavaScript before client is connected; try "await client.connected()" or "client.on_connect(...)".')
+        raise RuntimeError('Cannot run JavaScript before client is connected; '
+                           'try "await client.connected()" or "client.on_connect(...)".')
 
-    return await client.run_javascript(code, respond=respond, timeout=timeout, check_interval=check_interval)
+    return client.run_javascript(code, timeout=timeout, check_interval=check_interval)

+ 1 - 1
tests/test_events.py

@@ -171,7 +171,7 @@ def test_server_side_validation(screen: Screen, attribute: Literal['disabled', '
         b.set_visibility(False)
     ui.button('Hack', on_click=lambda: ui.run_javascript(f'''
         getElement({b.id}).$emit("click", {{"id": {b.id}, "listener_id": "{list(b._event_listeners.keys())[0]}"}});
-    ''', respond=False))  # pylint: disable=protected-access
+    '''))  # pylint: disable=protected-access
 
     screen.open('/')
     screen.click('Hack')

+ 5 - 10
tests/test_javascript.py

@@ -1,15 +1,12 @@
 import pytest
 
 from nicegui import Client, ui
-from nicegui.events import ValueChangeEventArguments
 
 from .screen import Screen
 
 
 def test_run_javascript_on_button_press(screen: Screen):
-    async def set_title() -> None:
-        await ui.run_javascript('document.title = "A New Title"')
-    ui.button('change title', on_click=set_title)
+    ui.button('change title', on_click=lambda: ui.run_javascript('document.title = "A New Title"'))
 
     screen.open('/')
     assert screen.selenium.title == 'NiceGUI'
@@ -21,11 +18,9 @@ def test_run_javascript_on_button_press(screen: Screen):
 def test_run_javascript_on_value_change(screen: Screen):
     @ui.page('/')
     async def page(client: Client):
-        async def set_title(e: ValueChangeEventArguments) -> None:
-            await ui.run_javascript(f'document.title = "Page {e.value}"')
-        ui.radio(['A', 'B'], on_change=set_title)
+        ui.radio(['A', 'B'], on_change=lambda e: ui.run_javascript(f'document.title = "Page {e.value}"'))
         await client.connected()
-        await ui.run_javascript('document.title = "Initial Title"')
+        ui.run_javascript('document.title = "Initial Title"')
 
     screen.open('/')
     screen.wait(0.5)
@@ -40,10 +35,10 @@ def test_run_javascript_on_value_change(screen: Screen):
 
 def test_run_javascript_before_client_connected(screen: Screen):
     @ui.page('/')
-    async def page():
+    def page():
         ui.label('before js')
         with pytest.raises(RuntimeError):
-            await ui.run_javascript('document.title = "A New Title"')
+            ui.run_javascript('document.title = "A New Title"')
         ui.label('after js')
 
     screen.open('/')

+ 1 - 1
tests/test_page.py

@@ -290,7 +290,7 @@ def test_reconnecting_without_page_reload(screen: Screen):
     @ui.page('/', reconnect_timeout=3.0)
     def page():
         ui.input('Input').props('autofocus')
-        ui.button('drop connection', on_click=lambda: ui.run_javascript('socket.io.engine.close()', respond=False))
+        ui.button('drop connection', on_click=lambda: ui.run_javascript('socket.io.engine.close()'))
 
     screen.open('/')
     screen.type('hello')

+ 2 - 2
website/demo.py

@@ -44,8 +44,8 @@ def demo(f: Callable) -> Callable:
             code.append('ui.run()')
         code = isort.code('\n'.join(code), no_sections=True, lines_after_imports=1)
         with python_window(classes='w-full max-w-[44rem]'):
-            async def copy_code():
-                await ui.run_javascript('navigator.clipboard.writeText(`' + code + '`)', respond=False)
+            def copy_code():
+                ui.run_javascript('navigator.clipboard.writeText(`' + code + '`)')
                 ui.notify('Copied to clipboard', type='positive', color='primary')
             ui.markdown(f'````python\n{code}\n````')
             ui.icon('content_copy', size='xs') \

+ 1 - 1
website/documentation_tools.py

@@ -47,7 +47,7 @@ def subheading(text: str, *, make_menu_entry: bool = True, more_link: Optional[s
     if make_menu_entry:
         with get_menu() as menu:
             async def click():
-                if await ui.run_javascript(f'!!document.querySelector("div.q-drawer__backdrop")'):
+                if await ui.run_javascript('!!document.querySelector("div.q-drawer__backdrop")'):
                     menu.hide()
                     ui.open(f'#{name}')
             ui.link(text, target=f'#{name}').props('data-close-overlay').on('click', click, [])

+ 2 - 2
website/more_documentation/generic_events_documentation.py

@@ -114,9 +114,9 @@ def more() -> None:
         ''')
         # END OF DEMO
         await globals.get_client().connected()
-        await ui.run_javascript(f'''
+        ui.run_javascript(f'''
             document.addEventListener('visibilitychange', () => {{
                 if (document.visibilityState === 'visible')
                     getElement({tabwatch.id}).$emit('tabvisible');
             }});
-        ''', respond=False)
+        ''')

+ 4 - 4
website/more_documentation/run_javascript_documentation.py

@@ -4,15 +4,15 @@ from ..documentation_tools import text_demo
 
 
 def main_demo() -> None:
-    async def alert():
-        await ui.run_javascript('alert("Hello!")', respond=False)
+    def alert():
+        ui.run_javascript('alert("Hello!")')
 
     async def get_date():
         time = await ui.run_javascript('Date()')
         ui.notify(f'Browser time: {time}')
 
-    async def access_elements():
-        await ui.run_javascript(f'getElement({label.id}).innerText += " Hello!"')
+    def access_elements():
+        ui.run_javascript(f'getElement({label.id}).innerText += " Hello!"')
 
     ui.button('fire and forget', on_click=alert)
     ui.button('receive result', on_click=get_date)

+ 3 - 3
website/search.py

@@ -62,12 +62,12 @@ class Search:
                             ui.label(result['item']['title'])
         background_tasks.create_lazy(handle_input(), name='handle_search_input')
 
-    async def open_url(self, url: str) -> None:
-        await ui.run_javascript(f'''
+    def open_url(self, url: str) -> None:
+        ui.run_javascript(f'''
             const url = "{url}"
             if (url.startsWith("http"))
                 window.open(url, "_blank");
             else
                 window.location.href = url;
-        ''', respond=False)
+        ''')
         self.dialog.close()