Browse Source

Merge branch 'main' into dependencies

Falko Schindler 2 năm trước cách đây
mục cha
commit
369ee5d9f1

+ 15 - 0
CONTRIBUTING.md

@@ -107,6 +107,21 @@ To get started, fork the repository on GitHub, make your changes, and open a pul
 When submitting a PR, please make sure that the code follows the existing coding style and that all tests are passing.
 If you're adding a new feature, please include tests that cover the new functionality.
 
+## YouTube
+
+We welcome and support video and tutorial contributions to the NiceGUI community!
+As recently [highlighted in a conversation on YouTube](https://www.youtube.com/watch?v=HiNNe4Q32U4&lc=UgyRcZCOZ9i5z6GuDcJ4AaABAg),
+creating and sharing tutorials or showcasing projects using NiceGUI can be an excellent way to help others learn and grow,
+while also spreading the word about our library.
+
+Please note that NiceGUI is pronounced like "nice guy," which might be helpful to know when creating any video content.
+
+If you decide to create YouTube content around NiceGUI,
+we kindly ask that you credit our repository, our YouTube channel, and any relevant videos or resources within the description.
+By doing so, you'll be contributing to the growth of our community and helping us receive more amazing pull requests and feature suggestions.
+
+We're thrilled to see your creations and look forward to watching your videos. Happy video-making!
+
 ## Thank you!
 
 Thank you for your interest in contributing to NiceGUI!

+ 1 - 0
examples/fastapi/frontend.py

@@ -1,4 +1,5 @@
 from fastapi import FastAPI
+
 from nicegui import ui
 
 

+ 1 - 2
examples/fastapi/main.py

@@ -1,6 +1,5 @@
 #!/usr/bin/env python3
 import frontend
-import uvicorn
 from fastapi import FastAPI
 
 app = FastAPI()
@@ -14,4 +13,4 @@ def read_root():
 frontend.init(app)
 
 if __name__ == '__main__':
-    uvicorn.run(app, host='0.0.0.0', port=8000)
+    print('Please start the app with the "uvicorn" command as shown in the start.sh script')

+ 15 - 2
examples/fastapi/start.sh

@@ -1,4 +1,17 @@
 #!/usr/bin/env bash
 
-# Start the FastAPI app
-uvicorn main:app --reload
+# use path of this demo as working directory; enables starting this script from anywhere
+cd "$(dirname "$0")"
+
+if [ "$1" = "prod" ]; then
+    echo "Starting Uvicorn server in production mode..."
+    # we also use a single worker in production mode so socket.io connections are always handled by the same worker
+    uvicorn main:app --workers 1 --log-level info --port 80
+elif [ "$1" = "dev" ]; then
+    echo "Starting Uvicorn server in development mode..."
+    # reload implies workers = 1
+    uvicorn main:app --reload --log-level debug --port 8000
+else
+    echo "Invalid parameter. Use 'prod' or 'dev'."
+    exit 1
+fi

+ 4 - 1
examples/nginx_subpath/README.md

@@ -12,4 +12,7 @@ Just run
 docker-compose up
 ```
 
-Then you can access http://localhost/nicegui.
+Then you can access http://localhost/nicegui/.
+Note the trailing "/" in the URL.
+It is important.
+We welcome suggestions on how to make it optional.

+ 20 - 0
examples/nginx_subpath/app/main.py

@@ -0,0 +1,20 @@
+from nicegui import ui
+
+
+@ui.page('/subpage')
+def subpage():
+    ui.label('This is a subpage').classes('text-h5 mx-auto mt-12')
+    # TODO: this is not working properly yet
+    # ui.button('back', on_click=lambda: ui.open('/')).classes('mx-auto')
+
+
+@ui.page('/')
+def index():
+    with ui.card().classes('mx-auto px-24 pt-12 pb-24 items-center text-center'):
+        ui.label('This demonstrates hosting of a NiceGUI app on a subpath.').classes('text-h5')
+        ui.label('As you can see the entire app is available below "/nicegui".').classes('text-lg')
+        ui.label('But the code here does not need to know that.').classes('text-lg')
+        ui.link('Navigate to a subpage.', 'subpage').classes('text-lg')
+
+
+ui.run()

+ 4 - 5
examples/nginx_subpath/docker-compose.yml

@@ -2,12 +2,11 @@ version: "3.9"
 services:
   app:
     image: zauberzeug/nicegui:latest
-    ports:
-      - "3000:8080"
-
+    volumes:
+      - ./app:/app # mount local app directory
   proxy:
     image: nginx:1.16.0-alpine
     ports:
-      - "80:80"
+      - "80:80" # map internal port 80 to external port 80
     volumes:
-      - ./nginx.conf:/etc/nginx/nginx.conf
+      - ./nginx.conf:/etc/nginx/nginx.conf # use custom nginx config

+ 3 - 2
examples/nginx_subpath/nginx.conf

@@ -40,16 +40,17 @@ http {
     server {
         listen 80 default_server;
         server_name _;
+        resolver 127.0.0.11; # see https://github.com/docker/compose/issues/3412
 
         location ~ ^/nicegui/(.*)$ {
             proxy_http_version 1.1;
             proxy_set_header Upgrade $http_upgrade;
             proxy_set_header Connection $connection_upgrade;
             proxy_set_header Authorization $http_authorization;
-            proxy_pass_header  Authorization;
+            proxy_pass_header Authorization;
 
             proxy_pass http://app:8080/$1?$args;
-            proxy_set_header X-Forwarded-Prefix /nicegui/$1/;
+            proxy_set_header X-Forwarded-Prefix /nicegui;
         }
     }
 }

+ 1 - 1
examples/slots/main.py

@@ -5,7 +5,7 @@ LABEL maintainer="Zauberzeug GmbH <info@zauberzeug.com>"
 WORKDIR /app
 
 ADD . .
-RUN pip install -e .
+RUN pip install .
 RUN pip install itsdangerous prometheus_client isort docutils
 
 EXPOSE 8080

+ 7 - 1
nicegui/elements/audio.py

@@ -7,7 +7,11 @@ register_component('audio', __file__, 'audio.js')
 class Audio(Element):
 
     def __init__(self, src: str, *,
-                 type: str = 'audio/mpeg', controls: bool = True, autoplay: bool = False, muted: bool = False) -> None:
+                 type: str = 'audio/mpeg',
+                 controls: bool = True,
+                 autoplay: bool = False,
+                 muted: bool = False,
+                 loop: bool = False) -> None:
         """Audio
 
         :param src: URL of the audio source
@@ -15,6 +19,7 @@ class Audio(Element):
         :param controls: whether to show the audio controls, like play, pause, and volume (default: `True`)
         :param autoplay: whether to start playing the audio automatically (default: `False`)
         :param muted: whether the audio should be initially muted (default: `False`)
+        :param loop: whether the audio should loop (default: `False`)
 
         See `here <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio#events>`_
         for a list of events you can subscribe to using the generic event subscription `on()`.
@@ -25,3 +30,4 @@ class Audio(Element):
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay
         self._props['muted'] = muted
+        self._props['loop'] = loop

+ 6 - 0
nicegui/elements/scene.js

@@ -198,6 +198,12 @@ export default {
         light.target.position.set(1, 0, 0);
         mesh.add(light);
         mesh.add(light.target);
+      } else if (type == "point_cloud") {
+        const geometry = new THREE.BufferGeometry();
+        geometry.setAttribute("position", new THREE.Float32BufferAttribute(args[0].flat(), 3));
+        geometry.setAttribute("color", new THREE.Float32BufferAttribute(args[1].flat(), 3));
+        const material = new THREE.PointsMaterial({ size: args[2], vertexColors: true });
+        mesh = new THREE.Points(geometry, material);
       } else {
         let geometry;
         const wireframe = args.pop();

+ 1 - 0
nicegui/elements/scene.py

@@ -44,6 +44,7 @@ class Scene(Element):
     from .scene_objects import Extrusion as extrusion
     from .scene_objects import Group as group
     from .scene_objects import Line as line
+    from .scene_objects import PointCloud as point_cloud
     from .scene_objects import QuadraticBezierTube as quadratic_bezier_tube
     from .scene_objects import Ring as ring
     from .scene_objects import Sphere as sphere

+ 10 - 0
nicegui/elements/scene_objects.py

@@ -169,3 +169,13 @@ class SpotLight(Object3D):
                  decay: float = 1.0,
                  ) -> None:
         super().__init__('spot_light', color, intensity, distance, angle, penumbra, decay)
+
+
+class PointCloud(Object3D):
+
+    def __init__(self,
+                 points: List[List[float]],
+                 colors: List[List[float]],
+                 point_size: float = 1.0,
+                 ) -> None:
+        super().__init__('point_cloud', points, colors, point_size)

+ 7 - 1
nicegui/elements/video.py

@@ -7,7 +7,11 @@ register_component('video', __file__, 'video.js')
 class Video(Element):
 
     def __init__(self, src: str, *,
-                 type: str = 'video/mp4', controls: bool = True, autoplay: bool = False, muted: bool = False) -> None:
+                 type: str = 'video/mp4',
+                 controls: bool = True,
+                 autoplay: bool = False,
+                 muted: bool = False,
+                 loop: bool = False) -> None:
         """Video
 
         :param src: URL of the video source
@@ -15,6 +19,7 @@ class Video(Element):
         :param controls: whether to show the video controls, like play, pause, and volume (default: `True`)
         :param autoplay: whether to start playing the video automatically (default: `False`)
         :param muted: whether the video should be initially muted (default: `False`)
+        :param loop: whether the video should loop (default: `False`)
 
         See `here <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video#events>`_
         for a list of events you can subscribe to using the generic event subscription `on()`.
@@ -25,3 +30,4 @@ class Video(Element):
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay
         self._props['muted'] = muted
+        self._props['loop'] = loop

+ 0 - 2
nicegui/globals.py

@@ -31,8 +31,6 @@ log: logging.Logger = logging.getLogger('nicegui')
 state: State = State.STOPPED
 ui_run_has_been_called: bool = False
 
-host: str
-port: int
 reload: bool
 title: str
 viewport: str

+ 50 - 0
nicegui/native_mode.py

@@ -0,0 +1,50 @@
+import multiprocessing
+import os
+import signal
+import socket
+import tempfile
+import time
+import warnings
+from threading import Thread
+
+with warnings.catch_warnings():
+    # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
+    warnings.filterwarnings('ignore', category=DeprecationWarning)
+    import webview
+
+shutdown = multiprocessing.Event()
+
+
+def open_window(url: str, title: str, width: int, height: int, fullscreen: bool, shutdown: multiprocessing.Event) -> None:
+    window = webview.create_window(title, url=url, width=width, height=height, fullscreen=fullscreen)
+    window.events.closing += shutdown.set  # signal to the main process that the program should be closed
+    webview.start(storage_path=tempfile.mkdtemp())
+
+
+def check_shutdown() -> None:
+    while True:
+        if shutdown.is_set():
+            os.kill(os.getpgid(os.getpid()), signal.SIGTERM)
+        time.sleep(0.1)
+
+
+def activate(url: str, title: str, width: int, height: int, fullscreen: bool) -> None:
+    args = url, title, width, height, fullscreen, shutdown
+    multiprocessing.Process(target=open_window, args=args, daemon=False).start()
+    Thread(target=check_shutdown, daemon=True).start()
+
+
+def find_open_port(start_port: int = 8000, end_port: int = 8999) -> int:
+    '''Reliably find an open port in a given range.
+
+    This function will actually try to open the port to ensure no firewall blocks it.
+    This is better than, e.g., passing port=0 to uvicorn.
+    '''
+    for port in range(start_port, end_port + 1):
+        try:
+            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+                s.bind(('localhost', port))
+                return port
+        except OSError:
+            pass
+    raise OSError('No open port found')

+ 2 - 1
nicegui/nicegui.py

@@ -1,4 +1,5 @@
 import asyncio
+import os
 import time
 import urllib.parse
 from pathlib import Path
@@ -71,7 +72,7 @@ def handle_startup(with_welcome_message: bool = True) -> None:
     background_tasks.create(prune_slot_stacks())
     globals.state = globals.State.STARTED
     if with_welcome_message:
-        print(f'NiceGUI ready to go on http://{globals.host}:{globals.port}')
+        print(f'NiceGUI ready to go on {os.environ["NICEGUI_URL"]}')
 
 
 @app.on_event('shutdown')

+ 23 - 14
nicegui/run.py

@@ -3,17 +3,17 @@ import multiprocessing
 import os
 import sys
 import webbrowser
-from typing import List, Optional, Tuple, Union
+from typing import List, Optional, Tuple
 
 import uvicorn
 from uvicorn.main import STARTUP_FAILURE
 from uvicorn.supervisors import ChangeReload, Multiprocess
 
-from . import globals, standalone_mode
+from . import globals, native_mode
 
 
 def run(*,
-        host: str = '0.0.0.0',
+        host: Optional[str] = None,
         port: int = 8080,
         title: str = 'NiceGUI',
         viewport: str = 'width=device-width, initial-scale=1',
@@ -21,8 +21,9 @@ def run(*,
         dark: Optional[bool] = False,
         binding_refresh_interval: float = 0.1,
         show: bool = True,
-        standalone: bool = False,
-        fullscreen: Union[bool, Tuple[int, int]] = False,
+        native: bool = False,
+        window_size: Optional[Tuple[int, int]] = None,
+        fullscreen: bool = False,
         reload: bool = True,
         uvicorn_logging_level: str = 'warning',
         uvicorn_reload_dirs: str = '.',
@@ -36,7 +37,7 @@ def run(*,
 
     You can call `ui.run()` with optional arguments:
 
-    :param host: start server with this host (default: `'0.0.0.0'`)
+    :param host: start server with this host (defaults to `'127.0.0.1` in native mode, otherwise `'0.0.0.0'`)
     :param port: use this port (default: `8080`)
     :param title: page title (default: `'NiceGUI'`, can be overwritten per page)
     :param viewport: page meta viewport content (default: `'width=device-width, initial-scale=1'`, can be overwritten per page)
@@ -44,8 +45,9 @@ def run(*,
     :param dark: whether to use Quasar's dark mode (default: `False`, use `None` for "auto" mode)
     :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
     :param show: automatically open the UI in a browser tab (default: `True`)
-    :param standalone: open the UI in a standalone window (default: `False`, accepts size as tuple or True (800, 600), deactivates `show`, automatically finds an open port)
-    :param fullscreen: open the UI in a fullscreen, standalone window (default: `False`, also activates `standalone`)
+    :param native: open the UI in a native window of size 800x600 (default: `False`, deactivates `show`, automatically finds an open port)
+    :param window_size: open the UI in a native window with the provided size (e.g. `(1024, 786)`, default: `None`, also activates `native`)
+    :param fullscreen: open the UI in a fullscreen window (default: `False`, also activates `native`)
     :param reload: automatically reload the UI on file changes (default: `True`)
     :param uvicorn_logging_level: logging level for uvicorn server (default: `'warning'`)
     :param uvicorn_reload_dirs: string with comma-separated list for directories to be monitored (default is current working directory only)
@@ -57,8 +59,6 @@ def run(*,
     :param kwargs: additional keyword arguments are passed to `uvicorn.run`
     '''
     globals.ui_run_has_been_called = True
-    globals.host = host
-    globals.port = port
     globals.reload = reload
     globals.title = title
     globals.viewport = viewport
@@ -72,11 +72,20 @@ def run(*,
         return
 
     if fullscreen:
-        standalone = True
-    if standalone:
+        native = True
+    if window_size:
+        native = True
+    if native:
         show = False
-        width, height = (800, 600) if standalone is True else standalone
-        standalone_mode.activate(f'http://localhost:{port}', title, width, height, fullscreen)
+        host = host or '127.0.0.1'
+        port = native_mode.find_open_port()
+        width, height = window_size or (800, 600)
+        native_mode.activate(f'http://{host}:{port}', title, width, height, fullscreen)
+    else:
+        host = host or '0.0.0.0'
+
+    # NOTE: We save the URL in an environment variable so the subprocess started in reload mode can access it.
+    os.environ['NICEGUI_URL'] = f'http://{host}:{port}'
 
     if show:
         webbrowser.open(f'http://{host if host != "0.0.0.0" else "127.0.0.1"}:{port}/')

+ 0 - 29
nicegui/standalone_mode.py

@@ -1,29 +0,0 @@
-import multiprocessing
-import os
-import signal
-import tempfile
-import time
-from threading import Thread
-
-import webview
-
-shutdown = multiprocessing.Event()
-
-
-def open_window(url: str, title: str, width: int, height: int, fullscreen: bool, shutdown: multiprocessing.Event) -> None:
-    window = webview.create_window(title, url=url, width=width, height=height, fullscreen=fullscreen)
-    window.events.closing += shutdown.set  # signal that the program should be closed to the main process
-    webview.start(storage_path=tempfile.mkdtemp())
-
-
-def check_shutdown() -> None:
-    while True:
-        if shutdown.is_set():
-            os.kill(os.getpgid(os.getpid()), signal.SIGTERM)
-        time.sleep(0.1)
-
-
-def activate(url: str, title: str, width: int, height: int, fullscreen: bool) -> None:
-    args = url, title, width, height, fullscreen, shutdown
-    multiprocessing.Process(target=open_window, args=args, daemon=False).start()
-    Thread(target=check_shutdown, daemon=True).start()

+ 7 - 5
test_startup.sh

@@ -1,10 +1,11 @@
 #!/usr/bin/env bash
 
 run() {
-    output=`{ timeout 10 python3 $1; } 2>&1`
+    pwd
+    output=$({ timeout 10 ./$1 $2; } 2>&1)
     exitcode=$?
     test $exitcode -eq 124 && exitcode=0 # exitcode 124 is comming from "timeout command above"
-    echo $output | grep -e "NiceGUI ready to go" -e "Uvicorn running on http://0.0.0.0:8000" > /dev/null || exitcode=1
+    echo $output | grep -e "NiceGUI ready to go" -e "Uvicorn running on http://127.0.0.1:8000" > /dev/null || exitcode=1
     echo $output | grep "Traceback" > /dev/null && exitcode=1
     echo $output | grep "Error" > /dev/null && exitcode=1
     if test $exitcode -ne 0; then
@@ -17,7 +18,7 @@ run() {
 check() {
     echo checking $1 ----------
     pushd $(dirname "$1") >/dev/null
-    if run $(basename "$1"); then
+    if run $(basename "$1") $2; then
         echo "ok --------"
         popd > /dev/null
     else
@@ -31,8 +32,9 @@ error=0
 check main.py || error=1
 for path in examples/*
 do
-    if test -f $path/main.py
-    then
+    if test -f $path/start.sh; then
+        check $path/start.sh dev || error=1 
+    elif test -f $path/main.py; then
         check $path/main.py || error=1
     fi
 done

+ 1 - 0
tests/test_table.py

@@ -63,6 +63,7 @@ def test_add_remove(screen: Screen):
     screen.should_contain('Carol')
 
     screen.click('Remove')
+    screen.wait(0.5)
     screen.should_not_contain('Alice')
 
 

+ 2 - 1
website/reference.py

@@ -1028,8 +1028,9 @@ You can provide SSL certificates directly using [FastAPI](https://fastapi.tiango
 In production we also like using reverse proxies like [Traefik](https://doc.traefik.io/traefik/) or [NGINX](https://www.nginx.com/) to handle these details for us.
 See our [docker-compose.yml](https://github.com/zauberzeug/nicegui/blob/main/docker-compose.yml) as an example.
 
-You may also have a look at [our example for using a custom FastAPI app](https://github.com/zauberzeug/nicegui/tree/main/examples/fastapi).
+You may also have a look at [our demo for using a custom FastAPI app](https://github.com/zauberzeug/nicegui/tree/main/examples/fastapi).
 This will allow you to do very flexible deployments as described in the [FastAPI documentation](https://fastapi.tiangolo.com/deployment/).
+Note that there are additional steps required to allow multiple workers.
 ''')
 
         with ui.column().classes('w-full mt-8 arrow-links'):