Răsfoiți Sursa

Merge branch 'main' into draggable-scene-objects

# Conflicts:
#	website/more_documentation/scene_documentation.py
Falko Schindler 1 an în urmă
părinte
comite
c9606d929c
65 a modificat fișierele cu 1072 adăugiri și 463 ștergeri
  1. 5 1
      .github/workflows/test.yml
  2. 0 1
      .vscode/settings.json
  3. 3 3
      CITATION.cff
  4. 38 0
      deploy.sh
  5. 2 2
      examples/ai_interface/main.py
  6. 37 0
      examples/docker_image/README.md
  7. 17 0
      examples/docker_image/app/main.py
  8. 13 0
      examples/docker_image/docker-compose.yml
  9. 27 0
      examples/download_text_as_file/main.py
  10. 2 0
      examples/script_executor/main.py
  11. 11 0
      fly.dockerfile
  12. 4 4
      fly.toml
  13. 43 11
      main.py
  14. 7 2
      nicegui/air.py
  15. 19 5
      nicegui/app.py
  16. 3 1
      nicegui/binding.py
  17. 14 7
      nicegui/client.py
  18. 0 13
      nicegui/deprecation.py
  19. 1 1
      nicegui/elements/icon.py
  20. 11 0
      nicegui/elements/line_plot.py
  21. 1 1
      nicegui/elements/mixins/value_element.py
  22. 12 2
      nicegui/elements/plotly.py
  23. 11 2
      nicegui/elements/pyplot.py
  24. 1 1
      nicegui/elements/query.js
  25. 16 0
      nicegui/elements/scene.py
  26. 3 4
      nicegui/elements/scene_object3d.py
  27. 20 0
      nicegui/elements/table.py
  28. 1 1
      nicegui/events.py
  29. 8 5
      nicegui/functions/download.py
  30. 7 3
      nicegui/functions/open.py
  31. 23 2
      nicegui/functions/refreshable.py
  32. 15 2
      nicegui/globals.py
  33. 1 1
      nicegui/nicegui.py
  34. 3 0
      nicegui/page.py
  35. 12 0
      nicegui/run.py
  36. 4 1
      nicegui/run_with.py
  37. 36 15
      nicegui/templates/index.html
  38. 6 30
      nicegui/ui.py
  39. 216 256
      poetry.lock
  40. 1 1
      pyproject.toml
  41. 9 4
      release.dockerfile
  42. 17 2
      tests/conftest.py
  43. 3 5
      tests/screen.py
  44. 30 11
      tests/test_download.py
  45. 16 0
      tests/test_element.py
  46. 41 0
      tests/test_endpoint_docs.py
  47. 2 2
      tests/test_favicon.py
  48. 18 0
      tests/test_open.py
  49. 19 0
      tests/test_prod_js.py
  50. 8 0
      tests/test_query.py
  51. 69 18
      tests/test_refreshable.py
  52. 32 0
      tests/test_scene.py
  53. 3 3
      tests/test_serving_files.py
  54. 2 2
      tests/test_storage.py
  55. 2 1
      website/build_search_index.py
  56. 17 6
      website/documentation.py
  57. 22 24
      website/more_documentation/aggrid_documentation.py
  58. 2 2
      website/more_documentation/color_picker_documentation.py
  59. 7 0
      website/more_documentation/image_documentation.py
  60. 9 0
      website/more_documentation/link_documentation.py
  61. 13 0
      website/more_documentation/scene_documentation.py
  62. 15 0
      website/more_documentation/table_documentation.py
  63. 21 0
      website/more_documentation/tree_documentation.py
  64. 2 1
      website/search.py
  65. 39 4
      website/static/search_index.json

+ 5 - 1
.github/workflows/test.yml

@@ -32,7 +32,11 @@ jobs:
       - name: test startup
       - name: test startup
         run: ./test_startup.sh
         run: ./test_startup.sh
       - name: setup chromedriver
       - name: setup chromedriver
-        uses: nanasess/setup-chromedriver@v1
+        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

+ 0 - 1
.vscode/settings.json

@@ -1,7 +1,6 @@
 {
 {
   "editor.defaultFormatter": "esbenp.prettier-vscode",
   "editor.defaultFormatter": "esbenp.prettier-vscode",
   "editor.formatOnSave": true,
   "editor.formatOnSave": true,
-  "editor.minimap.enabled": false,
   "isort.args": ["--line-length", "120"],
   "isort.args": ["--line-length", "120"],
   "prettier.printWidth": 120,
   "prettier.printWidth": 120,
   "python.formatting.provider": "autopep8",
   "python.formatting.provider": "autopep8",

+ 3 - 3
CITATION.cff

@@ -8,7 +8,7 @@ authors:
   given-names: Rodja
   given-names: Rodja
   orcid: https://orcid.org/0009-0009-4735-6227
   orcid: https://orcid.org/0009-0009-4735-6227
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
 title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
-version: v1.3.5
-date-released: '2023-07-19'
+version: v1.3.7
+date-released: '2023-08-03'
 url: https://github.com/zauberzeug/nicegui
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.8162736
+doi: 10.5281/zenodo.8210350

+ 38 - 0
deploy.sh

@@ -0,0 +1,38 @@
+#!/bin/bash
+
+# Get the PUID and PGID from environment variables (or use default values 1000 if not set)
+PUID=${PUID:-1000}
+PGID=${PGID:-1000}
+
+# Check if the provided PUID and PGID are non-empty, numeric values; otherwise, assign default values.
+if ! [[ "$PUID" =~ ^[0-9]+$ ]]; then
+  PUID=1000
+fi
+if ! [[ "$PGID" =~ ^[0-9]+$ ]]; then
+  PGID=1000
+fi
+# Check if the specified group with PGID exists, if not, create it.
+if ! getent group "$PGID" >/dev/null; then
+  groupadd -g "$PGID" appgroup
+fi
+# Create user.
+useradd --create-home --shell /bin/bash --uid "$PUID" --gid "$PGID" appuser
+# Make user the owner of the app directory.
+chown -R appuser:appgroup /app
+# Copy the default .bashrc file to the appuser home directory.
+cp /etc/skel/.bashrc /home/appuser/.bashrc
+chown appuser:appgroup /home/appuser/.bashrc
+export HOME=/home/appuser
+# Set permissions on font directories.
+if [ -d "/usr/share/fonts" ]; then
+  chmod -R 777 /usr/share/fonts
+fi
+if [ -d "/var/cache/fontconfig" ]; then
+  chmod -R 777 /var/cache/fontconfig
+fi
+if [ -d "/usr/local/share/fonts" ]; then
+  chmod -R 777 /usr/local/share/fonts
+fi
+# Switch to appuser and execute the Docker CMD or passed in command-line arguments.
+# Using setpriv let's it run as PID 1 which is required for proper signal handling (similar to gosu/su-exec).
+exec setpriv --reuid=$PUID --regid=$PGID --init-groups $@

+ 2 - 2
examples/ai_interface/main.py

@@ -19,7 +19,7 @@ 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))
+    prediction = await 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}"')
 
 
@@ -35,7 +35,7 @@ async def generate_image():
 with ui.row().style('gap:10em'):
 with ui.row().style('gap:10em'):
     with ui.column():
     with ui.column():
         ui.label('OpenAI Whisper (voice transcription)').classes('text-2xl')
         ui.label('OpenAI Whisper (voice transcription)').classes('text-2xl')
-        ui.upload(on_upload=transcribe).style('width: 20em')
+        ui.upload(on_upload=transcribe, auto_upload=True).style('width: 20em')
         transcription = ui.label().classes('text-xl')
         transcription = ui.label().classes('text-xl')
     with ui.column():
     with ui.column():
         ui.label('Stable Diffusion (image generator)').classes('text-2xl')
         ui.label('Stable Diffusion (image generator)').classes('text-2xl')

+ 37 - 0
examples/docker_image/README.md

