Forráskód Böngészése

introduce awaitable response for ui.run_javascript

Falko Schindler 1 éve
szülő
commit
bc5303db02

+ 3 - 3
examples/chat_app/main.py

@@ -9,10 +9,10 @@ messages: List[Tuple[str, str, str, str]] = []
 
 
 
 
 @ui.refreshable
 @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:
     for user_id, avatar, text, stamp in messages:
         ui.chat_message(text=text, stamp=stamp, avatar=avatar, sent=own_id == user_id)
         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('/')
 @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
     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'):
     with ui.column().classes('w-full max-w-2xl mx-auto items-stretch'):
-        await chat_messages(user_id)
+        chat_messages(user_id)
 
 
 ui.run()
 ui.run()

+ 3 - 3
examples/chat_with_ai/main.py

@@ -15,12 +15,12 @@ thinking: bool = False
 
 
 
 
 @ui.refreshable
 @ui.refreshable
-async def chat_messages() -> None:
+def chat_messages() -> None:
     for name, text in messages:
     for name, text in messages:
         ui.chat_message(text=text, name=name, sent=name == 'You')
         ui.chat_message(text=text, name=name, sent=name == 'You')
     if thinking:
     if thinking:
         ui.spinner(size='3rem').classes('self-center')
         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('/')
 @ui.page('/')
@@ -43,7 +43,7 @@ async def main(client: Client):
     await client.connected()
     await client.connected()
 
 
     with ui.column().classes('w-full max-w-2xl mx-auto items-stretch'):
     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.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'):
         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:
 async def logout() -> None:
     """Logout the user."""
     """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:
     if result['code'] == 200:
         app.storage.user['descope'] = None
         app.storage.user['descope'] = None
     else:
     else:
@@ -64,7 +64,7 @@ class page(ui.page):
             await client.connected()
             await client.connected()
             if await self._is_logged_in():
             if await self._is_logged_in():
                 if self.path == self.LOGIN_PATH:
                 if self.path == self.LOGIN_PATH:
-                    await self._refresh()
+                    self._refresh()
                     ui.open('/')
                     ui.open('/')
                     return
                     return
             else:
             else:
@@ -96,8 +96,8 @@ class page(ui.page):
             return False
             return False
 
 
     @staticmethod
     @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]:
 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:
         async def build() -> None:
             with self.content:
             with self.content:
-                await ui.run_javascript(f'''
+                ui.run_javascript(f'''
                     if (window.location.pathname !== "{path}") {{
                     if (window.location.pathname !== "{path}") {{
                         history.pushState({{page: "{path}"}}, "", "{path}");
                         history.pushState({{page: "{path}"}}, "", "{path}");
                     }}
                     }}
-                ''', respond=False)
+                ''')
                 result = builder()
                 result = builder()
                 if isinstance(result, Awaitable):
                 if isinstance(result, Awaitable):
                     await result
                     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'}},
             headers: {{'Content-Type': 'application/json'}},
             body: JSON.stringify({{value: {e.value}}}),
             body: JSON.stringify({{value: {e.value}}}),
         }});
         }});
-    ''', respond=False))
+    '''))
     with ui.header() \
     with ui.header() \
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .classes('items-center duration-200 p-0 px-4 no-wrap') \
             .style('box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)'):
             .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')
             ui.markdown('## Reference').classes('mt-16')
             generate_class_doc(api)
             generate_class_doc(api)
     await client.connected()
     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)
 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 elements, globals  # pylint: disable=redefined-builtin
 from . import run_executor as run
 from . import run_executor as run
 from .api_router import APIRouter
 from .api_router import APIRouter
+from .awaitable_response import AwaitableResponse
 from .client import Client
 from .client import Client
 from .nicegui import app
 from .nicegui import app
 from .tailwind import Tailwind
 from .tailwind import Tailwind
@@ -9,6 +10,7 @@ from .version import __version__
 
 
 __all__ = [
 __all__ = [
     'APIRouter',
     'APIRouter',
+    'AwaitableResponse',
     'app',
     'app',
     'Client',
     'Client',
     'elements',
     '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 nicegui import json
 
 
 from . import binding, globals, outbox  # pylint: disable=redefined-builtin
 from . import binding, globals, outbox  # pylint: disable=redefined-builtin
+from .awaitable_response import AwaitableResponse
 from .dependencies import generate_resources
 from .dependencies import generate_resources
 from .element import Element
 from .element import Element
 from .favicon import get_favicon_url
 from .favicon import get_favicon_url
@@ -125,28 +126,27 @@ class Client:
             await asyncio.sleep(check_interval)
             await asyncio.sleep(check_interval)
         self.is_waiting_for_disconnect = False
         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.
         """Execute JavaScript on the client.
 
 
         The client connection must be established before this method is called.
         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(...)`.
         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())
         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:
     def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
         """Open a new page in the client."""
         """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:
     async def copy_to_clipboard(self) -> None:
         """Copy the code to the clipboard."""
         """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')
         self.copy_button.props('icon=check')
         await asyncio.sleep(3.0)
         await asyncio.sleep(3.0)
         self.copy_button.props('icon=content_copy')
         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 .. 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
     """Run JavaScript
 
 
     This function runs arbitrary JavaScript code on a page that is executed in the browser.
     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()`.
     To access a client-side object by ID, use the JavaScript function `getElement()`.
 
 
     :param code: JavaScript code to run
     :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 timeout: timeout in seconds (default: `1.0`)
     :param check_interval: interval in seconds to check for a response (default: `0.01`)
     :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()
     client = globals.get_client()
     if not client.has_socket_connection:
     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)
         b.set_visibility(False)
     ui.button('Hack', on_click=lambda: ui.run_javascript(f'''
     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]}"}});
         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.open('/')
     screen.click('Hack')
     screen.click('Hack')

+ 5 - 10
tests/test_javascript.py

@@ -1,15 +1,12 @@
 import pytest
 import pytest
 
 
 from nicegui import Client, ui
 from nicegui import Client, ui
-from nicegui.events import ValueChangeEventArguments
 
 
 from .screen import Screen
 from .screen import Screen
 
 
 
 
 def test_run_javascript_on_button_press(screen: 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('/')
     screen.open('/')
     assert screen.selenium.title == 'NiceGUI'
     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):
 def test_run_javascript_on_value_change(screen: Screen):
     @ui.page('/')
     @ui.page('/')
     async def page(client: Client):
     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 client.connected()
-        await ui.run_javascript('document.title = "Initial Title"')
+        ui.run_javascript('document.title = "Initial Title"')
 
 
     screen.open('/')
     screen.open('/')
     screen.wait(0.5)
     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):
 def test_run_javascript_before_client_connected(screen: Screen):
     @ui.page('/')
     @ui.page('/')
-    async def page():
+    def page():
         ui.label('before js')
         ui.label('before js')
         with pytest.raises(RuntimeError):
         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')
         ui.label('after js')
 
 
     screen.open('/')
     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)
     @ui.page('/', reconnect_timeout=3.0)
     def page():
     def page():
         ui.input('Input').props('autofocus')
         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.open('/')
     screen.type('hello')
     screen.type('hello')

+ 2 - 2
website/demo.py

@@ -44,8 +44,8 @@ def demo(f: Callable) -> Callable:
             code.append('ui.run()')
             code.append('ui.run()')
         code = isort.code('\n'.join(code), no_sections=True, lines_after_imports=1)
         code = isort.code('\n'.join(code), no_sections=True, lines_after_imports=1)
         with python_window(classes='w-full max-w-[44rem]'):
         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.notify('Copied to clipboard', type='positive', color='primary')
             ui.markdown(f'````python\n{code}\n````')
             ui.markdown(f'````python\n{code}\n````')
             ui.icon('content_copy', size='xs') \
             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:
     if make_menu_entry:
         with get_menu() as menu:
         with get_menu() as menu:
             async def click():
             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()
                     menu.hide()
                     ui.open(f'#{name}')
                     ui.open(f'#{name}')
             ui.link(text, target=f'#{name}').props('data-close-overlay').on('click', click, [])
             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
         # END OF DEMO
         await globals.get_client().connected()
         await globals.get_client().connected()
-        await ui.run_javascript(f'''
+        ui.run_javascript(f'''
             document.addEventListener('visibilitychange', () => {{
             document.addEventListener('visibilitychange', () => {{
                 if (document.visibilityState === 'visible')
                 if (document.visibilityState === 'visible')
                     getElement({tabwatch.id}).$emit('tabvisible');
                     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:
 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():
     async def get_date():
         time = await ui.run_javascript('Date()')
         time = await ui.run_javascript('Date()')
         ui.notify(f'Browser time: {time}')
         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('fire and forget', on_click=alert)
     ui.button('receive result', on_click=get_date)
     ui.button('receive result', on_click=get_date)

+ 3 - 3
website/search.py

@@ -62,12 +62,12 @@ class Search:
                             ui.label(result['item']['title'])
                             ui.label(result['item']['title'])
         background_tasks.create_lazy(handle_input(), name='handle_search_input')
         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}"
             const url = "{url}"
             if (url.startsWith("http"))
             if (url.startsWith("http"))
                 window.open(url, "_blank");
                 window.open(url, "_blank");
             else
             else
                 window.location.href = url;
                 window.location.href = url;
-        ''', respond=False)
+        ''')
         self.dialog.close()
         self.dialog.close()