فهرست منبع

Merge branch 'main' into feature/open

Christoph Trappe 3 سال پیش
والد
کامیت
2b56b1b05c

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

@@ -0,0 +1,50 @@
+name: Run Tests
+
+on: [push]
+
+jobs:
+  test:
+    strategy:
+      matrix:
+        python: [3.7, 3.8, 3.9]
+      fail-fast: false
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    steps:
+      - uses: actions/checkout@v2
+      - name: set up Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: ${{ matrix.python }}
+      - name: set up Poetry
+        uses: abatilo/actions-poetry@v2.0.0
+        with:
+          poetry-version: "1.1.6"
+      - name: install dependencies
+        run: |
+          pip3 install setuptools==57.4.0 # to fix https://github.com/elimintz/justpy/issues/301
+          poetry config virtualenvs.create false --local
+          poetry install
+      - name: test startup
+        run: ./test_startup.sh
+
+  slack:
+    needs:
+      - test
+    if: always() # also execute when test fails
+    runs-on: ubuntu-latest
+    steps:
+      - name: Determine if we need to notify
+        uses: Jimdo/should-i-notify-action@main
+        id: should_notify
+        with:
+          needs_context: ${{ toJson(needs) }}
+          github_token: ${{ secrets.GITHUB_TOKEN }}
+      - name: Slack workflow notification
+        if: steps.should_notify.outputs.should_send_message == 'yes'
+        uses: Gamesight/slack-workflow-status@master
+        with:
+          repo_token: ${{ secrets.GITHUB_TOKEN }}
+          slack_webhook_url: ${{ secrets.SLACK_ROBOTICS_CI_WEBHOOK }}
+          channel: "robotik-ci"
+          name: "NiceGUI"

+ 10 - 8
README.md

@@ -2,8 +2,8 @@
 
 <img src="https://raw.githubusercontent.com/zauberzeug/nicegui/main/sceenshots/ui-elements.png" width="300" align="right">
 
-NiceGUI is an easy to use, Python-based UI framework, which renderes to the web browser.
-You can create buttons, dialogs, markdown, 3D scences, plots and much more.
+NiceGUI is an easy-to-use, Python-based UI framework, which renders to the web browser.
+You can create buttons, dialogs, markdown, 3D scenes, plots and much more.
 
 It was designed to be used for micro web apps, dashboards, robotics projects, smart home solutions and similar use cases.
 It is also helpful for development, for example when tweaking/configuring a machine learning algorithm or tuning motor controllers.
@@ -16,7 +16,7 @@ It is also helpful for development, for example when tweaking/configuring a mach
 - standard GUI elements like label, button, checkbox, switch, slider, input, file upload, ...
 - simple grouping with rows, columns, cards and dialogs
 - general-purpose HTML and markdown elements
-- powerful elements to plot graphs, render 3D scences and get steering events via virtual joysticks
+- powerful elements to plot graphs, render 3D scenes and get steering events via virtual joysticks
 - built-in timer to refresh data in intervals (even every 10 ms)
 - straight-forward data binding to write even less code
 - notifications, dialogs and menus to provide state of the art user interaction
@@ -46,15 +46,17 @@ Launch it with:
 python3 main.py
 ```
 
-The GUI is now avaliable through http://localhost/ in your browser.
+The GUI is now available through http://localhost:8080/ in your browser.
 Note: The script will automatically reload the page when you modify the code.
 
+Full documentation can be found at [https://nicegui.io](https://nicegui.io).
+
 ## Configuration
 
 You can call `ui.run()` with optional arguments for some high-level configuration:
 
 - `host` (default: `'0.0.0.0'`)
-- `port` (default: `80`)
+- `port` (default: `8080`)
 - `title` (default: `'NiceGUI'`)
 - `favicon` (default: `'favicon.ico'`)
 - `reload`: automatically reload the ui on file changes (default: `True`)
@@ -67,7 +69,7 @@ You can call `ui.run()` with optional arguments for some high-level configuratio
 Use the [multi-arch docker image](https://hub.docker.com/repository/docker/zauberzeug/nicegui) for pain-free installation:
 
 ```bash
