Browse Source

Merge branch 'main' into air_cookies

Falko Schindler 1 year ago
parent
commit
838474e9c8

+ 0 - 4
.github/workflows/test.yml

@@ -33,10 +33,6 @@ jobs:
         run: ./test_startup.sh
         run: ./test_startup.sh
       - name: setup chromedriver
       - name: setup chromedriver
         uses: nanasess/setup-chromedriver@v2.1.1
         uses: nanasess/setup-chromedriver@v2.1.1
-        with:
-          # XXX: This is an unfortunate workaround due to this issue:
-          # https://github.com/nanasess/setup-chromedriver/issues/199
-          chromedriver-version: "115.0.5790.102"
       - name: pytest
       - name: pytest
         run: pytest
         run: pytest
       - name: upload screenshots
       - name: upload screenshots

+ 3 - 11
examples/ai_interface/main.py

@@ -1,25 +1,17 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-import asyncio
-import functools
 import io
 import io
-from typing import Callable
 
 
 import replicate  # very nice API to run AI models; see https://replicate.com/
 import replicate  # very nice API to run AI models; see https://replicate.com/
 
 
-from nicegui import ui
+from nicegui import run, ui
 from nicegui.events import UploadEventArguments
 from nicegui.events import UploadEventArguments
 
 
 
 
-async def io_bound(callback: Callable, *args: any, **kwargs: any):
-    '''Makes a blocking function awaitable; pass function as first parameter and its arguments as the rest'''
-    return await asyncio.get_event_loop().run_in_executor(None, functools.partial(callback, *args, **kwargs))
-
-
 async def transcribe(e: UploadEventArguments):
 async def transcribe(e: UploadEventArguments):
     transcription.text = 'Transcribing...'
     transcription.text = 'Transcribing...'
     model = replicate.models.get('openai/whisper')
     model = replicate.models.get('openai/whisper')
     version = model.versions.get('30414ee7c4fffc37e260fcab7842b5be470b9b840f2b608f5baa9bbef9a259ed')
     version = model.versions.get('30414ee7c4fffc37e260fcab7842b5be470b9b840f2b608f5baa9bbef9a259ed')
-    prediction = await io_bound(version.predict, audio=io.BytesIO(e.content.read()))
+    prediction = await run.io_bound(version.predict, audio=io.BytesIO(e.content.read()))
     text = prediction.get('transcription', 'no transcription')
     text = prediction.get('transcription', 'no transcription')
     transcription.set_text(f'result: "{text}"')
     transcription.set_text(f'result: "{text}"')
 
 
@@ -28,7 +20,7 @@ async def generate_image():
     image.source = 'https://dummyimage.com/600x400/ccc/000000.png&text=building+image...'
     image.source = 'https://dummyimage.com/600x400/ccc/000000.png&text=building+image...'
     model = replicate.models.get('stability-ai/stable-diffusion')
     model = replicate.models.get('stability-ai/stable-diffusion')
     version = model.versions.get('db21e45d3f7023abc2a46ee38a23973f6dce16bb082a930b0c49861f96d1e5bf')
     version = model.versions.get('db21e45d3f7023abc2a46ee38a23973f6dce16bb082a930b0c49861f96d1e5bf')
-    prediction = await io_bound(version.predict, prompt=prompt.value)
+    prediction = await run.io_bound(version.predict, prompt=prompt.value)
     image.source = prediction[0]
     image.source = prediction[0]
 
 
 # User Interface
 # User Interface

+ 3 - 10
examples/opencv_webcam/main.py

@@ -1,7 +1,5 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-import asyncio
 import base64
 import base64
-import concurrent.futures
 import signal
 import signal
 import time
 import time
 
 
@@ -10,10 +8,8 @@ import numpy as np
 from fastapi import Response
 from fastapi import Response
 
 
 import nicegui.globals
 import nicegui.globals
-from nicegui import app, ui
+from nicegui import app, run, ui
 
 
-# We need an executor to schedule CPU-intensive tasks with `loop.run_in_executor()`.
-process_pool_executor = concurrent.futures.ProcessPoolExecutor()
 # In case you don't have a webcam, this will provide a black placeholder image.
 # In case you don't have a webcam, this will provide a black placeholder image.
 black_1px = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII='
 black_1px = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII='
 placeholder = Response(content=base64.b64decode(black_1px.encode('ascii')), media_type='image/png')
 placeholder = Response(content=base64.b64decode(black_1px.encode('ascii')), media_type='image/png')
@@ -31,14 +27,13 @@ def convert(frame: np.ndarray) -> bytes:
 async def grab_video_frame() -> Response:
 async def grab_video_frame() -> Response:
     if not video_capture.isOpened():
     if not video_capture.isOpened():
         return placeholder
         return placeholder