@@ -0,0 +1,37 @@
+# Docker Example with NiceGUI
+
+This README provides a walkthrough on how to utilize the NiceGUI release docker image, [zauberzeug/nicegui, available on Docker Hub](https://hub.docker.com/r/zauberzeug/nicegui).
+The image is configured using a `docker-compose.yml` file for ease of use.
+You can achieve similar results using the `docker run` command along with its appropriate parameters.
+
+## Testing the Setup
+
+Modify the `docker-compose.yml` file to reflect your local host user's uid/gid and then execute the command:
+
+```bash
+docker compose up
+```
+
+## Special Docker Features
+
+### Data Persistence
+
+NiceGUI automatically generates a `.nicegui` directory in the application's root directory (`/app` within the docker container).
+In this example, the local `app` folder is mounted to the `/app` location inside the container, ensuring that the `.nicegui` folder remains persistent across docker restarts.
+You can validate this by accessing http://localhost:8080, inputting some data for storage, and then restarting the container.
+
+### Non-Root User Execution
+
+The application within the container operates as a non-root user (similar to the [configs from linuxserver.io](https://docs.linuxserver.io/general/understanding-puid-and-pgid)).
+Consequently, all files generated by NiceGUI (such as the `.nicegui` persistence) will bear the configured uid/gid.
+
+### Docker Signal Pass-Through
+
+The docker image is designed to relay signals from Docker, such as SIGTERM, to initiate a graceful shutdown of NiceGUI.
+For instance, when you stop the container (using Ctrl+C) and subsequently examine the logs using the `docker compose logs` command,
+you should notice the initiation of the `ui.shutdown` method.
+
+### Storage Secret
+
+In the example `main.py` we read the [storage secret](https://nicegui.io/documentation/storage) from a environment variable.
+This can then be defined in the `docker-compose.yml` (or even passed on from an `.env` file).

+ 17 - 0
examples/docker_image/app/main.py

@@ -0,0 +1,17 @@
+import os
+
+from nicegui import app, ui
+
+
+@ui.page('/')
+def index():
+    ui.textarea('This note is kept between visits') \
+        .classes('w-96').bind_value(app.storage.user, 'note')
+
+
+def on_shutdown():
+    print('Shutdown has been initiated!')
+
+
+app.on_shutdown(on_shutdown)
+ui.run(storage_secret=os.environ['STORAGE_SECRET'])

+ 13 - 0
examples/docker_image/docker-compose.yml

@@ -0,0 +1,13 @@
+version: "3.9"
+
+services:
+  nicegui:
+    image: zauberzeug/nicegui:latest
+    ports:
+      - 8080:8080
+    volumes:
+      - ./app:/app # mounting local app directory
+    environment:
+      - PUID=1000 # change this to your user id
+      - PGID=1000 # change this to your group id
+      - STORAGE_SECRET="change-this-to-yor-own-private-secret"

+ 27 - 0
examples/download_text_as_file/main.py

@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+import io
+import uuid
+
+from fastapi.responses import StreamingResponse
+
+from nicegui import Client, app, ui
+
+
+@ui.page('/')
+async def index(client: Client):
+    download_path = f'/download/{uuid.uuid4()}.txt'
+
+    @app.get(download_path)
+    def download():
+        string_io = io.StringIO(textarea.value)  # create a file-like object from the string
+        headers = {'Content-Disposition': 'attachment; filename=download.txt'}
+        return StreamingResponse(string_io, media_type='text/plain', headers=headers)
+
+    textarea = ui.textarea(value='Hello World!')
+    ui.button('Download', on_click=lambda: ui.download(download_path))
+
+    # cleanup the download route after the client disconnected
+    await client.disconnected()
+    app.routes[:] = [route for route in app.routes if route.path != download_path]
+
+ui.run()

+ 2 - 0
examples/script_executor/main.py

@@ -3,6 +3,7 @@ import asyncio
 import os.path
 import os.path
 import platform
 import platform
 import shlex
 import shlex
+import sys
 
 
 from nicegui import ui
 from nicegui import ui
 
 
@@ -11,6 +12,7 @@ async def run_command(command: str) -> None:
     """Run a command in the background and display the output in the pre-created dialog."""
     """Run a command in the background and display the output in the pre-created dialog."""
     dialog.open()
     dialog.open()
     result.content = ''
     result.content = ''
+    command = command.replace('python3', sys.executable)  # NOTE replace with machine-independent Python path (#1240)
     process = await asyncio.create_subprocess_exec(
     process = await asyncio.create_subprocess_exec(
         *shlex.split(command),
         *shlex.split(command),
         stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
         stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,

+ 11 - 0
fly.dockerfile

@@ -4,8 +4,19 @@ LABEL maintainer="Zauberzeug GmbH <nicegui@zauberzeug.com>"
 
 
 RUN pip install itsdangerous prometheus_client isort docutils pandas plotly matplotlib requests
 RUN pip install itsdangerous prometheus_client isort docutils pandas plotly matplotlib requests
 
 
+RUN apt update && apt install curl -y
+
+RUN curl -sSL https://install.python-poetry.org | python3 - && \
+    cd /usr/local/bin && \
+    ln -s ~/.local/bin/poetry && \
+    poetry config virtualenvs.create false
+
 WORKDIR /app
 WORKDIR /app
 
 
+COPY pyproject.toml poetry.lock*  ./
+
+RUN poetry install --no-root --extras "plotly matplotlib"
+
 ADD . .
 ADD . .
 
 
 # ensure unique version to not serve cached and hence potentially wrong static files
 # ensure unique version to not serve cached and hence potentially wrong static files

+ 4 - 4
fly.toml

@@ -25,8 +25,8 @@ strategy = "rolling"
   protocol = "tcp"
   protocol = "tcp"
   script_checks = []
   script_checks = []
   [services.concurrency]
   [services.concurrency]
-    hard_limit = 100
-    soft_limit = 12
+    hard_limit = 200
+    soft_limit = 100
     type = "connections"
     type = "connections"
 
 
   [[services.ports]]
   [[services.ports]]
@@ -40,13 +40,13 @@ strategy = "rolling"
 
 
   [[services.tcp_checks]]
   [[services.tcp_checks]]
     interval = "10s"
     interval = "10s"
-    grace_period = "2m"
+    grace_period = "30s"
     restart_limit = 3
     restart_limit = 3
     timeout = "5s"
     timeout = "5s"
 
 
   [[services.http_checks]]
   [[services.http_checks]]
     interval = "20s"
     interval = "20s"
-    grace_period = "4m"
+    grace_period = "1m"
     method = "get"
     method = "get"
     path = "/"
     path = "/"
     protocol = "http"
     protocol = "http"

+ 43 - 11
main.py

@@ -1,20 +1,16 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
 import importlib
 import importlib
 import inspect
 import inspect
-
-if True:
-    # increasing max decode packets to be able to transfer images
-    # see https://github.com/miguelgrinberg/python-engineio/issues/142
-    from engineio.payload import Payload
-    Payload.max_decode_packets = 500
-
 import os
 import os
 from pathlib import Path
 from pathlib import Path
 from typing import Awaitable, Callable, Optional
 from typing import Awaitable, Callable, Optional
+from urllib.parse import parse_qs
 
 
 from fastapi import Request
 from fastapi import Request
 from fastapi.responses import FileResponse, RedirectResponse, Response
 from fastapi.responses import FileResponse, RedirectResponse, Response
+from starlette.middleware.base import BaseHTTPMiddleware
 from starlette.middleware.sessions import SessionMiddleware
 from starlette.middleware.sessions import SessionMiddleware
+from starlette.types import ASGIApp, Receive, Scope, Send
 
 
 import prometheus
 import prometheus
 from nicegui import Client, app
 from nicegui import Client, app
@@ -59,10 +55,42 @@ async def redirect_reference_to_documentation(request: Request,
         return RedirectResponse('/documentation')
         return RedirectResponse('/documentation')
     return await call_next(request)
     return await call_next(request)
 
 
-# NOTE in our global fly.io deployment we need to make sure that the websocket connects back to the same instance
-fly_instance_id = os.environ.get('FLY_ALLOC_ID', '').split('-')[0]
-if fly_instance_id:
-    nicegui_globals.socket_io_js_extra_headers['fly-force-instance-id'] = fly_instance_id
+# NOTE In our global fly.io deployment we need to make sure that we connect back to the same instance.
+fly_instance_id = os.environ.get('FLY_ALLOC_ID', 'local').split('-')[0]
+nicegui_globals.socket_io_js_extra_headers['fly-force-instance-id'] = fly_instance_id  # for HTTP long polling
+nicegui_globals.socket_io_js_query_params['fly_instance_id'] = fly_instance_id  # for websocket (FlyReplayMiddleware)
+
+
+class FlyReplayMiddleware(BaseHTTPMiddleware):
+    """Replay to correct fly.io instance.
+
+    If the wrong instance was picked by the fly.io load balancer, we use the fly-replay header
+    to repeat the request again on the right instance.
+
+    This only works if the correct instance is provided as a query_string parameter.
+    """
+
+    def __init__(self, app: ASGIApp) -> None:
+        self.app = app
+
+    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+        query_string = scope.get('query_string', b'').decode()
+        query_params = parse_qs(query_string)
+        target_instance = query_params.get('fly_instance_id', [fly_instance_id])[0]
+
+        async def send_wrapper(message):
+            if target_instance != fly_instance_id:
+                if message['type'] == 'websocket.close':
+                    # fly.io only seems to look at the fly-replay header if websocket is accepted
+                    message = {'type': 'websocket.accept'}
+                if 'headers' not in message:
+                    message['headers'] = []
+                message['headers'].append([b'fly-replay', f'instance={target_instance}'.encode()])
+            await send(message)
+        await self.app(scope, receive, send_wrapper)
+
+
+app.add_middleware(FlyReplayMiddleware)
 
 
 
 
 def add_head_html() -> None:
 def add_head_html() -> None:
@@ -310,6 +338,10 @@ async def index_page(client: Client) -> None:
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
             example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
             example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
             example_link('ROS2', 'Using NiceGUI as web interface for a ROS2 robot')
             example_link('ROS2', 'Using NiceGUI as web interface for a ROS2 robot')
+            example_link('Docker Image',
+                         'Demonstrate using the official'
+                         '[zauberzeug/nicegui](https://hub.docker.com/r/zauberzeug/nicegui) docker image')
+            example_link('Download Text as File', 'providing in-memory data like strings as file download')
 
 
     with ui.row().classes('dark-box min-h-screen mt-16'):
     with ui.row().classes('dark-box min-h-screen mt-16'):
         link_target('why')
         link_target('why')

+ 7 - 2
nicegui/air.py

@@ -1,6 +1,6 @@
-import asyncio
 import gzip
 import gzip
 import logging
 import logging
+import re
 from typing import Any, Dict
 from typing import Any, Dict
 
 
 import httpx
 import httpx
@@ -33,10 +33,15 @@ class Air:
                 content=data['body'],
                 content=data['body'],
             )
             )
             response = await self.client.send(request)
             response = await self.client.send(request)
+            instance_id = data['instance-id']
             content = response.content.replace(
             content = response.content.replace(
                 b'const extraHeaders = {};',
                 b'const extraHeaders = {};',
-                (f'const extraHeaders = {{ "fly-force-instance-id" : "{data["instance-id"]}" }};').encode(),
+                (f'const extraHeaders = {{ "fly-force-instance-id" : "{instance_id}" }};').encode(),
             )
             )
+            match = re.search(b'const query = ({.*?})', content)
+            if match:
+                new_js_object = match.group(1).decode().rstrip('}') + ", 'fly_instance_id' : '" + instance_id + "'}"
+                content = content.replace(match.group(0), f'const query = {new_js_object}'.encode())
             response_headers = dict(response.headers)
             response_headers = dict(response.headers)
             response_headers['content-encoding'] = 'gzip'
             response_headers['content-encoding'] = 'gzip'
             compressed = gzip.compress(content)
             compressed = gzip.compress(content)

+ 19 - 5
nicegui/app.py

@@ -82,7 +82,11 @@ class App(FastAPI):
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
         globals.app.mount(url_path, StaticFiles(directory=str(local_directory)))
         globals.app.mount(url_path, StaticFiles(directory=str(local_directory)))
 
 
-    def add_static_file(self, *, local_file: Union[str, Path], url_path: Optional[str] = None) -> str:
+    def add_static_file(self, *,
+                        local_file: Union[str, Path],
+                        url_path: Optional[str] = None,
+                        single_use: bool = False,
+                        ) -> str:
         """Add a single static file.
         """Add a single static file.
 
 
         Allows a local file to be accessed online with enabled caching.
         Allows a local file to be accessed online with enabled caching.
@@ -93,6 +97,7 @@ class App(FastAPI):
 
 
         :param local_file: local file to serve as static content
         :param local_file: local file to serve as static content
         :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
         :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
+        :param single_use: whether to remove the route after the file has been downloaded once (default: False)
         :return: URL path which can be used to access the file
         :return: URL path which can be used to access the file
         """
         """
         file = Path(local_file).resolve()
         file = Path(local_file).resolve()
@@ -102,7 +107,9 @@ class App(FastAPI):
             url_path = f'/_nicegui/auto/static/{helpers.hash_file_path(file)}/{file.name}'
             url_path = f'/_nicegui/auto/static/{helpers.hash_file_path(file)}/{file.name}'
 
 
         @self.get(url_path)
         @self.get(url_path)
-        async def read_item() -> FileResponse:
+        def read_item() -> FileResponse:
+            if single_use:
+                self.remove_route(url_path)
             return FileResponse(file, headers={'Cache-Control': 'public, max-age=3600'})
             return FileResponse(file, headers={'Cache-Control': 'public, max-age=3600'})
 
 
         return url_path
         return url_path
@@ -122,13 +129,17 @@ class App(FastAPI):
         :param local_directory: local folder with files to serve as media content
         :param local_directory: local folder with files to serve as media content
         """
         """
         @self.get(url_path + '/{filename:path}')
         @self.get(url_path + '/{filename:path}')
-        async def read_item(request: Request, filename: str) -> StreamingResponse:
+        def read_item(request: Request, filename: str) -> StreamingResponse:
             filepath = Path(local_directory) / filename
             filepath = Path(local_directory) / filename
             if not filepath.is_file():
             if not filepath.is_file():
                 return {'detail': 'Not Found'}, 404
                 return {'detail': 'Not Found'}, 404
             return helpers.get_streaming_response(filepath, request)
             return helpers.get_streaming_response(filepath, request)
 
 
-    def add_media_file(self, *, local_file: Union[str, Path], url_path: Optional[str] = None) -> str:
+    def add_media_file(self, *,
+                       local_file: Union[str, Path],
+                       url_path: Optional[str] = None,
+                       single_use: bool = False,
+                       ) -> str:
         """Add a single media file.
         """Add a single media file.
 
 
         Allows a local file to be streamed.
         Allows a local file to be streamed.
@@ -139,6 +150,7 @@ class App(FastAPI):
 
 
         :param local_file: local file to serve as media content
         :param local_file: local file to serve as media content
         :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
         :param url_path: string that starts with a slash "/" and identifies the path at which the file should be served (default: None -> auto-generated URL path)
+        :param single_use: whether to remove the route after the media file has been downloaded once (default: False)
         :return: URL path which can be used to access the file
         :return: URL path which can be used to access the file
         """
         """
         file = Path(local_file).resolve()
         file = Path(local_file).resolve()
@@ -148,7 +160,9 @@ class App(FastAPI):
             url_path = f'/_nicegui/auto/media/{helpers.hash_file_path(file)}/{file.name}'
             url_path = f'/_nicegui/auto/media/{helpers.hash_file_path(file)}/{file.name}'
 
 
         @self.get(url_path)
         @self.get(url_path)
-        async def read_item(request: Request) -> StreamingResponse:
+        def read_item(request: Request) -> StreamingResponse:
+            if single_use:
+                self.remove_route(url_path)
             return helpers.get_streaming_response(file, request)
             return helpers.get_streaming_response(file, request)
 
 
         return url_path
         return url_path

+ 3 - 1
nicegui/binding.py

@@ -7,6 +7,8 @@ from typing import Any, Callable, DefaultDict, Dict, List, Optional, Set, Tuple,
 
 
 from . import globals
 from . import globals
 
 
+MAX_PROPAGATION_TIME = 0.01
+
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], Any] = {}
 bindable_properties: Dict[Tuple[int, str], Any] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
@@ -45,7 +47,7 @@ async def loop() -> None:
                     set_attribute(target_obj, target_name, value)
                     set_attribute(target_obj, target_name, value)
                     propagate(target_obj, target_name, visited)
                     propagate(target_obj, target_name, visited)
             del link, source_obj, target_obj
             del link, source_obj, target_obj
-        if time.time() - t > 0.01:
+        if time.time() - t > MAX_PROPAGATION_TIME:
             logging.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
             logging.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
         await asyncio.sleep(globals.binding_refresh_interval)
         await asyncio.sleep(globals.binding_refresh_interval)
 
 

+ 14 - 7
nicegui/client.py

@@ -54,7 +54,7 @@ class Client:
     @property
     @property
     def ip(self) -> Optional[str]:
     def ip(self) -> Optional[str]:
         """Return the IP address of the client, or None if the client is not connected."""
         """Return the IP address of the client, or None if the client is not connected."""
-        return self.environ['asgi.scope']['client'][0] if self.environ else None
+        return self.environ['asgi.scope']['client'][0] if self.has_socket_connection else None
 
 
     @property
     @property
     def has_socket_connection(self) -> bool:
     def has_socket_connection(self) -> bool:
@@ -71,17 +71,21 @@ class Client:
     def build_response(self, request: Request, status_code: int = 200) -> Response:
     def build_response(self, request: Request, status_code: int = 200) -> Response:
         prefix = request.headers.get('X-Forwarded-Prefix', request.scope.get('root_path', ''))
         prefix = request.headers.get('X-Forwarded-Prefix', request.scope.get('root_path', ''))
         elements = json.dumps({id: element._to_dict() for id, element in self.elements.items()})
         elements = json.dumps({id: element._to_dict() for id, element in self.elements.items()})
+        socket_io_js_query_params = {**globals.socket_io_js_query_params, 'client_id': self.id}
         vue_html, vue_styles, vue_scripts, imports, js_imports = generate_resources(prefix, self.elements.values())
         vue_html, vue_styles, vue_scripts, imports, js_imports = generate_resources(prefix, self.elements.values())
         return templates.TemplateResponse('index.html', {
         return templates.TemplateResponse('index.html', {
             'request': request,
             'request': request,
             'version': __version__,
             'version': __version__,
-            'client_id': str(self.id),
-            'elements': elements,
+            'elements': elements.replace('&', '&amp;')
+                                .replace('<', '&lt;')
+                                .replace('>', '&gt;')
+                                .replace('`', '&#96;'),
             'head_html': self.head_html,
             'head_html': self.head_html,
             'body_html': '<style>' + '\n'.join(vue_styles) + '</style>\n' + self.body_html + '\n' + '\n'.join(vue_html),
             'body_html': '<style>' + '\n'.join(vue_styles) + '</style>\n' + self.body_html + '\n' + '\n'.join(vue_html),
             'vue_scripts': '\n'.join(vue_scripts),
             'vue_scripts': '\n'.join(vue_scripts),
             'imports': json.dumps(imports),
             'imports': json.dumps(imports),
             'js_imports': '\n'.join(js_imports),
             'js_imports': '\n'.join(js_imports),
+            'quasar_config': json.dumps(globals.quasar_config),
             'title': self.page.resolve_title(),
             'title': self.page.resolve_title(),
             'viewport': self.page.resolve_viewport(),
             'viewport': self.page.resolve_viewport(),
             'favicon_url': get_favicon_url(self.page, prefix),
             'favicon_url': get_favicon_url(self.page, prefix),
@@ -89,14 +93,17 @@ class Client:
             'language': self.page.resolve_language(),
             'language': self.page.resolve_language(),
             'prefix': prefix,
             'prefix': prefix,
             'tailwind': globals.tailwind,
             'tailwind': globals.tailwind,
+            'prod_js': globals.prod_js,
+            'socket_io_js_query_params': socket_io_js_query_params,
             'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
             'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
+            'socket_io_js_transports': globals.socket_io_js_transports,
         }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
         }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
 
 
     async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
     async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
         """Block execution until the client is connected."""
         """Block execution until the client is connected."""
         self.is_waiting_for_connection = True
         self.is_waiting_for_connection = True
         deadline = time.time() + timeout
         deadline = time.time() + timeout
-        while not self.environ:
+        while not self.has_socket_connection:
             if time.time() > deadline:
             if time.time() > deadline:
                 raise TimeoutError(f'No connection after {timeout} seconds')
                 raise TimeoutError(f'No connection after {timeout} seconds')
             await asyncio.sleep(check_interval)
             await asyncio.sleep(check_interval)
@@ -104,7 +111,7 @@ class Client:
 
 
     async def disconnected(self, check_interval: float = 0.1) -> None:
     async def disconnected(self, check_interval: float = 0.1) -> None:
         """Block execution until the client disconnects."""
         """Block execution until the client disconnects."""
-        if not self.environ:
+        if not self.has_socket_connection:
             await self.connected()
             await self.connected()
         self.is_waiting_for_disconnect = True
         self.is_waiting_for_disconnect = True
         while self.id in globals.clients:
         while self.id in globals.clients:
@@ -134,10 +141,10 @@ class Client:
             await asyncio.sleep(check_interval)
             await asyncio.sleep(check_interval)
         return self.waiting_javascript_commands.pop(request_id)
         return self.waiting_javascript_commands.pop(request_id)
 
 
-    def open(self, target: Union[Callable[..., Any], str]) -> 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."""
         path = target if isinstance(target, str) else globals.page_routes[target]
         path = target if isinstance(target, str) else globals.page_routes[target]
-        outbox.enqueue_message('open', path, self.id)
+        outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id)
 
 
     def download(self, url: str, filename: Optional[str] = None) -> None:
     def download(self, url: str, filename: Optional[str] = None) -> None:
         """Download a file from the given URL."""
         """Download a file from the given URL."""

+ 0 - 13
nicegui/deprecation.py

@@ -1,13 +0,0 @@
-import warnings
-from functools import wraps
-
-warnings.simplefilter('always', DeprecationWarning)
-
-
-def deprecated(func: type, old_name: str, new_name: str, issue: int) -> type:
-    @wraps(func)
-    def wrapped(*args, **kwargs):
-        url = f'https://github.com/zauberzeug/nicegui/issues/{issue}'
-        warnings.warn(DeprecationWarning(f'{old_name} is deprecated, use {new_name} instead ({url})'))
-        return func(*args, **kwargs)
-    return wrapped

+ 1 - 1
nicegui/elements/icon.py

@@ -15,7 +15,7 @@ class Icon(TextColorElement):
 
 
         This element is based on Quasar's `QIcon <https://quasar.dev/vue-components/icon>`_ component.
         This element is based on Quasar's `QIcon <https://quasar.dev/vue-components/icon>`_ component.
 
 
-        `Here <https://fonts.google.com/icons>`_ is a reference of possible names.
+        `Here <https://fonts.google.com/icons?icon.set=Material+Icons>`_ is a reference of possible names.
 
 
         :param name: name of the icon
         :param name: name of the icon
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem
         :param size: size in CSS units, including unit name or standard size name (xs|sm|md|lg|xl), examples: 16px, 2rem

+ 11 - 0
nicegui/elements/line_plot.py

@@ -62,3 +62,14 @@ class LinePlot(Pyplot):
         self.fig.gca().set_ylim(min_y - pad_y, max_y + pad_y)
         self.fig.gca().set_ylim(min_y - pad_y, max_y + pad_y)
         self._convert_to_html()
         self._convert_to_html()
         self.update()
         self.update()
+
+    def clear(self) -> None:
+        """Clear the line plot."""
+        super().clear()
+        self.x.clear()
+        for y in self.Y:
+            y.clear()
+        for line in self.lines:
+            line.set_data([], [])
+        self._convert_to_html()
+        self.update()

+ 1 - 1
nicegui/elements/mixins/value_element.py

@@ -1,4 +1,4 @@
-from typing import Any, Callable, List, Optional
+from typing import Any, Callable, Optional
 
 
 from typing_extensions import Self
 from typing_extensions import Self
 
 

+ 12 - 2
nicegui/elements/plotly.py

@@ -1,9 +1,16 @@
-from typing import Dict, Union
+from __future__ import annotations
 
 
-import plotly.graph_objects as go
+from typing import Dict, Union
 
 
+from .. import globals
 from ..element import Element
 from ..element import Element
 
 
+try:
+    import plotly.graph_objects as go
+    globals.optional_features.add('plotly')
+except ImportError:
+    pass
+
 
 
 class Plotly(Element, component='plotly.vue', libraries=['lib/plotly/plotly.min.js']):
 class Plotly(Element, component='plotly.vue', libraries=['lib/plotly/plotly.min.js']):
 
 
@@ -22,6 +29,9 @@ class Plotly(Element, component='plotly.vue', libraries=['lib/plotly/plotly.min.
         :param figure: Plotly figure to be rendered. Can be either a `go.Figure` instance, or
         :param figure: Plotly figure to be rendered. Can be either a `go.Figure` instance, or
                        a `dict` object with keys `data`, `layout`, `config` (optional).
                        a `dict` object with keys `data`, `layout`, `config` (optional).
         """
         """
+        if not 'plotly' in globals.optional_features:
+            raise ImportError('Plotly is not installed. Please run "pip install nicegui[plotly]".')
+
         super().__init__()
         super().__init__()
 
 
         self.figure = figure
         self.figure = figure

+ 11 - 2
nicegui/elements/pyplot.py

@@ -1,12 +1,18 @@
 import asyncio
 import asyncio
 import io
 import io
+import os
 from typing import Any
 from typing import Any
 
 
-import matplotlib.pyplot as plt
-
 from .. import background_tasks, globals
 from .. import background_tasks, globals
 from ..element import Element
 from ..element import Element
 
 
+try:
+    if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
+        import matplotlib.pyplot as plt
+        globals.optional_features.add('matplotlib')
+except ImportError:
+    pass
+
 
 
 class Pyplot(Element):
 class Pyplot(Element):
 
 
@@ -18,6 +24,9 @@ class Pyplot(Element):
         :param close: whether the figure should be closed after exiting the context; set to `False` if you want to update it later (default: `True`)
         :param close: whether the figure should be closed after exiting the context; set to `False` if you want to update it later (default: `True`)
         :param kwargs: arguments like `figsize` which should be passed to `pyplot.figure <https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.figure.html>`_
         :param kwargs: arguments like `figsize` which should be passed to `pyplot.figure <https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.figure.html>`_
         """
         """
+        if 'matplotlib' not in globals.optional_features:
+            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
+
         super().__init__('div')
         super().__init__('div')
         self.close = close
         self.close = close
         self.fig = plt.figure(**kwargs)
         self.fig = plt.figure(**kwargs)

+ 1 - 1
nicegui/elements/query.js

@@ -13,7 +13,7 @@ export default {
     },
     },
     add_style(style) {
     add_style(style) {
       Object.entries(style).forEach(([key, val]) =>
       Object.entries(style).forEach(([key, val]) =>
-        document.querySelectorAll(this.selector).forEach((e) => (e.style[key] = val))
+        document.querySelectorAll(this.selector).forEach((e) => e.style.setProperty(key, val))
       );
       );
     },
     },
     remove_style(keys) {
     remove_style(keys) {

+ 16 - 0
nicegui/elements/scene.py

@@ -92,6 +92,16 @@ class Scene(Element,
         self.on('dragstart', self.handle_drag)
         self.on('dragstart', self.handle_drag)
         self.on('dragend', self.handle_drag)
         self.on('dragend', self.handle_drag)
 
 
+    def __enter__(self) -> 'Scene':
+        Object3D.current_scene = self
+        return super().__enter__()
+
+    def __getattribute__(self, name: str) -> Any:
+        attribute = super().__getattribute__(name)
+        if isinstance(attribute, type) and issubclass(attribute, Object3D):
+            Object3D.current_scene = self
+        return attribute
+
     def handle_init(self, e: GenericEventArguments) -> None:
     def handle_init(self, e: GenericEventArguments) -> None:
         self.is_initialized = True
         self.is_initialized = True
         with globals.socket_id(e.args['socket_id']):
         with globals.socket_id(e.args['socket_id']):
@@ -170,3 +180,9 @@ class Scene(Element,
     def delete(self) -> None:
     def delete(self) -> None:
         binding.remove(list(self.objects.values()), Object3D)
         binding.remove(list(self.objects.values()), Object3D)
         super().delete()
         super().delete()
+
+    def clear(self) -> None:
+        """Remove all objects from the scene."""
+        super().clear()
+        for object in list(self.objects.values()):
+            object.delete()

+ 3 - 4
nicegui/elements/scene_object3d.py

@@ -1,20 +1,19 @@
 import math
 import math
 import uuid
 import uuid
-from typing import TYPE_CHECKING, Any, List, Optional, Union, cast
-
-from .. import globals
+from typing import TYPE_CHECKING, Any, List, Optional, Union
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from .scene import Scene, SceneObject
     from .scene import Scene, SceneObject
 
 
 
 
 class Object3D:
 class Object3D:
+    current_scene: Optional['Scene'] = None
 
 
     def __init__(self, type: str, *args: Any) -> None:
     def __init__(self, type: str, *args: Any) -> None:
         self.type = type
         self.type = type
         self.id = str(uuid.uuid4())
         self.id = str(uuid.uuid4())
         self.name: Optional[str] = None
         self.name: Optional[str] = None
-        self.scene: 'Scene' = cast('Scene', globals.get_slot().parent)
+        self.scene: 'Scene' = self.current_scene
         self.scene.objects[self.id] = self
         self.scene.objects[self.id] = self
         self.parent: Union[Object3D, SceneObject] = self.scene.stack[-1]
         self.parent: Union[Object3D, SceneObject] = self.scene.stack[-1]
         self.args: List = list(args)
         self.args: List = list(args)

+ 20 - 0
nicegui/elements/table.py

@@ -44,6 +44,7 @@ class Table(FilterElement, component='table.js'):
         self._props['pagination'] = {'rowsPerPage': pagination or 0}
         self._props['pagination'] = {'rowsPerPage': pagination or 0}
         self._props['selection'] = selection or 'none'
         self._props['selection'] = selection or 'none'
         self._props['selected'] = self.selected
         self._props['selected'] = self.selected
+        self._props['fullscreen'] = False
 
 
         def handle_selection(e: GenericEventArguments) -> None:
         def handle_selection(e: GenericEventArguments) -> None:
             if e.args['added']:
             if e.args['added']:
@@ -57,6 +58,25 @@ class Table(FilterElement, component='table.js'):
             handle_event(on_select, arguments)
             handle_event(on_select, arguments)
         self.on('selection', handle_selection, ['added', 'rows', 'keys'])
         self.on('selection', handle_selection, ['added', 'rows', 'keys'])
 
 
+    @property
+    def is_fullscreen(self) -> bool:
+        """Whether the table is in fullscreen mode."""
+        return self._props['fullscreen']
+
+    @is_fullscreen.setter
+    def is_fullscreen(self, value: bool) -> None:
+        """Set fullscreen mode."""
+        self._props['fullscreen'] = value
+        self.update()
+
+    def set_fullscreen(self, value: bool) -> None:
+        """Set fullscreen mode."""
+        self.is_fullscreen = value
+
+    def toggle_fullscreen(self) -> None:
+        """Toggle fullscreen mode."""
+        self.is_fullscreen = not self.is_fullscreen
+
     def add_rows(self, *rows: Dict) -> None:
     def add_rows(self, *rows: Dict) -> None:
         """Add rows to the table."""
         """Add rows to the table."""
         self.rows.extend(rows)
         self.rows.extend(rows)

+ 1 - 1
nicegui/events.py

@@ -23,7 +23,7 @@ class GenericEventArguments(EventArguments):
     def __getitem__(self, key: str) -> Any:
     def __getitem__(self, key: str) -> Any:
         if key == 'args':
         if key == 'args':
             globals.log.warning('msg["args"] is deprecated, use e.args instead '
             globals.log.warning('msg["args"] is deprecated, use e.args instead '
-                                '(see https://github.com/zauberzeug/nicegui/pull/1095)')
+                                '(see https://github.com/zauberzeug/nicegui/pull/1095)')  # DEPRECATED
             return self.args
             return self.args
         raise KeyError(key)
         raise KeyError(key)
 
 

+ 8 - 5
nicegui/functions/download.py

@@ -1,14 +1,17 @@
-from typing import Optional
+from pathlib import Path
+from typing import Optional, Union
 
 
-from .. import globals
+from .. import globals, helpers
 
 
 
 
-def download(url: str, filename: Optional[str] = None) -> None:
+def download(src: Union[str, Path], filename: Optional[str] = None) -> None:
     """Download
     """Download
 
 
     Function to trigger the download of a file.
     Function to trigger the download of a file.
 
 
-    :param url: target URL of the file to download
+    :param src: target URL or local path of the file which should be downloaded
     :param filename: name of the file to download (default: name of the file on the server)
     :param filename: name of the file to download (default: name of the file on the server)
     """
     """
-    globals.get_client().download(url, filename)
+    if helpers.is_file(src):
+        src = globals.app.add_static_file(local_file=src, single_use=True)
+    globals.get_client().download(src, filename)

+ 7 - 3
nicegui/functions/open.py

@@ -3,7 +3,7 @@ from typing import Any, Callable, Union
 from .. import globals
 from .. import globals
 
 
 
 
-def open(target: Union[Callable[..., Any], str]) -> None:
+def open(target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
     """Open
     """Open
 
 
     Can be used to programmatically trigger redirects for a specific client.
     Can be used to programmatically trigger redirects for a specific client.
@@ -12,7 +12,11 @@ def open(target: Union[Callable[..., Any], str]) -> None:
     User events like button clicks provide such a socket.
     User events like button clicks provide such a socket.
 
 
     :param target: page function or string that is a an absolute URL or relative path from base URL
     :param target: page function or string that is a an absolute URL or relative path from base URL
-    :param socket: optional WebSocket defining the target client
+    :param new_tab: whether to open the target in a new tab
     """
     """
     path = target if isinstance(target, str) else globals.page_routes[target]
     path = target if isinstance(target, str) else globals.page_routes[target]
-    globals.get_client().open(path)
+    client = globals.get_client()
+    if client.has_socket_connection:
+        client.open(path, new_tab)
+    else:
+        globals.log.error('Cannot open page because client is not connected, try RedirectResponse from FastAPI instead')

+ 23 - 2
nicegui/functions/refreshable.py

@@ -53,6 +53,15 @@ class refreshable:
         self.instance = instance
         self.instance = instance
         return self
         return self
 
 
+    def __getattribute__(self, __name: str) -> Any:
+        attribute = object.__getattribute__(self, __name)
+        if __name == 'refresh':
+            def refresh(*args: Any, _instance=self.instance, **kwargs: Any) -> None:
+                self.instance = _instance
+                attribute(*args, **kwargs)
+            return refresh
+        return attribute
+
     def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]:
     def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]:
         self.prune()
         self.prune()
         target = RefreshableTarget(container=RefreshableContainer(), instance=self.instance, args=args, kwargs=kwargs)
         target = RefreshableTarget(container=RefreshableContainer(), instance=self.instance, args=args, kwargs=kwargs)
@@ -67,7 +76,15 @@ class refreshable:
             target.container.clear()
             target.container.clear()
             target.args = args or target.args
             target.args = args or target.args
             target.kwargs.update(kwargs)
             target.kwargs.update(kwargs)
-            result = target.run(self.func)
+            try:
+                result = target.run(self.func)
+            except TypeError as e:
+                if 'got multiple values for argument' in str(e):
+                    function = str(e).split()[0].split('.')[-1]
+                    parameter = str(e).split()[-1]
+                    raise Exception(f'{parameter} needs to be consistently passed to {function} '
+                                    'either as positional or as keyword argument') from e
+                raise
             if is_coroutine_function(self.func):
             if is_coroutine_function(self.func):
                 assert result is not None
                 assert result is not None
                 if globals.loop and globals.loop.is_running():
                 if globals.loop and globals.loop.is_running():
@@ -76,4 +93,8 @@ class refreshable:
                     globals.app.on_startup(result)
                     globals.app.on_startup(result)
 
 
     def prune(self) -> None:
     def prune(self) -> None:
-        self.targets = [target for target in self.targets if target.container.client.id in globals.clients]
+        self.targets = [
+            target
+            for target in self.targets
+            if target.container.client.id in globals.clients and target.container.id in target.container.client.elements
+        ]

+ 15 - 2
nicegui/globals.py

@@ -4,7 +4,7 @@ import logging
 from contextlib import contextmanager
 from contextlib import contextmanager
 from enum import Enum
 from enum import Enum
 from pathlib import Path
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, Optional, Set, Union
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, Literal, Optional, Set, Union
 
 
 from socketio import AsyncServer
 from socketio import AsyncServer
 from uvicorn import Server
 from uvicorn import Server
@@ -43,13 +43,26 @@ dark: Optional[bool]
 language: Language
 language: Language
 binding_refresh_interval: float
 binding_refresh_interval: float
 tailwind: bool
 tailwind: bool
+prod_js: bool
+endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none'
 air: Optional['Air'] = None
 air: Optional['Air'] = None
+socket_io_js_query_params: Dict = {}
 socket_io_js_extra_headers: Dict = {}
 socket_io_js_extra_headers: Dict = {}
-
+# NOTE we favor websocket over polling
+socket_io_js_transports: List[Literal['websocket', 'polling']] = ['websocket', 'polling']
 _socket_id: Optional[str] = None
 _socket_id: Optional[str] = None
 slot_stacks: Dict[int, List['Slot']] = {}
 slot_stacks: Dict[int, List['Slot']] = {}
 clients: Dict[str, 'Client'] = {}
 clients: Dict[str, 'Client'] = {}
 index_client: 'Client'
 index_client: 'Client'
+quasar_config: Dict = {
+    'brand': {
+        'primary': '#5898d4',
+    },
+    'loadingBar': {
+        'color': 'primary',
+        'skipHijack': False,
+    },
+}
 
 
 page_routes: Dict[Callable[..., Any], str] = {}
 page_routes: Dict[Callable[..., Any], str] = {}
 
 

+ 1 - 1
nicegui/nicegui.py

@@ -173,7 +173,7 @@ def handle_event(client: Client, msg: Dict) -> None:
     with client:
     with client:
         sender = client.elements.get(msg['id'])
         sender = client.elements.get(msg['id'])
         if sender:
         if sender:
-            msg['args'] = [json.loads(arg) for arg in msg.get('args', [])]
+            msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
             if len(msg['args']) == 1:
             if len(msg['args']) == 1:
                 msg['args'] = msg['args'][0]
                 msg['args'] = msg['args'][0]
             sender._handle_event(msg)
             sender._handle_event(msg)

+ 3 - 0
nicegui/page.py

@@ -107,6 +107,9 @@ class page:
             parameters.insert(0, request)
             parameters.insert(0, request)
         decorated.__signature__ = inspect.Signature(parameters)
         decorated.__signature__ = inspect.Signature(parameters)
 
 
+        if 'include_in_schema' not in self.kwargs:
+            self.kwargs['include_in_schema'] = globals.endpoint_documentation in {'page', 'all'}
+
         self.api_router.get(self._path, **self.kwargs)(decorated)
         self.api_router.get(self._path, **self.kwargs)(decorated)
         globals.page_routes[func] = self.path
         globals.page_routes[func] = self.path
         return func
         return func

+ 12 - 0
nicegui/run.py

@@ -53,6 +53,8 @@ def run(*,
         uvicorn_reload_includes: str = '*.py',
         uvicorn_reload_includes: str = '*.py',
         uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
         uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
         tailwind: bool = True,
         tailwind: bool = True,
+        prod_js: bool = True,
+        endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none',
         storage_secret: Optional[str] = None,
         storage_secret: Optional[str] = None,
         **kwargs: Any,
         **kwargs: Any,
         ) -> None:
         ) -> None:
@@ -79,6 +81,8 @@ def run(*,
     :param uvicorn_reload_includes: string with comma-separated list of glob-patterns which trigger reload on modification (default: `'.py'`)
     :param uvicorn_reload_includes: string with comma-separated list of glob-patterns which trigger reload on modification (default: `'.py'`)
     :param uvicorn_reload_excludes: string with comma-separated list of glob-patterns which should be ignored for reload (default: `'.*, .py[cod], .sw.*, ~*'`)
     :param uvicorn_reload_excludes: string with comma-separated list of glob-patterns which should be ignored for reload (default: `'.*, .py[cod], .sw.*, ~*'`)
     :param tailwind: whether to use Tailwind (experimental, default: `True`)
     :param tailwind: whether to use Tailwind (experimental, default: `True`)
+    :param prod_js: whether to use the production version of Vue and Quasar dependencies (default: `True`)
+    :param endpoint_documentation: control what endpoints appear in the autogenerated OpenAPI docs (default: 'none', options: 'none', 'internal', 'page', 'all')
     :param storage_secret: secret key for browser based storage (default: `None`, a value is required to enable ui.storage.individual and ui.storage.browser)
     :param storage_secret: secret key for browser based storage (default: `None`, a value is required to enable ui.storage.individual and ui.storage.browser)
     :param kwargs: additional keyword arguments are passed to `uvicorn.run`    
     :param kwargs: additional keyword arguments are passed to `uvicorn.run`    
     '''
     '''
@@ -91,6 +95,14 @@ def run(*,
     globals.language = language
     globals.language = language
     globals.binding_refresh_interval = binding_refresh_interval
     globals.binding_refresh_interval = binding_refresh_interval
     globals.tailwind = tailwind
     globals.tailwind = tailwind
+    globals.prod_js = prod_js
+    globals.endpoint_documentation = endpoint_documentation
+
+    for route in globals.app.routes:
+        if route.path.startswith('/_nicegui') and hasattr(route, 'methods'):
+            route.include_in_schema = endpoint_documentation in {'internal', 'all'}
+        if route.path == '/' or route.path in globals.page_routes.values():
+            route.include_in_schema = endpoint_documentation in {'page', 'all'}
 
 
     if on_air:
     if on_air:
         globals.air = Air('' if on_air is True else on_air)
         globals.air = Air('' if on_air is True else on_air)

+ 4 - 1
nicegui/run_with.py

@@ -18,6 +18,8 @@ def run_with(
     language: Language = 'en-US',
     language: Language = 'en-US',
     binding_refresh_interval: float = 0.1,
     binding_refresh_interval: float = 0.1,
     mount_path: str = '/',
     mount_path: str = '/',
+    tailwind: bool = True,
+    prod_js: bool = True,
     storage_secret: Optional[str] = None,
     storage_secret: Optional[str] = None,
 ) -> None:
 ) -> None:
     globals.ui_run_has_been_called = True
     globals.ui_run_has_been_called = True
@@ -27,7 +29,8 @@ def run_with(
     globals.dark = dark
     globals.dark = dark
     globals.language = language
     globals.language = language
     globals.binding_refresh_interval = binding_refresh_interval
     globals.binding_refresh_interval = binding_refresh_interval
-    globals.tailwind = True
+    globals.tailwind = tailwind
+    globals.prod_js = prod_js
 
 
     set_storage_secret(storage_secret)
     set_storage_secret(storage_secret)
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))

+ 36 - 15
nicegui/templates/index.html

@@ -6,7 +6,12 @@
     <link href="{{ favicon_url }}" rel="shortcut icon" />
     <link href="{{ favicon_url }}" rel="shortcut icon" />
     <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/nicegui.css" rel="stylesheet" type="text/css" />
     <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/nicegui.css" rel="stylesheet" type="text/css" />
     <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/fonts.css" rel="stylesheet" type="text/css" />
     <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/fonts.css" rel="stylesheet" type="text/css" />
+    {% if prod_js %}
     <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.prod.css" rel="stylesheet" type="text/css" />
     <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.prod.css" rel="stylesheet" type="text/css" />
+    {% else %}
+    <link href="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.css" rel="stylesheet" type="text/css" />
+    {% endif %}
+    <!-- prevent Prettier from removing this line -->
     {{ head_html | safe }}
     {{ head_html | safe }}
   </head>
   </head>
   <body>
   <body>
@@ -15,8 +20,15 @@
     {% if tailwind %}
     {% if tailwind %}
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/tailwindcss.min.js"></script>
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/tailwindcss.min.js"></script>
     {% endif %}
     {% endif %}
+    <!-- prevent Prettier from removing this line -->
+    {% if prod_js %}
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/vue.global.prod.js"></script>
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/vue.global.prod.js"></script>
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.umd.prod.js"></script>
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.umd.prod.js"></script>
+    {% else %}
+    <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/vue.global.js"></script>
+    <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.umd.js"></script>
+    {% endif %}
+
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/lang/{{ language }}.umd.prod.js"></script>
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/lang/{{ language }}.umd.prod.js"></script>
     <script type="importmap">
     <script type="importmap">
       {"imports": {{ imports | safe }}}
       {"imports": {{ imports | safe }}}
@@ -41,7 +53,12 @@
 
 
       const loaded_libraries = new Set();
       const loaded_libraries = new Set();
       const loaded_components = new Set();
       const loaded_components = new Set();
-      const elements = {{ elements | safe }};
+
+      const raw_elements = String.raw`{{ elements | safe }}`;
+      const elements = JSON.parse(raw_elements.replace(/&#96;/g, '`')
+                                              .replace(/&gt;/g, '>')
+                                              .replace(/&lt;/g, '<')
+                                              .replace(/&amp;/g, '&'));
 
 
       function stringifyEventArgs(args, event_args) {
       function stringifyEventArgs(args, event_args) {
         const result = [];
         const result = [];
@@ -213,25 +230,32 @@
         },
         },
         mounted() {
         mounted() {
           window.app = this;
           window.app = this;
-          const query = { client_id: "{{ client_id }}" };
+          const query = {{ socket_io_js_query_params | safe }};
           const url = window.location.protocol === 'https:' ? 'wss://' : 'ws://' + window.location.host;
           const url = window.location.protocol === 'https:' ? 'wss://' : 'ws://' + window.location.host;
           const extraHeaders = {{ socket_io_js_extra_headers | safe }};
           const extraHeaders = {{ socket_io_js_extra_headers | safe }};
-          const transports = ['websocket', 'polling'];
+          const transports = {{ socket_io_js_transports | safe }};
           window.path_prefix = "{{ prefix | safe }}";
           window.path_prefix = "{{ prefix | safe }}";
           window.socket = io(url, { path: "{{ prefix | safe }}/_nicegui_ws/socket.io", query, extraHeaders, transports });
           window.socket = io(url, { path: "{{ prefix | safe }}/_nicegui_ws/socket.io", query, extraHeaders, transports });
           const messageHandlers = {
           const messageHandlers = {
             connect: () => {
             connect: () => {
               window.socket.emit("handshake", (ok) => {
               window.socket.emit("handshake", (ok) => {
-                if (!ok) window.location.reload();
+                if (!ok) {
+                  console.log('reloading because handshake failed')
+                  window.location.reload();
+                }
                 document.getElementById('popup').style.opacity = 0;
                 document.getElementById('popup').style.opacity = 0;
               });
               });
             },
             },
             connect_error: (err) => {
             connect_error: (err) => {
-              if (err.message == 'timeout') window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
+              if (err.message == 'timeout') {
+                console.log('reloading because connection timed out')
+                window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
+              }
             },
             },
             try_reconnect: () => {
             try_reconnect: () => {
               const checkAndReload = async () => {
               const checkAndReload = async () => {
                 await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
                 await fetch(window.location.href, { headers: { 'NiceGUI-Check': 'try_reconnect' } });
+                console.log('reloading because reconnect was requested')
                 window.location.reload();
                 window.location.reload();
               };
               };
               setInterval(checkAndReload, 500);
               setInterval(checkAndReload, 500);
@@ -261,7 +285,11 @@
               }
               }
             },
             },
             run_javascript: (msg) => runJavascript(msg['code'], msg['request_id']),
             run_javascript: (msg) => runJavascript(msg['code'], msg['request_id']),
-            open: (msg) => (location.href = msg.startsWith('/') ? "{{ prefix | safe }}" + msg : msg),
+            open: (msg) => {
+              const url = msg.path.startsWith('/') ? "{{ prefix | safe }}" + msg.path : msg.path;
+              const target = msg.new_tab ? '_blank' : '_self';
+              window.open(url, target);
+            },
             download: (msg) => download(msg.url, msg.filename),
             download: (msg) => download(msg.url, msg.filename),
             notify: (msg) => Quasar.Notify.create(msg),
             notify: (msg) => Quasar.Notify.create(msg),
           };
           };
@@ -287,21 +315,14 @@
           }
           }
         },
         },
       }).use(Quasar, {
       }).use(Quasar, {
-        config: {
-          brand: {
-            primary: '#5898d4',
-          },
-          loadingBar: {
-            color: 'primary'
-          },
-        }
+        config: {{ quasar_config | safe }}
       });
       });
 
 
       {{ js_imports | safe }}
       {{ js_imports | safe }}
       {{ vue_scripts | safe }}
       {{ vue_scripts | safe }}
 
 
       const dark = {{ dark }};
       const dark = {{ dark }};
-      Quasar.lang.set(Quasar.lang["{{ language }}"]);
+      Quasar.lang.set(Quasar.lang["{{ language }}".replace('-', '')]);
       Quasar.Dark.set(dark === None ? "auto" : dark);
       Quasar.Dark.set(dark === None ? "auto" : dark);
       {% if tailwind %}
       {% if tailwind %}
       if (dark !== None) tailwind.config.darkMode = "class";
       if (dark !== None) tailwind.config.darkMode = "class";

+ 6 - 30
nicegui/ui.py

@@ -1,7 +1,4 @@
-import os
-
 __all__ = [
 __all__ = [
-    'deprecated',
     'element',
     'element',
     'aggrid',
     'aggrid',
     'audio',
     'audio',
@@ -34,6 +31,7 @@ __all__ = [
     'keyboard',
     'keyboard',
     'knob',
     'knob',
     'label',
     'label',
+    'line_plot',
     'link',
     'link',
     'link_target',
     'link_target',
     'log',
     'log',
@@ -42,8 +40,10 @@ __all__ = [
     'menu_item',
     'menu_item',
     'mermaid',
     'mermaid',
     'number',
     'number',
+    'plotly',
     'circular_progress',
     'circular_progress',
     'linear_progress',
     'linear_progress',
+    'pyplot',
     'query',
     'query',
     'radio',
     'radio',
     'row',
     'row',
@@ -90,8 +90,6 @@ __all__ = [
     'run_with',
     'run_with',
 ]
 ]
 
 
-from . import globals
-from .deprecation import deprecated
 from .element import Element as element
 from .element import Element as element
 from .elements.aggrid import AgGrid as aggrid
 from .elements.aggrid import AgGrid as aggrid
 from .elements.audio import Audio as audio
 from .elements.audio import Audio as audio
@@ -124,6 +122,7 @@ from .elements.joystick import Joystick as joystick
 from .elements.keyboard import Keyboard as keyboard
 from .elements.keyboard import Keyboard as keyboard
 from .elements.knob import Knob as knob
 from .elements.knob import Knob as knob
 from .elements.label import Label as label
 from .elements.label import Label as label
+from .elements.line_plot import LinePlot as line_plot
 from .elements.link import Link as link
 from .elements.link import Link as link
 from .elements.link import LinkTarget as link_target
 from .elements.link import LinkTarget as link_target
 from .elements.log import Log as log
 from .elements.log import Log as log
@@ -132,8 +131,10 @@ from .elements.menu import Menu as menu
 from .elements.menu import MenuItem as menu_item
 from .elements.menu import MenuItem as menu_item
 from .elements.mermaid import Mermaid as mermaid
 from .elements.mermaid import Mermaid as mermaid
 from .elements.number import Number as number
 from .elements.number import Number as number
+from .elements.plotly import Plotly as plotly
 from .elements.progress import CircularProgress as circular_progress
 from .elements.progress import CircularProgress as circular_progress
 from .elements.progress import LinearProgress as linear_progress
 from .elements.progress import LinearProgress as linear_progress
+from .elements.pyplot import Pyplot as pyplot
 from .elements.query import query
 from .elements.query import query
 from .elements.radio import Radio as radio
 from .elements.radio import Radio as radio
 from .elements.row import Row as row
 from .elements.row import Row as row
@@ -177,28 +178,3 @@ from .page_layout import PageSticky as page_sticky
 from .page_layout import RightDrawer as right_drawer
 from .page_layout import RightDrawer as right_drawer
 from .run import run
 from .run import run
 from .run_with import run_with
 from .run_with import run_with
-
-try:
-    from .elements.plotly import Plotly as plotly
-    globals.optional_features.add('plotly')
-except ImportError:
-    def plotly(*args, **kwargs):
-        raise ImportError('Plotly is not installed. Please run "pip install plotly".')
-__all__.append('plotly')
-
-if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
-    try:
-        from .elements.line_plot import LinePlot as line_plot
-        from .elements.pyplot import Pyplot as pyplot
-        plot = deprecated(pyplot, 'ui.plot', 'ui.pyplot', 317)
-        globals.optional_features.add('matplotlib')
-    except ImportError:
-        def line_plot(*args, **kwargs):
-            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
-
-        def pyplot(*args, **kwargs):
-            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
-
-        def plot(*args, **kwargs):
-            raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
-    __all__.extend(['line_plot', 'pyplot', 'plot'])

+ 216 - 256
poetry.lock

@@ -135,13 +135,13 @@ files = [
 
 
 [[package]]
 [[package]]
 name = "certifi"
 name = "certifi"
-version = "2023.5.7"
+version = "2023.7.22"
 description = "Python package for providing Mozilla's CA Bundle."
 description = "Python package for providing Mozilla's CA Bundle."
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
-    {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"},
-    {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"},
+    {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"},
+    {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"},
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -306,13 +306,13 @@ files = [
 
 
 [[package]]
 [[package]]
 name = "click"
 name = "click"
-version = "8.1.5"
+version = "8.1.6"
 description = "Composable command line interface toolkit"
 description = "Composable command line interface toolkit"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
-    {file = "click-8.1.5-py3-none-any.whl", hash = "sha256:e576aa487d679441d7d30abb87e1b43d24fc53bffb8758443b1a9e1cee504548"},
-    {file = "click-8.1.5.tar.gz", hash = "sha256:4be4b1af8d665c6d942909916d31a213a106800c47d0eeba73d34da3cbc11367"},
+    {file = "click-8.1.6-py3-none-any.whl", hash = "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5"},
+    {file = "click-8.1.6.tar.gz", hash = "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd"},
 ]
 ]
 
 
 [package.dependencies]
 [package.dependencies]
@@ -466,13 +466,13 @@ tests = ["asttokens", "littleutils", "pytest", "rich"]
 
 
 [[package]]
 [[package]]
 name = "fastapi"
 name = "fastapi"
-version = "0.100.0"
+version = "0.100.1"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
 description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
-    {file = "fastapi-0.100.0-py3-none-any.whl", hash = "sha256:271662daf986da8fa98dc2b7c7f61c4abdfdccfb4786d79ed8b2878f172c6d5f"},
-    {file = "fastapi-0.100.0.tar.gz", hash = "sha256:acb5f941ea8215663283c10018323ba7ea737c571b67fc7e88e9469c7eb1d12e"},
+    {file = "fastapi-0.100.1-py3-none-any.whl", hash = "sha256:ec6dd52bfc4eff3063cfcd0713b43c87640fefb2687bbbe3d8a08d94049cdf32"},
+    {file = "fastapi-0.100.1.tar.gz", hash = "sha256:522700d7a469e4a973d92321ab93312448fbe20fca9c8da97effc7e7bc56df23"},
 ]
 ]
 
 
 [package.dependencies]
 [package.dependencies]
@@ -503,45 +503,45 @@ test = ["pytest"]
 
 
 [[package]]
 [[package]]
 name = "fonttools"
 name = "fonttools"
-version = "4.41.0"
+version = "4.42.0"
 description = "Tools to manipulate font files"
 description = "Tools to manipulate font files"
 optional = true
 optional = true
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
-    {file = "fonttools-4.41.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ba2a367ff478cd108d5319c0dc4fd4eb4ea3476dbfc45b00c45718e889cd9463"},
-    {file = "fonttools-4.41.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:69178674505ec81adf4af2a3bbacd0cb9a37ba7831bc3fca307f80e48ab2767b"},
-    {file = "fonttools-4.41.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:86edb95c4d1fe4fae2111d7e0c10c6e42b7790b377bcf1952303469eee5b52bb"},
-    {file = "fonttools-4.41.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50f8bdb421270f71b54695c62785e300fab4bb6127be40bf9f3084962a0c3adb"},
-    {file = "fonttools-4.41.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c890061915e95b619c1d3cc3c107c6fb021406b701c0c24b03e74830d522f210"},
-    {file = "fonttools-4.41.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b329ae7ce971b5c4148d6cdb8119c0ce4587265b2330d4f2f3776ef851bee020"},
-    {file = "fonttools-4.41.0-cp310-cp310-win32.whl", hash = "sha256:bc9e7b1e268be7a23fc66471b615c324e99c5db39ce8c49dd6dd8e962c7bc1b8"},
-    {file = "fonttools-4.41.0-cp310-cp310-win_amd64.whl", hash = "sha256:f3fe90dfb297bd8265238c06787911cd81c2cb89ac5b13e1c911928bdabfce0f"},
-    {file = "fonttools-4.41.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e38bd91eae257f36c2b7245c0278e9cd9d754f3a66b8d2b548c623ba66e387b6"},
-    {file = "fonttools-4.41.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:415cf7c806a3f56fb280dadcf3c92c85c0415e75665ca957b4a2a2e39c17a5c9"},
-    {file = "fonttools-4.41.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:381558eafffc1432d08ca58063e71c7376ecaae48e9318354a90a1049a644845"},
-    {file = "fonttools-4.41.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ee75b8ca48f6c48af25e967dce995ef94e46872b35c7d454b983c62c9c7006d"},
-    {file = "fonttools-4.41.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d45f28c20bb67dee0f4a4caae709f40b0693d764b7b2bf2d58890f36b1bfcef0"},
-    {file = "fonttools-4.41.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5448a87f6ed57ed844b64a05d3792827af584a8584613f6289867f4e77eb603b"},
-    {file = "fonttools-4.41.0-cp311-cp311-win32.whl", hash = "sha256:69dbe0154e15b68dd671441ea8f23dad87488b24a6e650d45958f4722819a443"},
-    {file = "fonttools-4.41.0-cp311-cp311-win_amd64.whl", hash = "sha256:ea879afd1d6189fca02a85a7868560c9bb8415dccff6b7ae6d81e4f06b3ab30d"},
-    {file = "fonttools-4.41.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8f602dd5bcde7e4241419924f23c6f0d66723dd5408a58c3a2f781745c693f45"},
-    {file = "fonttools-4.41.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:06eac087ea55b3ebb2207d93b5ac56c847163899f05f5a77e1910f688fe10030"},
-    {file = "fonttools-4.41.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7e22d0144d735f6c7df770509b8c0c33414bf460df0d5dddc98a159e5dbb10eb"},
-    {file = "fonttools-4.41.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19d461c801b8904d201c6c38a99bfcfef673bfdfe0c7f026f582ef78896434e0"},
-    {file = "fonttools-4.41.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:72d40a32d6443871ea0d147813caad58394b48729dfa4fbc45dcaac54f9506f2"},
-    {file = "fonttools-4.41.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0614b6348866092d00df3dfb37e037fc06412ca67087de361a2777ea5ed62c16"},
-    {file = "fonttools-4.41.0-cp38-cp38-win32.whl", hash = "sha256:e43f6c7f9ba4f9d29edee530e45f9aa162872ec9549398b85971477a99f2a806"},
-    {file = "fonttools-4.41.0-cp38-cp38-win_amd64.whl", hash = "sha256:eb9dfa87152bd97019adc387b2f29ef6af601de4386f36570ca537ace96d96ed"},
-    {file = "fonttools-4.41.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d2dae84a3d0f76884a6102c62f2795b2d6602c2c95cfcce74c8a590b6200e533"},
-    {file = "fonttools-4.41.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc3324e4159e6d1f55c3615b4c1c211f87cc96cc0cc7c946c8447dc1319f2e9d"},
-    {file = "fonttools-4.41.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c654b1facf1f3b742e4d9b2dcdf0fa867b1f007b1b4981cc58a75ef5dca2a3c"},
-    {file = "fonttools-4.41.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560ea1a604c927399f36742abf342a4c5f3fee8e8e8a484b774dfe9630bd9a91"},
-    {file = "fonttools-4.41.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9387b09694fbf8ac7dcf887069068f81fb4124d05e09557ac7daabfbec1744bd"},
-    {file = "fonttools-4.41.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:465d0f24bf4f75160f441793b55076b7a080a57d3a1f738390af2c20bee24fbb"},
-    {file = "fonttools-4.41.0-cp39-cp39-win32.whl", hash = "sha256:841c491fa3e9c54e8f9cd5dae059e88f45e086aea090c28be9d42f59c8b99e01"},
-    {file = "fonttools-4.41.0-cp39-cp39-win_amd64.whl", hash = "sha256:efd59e83223cb77952997fb850c7a7c2a958c9af0642060f536722c2a9e9d53b"},
-    {file = "fonttools-4.41.0-py3-none-any.whl", hash = "sha256:5b1c2b21b40229166a864f2b0aec06d37f0a204066deb1734c93370e0c76339d"},
-    {file = "fonttools-4.41.0.tar.gz", hash = "sha256:6faff25991dec48f8cac882055a09ae1a29fd15bc160bc3d663e789e994664c2"},
+    {file = "fonttools-4.42.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9c456d1f23deff64ffc8b5b098718e149279abdea4d8692dba69172fb6a0d597"},
+    {file = "fonttools-4.42.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:150122ed93127a26bc3670ebab7e2add1e0983d30927733aec327ebf4255b072"},
+    {file = "fonttools-4.42.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48e82d776d2e93f88ca56567509d102266e7ab2fb707a0326f032fe657335238"},
+    {file = "fonttools-4.42.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58c1165f9b2662645de9b19a8c8bdd636b36294ccc07e1b0163856b74f10bafc"},
+    {file = "fonttools-4.42.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2d6dc3fa91414ff4daa195c05f946e6a575bd214821e26d17ca50f74b35b0fe4"},
+    {file = "fonttools-4.42.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fae4e801b774cc62cecf4a57b1eae4097903fced00c608d9e2bc8f84cd87b54a"},
+    {file = "fonttools-4.42.0-cp310-cp310-win32.whl", hash = "sha256:b8600ae7dce6ec3ddfb201abb98c9d53abbf8064d7ac0c8a0d8925e722ccf2a0"},
+    {file = "fonttools-4.42.0-cp310-cp310-win_amd64.whl", hash = "sha256:57b68eab183fafac7cd7d464a7bfa0fcd4edf6c67837d14fb09c1c20516cf20b"},
+    {file = "fonttools-4.42.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0a1466713e54bdbf5521f2f73eebfe727a528905ff5ec63cda40961b4b1eea95"},
+    {file = "fonttools-4.42.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3fb2a69870bfe143ec20b039a1c8009e149dd7780dd89554cc8a11f79e5de86b"},
+    {file = "fonttools-4.42.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae881e484702efdb6cf756462622de81d4414c454edfd950b137e9a7352b3cb9"},
+    {file = "fonttools-4.42.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27ec3246a088555629f9f0902f7412220c67340553ca91eb540cf247aacb1983"},
+    {file = "fonttools-4.42.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8ece1886d12bb36c48c00b2031518877f41abae317e3a55620d38e307d799b7e"},
+    {file = "fonttools-4.42.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:10dac980f2b975ef74532e2a94bb00e97a95b4595fb7f98db493c474d5f54d0e"},
+    {file = "fonttools-4.42.0-cp311-cp311-win32.whl", hash = "sha256:83b98be5d291e08501bd4fc0c4e0f8e6e05b99f3924068b17c5c9972af6fff84"},
+    {file = "fonttools-4.42.0-cp311-cp311-win_amd64.whl", hash = "sha256:e35bed436726194c5e6e094fdfb423fb7afaa0211199f9d245e59e11118c576c"},
+    {file = "fonttools-4.42.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c36c904ce0322df01e590ba814d5d69e084e985d7e4c2869378671d79662a7d4"},
+    {file = "fonttools-4.42.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d54e600a2bcfa5cdaa860237765c01804a03b08404d6affcd92942fa7315ffba"},
+    {file = "fonttools-4.42.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01cfe02416b6d416c5c8d15e30315cbcd3e97d1b50d3b34b0ce59f742ef55258"},
+    {file = "fonttools-4.42.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f81ed9065b4bd3f4f3ce8e4873cd6a6b3f4e92b1eddefde35d332c6f414acc3"},
+    {file = "fonttools-4.42.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:685a4dd6cf31593b50d6d441feb7781a4a7ef61e19551463e14ed7c527b86f9f"},
+    {file = "fonttools-4.42.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:329341ba3d86a36e482610db56b30705384cb23bd595eac8cbb045f627778e9d"},
+    {file = "fonttools-4.42.0-cp38-cp38-win32.whl", hash = "sha256:4655c480a1a4d706152ff54f20e20cf7609084016f1df3851cce67cef768f40a"},
+    {file = "fonttools-4.42.0-cp38-cp38-win_amd64.whl", hash = "sha256:6bd7e4777bff1dcb7c4eff4786998422770f3bfbef8be401c5332895517ba3fa"},
+    {file = "fonttools-4.42.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9b55d2a3b360e0c7fc5bd8badf1503ca1c11dd3a1cd20f2c26787ffa145a9c7"},
+    {file = "fonttools-4.42.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0df8ef75ba5791e873c9eac2262196497525e3f07699a2576d3ab9ddf41cb619"},
+    {file = "fonttools-4.42.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cd2363ea7728496827658682d049ffb2e98525e2247ca64554864a8cc945568"},
+    {file = "fonttools-4.42.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d40673b2e927f7cd0819c6f04489dfbeb337b4a7b10fc633c89bf4f34ecb9620"},
+    {file = "fonttools-4.42.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c8bf88f9e3ce347c716921804ef3a8330cb128284eb6c0b6c4b3574f3c580023"},
+    {file = "fonttools-4.42.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:703101eb0490fae32baf385385d47787b73d9ea55253df43b487c89ec767e0d7"},
+    {file = "fonttools-4.42.0-cp39-cp39-win32.whl", hash = "sha256:f0290ea7f9945174bd4dfd66e96149037441eb2008f3649094f056201d99e293"},
+    {file = "fonttools-4.42.0-cp39-cp39-win_amd64.whl", hash = "sha256:ae7df0ae9ee2f3f7676b0ff6f4ebe48ad0acaeeeaa0b6839d15dbf0709f2c5ef"},
+    {file = "fonttools-4.42.0-py3-none-any.whl", hash = "sha256:dfe7fa7e607f7e8b58d0c32501a3a7cac148538300626d1b930082c90ae7f6bd"},
+    {file = "fonttools-4.42.0.tar.gz", hash = "sha256:614b1283dca88effd20ee48160518e6de275ce9b5456a3134d5f235523fc5065"},
 ]
 ]
 
 
 [package.extras]
 [package.extras]
@@ -688,25 +688,6 @@ files = [
     {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
     {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
 ]
 ]
 
 
-[[package]]
-name = "importlib-metadata"
-version = "6.8.0"
-description = "Read metadata from Python packages"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"},
-    {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"},
-]
-
-[package.dependencies]
-zipp = ">=0.5"
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-perf = ["ipython"]
-testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
-
 [[package]]
 [[package]]
 name = "importlib-resources"
 name = "importlib-resources"
 version = "6.0.0"
 version = "6.0.0"
@@ -860,13 +841,13 @@ files = [
 
 
 [[package]]
 [[package]]
 name = "markdown2"
 name = "markdown2"
-version = "2.4.9"
+version = "2.4.10"
 description = "A fast and complete Python implementation of Markdown"
 description = "A fast and complete Python implementation of Markdown"
 optional = false
 optional = false
 python-versions = ">=3.5, <4"
 python-versions = ">=3.5, <4"
 files = [
 files = [
-    {file = "markdown2-2.4.9-py2.py3-none-any.whl", hash = "sha256:58e1789543f47cdd4197760b04771671411f07699f958ad40a4b56c55ba3e668"},
-    {file = "markdown2-2.4.9.tar.gz", hash = "sha256:7a1742dade7ec29b90f5c1d5a820eb977eee597e314c428e6b0aa7929417cd1b"},
+    {file = "markdown2-2.4.10-py2.py3-none-any.whl", hash = "sha256:e6105800483783831f5dc54f827aa5b44eb137ecef5a70293d8ecfbb4109ecc6"},
+    {file = "markdown2-2.4.10.tar.gz", hash = "sha256:cdba126d90dc3aef6f4070ac342f974d63f415678959329cc7909f96cc235d72"},
 ]
 ]
 
 
 [package.extras]
 [package.extras]
@@ -1351,13 +1332,13 @@ files = [
 
 
 [[package]]
 [[package]]
 name = "pycodestyle"
 name = "pycodestyle"
-version = "2.10.0"
+version = "2.11.0"
 description = "Python style guide checker"
 description = "Python style guide checker"
 optional = false
 optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.8"
 files = [
 files = [
-    {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"},
-    {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"},
+    {file = "pycodestyle-2.11.0-py2.py3-none-any.whl", hash = "sha256:5d1013ba8dc7895b548be5afb05740ca82454fd899971563d2ef625d090326f8"},
+    {file = "pycodestyle-2.11.0.tar.gz", hash = "sha256:259bcc17857d8a8b3b4a2327324b79e5f020a13c16074670f9c8c8f872ea76d0"},
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -1373,18 +1354,18 @@ files = [
 
 
 [[package]]
 [[package]]
 name = "pydantic"
 name = "pydantic"
-version = "2.0.2"
+version = "2.1.1"
 description = "Data validation using Python type hints"
 description = "Data validation using Python type hints"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
-    {file = "pydantic-2.0.2-py3-none-any.whl", hash = "sha256:f5581e0c79b2ec2fa25a9d30d766629811cdda022107fa73d022ab5578873ae3"},
-    {file = "pydantic-2.0.2.tar.gz", hash = "sha256:b802f5245b8576315fe619e5989fd083448fa1258638ef9dac301ca60878396d"},
+    {file = "pydantic-2.1.1-py3-none-any.whl", hash = "sha256:43bdbf359d6304c57afda15c2b95797295b702948082d4c23851ce752f21da70"},
+    {file = "pydantic-2.1.1.tar.gz", hash = "sha256:22d63db5ce4831afd16e7c58b3192d3faf8f79154980d9397d9867254310ba4b"},
 ]
 ]
 
 
 [package.dependencies]
 [package.dependencies]
 annotated-types = ">=0.4.0"
 annotated-types = ">=0.4.0"
-pydantic-core = "2.1.2"
+pydantic-core = "2.4.0"
 typing-extensions = ">=4.6.1"
 typing-extensions = ">=4.6.1"
 
 
 [package.extras]
 [package.extras]
@@ -1392,112 +1373,112 @@ email = ["email-validator (>=2.0.0)"]
 
 
 [[package]]
 [[package]]
 name = "pydantic-core"
 name = "pydantic-core"
-version = "2.1.2"
+version = "2.4.0"
 description = ""
 description = ""
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
-    {file = "pydantic_core-2.1.2-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:b4815720c266e832b20e27a7a5f3772bb09fdedb31a9a34bab7b49d98967ef5a"},
-    {file = "pydantic_core-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8884a1dbfc5cb8c54b48446ca916d4577c1f4d901126091e4ab25d00194e065f"},
-    {file = "pydantic_core-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74a33aa69d476773230396396afb8e11908f8dafdcfd422e746770599a3f889d"},
-    {file = "pydantic_core-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af832edd384755826e494ffdcf1fdda86e4babc42a0b18d342943fb18181040e"},
-    {file = "pydantic_core-2.1.2-cp310-cp310-manylinux_2_24_armv7l.whl", hash = "sha256:017700236ea2e7afbef5d3803559c80bd8720306778ebd49268de7ce9972e83e"},
-    {file = "pydantic_core-2.1.2-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:c2d00a96fdf26295c6f25eaf9e4a233f353146a73713cd97a5f5dc6090c3aef2"},
-    {file = "pydantic_core-2.1.2-cp310-cp310-manylinux_2_24_s390x.whl", hash = "sha256:2575664f0a559a7b951a518f6f34c23cab7190f34f8220b8c8218c4f403147ee"},
-    {file = "pydantic_core-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:24c3c9180a2d19d640bacc2d00f497a9a1f2abadb2a9ee201b56bb03bc5343bd"},
-    {file = "pydantic_core-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:88a56f0f6d020b4d17641f4b4d1f9540a536d4146768d059c430e97bdb485fc1"},
-    {file = "pydantic_core-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fa38a76e832743866aed6b715869757074b06357d1a260163ec26d84974245fe"},
-    {file = "pydantic_core-2.1.2-cp310-none-win32.whl", hash = "sha256:a772c652603855d7180015849d483a1f539351a263bb9b81bfe85193a33ce124"},
-    {file = "pydantic_core-2.1.2-cp310-none-win_amd64.whl", hash = "sha256:b4673d1f29487608d613ebcc5caa99ba15eb58450a7449fb6d800f29d90bebc1"},
-    {file = "pydantic_core-2.1.2-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:76c9c55462740d728b344e3a087775846516c3fee31ec56e2075faa7cfcafcbf"},
-    {file = "pydantic_core-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb854ec52e6e2e05b83d647695f4d913452fdd45a3dfa8233d7dab5967b3908f"},
-    {file = "pydantic_core-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ac140d54da366672f6b91f9a1e8e2d4e7e72720143353501ae886d3fca03272"},
-    {file = "pydantic_core-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:818f5cb1b209ab1295087c45717178f4bbbd2bd7eda421f7a119e7b9b736a3cb"},
-    {file = "pydantic_core-2.1.2-cp311-cp311-manylinux_2_24_armv7l.whl", hash = "sha256:db4564aea8b3cb6cf1e5f3fd80f1ced73a255d492396d1bd8abd688795b34d63"},
-    {file = "pydantic_core-2.1.2-cp311-cp311-manylinux_2_24_ppc64le.whl", hash = "sha256:2ca2d2d5ab65fb40dd05259965006edcc62a9d9b30102737c0a6f45bcbd254e8"},
-    {file = "pydantic_core-2.1.2-cp311-cp311-manylinux_2_24_s390x.whl", hash = "sha256:7c7ad8958aadfbcd664078002246796ecd5566b64b22f6af4fd1bbcec6bf8f60"},
-    {file = "pydantic_core-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:080a7af828388284a68ad7d3d3eac3bcfff6a580292849aff087e7d556ec42d4"},
-    {file = "pydantic_core-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bad7029fb2251c1ac7d3acdd607e540d40d137a7d43a5e5acdcfdbd38db3fc0a"},
-    {file = "pydantic_core-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1635a37137fafbc6ee0a8c879857e05b30b1aabaa927e653872b71f1501b1502"},
-    {file = "pydantic_core-2.1.2-cp311-none-win32.whl", hash = "sha256:eb4301f009a44bb5db5edfe4e51a8175a4112b566baec07f4af8b1f8cb4649a2"},
-    {file = "pydantic_core-2.1.2-cp311-none-win_amd64.whl", hash = "sha256:ebf583f4d9b52abd15cc59e5f6eeca7e3e9741c6ea62d8711c00ac3acb067875"},
-    {file = "pydantic_core-2.1.2-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:90b06bb47e60173d24c7cb79670aa8dd6081797290353b9d3c66d3a23e88eb34"},
-    {file = "pydantic_core-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e5761ce986ec709897b1b965fad9743f301500434bea3cbab2b6e662571580f"},
-    {file = "pydantic_core-2.1.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b9f8bf1d7008a58fbb6eb334dc6e2f2905400cced8dadb46c4ca28f005a8562"},
-    {file = "pydantic_core-2.1.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a014ee88980013d192a718cbb88e8cea20acd3afad69bc6d15672d05a49cdb6"},
-    {file = "pydantic_core-2.1.2-cp312-cp312-manylinux_2_24_armv7l.whl", hash = "sha256:8125152b03dd91deca5afe5b933a1994b39405adf6be2fe8dce3632319283f85"},
-    {file = "pydantic_core-2.1.2-cp312-cp312-manylinux_2_24_ppc64le.whl", hash = "sha256:dc737506b4a0ba2922a2626fc6d620ce50a46aebd0fe2fbcad1b93bbdd8c7e78"},
-    {file = "pydantic_core-2.1.2-cp312-cp312-manylinux_2_24_s390x.whl", hash = "sha256:bb471ea8650796060afc99909d9b75da583d317e52f660faf64c45f70b3bf1e2"},
-    {file = "pydantic_core-2.1.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b1fad38db1744d27061df516e59c5025b09b0a50a337c04e6eebdbddc18951bc"},
-    {file = "pydantic_core-2.1.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:94d368af9e6563de6e7170a74710a2cbace7a1e9c8e507d9e3ac34c7065d7ae3"},
-    {file = "pydantic_core-2.1.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bd95d223de5162811a7b36c73d48eac4fee03b075132f3a1b73c132ce157a60c"},
-    {file = "pydantic_core-2.1.2-cp312-none-win32.whl", hash = "sha256:cd62f73830d4715bc643ae39de0bd4fb9c81d6d743530074da91e77a2cccfe67"},
-    {file = "pydantic_core-2.1.2-cp312-none-win_amd64.whl", hash = "sha256:51968887d6bd1eaa7fc7759701ea8ccb470c04654beaa8ede6835b0533f206a9"},
-    {file = "pydantic_core-2.1.2-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:7ff6bfe63f447a509ed4d368a7f4ba6a7abc03bc4744fc3fb30f2ffab73f3821"},
-    {file = "pydantic_core-2.1.2-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:4e67f9b9dfda2e42b39459cbf99d319ccb90da151e35cead3521975b2afbf673"},
-    {file = "pydantic_core-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b815a769b019dd96be6571096f246b74f63330547e9b30244c51b4a2eb0277fc"},
-    {file = "pydantic_core-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aff436c23c68449601b3fba7075b4f37ef8fbb893c8c1ed3ef898f090332b1e"},
-    {file = "pydantic_core-2.1.2-cp37-cp37m-manylinux_2_24_armv7l.whl", hash = "sha256:2ee3ae58f271851362f6c9b33e4c9f9e866557ec7d8c03dc091e9b5aa5566cec"},
-    {file = "pydantic_core-2.1.2-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:cf92dccca8f66e987f6c4378700447f82b79e86407912ab1ee06b16b82f05120"},
-    {file = "pydantic_core-2.1.2-cp37-cp37m-manylinux_2_24_s390x.whl", hash = "sha256:4663293a36a851a860b1299c50837914269fca127434911297dd39fea9667a01"},
-    {file = "pydantic_core-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c917f7a41d9d09b8b024a5d65cf37e5588ccdb6e610d2df565fb7186b1f3b1c"},
-    {file = "pydantic_core-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:06ae67547251135a1b3f8dd465797b13146295a3866bc12ddd73f7512787bb7c"},
-    {file = "pydantic_core-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4938b32c09dbcecbeb652327cb4a449b1ef1a1bf6c8fc2c8241aa6b8f6d63b54"},
-    {file = "pydantic_core-2.1.2-cp37-none-win32.whl", hash = "sha256:682ff9228c838018c47dfa89b3d84cca45f88cacde28807ab8296ec221862af4"},
-    {file = "pydantic_core-2.1.2-cp37-none-win_amd64.whl", hash = "sha256:6e3bcb4a9bc209a61ea2aceb7433ce2ece32c7e670b0c06848bf870c9b3e7d87"},
-    {file = "pydantic_core-2.1.2-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:2278ca0b0dfbcfb1e12fa58570916dc260dc72bee5e6e342debf5329d8204688"},
-    {file = "pydantic_core-2.1.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:87cff210af3258ca0c829e3ebc849d7981bfde23a99d6cb7a3c17a163b3dbad2"},
-    {file = "pydantic_core-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7684b5fb906b37e940c5df3f57118f32e033af5e4770e5ae2ae56fbd2fe1a30a"},
-    {file = "pydantic_core-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3747a4178139ebf3f19541285b2eb7c886890ca4eb7eec851578c02a13cc1385"},
-    {file = "pydantic_core-2.1.2-cp38-cp38-manylinux_2_24_armv7l.whl", hash = "sha256:e17056390068afd4583d88dcf4d4495764e4e2c7d756464468e0d21abcb8931e"},
-    {file = "pydantic_core-2.1.2-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:c720e55cef609d50418bdfdfb5c44a76efc020ae7455505788d0113c54c7df55"},
-    {file = "pydantic_core-2.1.2-cp38-cp38-manylinux_2_24_s390x.whl", hash = "sha256:b59a64c367f350873c40a126ffe9184d903d2126c701380b4b55753484df5948"},
-    {file = "pydantic_core-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68a2a767953c707d9575dcf14d8edee7930527ee0141a8bb612c22d1f1059f9a"},
-    {file = "pydantic_core-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4ae46769d9a7138d58cd190441cac14ce954010a0081f28462ed916c8e55a4f"},
-    {file = "pydantic_core-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fc909f62325a631e1401dd07dfc386986dbcac15f98c9ff2145d930678a9d25a"},
-    {file = "pydantic_core-2.1.2-cp38-none-win32.whl", hash = "sha256:b4038869ba1d8fa33863b4b1286ab07e6075a641ae269b865f94d7e10b3e800e"},
-    {file = "pydantic_core-2.1.2-cp38-none-win_amd64.whl", hash = "sha256:5948af62f323252d56acaec8ebfca5f15933f6b72f8dbe3bf21ee97b2d10e3f0"},
-    {file = "pydantic_core-2.1.2-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:8e6ce261ccb9a986953c4dce070327e4954f9dd4cd214746dfc70efbc713b6a1"},
-    {file = "pydantic_core-2.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d35d634d9d1ed280c87bc2a7a6217b8787eedc86f368fc2fa1c0c8c78f7d3c93"},
-    {file = "pydantic_core-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0be2e2812a43205728a06c9d0fd090432cd76a9bb5bff2bfcfdf8b0e27d51851"},
-    {file = "pydantic_core-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0eb54b11cd4fe0c6404611eef77086ade03eb1457e92910bbb4f3479efa3f79"},
-    {file = "pydantic_core-2.1.2-cp39-cp39-manylinux_2_24_armv7l.whl", hash = "sha256:087ddbb754575618a8832ee4ab52fe7eb332f502e2a56088b53dbeb5c4efdf9f"},
-    {file = "pydantic_core-2.1.2-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:b74906e01c7fc938ac889588ef438de812989817095c3c4904721f647d64a4d1"},
-    {file = "pydantic_core-2.1.2-cp39-cp39-manylinux_2_24_s390x.whl", hash = "sha256:60b7239206a2f61ad89c7518adfacb3ccd6662eaa07c5e437317aea2615a1f18"},
-    {file = "pydantic_core-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:be3419204952bbe9b72b90008977379c52f99ae1c6e640488de4be783c345d71"},
-    {file = "pydantic_core-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:804cf8f6a859620f8eb754c02f7770f61c3e9c519f8338c331d555b3d6976e3c"},
-    {file = "pydantic_core-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cbba32fb14e199d0493c6b9c44870dab0a9c37af9f0f729068459d1849279ffd"},
-    {file = "pydantic_core-2.1.2-cp39-none-win32.whl", hash = "sha256:6bf00f56a4468f5b03dadb672a5f1d24aea303d4ccffe8a0f548c9e36017edd3"},
-    {file = "pydantic_core-2.1.2-cp39-none-win_amd64.whl", hash = "sha256:ac462a28218ea7d592c7ad51b517558f4ac6565a4e53db7a4811eeaf9c9660b0"},
-    {file = "pydantic_core-2.1.2-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:047e782b9918f35ef534ced36f1fd2064f5581229b7a15e4d3177387a6b53134"},
-    {file = "pydantic_core-2.1.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c0213891898fa5b404cf3edf4797e3ac7819a0708ea5473fc6432a2aa27c189"},
-    {file = "pydantic_core-2.1.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0f481aaf0119f77b200e5a5e2799b3e14c015a317eaa948f42263908735cc9f"},
-    {file = "pydantic_core-2.1.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15eb4cb543ed36f6a4f16e3bee7aa7ed1c3757be95a3f3bbb2b82b9887131e0f"},
-    {file = "pydantic_core-2.1.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:ef71e73a81a4cd7e87c93e8ff0170140fd93ba33b0f61e83da3f55f6e0a84fb4"},
-    {file = "pydantic_core-2.1.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:840238c845b0f80777151fef0003088ab91c6f7b3467edaff4932b425c4e3c3f"},
-    {file = "pydantic_core-2.1.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7648e48ba263ca0a8a2dc55a60a219c9133fb101ba52c89a14a29fb3d4322ca3"},
-    {file = "pydantic_core-2.1.2-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:8eb4e2b71562375609c66a79f89acd4fe95c5cba23473d04952c8b14b6f908f5"},
-    {file = "pydantic_core-2.1.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056afea59651c4e47ec6dadbb77ccae4742c059a3d12bc1c0e393d189d2970d"},
-    {file = "pydantic_core-2.1.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46cd323371aa7e4053010ccdb94063a4273aa9e5dbe97f8a1147faa769de8d8d"},
-    {file = "pydantic_core-2.1.2-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa39499625239da4ec960cf4fc66b023929b24cc77fb8520289cfdb3c1986428"},
-    {file = "pydantic_core-2.1.2-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f5de2d4167fd4bc5ad205fb7297e25867b8e335ca08d64ed7a561d2955a2c32d"},
-    {file = "pydantic_core-2.1.2-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:9a5fba9168fc27805553760fa8198db46eef83bf52b4e87ebbe1333b823d0e70"},
-    {file = "pydantic_core-2.1.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:e68a404fad8493989d6f07b7b9e066f1d2524d7cb64db2d4e9a84c920032c67f"},
-    {file = "pydantic_core-2.1.2-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:1a5c4475510d1a9cc1458a26cfc21442223e52ce9adb640775c38739315d03c7"},
-    {file = "pydantic_core-2.1.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0681472245ef182554208a25d16884c84f1c5a69f14e6169b88932e5da739a1c"},
-    {file = "pydantic_core-2.1.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7fd334b40c5e13a97becfcaba314de0dcc6f7fe21ec8f992139bcc64700e9dc"},
-    {file = "pydantic_core-2.1.2-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7345b1741bf66a9d8ed0ec291c3eabd534444e139e1ea6db5742ac9fd3be2530"},
-    {file = "pydantic_core-2.1.2-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0855cf8b760fb40f97f0226cb527c8a94a2ab9d8179628beae20d6939aaeacb0"},
-    {file = "pydantic_core-2.1.2-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d281a10837d98db997c0247f45d138522c91ce30cf3ae7a6afdb5e709707d360"},
-    {file = "pydantic_core-2.1.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:82e09f27edab289187dd924d4d93f2a35f21aa969699b2504aa643da7fbfeff9"},
-    {file = "pydantic_core-2.1.2-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:aa54902fa51f7d921ba80923cf1c7ff3dce796a7903300bd8824deb90e357744"},
-    {file = "pydantic_core-2.1.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b9a5fc4058d64c9c826684dcdb43891c1b474a4a88dcf8dfc3e1fb5889496f8"},
-    {file = "pydantic_core-2.1.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:817681d111cb65f07d46496eafec815f48e1aff37713b73135a0a9eb4d3610ab"},
-    {file = "pydantic_core-2.1.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b5d37aedea5963f2097bddbcdb255483191646a52d40d8bb66d61c190fcac91"},
-    {file = "pydantic_core-2.1.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f2de65752fff248319bcd3b29da24e205fa505607539fcd4acc4037355175b63"},
-    {file = "pydantic_core-2.1.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:a8b9c2cc4c5f8169b943d24be4bd1548fe81c016d704126e3a3124a2fc164885"},
-    {file = "pydantic_core-2.1.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:f7bcdf70c8b6e70be11c78d3c00b80a24cccfb408128f23e91ec3019bed1ecc1"},
-    {file = "pydantic_core-2.1.2.tar.gz", hash = "sha256:d2c790f0d928b672484eac4f5696dd0b78f3d6d148a641ea196eb49c0875e30a"},
+    {file = "pydantic_core-2.4.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:2ca4687dd996bde7f3c420def450797feeb20dcee2b9687023e3323c73fc14a2"},
+    {file = "pydantic_core-2.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:782fced7d61469fd1231b184a80e4f2fa7ad54cd7173834651a453f96f29d673"},
+    {file = "pydantic_core-2.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6213b471b68146af97b8551294e59e7392c2117e28ffad9c557c65087f4baee3"},
+    {file = "pydantic_core-2.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63797499a219d8e81eb4e0c42222d0a4c8ec896f5c76751d4258af95de41fdf1"},
+    {file = "pydantic_core-2.4.0-cp310-cp310-manylinux_2_24_armv7l.whl", hash = "sha256:0455876d575a35defc4da7e0a199596d6c773e20d3d42fa1fc29f6aa640369ed"},
+    {file = "pydantic_core-2.4.0-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:8c938c96294d983dcf419b54dba2d21056959c22911d41788efbf949a29ae30d"},
+    {file = "pydantic_core-2.4.0-cp310-cp310-manylinux_2_24_s390x.whl", hash = "sha256:878a5017d93e776c379af4e7b20f173c82594d94fa073059bcc546789ad50bf8"},
+    {file = "pydantic_core-2.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:69159afc2f2dc43285725f16143bc5df3c853bc1cb7df6021fce7ef1c69e8171"},
+    {file = "pydantic_core-2.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:54df7df399b777c1fd144f541c95d351b3aa110535a6810a6a569905d106b6f3"},
+    {file = "pydantic_core-2.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e412607ca89a0ced10758dfb8f9adcc365ce4c1c377e637c01989a75e9a9ec8a"},
+    {file = "pydantic_core-2.4.0-cp310-none-win32.whl", hash = "sha256:853f103e2b9a58832fdd08a587a51de8b552ae90e1a5d167f316b7eabf8d7dde"},
+    {file = "pydantic_core-2.4.0-cp310-none-win_amd64.whl", hash = "sha256:3ba2c9c94a9176f6321a879c8b864d7c5b12d34f549a4c216c72ce213d7d953c"},
+    {file = "pydantic_core-2.4.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:a8b7acd04896e8f161e1500dc5f218017db05c1d322f054e89cbd089ce5d0071"},
+    {file = "pydantic_core-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:16468bd074fa4567592d3255bf25528ed41e6b616d69bf07096bdb5b66f947d1"},
+    {file = "pydantic_core-2.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cba5ad5eef02c86a1f3da00544cbc59a510d596b27566479a7cd4d91c6187a11"},
+    {file = "pydantic_core-2.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7206e41e04b443016e930e01685bab7a308113c0b251b3f906942c8d4b48fcb"},
+    {file = "pydantic_core-2.4.0-cp311-cp311-manylinux_2_24_armv7l.whl", hash = "sha256:c1375025f0bfc9155286ebae8eecc65e33e494c90025cda69e247c3ccd2bab00"},
+    {file = "pydantic_core-2.4.0-cp311-cp311-manylinux_2_24_ppc64le.whl", hash = "sha256:3534118289e33130ed3f1cc487002e8d09b9f359be48b02e9cd3de58ce58fba9"},
+    {file = "pydantic_core-2.4.0-cp311-cp311-manylinux_2_24_s390x.whl", hash = "sha256:94d2b36a74623caab262bf95f0e365c2c058396082bd9d6a9e825657d0c1e7fa"},
+    {file = "pydantic_core-2.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af24ad4fbaa5e4a2000beae0c3b7fd1c78d7819ab90f9370a1cfd8998e3f8a3c"},
+    {file = "pydantic_core-2.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bf10963d8aed8bbe0165b41797c9463d4c5c8788ae6a77c68427569be6bead41"},
+    {file = "pydantic_core-2.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68199ada7c310ddb8c76efbb606a0de656b40899388a7498954f423e03fc38be"},
+    {file = "pydantic_core-2.4.0-cp311-none-win32.whl", hash = "sha256:6f855bcc96ed3dd56da7373cfcc9dcbabbc2073cac7f65c185772d08884790ce"},
+    {file = "pydantic_core-2.4.0-cp311-none-win_amd64.whl", hash = "sha256:de39eb3bab93a99ddda1ac1b9aa331b944d8bcc4aa9141148f7fd8ee0299dafc"},
+    {file = "pydantic_core-2.4.0-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:f773b39780323a0499b53ebd91a28ad11cde6705605d98d999dfa08624caf064"},
+    {file = "pydantic_core-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a297c0d6c61963c5c3726840677b798ca5b7dfc71bc9c02b9a4af11d23236008"},
+    {file = "pydantic_core-2.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:546064c55264156b973b5e65e5fafbe5e62390902ce3cf6b4005765505e8ff56"},
+    {file = "pydantic_core-2.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36ba9e728588588f0196deaf6751b9222492331b5552f865a8ff120869d372e0"},
+    {file = "pydantic_core-2.4.0-cp312-cp312-manylinux_2_24_armv7l.whl", hash = "sha256:57a53a75010c635b3ad6499e7721eaa3b450e03f6862afe2dbef9c8f66e46ec8"},
+    {file = "pydantic_core-2.4.0-cp312-cp312-manylinux_2_24_ppc64le.whl", hash = "sha256:4b262bbc13022f2097c48a21adcc360a81d83dc1d854c11b94953cd46d7d3c07"},
+    {file = "pydantic_core-2.4.0-cp312-cp312-manylinux_2_24_s390x.whl", hash = "sha256:01947ad728f426fa07fcb26457ebf90ce29320259938414bc0edd1476e75addb"},
+    {file = "pydantic_core-2.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b2799c2eaf182769889761d4fb4d78b82bc47dae833799fedbf69fc7de306faa"},
+    {file = "pydantic_core-2.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a08fd490ba36d1fbb2cd5dcdcfb9f3892deb93bd53456724389135712b5fc735"},
+    {file = "pydantic_core-2.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1e8a7c62d15a5c4b307271e4252d76ebb981d6251c6ecea4daf203ef0179ea4f"},
+    {file = "pydantic_core-2.4.0-cp312-none-win32.whl", hash = "sha256:9206c14a67c38de7b916e486ae280017cf394fa4b1aa95cfe88621a4e1d79725"},
+    {file = "pydantic_core-2.4.0-cp312-none-win_amd64.whl", hash = "sha256:884235507549a6b2d3c4113fb1877ae263109e787d9e0eb25c35982ab28d0399"},
+    {file = "pydantic_core-2.4.0-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:4cbe929efa77a806e8f1a97793f2dc3ea3475ae21a9ed0f37c21320fe93f6f50"},
+    {file = "pydantic_core-2.4.0-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:9137289de8fe845c246a8c3482dd0cb40338846ba683756d8f489a4bd8fddcae"},
+    {file = "pydantic_core-2.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5d8e764b5646623e57575f624f8ebb8f7a9f7fd1fae682ef87869ca5fec8dcf"},
+    {file = "pydantic_core-2.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fba0aff4c407d0274e43697e785bcac155ad962be57518d1c711f45e72da70f"},
+    {file = "pydantic_core-2.4.0-cp37-cp37m-manylinux_2_24_armv7l.whl", hash = "sha256:30527d173e826f2f7651f91c821e337073df1555e3b5a0b7b1e2c39e26e50678"},
+    {file = "pydantic_core-2.4.0-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:bd7d1dde70ff3e09e4bc7a1cbb91a7a538add291bfd5b3e70ef1e7b45192440f"},
+    {file = "pydantic_core-2.4.0-cp37-cp37m-manylinux_2_24_s390x.whl", hash = "sha256:72f1216ca8cef7b8adacd4c4c6b89c3b0c4f97503197f5284c80f36d6e4edd30"},
+    {file = "pydantic_core-2.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b013c7861a7c7bfcec48fd709513fea6f9f31727e7a0a93ca0dd12e056740717"},
+    {file = "pydantic_core-2.4.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:478f5f6d7e32bd4a04d102160efb2d389432ecf095fe87c555c0a6fc4adfc1a4"},
+    {file = "pydantic_core-2.4.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d9610b47b5fe4aacbbba6a9cb5f12cbe864eec99dbfed5710bd32ef5dd8a5d5b"},
+    {file = "pydantic_core-2.4.0-cp37-none-win32.whl", hash = "sha256:ff246c0111076c8022f9ba325c294f2cb5983403506989253e04dbae565e019b"},
+    {file = "pydantic_core-2.4.0-cp37-none-win_amd64.whl", hash = "sha256:d0c2b713464a8e263a243ae7980d81ce2de5ac59a9f798a282e44350b42dc516"},
+    {file = "pydantic_core-2.4.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:12ef6838245569fd60a179fade81ca4b90ae2fa0ef355d616f519f7bb27582db"},
+    {file = "pydantic_core-2.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49db206eb8fdc4b4f30e6e3e410584146d813c151928f94ec0db06c4f2595538"},
+    {file = "pydantic_core-2.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a507d7fa44688bbac76af6521e488b3da93de155b9cba6f2c9b7833ce243d59"},
+    {file = "pydantic_core-2.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe18407a4d000c568182ce5388bbbedeb099896904e43fc14eee76cfae6dec5"},
+    {file = "pydantic_core-2.4.0-cp38-cp38-manylinux_2_24_armv7l.whl", hash = "sha256:fa8e48001b39d54d97d7b380a0669fa99fc0feeb972e35a2d677ba59164a9a22"},
+    {file = "pydantic_core-2.4.0-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:394f12a2671ff8c4dfa2e85be6c08be0651ad85bc1e6aa9c77c21671baaf28cd"},
+    {file = "pydantic_core-2.4.0-cp38-cp38-manylinux_2_24_s390x.whl", hash = "sha256:2f9ea0355f90db2a76af530245fa42f04d98f752a1236ed7c6809ec484560d5b"},
+    {file = "pydantic_core-2.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:61d4e713f467abcdd59b47665d488bb898ad3dd47ce7446522a50e0cbd8e8279"},
+    {file = "pydantic_core-2.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:453862ab268f6326b01f067ed89cb3a527d34dc46f6f4eeec46a15bbc706d0da"},
+    {file = "pydantic_core-2.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:56a85fa0dab1567bd0cac10f0c3837b03e8a0d939e6a8061a3a420acd97e9421"},
+    {file = "pydantic_core-2.4.0-cp38-none-win32.whl", hash = "sha256:0d726108c1c0380b88b6dd4db559f0280e0ceda9e077f46ff90bc85cd4d03e77"},
+    {file = "pydantic_core-2.4.0-cp38-none-win_amd64.whl", hash = "sha256:047580388644c473b934d27849f8ed8dbe45df0adb72104e78b543e13bf69762"},
+    {file = "pydantic_core-2.4.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:867d3eea954bea807cabba83cfc939c889a18576d66d197c60025b15269d7cc0"},
+    {file = "pydantic_core-2.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:664402ef0c238a7f8a46efb101789d5f2275600fb18114446efec83cfadb5b66"},
+    {file = "pydantic_core-2.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64e8012ad60a5f0da09ed48725e6e923d1be25f2f091a640af6079f874663813"},
+    {file = "pydantic_core-2.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac2b680de398f293b68183317432b3d67ab3faeba216aec18de0c395cb5e3060"},
+    {file = "pydantic_core-2.4.0-cp39-cp39-manylinux_2_24_armv7l.whl", hash = "sha256:8efc1be43b036c2b6bcfb1451df24ee0ddcf69c31351003daf2699ed93f5687b"},
+    {file = "pydantic_core-2.4.0-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:d93aedbc4614cc21b9ab0d0c4ccd7143354c1f7cffbbe96ae5216ad21d1b21b5"},
+    {file = "pydantic_core-2.4.0-cp39-cp39-manylinux_2_24_s390x.whl", hash = "sha256:af788b64e13d52fc3600a68b16d31fa8d8573e3ff2fc9a38f8a60b8d94d1f012"},
+    {file = "pydantic_core-2.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:97c6349c81cee2e69ef59eba6e6c08c5936e6b01c2d50b9e4ac152217845ae09"},
+    {file = "pydantic_core-2.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cc086ddb6dc654a15deeed1d1f2bcb1cb924ebd70df9dca738af19f64229b06c"},
+    {file = "pydantic_core-2.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e953353180bec330c3b830891d260b6f8e576e2d18db3c78d314e56bb2276066"},
+    {file = "pydantic_core-2.4.0-cp39-none-win32.whl", hash = "sha256:6feb4b64d11d5420e517910d60a907d08d846cacaf4e029668725cd21d16743c"},
+    {file = "pydantic_core-2.4.0-cp39-none-win_amd64.whl", hash = "sha256:153a61ac4030fa019b70b31fb7986461119230d3ba0ab661c757cfea652f4332"},
+    {file = "pydantic_core-2.4.0-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:3fcf529382b282a30b466bd7af05be28e22aa620e016135ac414f14e1ee6b9e1"},
+    {file = "pydantic_core-2.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2edef05b63d82568b877002dc4cb5cc18f8929b59077120192df1e03e0c633f8"},
+    {file = "pydantic_core-2.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da055a1b0bfa8041bb2ff586b2cb0353ed03944a3472186a02cc44a557a0e661"},
+    {file = "pydantic_core-2.4.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:77dadc764cf7c5405e04866181c5bd94a447372a9763e473abb63d1dfe9b7387"},
+    {file = "pydantic_core-2.4.0-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a4ea23b07f29487a7bef2a869f68c7ee0e05424d81375ce3d3de829314c6b5ec"},
+    {file = "pydantic_core-2.4.0-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:382f0baa044d674ad59455a5eff83d7965572b745cc72df35c52c2ce8c731d37"},
+    {file = "pydantic_core-2.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:08f89697625e453421401c7f661b9d1eb4c9e4c0a12fd256eeb55b06994ac6af"},
+    {file = "pydantic_core-2.4.0-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:43a405ce520b45941df9ff55d0cd09762017756a7b413bbad3a6e8178e64a2c2"},
+    {file = "pydantic_core-2.4.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:584a7a818c84767af16ce8bda5d4f7fedb37d3d231fc89928a192f567e4ef685"},
+    {file = "pydantic_core-2.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04922fea7b13cd480586fa106345fe06e43220b8327358873c22d8dfa7a711c7"},
+    {file = "pydantic_core-2.4.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:17156abac20a9feed10feec867fddd91a80819a485b0107fe61f09f2117fe5f3"},
+    {file = "pydantic_core-2.4.0-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4e562cc63b04636cde361fd47569162f1daa94c759220ff202a8129902229114"},
+    {file = "pydantic_core-2.4.0-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:90f3785146f701e053bb6b9e8f53acce2c919aca91df88bd4975be0cb926eb41"},
+    {file = "pydantic_core-2.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:e40b1e97edd3dc127aa53d8a5e539a3d0c227d71574d3f9ac1af02d58218a122"},
+    {file = "pydantic_core-2.4.0-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:b27f3e67f6e031f6620655741b7d0d6bebea8b25d415924b3e8bfef2dd7bd841"},
+    {file = "pydantic_core-2.4.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be86c2eb12fb0f846262ace9d8f032dc6978b8cb26a058920ecb723dbcb87d05"},
+    {file = "pydantic_core-2.4.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4665f7ed345012a8d2eddf4203ef145f5f56a291d010382d235b94e91813f88a"},
+    {file = "pydantic_core-2.4.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:79262be5a292d1df060f29b9a7cdd66934801f987a817632d7552534a172709a"},
+    {file = "pydantic_core-2.4.0-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5fd905a69ac74eaba5041e21a1e8b1a479dab2b41c93bdcc4c1cede3c12a8d86"},
+    {file = "pydantic_core-2.4.0-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:2ad538b7e07343001934417cdc8584623b4d8823c5b8b258e75ec8d327cec969"},
+    {file = "pydantic_core-2.4.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:dd2429f7635ad4857b5881503f9c310be7761dc681c467a9d27787b674d1250a"},
+    {file = "pydantic_core-2.4.0-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:efff8b6761a1f6e45cebd1b7a6406eb2723d2d5710ff0d1b624fe11313693989"},
+    {file = "pydantic_core-2.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a1e0352558cd7ccc014ffe818c7d87b15ec6145875e2cc5fa4bb7351a1033d"},
+    {file = "pydantic_core-2.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a027f41c5008571314861744d83aff75a34cf3a07022e0be32b214a5bc93f7f1"},
+    {file = "pydantic_core-2.4.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1927f0e15d190f11f0b8344373731e28fd774c6d676d8a6cfadc95c77214a48b"},
+    {file = "pydantic_core-2.4.0-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7aa82d483d5fb867d4fb10a138ffd57b0f1644e99f2f4f336e48790ada9ada5e"},
+    {file = "pydantic_core-2.4.0-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b85778308bf945e9b33ac604e6793df9b07933108d20bdf53811bc7c2798a4af"},
+    {file = "pydantic_core-2.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:3ded19dcaefe2f6706d81e0db787b59095f4ad0fbadce1edffdf092294c8a23f"},
+    {file = "pydantic_core-2.4.0.tar.gz", hash = "sha256:ec3473c9789cc00c7260d840c3db2c16dbfc816ca70ec87a00cddfa3e1a1cdd5"},
 ]
 ]
 
 
 [package.dependencies]
 [package.dependencies]
@@ -1889,51 +1870,51 @@ qt = ["PyQt5", "QtPy", "pyqtwebengine"]
 
 
 [[package]]
 [[package]]
 name = "pyyaml"
 name = "pyyaml"
-version = "6.0"
+version = "6.0.1"
 description = "YAML parser and emitter for Python"
 description = "YAML parser and emitter for Python"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
-    {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
-    {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
-    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
-    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
-    {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
-    {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
-    {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
-    {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"},
-    {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"},
-    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"},
-    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"},
-    {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"},
-    {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"},
-    {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"},
-    {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
-    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
-    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
-    {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
-    {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
-    {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
-    {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
-    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
-    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
-    {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
-    {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
-    {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
-    {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
-    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
-    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
-    {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
-    {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
-    {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
-    {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
-    {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
-    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
-    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
-    {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
-    {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
-    {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
-    {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
+    {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
+    {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
+    {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
+    {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
+    {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+    {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
+    {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
+    {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
+    {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
+    {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
+    {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
+    {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+    {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
+    {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
+    {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
+    {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
+    {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
+    {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
+    {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
+    {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+    {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
+    {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
+    {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
+    {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
+    {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
+    {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
+    {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+    {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
+    {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
+    {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -1987,13 +1968,13 @@ files = [
 
 
 [[package]]
 [[package]]
 name = "selenium"
 name = "selenium"
-version = "4.10.0"
+version = "4.11.2"
 description = ""
 description = ""
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
-    {file = "selenium-4.10.0-py3-none-any.whl", hash = "sha256:40241b9d872f58959e9b34e258488bf11844cd86142fd68182bd41db9991fc5c"},
-    {file = "selenium-4.10.0.tar.gz", hash = "sha256:871bf800c4934f745b909c8dfc7d15c65cf45bd2e943abd54451c810ada395e3"},
+    {file = "selenium-4.11.2-py3-none-any.whl", hash = "sha256:98e72117b194b3fa9c69b48998f44bf7dd4152c7bd98544911a1753b9f03cc7d"},
+    {file = "selenium-4.11.2.tar.gz", hash = "sha256:9f9a5ed586280a3594f7461eb1d9dab3eac9d91e28572f365e9b98d9d03e02b5"},
 ]
 ]
 
 
 [package.dependencies]
 [package.dependencies]
@@ -2078,26 +2059,6 @@ files = [
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
     {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 ]
 
 
-[[package]]
-name = "tqdm"
-version = "4.65.0"
-description = "Fast, Extensible Progress Meter"
-optional = false
-python-versions = ">=3.7"
-files = [
-    {file = "tqdm-4.65.0-py3-none-any.whl", hash = "sha256:c4f53a17fe37e132815abceec022631be8ffe1b9381c2e6e30aa70edc99e9671"},
-    {file = "tqdm-4.65.0.tar.gz", hash = "sha256:1871fb68a86b8fb3b59ca4cdd3dcccbc7e6d613eeed31f4c332531977b89beb5"},
-]
-
-[package.dependencies]
-colorama = {version = "*", markers = "platform_system == \"Windows\""}
-
-[package.extras]
-dev = ["py-make (>=0.1.0)", "twine", "wheel"]
-notebook = ["ipywidgets (>=6)"]
-slack = ["slack-sdk"]
-telegram = ["requests"]
-
 [[package]]
 [[package]]
 name = "trio"
 name = "trio"
 version = "0.22.2"
 version = "0.22.2"
@@ -2158,13 +2119,13 @@ files = [
 
 
 [[package]]
 [[package]]
 name = "urllib3"
 name = "urllib3"
-version = "2.0.3"
+version = "2.0.4"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
-    {file = "urllib3-2.0.3-py3-none-any.whl", hash = "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1"},
-    {file = "urllib3-2.0.3.tar.gz", hash = "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"},
+    {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"},
+    {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"},
 ]
 ]
 
 
 [package.dependencies]
 [package.dependencies]
@@ -2247,13 +2208,13 @@ test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "my
 
 
 [[package]]
 [[package]]
 name = "vbuild"
 name = "vbuild"
-version = "0.8.1"
+version = "0.8.2"
 description = "A simple module to extract html/script/style from a vuejs '.vue' file (can minimize/es2015 compliant js) ... just py2 or py3, NO nodejs !"
 description = "A simple module to extract html/script/style from a vuejs '.vue' file (can minimize/es2015 compliant js) ... just py2 or py3, NO nodejs !"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
 files = [
 files = [
-    {file = "vbuild-0.8.1-py2.py3-none-any.whl", hash = "sha256:967886801a47594346802aa637d9361f38f3360738331d375c2a5d6f3e5087fd"},
-    {file = "vbuild-0.8.1.tar.gz", hash = "sha256:b9ff9071fa61009563e935eddea4fdf1494c71941285f05fd57648f73dc19ecc"},
+    {file = "vbuild-0.8.2-py2.py3-none-any.whl", hash = "sha256:d76bcc976a1c53b6a5776ac947606f9e7786c25df33a587ebe33ed09dd8a1076"},
+    {file = "vbuild-0.8.2.tar.gz", hash = "sha256:270cd9078349d907dfae6c0e6364a5a5e74cb86183bb5093613f12a18b435fa9"},
 ]
 ]
 
 
 [package.dependencies]
 [package.dependencies]
@@ -2295,20 +2256,19 @@ anyio = ">=3.0.0"
 
 
 [[package]]
 [[package]]
 name = "webdriver-manager"
 name = "webdriver-manager"
-version = "3.8.6"
+version = "3.9.1"
 description = "Library provides the way to automatically manage drivers for different browsers"
 description = "Library provides the way to automatically manage drivers for different browsers"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
-    {file = "webdriver_manager-3.8.6-py2.py3-none-any.whl", hash = "sha256:7d3aa8d67bd6c92a5d25f4abd75eea2c6dd24ea6617bff986f502280903a0e2b"},
-    {file = "webdriver_manager-3.8.6.tar.gz", hash = "sha256:ee788d389b8f45222a8a62f6f39b579360a1f87be46dad6da89918354af3ce73"},
+    {file = "webdriver_manager-3.9.1-py2.py3-none-any.whl", hash = "sha256:1dfc29a786abb97ba28076d4766d931064eeeac71a9685a3e8d46f5d363fcbe3"},
+    {file = "webdriver_manager-3.9.1.tar.gz", hash = "sha256:cd1f49ebb325a98b4dc3c41056f5b645e82fff3f83e346607844ec0bdf561c0b"},
 ]
 ]
 
 
 [package.dependencies]
 [package.dependencies]
 packaging = "*"
 packaging = "*"
 python-dotenv = "*"
 python-dotenv = "*"
 requests = "*"
 requests = "*"
-tqdm = "*"
 
 
 [[package]]
 [[package]]
 name = "websockets"
 name = "websockets"
@@ -2405,18 +2365,18 @@ h11 = ">=0.9.0,<1"
 
 
 [[package]]
 [[package]]
 name = "zipp"
 name = "zipp"
-version = "3.16.1"
+version = "3.16.2"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 description = "Backport of pathlib-compatible object wrapper for zip files"
-optional = false
+optional = true
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
-    {file = "zipp-3.16.1-py3-none-any.whl", hash = "sha256:0b37c326d826d5ca35f2b9685cd750292740774ef16190008b00a0227c256fe0"},
-    {file = "zipp-3.16.1.tar.gz", hash = "sha256:857b158da2cbf427b376da1c24fd11faecbac5a4ac7523c3607f8a01f94c2ec0"},
+    {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"},
+    {file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"},
 ]
 ]
 
 
 [package.extras]
 [package.extras]
 docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
 docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
+testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
 
 
 [extras]
 [extras]
 matplotlib = ["matplotlib"]
 matplotlib = ["matplotlib"]
@@ -2427,4 +2387,4 @@ plotly = ["plotly"]
 [metadata]
 [metadata]
 lock-version = "2.0"
 lock-version = "2.0"
 python-versions = "^3.8"
 python-versions = "^3.8"
-content-hash = "18d16b65f8410bab4a2585a8c117ce6649066c311381f8096d24f6a4337eee81"
+content-hash = "a7d82ed4bf9bfb5df43c5c906a964cc93e55d5fa19839cd32ebd2d1ca6d710d7"

+ 1 - 1
pyproject.toml

@@ -16,7 +16,7 @@ Pygments = ">=2.9.0,<3.0.0"
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 uvicorn = {extras = ["standard"], version = "^0.22.0"}
 fastapi = ">=0.92,<1.0.0"
 fastapi = ">=0.92,<1.0.0"
 fastapi-socketio = "^0.0.10"
 fastapi-socketio = "^0.0.10"
-vbuild = "^0.8.1"
+vbuild = ">=0.8.2"
 watchfiles = ">=0.18.1,<1.0.0"
 watchfiles = ">=0.18.1,<1.0.0"
 jinja2 = "^3.1.2"
 jinja2 = "^3.1.2"
 python-multipart = "^0.0.6"
 python-multipart = "^0.0.6"

+ 9 - 4
release.dockerfile

@@ -7,10 +7,15 @@ RUN python -m pip install nicegui==$VERSION itsdangerous isort docutils requests
 
 
 WORKDIR /app
 WORKDIR /app
 
 
-COPY main.py README.md prometheus.py ./ 
-ADD examples ./examples
-ADD website ./website
+COPY main.py README.md prometheus.py ./
+COPY examples ./examples
+COPY website ./website
+RUN mkdir /resources
+COPY docker-entrypoint.sh /resources
+RUN chmod 777 /resources/docker-entrypoint.sh
 
 
 EXPOSE 8080
 EXPOSE 8080
+ENV PYTHONUNBUFFERED True
 
 
-CMD python3 main.py
+ENTRYPOINT ["/resources/docker-entrypoint.sh"]
+CMD ["python", "main.py"]

+ 17 - 2
tests/conftest.py

@@ -1,18 +1,22 @@
 import importlib
 import importlib
 import os
 import os
+import shutil
+from pathlib import Path
 from typing import Dict, Generator
 from typing import Dict, Generator
 
 
 import icecream
 import icecream
 import pytest
 import pytest
 from selenium import webdriver
 from selenium import webdriver
 from selenium.webdriver.chrome.service import Service
 from selenium.webdriver.chrome.service import Service
-from webdriver_manager.chrome import ChromeDriverManager
 
 
 from nicegui import Client, globals
 from nicegui import Client, globals
+from nicegui.elements import plotly, pyplot
 from nicegui.page import page
 from nicegui.page import page
 
 
 from .screen import Screen
 from .screen import Screen
 
 
+DOWNLOAD_DIR = Path(__file__).parent / 'download'
+
 icecream.install()
 icecream.install()
 
 
 
 
@@ -21,6 +25,11 @@ def chrome_options(chrome_options: webdriver.ChromeOptions) -> webdriver.ChromeO
     chrome_options.add_argument('headless')
     chrome_options.add_argument('headless')
     chrome_options.add_argument('disable-gpu')
     chrome_options.add_argument('disable-gpu')
     chrome_options.add_argument('window-size=600x600')
     chrome_options.add_argument('window-size=600x600')
+    chrome_options.add_experimental_option('prefs', {
+        "download.default_directory": str(DOWNLOAD_DIR),
+        "download.prompt_for_download": False,  # To auto download the file
+        "download.directory_upgrade": True,
+    })
     return chrome_options
     return chrome_options
 
 
 
 
@@ -34,11 +43,15 @@ def capabilities(capabilities: Dict) -> Dict:
 def reset_globals() -> Generator[None, None, None]:
 def reset_globals() -> Generator[None, None, None]:
     for path in {'/'}.union(globals.page_routes.values()):
     for path in {'/'}.union(globals.page_routes.values()):
         globals.app.remove_route(path)
         globals.app.remove_route(path)
+    globals.app.openapi_schema = None
     globals.app.middleware_stack = None
     globals.app.middleware_stack = None
     globals.app.user_middleware.clear()
     globals.app.user_middleware.clear()
     # NOTE favicon routes must be removed separately because they are not "pages"
     # NOTE favicon routes must be removed separately because they are not "pages"
     [globals.app.routes.remove(r) for r in globals.app.routes if r.path.endswith('/favicon.ico')]
     [globals.app.routes.remove(r) for r in globals.app.routes if r.path.endswith('/favicon.ico')]
     importlib.reload(globals)
     importlib.reload(globals)
+    # repopulate globals.optional_features
+    importlib.reload(plotly)
+    importlib.reload(pyplot)
     globals.app.storage.clear()
     globals.app.storage.clear()
     globals.index_client = Client(page('/'), shared=True).__enter__()
     globals.index_client = Client(page('/'), shared=True).__enter__()
     globals.app.get('/')(globals.index_client.build_response)
     globals.app.get('/')(globals.index_client.build_response)
@@ -53,7 +66,7 @@ def remove_all_screenshots() -> None:
 
 
 @pytest.fixture(scope='function')
 @pytest.fixture(scope='function')
 def driver(chrome_options: webdriver.ChromeOptions) -> webdriver.Chrome:
 def driver(chrome_options: webdriver.ChromeOptions) -> webdriver.Chrome:
-    s = Service(ChromeDriverManager().install())
+    s = Service()
     driver = webdriver.Chrome(service=s, options=chrome_options)
     driver = webdriver.Chrome(service=s, options=chrome_options)
     driver.implicitly_wait(Screen.IMPLICIT_WAIT)
     driver.implicitly_wait(Screen.IMPLICIT_WAIT)
     driver.set_page_load_timeout(4)
     driver.set_page_load_timeout(4)
@@ -71,3 +84,5 @@ def screen(driver: webdriver.Chrome, request: pytest.FixtureRequest, caplog: pyt
     logs = screen.caplog.get_records('call')
     logs = screen.caplog.get_records('call')
     assert not logs, f'There were unexpected logs:\n-------\n{logs}\n-------'
     assert not logs, f'There were unexpected logs:\n-------\n{logs}\n-------'
     screen.stop_server()
     screen.stop_server()
+    if DOWNLOAD_DIR.exists():
+        shutil.rmtree(DOWNLOAD_DIR)

+ 3 - 5
tests/screen.py

@@ -16,11 +16,9 @@ from nicegui import globals, ui
 
 
 from .test_helpers import TEST_DIR
 from .test_helpers import TEST_DIR
 
 
-PORT = 3392
-IGNORED_CLASSES = ['row', 'column', 'q-card', 'q-field', 'q-field__label', 'q-input']
-
 
 
 class Screen:
 class Screen:
+    PORT = 3392
     IMPLICIT_WAIT = 4
     IMPLICIT_WAIT = 4
     SCREENSHOT_DIR = TEST_DIR / 'screenshots'
     SCREENSHOT_DIR = TEST_DIR / 'screenshots'
 
 
@@ -28,7 +26,7 @@ class Screen:
         self.selenium = selenium
         self.selenium = selenium
         self.caplog = caplog
         self.caplog = caplog
         self.server_thread = None
         self.server_thread = None
-        self.ui_run_kwargs = {'port': PORT, 'show': False, 'reload': False}
+        self.ui_run_kwargs = {'port': self.PORT, 'show': False, 'reload': False}
 
 
     def start_server(self) -> None:
     def start_server(self) -> None:
         """Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script."""
         """Start the webserver in a separate thread. This is the equivalent of `ui.run()` in a normal script."""
@@ -61,7 +59,7 @@ class Screen:
         deadline = time.time() + timeout
         deadline = time.time() + timeout
         while True:
         while True:
             try:
             try:
-                self.selenium.get(f'http://localhost:{PORT}{path}')
+                self.selenium.get(f'http://localhost:{self.PORT}{path}')
                 self.selenium.find_element(By.XPATH, '//body')  # ensure page and JS are loaded
                 self.selenium.find_element(By.XPATH, '//body')  # ensure page and JS are loaded
                 break
                 break
             except Exception as e:
             except Exception as e:

+ 30 - 11
tests/test_download.py

@@ -1,23 +1,42 @@
-from fastapi import HTTPException
+from pathlib import Path
+from typing import Generator
+
+import pytest
+from fastapi.responses import PlainTextResponse
 
 
 from nicegui import app, ui
 from nicegui import app, ui
 
 
-from .screen import PORT, Screen
+from .conftest import DOWNLOAD_DIR
+from .screen import Screen
+
 
 
+@pytest.fixture
+def test_route() -> Generator[str, None, None]:
+    TEST_ROUTE = '/static/test.txt'
+    yield TEST_ROUTE
+    app.remove_route(TEST_ROUTE)
 
 
-def test_download(screen: Screen):
-    success = False
 
 
-    @app.get('/static/test.py')
+def test_download_text_file(screen: Screen, test_route: str):
+    @app.get(test_route)
     def test():
     def test():
-        nonlocal success
-        success = True
-        raise HTTPException(404, 'Not found')
+        return PlainTextResponse('test')
 
 
-    ui.button('Download', on_click=lambda: ui.download('static/test.py'))
+    ui.button('Download', on_click=lambda: ui.download(test_route))
 
 
     screen.open('/')
     screen.open('/')
     screen.click('Download')
     screen.click('Download')
     screen.wait(0.5)
     screen.wait(0.5)
-    assert success
-    screen.assert_py_logger('WARNING', f'http://localhost:{PORT}/static/test.py not found')
+    assert (DOWNLOAD_DIR / 'test.txt').read_text() == 'test'
+
+
+def test_downloading_local_file_as_src(screen: Screen):
+    IMAGE_FILE = Path(__file__).parent.parent / 'examples' / 'slideshow' / 'slides' / 'slide1.jpg'
+    ui.button('download', on_click=lambda: ui.download(IMAGE_FILE))
+
+    screen.open('/')
+    route_count_before_download = len(app.routes)
+    screen.click('download')
+    screen.wait(0.5)
+    assert (DOWNLOAD_DIR / 'slide1.jpg').exists()
+    assert len(app.routes) == route_count_before_download

+ 16 - 0
tests/test_element.py

@@ -161,3 +161,19 @@ def test_move(screen: Screen):
     screen.click('Move X to top')
     screen.click('Move X to top')
     screen.wait(0.5)
     screen.wait(0.5)
     assert screen.find('X').location['y'] < screen.find('A').location['y'] < screen.find('B').location['y']
     assert screen.find('X').location['y'] < screen.find('A').location['y'] < screen.find('B').location['y']
+
+
+def test_xss(screen: Screen):
+    ui.label('</script><script>alert(1)</script>')
+    ui.label('<b>Bold 1</b>, `code`, copy&paste, multi\nline')
+    ui.button('Button', on_click=lambda: (
+        ui.label('</script><script>alert(2)</script>'),
+        ui.label('<b>Bold 2</b>, `code`, copy&paste, multi\nline'),
+    ))
+
+    screen.open('/')
+    screen.click('Button')
+    screen.should_contain('</script><script>alert(1)</script>')
+    screen.should_contain('</script><script>alert(2)</script>')
+    screen.should_contain('<b>Bold 1</b>, `code`, copy&paste, multi\nline')
+    screen.should_contain('<b>Bold 2</b>, `code`, copy&paste, multi\nline')

+ 41 - 0
tests/test_endpoint_docs.py

@@ -0,0 +1,41 @@
+from typing import Set
+
+import requests
+
+from nicegui import __version__
+
+from .screen import Screen
+
+
+def get_openapi_paths() -> Set[str]:
+    return set(requests.get(f'http://localhost:{Screen.PORT}/openapi.json').json()['paths'])
+
+
+def test_endpoint_documentation_default(screen: Screen):
+    screen.open('/')
+    assert get_openapi_paths() == set()
+
+
+def test_endpoint_documentation_page_only(screen: Screen):
+    screen.ui_run_kwargs['endpoint_documentation'] = 'page'
+    screen.open('/')
+    assert get_openapi_paths() == {'/'}
+
+
+def test_endpoint_documentation_internal_only(screen: Screen):
+    screen.ui_run_kwargs['endpoint_documentation'] = 'internal'
+    screen.open('/')
+    assert get_openapi_paths() == {
+        f'/_nicegui/{__version__}/libraries/{{key}}',
+        f'/_nicegui/{__version__}/components/{{key}}',
+    }
+
+
+def test_endpoint_documentation_all(screen: Screen):
+    screen.ui_run_kwargs['endpoint_documentation'] = 'all'
+    screen.open('/')
+    assert get_openapi_paths() == {
+        '/',
+        f'/_nicegui/{__version__}/libraries/{{key}}',
+        f'/_nicegui/{__version__}/components/{{key}}',
+    }

+ 2 - 2
tests/test_favicon.py

@@ -6,7 +6,7 @@ from bs4 import BeautifulSoup
 
 
 from nicegui import favicon, ui
 from nicegui import favicon, ui
 
 
-from .screen import PORT, Screen
+from .screen import Screen
 
 
 DEFAULT_FAVICON_PATH = Path(__file__).parent.parent / 'nicegui' / 'static' / 'favicon.ico'
 DEFAULT_FAVICON_PATH = Path(__file__).parent.parent / 'nicegui' / 'static' / 'favicon.ico'
 LOGO_FAVICON_PATH = Path(__file__).parent.parent / 'website' / 'static' / 'logo_square.png'
 LOGO_FAVICON_PATH = Path(__file__).parent.parent / 'website' / 'static' / 'logo_square.png'
@@ -19,7 +19,7 @@ def assert_favicon_url_starts_with(screen: Screen, content: str):
 
 
 
 
 def assert_favicon(content: Union[Path, str, bytes], url_path: str = '/favicon.ico'):
 def assert_favicon(content: Union[Path, str, bytes], url_path: str = '/favicon.ico'):
-    response = requests.get(f'http://localhost:{PORT}{url_path}')
+    response = requests.get(f'http://localhost:{Screen.PORT}{url_path}')
     assert response.status_code == 200
     assert response.status_code == 200
     if isinstance(content, Path):
     if isinstance(content, Path):
         assert content.read_bytes() == response.content
         assert content.read_bytes() == response.content

+ 18 - 0
tests/test_open.py

@@ -0,0 +1,18 @@
+import pytest
+
+from nicegui import ui
+
+from .screen import Screen
+
+
+@pytest.mark.parametrize('new_tab', [False, True])
+def test_open_page(screen: Screen, new_tab: bool):
+    @ui.page('/test_page')
+    def page():
+        ui.label('Test page')
+    ui.button('Open test page', on_click=lambda: ui.open('/test_page', new_tab=new_tab))
+
+    screen.open('/')
+    screen.click('Open test page')
+    screen.switch_to(1 if new_tab else 0)
+    screen.should_contain('Test page')

+ 19 - 0
tests/test_prod_js.py

@@ -0,0 +1,19 @@
+from selenium.webdriver.common.by import By
+
+from nicegui import __version__
+
+from .screen import Screen
+
+
+def test_dev_mode(screen: Screen) -> None:
+    screen.ui_run_kwargs['prod_js'] = False
+    screen.open('/')
+    screen.selenium.find_element(By.XPATH, f'//script[@src="/_nicegui/{__version__}/static/vue.global.js"]')
+    screen.selenium.find_element(By.XPATH, f'//script[@src="/_nicegui/{__version__}/static/quasar.umd.js"]')
+
+
+def test_prod_mode(screen: Screen):
+    screen.ui_run_kwargs['prod_js'] = True
+    screen.open('/')
+    screen.selenium.find_element(By.XPATH, f'//script[@src="/_nicegui/{__version__}/static/vue.global.prod.js"]')
+    screen.selenium.find_element(By.XPATH, f'//script[@src="/_nicegui/{__version__}/static/quasar.umd.prod.js"]')

+ 8 - 0
tests/test_query.py

@@ -52,3 +52,11 @@ def test_query_multiple_divs(screen: Screen):
     screen.wait(0.5)
     screen.wait(0.5)
     assert screen.find('A').value_of_css_property('border') == '1px solid rgb(0, 0, 0)'
     assert screen.find('A').value_of_css_property('border') == '1px solid rgb(0, 0, 0)'
     assert screen.find('B').value_of_css_property('border') == '1px solid rgb(0, 0, 0)'
     assert screen.find('B').value_of_css_property('border') == '1px solid rgb(0, 0, 0)'
+
+
+def test_query_with_css_variables(screen: Screen):
+    ui.add_body_html('<div id="element">Test</div>')
+    ui.query('#element').style('--color: red; color: var(--color)')
+
+    screen.open('/')
+    assert screen.find('Test').value_of_css_property('color') == 'rgba(255, 0, 0, 1)'

+ 69 - 18
tests/test_refreshable.py

@@ -31,7 +31,7 @@ def test_refreshable(screen: Screen) -> None:
     screen.should_contain('[]')
     screen.should_contain('[]')
 
 
 
 
-async def test_async_refreshable(screen: Screen) -> None:
+def test_async_refreshable(screen: Screen) -> None:
     numbers = []
     numbers = []
 
 
     @ui.refreshable
     @ui.refreshable
@@ -101,28 +101,79 @@ def test_multiple_targets(screen: Screen) -> None:
 
 
 
 
 def test_refresh_with_arguments(screen: Screen):
 def test_refresh_with_arguments(screen: Screen):
-    a = 0
+    count = 0
+
+    @ui.refreshable
+    def some_ui(value: int):
+        nonlocal count
+        count += 1
+        ui.label(f'{count=}, {value=}')
+
+    some_ui(0)
+    ui.button('refresh', on_click=some_ui.refresh)
+    ui.button('refresh()', on_click=lambda: some_ui.refresh())
+    ui.button('refresh(1)', on_click=lambda: some_ui.refresh(1))
+    ui.button('refresh(2)', on_click=lambda: some_ui.refresh(2))
+    ui.button('refresh(value=3)', on_click=lambda: some_ui.refresh(value=3))
+
+    screen.open('/')
+    screen.should_contain('count=1, value=0')
+
+    screen.click('refresh')
+    screen.should_contain('count=2, value=0')
+
+    screen.click('refresh()')
+    screen.should_contain('count=3, value=0')
+
+    screen.click('refresh(1)')
+    screen.should_contain('count=4, value=1')
+
+    screen.click('refresh(2)')
+    screen.should_contain('count=5, value=2')
+
+    screen.click('refresh(value=3)')
+    screen.assert_py_logger(
+        'ERROR', "'value' needs to be consistently passed to some_ui() either as positional or as keyword argument")
+
 
 
+def test_refresh_deleted_element(screen: Screen):
     @ui.refreshable
     @ui.refreshable
-    def some_ui(*, b: int):
-        ui.label(f'a={a}, b={b}')
+    def some_ui():
+        ui.label('some text')
 
 
-    some_ui(b=0)
-    ui.button('Refresh 1', on_click=lambda: some_ui.refresh(b=1))
-    ui.button('Refresh 2', on_click=lambda: some_ui.refresh())
-    ui.button('Refresh 3', on_click=some_ui.refresh)
+    with ui.card() as card:
+        some_ui()
+
+    ui.button('Refresh', on_click=some_ui.refresh)
+    ui.button('Clear', on_click=card.clear)
+
+    some_ui()
 
 
     screen.open('/')
     screen.open('/')
-    screen.should_contain('a=0, b=0')
+    screen.should_contain('some text')
+
+    screen.click('Clear')
+    screen.click('Refresh')
+
 
 
-    a = 1
-    screen.click('Refresh 1')
-    screen.should_contain('a=1, b=1')
+def test_refresh_with_function_reference(screen: Screen):
+    # https://github.com/zauberzeug/nicegui/issues/1283
+    class Test:
 
 
-    a = 2
-    screen.click('Refresh 2')
-    screen.should_contain('a=2, b=1')
+        def __init__(self, name):
+            self.name = name
+            self.ui()
+
+        @ui.refreshable
+        def ui(self):
+            ui.notify(f'Refreshing {self.name}')
+            ui.button(self.name, on_click=self.ui.refresh)
 
 
-    a = 3
-    screen.click('Refresh 3')
-    screen.should_contain('a=3, b=1')
+    Test('A')
+    Test('B')
+
+    screen.open('/')
+    screen.click('A')
+    screen.should_contain('Refreshing A')
+    screen.click('B')
+    screen.should_contain('Refreshing B')

+ 32 - 0
tests/test_scene.py

@@ -112,3 +112,35 @@ def test_rotation_matrix_from_euler():
     Rz = np.array([[np.cos(kappa), -np.sin(kappa), 0], [np.sin(kappa), np.cos(kappa), 0], [0, 0, 1]])
     Rz = np.array([[np.cos(kappa), -np.sin(kappa), 0], [np.sin(kappa), np.cos(kappa), 0], [0, 0, 1]])
     R = Rz @ Ry @ Rx
     R = Rz @ Ry @ Rx
     assert np.allclose(Object3D.rotation_matrix_from_euler(omega, phi, kappa), R)
     assert np.allclose(Object3D.rotation_matrix_from_euler(omega, phi, kappa), R)
+
+
+def test_object_creation_via_context(screen: Screen):
+    with ui.scene() as scene:
+        scene.box().with_name('box')
+
+    screen.open('/')
+    screen.wait(0.5)
+    assert screen.selenium.execute_script(f'return scene_c{scene.id}.children[4].name') == 'box'
+
+
+def test_object_creation_via_attribute(screen: Screen):
+    scene = ui.scene()
+    scene.box().with_name('box')
+
+    screen.open('/')
+    screen.wait(0.5)
+    assert screen.selenium.execute_script(f'return scene_c{scene.id}.children[4].name') == 'box'
+
+
+def test_clearing_scene(screen: Screen):
+    with ui.scene() as scene:
+        scene.box().with_name('box')
+        scene.box().with_name('box2')
+    ui.button('Clear', on_click=scene.clear)
+
+    screen.open('/')
+    screen.wait(0.5)
+    assert len(scene.objects) == 2
+    screen.click('Clear')
+    screen.wait(0.5)
+    assert len(scene.objects) == 0

+ 3 - 3
tests/test_serving_files.py

@@ -6,7 +6,7 @@ import pytest
 
 
 from nicegui import app, ui
 from nicegui import app, ui
 
 
-from .screen import PORT, Screen
+from .screen import Screen
 from .test_helpers import TEST_DIR
 from .test_helpers import TEST_DIR
 
 
 IMAGE_FILE = Path(TEST_DIR).parent / 'examples' / 'slideshow' / 'slides' / 'slide1.jpg'
 IMAGE_FILE = Path(TEST_DIR).parent / 'examples' / 'slideshow' / 'slides' / 'slide1.jpg'
@@ -27,7 +27,7 @@ def provide_media_files():
 def assert_video_file_streaming(path: str) -> None:
 def assert_video_file_streaming(path: str) -> None:
     with httpx.Client() as http_client:
     with httpx.Client() as http_client:
         r = http_client.get(
         r = http_client.get(
-            path if 'http' in path else f'http://localhost:{PORT}{path}',
+            path if 'http' in path else f'http://localhost:{Screen.PORT}{path}',
             headers={'Range': 'bytes=0-1000'},
             headers={'Range': 'bytes=0-1000'},
         )
         )
         assert r.status_code == 206
         assert r.status_code == 206
@@ -56,7 +56,7 @@ def test_adding_single_static_file(screen: Screen):
 
 
     screen.open('/')
     screen.open('/')
     with httpx.Client() as http_client:
     with httpx.Client() as http_client:
-        r = http_client.get(f'http://localhost:{PORT}{url_path}')
+        r = http_client.get(f'http://localhost:{Screen.PORT}{url_path}')
         assert r.status_code == 200
         assert r.status_code == 200
         assert 'max-age=' in r.headers['Cache-Control']
         assert 'max-age=' in r.headers['Cache-Control']
 
 

+ 2 - 2
tests/test_storage.py

@@ -6,7 +6,7 @@ import httpx
 
 
 from nicegui import Client, app, background_tasks, ui
 from nicegui import Client, app, background_tasks, ui
 
 
-from .screen import PORT, Screen
+from .screen import Screen
 
 
 
 
 def test_browser_data_is_stored_in_the_browser(screen: Screen):
 def test_browser_data_is_stored_in_the_browser(screen: Screen):
@@ -87,7 +87,7 @@ async def test_access_user_storage_from_fastapi(screen: Screen):
     screen.ui_run_kwargs['storage_secret'] = 'just a test'
     screen.ui_run_kwargs['storage_secret'] = 'just a test'
     screen.open('/')
     screen.open('/')
     async with httpx.AsyncClient() as http_client:
     async with httpx.AsyncClient() as http_client:
-        response = await http_client.get(f'http://localhost:{PORT}/api')
+        response = await http_client.get(f'http://localhost:{Screen.PORT}/api')
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.text == '"OK"'
         assert response.text == '"OK"'
         await asyncio.sleep(0.5)  # wait for storage to be written
         await asyncio.sleep(0.5)  # wait for storage to be written

+ 2 - 1
website/build_search_index.py

@@ -132,7 +132,8 @@ class MainVisitor(ast.NodeVisitor):
         if function_name == 'example_link':
         if function_name == 'example_link':
             title = ast_string_node_to_string(node.args[0])
             title = ast_string_node_to_string(node.args[0])
             name = name = title.lower().replace(' ', '_')
             name = name = title.lower().replace(' ', '_')
-            file = 'main.py' if not 'ros' in name else ''  # TODO: generalize hack to use folder if main.py is not available
+            # TODO: generalize hack to use folder if main.py is not available
+            file = 'main.py' if not any(x in name for x in ['ros', 'docker']) else ''
             documents.append({
             documents.append({
                 'title': 'Example: ' + title,
                 'title': 'Example: ' + title,
                 'content': ast_string_node_to_string(node.args[1]),
                 'content': ast_string_node_to_string(node.args[1]),

+ 17 - 6
website/documentation.py

@@ -164,7 +164,7 @@ def create_full() -> None:
         add_face()
         add_face()
 
 
         ui.button('Add', on_click=add_face)
         ui.button('Add', on_click=add_face)
-        ui.button('Remove', on_click=lambda: container.remove(0))
+        ui.button('Remove', on_click=lambda: container.remove(0) if list(container) else None)
         ui.button('Clear', on_click=container.clear)
         ui.button('Clear', on_click=container.clear)
 
 
     load_demo(ui.expansion)
     load_demo(ui.expansion)
@@ -607,11 +607,15 @@ def create_full() -> None:
         A convenient alternative is the use of our [pre-built multi-arch Docker image](https://hub.docker.com/r/zauberzeug/nicegui) which contains all necessary dependencies.
         A convenient alternative is the use of our [pre-built multi-arch Docker image](https://hub.docker.com/r/zauberzeug/nicegui) which contains all necessary dependencies.
         With this command you can launch the script `main.py` in the current directory on the public port 80:
         With this command you can launch the script `main.py` in the current directory on the public port 80:
     ''').classes('bold-links arrow-links')
     ''').classes('bold-links arrow-links')
-    with demo.bash_window(classes='max-w-lg w-full h-52'):
+    with demo.bash_window(classes='max-w-lg w-full h-44'):
         ui.markdown('''
         ui.markdown('''
             ```bash
             ```bash
-            docker run -p 80:8080 -v $(pwd)/:/app/ \\
-                -d --restart always zauberzeug/nicegui:latest
+            docker run -it --restart always \\
+              -p 80:8080 \\
+              -e PUID=$(id -u) \\
+              -e PGID=$(id -g) \\
+              -v $(pwd)/:/app/ \\
+              zauberzeug/nicegui:latest
             ```
             ```
         ''')
         ''')
     ui.markdown('''
     ui.markdown('''
@@ -619,7 +623,7 @@ def create_full() -> None:
         The `-d` tells docker to run in background and `--restart always` makes sure the container is restarted if the app crashes or the server reboots.
         The `-d` tells docker to run in background and `--restart always` makes sure the container is restarted if the app crashes or the server reboots.
         Of course this can also be written in a Docker compose file:
         Of course this can also be written in a Docker compose file:
     ''')
     ''')
-    with demo.python_window('docker-compose.yml', classes='max-w-lg w-full h-52'):
+    with demo.python_window('docker-compose.yml', classes='max-w-lg w-full h-60'):
         ui.markdown('''
         ui.markdown('''
             ```yaml
             ```yaml
             app:
             app:
@@ -627,15 +631,22 @@ def create_full() -> None:
                 restart: always
                 restart: always
                 ports:
                 ports:
                     - 80:8080
                     - 80:8080
+                environment:
+                    - PUID=1000 # change this to your user id
+                    - PGID=1000 # change this to your group id
                 volumes:
                 volumes:
                     - ./:/app/
                     - ./:/app/
             ```
             ```
         ''')
         ''')
+    ui.markdown('''
+        There are other handy features in the Docker image like non-root user execution and signal pass-through.
+        For more details we recommend to have a look at our [Docker example](https://github.com/zauberzeug/nicegui/tree/main/examples/docker_image).
+    ''').classes('bold-links arrow-links')
 
 
     ui.markdown('''
     ui.markdown('''
         You can provide SSL certificates directly using [FastAPI](https://fastapi.tiangolo.com/deployment/https/).
         You can provide SSL certificates directly using [FastAPI](https://fastapi.tiangolo.com/deployment/https/).
         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.
         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.
+        See our development [docker-compose.yml](https://github.com/zauberzeug/nicegui/blob/main/docker-compose.yml) as an example.
 
 
         You may also have a look at [our demo 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/).
         This will allow you to do very flexible deployments as described in the [FastAPI documentation](https://fastapi.tiangolo.com/deployment/).

+ 22 - 24
website/more_documentation/aggrid_documentation.py

@@ -93,23 +93,15 @@ def more() -> None:
     @text_demo('AG Grid with Conditional Cell Formatting', '''
     @text_demo('AG Grid with Conditional Cell Formatting', '''
         This demo shows how to use [cellClassRules](https://www.ag-grid.com/javascript-grid-cell-styles/#cell-class-rules)
         This demo shows how to use [cellClassRules](https://www.ag-grid.com/javascript-grid-cell-styles/#cell-class-rules)
         to conditionally format cells based on their values.
         to conditionally format cells based on their values.
-        Since it is currently not possible to use the `cellClassRules` option in the `columnDefs` option,
-        we use the `run_javascript` method to set the `cellClassRules` option after the grid has been created.
-        The timer is used to delay the execution of the javascript code until the grid has been created.
-        You can also use `app.on_connect` instead.
     ''')
     ''')
     def aggrid_with_conditional_cell_formatting():
     def aggrid_with_conditional_cell_formatting():
-        ui.html('''
-            <style>
-            .cell-fail { background-color: #f6695e; }
-            .cell-pass { background-color: #70bf73; }
-           </style>
-        ''')
-
-        grid = ui.aggrid({
+        ui.aggrid({
             'columnDefs': [
             'columnDefs': [
                 {'headerName': 'Name', 'field': 'name'},
                 {'headerName': 'Name', 'field': 'name'},
-                {'headerName': 'Age', 'field': 'age'},
+                {'headerName': 'Age', 'field': 'age', 'cellClassRules': {
+                    'bg-red-300': 'x < 21',
+                    'bg-green-300': 'x >= 21',
+                }},
             ],
             ],
             'rowData': [
             'rowData': [
                 {'name': 'Alice', 'age': 18},
                 {'name': 'Alice', 'age': 18},
@@ -118,17 +110,6 @@ def more() -> None:
             ],
             ],
         })
         })
 
 
-        async def format() -> None:
-            await ui.run_javascript(f'''
-                getElement({grid.id}).gridOptions.columnApi.getColumn("age").getColDef().cellClassRules = {{
-                    "cell-fail": x => x.value < 21,
-                    "cell-pass": x => x.value >= 21,
-                }};
-                getElement({grid.id}).gridOptions.api.refreshCells();
-            ''', respond=False)
-
-        ui.timer(0, format, once=True)
-
     @text_demo('Create Grid from Pandas Dataframe', '''
     @text_demo('Create Grid from Pandas Dataframe', '''
         You can create an AG Grid from a Pandas Dataframe using the `from_pandas` method.
         You can create an AG Grid from a Pandas Dataframe using the `from_pandas` method.
         This method takes a Pandas Dataframe as input and returns an AG Grid.
         This method takes a Pandas Dataframe as input and returns an AG Grid.
@@ -153,3 +134,20 @@ def more() -> None:
                 {'name': 'Facebook', 'url': '<a href="https://facebook.com">https://facebook.com</a>'},
                 {'name': 'Facebook', 'url': '<a href="https://facebook.com">https://facebook.com</a>'},
             ],
             ],
         }, html_columns=[1])
         }, html_columns=[1])
+
+    @text_demo('Respond to an AG Grid event', '''
+        All AG Grid events are passed through to NiceGUI via the AG Grid global listener.
+        These events can be subscribed to using the `.on()` method.
+    ''')
+    def aggrid_with_html_columns():
+        ui.aggrid({
+            'columnDefs': [
+                {'headerName': 'Name', 'field': 'name'},
+                {'headerName': 'Age', 'field': 'age'},
+            ],
+            'rowData': [
+                {'name': 'Alice', 'age': 18},
+                {'name': 'Bob', 'age': 21},
+                {'name': 'Carol', 'age': 42},
+            ],
+        }).on('cellClicked', lambda event: ui.notify(f'Cell value: {event.args["value"]}'))

+ 2 - 2
website/more_documentation/color_picker_documentation.py

@@ -2,5 +2,5 @@ from nicegui import ui
 
 
 
 
 def main_demo() -> None:
 def main_demo() -> None:
-    picker = ui.color_picker(on_pick=lambda e: button.style(f'background-color:{e.color}!important'))
-    button = ui.button(on_click=picker.open, icon='colorize')
+    with ui.button(icon='colorize') as button:
+        ui.color_picker(on_pick=lambda e: button.style(f'background-color:{e.color}!important'))

+ 7 - 0
website/more_documentation/image_documentation.py

@@ -31,3 +31,10 @@ def more() -> None:
 
 
         src = 'https://assets1.lottiefiles.com/datafiles/HN7OcWNnoqje6iXIiZdWzKxvLIbfeCGTmvXmEm1h/data.json'
         src = 'https://assets1.lottiefiles.com/datafiles/HN7OcWNnoqje6iXIiZdWzKxvLIbfeCGTmvXmEm1h/data.json'
         ui.html(f'<lottie-player src="{src}" loop autoplay />').classes('w-full')
         ui.html(f'<lottie-player src="{src}" loop autoplay />').classes('w-full')
+
+    @text_demo('Image link', '''
+        Images can link to another page by wrapping them in a [ui.link](https://nicegui.io/documentation/link).
+    ''')
+    def link():
+        with ui.link(target='https://github.com/zauberzeug/nicegui'):
+            ui.image('https://picsum.photos/id/41/640/360').classes('w-64')

+ 9 - 0
website/more_documentation/link_documentation.py

@@ -43,3 +43,12 @@ def more() -> None:
         ui.label('Go to other page')
         ui.label('Go to other page')
         ui.link('... with path', '/some_other_page')
         ui.link('... with path', '/some_other_page')
         ui.link('... with function reference', my_page)
         ui.link('... with function reference', my_page)
+
+    @text_demo('Link from images and other elements', '''
+        By nesting elements inside a link you can make the whole element clickable.
+        This works with all elements but is most useful for non-interactive elements like 
+        [ui.image](/documentation/image), [ui.avatar](/documentation/image) etc.
+    ''')
+    def link_from_elements():
+        with ui.link(target='https://github.com/zauberzeug/nicegui'):
+            ui.image('https://picsum.photos/id/41/640/360').classes('w-64')

+ 13 - 0
website/more_documentation/scene_documentation.py

@@ -76,3 +76,16 @@ def more() -> None:
             sphere = scene.sphere()
             sphere = scene.sphere()
 
 
         ui.switch('draggable sphere', on_change=lambda e: sphere.draggable(e.value))
         ui.switch('draggable sphere', on_change=lambda e: sphere.draggable(e.value))
+
+    @text_demo('Rendering point clouds', '''
+        You can render point clouds using the `point_cloud` method.
+        The `points` argument is a list of point coordinates, and the `colors` argument is a list of RGB colors (0..1).
+    ''')
+    def point_clouds() -> None:
+        import numpy as np
+
+        with ui.scene().classes('w-full h-64') as scene:
+            x, y = np.meshgrid(np.linspace(-3, 3), np.linspace(-3, 3))
+            z = np.sin(x) * np.cos(y) + 1
+            points = np.dstack([x, y, z]).reshape(-1, 3)
+            scene.point_cloud(points=points, colors=points, point_size=0.1)

+ 15 - 0
website/more_documentation/table_documentation.py

@@ -186,3 +186,18 @@ def more() -> None:
             {'name': 'Carl', 'age': 42},
             {'name': 'Carl', 'age': 42},
         ]
         ]
         ui.table(columns=columns, rows=rows, row_key='name')
         ui.table(columns=columns, rows=rows, row_key='name')
+
+    @text_demo('Toggle fullscreen', '''
+        You can toggle the fullscreen mode of a table using the `toggle_fullscreen()` method.
+    ''')
+    def toggle_fullscreen():
+        table = ui.table(
+            columns=[{'name': 'name', 'label': 'Name', 'field': 'name'}],
+            rows=[{'name': 'Alice'}, {'name': 'Bob'}, {'name': 'Carol'}],
+        ).classes('w-full')
+
+        with table.add_slot('top-left'):
+            def toggle() -> None:
+                table.toggle_fullscreen()
+                button.props('icon=fullscreen_exit' if table.is_fullscreen else 'icon=fullscreen')
+            button = ui.button('Toggle fullscreen', icon='fullscreen', on_click=toggle).props('flat')

+ 21 - 0
website/more_documentation/tree_documentation.py

@@ -33,3 +33,24 @@ def more() -> None:
         tree.add_slot('default-body', '''
         tree.add_slot('default-body', '''
             <span :props="props">Description: "{{ props.node.description }}"</span>
             <span :props="props">Description: "{{ props.node.description }}"</span>
         ''')
         ''')
+
+    @text_demo('Expand programmatically', '''
+        The tree can be expanded programmatically by modifying the "expanded" prop.
+    ''')
+    def expand_programmatically():
+        from typing import List
+
+        def expand(node_ids: List[str]) -> None:
+            t._props['expanded'] = node_ids
+            t.update()
+
+        with ui.row():
+            ui.button('all', on_click=lambda: expand(['A', 'B']))
+            ui.button('A', on_click=lambda: expand(['A']))
+            ui.button('B', on_click=lambda: expand(['B']))
+            ui.button('none', on_click=lambda: expand([]))
+
+        t = ui.tree([
+            {'id': 'A', 'children': [{'id': 'A1'}, {'id': 'A2'}]},
+            {'id': 'B', 'children': [{'id': 'B1'}, {'id': 'B2'}]},
+        ], label_key='id')

+ 2 - 1
website/search.py

@@ -32,7 +32,8 @@ class Search:
                 ui.icon('search', size='2em')
                 ui.icon('search', size='2em')
                 ui.input(placeholder='Search documentation', on_change=self.handle_input) \
                 ui.input(placeholder='Search documentation', on_change=self.handle_input) \
                     .classes('flex-grow').props('borderless autofocus')
                     .classes('flex-grow').props('borderless autofocus')
-                ui.button('ESC').props('padding="2px 8px" outline size=sm color=grey-5').classes('shadow')
+                ui.button('ESC', on_click=self.dialog.close) \
+                    .props('padding="2px 8px" outline size=sm color=grey-5').classes('shadow')
             ui.separator()
             ui.separator()
             self.results = ui.element('q-list').classes('w-full').props('separator')
             self.results = ui.element('q-list').classes('w-full').props('separator')
         ui.keyboard(self.handle_keypress)
         ui.keyboard(self.handle_keypress)

Fișier diff suprimat deoarece este prea mare
+ 39 - 4
website/static/search_index.json


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff