Browse Source

Merge commit 'c63828ef2c7b8f464b7fff43784a3f8b7802f6a0' into v1.3
resolved merge conflict in events.py

Rodja Trappe 1 year ago
parent
commit
f6543a56e0

+ 13 - 3
.github/workflows/test.yml

@@ -25,7 +25,7 @@ jobs:
           poetry config virtualenvs.create false
           poetry config virtualenvs.create false
           poetry install
           poetry install
           # install packages to run the examples
           # install packages to run the examples
-          pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy
+          pip install opencv-python opencv-contrib-python-headless httpx replicate langchain openai simpy tortoise-orm
           pip install -r tests/requirements.txt
           pip install -r tests/requirements.txt
           # try fix issue with importlib_resources
           # try fix issue with importlib_resources
           pip install importlib-resources
           pip install importlib-resources
@@ -46,7 +46,7 @@ jobs:
   slack:
   slack:
     needs:
     needs:
       - test
       - test
-    if: always() # also execute when test fails
+    if: always() # also execute when test fail
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
       - name: Determine if we need to notify
       - name: Determine if we need to notify
@@ -55,8 +55,18 @@ jobs:
         with:
         with:
           needs_context: ${{ toJson(needs) }}
           needs_context: ${{ toJson(needs) }}
           github_token: ${{ secrets.GITHUB_TOKEN }}
           github_token: ${{ secrets.GITHUB_TOKEN }}
+      - name: Check if secret exists
+        id: check_secret
+        env:
+          SLACK_WEBHOOK: ${{ secrets.SLACK_ROBOTICS_CI_WEBHOOK }}
+        run: |
+          if [[ -z "$SLACK_WEBHOOK" ]]; then
+            echo "slack_webhook_exists=false" >> $GITHUB_ENV
+          else
+            echo "slack_webhook_exists=true" >> $GITHUB_ENV
+          fi
       - name: Slack workflow notification
       - name: Slack workflow notification
-        if: steps.should_notify.outputs.should_send_message == 'yes'
+        if: steps.should_notify.outputs.should_send_message == 'yes' && env.slack_webhook_exists == 'true'
         uses: Gamesight/slack-workflow-status@master
         uses: Gamesight/slack-workflow-status@master
         with:
         with:
           repo_token: ${{ secrets.GITHUB_TOKEN }}
           repo_token: ${{ secrets.GITHUB_TOKEN }}

+ 1 - 0
.gitignore

@@ -9,3 +9,4 @@ tests/media/
 venv
 venv
 .idea
 .idea
 .nicegui/
 .nicegui/
+*.sqlite*

+ 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.2.23
-date-released: '2023-06-26'
+version: v1.2.24
+date-released: '2023-06-30'
 url: https://github.com/zauberzeug/nicegui
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.8083457
+doi: 10.5281/zenodo.8098592

+ 6 - 5
examples/script_executor/main.py

@@ -29,11 +29,12 @@ async def run_command(command: str) -> None:
 with ui.dialog() as dialog, ui.card():
 with ui.dialog() as dialog, ui.card():
     result = ui.markdown()
     result = ui.markdown()
 
 
-commands = ['python3 hello.py', 'python3 hello.py NiceGUI', 'python3 slow.py']
-with ui.row():
-    for command in commands:
-        ui.button(command, on_click=lambda command=command: run_command(command)).props('no-caps')
-
+ui.button('python3 hello.py', on_click=lambda: run_command('python3 hello.py')).props('no-caps')
+ui.button('python3 slow.py', on_click=lambda: run_command('python3 slow.py')).props('no-caps')
+with ui.row().classes('items-center'):
+    ui.button('python3 hello.py "<message>"', on_click=lambda: run_command(f'python3 hello.py "{message.value}"')) \
+        .props('no-caps')
+    message = ui.input('message', value='NiceGUI')
 
 
 # NOTE on windows reload must be disabled to make asyncio.create_subprocess_exec work (see https://github.com/zauberzeug/nicegui/issues/486)
 # NOTE on windows reload must be disabled to make asyncio.create_subprocess_exec work (see https://github.com/zauberzeug/nicegui/issues/486)
 ui.run(reload=platform.system() != "Windows")
 ui.run(reload=platform.system() != "Windows")

+ 1 - 1
examples/sqlite_database/.gitignore

@@ -1 +1 @@
-*.db
+*.sqlite*

+ 40 - 70
examples/sqlite_database/main.py

@@ -1,79 +1,49 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
-import sqlite3
-from pathlib import Path
-from typing import Any, Dict
+from typing import List
 
 
-from nicegui import ui
+import models
+from tortoise.contrib.fastapi import register_tortoise
 
 
-DB_FILE = Path(__file__).parent / 'users.db'
-DB_FILE.touch()
-conn = sqlite3.connect(DB_FILE, check_same_thread=False)
-cursor = conn.cursor()
-cursor.execute('CREATE TABLE IF NOT EXISTS users (id integer primary key AUTOINCREMENT, name text, age integer)')
-conn.commit()
+from nicegui import app, ui
 
 
-
-@ui.refreshable
-def users_ui() -> None:
-    cursor.execute('SELECT * FROM users')
-    for row in cursor.fetchall():
-        user = {'id': row[0], 'name': row[1], 'age': row[2]}
-        with ui.card():
-            with ui.row().classes('justify-between w-full'):
-                ui.label(user['id'])
-                ui.label(user['name'])
-                ui.label(user['age'])
-            with ui.row():
-                ui.button('edit', on_click=lambda user=user: open_dialog(user))
-                ui.button('delete', on_click=lambda user=user: delete(user), color='red')
-
-
-def create() -> None:
-    cursor.execute('INSERT INTO users (name, age) VALUES (?, ?)', (name.value, age.value))
-    conn.commit()
-    ui.notify(f'Created new user {name.value}')
-    name.value = ''
-    age.value = None
-    users_ui.refresh()
-
-
-def update() -> None:
-    query = 'UPDATE users SET name=?, age=? WHERE id=?'
-    cursor.execute(query, (dialog_name.value, dialog_age.value, dialog_id))
-    conn.commit()
-    ui.notify(f'Updated user {dialog_name.value}')
-    dialog.close()
-    users_ui.refresh()
+register_tortoise(
+    app,
+    db_url='sqlite://db.sqlite3',
+    modules={'models': ['models']},  # tortoise will look for models in this main module
+    generate_schemas=True,  # in production you should use version control migrations instead
+)
 
 
 
 
-def delete(user: Dict[str, Any]) -> None:
-    cursor.execute('DELETE from users WHERE id=?', (user['id'],))
-    conn.commit()
-    ui.notify(f'Deleted user {user["name"]}')
-    users_ui.refresh()
-
-
-def open_dialog(user: Dict[str, Any]) -> None:
-    global dialog_id
-    dialog_id = user['id']
-    dialog_name.value = user['name']
-    dialog_age.value = user['age']
-    dialog.open()
-
-
-name = ui.input(label='Name')
-age = ui.number(label='Age', format='%.0f')
-ui.button('Add new user', on_click=create)
-
-users_ui()
+@ui.refreshable
+async def list_of_users() -> None:
+    async def delete(user: models.User) -> None:
+        await user.delete()
+        list_of_users.refresh()
 
 
-with ui.dialog() as dialog:
-    with ui.card():
-        dialog_id = None
-        dialog_name = ui.input('Name')
-        dialog_age = ui.number('Age', format='%.0f')
-        with ui.row():
-            ui.button('Save', on_click=update)
-            ui.button('Close', on_click=dialog.close).props('outline')
+    users: List[models.User] = await models.User.all()
+    for user in reversed(users):
+        with ui.card():
+            with ui.row().classes('items-center'):
+                ui.input('Name', on_change=user.save) \
+                    .bind_value(user, 'name').on('blur', list_of_users.refresh)
+                ui.number('Age', on_change=user.save, format='%.0f') \
+                    .bind_value(user, 'age').on('blur', list_of_users.refresh).classes('w-20')
+                ui.button(icon='delete', on_click=lambda u=user: delete(u)).props('flat')
+
+
+@ui.page('/')
+async def index():
+    async def create() -> None:
+        await models.User.create(name=name.value, age=age.value or 0)
+        name.value = ''
+        age.value = None
+        list_of_users.refresh()
+
+    with ui.column().classes('mx-auto'):
+        with ui.row().classes('w-full items-center px-4'):
+            name = ui.input(label='Name')
+            age = ui.number(label='Age', format='%.0f').classes('w-20')
+            ui.button(on_click=create, icon='add').props('flat').classes('ml-auto')
+        await list_of_users()
 
 
 ui.run()
 ui.run()