-    loop = asyncio.get_running_loop()
     # The `video_capture.read` call is a blocking function.
     # The `video_capture.read` call is a blocking function.
     # So we run it in a separate thread (default executor) to avoid blocking the event loop.
     # So we run it in a separate thread (default executor) to avoid blocking the event loop.
-    _, frame = await loop.run_in_executor(None, video_capture.read)
+    _, frame = await run.io_bound(video_capture.read)
     if frame is None:
     if frame is None:
         return placeholder
         return placeholder
     # `convert` is a CPU-intensive function, so we run it in a separate process to avoid blocking the event loop and GIL.
     # `convert` is a CPU-intensive function, so we run it in a separate process to avoid blocking the event loop and GIL.
-    jpeg = await loop.run_in_executor(process_pool_executor, convert, frame)
+    jpeg = await run.cpu_bound(convert, frame)
     return Response(content=jpeg, media_type='image/jpeg')
     return Response(content=jpeg, media_type='image/jpeg')
 
 
 # For non-flickering image updates an interactive image is much better than `ui.image()`.
 # For non-flickering image updates an interactive image is much better than `ui.image()`.
@@ -68,8 +63,6 @@ async def cleanup() -> None:
     await disconnect()
     await disconnect()
     # Release the webcam hardware so it can be used by other applications again.
     # Release the webcam hardware so it can be used by other applications again.
     video_capture.release()
     video_capture.release()
-    # The process pool executor must be shutdown when the app is closed, otherwise the process will not exit.
-    process_pool_executor.shutdown()
 
 
 app.on_shutdown(cleanup)
 app.on_shutdown(cleanup)
 # We also need to disconnect clients when the app is stopped with Ctrl+C,
 # We also need to disconnect clients when the app is stopped with Ctrl+C,

+ 3 - 12
examples/progress/main.py

@@ -1,16 +1,12 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-import asyncio
 import time
 import time
-from concurrent.futures import ProcessPoolExecutor
 from multiprocessing import Manager, Queue
 from multiprocessing import Manager, Queue
 
 
-from nicegui import app, ui
-
-pool = ProcessPoolExecutor()
+from nicegui import run, ui
 
 
 
 
 def heavy_computation(q: Queue) -> str:
 def heavy_computation(q: Queue) -> str:
-    '''Some heavy computation that updates the progress bar through the queue.'''
+    """Run some heavy computation that updates the progress bar through the queue."""
     n = 50
     n = 50
     for i in range(n):
     for i in range(n):
         # Perform some heavy computation
         # Perform some heavy computation
@@ -23,11 +19,9 @@ def heavy_computation(q: Queue) -> str:
 
 
 @ui.page('/')
 @ui.page('/')
 def main_page():
 def main_page():
-
     async def start_computation():
     async def start_computation():
         progressbar.visible = True
         progressbar.visible = True
-        loop = asyncio.get_running_loop()
-        result = await loop.run_in_executor(pool, heavy_computation, queue)
+        result = await run.cpu_bound(heavy_computation, queue)
         ui.notify(result)
         ui.notify(result)
         progressbar.visible = False
         progressbar.visible = False
 
 
@@ -42,7 +36,4 @@ def main_page():
     progressbar.visible = False
     progressbar.visible = False
 
 
 
 
-# stop the pool when the app is closed; will not cancel any running tasks
-app.on_shutdown(pool.shutdown)
-
 ui.run()
 ui.run()

+ 4 - 1
nicegui/__init__.py

@@ -1,4 +1,6 @@
-from . import elements, globals, ui  # pylint: disable=redefined-builtin
+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 .api_router import APIRouter
 from .client import Client
 from .client import Client
 from .nicegui import app
 from .nicegui import app