-docker run --rm -p 8888:80 -v $(pwd)/my_script.py:/app/main.py -it zauberzeug/nicegui:latest
+docker run --rm -p 8888:8080 -v $(pwd)/my_script.py:/app/main.py -it zauberzeug/nicegui:latest
 ```
 
 This will start the server at http://localhost:8888 with code from `my_script.py` within the current directory.
@@ -76,10 +78,10 @@ Code modification triggers an automatic reload.
 ## Why?
 
 We like [Streamlit](https://streamlit.io/) but find it does [too much magic when it comes to state handling](https://github.com/zauberzeug/nicegui/issues/1#issuecomment-847413651).
-In search for an alernative nice library to write simple graphical user interfaces in Python we discovered [justpy](https://justpy.io/).
+In search for an alternative nice library to write simple graphical user interfaces in Python we discovered [justpy](https://justpy.io/).
 While too "low-level HTML" for our daily usage it provides a great basis for "NiceGUI".
 
 ## API
 
 The API reference is hosted at [https://nicegui.io](https://nicegui.io) and is [implemented with NiceGUI itself](https://github.com/zauberzeug/nicegui/blob/main/main.py).
-You should also have a look at [examples.py](https://github.com/zauberzeug/nicegui/tree/main/examples.py) for an extensive demonstration of what you can do with NiceGUI.
+You may also have a look at [examples.py](https://github.com/zauberzeug/nicegui/tree/main/examples.py) for more demonstrations of what you can do with NiceGUI.

+ 4 - 1
main.py

@@ -39,7 +39,10 @@ def example(content: Union[Element, str]):
         callFrame = inspect.currentframe().f_back.f_back
         end = callFrame.f_lineno
         code = inspect.getsource(sys.modules[__name__])
-        code = code.splitlines()[begin:end]
+        lines = code.splitlines()
+        while lines[end]:
+            end += 1
+        code = lines[begin:end]
         code = [l[4:] for l in code]
         code.insert(0, '```python')
         code.insert(1, 'from nicegui import ui')

+ 2 - 2
nicegui/config.py

@@ -7,7 +7,7 @@ from . import globals
 class Config(BaseModel):
     # NOTE: should be in sync with ui.run arguments
     host: str = '0.0.0.0'
-    port: int = 80
+    port: int = 8080
     title: str = 'NiceGUI'
     favicon: str = 'favicon.ico'
     reload: bool = True
@@ -34,7 +34,7 @@ try:
     with open(filepath) as f:
         source = f.read()
 except FileNotFoundError:
-    print('Could not main script. Starting with interactive mode.', flush=True)
+    print('Could not find main script. Starting with interactive mode.', flush=True)
     config = Config(interactive=True)
 else:
     for node in ast.walk(ast.parse(source)):

+ 3 - 3
nicegui/elements/choice_element.py

@@ -1,17 +1,17 @@
 import justpy as jp
-from typing import Any, Awaitable, Callable, Optional, Union
+from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
 from .value_element import ValueElement
 
 class ChoiceElement(ValueElement):
 
     def __init__(self,
                  view: jp.HTMLBaseComponent,
-                 options: Union[list, dict],
+                 options: Union[List, Dict],
                  *,
                  value: Any,
                  on_change: Optional[Union[Callable, Awaitable]] = None,
                  ):
-        if isinstance(options, list):
+        if isinstance(options, List):
             view.options = [{'label': option, 'value': option} for option in options]
         else:
             view.options = [{'label': value, 'value': key} for key, value in options.items()]

+ 2 - 1
nicegui/elements/group.py

@@ -1,4 +1,5 @@
 from __future__ import annotations
+from typing import List
 import justpy as jp
 from ..globals import view_stack
 from ..binding import active_links, bindings, bindable_properties
@@ -17,7 +18,7 @@ class Group(Element):
         return self.classes(replace='').style(replace='')
 
     def clear(self):
-        def collect_components(view: jp.HTMLBaseComponent) -> list[jp.HTMLBaseComponent]:
+        def collect_components(view: jp.HTMLBaseComponent) -> List[jp.HTMLBaseComponent]:
             return view.components + [view for child in view.components for view in collect_components(child)]
         components = collect_components(self.view)
 

+ 2 - 2
nicegui/elements/joystick.py

@@ -1,4 +1,4 @@
-from typing import Callable, Optional
+from typing import Callable, Dict, Optional
 from .custom_view import CustomView
 from .element import Element
 
@@ -43,7 +43,7 @@ class Joystick(Element):
                  on_start: Optional[Callable] = None,
                  on_move: Optional[Callable] = None,
                  on_end: Optional[Callable] = None,