+ 7 - 0
examples/sqlite_database/models.py

@@ -0,0 +1,7 @@
+from tortoise import fields, models
+
+
+class User(models.Model):
+    id = fields.IntField(pk=True)
+    name = fields.CharField(max_length=255)
+    age = fields.IntField()

+ 1 - 0
examples/sqlite_database/requirements.txt

@@ -0,0 +1 @@
+tortoise-orm

+ 1 - 1
main.py

@@ -281,7 +281,7 @@ async def index_page(client: Client) -> None:
             example_link('Single Page App', 'navigate without reloading the page')
             example_link('Single Page App', 'navigate without reloading the page')
             example_link('Chat App', 'a simple chat app')
             example_link('Chat App', 'a simple chat app')
             example_link('Chat with AI', 'a simple chat app with AI')
             example_link('Chat with AI', 'a simple chat app with AI')
-            example_link('SQLite Database', 'CRUD operations on a SQLite database')
+            example_link('SQLite Database', 'CRUD operations on a SQLite database with async-support through Tortoise ORM')
             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')

+ 2 - 0
nicegui/background_tasks.py

@@ -36,6 +36,8 @@ def create_lazy(coroutine: Awaitable[T], *, name: str) -> None:
     If a third task with the same name is created while the first one is still running, the second one is discarded.
     If a third task with the same name is created while the first one is still running, the second one is discarded.
     """
     """
     if name in lazy_tasks_running:
     if name in lazy_tasks_running:
+        if name in lazy_tasks_waiting:
+            asyncio.Task(lazy_tasks_waiting[name]).cancel()
         lazy_tasks_waiting[name] = coroutine
         lazy_tasks_waiting[name] = coroutine
         return
         return
 
 

+ 1 - 1
nicegui/client.py

@@ -36,7 +36,7 @@ class Client:
         self.shared = shared
         self.shared = shared
 
 
         with Element('q-layout', _client=self).props('view="HHH LpR FFF"').classes('nicegui-layout') as self.layout:
         with Element('q-layout', _client=self).props('view="HHH LpR FFF"').classes('nicegui-layout') as self.layout:
-            with Element('q-page-container'):
+            with Element('q-page-container') as self.page_container:
                 with Element('q-page'):
                 with Element('q-page'):
                     self.content = Element('div').classes('nicegui-content')
                     self.content = Element('div').classes('nicegui-content')
 
 