@@ -11,6 +13,7 @@ __all__ = [
     'Client',
     'Client',
     'elements',
     'elements',
     'globals',
     'globals',
+    'run',
     'Tailwind',
     'Tailwind',
     'ui',
     'ui',
     '__version__',
     '__version__',

+ 25 - 0
nicegui/elements/editor.py

@@ -0,0 +1,25 @@
+from typing import Any, Callable, Optional
+
+from .mixins.disableable_element import DisableableElement
+from .mixins.value_element import ValueElement
+
+
+class Editor(ValueElement, DisableableElement):
+
+    def __init__(self,
+                 *,
+                 placeholder: Optional[str] = None,
+                 value: str = '',
+                 on_change: Optional[Callable[..., Any]] = None,
+                 ) -> None:
+        """Editor
+
+        A WYSIWYG editor based on `Quasar's QEditor <https://quasar.dev/vue-components/editor>`_.
+        The value is a string containing the formatted text as HTML code.
+
+        :param value: initial value
+        :param on_change: callback to be invoked when the value changes
+        """
+        super().__init__(tag='q-editor', value=value, on_value_change=on_change)
+        if placeholder is not None:
+            self._props['placeholder'] = placeholder

+ 2 - 2
nicegui/native.py

@@ -1,4 +1,3 @@
-import asyncio
 import inspect
 import inspect
 import warnings
 import warnings
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
@@ -8,6 +7,7 @@ from typing import Any, Callable, Dict, Optional, Tuple
 
 
 from .dataclasses import KWONLY_SLOTS
 from .dataclasses import KWONLY_SLOTS
 from .globals import log
 from .globals import log
+from .run_executor import io_bound
 
 
 method_queue: Queue = Queue()
 method_queue: Queue = Queue()
 response_queue: Queue = Queue()
 response_queue: Queue = Queue()
@@ -123,7 +123,7 @@ try:
                     log.exception(f'error in {name}')
                     log.exception(f'error in {name}')
                     return None
                     return None
             name = inspect.currentframe().f_back.f_code.co_name  # type: ignore
             name = inspect.currentframe().f_back.f_code.co_name  # type: ignore
-            return await asyncio.get_event_loop().run_in_executor(None, partial(wrapper, *args, **kwargs))
+            return await io_bound(wrapper, *args, **kwargs)
 
 
         def signal_server_shutdown(self) -> None:
         def signal_server_shutdown(self) -> None:
             self._send()
             self._send()

+ 3 - 1
nicegui/nicegui.py

@@ -11,7 +11,8 @@ from fastapi.responses import FileResponse, Response
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from fastapi_socketio import SocketManager
 from fastapi_socketio import SocketManager
 
 
-from . import background_tasks, binding, favicon, globals, json, outbox, welcome  # pylint: disable=redefined-builtin
+from . import (background_tasks, binding, favicon, globals, json, outbox,  # pylint: disable=redefined-builtin
+               run_executor, welcome)
 from .app import App
 from .app import App
 from .client import Client
 from .client import Client
 from .dependencies import js_components, libraries
 from .dependencies import js_components, libraries
@@ -109,6 +110,7 @@ async def handle_shutdown() -> None:
     with globals.index_client:
     with globals.index_client:
         for t in globals.shutdown_handlers:
         for t in globals.shutdown_handlers:
             safe_invoke(t)
             safe_invoke(t)
+    run_executor.tear_down()
     globals.state = globals.State.STOPPED
     globals.state = globals.State.STOPPED
     if globals.air:
     if globals.air:
         await globals.air.disconnect()
         await globals.air.disconnect()

+ 44 - 0
nicegui/run_executor.py

@@ -0,0 +1,44 @@
+import asyncio
+import sys
+from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
+from functools import partial
+from typing import Any, Callable
+
+from . import globals, helpers  # pylint: disable=redefined-builtin
+
+process_pool = ProcessPoolExecutor()
+thread_pool = ThreadPoolExecutor()
+
+
+async def _run(executor: Any, callback: Callable, *args: Any, **kwargs: Any) -> Any:
+    if globals.state == globals.State.STOPPING:
+        return
+    try:
+        loop = asyncio.get_running_loop()
+        return await loop.run_in_executor(executor, partial(callback, *args, **kwargs))
+    except RuntimeError as e:
+        if 'cannot schedule new futures after shutdown' not in str(e):
+            raise
+    except asyncio.exceptions.CancelledError:
+        pass
+
+
+async def cpu_bound(callback: Callable, *args: Any, **kwargs: Any) -> Any:
+    """Run a CPU-bound function in a separate process."""
+    return await _run(process_pool, callback, *args, **kwargs)
+
+
+async def io_bound(callback: Callable, *args: Any, **kwargs: Any) -> Any:
+    """Run an I/O-bound function in a separate thread."""
+    return await _run(thread_pool, callback, *args, **kwargs)
+
+
+def tear_down() -> None:
+    """Kill all processes and threads."""
+    if helpers.is_pytest():
+        return
+    for p in process_pool._processes.values():  # pylint: disable=protected-access
+        p.kill()
+    kwargs = {'cancel_futures': True} if sys.version_info >= (3, 9) else {}
+    process_pool.shutdown(wait=True, **kwargs)
+    thread_pool.shutdown(wait=False, **kwargs)

+ 2 - 0
nicegui/ui.py

@@ -22,6 +22,7 @@ __all__ = [
     'date',
     'date',
     'dialog',
     'dialog',
     'echart',
     'echart',
+    'editor',
     'expansion',
     'expansion',
     'grid',
     'grid',
     'html',
     'html',
@@ -118,6 +119,7 @@ from .elements.dark_mode import DarkMode as dark_mode
 from .elements.date import Date as date
 from .elements.date import Date as date
 from .elements.dialog import Dialog as dialog
 from .elements.dialog import Dialog as dialog
 from .elements.echart import EChart as echart
 from .elements.echart import EChart as echart
+from .elements.editor import Editor as editor
 from .elements.expansion import Expansion as expansion
 from .elements.expansion import Expansion as expansion
 from .elements.grid import Grid as grid
 from .elements.grid import Grid as grid
 from .elements.html import Html as html
 from .elements.html import Html as html

+ 2 - 3
nicegui/welcome.py

@@ -1,9 +1,9 @@
-import asyncio
 import os
 import os
 import socket
 import socket
 from typing import List
 from typing import List
 
 
 from . import globals  # pylint: disable=redefined-builtin
 from . import globals  # pylint: disable=redefined-builtin
+from .run_executor import io_bound
 
 
 try:
 try:
     import netifaces
     import netifaces
@@ -33,8 +33,7 @@ async def print_message() -> None:
     print('NiceGUI ready to go ', end='', flush=True)
     print('NiceGUI ready to go ', end='', flush=True)
     host = os.environ['NICEGUI_HOST']
     host = os.environ['NICEGUI_HOST']
     port = os.environ['NICEGUI_PORT']
     port = os.environ['NICEGUI_PORT']
-    loop = asyncio.get_running_loop()
-    ips = set((await loop.run_in_executor(None, get_all_ips)) if host == '0.0.0.0' else [])
+    ips = set((await io_bound(get_all_ips)) if host == '0.0.0.0' else [])
     ips.discard('127.0.0.1')
     ips.discard('127.0.0.1')
     urls = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in ['localhost'] + sorted(ips)]
     urls = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in ['localhost'] + sorted(ips)]
     globals.app.urls.update(urls)
     globals.app.urls.update(urls)