-                 **options: dict,
+                 **options: Dict,
                  ):
         """Joystick
 

+ 2 - 2
nicegui/elements/keyboard.py

@@ -1,5 +1,5 @@
 import traceback
-from typing import Awaitable, Callable, Optional, Union
+from typing import Awaitable, Callable, Dict, Optional, Union
 
 from ..events import KeyEventArguments, KeyboardAction, KeyboardKey, KeyboardModifiers, handle_event
 from .custom_view import CustomView
@@ -35,7 +35,7 @@ class Keyboard(Element):
         self.active = active
         self.key_handler = on_key
 
-    def handle_key(self, msg: dict):
+    def handle_key(self, msg: Dict):
         if not self.active:
             return
 

+ 6 - 3
nicegui/elements/log.py

@@ -1,3 +1,5 @@
+from __future__ import annotations
+from typing import Deque
 import asyncio
 import traceback
 import urllib
@@ -5,10 +7,11 @@ from collections import deque
 from justpy.htmlcomponents import WebPage
 from .custom_view import CustomView
 from .element import Element
+from ..task_logger import create_task
 
 class LogView(CustomView):
 
-    def __init__(self, lines: deque[str], max_lines: int):
+    def __init__(self, lines: Deque[str], max_lines: int):
         super().__init__('log', __file__, max_lines=max_lines)
         self.lines = lines
         self.allowed_events = ['onConnect']
@@ -19,7 +22,7 @@ class LogView(CustomView):
             if self.lines:
                 content = '\n'.join(self.lines)
                 command = f'push("{urllib.parse.quote(content)}")'
-                asyncio.get_event_loop().create_task(self.run_method(command, msg.websocket))
+                create_task(self.run_method(command, msg.websocket), name=str(command))
         except:
             traceback.print_exc()
 
@@ -44,4 +47,4 @@ class Log(Element):
         ])
 
     def push(self, line: str):
-        asyncio.get_event_loop().create_task(self.push_async(line))
+        create_task(self.push_async(line), name=f'log.push line {line}')

+ 2 - 2
nicegui/elements/notify.py

@@ -1,6 +1,6 @@
 import justpy as jp
 from .element import Element
-import asyncio
+from ..task_logger import create_task
 
 
 class Notify(Element):
@@ -22,7 +22,7 @@ class Notify(Element):
         view = jp.QNotify(message=message, position=position, closeBtn=close_button)
 
         super().__init__(view)
-        asyncio.get_event_loop().create_task(self.notify_async())
+        create_task(self.notify_async(), name='notify_async')
 
     async def notify_async(self):
         self.view.notify = True

+ 2 - 2
nicegui/elements/radio.py

@@ -1,11 +1,11 @@
 import justpy as jp
-from typing import Awaitable, Callable, Optional, Union
+from typing import Awaitable, Callable, Dict, List, Optional, Union
 from .choice_element import ChoiceElement
 
 class Radio(ChoiceElement):
 
     def __init__(self,
-                 options: Union[list, dict],
+                 options: Union[List, Dict],
                  *,
                  value: any = None,
                  on_change: Optional[Union[Callable, Awaitable]] = None,

+ 5 - 4
nicegui/elements/scene_object3d.py

@@ -1,12 +1,13 @@
 from __future__ import annotations
 import asyncio
-from typing import Optional
+from typing import List, Optional
 import uuid
 import numpy as np
 from justpy.htmlcomponents import WebPage
+from ..task_logger import create_task
 
 class Object3D:
-    stack: list[Object3D] = []
+    stack: List[Object3D] = []
 
     def __init__(self, type: str, *args):
         self.type = type
@@ -35,7 +36,7 @@ class Object3D:
     def run_command(self, command: str, socket=None):
         sockets = [socket] if socket else WebPage.sockets.get(self.page.page_id, {}).values()
         for socket in sockets:
-            asyncio.get_event_loop().create_task(self.view.run_method(command, socket))
+            create_task(self.view.run_method(command, socket), name=command)
 
     def send_to(self, socket):
         self.run_command(self._create_command, socket)
@@ -96,7 +97,7 @@ class Object3D:
         Rz = np.array([[np.cos(kappa), -np.sin(kappa), 0], [np.sin(kappa), np.cos(kappa), 0], [0, 0, 1]])
         return self.rotate_R((Rz @ Ry @ Rx).tolist())
 
-    def rotate_R(self, R: list[list[float]]):
+    def rotate_R(self, R: List[List[float]]):
         if self.R != R:
             self.R = R
             self.run_command(self._rotate_command)

+ 9 - 9
nicegui/elements/scene_objects.py

@@ -1,5 +1,5 @@
 from __future__ import annotations
-from typing import Optional
+from typing import List, Optional
 from .scene_object3d import Object3D
 
 class Scene(Object3D):
@@ -47,7 +47,7 @@ class Cylinder(Object3D):
 class Extrusion(Object3D):
 
     def __init__(self,
-                 outline: list[list[float, float]],
+                 outline: List[List[float, float]],
                  height: float,
                  wireframe: bool = False,
                  ):
@@ -64,18 +64,18 @@ class Stl(Object3D):
 class Line(Object3D):
 
     def __init__(self,
-                 start: list[float, float, float],
-                 end: list[float, float, float],
+                 start: List[float, float, float],
+                 end: List[float, float, float],
                  ):
         super().__init__('line', start, end)
 
 class Curve(Object3D):
 
     def __init__(self,
-                 start: list[float, float, float],
-                 control1: list[float, float, float],
-                 control2: list[float, float, float],
-                 end: list[float, float, float],
+                 start: List[float, float, float],
+                 control1: List[float, float, float],
+                 control2: List[float, float, float],
+                 end: List[float, float, float],
                  num_points: int = 20,
                  ):
         super().__init__('curve', start, control1, control2, end, num_points)
@@ -84,6 +84,6 @@ class Texture(Object3D):
 
     def __init__(self,
                  url: str,
-                 coordinates: list[list[Optional[list[float]]]],
+                 coordinates: List[List[Optional[List[float]]]],
                  ):
         super().__init__('texture', url, coordinates)

+ 2 - 2
nicegui/elements/select.py

@@ -1,11 +1,11 @@
 import justpy as jp
-from typing import Awaitable, Callable, Optional, Union
+from typing import Awaitable, Callable, Dict, List, Optional, Union
 from .choice_element import ChoiceElement
 
 class Select(ChoiceElement):
 
     def __init__(self,
-                 options: Union[list, dict],
+                 options: Union[List, Dict],
                  *,
                  value: any = None,
                  on_change: Optional[Union[Callable, Awaitable]] = None,

+ 2 - 2
nicegui/elements/toggle.py

@@ -1,11 +1,11 @@
 import justpy as jp
-from typing import Awaitable, Callable, Optional, Union
+from typing import Awaitable, Callable, Dict, List, Optional, Union
 from .choice_element import ChoiceElement
 
 class Toggle(ChoiceElement):
 
     def __init__(self,
-                 options: Union[list, dict],
+                 options: Union[List, Dict],
                  *,
                  value: any = None,
                  on_change: Optional[Union[Callable, Awaitable]] = None,

+ 2 - 1
nicegui/events.py

@@ -5,6 +5,7 @@ import traceback
 from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
 
 from .elements.element import Element
+from .task_logger import create_task
 
 class EventArguments(BaseModel):
     class Config:
@@ -209,7 +210,7 @@ def handle_event(handler: Optional[Union[Callable, Awaitable]], arguments: Event
                     await result
                 except Exception:
                     traceback.print_exc()
-            asyncio.get_event_loop().create_task(async_handler())
+            create_task(async_handler(), name=str(handler))
             return False
         else:
             return result

+ 4 - 4
nicegui/globals.py

@@ -1,7 +1,7 @@
 from __future__ import annotations
 import asyncio
 import logging
-from typing import TYPE_CHECKING
+from typing import List, TYPE_CHECKING
 if TYPE_CHECKING:
     from starlette.applications import Starlette
     import justpy as jp
@@ -10,7 +10,7 @@ if TYPE_CHECKING:
 
 app: 'Starlette'
 config: 'Config'
-page_stack: list['Page'] = []
-view_stack: list['jp.HTMLBaseComponent'] = []
-tasks: list[asyncio.tasks.Task] = []
+page_stack: List['Page'] = []
+view_stack: List['jp.HTMLBaseComponent'] = []
+tasks: List[asyncio.tasks.Task] = []
 log: logging.Logger = logging.getLogger('nicegui')

+ 1 - 4
nicegui/nicegui.py

@@ -7,12 +7,9 @@ import justpy as jp
 from .timer import Timer
 from . import globals
 from . import binding
+from .task_logger import create_task
 
 
-def create_task(coro, name: str) -> asyncio.tasks.Task:
-    loop = asyncio.get_event_loop()
-    return loop.create_task(coro, name=name)
-
 @jp.app.on_event('startup')
 def startup():
     globals.tasks.extend(create_task(t.coro, name=t.name) for t in Timer.prepared_coroutines)

+ 1 - 1
nicegui/run.py

@@ -14,7 +14,7 @@ if not globals.config.interactive and globals.config.reload and not inspect.stac
 
 def run(self, *,
         host: str = '0.0.0.0',
-        port: int = 80,
+        port: int = 8080,
         title: str = 'NiceGUI',
         favicon: str = 'favicon.ico',
         reload: bool = True,

+ 55 - 0
nicegui/task_logger.py

@@ -0,0 +1,55 @@
+'''original copied from https://quantlane.com/blog/ensure-asyncio-task-exceptions-get-logged/'''
+
+from typing import Any, Awaitable, Optional, Tuple, TypeVar
+
+import asyncio
+import functools
+import logging
+import sys
+
+T = TypeVar('T')
+
+
+def create_task(
+    coroutine: Awaitable[T],
+    *,
+    loop: Optional[asyncio.AbstractEventLoop] = None,
+    name: str = 'unnamed task',
+) -> 'asyncio.Task[T]':  # This type annotation has to be quoted for Python < 3.9, see https://www.python.org/dev/peps/pep-0585/
+    '''
+    This helper function wraps a ``loop.create_task(coroutine())`` call and ensures there is
+    an exception handler added to the resulting task. If the task raises an exception it is logged
+    using the provided ``logger``, with additional context provided by ``message`` and optionally
+    ``message_args``.
+    '''
+
+    logger = logging.getLogger(__name__)
+    message = 'Task raised an exception'
+    message_args = ()
+    if loop is None:
+        loop = asyncio.get_running_loop()
+    if sys.version_info[1] < 8:
+        task = loop.create_task(coroutine)  # name parameter is only supported from 3.8 onward
+    else:
+        task = loop.create_task(coroutine, name=name)
+    task.add_done_callback(
+        functools.partial(_handle_task_result, logger=logger, message=message, message_args=message_args)
+    )
+    return task
+
+
+def _handle_task_result(
+    task: asyncio.Task,
+    *,
+    logger: logging.Logger,
+    message: str,
+    message_args: Tuple[Any, ...] = (),
+) -> None:
+    try:
+        task.result()
+    except asyncio.CancelledError:
+        pass  # Task cancellation should not be logged as an error.
+    # Ad the pylint ignore: we want to handle all exceptions here so that the result of the task
+    # is properly logged. There is no point re-raising the exception in this callback.
+    except Exception:  # pylint: disable=broad-except
+        logger.exception(message, *message_args)

+ 2 - 1
nicegui/timer.py

@@ -6,6 +6,7 @@ from collections import namedtuple
 
 from .binding import BindableProperty
 from .globals import tasks, view_stack
+from .task_logger import create_task
 
 NamedCoroutine = namedtuple('NamedCoroutine', ['name', 'coro'])
 
@@ -65,4 +66,4 @@ class Timer:
         if not event_loop.is_running():
             self.prepared_coroutines.append(NamedCoroutine(str(callback), coroutine))
         else:
-            tasks.append(event_loop.create_task(coroutine, name=str(callback)))
+            tasks.append(create_task(coroutine, name=str(callback)))

+ 34 - 0
test_startup.sh

@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+
+run() {
+    output=`{ timeout 10 python3 $1; } 2>&1`
+    exitcode=$?
+    test $exitcode -eq 124 && exitcode=0 # exitcode 124 is comming from "timeout command above"
+    echo $output | grep "JustPy ready to go" > /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
+        echo "wrong exit code $exitcode. Output was:"
+        echo $output
+        return 1
+    fi
+}
+
+check() {
+    echo checking $1 ----------
+    pushd $(dirname "$1") >/dev/null
+    if run $(basename "$1"); then
+        echo "ok --------"
+        popd > /dev/null
+    else
+        echo "failed -------"
+        popd > /dev/null
+        return 1
+    fi
+}
+
+success=0
+check main.py || success=1
+check examples.py || success=1
+echo exit $success
+test $success -eq 0