+ 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://material.io/icons/>`_ is a reference of possible names.
+        `Here <https://fonts.google.com/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

+ 9 - 5
nicegui/events.py

@@ -284,12 +284,16 @@ def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArgument
     if handler is None:
     if handler is None:
         return
         return
     try:
     try:
-        no_arguments = not any(p.default is Parameter.empty for p in signature(handler).parameters.values())
-        assert arguments.sender.parent_slot is not None
-        if arguments.sender.is_ignoring_events:
+        expects_arguments = any(p.default is Parameter.empty and
+                                p.kind is not Parameter.VAR_POSITIONAL and
+                                p.kind is not Parameter.VAR_KEYWORD
+                                for p in signature(handler).parameters.values())
+        sender = arguments.sender if isinstance(arguments, EventArguments) else sender
+        assert sender is not None and sender.parent_slot is not None
+        if sender.is_ignoring_events:
             return
             return
-        with arguments.sender.parent_slot:
-            result = handler() if no_arguments else handler(arguments)
+        with sender.parent_slot:
+            result = handler(arguments) if expects_arguments else handler()
         if isinstance(result, Awaitable):
         if isinstance(result, Awaitable):
             async def wait_for_result():
             async def wait_for_result():
                 with arguments.sender.parent_slot:
                 with arguments.sender.parent_slot:

+ 3 - 1
nicegui/functions/refreshable.py

@@ -61,12 +61,14 @@ class refreshable:
         self.targets.append(target)
         self.targets.append(target)
         return target.run(self.func)
         return target.run(self.func)
 
 
-    def refresh(self) -> None:
+    def refresh(self, *args: Any, **kwargs: Any) -> None:
         self.prune()
         self.prune()
         for target in self.targets:
         for target in self.targets:
             if target.instance != self.instance:
             if target.instance != self.instance:
                 continue
                 continue
             target.container.clear()
             target.container.clear()
+            target.args = args or target.args
+            target.kwargs.update(kwargs)
             result = target.run(self.func)
             result = target.run(self.func)
             if is_coroutine_function(self.func):
             if is_coroutine_function(self.func):
                 assert result is not None
                 assert result is not None

+ 2 - 3
nicegui/functions/timer.py

@@ -1,10 +1,9 @@
 import asyncio
 import asyncio
 import time
 import time
-from typing import Any, Callable, Optional
+from typing import Any, Awaitable, Callable, Optional
 
 
 from .. import background_tasks, globals
 from .. import background_tasks, globals
 from ..binding import BindableProperty
 from ..binding import BindableProperty
-from ..helpers import is_coroutine_function
 from ..slot import Slot
 from ..slot import Slot
 
 
 
 
@@ -89,7 +88,7 @@ class Timer:
         try:
         try:
             assert self.callback is not None
             assert self.callback is not None
             result = self.callback()
             result = self.callback()
-            if is_coroutine_function(self.callback):
+            if isinstance(result, Awaitable):
                 await result
                 await result
         except Exception as e:
         except Exception as e:
             globals.handle_exception(e)
             globals.handle_exception(e)

+ 22 - 0
nicegui/page_layout.py

@@ -29,6 +29,9 @@ class Header(ValueElement):
                  elevated: bool = False) -> None:
                  elevated: bool = False) -> None:
         '''Header
         '''Header
 
 
+        Note: The header is automatically placed above other layout elements in the DOM to improve accessibility.
+        To change the order, use the `move` method.
+
         :param value: whether the header is already opened (default: `True`)
         :param value: whether the header is already opened (default: `True`)
         :param fixed: whether the header should be fixed to the top of the page (default: `True`)
         :param fixed: whether the header should be fixed to the top of the page (default: `True`)
         :param bordered: whether the header should have a border (default: `False`)
         :param bordered: whether the header should have a border (default: `False`)
@@ -43,6 +46,8 @@ class Header(ValueElement):
         code[1] = 'H' if fixed else 'h'
         code[1] = 'H' if fixed else 'h'
         self.client.layout._props['view'] = ''.join(code)
         self.client.layout._props['view'] = ''.join(code)
 
 
+        self.move(target_index=0)
+
     def toggle(self):
     def toggle(self):
         '''Toggle the header'''
         '''Toggle the header'''
         self.value = not self.value
         self.value = not self.value
@@ -68,6 +73,9 @@ class Drawer(Element):
                  bottom_corner: bool = False) -> None:
                  bottom_corner: bool = False) -> None:
         '''Drawer
         '''Drawer
 
 
+        Note: Depending on the side, the drawer is automatically placed above or below the main page container in the DOM to improve accessibility.
+        To change the order, use the `move` method.
+
         :param side: side of the page where the drawer should be placed (`left` or `right`)
         :param side: side of the page where the drawer should be placed (`left` or `right`)
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
@@ -92,6 +100,9 @@ class Drawer(Element):
         code[8 if side == 'left' else 10] = side[0].lower() if bottom_corner else 'f'
         code[8 if side == 'left' else 10] = side[0].lower() if bottom_corner else 'f'
         self.client.layout._props['view'] = ''.join(code)
         self.client.layout._props['view'] = ''.join(code)
 
 
+        page_container_index = self.client.layout.default_slot.children.index(self.client.page_container)
+        self.move(target_index=page_container_index if side == 'left' else page_container_index + 1)
+
     def toggle(self) -> None:
     def toggle(self) -> None:
         '''Toggle the drawer'''
         '''Toggle the drawer'''
         self.run_method('toggle')
         self.run_method('toggle')
@@ -116,6 +127,9 @@ class LeftDrawer(Drawer):
                  bottom_corner: bool = False) -> None:
                  bottom_corner: bool = False) -> None:
         '''Left drawer
         '''Left drawer
 
 
+        Note: The left drawer is automatically placed above the main page container in the DOM to improve accessibility.
+        To change the order, use the `move` method.
+
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the drawer should have a border (default: `False`)
         :param bordered: whether the drawer should have a border (default: `False`)
@@ -143,6 +157,9 @@ class RightDrawer(Drawer):
                  bottom_corner: bool = False) -> None:
                  bottom_corner: bool = False) -> None:
         '''Right drawer
         '''Right drawer
 
 
+        Note: The right drawer is automatically placed below the main page container in the DOM to improve accessibility.
+        To change the order, use the `move` method.
+
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param value: whether the drawer is already opened (default: `None`, i.e. if layout width is above threshold)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param fixed: whether the drawer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the drawer should have a border (default: `False`)
         :param bordered: whether the drawer should have a border (default: `False`)
@@ -168,6 +185,9 @@ class Footer(ValueElement):
                  elevated: bool = False) -> None:
                  elevated: bool = False) -> None:
         '''Footer
         '''Footer
 
 
+        Note: The footer is automatically placed below other layout elements in the DOM to improve accessibility.
+        To change the order, use the `move` method.
+
         :param value: whether the footer is already opened (default: `True`)
         :param value: whether the footer is already opened (default: `True`)
         :param fixed: whether the footer is fixed or scrolls with the content (default: `True`)
         :param fixed: whether the footer is fixed or scrolls with the content (default: `True`)
         :param bordered: whether the footer should have a border (default: `False`)
         :param bordered: whether the footer should have a border (default: `False`)
@@ -182,6 +202,8 @@ class Footer(ValueElement):
         code[9] = 'F' if fixed else 'f'
         code[9] = 'F' if fixed else 'f'
         self.client.layout._props['view'] = ''.join(code)
         self.client.layout._props['view'] = ''.join(code)
 
 
+        self.move(target_index=-1)
+
     def toggle(self) -> None:
     def toggle(self) -> None:
         '''Toggle the footer'''
         '''Toggle the footer'''
         self.value = not self.value
         self.value = not self.value

+ 140 - 3
poetry.lock

@@ -1,9 +1,10 @@
-# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry and should not be changed by hand.
 
 
 [[package]]
 [[package]]
 name = "aiofiles"
 name = "aiofiles"
 version = "23.1.0"
 version = "23.1.0"
 description = "File support for asyncio."
 description = "File support for asyncio."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7,<4.0"
 python-versions = ">=3.7,<4.0"
 files = [
 files = [
@@ -15,6 +16,7 @@ files = [
 name = "anyio"
 name = "anyio"
 version = "3.7.0"
 version = "3.7.0"
 description = "High level compatibility layer for multiple asynchronous event loop implementations"
 description = "High level compatibility layer for multiple asynchronous event loop implementations"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -37,6 +39,7 @@ trio = ["trio (<0.22)"]
 name = "asttokens"
 name = "asttokens"
 version = "2.2.1"
 version = "2.2.1"
 description = "Annotate AST trees with source code positions"
 description = "Annotate AST trees with source code positions"
+category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -54,6 +57,7 @@ test = ["astroid", "pytest"]
 name = "async-generator"
 name = "async-generator"
 version = "1.10"
 version = "1.10"
 description = "Async generators and context managers for Python 3.5+"
 description = "Async generators and context managers for Python 3.5+"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.5"
 python-versions = ">=3.5"
 files = [
 files = [
@@ -65,6 +69,7 @@ files = [
 name = "atomicwrites"
 name = "atomicwrites"
 version = "1.4.1"
 version = "1.4.1"
 description = "Atomic file writes."
 description = "Atomic file writes."
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
 files = [
@@ -75,6 +80,7 @@ files = [
 name = "attrs"
 name = "attrs"
 version = "23.1.0"
 version = "23.1.0"
 description = "Classes Without Boilerplate"
 description = "Classes Without Boilerplate"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -96,6 +102,7 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte
 name = "autopep8"
 name = "autopep8"
 version = "1.7.0"
 version = "1.7.0"
 description = "A tool that automatically formats Python code to conform to the PEP 8 style guide"
 description = "A tool that automatically formats Python code to conform to the PEP 8 style guide"
+category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -111,6 +118,7 @@ toml = "*"
 name = "bidict"
 name = "bidict"
 version = "0.22.1"
 version = "0.22.1"
 description = "The bidirectional mapping library for Python."
 description = "The bidirectional mapping library for Python."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -127,6 +135,7 @@ test = ["hypothesis", "pytest", "pytest-benchmark[histogram]", "pytest-cov", "py
 name = "bottle"
 name = "bottle"
 version = "0.12.25"
 version = "0.12.25"
 description = "Fast and simple WSGI-framework for small web-applications."
 description = "Fast and simple WSGI-framework for small web-applications."
+category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -138,6 +147,7 @@ files = [
 name = "certifi"
 name = "certifi"
 version = "2023.5.7"
 version = "2023.5.7"
 description = "Python package for providing Mozilla's CA Bundle."
 description = "Python package for providing Mozilla's CA Bundle."
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -149,6 +159,7 @@ files = [
 name = "cffi"
 name = "cffi"
 version = "1.15.1"
 version = "1.15.1"
 description = "Foreign Function Interface for Python calling C code."
 description = "Foreign Function Interface for Python calling C code."
+category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -225,6 +236,7 @@ pycparser = "*"
 name = "charset-normalizer"
 name = "charset-normalizer"
 version = "3.1.0"
 version = "3.1.0"
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7.0"
 python-versions = ">=3.7.0"
 files = [
 files = [
@@ -309,6 +321,7 @@ files = [
 name = "click"
 name = "click"
 version = "8.1.3"
 version = "8.1.3"
 description = "Composable command line interface toolkit"
 description = "Composable command line interface toolkit"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -324,6 +337,7 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
 name = "colorama"
 name = "colorama"
 version = "0.4.6"
 version = "0.4.6"
 description = "Cross-platform colored terminal text."
 description = "Cross-platform colored terminal text."
+category = "main"
 optional = false
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 files = [
 files = [
@@ -335,6 +349,7 @@ files = [
 name = "contourpy"
 name = "contourpy"
 version = "1.0.7"
 version = "1.0.7"
 description = "Python library for calculating contours of 2D quadrilateral grids"
 description = "Python library for calculating contours of 2D quadrilateral grids"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
@@ -409,6 +424,7 @@ test-no-images = ["pytest"]
 name = "cycler"
 name = "cycler"
 version = "0.11.0"
 version = "0.11.0"
 description = "Composable style cycles"
 description = "Composable style cycles"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -420,6 +436,7 @@ files = [
 name = "debugpy"
 name = "debugpy"
 version = "1.6.7"
 version = "1.6.7"
 description = "An implementation of the Debug Adapter Protocol for Python"
 description = "An implementation of the Debug Adapter Protocol for Python"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -447,6 +464,7 @@ files = [
 name = "docutils"
 name = "docutils"
 version = "0.19"
 version = "0.19"
 description = "Docutils -- Python Documentation Utilities"
 description = "Docutils -- Python Documentation Utilities"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -458,6 +476,7 @@ files = [
 name = "exceptiongroup"
 name = "exceptiongroup"
 version = "1.1.1"
 version = "1.1.1"
 description = "Backport of PEP 654 (exception groups)"
 description = "Backport of PEP 654 (exception groups)"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -472,6 +491,7 @@ test = ["pytest (>=6)"]
 name = "executing"
 name = "executing"
 version = "1.2.0"
 version = "1.2.0"
 description = "Get the currently executing AST node of a frame, and other information"
 description = "Get the currently executing AST node of a frame, and other information"
+category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -486,6 +506,7 @@ tests = ["asttokens", "littleutils", "pytest", "rich"]
 name = "fastapi"
 name = "fastapi"
 version = "0.95.2"
 version = "0.95.2"
 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"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -507,6 +528,7 @@ test = ["anyio[trio] (>=3.2.1,<4.0.0)", "black (==23.1.0)", "coverage[toml] (>=6
 name = "fastapi-socketio"
 name = "fastapi-socketio"
 version = "0.0.10"
 version = "0.0.10"
 description = "Easily integrate socket.io with your FastAPI app."
 description = "Easily integrate socket.io with your FastAPI app."
+category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -525,6 +547,7 @@ test = ["pytest"]
 name = "fonttools"
 name = "fonttools"
 version = "4.38.0"
 version = "4.38.0"
 description = "Tools to manipulate font files"
 description = "Tools to manipulate font files"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -550,6 +573,7 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
 name = "fonttools"
 name = "fonttools"
 version = "4.39.4"
 version = "4.39.4"
 description = "Tools to manipulate font files"
 description = "Tools to manipulate font files"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
@@ -575,6 +599,7 @@ woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
 name = "h11"
 name = "h11"
 version = "0.14.0"
 version = "0.14.0"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
 description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -589,6 +614,7 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
 name = "httptools"
 name = "httptools"
 version = "0.5.0"
 version = "0.5.0"
 description = "A collection of framework independent HTTP protocol utils."
 description = "A collection of framework independent HTTP protocol utils."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.5.0"
 python-versions = ">=3.5.0"
 files = [
 files = [
@@ -642,6 +668,7 @@ test = ["Cython (>=0.29.24,<0.30.0)"]
 name = "icecream"
 name = "icecream"
 version = "2.1.3"
 version = "2.1.3"
 description = "Never use print() to debug again; inspect variables, expressions, and program execution with a single, simple function call."
 description = "Never use print() to debug again; inspect variables, expressions, and program execution with a single, simple function call."
+category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -659,6 +686,7 @@ pygments = ">=2.2.0"
 name = "idna"
 name = "idna"
 version = "3.4"
 version = "3.4"
 description = "Internationalized Domain Names in Applications (IDNA)"
 description = "Internationalized Domain Names in Applications (IDNA)"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.5"
 python-versions = ">=3.5"
 files = [
 files = [
@@ -670,6 +698,7 @@ files = [
 name = "importlib-metadata"
 name = "importlib-metadata"
 version = "6.6.0"
 version = "6.6.0"
 description = "Read metadata from Python packages"
 description = "Read metadata from Python packages"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -690,6 +719,7 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag
 name = "iniconfig"
 name = "iniconfig"
 version = "2.0.0"
 version = "2.0.0"
 description = "brain-dead simple config-ini parsing"
 description = "brain-dead simple config-ini parsing"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -701,6 +731,7 @@ files = [
 name = "isort"
 name = "isort"
 version = "5.11.5"
 version = "5.11.5"
 description = "A Python utility / library to sort Python imports."
 description = "A Python utility / library to sort Python imports."
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7.0"
 python-versions = ">=3.7.0"
 files = [
 files = [
@@ -718,6 +749,7 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"]
 name = "itsdangerous"
 name = "itsdangerous"
 version = "2.1.2"
 version = "2.1.2"
 description = "Safely pass data to untrusted environments and back."
 description = "Safely pass data to untrusted environments and back."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -729,6 +761,7 @@ files = [
 name = "jinja2"
 name = "jinja2"
 version = "3.1.2"
 version = "3.1.2"
 description = "A very fast and expressive template engine."
 description = "A very fast and expressive template engine."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -746,6 +779,7 @@ i18n = ["Babel (>=2.7)"]
 name = "kiwisolver"
 name = "kiwisolver"
 version = "1.4.4"
 version = "1.4.4"
 description = "A fast implementation of the Cassowary constraint solver"
 description = "A fast implementation of the Cassowary constraint solver"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -826,6 +860,7 @@ typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
 name = "markdown2"
 name = "markdown2"
 version = "2.4.8"
 version = "2.4.8"
 description = "A fast and complete Python implementation of Markdown"
 description = "A fast and complete Python implementation of Markdown"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.5, <4"
 python-versions = ">=3.5, <4"
 files = [
 files = [
@@ -842,6 +877,7 @@ wavedrom = ["wavedrom"]
 name = "markupsafe"
 name = "markupsafe"
 version = "2.1.2"
 version = "2.1.2"
 description = "Safely add untrusted strings to HTML/XML markup."
 description = "Safely add untrusted strings to HTML/XML markup."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -901,6 +937,7 @@ files = [
 name = "matplotlib"
 name = "matplotlib"
 version = "3.5.3"
 version = "3.5.3"
 description = "Python plotting package"
 description = "Python plotting package"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -955,6 +992,7 @@ python-dateutil = ">=2.7"
 name = "matplotlib"
 name = "matplotlib"
 version = "3.7.1"
 version = "3.7.1"
 description = "Python plotting package"
 description = "Python plotting package"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
@@ -1016,6 +1054,7 @@ python-dateutil = ">=2.7"
 name = "numpy"
 name = "numpy"
 version = "1.21.1"
 version = "1.21.1"
 description = "NumPy is the fundamental package for array computing with Python."
 description = "NumPy is the fundamental package for array computing with Python."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1053,6 +1092,7 @@ files = [
 name = "numpy"
 name = "numpy"
 version = "1.24.3"
 version = "1.24.3"
 description = "Fundamental package for array computing in Python"
 description = "Fundamental package for array computing in Python"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
@@ -1090,6 +1130,7 @@ files = [
 name = "orjson"
 name = "orjson"
 version = "3.9.0"
 version = "3.9.0"
 description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
 description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1145,6 +1186,7 @@ files = [
 name = "outcome"
 name = "outcome"
 version = "1.2.0"
 version = "1.2.0"
 description = "Capture the outcome of Python function calls."
 description = "Capture the outcome of Python function calls."
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1159,6 +1201,7 @@ attrs = ">=19.2.0"
 name = "packaging"
 name = "packaging"
 version = "23.1"
 version = "23.1"
 description = "Core utilities for Python packages"
 description = "Core utilities for Python packages"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1170,6 +1213,7 @@ files = [
 name = "pandas"
 name = "pandas"
 version = "1.1.5"
 version = "1.1.5"
 description = "Powerful data structures for data analysis, time series, and statistics"
 description = "Powerful data structures for data analysis, time series, and statistics"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6.1"
 python-versions = ">=3.6.1"
 files = [
 files = [
@@ -1211,6 +1255,7 @@ test = ["hypothesis (>=3.58)", "pytest (>=4.0.2)", "pytest-xdist"]
 name = "pandas"
 name = "pandas"
 version = "2.0.2"
 version = "2.0.2"
 description = "Powerful data structures for data analysis, time series, and statistics"
 description = "Powerful data structures for data analysis, time series, and statistics"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.8"
 python-versions = ">=3.8"
 files = [
 files = [
@@ -1278,6 +1323,7 @@ xml = ["lxml (>=4.6.3)"]
 name = "pillow"
 name = "pillow"
 version = "9.5.0"
 version = "9.5.0"
 description = "Python Imaging Library (Fork)"
 description = "Python Imaging Library (Fork)"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1357,6 +1403,7 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
 name = "plotly"
 name = "plotly"
 version = "5.14.1"
 version = "5.14.1"
 description = "An open-source, interactive data visualization library for Python"
 description = "An open-source, interactive data visualization library for Python"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1372,6 +1419,7 @@ tenacity = ">=6.2.0"
 name = "pluggy"
 name = "pluggy"
 version = "1.0.0"
 version = "1.0.0"
 description = "plugin and hook calling mechanisms for python"
 description = "plugin and hook calling mechanisms for python"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1390,6 +1438,7 @@ testing = ["pytest", "pytest-benchmark"]
 name = "proxy-tools"
 name = "proxy-tools"
 version = "0.1.0"
 version = "0.1.0"
 description = "Proxy Implementation"
 description = "Proxy Implementation"
+category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -1400,6 +1449,7 @@ files = [
 name = "pscript"
 name = "pscript"
 version = "0.7.7"
 version = "0.7.7"
 description = "Python to JavaScript compiler."
 description = "Python to JavaScript compiler."
+category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -1411,6 +1461,7 @@ files = [
 name = "py"
 name = "py"
 version = "1.11.0"
 version = "1.11.0"
 description = "library with cross-python path, ini-parsing, io, code, log facilities"
 description = "library with cross-python path, ini-parsing, io, code, log facilities"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 files = [
 files = [
@@ -1422,6 +1473,7 @@ files = [
 name = "pycodestyle"
 name = "pycodestyle"
 version = "2.10.0"
 version = "2.10.0"
 description = "Python style guide checker"
 description = "Python style guide checker"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1433,6 +1485,7 @@ files = [
 name = "pycparser"
 name = "pycparser"
 version = "2.21"
 version = "2.21"
 description = "C parser in Python"
 description = "C parser in Python"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
 files = [
@@ -1444,6 +1497,7 @@ files = [
 name = "pydantic"
 name = "pydantic"
 version = "1.10.8"
 version = "1.10.8"
 description = "Data validation and settings management using python type hints"
 description = "Data validation and settings management using python type hints"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1496,6 +1550,7 @@ email = ["email-validator (>=1.0.3)"]
 name = "pygments"
 name = "pygments"
 version = "2.15.1"
 version = "2.15.1"
 description = "Pygments is a syntax highlighting package written in Python."
 description = "Pygments is a syntax highlighting package written in Python."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1510,6 +1565,7 @@ plugins = ["importlib-metadata"]
 name = "pyobjc-core"
 name = "pyobjc-core"
 version = "9.1.1"
 version = "9.1.1"
 description = "Python<->ObjC Interoperability Module"
 description = "Python<->ObjC Interoperability Module"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1526,6 +1582,7 @@ files = [
 name = "pyobjc-framework-cocoa"
 name = "pyobjc-framework-cocoa"
 version = "9.1.1"
 version = "9.1.1"
 description = "Wrappers for the Cocoa frameworks on macOS"
 description = "Wrappers for the Cocoa frameworks on macOS"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1545,6 +1602,7 @@ pyobjc-core = ">=9.1.1"
 name = "pyobjc-framework-webkit"
 name = "pyobjc-framework-webkit"
 version = "9.1.1"
 version = "9.1.1"
 description = "Wrappers for the framework WebKit on macOS"
 description = "Wrappers for the framework WebKit on macOS"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1562,6 +1620,7 @@ pyobjc-framework-Cocoa = ">=9.1.1"
 name = "pyparsing"
 name = "pyparsing"
 version = "3.0.9"
 version = "3.0.9"
 description = "pyparsing module - Classes and methods to define and execute parsing grammars"
 description = "pyparsing module - Classes and methods to define and execute parsing grammars"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6.8"
 python-versions = ">=3.6.8"
 files = [
 files = [
@@ -1576,6 +1635,7 @@ diagrams = ["jinja2", "railroad-diagrams"]
 name = "pysocks"
 name = "pysocks"
 version = "1.7.1"
 version = "1.7.1"
 description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information."
 description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information."
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 files = [
 files = [
@@ -1588,6 +1648,7 @@ files = [
 name = "pytest"
 name = "pytest"
 version = "6.2.5"
 version = "6.2.5"
 description = "pytest: simple powerful testing with Python"
 description = "pytest: simple powerful testing with Python"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1613,6 +1674,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm
 name = "pytest-asyncio"
 name = "pytest-asyncio"
 version = "0.19.0"
 version = "0.19.0"
 description = "Pytest support for asyncio"
 description = "Pytest support for asyncio"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1631,6 +1693,7 @@ testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy
 name = "pytest-base-url"
 name = "pytest-base-url"
 version = "2.0.0"
 version = "2.0.0"
 description = "pytest plugin for URL based testing"
 description = "pytest plugin for URL based testing"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7,<4.0"
 python-versions = ">=3.7,<4.0"
 files = [
 files = [
@@ -1646,6 +1709,7 @@ requests = ">=2.9"
 name = "pytest-html"
 name = "pytest-html"
 version = "3.2.0"
 version = "3.2.0"
 description = "pytest plugin for generating HTML reports"
 description = "pytest plugin for generating HTML reports"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1662,6 +1726,7 @@ pytest-metadata = "*"
 name = "pytest-metadata"
 name = "pytest-metadata"
 version = "2.0.4"
 version = "2.0.4"
 description = "pytest plugin for test session metadata"
 description = "pytest plugin for test session metadata"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7,<4.0"
 python-versions = ">=3.7,<4.0"
 files = [
 files = [
@@ -1676,6 +1741,7 @@ pytest = ">=3.0.0,<8.0.0"
 name = "pytest-selenium"
 name = "pytest-selenium"
 version = "4.0.1"
 version = "4.0.1"
 description = "pytest plugin for Selenium"
 description = "pytest plugin for Selenium"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1700,6 +1766,7 @@ test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest
 name = "pytest-variables"
 name = "pytest-variables"
 version = "2.0.0"
 version = "2.0.0"
 description = "pytest plugin for providing variables to tests/fixtures"
 description = "pytest plugin for providing variables to tests/fixtures"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7,<4.0"
 python-versions = ">=3.7,<4.0"
 files = [
 files = [
@@ -1719,6 +1786,7 @@ yaml = ["PyYAML"]
 name = "python-dateutil"
 name = "python-dateutil"
 version = "2.8.2"
 version = "2.8.2"
 description = "Extensions to the standard Python datetime module"
 description = "Extensions to the standard Python datetime module"
+category = "main"
 optional = false
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
 files = [
 files = [
@@ -1733,6 +1801,7 @@ six = ">=1.5"
 name = "python-dotenv"
 name = "python-dotenv"
 version = "0.21.1"
 version = "0.21.1"
 description = "Read key-value pairs from a .env file and set them as environment variables"
 description = "Read key-value pairs from a .env file and set them as environment variables"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1747,6 +1816,7 @@ cli = ["click (>=5.0)"]
 name = "python-engineio"
 name = "python-engineio"
 version = "4.4.1"
 version = "4.4.1"
 description = "Engine.IO server and client for Python"
 description = "Engine.IO server and client for Python"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1762,6 +1832,7 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
 name = "python-multipart"
 name = "python-multipart"
 version = "0.0.6"
 version = "0.0.6"
 description = "A streaming multipart parser for Python"
 description = "A streaming multipart parser for Python"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1776,6 +1847,7 @@ dev = ["atomicwrites (==1.2.1)", "attrs (==19.2.0)", "coverage (==6.5.0)", "hatc
 name = "python-socketio"
 name = "python-socketio"
 version = "5.8.0"
 version = "5.8.0"
 description = "Socket.IO server and client for Python"
 description = "Socket.IO server and client for Python"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1795,6 +1867,7 @@ client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
 name = "pythonnet"
 name = "pythonnet"
 version = "2.5.2"
 version = "2.5.2"
 description = ".Net and Mono integration for Python"
 description = ".Net and Mono integration for Python"
+category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -1818,6 +1891,7 @@ pycparser = "*"
 name = "pytz"
 name = "pytz"
 version = "2023.3"
 version = "2023.3"
 description = "World timezone definitions, modern and historical"
 description = "World timezone definitions, modern and historical"
+category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -1829,6 +1903,7 @@ files = [
 name = "pywebview"
 name = "pywebview"
 version = "4.1"
 version = "4.1"
 description = "Build GUI for your Python program with JavaScript, HTML, and CSS."
 description = "Build GUI for your Python program with JavaScript, HTML, and CSS."
+category = "main"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -1856,6 +1931,7 @@ qt = ["PyQt5", "QtPy", "pyqtwebengine"]
 name = "pyyaml"
 name = "pyyaml"
 version = "6.0"
 version = "6.0"
 description = "YAML parser and emitter for Python"
 description = "YAML parser and emitter for Python"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1905,6 +1981,7 @@ files = [
 name = "qtpy"
 name = "qtpy"
 version = "2.3.1"
 version = "2.3.1"
 description = "Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6)."
 description = "Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6)."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1922,6 +1999,7 @@ test = ["pytest (>=6,!=7.0.0,!=7.0.1)", "pytest-cov (>=3.0.0)", "pytest-qt"]
 name = "requests"
 name = "requests"
 version = "2.31.0"
 version = "2.31.0"
 description = "Python HTTP for Humans."
 description = "Python HTTP for Humans."
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1943,6 +2021,7 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 name = "secure"
 name = "secure"
 version = "0.3.0"
 version = "0.3.0"
 description = "A lightweight package that adds security headers for Python web frameworks."
 description = "A lightweight package that adds security headers for Python web frameworks."
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -1954,6 +2033,7 @@ files = [
 name = "selenium"
 name = "selenium"
 version = "4.9.1"
 version = "4.9.1"
 description = ""
 description = ""
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1971,6 +2051,7 @@ urllib3 = {version = ">=1.26,<3", extras = ["socks"]}
 name = "six"
 name = "six"
 version = "1.16.0"
 version = "1.16.0"
 description = "Python 2 and 3 compatibility utilities"
 description = "Python 2 and 3 compatibility utilities"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
 files = [
@@ -1982,6 +2063,7 @@ files = [
 name = "sniffio"
 name = "sniffio"
 version = "1.3.0"
 version = "1.3.0"
 description = "Sniff out which async library your code is running under"
 description = "Sniff out which async library your code is running under"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -1993,6 +2075,7 @@ files = [
 name = "sortedcontainers"
 name = "sortedcontainers"
 version = "2.4.0"
 version = "2.4.0"
 description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
 description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
+category = "dev"
 optional = false
 optional = false
 python-versions = "*"
 python-versions = "*"
 files = [
 files = [
@@ -2004,6 +2087,7 @@ files = [
 name = "starlette"
 name = "starlette"
 version = "0.27.0"
 version = "0.27.0"
 description = "The little ASGI library that shines."
 description = "The little ASGI library that shines."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2022,6 +2106,7 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyam
 name = "tenacity"
 name = "tenacity"
 version = "8.2.2"
 version = "8.2.2"
 description = "Retry code until it succeeds"
 description = "Retry code until it succeeds"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.6"
 python-versions = ">=3.6"
 files = [
 files = [
@@ -2036,6 +2121,7 @@ doc = ["reno", "sphinx", "tornado (>=4.5)"]
 name = "toml"
 name = "toml"
 version = "0.10.2"
 version = "0.10.2"
 description = "Python Library for Tom's Obvious, Minimal Language"
 description = "Python Library for Tom's Obvious, Minimal Language"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
 files = [
 files = [
@@ -2043,10 +2129,32 @@ 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"
+category = "dev"
+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.0"
 version = "0.22.0"
 description = "A friendly Python library for async concurrency and I/O"
 description = "A friendly Python library for async concurrency and I/O"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2068,6 +2176,7 @@ sortedcontainers = "*"
 name = "trio-websocket"
 name = "trio-websocket"
 version = "0.10.2"
 version = "0.10.2"
 description = "WebSocket library for Trio"
 description = "WebSocket library for Trio"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2084,6 +2193,7 @@ wsproto = ">=0.14"
 name = "typing-extensions"
 name = "typing-extensions"
 version = "4.6.3"
 version = "4.6.3"
 description = "Backported and Experimental Type Hints for Python 3.7+"
 description = "Backported and Experimental Type Hints for Python 3.7+"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2095,6 +2205,7 @@ files = [
 name = "tzdata"
 name = "tzdata"
 version = "2023.3"
 version = "2023.3"
 description = "Provider of IANA time zone data"
 description = "Provider of IANA time zone data"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=2"
 python-versions = ">=2"
 files = [
 files = [
@@ -2106,6 +2217,7 @@ files = [
 name = "urllib3"
 name = "urllib3"
 version = "2.0.2"
 version = "2.0.2"
 description = "HTTP library with thread-safe connection pooling, file post, and more."
 description = "HTTP library with thread-safe connection pooling, file post, and more."
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2126,6 +2238,7 @@ zstd = ["zstandard (>=0.18.0)"]
 name = "uvicorn"
 name = "uvicorn"
 version = "0.20.0"
 version = "0.20.0"
 description = "The lightning-fast ASGI server."
 description = "The lightning-fast ASGI server."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2141,7 +2254,7 @@ httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standar
 python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
 pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
 typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
 typing-extensions = {version = "*", markers = "python_version < \"3.8\""}
-uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
+uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
 watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
 websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
 websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
 
 
@@ -2152,6 +2265,7 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
 name = "uvloop"
 name = "uvloop"
 version = "0.17.0"
 version = "0.17.0"
 description = "Fast implementation of asyncio event loop on top of libuv"
 description = "Fast implementation of asyncio event loop on top of libuv"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2196,6 +2310,7 @@ test = ["Cython (>=0.29.32,<0.30.0)", "aiohttp", "flake8 (>=3.9.2,<3.10.0)", "my
 name = "vbuild"
 name = "vbuild"
 version = "0.8.1"
 version = "0.8.1"
 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 !"
+category = "main"
 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 = [
@@ -2210,6 +2325,7 @@ pscript = ">=0.7.0,<0.8.0"
 name = "watchfiles"
 name = "watchfiles"
 version = "0.18.1"
 version = "0.18.1"
 description = "Simple, modern and high performance file watching and code reload in python."
 description = "Simple, modern and high performance file watching and code reload in python."
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2236,10 +2352,29 @@ files = [
 [package.dependencies]
 [package.dependencies]
 anyio = ">=3.0.0"
 anyio = ">=3.0.0"
 
 
+[[package]]
+name = "webdriver-manager"
+version = "3.8.6"
+description = "Library provides the way to automatically manage drivers for different browsers"
+category = "dev"
+optional = false
+python-versions = ">=3.7"
+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"},
+]
+
+[package.dependencies]
+packaging = "*"
+python-dotenv = "*"
+requests = "*"
+tqdm = "*"
+
 [[package]]
 [[package]]
 name = "websockets"
 name = "websockets"
 version = "11.0.3"
 version = "11.0.3"
 description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
 description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2319,6 +2454,7 @@ files = [
 name = "wsproto"
 name = "wsproto"
 version = "1.2.0"
 version = "1.2.0"
 description = "WebSockets state-machine based protocol implementation"
 description = "WebSockets state-machine based protocol implementation"
+category = "dev"
 optional = false
 optional = false
 python-versions = ">=3.7.0"
 python-versions = ">=3.7.0"
 files = [
 files = [
@@ -2333,6 +2469,7 @@ h11 = ">=0.9.0,<1"
 name = "zipp"
 name = "zipp"
 version = "3.15.0"
 version = "3.15.0"
 description = "Backport of pathlib-compatible object wrapper for zip files"
 description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
 optional = false
 optional = false
 python-versions = ">=3.7"
 python-versions = ">=3.7"
 files = [
 files = [
@@ -2347,4 +2484,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
 [metadata]
 [metadata]
 lock-version = "2.0"
 lock-version = "2.0"
 python-versions = "^3.7"
 python-versions = "^3.7"
-content-hash = "01dd4e6d62f913d2f5206dcd946dc9804767c4b57b115ef69eb56a73213ae5e4"
+content-hash = "3e7add5739e467bad77fdaba15ee560582a60c65f965d4efb967ab2dcc63af5c"

+ 1 - 0
pyproject.toml

@@ -46,6 +46,7 @@ pandas = [
     { version = "^2.0.0", markers = "python_version >= '3.8'" },
     { version = "^2.0.0", markers = "python_version >= '3.8'" },
 ]
 ]
 secure = "^0.3.0"
 secure = "^0.3.0"
+webdriver-manager = "^3.8.6"
 
 
 [build-system]
 [build-system]
 requires = [
 requires = [

+ 14 - 9
tests/conftest.py

@@ -5,6 +5,8 @@ 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 webdriver_manager.chrome import ChromeDriverManager
 
 
 from nicegui import Client, globals
 from nicegui import Client, globals
 from nicegui.page import page
 from nicegui.page import page
@@ -28,13 +30,6 @@ def capabilities(capabilities: Dict) -> Dict:
     return capabilities
     return capabilities
 
 
 
 
-@pytest.fixture
-def selenium(selenium: webdriver.Chrome) -> webdriver.Chrome:
-    selenium.implicitly_wait(Screen.IMPLICIT_WAIT)
-    selenium.set_page_load_timeout(4)
-    return selenium
-
-
 @pytest.fixture(autouse=True)
 @pytest.fixture(autouse=True)
 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()):
@@ -56,10 +51,20 @@ def remove_all_screenshots() -> None:
             os.remove(os.path.join(Screen.SCREENSHOT_DIR, name))
             os.remove(os.path.join(Screen.SCREENSHOT_DIR, name))
 
 
 
 
+@pytest.fixture(scope='function')
+def driver(chrome_options: webdriver.ChromeOptions) -> webdriver.Chrome:
+    s = Service(ChromeDriverManager().install())
+    driver = webdriver.Chrome(service=s, options=chrome_options)
+    driver.implicitly_wait(Screen.IMPLICIT_WAIT)
+    driver.set_page_load_timeout(4)
+    yield driver
+    driver.quit()
+
+
 @pytest.fixture
 @pytest.fixture
-def screen(selenium: webdriver.Chrome, request: pytest.FixtureRequest, caplog: pytest.LogCaptureFixture) \
+def screen(driver: webdriver.Chrome, request: pytest.FixtureRequest, caplog: pytest.LogCaptureFixture) \
         -> Generator[Screen, None, None]:
         -> Generator[Screen, None, None]:
-    screen = Screen(selenium, caplog)
+    screen = Screen(driver, caplog)
     yield screen
     yield screen
     if screen.is_open:
     if screen.is_open:
         screen.shot(request.node.name)
         screen.shot(request.node.name)

+ 5 - 4
tests/screen.py

@@ -31,7 +31,7 @@ class Screen:
         self.ui_run_kwargs = {'port': PORT, 'show': False, 'reload': False}
         self.ui_run_kwargs = {'port': 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."""
         self.server_thread = threading.Thread(target=ui.run, kwargs=self.ui_run_kwargs)
         self.server_thread = threading.Thread(target=ui.run, kwargs=self.ui_run_kwargs)
         self.server_thread.start()
         self.server_thread.start()
 
 
@@ -45,17 +45,17 @@ class Screen:
             return False
             return False
 
 
     def stop_server(self) -> None:
     def stop_server(self) -> None:
-        '''Stop the webserver.'''
+        """Stop the webserver."""
         self.close()
         self.close()
         self.caplog.clear()
         self.caplog.clear()
         globals.server.should_exit = True
         globals.server.should_exit = True
         self.server_thread.join()
         self.server_thread.join()
 
 
     def open(self, path: str, timeout: float = 3.0) -> None:
     def open(self, path: str, timeout: float = 3.0) -> None:
-        '''Try to open the page until the server is ready or we time out.
+        """Try to open the page until the server is ready or we time out.
 
 
         If the server is not yet running, start it.
         If the server is not yet running, start it.
-        '''
+        """
         if self.server_thread is None:
         if self.server_thread is None:
             self.start_server()
             self.start_server()
         deadline = time.time() + timeout
         deadline = time.time() + timeout
@@ -166,6 +166,7 @@ class Screen:
         self.selenium.get_screenshot_as_file(filename)
         self.selenium.get_screenshot_as_file(filename)
 
 
     def assert_py_logger(self, level: str, message: str) -> None:
     def assert_py_logger(self, level: str, message: str) -> None:
+        """Assert that the Python logger has received a message with the given level and text."""
         try:
         try:
             assert self.caplog.records, 'Expected a log message'
             assert self.caplog.records, 'Expected a log message'
             record = self.caplog.records[0]
             record = self.caplog.records[0]

+ 28 - 0
tests/test_refreshable.py

@@ -98,3 +98,31 @@ def test_multiple_targets(screen: Screen) -> None:
     screen.click('increment B')
     screen.click('increment B')
     screen.should_contain('A = 2 (3)')
     screen.should_contain('A = 2 (3)')
     screen.should_contain('B = 2 (4)')
     screen.should_contain('B = 2 (4)')
+
+
+def test_refresh_with_arguments(screen: Screen):
+    a = 0
+
+    @ui.refreshable
+    def some_ui(*, b: int):
+        ui.label(f'a={a}, b={b}')
+
+    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)
+
+    screen.open('/')
+    screen.should_contain('a=0, b=0')
+
+    a = 1
+    screen.click('Refresh 1')
+    screen.should_contain('a=1, b=1')
+
+    a = 2
+    screen.click('Refresh 2')
+    screen.should_contain('a=2, b=1')
+
+    a = 3
+    screen.click('Refresh 3')
+    screen.should_contain('a=3, b=1')

+ 17 - 0
tests/test_storage.py

@@ -1,4 +1,5 @@
 import asyncio
 import asyncio
+import warnings
 from pathlib import Path
 from pathlib import Path
 
 
 import httpx
 import httpx
@@ -150,3 +151,19 @@ def test_user_and_general_storage_is_persisted(screen: Screen):
     screen.open('/')
     screen.open('/')
     screen.should_contain('user: 1')
     screen.should_contain('user: 1')
     screen.should_contain('general: 4')
     screen.should_contain('general: 4')
+
+
+def test_rapid_storage(screen: Screen):
+    # https://github.com/zauberzeug/nicegui/issues/1099
+    warnings.simplefilter('error')
+
+    ui.button('test', on_click=lambda: (
+        app.storage.general.update(one=1),
+        app.storage.general.update(two=2),
+        app.storage.general.update(three=3),
+    ))
+
+    screen.open('/')
+    screen.click('test')
+    screen.wait(0.5)
+    assert '{"one": 1, "two": 2, "three": 3}' in Path('.nicegui', 'storage_general.json').read_text()

+ 15 - 0
tests/test_timer.py

@@ -1,3 +1,6 @@
+import asyncio
+import warnings
+
 import pytest
 import pytest
 
 
 from nicegui import ui
 from nicegui import ui
@@ -58,3 +61,15 @@ def test_setting_visibility(screen: Screen, once: bool):
     screen.open('/')
     screen.open('/')
     screen.wait(0.5)
     screen.wait(0.5)
     screen.should_not_contain('Some Label')
     screen.should_not_contain('Some Label')
+
+
+def test_awaiting_coroutine(screen: Screen):
+    warnings.simplefilter('error')
+
+    async def update_user():
+        await asyncio.sleep(0.1)
+
+    ui.timer(1, lambda: update_user())
+
+    screen.open('/')
+    screen.wait(1)

+ 5 - 2
website/more_documentation/colors_documentation.py

@@ -2,5 +2,8 @@ from nicegui import ui
 
 
 
 
 def main_demo() -> None:
 def main_demo() -> None:
-    ui.button('Default', on_click=lambda: ui.colors())
-    ui.button('Gray', on_click=lambda: ui.colors(primary='#555'))
+    # ui.button('Default', on_click=lambda: ui.colors())
+    # ui.button('Gray', on_click=lambda: ui.colors(primary='#555'))
+    # END OF DEMO
+    b1 = ui.button('Default', on_click=lambda: [b.classes(replace='!bg-primary') for b in {b1, b2}])
+    b2 = ui.button('Gray', on_click=lambda: [b.classes(replace='!bg-[#555]') for b in {b1, b2}])

+ 18 - 0
website/more_documentation/refreshable_documentation.py

@@ -21,6 +21,24 @@ def main_demo() -> None:
 
 
 
 
 def more() -> None:
 def more() -> None:
+    @text_demo('Refreshable UI with parameters', '''
+        Here is a demo of how to use the refreshable decorator to create a UI that can be refreshed with different parameters.
+    ''')
+    def refreshable_with_parameters():
+        from datetime import datetime
+
+        import pytz
+
+        @ui.refreshable
+        def clock_ui(timezone: str):
+            ui.label(f'Current time in {timezone}:')
+            ui.label(datetime.now(tz=pytz.timezone(timezone)).strftime('%H:%M:%S'))
+
+        clock_ui('Europe/Berlin')
+        ui.button('Refresh', on_click=clock_ui.refresh)
+        ui.button('Refresh for New York', on_click=lambda: clock_ui.refresh('America/New_York'))
+        ui.button('Refresh for Tokyo', on_click=lambda: clock_ui.refresh('Asia/Tokyo'))
+
     @text_demo('Refreshable UI for input validation', '''
     @text_demo('Refreshable UI for input validation', '''
         Here is a demo of how to use the refreshable decorator to give feedback about the validity of user input.
         Here is a demo of how to use the refreshable decorator to give feedback about the validity of user input.
     ''')
     ''')

+ 7 - 2
website/static/search_index.json

@@ -126,7 +126,7 @@
   },
   },
   {
   {
     "title": "Example: SQLite Database",
     "title": "Example: SQLite Database",
-    "content": "CRUD operations on a SQLite database",
+    "content": "CRUD operations on a SQLite database with async-support through Tortoise ORM",
     "url": "https://github.com/zauberzeug/nicegui/tree/main/examples/sqlite_database/main.py"
     "url": "https://github.com/zauberzeug/nicegui/tree/main/examples/sqlite_database/main.py"
   },
   },
   {
   {
@@ -321,7 +321,7 @@
   },
   },
   {
   {
     "title": "Icon",
     "title": "Icon",
-    "content": "This element is based on Quasar's QIcon <https://quasar.dev/vue-components/icon>_ component.  Here <https://material.io/icons/>_ is a reference of possible names.  :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 color: icon color (either a Quasar, Tailwind, or CSS color or None, default: None)",
+    "content": "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.  :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 color: icon color (either a Quasar, Tailwind, or CSS color or None, default: None)",
     "url": "/documentation/icon"
     "url": "/documentation/icon"
   },
   },
   {
   {
@@ -709,6 +709,11 @@
     "content": "The @ui.refreshable decorator allows you to create functions that have a refresh method. This method will automatically delete all elements created by the function and recreate them. refresh prune",
     "content": "The @ui.refreshable decorator allows you to create functions that have a refresh method. This method will automatically delete all elements created by the function and recreate them. refresh prune",
     "url": "/documentation/refreshable"
     "url": "/documentation/refreshable"
   },
   },
+  {
+    "title": "Refreshable: Refreshable UI with parameters",
+    "content": "Here is a demo of how to use the refreshable decorator to create a UI that can be refreshed with different parameters.",
+    "url": "/documentation/refreshable#refreshable_ui_with_parameters"
+  },
   {
   {
     "title": "Refreshable: Refreshable UI for input validation",
     "title": "Refreshable: Refreshable UI for input validation",
     "content": "Here is a demo of how to use the refreshable decorator to give feedback about the validity of user input.",
     "content": "Here is a demo of how to use the refreshable decorator to give feedback about the validity of user input.",