+ 14 - 0
tests/test_editor.py

@@ -0,0 +1,14 @@
+
+from nicegui import ui
+
+from .screen import Screen
+
+
+def test_editor(screen: Screen):
+    editor = ui.editor(placeholder='Type something here')
+    ui.markdown().bind_content_from(editor, 'value', backward=lambda v: f'HTML code:\n```\n{v}\n```')
+
+    screen.open('/')
+    screen.find_by_class('q-editor__content').click()
+    screen.type('Hello\nworld!')
+    screen.should_contain('Hello<div>world!</div>')

+ 44 - 0
website/documentation.py

@@ -143,6 +143,7 @@ def create_full() -> None:
     load_demo(ui.scene)
     load_demo(ui.scene)
     load_demo(ui.tree)
     load_demo(ui.tree)
     load_demo(ui.log)
     load_demo(ui.log)
+    load_demo(ui.editor)
     load_demo(ui.code)
     load_demo(ui.code)
     load_demo(ui.json_editor)
     load_demo(ui.json_editor)
 
 
@@ -357,6 +358,49 @@ def create_full() -> None:
 
 
         ui.button('start async task', on_click=async_task)
         ui.button('start async task', on_click=async_task)
 
 
+    @text_demo('Running CPU-bound tasks', '''
+        NiceGUI provides a `cpu_bound` function for running CPU-bound tasks in a separate process.
+        This is useful for long-running computations that would otherwise block the event loop and make the UI unresponsive.
+        The function returns a future that can be awaited.
+    ''')
+    def cpu_bound_demo():
+        import time
+
+        from nicegui import run
+
+        def compute_sum(a: float, b: float) -> float:
+            time.sleep(1)  # simulate a long-running computation
+            return a + b
+
+        async def handle_click():
+            result = await run.cpu_bound(compute_sum, 1, 2)
+            ui.notify(f'Sum is {result}')
+
+        # ui.button('Compute', on_click=handle_click)
+        # END OF DEMO
+        async def mock_click():
+            import asyncio
+            await asyncio.sleep(1)
+            ui.notify('Sum is 3')
+        ui.button('Compute', on_click=mock_click)
+
+    @text_demo('Running I/O-bound tasks', '''
+        NiceGUI provides an `io_bound` function for running I/O-bound tasks in a separate thread.
+        This is useful for long-running I/O operations that would otherwise block the event loop and make the UI unresponsive.
+        The function returns a future that can be awaited.
+    ''')
+    def io_bound_demo():
+        import requests
+
+        from nicegui import run
+
+        async def handle_click():
+            URL = 'https://httpbin.org/delay/1'
+            response = await run.io_bound(requests.get, URL, timeout=3)
+            ui.notify(f'Downloaded {len(response.content)} bytes')
+
+        ui.button('Download', on_click=handle_click)
+
     heading('Pages')
     heading('Pages')
 
 
     load_demo(ui.page)
     load_demo(ui.page)

+ 7 - 0
website/more_documentation/editor_documentation.py

@@ -0,0 +1,7 @@
+from nicegui import ui
+
+
+def main_demo() -> None:
+    editor = ui.editor(placeholder='Type something here')
+    ui.markdown().bind_content_from(editor, 'value',
+                                    backward=lambda v: f'HTML code:\n```\n{v}\n```')