浏览代码

Merge pull request #1847 from zauberzeug/globals

Refactor `globals` and related modules
Rodja Trappe 1 年之前
父节点
当前提交
964108ac1f
共有 66 个文件被更改,包括 917 次插入785 次删除
  1. 2 3
      examples/authentication/main.py
  2. 3 4
      examples/opencv_webcam/main.py
  3. 3 3
      examples/ros2/ros2_ws/src/gui/gui/node.py
  4. 3 5
      main.py
  5. 3 5
      nicegui/__init__.py
  6. 42 20
      nicegui/air.py
  7. 85 13
      nicegui/app.py
  8. 39 0
      nicegui/app_config.py
  9. 4 8
      nicegui/background_tasks.py
  10. 4 3
      nicegui/binding.py
  11. 110 16
      nicegui/client.py
  12. 23 0
      nicegui/context.py
  13. 13 0
      nicegui/core.py
  14. 4 4
      nicegui/element.py
  15. 2 2
      nicegui/elements/aggrid.py
  16. 2 2
      nicegui/elements/audio.py
  17. 2 2
      nicegui/elements/carousel.py
  18. 2 2
      nicegui/elements/link.py
  19. 5 4
      nicegui/elements/menu.py
  20. 2 2
      nicegui/elements/mixins/source_element.py
  21. 3 3
      nicegui/elements/plotly.py
  22. 5 4
      nicegui/elements/pyplot.py
  23. 2 2
      nicegui/elements/query.py
  24. 2 2
      nicegui/elements/scene.py
  25. 2 2
      nicegui/elements/stepper.py
  26. 2 2
      nicegui/elements/tabs.py
  27. 14 10
      nicegui/elements/timer.py
  28. 3 3
      nicegui/elements/tree.py
  29. 2 2
      nicegui/elements/video.py
  30. 5 5
      nicegui/events.py
  31. 8 7
      nicegui/favicon.py
  32. 3 3
      nicegui/functions/download.py
  33. 3 3
      nicegui/functions/html.py
  34. 6 5
      nicegui/functions/javascript.py
  35. 5 4
      nicegui/functions/notify.py
  36. 6 4
      nicegui/functions/open.py
  37. 13 7
      nicegui/functions/refreshable.py
  38. 0 128
      nicegui/globals.py
  39. 2 2
      nicegui/helpers.py
  40. 3 0
      nicegui/logging.py
  41. 12 0
      nicegui/native/__init__.py
  42. 4 13
      nicegui/native/native.py
  43. 12 0
      nicegui/native/native_config.py
  44. 16 13
      nicegui/native/native_mode.py
  45. 35 133
      nicegui/nicegui.py
  46. 13 0
      nicegui/optional_features.py
  47. 12 20
      nicegui/outbox.py
  48. 9 9
      nicegui/page.py
  49. 4 4
      nicegui/page_layout.py
  50. 32 179
      nicegui/run.py
  51. 0 44
      nicegui/run_executor.py
  52. 36 0
      nicegui/server.py
  53. 45 5
      nicegui/slot.py
  54. 14 12
      nicegui/storage.py
  55. 2 2
      nicegui/ui.py
  56. 172 0
      nicegui/ui_run.py
  57. 17 14
      nicegui/ui_run_with.py
  58. 3 4
      nicegui/welcome.py
  59. 2 3
      prometheus.py
  60. 4 2
      test_startup.sh
  61. 14 16
      tests/conftest.py
  62. 7 5
      tests/screen.py
  63. 3 4
      website/documentation.py
  64. 2 2
      website/documentation_tools.py
  65. 2 2
      website/more_documentation/generic_events_documentation.py
  66. 3 3
      website/more_documentation/query_documentation.py

+ 2 - 3
examples/authentication/main.py

@@ -11,8 +11,7 @@ from fastapi import Request
 from fastapi.responses import RedirectResponse
 from starlette.middleware.base import BaseHTTPMiddleware
 
-import nicegui.globals
-from nicegui import app, ui
+from nicegui import Client, app, ui
 
 # in reality users passwords would obviously need to be hashed
 passwords = {'user1': 'pass1', 'user2': 'pass2'}
@@ -28,7 +27,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
 
     async def dispatch(self, request: Request, call_next):
         if not app.storage.user.get('authenticated', False):
-            if request.url.path in nicegui.globals.page_routes.values() and request.url.path not in unrestricted_page_routes:
+            if request.url.path in Client.page_routes.values() and request.url.path not in unrestricted_page_routes:
                 app.storage.user['referrer_path'] = request.url.path  # remember where the user wanted to go
                 return RedirectResponse('/login')
         return await call_next(request)

+ 3 - 4
examples/opencv_webcam/main.py

@@ -7,8 +7,7 @@ import cv2
 import numpy as np
 from fastapi import Response
 
-import nicegui.globals
-from nicegui import app, run, ui
+from nicegui import Client, app, run, ui
 
 # In case you don't have a webcam, this will provide a black placeholder image.
 black_1px = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII='
@@ -46,8 +45,8 @@ ui.timer(interval=0.1, callback=lambda: video_image.set_source(f'/video/frame?{t
 
 async def disconnect() -> None:
     """Disconnect all clients from current running server."""
-    for client in nicegui.globals.clients.keys():
-        await app.sio.disconnect(client)
+    for client_id in Client.instances:
+        await app.sio.disconnect(client_id)
 
 
 def handle_sigint(signum, frame) -> None:

+ 3 - 3
examples/ros2/ros2_ws/src/gui/gui/node.py

@@ -7,7 +7,7 @@ from geometry_msgs.msg import Pose, Twist
 from rclpy.executors import ExternalShutdownException
 from rclpy.node import Node
 
-from nicegui import app, globals, run, ui
+from nicegui import Client, app, run, ui
 
 
 class NiceGuiNode(Node):
@@ -17,7 +17,7 @@ class NiceGuiNode(Node):
         self.cmd_vel_publisher = self.create_publisher(Twist, 'cmd_vel', 1)
         self.subscription = self.create_subscription(Pose, 'pose', self.handle_pose, 1)
 
-        with globals.index_client:
+        with Client.auto_index_client:
             with ui.row().classes('items-stretch'):
                 with ui.card().classes('w-44 text-center items-center'):
                     ui.label('Control').classes('text-2xl')
@@ -67,7 +67,7 @@ def ros_main() -> None:
         rclpy.spin(node)
     except ExternalShutdownException:
         pass
-    
+
 
 app.on_startup(lambda: threading.Thread(target=ros_main).start())
 run.APP_IMPORT_STRING = f'{__name__}:app'  # ROS2 uses a non-standard module name, so we need to specify it here

+ 3 - 5
main.py

@@ -14,9 +14,7 @@ from starlette.middleware.sessions import SessionMiddleware
 from starlette.types import ASGIApp, Receive, Scope, Send
 
 import prometheus
-from nicegui import Client, app
-from nicegui import globals as nicegui_globals
-from nicegui import ui
+from nicegui import Client, app, ui
 from website import documentation, example_card, svg
 from website.demo import bash_window, browser_window, python_window
 from website.documentation_tools import create_anchor_name, element_demo, generate_class_doc
@@ -67,8 +65,8 @@ async def redirect_reference_to_documentation(request: Request,
 
 # 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)
+app.config.socket_io_js_extra_headers['fly-force-instance-id'] = fly_instance_id  # for HTTP long polling
+app.config.socket_io_js_query_params['fly_instance_id'] = fly_instance_id  # for websocket (FlyReplayMiddleware)
 
 
 class FlyReplayMiddleware(BaseHTTPMiddleware):

+ 3 - 5
nicegui/__init__.py

@@ -1,6 +1,4 @@
-from . import ui  # pylint: disable=redefined-builtin
-from . import elements, globals  # pylint: disable=redefined-builtin
-from . import run_executor as run
+from . import context, elements, run, ui
 from .api_router import APIRouter
 from .awaitable_response import AwaitableResponse
 from .client import Client
@@ -10,11 +8,11 @@ from .version import __version__
 
 __all__ = [
     'APIRouter',
-    'AwaitableResponse',
     'app',
+    'AwaitableResponse',
     'Client',
+    'context',
     'elements',
-    'globals',
     'run',
     'Tailwind',
     'ui',

+ 42 - 20
nicegui/air.py

@@ -1,14 +1,15 @@
 import asyncio
 import gzip
 import re
-from typing import Any, Dict
+from typing import Any, Dict, Optional
 
 import httpx
 import socketio
-from socketio import AsyncClient
+import socketio.exceptions
 
-from . import background_tasks, globals  # pylint: disable=redefined-builtin
-from .nicegui import handle_disconnect, handle_event, handle_handshake, handle_javascript_response
+from . import background_tasks, core
+from .client import Client
+from .logging import log
 
 RELAY_HOST = 'https://on-air.nicegui.io/'
 
@@ -17,8 +18,8 @@ class Air:
 
     def __init__(self, token: str) -> None:
         self.token = token
-        self.relay = AsyncClient()
-        self.client = httpx.AsyncClient(app=globals.app)
+        self.relay = socketio.AsyncClient()
+        self.client = httpx.AsyncClient(app=core.app)
         self.connecting = False
 
         @self.relay.on('http')
@@ -53,7 +54,7 @@ class Air:
 
         @self.relay.on('ready')
         def _handle_ready(data: Dict[str, Any]) -> None:
-            globals.app.urls.add(data['device_url'])
+            core.app.urls.add(data['device_url'])
             print(f'NiceGUI is on air at {data["device_url"]}', flush=True)
 
         @self.relay.on('error')
@@ -63,39 +64,38 @@ class Air:
         @self.relay.on('handshake')
         def _handle_handshake(data: Dict[str, Any]) -> bool:
             client_id = data['client_id']
-            if client_id not in globals.clients:
+            if client_id not in Client.instances:
                 return False
-            client = globals.clients[client_id]
+            client = Client.instances[client_id]
             client.environ = data['environ']
             client.on_air = True
-            handle_handshake(client)
+            client.handle_handshake()
             return True
 
         @self.relay.on('client_disconnect')
         def _handle_disconnect(data: Dict[str, Any]) -> None:
             client_id = data['client_id']
-            if client_id not in globals.clients:
+            if client_id not in Client.instances:
                 return
-            client = globals.clients[client_id]
-            client.disconnect_task = background_tasks.create(handle_disconnect(client))
+            Client.instances[client_id].handle_disconnect()
 
         @self.relay.on('event')
         def _handle_event(data: Dict[str, Any]) -> None:
             client_id = data['client_id']
-            if client_id not in globals.clients:
+            if client_id not in Client.instances:
                 return
-            client = globals.clients[client_id]
+            client = Client.instances[client_id]
             if isinstance(data['msg']['args'], dict) and 'socket_id' in data['msg']['args']:
                 data['msg']['args']['socket_id'] = client_id  # HACK: translate socket_id of ui.scene's init event
-            handle_event(client, data['msg'])
+            client.handle_event(data['msg'])
 
         @self.relay.on('javascript_response')
         def _handle_javascript_response(data: Dict[str, Any]) -> None:
             client_id = data['client_id']
-            if client_id not in globals.clients:
+            if client_id not in Client.instances:
                 return
-            client = globals.clients[client_id]
-            handle_javascript_response(client, data['msg'])
+            client = Client.instances[client_id]
+            client.handle_javascript_response(data['msg'])
 
         @self.relay.on('out_of_time')
         async def _handle_out_of_time() -> None:
@@ -127,7 +127,7 @@ class Air:
             except ValueError:  # NOTE this sometimes happens when the internal socketio client is not yet ready
                 await self.relay.disconnect()
             except Exception:
-                globals.log.exception('Could not connect to NiceGUI On Air server.')
+                log.exception('Could not connect to NiceGUI On Air server.')
 
             await asyncio.sleep(backoff_time)
             backoff_time = min(backoff_time * 2, 32)
@@ -141,3 +141,25 @@ class Air:
         """Emit a message to the NiceGUI On Air server."""
         if self.relay.connected:
             await self.relay.emit('forward', {'event': message_type, 'data': data, 'room': room})
+
+    @staticmethod
+    def is_air_target(target_id: str) -> bool:
+        """Whether the given target ID is an On Air client or a SocketIO room."""
+        if target_id in Client.instances:
+            return Client.instances[target_id].on_air
+        return target_id in core.sio.manager.rooms
+
+
+instance: Optional[Air] = None
+
+
+def connect() -> None:
+    """Connect to the NiceGUI On Air server if there is an air instance."""
+    if instance:
+        background_tasks.create(instance.connect())
+
+
+def disconnect() -> None:
+    """Disconnect from the NiceGUI On Air server if there is an air instance."""
+    if instance:
+        background_tasks.create(instance.disconnect())

+ 85 - 13
nicegui/app.py

@@ -1,60 +1,123 @@
+import inspect
+from enum import Enum
 from pathlib import Path
-from typing import Awaitable, Callable, Optional, Union
+from typing import Any, Awaitable, Callable, List, Optional, Union
 
 from fastapi import FastAPI, HTTPException, Request
 from fastapi.responses import FileResponse, StreamingResponse
 from fastapi.staticfiles import StaticFiles
 
-from . import globals, helpers  # pylint: disable=redefined-builtin
-from .native import Native
+from . import background_tasks, helpers
+from .app_config import AppConfig, RunConfig
+from .client import Client
+from .logging import log
+from .native import NativeConfig
 from .observables import ObservableSet
+from .server import Server
 from .storage import Storage
 
 
+class State(Enum):
+    STOPPED = 0
+    STARTING = 1
+    STARTED = 2
+    STOPPING = 3
+
+
 class App(FastAPI):
 
     def __init__(self, **kwargs) -> None:
         super().__init__(**kwargs)
-        self.native = Native()
+        self.native = NativeConfig()
         self.storage = Storage()
         self.urls = ObservableSet()
+        self._state: State = State.STOPPED
+        self._run_config: RunConfig
+        self.config = AppConfig()
+
+        self._startup_handlers: List[Union[Callable[..., Any], Awaitable]] = []
+        self._shutdown_handlers: List[Union[Callable[..., Any], Awaitable]] = []
+        self._connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
+        self._disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
+        self._exception_handlers: List[Callable[..., Any]] = [log.exception]
+
+    @property
+    def is_starting(self) -> bool:
+        """Return whether NiceGUI is starting."""
+        return self._state == State.STARTING
+
+    @property
+    def is_started(self) -> bool:
+        """Return whether NiceGUI is started."""
+        return self._state == State.STARTED
+
+    @property
+    def is_stopping(self) -> bool:
+        """Return whether NiceGUI is stopping."""
+        return self._state == State.STOPPING
+
+    @property
+    def is_stopped(self) -> bool:
+        """Return whether NiceGUI is stopped."""
+        return self._state == State.STOPPED
+
+    def start(self) -> None:
+        """Start NiceGUI. (For internal use only.)"""
+        self._state = State.STARTING
+        for t in self._startup_handlers:
+            helpers.safe_invoke(t, Client.auto_index_client)
+        self._state = State.STARTED
+
+    def stop(self) -> None:
+        """Stop NiceGUI. (For internal use only.)"""
+        self._state = State.STOPPING
+        for t in self._shutdown_handlers:
+            helpers.safe_invoke(t, Client.auto_index_client)
+        self._state = State.STOPPED
 
     def on_connect(self, handler: Union[Callable, Awaitable]) -> None:
         """Called every time a new client connects to NiceGUI.
 
         The callback has an optional parameter of `nicegui.Client`.
         """
-        globals.connect_handlers.append(handler)
+        self._connect_handlers.append(handler)
 
     def on_disconnect(self, handler: Union[Callable, Awaitable]) -> None:
         """Called every time a new client disconnects from NiceGUI.
 
         The callback has an optional parameter of `nicegui.Client`.
         """
-        globals.disconnect_handlers.append(handler)
+        self._disconnect_handlers.append(handler)
 
     def on_startup(self, handler: Union[Callable, Awaitable]) -> None:
         """Called when NiceGUI is started or restarted.
 
         Needs to be called before `ui.run()`.
         """
-        if globals.state == globals.State.STARTED:
+        if self.is_started:
             raise RuntimeError('Unable to register another startup handler. NiceGUI has already been started.')
-        globals.startup_handlers.append(handler)
+        self._startup_handlers.append(handler)
 
     def on_shutdown(self, handler: Union[Callable, Awaitable]) -> None:
         """Called when NiceGUI is shut down or restarted.
 
         When NiceGUI is shut down or restarted, all tasks still in execution will be automatically canceled.
         """
-        globals.shutdown_handlers.append(handler)
+        self._shutdown_handlers.append(handler)
 
     def on_exception(self, handler: Callable) -> None:
         """Called when an exception occurs.
 
         The callback has an optional parameter of `Exception`.
         """
-        globals.exception_handlers.append(handler)
+        self._exception_handlers.append(handler)
+
+    def handle_exception(self, exception: Exception) -> None:
+        """Handle an exception by invoking all registered exception handlers."""
+        for handler in self._exception_handlers:
+            result = handler() if not inspect.signature(handler).parameters else handler(exception)
+            if helpers.is_coroutine_function(handler):
+                background_tasks.create(result)
 
     def shutdown(self) -> None:
         """Shut down NiceGUI.
@@ -62,12 +125,12 @@ class App(FastAPI):
         This will programmatically stop the server.
         Only possible when auto-reload is disabled.
         """
-        if globals.reload:
+        if self._run_config.reload:
             raise RuntimeError('calling shutdown() is not supported when auto-reload is enabled')
         if self.native.main_window:
             self.native.main_window.destroy()
         else:
-            globals.server.should_exit = True
+            Server.instance.should_exit = True
 
     def add_static_files(self, url_path: str, local_directory: Union[str, Path]) -> None:
         """Add a directory of static files.
@@ -85,7 +148,7 @@ class App(FastAPI):
         """
         if url_path == '/':
             raise ValueError('''Path cannot be "/", because it would hide NiceGUI's internal "/_nicegui" route.''')
-        globals.app.mount(url_path, StaticFiles(directory=str(local_directory)))
+        self.mount(url_path, StaticFiles(directory=str(local_directory)))
 
     def add_static_file(self, *,
                         local_file: Union[str, Path],
@@ -173,3 +236,12 @@ class App(FastAPI):
     def remove_route(self, path: str) -> None:
         """Remove routes with the given path."""
         self.routes[:] = [r for r in self.routes if getattr(r, 'path', None) != path]
+
+    def reset(self) -> None:
+        """Reset app to its initial state. (Useful for testing.)"""
+        self.storage.clear()
+        self._startup_handlers.clear()
+        self._shutdown_handlers.clear()
+        self._connect_handlers.clear()
+        self._disconnect_handlers.clear()
+        self._exception_handlers[:] = [log.exception]

+ 39 - 0
nicegui/app_config.py

@@ -0,0 +1,39 @@
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Dict, List, Literal, Optional, Union
+
+from .dataclasses import KWONLY_SLOTS
+from .language import Language
+
+
+@dataclass(**KWONLY_SLOTS)
+class RunConfig:
+    reload: bool
+    title: str
+    viewport: str
+    favicon: Optional[Union[str, Path]]
+    dark: Optional[bool]
+    language: Language
+    binding_refresh_interval: float
+    reconnect_timeout: float
+    tailwind: bool
+    prod_js: bool
+
+
+@dataclass(**KWONLY_SLOTS)
+class AppConfig:
+    endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none'
+    socket_io_js_query_params: Dict = field(default_factory=dict)
+    socket_io_js_extra_headers: Dict = field(default_factory=dict)
+    socket_io_js_transports: List[Literal['websocket', 'polling']] = \
+        field(default_factory=lambda: ['websocket', 'polling'])  # NOTE: we favor websocket
+    quasar_config: Dict = \
+        field(default_factory=lambda: {
+            'brand': {
+                'primary': '#5898d4',
+            },
+            'loadingBar': {
+                'color': 'primary',
+                'skipHijack': False,
+            },
+        })

+ 4 - 8
nicegui/background_tasks.py

@@ -2,12 +2,9 @@
 from __future__ import annotations
 
 import asyncio
-import sys
 from typing import Awaitable, Dict, Set
 
-from . import globals  # pylint: disable=redefined-builtin,cyclic-import
-
-name_supported = sys.version_info[1] >= 8
+from . import core
 
 running_tasks: Set[asyncio.Task] = set()
 lazy_tasks_running: Dict[str, asyncio.Task] = {}
@@ -21,10 +18,9 @@ def create(coroutine: Awaitable, *, name: str = 'unnamed task') -> asyncio.Task:
     Also a reference to the task is kept until it is done, so that the task is not garbage collected mid-execution.
     See https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task.
     """
-    assert globals.loop is not None
+    assert core.loop is not None
     assert asyncio.iscoroutine(coroutine)
-    task: asyncio.Task = \
-        globals.loop.create_task(coroutine, name=name) if name_supported else globals.loop.create_task(coroutine)
+    task: asyncio.Task = core.loop.create_task(coroutine, name=name)
     task.add_done_callback(_handle_task_result)
     running_tasks.add(task)
     task.add_done_callback(running_tasks.discard)
@@ -57,4 +53,4 @@ def _handle_task_result(task: asyncio.Task) -> None:
     except asyncio.CancelledError:
         pass
     except Exception as e:
-        globals.handle_exception(e)
+        core.app.handle_exception(e)

+ 4 - 3
nicegui/binding.py

@@ -4,7 +4,8 @@ from collections import defaultdict
 from collections.abc import Mapping
 from typing import Any, Callable, DefaultDict, Dict, Iterable, List, Optional, Set, Tuple, Type, Union
 
-from . import globals  # pylint: disable=redefined-builtin
+from . import core
+from .logging import log
 
 MAX_PROPAGATION_TIME = 0.01
 
@@ -36,7 +37,7 @@ async def refresh_loop() -> None:
     """Refresh all bindings in an endless loop."""
     while True:
         _refresh_step()
-        await asyncio.sleep(globals.binding_refresh_interval)
+        await asyncio.sleep(core.app._run_config.binding_refresh_interval)  # pylint: disable=protected-access
 
 
 def _refresh_step() -> None:
@@ -51,7 +52,7 @@ def _refresh_step() -> None:
                 _propagate(target_obj, target_name, visited)
         del link, source_obj, target_obj  # pylint: disable=modified-iterating-list
     if time.time() - t > MAX_PROPAGATION_TIME:
-        globals.log.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
+        log.warning(f'binding propagation for {len(active_links)} active links took {time.time() - t:.3f} s')
 
 
 def _propagate(source_obj: Any, source_name: str, visited: Optional[Set[Tuple[int, str]]] = None) -> None:

+ 110 - 16
nicegui/client.py

@@ -3,8 +3,9 @@ from __future__ import annotations
 import asyncio
 import time
 import uuid
+from contextlib import contextmanager
 from pathlib import Path
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, List, Optional, Union
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterable, Iterator, List, Optional, Union
 
 from fastapi import Request
 from fastapi.responses import Response
@@ -12,11 +13,13 @@ from fastapi.templating import Jinja2Templates
 
 from nicegui import json
 
-from . import binding, globals, outbox  # pylint: disable=redefined-builtin
+from . import background_tasks, binding, core, outbox
 from .awaitable_response import AwaitableResponse
 from .dependencies import generate_resources
 from .element import Element
 from .favicon import get_favicon_url
+from .helpers import safe_invoke
+from .logging import log
 from .version import __version__
 
 if TYPE_CHECKING:
@@ -26,11 +29,19 @@ templates = Jinja2Templates(Path(__file__).parent / 'templates')
 
 
 class Client:
+    page_routes: Dict[Callable[..., Any], str] = {}
+    """Maps page builders to their routes."""
+
+    instances: Dict[str, Client] = {}
+    """Maps client IDs to clients."""
+
+    auto_index_client: Client
+    """The client that is used to render the auto-index page."""
 
     def __init__(self, page: page, *, shared: bool = False) -> None:
         self.id = str(uuid.uuid4())
         self.created = time.time()
-        globals.clients[self.id] = self
+        self.instances[self.id] = self
 
         self.elements: Dict[int, Element] = {}
         self.next_element_id: int = 0
@@ -39,7 +50,7 @@ class Client:
         self.environ: Optional[Dict[str, Any]] = None
         self.shared = shared
         self.on_air = False
-        self.disconnect_task: Optional[asyncio.Task] = None
+        self._disconnect_task: Optional[asyncio.Task] = None
 
         with Element('q-layout', _client=self).props('view="hhh lpr fff"').classes('nicegui-layout') as self.layout:
             with Element('q-page-container') as self.page_container:
@@ -56,6 +67,13 @@ class Client:
         self.connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
         self.disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
 
+        self._temporary_socket_id: Optional[str] = None
+
+    @property
+    def is_auto_index_client(self) -> bool:
+        """Return True if this client is the auto-index client."""
+        return self is self.auto_index_client
+
     @property
     def ip(self) -> Optional[str]:
         """Return the IP address of the client, or None if the client is not connected."""
@@ -79,7 +97,7 @@ class Client:
         elements = json.dumps({
             id: element._to_dict() for id, element in self.elements.items()  # pylint: disable=protected-access
         })
-        socket_io_js_query_params = {**globals.socket_io_js_query_params, 'client_id': self.id}
+        socket_io_js_query_params = {**core.app.config.socket_io_js_query_params, 'client_id': self.id}
         vue_html, vue_styles, vue_scripts, imports, js_imports = generate_resources(prefix, self.elements.values())
         return templates.TemplateResponse('index.html', {
             'request': request,
@@ -93,18 +111,18 @@ class Client:
             'vue_scripts': '\n'.join(vue_scripts),
             'imports': json.dumps(imports),
             'js_imports': '\n'.join(js_imports),
-            'quasar_config': json.dumps(globals.quasar_config),
+            'quasar_config': json.dumps(core.app.config.quasar_config),
             'title': self.page.resolve_title(),
             'viewport': self.page.resolve_viewport(),
             'favicon_url': get_favicon_url(self.page, prefix),
             'dark': str(self.page.resolve_dark()),
             'language': self.page.resolve_language(),
             'prefix': prefix,
-            'tailwind': globals.tailwind,
-            'prod_js': globals.prod_js,
+            'tailwind': core.app._run_config.tailwind,  # pylint: disable=protected-access
+            'prod_js': core.app._run_config.prod_js,  # pylint: disable=protected-access
             'socket_io_js_query_params': socket_io_js_query_params,
-            'socket_io_js_extra_headers': globals.socket_io_js_extra_headers,
-            'socket_io_js_transports': globals.socket_io_js_transports,
+            'socket_io_js_extra_headers': core.app.config.socket_io_js_extra_headers,
+            'socket_io_js_transports': core.app.config.socket_io_js_transports,
         }, status_code, {'Cache-Control': 'no-store', 'X-NiceGUI-Content': 'page'})
 
     async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> None:
@@ -122,7 +140,7 @@ class Client:
         if not self.has_socket_connection:
             await self.connected()
         self.is_waiting_for_disconnect = True
-        while self.id in globals.clients:
+        while self.id in self.instances:
             await asyncio.sleep(check_interval)
         self.is_waiting_for_disconnect = False
 
@@ -135,16 +153,16 @@ class Client:
         You can do this by `await client.connected()` or register a callback with `client.on_connect(...)`.
         """
         if respond is True:
-            globals.log.warning('The "respond" argument of run_javascript() has been removed. '
-                                'Now the method always returns an AwaitableResponse that can be awaited. '
-                                'Please remove the "respond=True" argument.')
+            log.warning('The "respond" argument of run_javascript() has been removed. '
+                        'Now the method always returns an AwaitableResponse that can be awaited. '
+                        'Please remove the "respond=True" argument.')
         if respond is False:
             raise ValueError('The "respond" argument of run_javascript() has been removed. '
                              'Now the method always returns an AwaitableResponse that can be awaited. '
                              'Please remove the "respond=False" argument and call the method without awaiting.')
 
         request_id = str(uuid.uuid4())
-        target_id = globals._socket_id or self.id  # pylint: disable=protected-access
+        target_id = self._temporary_socket_id or self.id
 
         def send_and_forget():
             outbox.enqueue_message('run_javascript', {'code': code}, target_id)
@@ -162,7 +180,7 @@ class Client:
 
     def open(self, target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
         """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 self.page_routes[target]
         outbox.enqueue_message('open', {'path': path, 'new_tab': new_tab}, self.id)
 
     def download(self, url: str, filename: Optional[str] = None) -> None:
@@ -177,6 +195,46 @@ class Client:
         """Register a callback to be called when the client disconnects."""
         self.disconnect_handlers.append(handler)
 
+    def handle_handshake(self) -> None:
+        """Cancel pending disconnect task and invoke connect handlers."""
+        if self._disconnect_task:
+            self._disconnect_task.cancel()
+            self._disconnect_task = None
+        for t in self.connect_handlers:
+            safe_invoke(t, self)
+        for t in core.app._connect_handlers:  # pylint: disable=protected-access
+            safe_invoke(t, self)
+
+    def handle_disconnect(self) -> None:
+        """Wait for the browser to reconnect; invoke disconnect handlers if it doesn't."""
+        async def handle_disconnect() -> None:
+            if self.page.reconnect_timeout is not None:
+                delay = self.page.reconnect_timeout
+            else:
+                delay = core.app._run_config.reconnect_timeout  # pylint: disable=protected-access
+            await asyncio.sleep(delay)
+            if not self.shared:
+                self.delete()
+            for t in self.disconnect_handlers:
+                safe_invoke(t, self)
+            for t in core.app._disconnect_handlers:  # pylint: disable=protected-access
+                safe_invoke(t, self)
+        self._disconnect_task = background_tasks.create(handle_disconnect())
+
+    def handle_event(self, msg: Dict) -> None:
+        """Forward an event to the corresponding element."""
+        with self:
+            sender = self.elements.get(msg['id'])
+            if sender:
+                msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
+                if len(msg['args']) == 1:
+                    msg['args'] = msg['args'][0]
+                sender._handle_event(msg)  # pylint: disable=protected-access
+
+    def handle_javascript_response(self, msg: Dict) -> None:
+        """Store the result of a JavaScript command."""
+        self.waiting_javascript_commands[msg['request_id']] = msg['result']
+
     def remove_elements(self, elements: Iterable[Element]) -> None:
         """Remove the given elements from the client."""
         binding.remove(elements, Element)
@@ -191,3 +249,39 @@ class Client:
     def remove_all_elements(self) -> None:
         """Remove all elements from the client."""
         self.remove_elements(self.elements.values())
+
+    def delete(self) -> None:
+        """Delete a client and all its elements.
+
+        If the global clients dictionary does not contain the client, its elements are still removed and a KeyError is raised.
+        Normally this should never happen, but has been observed (see #1826).
+        """
+        self.remove_all_elements()
+        del Client.instances[self.id]
+
+    @contextmanager
+    def individual_target(self, socket_id: str) -> Iterator[None]:
+        """Use individual socket ID while in this context.
+
+        This context is useful for limiting messages from the shared auto-index page to a single client.
+        """
+        self._temporary_socket_id = socket_id
+        yield
+        self._temporary_socket_id = None
+
+    @classmethod
+    async def prune_instances(cls) -> None:
+        """Prune stale clients in an endless loop."""
+        while True:
+            try:
+                stale_clients = [
+                    client
+                    for client in cls.instances.values()
+                    if not client.shared and not client.has_socket_connection and client.created < time.time() - 60.0
+                ]
+                for client in stale_clients:
+                    client.delete()
+            except Exception:
+                # NOTE: make sure the loop doesn't crash
+                log.exception('Error while pruning clients')
+            await asyncio.sleep(10)

+ 23 - 0
nicegui/context.py

@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, List
+
+from .slot import Slot
+
+if TYPE_CHECKING:
+    from .client import Client
+
+
+def get_slot_stack() -> List[Slot]:
+    """Return the slot stack of the current asyncio task."""
+    return Slot.get_stack()
+
+
+def get_slot() -> Slot:
+    """Return the current slot."""
+    return get_slot_stack()[-1]
+
+
+def get_client() -> Client:
+    """Return the current client."""
+    return get_slot().parent.client

+ 13 - 0
nicegui/core.py

@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+import asyncio
+from typing import TYPE_CHECKING, Optional
+
+from socketio import AsyncServer
+
+if TYPE_CHECKING:
+    from .app import App
+
+app: App
+sio: AsyncServer
+loop: Optional[asyncio.AbstractEventLoop] = None

+ 4 - 4
nicegui/element.py

@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional,
 
 from typing_extensions import Self
 
-from . import events, globals, json, outbox, storage  # pylint: disable=redefined-builtin
+from . import context, core, events, json, outbox, storage
 from .awaitable_response import AwaitableResponse
 from .dependencies import Component, Library, register_library, register_vue_component
 from .elements.mixins.visibility import Visibility
@@ -66,7 +66,7 @@ class Element(Visibility):
         :param _client: client for this element (for internal use only)
         """
         super().__init__()
-        self.client = _client or globals.get_client()
+        self.client = _client or context.get_client()
         self.id = self.client.next_element_id
         self.client.next_element_id += 1
         self.tag = tag if tag else self.component.tag if self.component else 'div'
@@ -84,7 +84,7 @@ class Element(Visibility):
 
         self.client.elements[self.id] = self
         self.parent_slot: Optional[Slot] = None
-        slot_stack = globals.get_slot_stack()
+        slot_stack = context.get_slot_stack()
         if slot_stack:
             self.parent_slot = slot_stack[-1]
             self.parent_slot.children.append(self)
@@ -409,7 +409,7 @@ class Element(Visibility):
         :param name: name of the method
         :param args: arguments to pass to the method
         """
-        if not globals.loop:
+        if not core.loop:
             return AwaitableResponse(None, None)
         return self.client.run_javascript(f'return runMethod({self.id}, "{name}", {json.dumps(args)})')
 

+ 2 - 2
nicegui/elements/aggrid.py

@@ -2,13 +2,13 @@ from __future__ import annotations
 
 from typing import Dict, List, Optional, cast
 
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import optional_features
 from ..awaitable_response import AwaitableResponse
 from ..element import Element
 
 try:
     import pandas as pd
-    globals.optional_features.add('pandas')
+    optional_features.register('pandas')
 except ImportError:
     pass
 

+ 2 - 2
nicegui/elements/audio.py

@@ -1,7 +1,7 @@
 from pathlib import Path
 from typing import Union
 
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import core
 from ..element import Element
 
 
@@ -28,7 +28,7 @@ class Audio(Element, component='audio.js'):
         """
         super().__init__()
         if Path(src).is_file():
-            src = globals.app.add_media_file(local_file=src)
+            src = core.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay

+ 2 - 2
nicegui/elements/carousel.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from typing import Any, Callable, Optional, Union, cast
 
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import context
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
@@ -62,7 +62,7 @@ class CarouselSlide(DisableableElement):
         :param name: name of the slide (will be the value of the `ui.carousel` element, auto-generated if `None`)
         """
         super().__init__(tag='q-carousel-slide')
-        self.carousel = cast(ValueElement, globals.get_slot().parent)
+        self.carousel = cast(ValueElement, context.get_slot().parent)
         name = name or f'slide_{len(self.carousel.default_slot.children)}'
         self._props['name'] = name
         self._classes = ['nicegui-carousel-slide']

+ 2 - 2
nicegui/elements/link.py

@@ -1,6 +1,6 @@
 from typing import Any, Callable, Union
 
-from .. import globals  # pylint: disable=redefined-builtin
+from ..client import Client
 from ..element import Element
 from .mixins.text_element import TextElement
 
@@ -29,7 +29,7 @@ class Link(TextElement, component='link.js'):
         elif isinstance(target, Element):
             self._props['href'] = f'#c{target.id}'
         elif callable(target):
-            self._props['href'] = globals.page_routes[target]
+            self._props['href'] = Client.page_routes[target]
         self._props['target'] = '_blank' if new_tab else '_self'
         self._classes = ['nicegui-link']
 

+ 5 - 4
nicegui/elements/menu.py

@@ -2,8 +2,9 @@ from typing import Any, Callable, Optional
 
 from typing_extensions import Self
 
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import context
 from ..events import ClickEventArguments, handle_event
+from ..logging import log
 from .context_menu import ContextMenu
 from .mixins.text_element import TextElement
 from .mixins.value_element import ValueElement
@@ -38,8 +39,8 @@ class Menu(ValueElement):
         if 'touch-position' in self._props:
             # https://github.com/zauberzeug/nicegui/issues/1738
             del self._props['touch-position']
-            globals.log.warning('The prop "touch-position" is not supported by `ui.menu`.\n'
-                                'Use "ui.context_menu()" instead.')
+            log.warning('The prop "touch-position" is not supported by `ui.menu`.\n'
+                        'Use "ui.context_menu()" instead.')
         return self
 
 
@@ -60,7 +61,7 @@ class MenuItem(TextElement):
         :param auto_close: whether the menu should be closed after a click event (default: `True`)
         """
         super().__init__(tag='q-item', text=text)
-        self.menu = globals.get_slot().parent
+        self.menu = context.get_slot().parent
         self._props['clickable'] = True
 
         def handle_click(_) -> None:

+ 2 - 2
nicegui/elements/mixins/source_element.py

@@ -3,7 +3,7 @@ from typing import Any, Callable, Union
 
 from typing_extensions import Self, cast
 
-from ... import globals  # pylint: disable=redefined-builtin
+from ... import core
 from ...binding import BindableProperty, bind, bind_from, bind_to
 from ...element import Element
 from ...helpers import is_file
@@ -16,7 +16,7 @@ class SourceElement(Element):
     def __init__(self, *, source: Union[str, Path], **kwargs: Any) -> None:
         super().__init__(**kwargs)
         if is_file(source):
-            source = globals.app.add_static_file(local_file=source)
+            source = core.app.add_static_file(local_file=source)
         self.source = source
         self._props['src'] = source
 

+ 3 - 3
nicegui/elements/plotly.py

@@ -2,12 +2,12 @@ from __future__ import annotations
 
 from typing import Dict, Union
 
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import optional_features
 from ..element import Element
 
 try:
     import plotly.graph_objects as go
-    globals.optional_features.add('plotly')
+    optional_features.register('plotly')
 except ImportError:
     pass
 
@@ -29,7 +29,7 @@ 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
                        a `dict` object with keys `data`, `layout`, `config` (optional).
         """
-        if not 'plotly' in globals.optional_features:
+        if not optional_features.has('plotly'):
             raise ImportError('Plotly is not installed. Please run "pip install nicegui[plotly]".')
 
         super().__init__()

+ 5 - 4
nicegui/elements/pyplot.py

@@ -3,13 +3,14 @@ import io
 import os
 from typing import Any
 
-from .. import background_tasks, globals  # pylint: disable=redefined-builtin
+from .. import background_tasks, optional_features
+from ..client import Client
 from ..element import Element
 
 try:
     if os.environ.get('MATPLOTLIB', 'true').lower() == 'true':
         import matplotlib.pyplot as plt
-        globals.optional_features.add('matplotlib')
+        optional_features.register('matplotlib')
 except ImportError:
     pass
 
@@ -24,7 +25,7 @@ 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 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:
+        if not optional_features.has('matplotlib'):
             raise ImportError('Matplotlib is not installed. Please run "pip install matplotlib".')
 
         super().__init__('div')
@@ -51,6 +52,6 @@ class Pyplot(Element):
         self.update()
 
     async def _auto_close(self) -> None:
-        while self.client.id in globals.clients:
+        while self.client.id in Client.instances:
             await asyncio.sleep(1.0)
         plt.close(self.fig)

+ 2 - 2
nicegui/elements/query.py

@@ -2,8 +2,8 @@ from typing import Optional
 
 from typing_extensions import Self
 
+from .. import context
 from ..element import Element
-from ..globals import get_client
 
 
 class Query(Element, component='query.js'):
@@ -62,7 +62,7 @@ def query(selector: str) -> Query:
 
     :param selector: the CSS selector (e.g. "body", "#my-id", ".my-class", "div > p")
     """
-    for element in get_client().elements.values():
+    for element in context.get_client().elements.values():
         if isinstance(element, Query) and element._props['selector'] == selector:  # pylint: disable=protected-access
             return element
     return Query(selector)

+ 2 - 2
nicegui/elements/scene.py

@@ -3,7 +3,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
 
 from typing_extensions import Self
 
-from .. import binding, globals  # pylint: disable=redefined-builtin
+from .. import binding
 from ..awaitable_response import AwaitableResponse
 from ..dataclasses import KWONLY_SLOTS
 from ..element import Element
@@ -112,7 +112,7 @@ class Scene(Element,
 
     def _handle_init(self, e: GenericEventArguments) -> None:
         self.is_initialized = True
-        with globals.socket_id(e.args['socket_id']):
+        with self.client.individual_target(e.args['socket_id']):
             self.move_camera(duration=0)
             for obj in self.objects.values():
                 obj.send()

+ 2 - 2
nicegui/elements/stepper.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from typing import Any, Callable, Optional, Union, cast
 
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import context
 from ..element import Element
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
@@ -69,7 +69,7 @@ class Step(DisableableElement):
         self._classes = ['nicegui-step']
         if icon:
             self._props['icon'] = icon
-        self.stepper = cast(ValueElement, globals.get_slot().parent)
+        self.stepper = cast(ValueElement, context.get_slot().parent)
         if self.stepper.value is None:
             self.stepper.value = name
 

+ 2 - 2
nicegui/elements/tabs.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from typing import Any, Callable, Optional, Union
 
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import context
 from .mixins.disableable_element import DisableableElement
 from .mixins.value_element import ValueElement
 
@@ -44,7 +44,7 @@ class Tab(DisableableElement):
         self._props['label'] = label if label is not None else name
         if icon:
             self._props['icon'] = icon
-        self.tabs = globals.get_slot().parent
+        self.tabs = context.get_slot().parent
 
 
 class TabPanels(ValueElement):

+ 14 - 10
nicegui/elements/timer.py

@@ -1,10 +1,13 @@
 import asyncio
 import time
+from contextlib import nullcontext
 from typing import Any, Callable, Optional
 
-from .. import background_tasks, globals, helpers  # pylint: disable=redefined-builtin
+from .. import background_tasks, core, helpers
 from ..binding import BindableProperty
+from ..client import Client
 from ..element import Element
+from ..logging import log
 
 
 class Timer(Element, component='timer.js'):
@@ -35,10 +38,10 @@ class Timer(Element, component='timer.js'):
         self._is_canceled: bool = False
 
         coroutine = self._run_once if once else self._run_in_loop
-        if globals.state == globals.State.STARTED:
+        if core.app.is_started:
             background_tasks.create(coroutine(), name=str(callback))
         else:
-            globals.app.on_startup(coroutine)
+            core.app.on_startup(coroutine)
 
     def activate(self) -> None:
         """Activate the timer."""
@@ -57,7 +60,7 @@ class Timer(Element, component='timer.js'):
         try:
             if not await self._connected():
                 return
-            with self.parent_slot:
+            with self.parent_slot or nullcontext():
                 await asyncio.sleep(self.interval)
                 if self.active and not self._should_stop():
                     await self._invoke_callback()
@@ -68,7 +71,7 @@ class Timer(Element, component='timer.js'):
         try:
             if not await self._connected():
                 return
-            with self.parent_slot:
+            with self.parent_slot or nullcontext():
                 while not self._should_stop():
                     try:
                         start = time.time()
@@ -79,7 +82,7 @@ class Timer(Element, component='timer.js'):
                     except asyncio.CancelledError:
                         break
                     except Exception as e:
-                        globals.handle_exception(e)
+                        core.app.handle_exception(e)
                         await asyncio.sleep(self.interval)
         finally:
             self._cleanup()
@@ -91,7 +94,7 @@ class Timer(Element, component='timer.js'):
             if helpers.is_coroutine_function(self.callback):
                 await result
         except Exception as e:
-            globals.handle_exception(e)
+            core.app.handle_exception(e)
 
     async def _connected(self, timeout: float = 60.0) -> bool:
         """Wait for the client connection before the timer callback can be allowed to manipulate the state.
@@ -107,15 +110,16 @@ class Timer(Element, component='timer.js'):
             await self.client.connected(timeout=timeout)
             return True
         except TimeoutError:
-            globals.log.error(f'Timer cancelled because client is not connected after {timeout} seconds')
+            log.error(f'Timer cancelled because client is not connected after {timeout} seconds')
             return False
 
     def _should_stop(self) -> bool:
         return (
             self.is_deleted or
-            self.client.id not in globals.clients or
+            self.client.id not in Client.instances or
             self._is_canceled or
-            globals.state in {globals.State.STOPPING, globals.State.STOPPED}
+            core.app.is_stopping or
+            core.app.is_stopped
         )
 
     def _cleanup(self) -> None:

+ 3 - 3
nicegui/elements/tree.py

@@ -2,9 +2,9 @@ from typing import Any, Callable, Dict, Iterator, List, Literal, Optional, Set
 
 from typing_extensions import Self
 
-from .. import globals  # pylint: disable=redefined-builtin
 from ..element import Element
 from ..events import GenericEventArguments, ValueChangeEventArguments, handle_event
+from ..logging import log
 
 
 class Tree(Element):
@@ -104,6 +104,6 @@ class Tree(Element):
         if 'default-expand-all' in self._props:
             # https://github.com/zauberzeug/nicegui/issues/1385
             del self._props['default-expand-all']
-            globals.log.warning('The prop "default_expand_all" is not supported by `ui.tree`.\n'
-                                'Use ".expand()" instead.')
+            log.warning('The prop "default_expand_all" is not supported by `ui.tree`.\n'
+                        'Use ".expand()" instead.')
         return self

+ 2 - 2
nicegui/elements/video.py

@@ -1,7 +1,7 @@
 from pathlib import Path
 from typing import Union
 
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import core
 from ..element import Element
 
 
@@ -28,7 +28,7 @@ class Video(Element, component='video.js'):
         """
         super().__init__()
         if Path(src).is_file():
-            src = globals.app.add_media_file(local_file=src)
+            src = core.app.add_media_file(local_file=src)
         self._props['src'] = src
         self._props['controls'] = controls
         self._props['autoplay'] = autoplay

+ 5 - 5
nicegui/events.py

@@ -5,7 +5,7 @@ from dataclasses import dataclass
 from inspect import Parameter, signature
 from typing import TYPE_CHECKING, Any, Awaitable, BinaryIO, Callable, Dict, List, Literal, Optional, Union
 
-from . import background_tasks, globals  # pylint: disable=redefined-builtin
+from . import background_tasks, core
 from .awaitable_response import AwaitableResponse
 from .dataclasses import KWONLY_SLOTS
 from .slot import Slot
@@ -440,10 +440,10 @@ def handle_event(handler: Optional[Callable[..., Any]], arguments: EventArgument
                     try:
                         await result
                     except Exception as e:
-                        globals.handle_exception(e)
-            if globals.loop and globals.loop.is_running():
+                        core.app.handle_exception(e)
+            if core.loop and core.loop.is_running():
                 background_tasks.create(wait_for_result(), name=str(handler))
             else:
-                globals.app.on_startup(wait_for_result())
+                core.app.on_startup(wait_for_result())
     except Exception as e:
-        globals.handle_exception(e)
+        core.app.handle_exception(e)

+ 8 - 7
nicegui/favicon.py

@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Optional, Tuple, Union
 
 from fastapi.responses import FileResponse, Response, StreamingResponse
 
-from . import globals  # pylint: disable=redefined-builtin
+from . import core
 from .helpers import is_file
 from .version import __version__
 
@@ -19,13 +19,13 @@ if TYPE_CHECKING:
 def create_favicon_route(path: str, favicon: Optional[Union[str, Path]]) -> None:
     """Create a favicon route for the given path."""
     if is_file(favicon):
-        globals.app.add_route('/favicon.ico' if path == '/' else f'{path}/favicon.ico',
-                              lambda _: FileResponse(favicon))  # type: ignore
+        core.app.add_route('/favicon.ico' if path == '/' else f'{path}/favicon.ico',
+                           lambda _: FileResponse(favicon))  # type: ignore
 
 
 def get_favicon_url(page: page, prefix: str) -> str:
     """Return the URL of the favicon for a given page."""
-    favicon = page.favicon or globals.favicon
+    favicon = page.favicon or core.app._run_config.favicon  # pylint: disable=protected-access
     if not favicon:
         return f'{prefix}/_nicegui/{__version__}/static/favicon.ico'
 
@@ -46,9 +46,10 @@ def get_favicon_url(page: page, prefix: str) -> str:
 
 def get_favicon_response() -> Response:
     """Return the FastAPI response for the global favicon."""
-    if not globals.favicon:
-        raise ValueError(f'invalid favicon: {globals.favicon}')
-    favicon = str(globals.favicon).strip()
+    global_favicon = core.app._run_config.favicon  # pylint: disable=protected-access
+    if not global_favicon:
+        raise ValueError(f'invalid favicon: {global_favicon}')
+    favicon = str(global_favicon).strip()
 
     if _is_svg(favicon):
         return Response(favicon, media_type='image/svg+xml')

+ 3 - 3
nicegui/functions/download.py

@@ -1,7 +1,7 @@
 from pathlib import Path
 from typing import Optional, Union
 
-from .. import globals, helpers  # pylint: disable=redefined-builtin
+from .. import context, core, helpers
 
 
 def download(src: Union[str, Path], filename: Optional[str] = None) -> None:
@@ -13,7 +13,7 @@ def download(src: Union[str, Path], filename: Optional[str] = None) -> None:
     :param filename: name of the file to download (default: name of the file on the server)
     """
     if helpers.is_file(src):
-        src = globals.app.add_static_file(local_file=src, single_use=True)
+        src = core.app.add_static_file(local_file=src, single_use=True)
     else:
         src = str(src)
-    globals.get_client().download(src, filename)
+    context.get_client().download(src, filename)

+ 3 - 3
nicegui/functions/html.py

@@ -1,11 +1,11 @@
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import context
 
 
 def add_body_html(code: str) -> None:
     """Add HTML code to the body of the page."""
-    globals.get_client().body_html += code + '\n'
+    context.get_client().body_html += code + '\n'
 
 
 def add_head_html(code: str) -> None:
     """Add HTML code to the head of the page."""
-    globals.get_client().head_html += code + '\n'
+    context.get_client().head_html += code + '\n'

+ 6 - 5
nicegui/functions/javascript.py

@@ -1,7 +1,8 @@
 from typing import Optional
 
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import context
 from ..awaitable_response import AwaitableResponse
+from ..logging import log
 
 
 def run_javascript(code: str, *,
@@ -21,15 +22,15 @@ def run_javascript(code: str, *,
     :return: response from the browser, or `None` if `respond` is `False`
     """
     if respond is True:
-        globals.log.warning('The "respond" argument of run_javascript() has been removed. '
-                            'Now the function always returns an AwaitableResponse that can be awaited. '
-                            'Please remove the "respond=True" argument.')
+        log.warning('The "respond" argument of run_javascript() has been removed. '
+                    'Now the function always returns an AwaitableResponse that can be awaited. '
+                    'Please remove the "respond=True" argument.')
     if respond is False:
         raise ValueError('The "respond" argument of run_javascript() has been removed. '
                          'Now the function always returns an AwaitableResponse that can be awaited. '
                          'Please remove the "respond=False" argument and call the function without awaiting.')
 
-    client = globals.get_client()
+    client = context.get_client()
     if not client.has_socket_connection:
         raise RuntimeError('Cannot run JavaScript before client is connected; '
                            'try "await client.connected()" or "client.on_connect(...)".')

+ 5 - 4
nicegui/functions/notify.py

@@ -1,6 +1,7 @@
 from typing import Any, Literal, Optional, Union
 
-from .. import globals, outbox  # pylint: disable=redefined-builtin
+from .. import context, outbox
+from ..logging import log
 
 ARG_MAP = {
     'close_button': 'closeBtn',
@@ -49,7 +50,7 @@ def notify(message: Any, *,
     options = {ARG_MAP.get(key, key): value for key, value in locals().items() if key != 'kwargs' and value is not None}
     options['message'] = str(message)
     options.update(kwargs)
-    if globals.get_client().has_socket_connection:
-        outbox.enqueue_message('notify', options, globals.get_client().id)
+    if context.get_client().has_socket_connection:
+        outbox.enqueue_message('notify', options, context.get_client().id)
     else:
-        globals.log.warning(f'Ignoring notification "{message}" because the client is not connected.')
+        log.warning(f'Ignoring notification "{message}" because the client is not connected.')

+ 6 - 4
nicegui/functions/open.py

@@ -1,6 +1,8 @@
 from typing import Any, Callable, Union
 
-from .. import globals  # pylint: disable=redefined-builtin
+from .. import context
+from ..client import Client
+from ..logging import log
 
 
 def open(target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:  # pylint: disable=redefined-builtin
@@ -19,9 +21,9 @@ def open(target: Union[Callable[..., Any], str], new_tab: bool = False) -> None:
     :param target: page function or string that is a an absolute URL or relative path from base URL
     :param new_tab: whether to open the target in a new tab (might be blocked by the browser)
     """
-    path = target if isinstance(target, str) else globals.page_routes[target]
-    client = globals.get_client()
+    path = target if isinstance(target, str) else Client.page_routes[target]
+    client = context.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')
+        log.error('Cannot open page because client is not connected, try RedirectResponse from FastAPI instead')

+ 13 - 7
nicegui/functions/refreshable.py

@@ -1,11 +1,12 @@
 from __future__ import annotations
 
 from dataclasses import dataclass, field
-from typing import Any, Awaitable, Callable, ClassVar, Dict, List, Optional, Tuple, Union
+from typing import Any, Awaitable, Callable, ClassVar, Dict, List, Optional, Tuple, Union, cast
 
 from typing_extensions import Self
 
-from .. import background_tasks, globals  # pylint: disable=redefined-builtin
+from .. import background_tasks, core
+from ..client import Client
 from ..dataclasses import KWONLY_SLOTS
 from ..element import Element
 from ..helpers import is_coroutine_function
@@ -101,10 +102,10 @@ class refreshable:
                 raise
             if is_coroutine_function(self.func):
                 assert result is not None
-                if globals.loop and globals.loop.is_running():
+                if core.loop and core.loop.is_running():
                     background_tasks.create(result)
                 else:
-                    globals.app.on_startup(result)
+                    core.app.on_startup(result)
 
     def prune(self) -> None:
         """Remove all targets that are no longer on a page with a client connection.
@@ -114,13 +115,18 @@ class refreshable:
         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
+            if target.container.client.id in Client.instances and target.container.id in target.container.client.elements
         ]
 
 
 def state(value: Any) -> Tuple[Any, Callable[[Any], None]]:
-    target = RefreshableTarget.current_target
-    assert target is not None
+    """Create a state variable that automatically updates its refreshable UI container.
+
+    :param value: The initial value of the state variable.
+
+    :return: A tuple containing the current value and a function to update the value.
+    """
+    target = cast(RefreshableTarget, RefreshableTarget.current_target)
 
     if target.next_index >= len(target.locals):
         target.locals.append(value)

+ 0 - 128
nicegui/globals.py

@@ -1,128 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-import inspect
-import logging
-import os
-from contextlib import contextmanager
-from enum import Enum
-from pathlib import Path
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Iterator, List, Literal, Optional, Set, Union
-
-from socketio import AsyncServer
-from uvicorn import Server
-
-from . import background_tasks
-from .helpers import is_coroutine_function
-
-if TYPE_CHECKING:
-    from .air import Air
-    from .app import App
-    from .client import Client
-    from .language import Language
-    from .slot import Slot
-
-
-class State(Enum):
-    STOPPED = 0
-    STARTING = 1
-    STARTED = 2
-    STOPPING = 3
-
-
-app: App
-sio: AsyncServer
-server: Server
-loop: Optional[asyncio.AbstractEventLoop] = None
-log: logging.Logger = logging.getLogger('nicegui')
-state: State = State.STOPPED
-ui_run_has_been_called: bool = False
-optional_features: Set[str] = set()
-
-reload: bool
-title: str
-viewport: str
-favicon: Optional[Union[str, Path]]
-dark: Optional[bool]
-language: Language
-binding_refresh_interval: float
-reconnect_timeout: float
-tailwind: bool
-prod_js: bool
-endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none'
-air: Optional[Air] = None
-storage_path: Path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve()
-socket_io_js_query_params: Dict = {}
-socket_io_js_extra_headers: Dict = {}
-socket_io_js_transports: List[Literal['websocket', 'polling']] = ['websocket', 'polling']  # NOTE: we favor websocket
-_socket_id: Optional[str] = None
-slot_stacks: Dict[int, List[Slot]] = {}
-clients: Dict[str, Client] = {}
-index_client: Client
-quasar_config: Dict = {
-    'brand': {
-        'primary': '#5898d4',
-    },
-    'loadingBar': {
-        'color': 'primary',
-        'skipHijack': False,
-    },
-}
-
-page_routes: Dict[Callable[..., Any], str] = {}
-
-startup_handlers: List[Union[Callable[..., Any], Awaitable]] = []
-shutdown_handlers: List[Union[Callable[..., Any], Awaitable]] = []
-connect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
-disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = []
-exception_handlers: List[Callable[..., Any]] = [log.exception]
-
-
-def get_task_id() -> int:
-    """Return the ID of the current asyncio task."""
-    try:
-        return id(asyncio.current_task())
-    except RuntimeError:
-        return 0
-
-
-def get_slot_stack() -> List[Slot]:
-    """Return the slot stack of the current asyncio task."""
-    task_id = get_task_id()
-    if task_id not in slot_stacks:
-        slot_stacks[task_id] = []
-    return slot_stacks[task_id]
-
-
-def prune_slot_stack() -> None:
-    """Remove the current slot stack if it is empty."""
-    task_id = get_task_id()
-    if not slot_stacks[task_id]:
-        del slot_stacks[task_id]
-
-
-def get_slot() -> Slot:
-    """Return the current slot."""
-    return get_slot_stack()[-1]
-
-
-def get_client() -> Client:
-    """Return the current client."""
-    return get_slot().parent.client
-
-
-@contextmanager
-def socket_id(id_: str) -> Iterator[None]:
-    """Enter a context with a specific socket ID."""
-    global _socket_id  # pylint: disable=global-statement
-    _socket_id = id_
-    yield
-    _socket_id = None
-
-
-def handle_exception(exception: Exception) -> None:
-    """Handle an exception by invoking all registered exception handlers."""
-    for handler in exception_handlers:
-        result = handler() if not inspect.signature(handler).parameters else handler(exception)
-        if is_coroutine_function(handler):
-            background_tasks.create(result)

+ 2 - 2
nicegui/helpers.py

@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, Optional,
 from fastapi import Request
 from fastapi.responses import StreamingResponse
 
-from . import background_tasks, globals  # pylint: disable=redefined-builtin
+from . import background_tasks, core
 
 if TYPE_CHECKING:
     from .client import Client
@@ -75,7 +75,7 @@ def safe_invoke(func: Union[Callable[..., Any], Awaitable], client: Optional[Cli
                         await result
                 background_tasks.create(result_with_client())
     except Exception as e:
-        globals.handle_exception(e)
+        core.app.handle_exception(e)
 
 
 def is_port_open(host: str, port: int) -> bool:

+ 3 - 0
nicegui/logging.py

@@ -0,0 +1,3 @@
+import logging
+
+log: logging.Logger = logging.getLogger('nicegui')

+ 12 - 0
nicegui/native/__init__.py

@@ -0,0 +1,12 @@
+from .native import WindowProxy, method_queue, response_queue
+from .native_config import NativeConfig
+from .native_mode import activate, find_open_port
+
+__all__ = [
+    'activate',
+    'find_open_port',
+    'method_queue',
+    'NativeConfig',
+    'response_queue',
+    'WindowProxy',
+]

+ 4 - 13
nicegui/native.py → nicegui/native/native.py

@@ -1,12 +1,10 @@
 import inspect
 import warnings
-from dataclasses import dataclass, field
 from multiprocessing import Queue
-from typing import Any, Callable, Dict, Optional, Tuple
+from typing import Any, Callable, Tuple
 
-from .dataclasses import KWONLY_SLOTS
-from .globals import log
-from .run_executor import io_bound
+from .. import run
+from ..logging import log
 
 method_queue: Queue = Queue()
 response_queue: Queue = Queue()
@@ -122,7 +120,7 @@ try:
                     log.exception(f'error in {name}')
                     return None
             name = inspect.currentframe().f_back.f_code.co_name  # type: ignore
-            return await io_bound(wrapper, *args, **kwargs)
+            return await run.io_bound(wrapper, *args, **kwargs)
 
         def signal_server_shutdown(self) -> None:
             """Signal the server shutdown."""
@@ -131,10 +129,3 @@ try:
 except ModuleNotFoundError:
     class WindowProxy:  # type: ignore
         pass  # just a dummy if webview is not installed
-
-
-@dataclass(**KWONLY_SLOTS)
-class Native:
-    start_args: Dict[str, Any] = field(default_factory=dict)
-    window_args: Dict[str, Any] = field(default_factory=dict)
-    main_window: Optional[WindowProxy] = None

+ 12 - 0
nicegui/native/native_config.py

@@ -0,0 +1,12 @@
+from dataclasses import dataclass, field
+from typing import Any, Dict, Optional
+
+from ..dataclasses import KWONLY_SLOTS
+from .native import WindowProxy
+
+
+@dataclass(**KWONLY_SLOTS)
+class NativeConfig:
+    start_args: Dict[str, Any] = field(default_factory=dict)
+    window_args: Dict[str, Any] = field(default_factory=dict)
+    main_window: Optional[WindowProxy] = None

+ 16 - 13
nicegui/native_mode.py → nicegui/native/native_mode.py

@@ -11,14 +11,17 @@ import warnings
 from threading import Event, Thread
 from typing import Any, Callable, Dict, List, Tuple
 
-from . import globals, helpers, native  # pylint: disable=redefined-builtin
+from .. import core, helpers, optional_features
+from ..logging import log
+from ..server import Server
+from . import native
 
 try:
     with warnings.catch_warnings():
         # webview depends on bottle which uses the deprecated CGI function (https://github.com/bottlepy/bottle/issues/1403)
         warnings.filterwarnings('ignore', category=DeprecationWarning)
         import webview
-    globals.optional_features.add('native')
+    optional_features.register('native')
 except ModuleNotFoundError:
     pass
 
@@ -37,13 +40,13 @@ def _open_window(
         'height': height,
         'fullscreen': fullscreen,
         'frameless': frameless,
-        **globals.app.native.window_args,
+        **core.app.native.window_args,
     }
     window = webview.create_window(**window_kwargs)
     closed = Event()
     window.events.closed += closed.set
     _start_window_method_executor(window, method_queue, response_queue, closed)
-    webview.start(storage_path=tempfile.mkdtemp(), **globals.app.native.start_args)
+    webview.start(storage_path=tempfile.mkdtemp(), **core.app.native.start_args)
 
 
 def _start_window_method_executor(window: webview.Window,
@@ -56,7 +59,7 @@ def _start_window_method_executor(window: webview.Window,
             if response is not None or 'dialog' in method.__name__:
                 response_queue.put(response)
         except Exception:
-            globals.log.exception(f'error in window.{method.__name__}')
+            log.exception(f'error in window.{method.__name__}')
 
     def window_method_executor() -> None:
         pending_executions: List[Thread] = []
@@ -65,7 +68,7 @@ def _start_window_method_executor(window: webview.Window,
                 method_name, args, kwargs = method_queue.get(block=False)
                 if method_name == 'signal_server_shutdown':
                     if pending_executions:
-                        globals.log.warning('shutdown is possibly blocked by opened dialogs like a file picker')
+                        log.warning('shutdown is possibly blocked by opened dialogs like a file picker')
                         while pending_executions:
                             pending_executions.pop().join()
                 elif method_name == 'get_always_on_top':
@@ -82,11 +85,11 @@ def _start_window_method_executor(window: webview.Window,
                         pending_executions.append(Thread(target=execute, args=(method, args, kwargs)))
                         pending_executions[-1].start()
                     else:
-                        globals.log.error(f'window.{method_name} is not callable')
+                        log.error(f'window.{method_name} is not callable')
             except queue.Empty:
                 time.sleep(0.01)
             except Exception:
-                globals.log.exception(f'error in window.{method_name}')
+                log.exception(f'error in window.{method_name}')
 
     Thread(target=window_method_executor).start()
 
@@ -96,14 +99,14 @@ def activate(host: str, port: int, title: str, width: int, height: int, fullscre
     def check_shutdown() -> None:
         while process.is_alive():
             time.sleep(0.1)
-        globals.server.should_exit = True
-        while globals.state != globals.State.STOPPED:
+        Server.instance.should_exit = True
+        while not core.app.is_stopped:
             time.sleep(0.1)
         _thread.interrupt_main()
 
-    if 'native' not in globals.optional_features:
-        globals.log.error('Native mode is not supported in this configuration.\n'
-                          'Please run "pip install pywebview" to use it.')
+    if not optional_features.has('native'):
+        log.error('Native mode is not supported in this configuration.\n'
+                  'Please run "pip install pywebview" to use it.')
         sys.exit(1)
 
     mp.freeze_support()

+ 35 - 133
nicegui/nicegui.py

@@ -1,6 +1,5 @@
 import asyncio
 import mimetypes
-import time
 import urllib.parse
 from pathlib import Path
 from typing import Dict
@@ -11,22 +10,22 @@ from fastapi.responses import FileResponse, Response
 from fastapi.staticfiles import StaticFiles
 from fastapi_socketio import SocketManager
 
-from . import (background_tasks, binding, favicon, globals, json, outbox,  # pylint: disable=redefined-builtin
-               run_executor, welcome)
+from . import air, background_tasks, binding, core, favicon, helpers, json, outbox, run, welcome
 from .app import App
 from .client import Client
 from .dependencies import js_components, libraries
 from .error import error_content
-from .helpers import is_file, safe_invoke
 from .json import NiceGUIJSONResponse
+from .logging import log
 from .middlewares import RedirectWithPrefixMiddleware
 from .page import page
+from .slot import Slot
 from .version import __version__
 
-globals.app = app = App(default_response_class=NiceGUIJSONResponse)
+core.app = app = App(default_response_class=NiceGUIJSONResponse)
 # NOTE we use custom json module which wraps orjson
 socket_manager = SocketManager(app=app, mount_location='/_nicegui_ws/', json=json)
-globals.sio = sio = socket_manager._sio  # pylint: disable=protected-access
+core.sio = sio = socket_manager._sio  # pylint: disable=protected-access
 
 mimetypes.add_type('text/javascript', '.js')
 mimetypes.add_type('text/css', '.css')
@@ -39,12 +38,12 @@ static_files = StaticFiles(
 )
 app.mount(f'/_nicegui/{__version__}/static', static_files, name='static')
 
-globals.index_client = Client(page('/'), shared=True).__enter__()  # pylint: disable=unnecessary-dunder-call
+Client.auto_index_client = Client(page('/'), shared=True).__enter__()  # pylint: disable=unnecessary-dunder-call
 
 
 @app.get('/')
 def _get_index(request: Request) -> Response:
-    return globals.index_client.build_response(request)
+    return Client.auto_index_client.build_response(request)
 
 
 @app.get(f'/_nicegui/{__version__}' + '/libraries/{key:path}')
@@ -73,9 +72,9 @@ def _get_component(key: str) -> FileResponse:
 def handle_startup(with_welcome_message: bool = True) -> None:
     """Handle the startup event."""
     # NOTE ping interval and timeout need to be lower than the reconnect timeout, but can't be too low
-    globals.sio.eio.ping_interval = max(globals.reconnect_timeout * 0.8, 4)
-    globals.sio.eio.ping_timeout = max(globals.reconnect_timeout * 0.4, 2)
-    if not globals.ui_run_has_been_called:
+    sio.eio.ping_interval = max(app._run_config.reconnect_timeout * 0.8, 4)  # pylint: disable=protected-access
+    sio.eio.ping_timeout = max(app._run_config.reconnect_timeout * 0.4, 2)  # pylint: disable=protected-access
+    if not hasattr(app, '_run_config'):
         raise RuntimeError('\n\n'
                            'You must call ui.run() to start the server.\n'
                            'If ui.run() is behind a main guard\n'
@@ -83,27 +82,23 @@ def handle_startup(with_welcome_message: bool = True) -> None:
                            'remove the guard or replace it with\n'
                            '   if __name__ in {"__main__", "__mp_main__"}:\n'
                            'to allow for multiprocessing.')
-    if globals.favicon:
-        if is_file(globals.favicon):
-            globals.app.add_route('/favicon.ico', lambda _: FileResponse(globals.favicon))  # type: ignore
+    global_favicon = app._run_config.favicon  # pylint: disable=protected-access
+    if global_favicon:
+        if helpers.is_file(global_favicon):
+            app.add_route('/favicon.ico', lambda _: FileResponse(global_favicon))  # type: ignore
         else:
-            globals.app.add_route('/favicon.ico', lambda _: favicon.get_favicon_response())
+            app.add_route('/favicon.ico', lambda _: favicon.get_favicon_response())
     else:
-        globals.app.add_route('/favicon.ico', lambda _: FileResponse(Path(__file__).parent / 'static' / 'favicon.ico'))
-    globals.state = globals.State.STARTING
-    globals.loop = asyncio.get_running_loop()
-    with globals.index_client:
-        for t in globals.startup_handlers:
-            safe_invoke(t)
+        app.add_route('/favicon.ico', lambda _: FileResponse(Path(__file__).parent / 'static' / 'favicon.ico'))
+    core.loop = asyncio.get_running_loop()
+    app.start()
     background_tasks.create(binding.refresh_loop(), name='refresh bindings')
-    background_tasks.create(outbox.loop(), name='send outbox')
-    background_tasks.create(prune_clients(), name='prune clients')
-    background_tasks.create(prune_slot_stacks(), name='prune slot stacks')
-    globals.state = globals.State.STARTED
+    background_tasks.create(outbox.loop(air.instance), name='send outbox')
+    background_tasks.create(Client.prune_instances(), name='prune clients')
+    background_tasks.create(Slot.prune_stacks(), name='prune slot stacks')
     if with_welcome_message:
         background_tasks.create(welcome.print_message())
-    if globals.air:
-        background_tasks.create(globals.air.connect())
+    air.connect()
 
 
 @app.on_event('shutdown')
@@ -111,19 +106,14 @@ async def handle_shutdown() -> None:
     """Handle the shutdown event."""
     if app.native.main_window:
         app.native.main_window.signal_server_shutdown()
-    globals.state = globals.State.STOPPING
-    with globals.index_client:
-        for t in globals.shutdown_handlers:
-            safe_invoke(t)
-    run_executor.tear_down()
-    globals.state = globals.State.STOPPED
-    if globals.air:
-        await globals.air.disconnect()
+    app.stop()
+    run.tear_down()
+    air.disconnect()
 
 
 @app.exception_handler(404)
 async def _exception_handler_404(request: Request, exception: Exception) -> Response:
-    globals.log.warning(f'{request.url} not found')
+    log.warning(f'{request.url} not found')
     with Client(page('')) as client:
         error_content(404, exception)
     return client.build_response(request, 404)
@@ -131,7 +121,7 @@ async def _exception_handler_404(request: Request, exception: Exception) -> Resp
 
 @app.exception_handler(Exception)
 async def _exception_handler_500(request: Request, exception: Exception) -> Response:
-    globals.log.exception(exception)
+    log.exception(exception)
     with Client(page('')) as client:
         error_content(500, exception)
     return client.build_response(request, 500)
@@ -139,124 +129,36 @@ async def _exception_handler_500(request: Request, exception: Exception) -> Resp
 
 @sio.on('handshake')
 async def _on_handshake(sid: str, client_id: str) -> bool:
-    client = globals.clients.get(client_id)
+    client = Client.instances.get(client_id)
     if not client:
         return False
     client.environ = sio.get_environ(sid)
     await sio.enter_room(sid, client.id)
-    handle_handshake(client)
+    client.handle_handshake()
     return True
 
 
-def handle_handshake(client: Client) -> None:
-    """Cancel pending disconnect task and invoke connect handlers."""
-    if client.disconnect_task:
-        client.disconnect_task.cancel()
-        client.disconnect_task = None
-    for t in client.connect_handlers:
-        safe_invoke(t, client)
-    for t in globals.connect_handlers:
-        safe_invoke(t, client)
-
-
 @sio.on('disconnect')
 def _on_disconnect(sid: str) -> None:
     query_bytes: bytearray = sio.get_environ(sid)['asgi.scope']['query_string']
     query = urllib.parse.parse_qs(query_bytes.decode())
     client_id = query['client_id'][0]
-    client = globals.clients.get(client_id)
+    client = Client.instances.get(client_id)
     if client:
-        client.disconnect_task = background_tasks.create(handle_disconnect(client))
-
-
-async def handle_disconnect(client: Client) -> None:
-    """Wait for the browser to reconnect; invoke disconnect handlers if it doesn't."""
-    delay = client.page.reconnect_timeout if client.page.reconnect_timeout is not None else globals.reconnect_timeout
-    await asyncio.sleep(delay)
-    if not client.shared:
-        _delete_client(client)
-    for t in client.disconnect_handlers:
-        safe_invoke(t, client)
-    for t in globals.disconnect_handlers:
-        safe_invoke(t, client)
+        client.handle_disconnect()
 
 
 @sio.on('event')
 def _on_event(_: str, msg: Dict) -> None:
-    client = globals.clients.get(msg['client_id'])
+    client = Client.instances.get(msg['client_id'])
     if not client or not client.has_socket_connection:
         return
-    handle_event(client, msg)
-
-
-def handle_event(client: Client, msg: Dict) -> None:
-    """Forward an event to the corresponding element."""
-    with client:
-        sender = client.elements.get(msg['id'])
-        if sender:
-            msg['args'] = [None if arg is None else json.loads(arg) for arg in msg.get('args', [])]
-            if len(msg['args']) == 1:
-                msg['args'] = msg['args'][0]
-            sender._handle_event(msg)  # pylint: disable=protected-access
+    client.handle_event(msg)
 
 
 @sio.on('javascript_response')
 def _on_javascript_response(_: str, msg: Dict) -> None:
-    client = globals.clients.get(msg['client_id'])
+    client = Client.instances.get(msg['client_id'])
     if not client:
         return
-    handle_javascript_response(client, msg)
-
-
-def handle_javascript_response(client: Client, msg: Dict) -> None:
-    """Forward a JavaScript response to the corresponding element."""
-    client.waiting_javascript_commands[msg['request_id']] = msg['result']
-
-
-async def prune_clients() -> None:
-    """Prune stale clients in an endless loop."""
-    while True:
-        try:
-            stale_clients = [
-                client
-                for client in globals.clients.values()
-                if not client.shared and not client.has_socket_connection and client.created < time.time() - 60.0
-            ]
-            for client in stale_clients:
-                _delete_client(client)
-        except Exception:
-            # NOTE: make sure the loop doesn't crash
-            globals.log.exception('Error while pruning clients')
-        await asyncio.sleep(10)
-
-
-async def prune_slot_stacks() -> None:
-    """Prune stale slot stacks in an endless loop."""
-    while True:
-        try:
-            running = [
-                id(task)
-                for task in asyncio.tasks.all_tasks()
-                if not task.done() and not task.cancelled()
-            ]
-            stale = [
-                id_
-                for id_ in globals.slot_stacks
-                if id_ not in running
-            ]
-            for id_ in stale:
-                del globals.slot_stacks[id_]
-        except Exception:
-            # NOTE: make sure the loop doesn't crash
-            globals.log.exception('Error while pruning slot stacks')
-        await asyncio.sleep(10)
-
-
-def _delete_client(client: Client) -> None:
-    """Delete a client and all its elements.
-
-    If the global clients dictionary does not contain the client, its elements are still removed and a KeyError is raised.
-    Normally this should never happen, but has been observed (see #1826).
-    """
-    client.remove_all_elements()
-    del globals.clients[client.id]
+    client.handle_javascript_response(msg)

+ 13 - 0
nicegui/optional_features.py

@@ -0,0 +1,13 @@
+from typing import Set
+
+_optional_features: Set[str] = set()
+
+
+def register(feature: str) -> None:
+    """Register an optional feature."""
+    _optional_features.add(feature)
+
+
+def has(feature: str) -> bool:
+    """Check if an optional feature is registered."""
+    return feature in _optional_features

+ 12 - 20
nicegui/outbox.py

@@ -4,9 +4,10 @@ import asyncio
 from collections import defaultdict, deque
 from typing import TYPE_CHECKING, Any, DefaultDict, Deque, Dict, Optional, Tuple
 
-from . import globals  # pylint: disable=redefined-builtin
+from . import core
 
 if TYPE_CHECKING:
+    from .air import Air
     from .element import Element
 
 ClientId = str
@@ -33,15 +34,13 @@ def enqueue_message(message_type: MessageType, data: Any, target_id: ClientId) -
     message_queue.append((target_id, message_type, data))
 
 
-async def _emit(message_type: MessageType, data: Any, target_id: ClientId) -> None:
-    await globals.sio.emit(message_type, data, room=target_id)
-    if _is_target_on_air(target_id):
-        assert globals.air is not None
-        await globals.air.emit(message_type, data, room=target_id)
-
-
-async def loop() -> None:
+async def loop(air: Optional[Air]) -> None:
     """Emit queued updates and messages in an endless loop."""
+    async def emit(message_type: MessageType, data: Any, target_id: ClientId) -> None:
+        await core.sio.emit(message_type, data, room=target_id)
+        if air is not None and air.is_air_target(target_id):
+            await air.emit(message_type, data, room=target_id)
+
     while True:
         if not update_queue and not message_queue:
             await asyncio.sleep(0.01)
@@ -54,25 +53,18 @@ async def loop() -> None:
                     element_id: None if element is None else element._to_dict()  # pylint: disable=protected-access
                     for element_id, element in elements.items()
                 }
-                coros.append(_emit('update', data, client_id))
+                coros.append(emit('update', data, client_id))
             update_queue.clear()
 
             for target_id, message_type, data in message_queue:
-                coros.append(_emit(message_type, data, target_id))
+                coros.append(emit(message_type, data, target_id))
             message_queue.clear()
 
             for coro in coros:
                 try:
                     await coro
                 except Exception as e:
-                    globals.handle_exception(e)
+                    core.app.handle_exception(e)
         except Exception as e:
-            globals.handle_exception(e)
+            core.app.handle_exception(e)
             await asyncio.sleep(0.1)
-
-
-def _is_target_on_air(target_id: str) -> bool:
-    if target_id in globals.clients:
-        return globals.clients[target_id].on_air
-
-    return target_id in globals.sio.manager.rooms

+ 9 - 9
nicegui/page.py

@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Callable, Optional, Union
 
 from fastapi import Request, Response
 
-from . import background_tasks, binding, globals, helpers  # pylint: disable=redefined-builtin
+from . import background_tasks, binding, core, helpers
 from .client import Client
 from .favicon import create_favicon_route
 from .language import Language
@@ -57,7 +57,7 @@ class page:
         self.language = language
         self.response_timeout = response_timeout
         self.kwargs = kwargs
-        self.api_router = api_router or globals.app.router
+        self.api_router = api_router or core.app.router
         self.reconnect_timeout = reconnect_timeout
 
         create_favicon_route(self.path, favicon)
@@ -69,22 +69,22 @@ class page:
 
     def resolve_title(self) -> str:
         """Return the title of the page."""
-        return self.title if self.title is not None else globals.title
+        return self.title if self.title is not None else core.app._run_config.title  # pylint: disable=protected-access
 
     def resolve_viewport(self) -> str:
         """Return the viewport of the page."""
-        return self.viewport if self.viewport is not None else globals.viewport
+        return self.viewport if self.viewport is not None else core.app._run_config.viewport  # pylint: disable=protected-access
 
     def resolve_dark(self) -> Optional[bool]:
         """Return whether the page should use dark mode."""
-        return self.dark if self.dark is not ... else globals.dark
+        return self.dark if self.dark is not ... else core.app._run_config.dark  # pylint: disable=protected-access
 
     def resolve_language(self) -> Optional[str]:
         """Return the language of the page."""
-        return self.language if self.language is not ... else globals.language
+        return self.language if self.language is not ... else core.app._run_config.language  # pylint: disable=protected-access
 
     def __call__(self, func: Callable[..., Any]) -> Callable[..., Any]:
-        globals.app.remove_route(self.path)  # NOTE make sure only the latest route definition is used
+        core.app.remove_route(self.path)  # NOTE make sure only the latest route definition is used
         parameters_of_decorated_func = list(inspect.signature(func).parameters.keys())
 
         async def decorated(*dec_args, **dec_kwargs) -> Response:
@@ -119,8 +119,8 @@ class page:
         decorated.__signature__ = inspect.Signature(parameters)  # type: ignore
 
         if 'include_in_schema' not in self.kwargs:
-            self.kwargs['include_in_schema'] = globals.endpoint_documentation in {'page', 'all'}
+            self.kwargs['include_in_schema'] = core.app.config.endpoint_documentation in {'page', 'all'}
 
         self.api_router.get(self._path, **self.kwargs)(decorated)
-        globals.page_routes[func] = self.path
+        Client.page_routes[func] = self.path
         return func

+ 4 - 4
nicegui/page_layout.py

@@ -1,6 +1,6 @@
 from typing import Literal, Optional
 
-from . import globals  # pylint: disable=redefined-builtin
+from . import context
 from .element import Element
 from .elements.mixins.value_element import ValueElement
 from .functions.html import add_body_html
@@ -43,7 +43,7 @@ class Header(ValueElement):
         :param wrap: whether the header should wrap its content (default: `True`)
         :param add_scroll_padding: whether to automatically prevent link targets from being hidden behind the header (default: `True`)
         """
-        with globals.get_client().layout:
+        with context.get_client().layout:
             super().__init__(tag='q-header', value=value, on_value_change=None)
         self._classes = ['nicegui-header']
         self._props['bordered'] = bordered
@@ -106,7 +106,7 @@ class Drawer(Element):
         :param top_corner: whether the drawer expands into the top corner (default: `False`)
         :param bottom_corner: whether the drawer expands into the bottom corner (default: `False`)
         """
-        with globals.get_client().layout:
+        with context.get_client().layout:
             super().__init__('q-drawer')
         if value is None:
             self._props['show-if-above'] = True
@@ -224,7 +224,7 @@ class Footer(ValueElement):
         :param elevated: whether the footer should have a shadow (default: `False`)
         :param wrap: whether the footer should wrap its content (default: `True`)
         """
-        with globals.get_client().layout:
+        with context.get_client().layout:
             super().__init__(tag='q-footer', value=value, on_value_change=None)
         self.classes('nicegui-footer')
         self._props['bordered'] = bordered

+ 32 - 179
nicegui/run.py

@@ -1,191 +1,44 @@
-import multiprocessing
-import os
-import socket
+import asyncio
 import sys
-from pathlib import Path
-from typing import Any, List, Literal, Optional, Tuple, Union
+from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
+from functools import partial
+from typing import Any, Callable
 
-import __main__
-import uvicorn
-from starlette.routing import Route
-from uvicorn.main import STARTUP_FAILURE
-from uvicorn.supervisors import ChangeReload, Multiprocess
+from . import core, helpers
 
-from . import native_mode  # pylint: disable=redefined-builtin
-from . import storage  # pylint: disable=redefined-builtin
-from . import globals, helpers  # pylint: disable=redefined-builtin
-from . import native as native_module
-from .air import Air
-from .language import Language
+process_pool = ProcessPoolExecutor()
+thread_pool = ThreadPoolExecutor()
 
-APP_IMPORT_STRING = 'nicegui:app'
 
-
-class CustomServerConfig(uvicorn.Config):
-    storage_secret: Optional[str] = None
-    method_queue: Optional[multiprocessing.Queue] = None
-    response_queue: Optional[multiprocessing.Queue] = None
-
-
-class Server(uvicorn.Server):
-
-    def run(self, sockets: Optional[List[socket.socket]] = None) -> None:
-        globals.server = self
-        assert isinstance(self.config, CustomServerConfig)
-        if self.config.method_queue is not None and self.config.response_queue is not None:
-            native_module.method_queue = self.config.method_queue
-            native_module.response_queue = self.config.response_queue
-            globals.app.native.main_window = native_module.WindowProxy()
-
-        storage.set_storage_secret(self.config.storage_secret)
-        super().run(sockets=sockets)
-
-
-def run(*,
-        host: Optional[str] = None,
-        port: int = 8080,
-        title: str = 'NiceGUI',
-        viewport: str = 'width=device-width, initial-scale=1',
-        favicon: Optional[Union[str, Path]] = None,
-        dark: Optional[bool] = False,
-        language: Language = 'en-US',
-        binding_refresh_interval: float = 0.1,
-        reconnect_timeout: float = 3.0,
-        show: bool = True,
-        on_air: Optional[Union[str, Literal[True]]] = None,
-        native: bool = False,
-        window_size: Optional[Tuple[int, int]] = None,
-        fullscreen: bool = False,
-        frameless: bool = False,
-        reload: bool = True,
-        uvicorn_logging_level: str = 'warning',
-        uvicorn_reload_dirs: str = '.',
-        uvicorn_reload_includes: str = '*.py',
-        uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
-        tailwind: bool = True,
-        prod_js: bool = True,
-        endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none',
-        storage_secret: Optional[str] = None,
-        **kwargs: Any,
-        ) -> None:
-    '''ui.run
-
-    You can call `ui.run()` with optional arguments:
-
-    :param host: start server with this host (defaults to `'127.0.0.1` in native mode, otherwise `'0.0.0.0'`)
-    :param port: use this port (default: `8080`)
-    :param title: page title (default: `'NiceGUI'`, can be overwritten per page)
-    :param viewport: page meta viewport content (default: `'width=device-width, initial-scale=1'`, can be overwritten per page)
-    :param favicon: relative filepath, absolute URL to a favicon (default: `None`, NiceGUI icon will be used) or emoji (e.g. `'🚀'`, works for most browsers)
-    :param dark: whether to use Quasar's dark mode (default: `False`, use `None` for "auto" mode)
-    :param language: language for Quasar elements (default: `'en-US'`)
-    :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
-    :param reconnect_timeout: maximum time the server waits for the browser to reconnect (default: 3.0 seconds)
-    :param show: automatically open the UI in a browser tab (default: `True`)
-    :param on_air: tech preview: `allows temporary remote access <https://nicegui.io/documentation#nicegui_on_air>`_ if set to `True` (default: disabled)
-    :param native: open the UI in a native window of size 800x600 (default: `False`, deactivates `show`, automatically finds an open port)
-    :param window_size: open the UI in a native window with the provided size (e.g. `(1024, 786)`, default: `None`, also activates `native`)
-    :param fullscreen: open the UI in a fullscreen window (default: `False`, also activates `native`)
-    :param frameless: open the UI in a frameless window (default: `False`, also activates `native`)
-    :param reload: automatically reload the UI on file changes (default: `True`)
-    :param uvicorn_logging_level: logging level for uvicorn server (default: `'warning'`)
-    :param uvicorn_reload_dirs: string with comma-separated list for directories to be monitored (default is current working directory only)
-    :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 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 kwargs: additional keyword arguments are passed to `uvicorn.run`    
-    '''
-    globals.ui_run_has_been_called = True
-    globals.reload = reload
-    globals.title = title
-    globals.viewport = viewport
-    globals.favicon = favicon
-    globals.dark = dark
-    globals.language = language
-    globals.binding_refresh_interval = binding_refresh_interval
-    globals.reconnect_timeout = reconnect_timeout
-    globals.tailwind = tailwind
-    globals.prod_js = prod_js
-    globals.endpoint_documentation = endpoint_documentation
-
-    for route in globals.app.routes:
-        if not isinstance(route, Route):
-            continue
-        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:
-        globals.air = Air('' if on_air is True else on_air)
-
-    if multiprocessing.current_process().name != 'MainProcess':
+async def _run(executor: Any, callback: Callable, *args: Any, **kwargs: Any) -> Any:
+    if core.app.is_stopping:
         return
+    try:
+        loop = asyncio.get_running_loop()
+        return await loop.run_in_executor(executor, partial(callback, *args, **kwargs))
+    except RuntimeError as e:
+        if 'cannot schedule new futures after shutdown' not in str(e):
+            raise
+    except asyncio.exceptions.CancelledError:
+        pass
 
-    if reload and not hasattr(__main__, '__file__'):
-        globals.log.warning('auto-reloading is only supported when running from a file')
-        globals.reload = reload = False
-
-    if fullscreen:
-        native = True
-    if frameless:
-        native = True
-    if window_size:
-        native = True
-    if native:
-        show = False
-        host = host or '127.0.0.1'
-        port = native_mode.find_open_port()
-        width, height = window_size or (800, 600)
-        native_mode.activate(host, port, title, width, height, fullscreen, frameless)
-    else:
-        host = host or '0.0.0.0'
 
-    # NOTE: We save host and port in environment variables so the subprocess started in reload mode can access them.
-    os.environ['NICEGUI_HOST'] = host
-    os.environ['NICEGUI_PORT'] = str(port)
+async def cpu_bound(callback: Callable, *args: Any, **kwargs: Any) -> Any:
+    """Run a CPU-bound function in a separate process."""
+    return await _run(process_pool, callback, *args, **kwargs)
 
-    if show:
-        helpers.schedule_browser(host, port)
 
-    def split_args(args: str) -> List[str]:
-        return [a.strip() for a in args.split(',')]
+async def io_bound(callback: Callable, *args: Any, **kwargs: Any) -> Any:
+    """Run an I/O-bound function in a separate thread."""
+    return await _run(thread_pool, callback, *args, **kwargs)
 
-    # NOTE: The following lines are basically a copy of `uvicorn.run`, but keep a reference to the `server`.
 
-    config = CustomServerConfig(
-        APP_IMPORT_STRING if reload else globals.app,
-        host=host,
-        port=port,
-        reload=reload,
-        reload_includes=split_args(uvicorn_reload_includes) if reload else None,
-        reload_excludes=split_args(uvicorn_reload_excludes) if reload else None,
-        reload_dirs=split_args(uvicorn_reload_dirs) if reload else None,
-        log_level=uvicorn_logging_level,
-        **kwargs,
-    )
-    config.storage_secret = storage_secret
-    config.method_queue = native_module.method_queue if native else None
-    config.response_queue = native_module.response_queue if native else None
-    globals.server = Server(config=config)
-
-    if (reload or config.workers > 1) and not isinstance(config.app, str):
-        globals.log.warning('You must pass the application as an import string to enable "reload" or "workers".')
-        sys.exit(1)
-
-    if config.should_reload:
-        sock = config.bind_socket()
-        ChangeReload(config, target=globals.server.run, sockets=[sock]).run()
-    elif config.workers > 1:
-        sock = config.bind_socket()
-        Multiprocess(config, target=globals.server.run, sockets=[sock]).run()
-    else:
-        globals.server.run()
-    if config.uds:
-        os.remove(config.uds)  # pragma: py-win32
-
-    if not globals.server.started and not config.should_reload and config.workers == 1:
-        sys.exit(STARTUP_FAILURE)
+def tear_down() -> None:
+    """Kill all processes and threads."""
+    if helpers.is_pytest():
+        return
+    for p in process_pool._processes.values():  # pylint: disable=protected-access
+        p.kill()
+    kwargs = {'cancel_futures': True} if sys.version_info >= (3, 9) else {}
+    process_pool.shutdown(wait=True, **kwargs)
+    thread_pool.shutdown(wait=False, **kwargs)

+ 0 - 44
nicegui/run_executor.py

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

+ 36 - 0
nicegui/server.py

@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import multiprocessing
+import socket
+from typing import List, Optional
+
+import uvicorn
+
+from . import core, storage
+from .native import native
+
+
+class CustomServerConfig(uvicorn.Config):
+    storage_secret: Optional[str] = None
+    method_queue: Optional[multiprocessing.Queue] = None
+    response_queue: Optional[multiprocessing.Queue] = None
+
+
+class Server(uvicorn.Server):
+    instance: Server
+
+    @classmethod
+    def create_singleton(cls, config: CustomServerConfig) -> None:
+        """Create a singleton instance of the server."""
+        cls.instance = cls(config=config)
+
+    def run(self, sockets: Optional[List[socket.socket]] = None) -> None:
+        self.instance = self
+        assert isinstance(self.config, CustomServerConfig)
+        if self.config.method_queue is not None and self.config.response_queue is not None:
+            core.app.native.main_window = native.WindowProxy()
+            native.method_queue = self.config.method_queue
+            native.response_queue = self.config.response_queue
+
+        storage.set_storage_secret(self.config.storage_secret)
+        super().run(sockets=sockets)

+ 45 - 5
nicegui/slot.py

@@ -1,16 +1,19 @@
 from __future__ import annotations
 
-from typing import TYPE_CHECKING, Iterator, List, Optional
+import asyncio
+from typing import TYPE_CHECKING, Dict, Iterator, List, Optional
 
 from typing_extensions import Self
 
-from . import globals  # pylint: disable=redefined-builtin
+from .logging import log
 
 if TYPE_CHECKING:
     from .element import Element
 
 
 class Slot:
+    stacks: Dict[int, List[Slot]] = {}
+    """Maps asyncio task IDs to slot stacks, which keep track of the current slot in each task."""
 
     def __init__(self, parent: Element, name: str, template: Optional[str] = None) -> None:
         self.name = name
@@ -19,12 +22,49 @@ class Slot:
         self.children: List[Element] = []
 
     def __enter__(self) -> Self:
-        globals.get_slot_stack().append(self)
+        self.get_stack().append(self)
         return self
 
     def __exit__(self, *_) -> None:
-        globals.get_slot_stack().pop()
-        globals.prune_slot_stack()
+        self.get_stack().pop()
+        self.prune_stack()
 
     def __iter__(self) -> Iterator[Element]:
         return iter(self.children)
+
+    @classmethod
+    def get_stack(cls) -> List[Slot]:
+        """Return the slot stack of the current asyncio task."""
+        task_id = get_task_id()
+        if task_id not in cls.stacks:
+            cls.stacks[task_id] = []
+        return cls.stacks[task_id]
+
+    @classmethod
+    def prune_stack(cls) -> None:
+        """Remove the current slot stack if it is empty."""
+        task_id = get_task_id()
+        if not cls.stacks[task_id]:
+            del cls.stacks[task_id]
+
+    @classmethod
+    async def prune_stacks(cls) -> None:
+        """Remove stale slot stacks in an endless loop."""
+        while True:
+            try:
+                running = [id(task) for task in asyncio.tasks.all_tasks() if not task.done() and not task.cancelled()]
+                stale_ids = [task_id for task_id in cls.stacks if task_id not in running]
+                for task_id in stale_ids:
+                    del cls.stacks[task_id]
+            except Exception:
+                # NOTE: make sure the loop doesn't crash
+                log.exception('Error while pruning slot stacks')
+            await asyncio.sleep(10)
+
+
+def get_task_id() -> int:
+    """Return the ID of the current asyncio task."""
+    try:
+        return id(asyncio.current_task())
+    except RuntimeError:
+        return 0

+ 14 - 12
nicegui/storage.py

@@ -1,4 +1,5 @@
 import contextvars
+import os
 import uuid
 from collections.abc import MutableMapping
 from pathlib import Path
@@ -11,7 +12,7 @@ from starlette.middleware.sessions import SessionMiddleware
 from starlette.requests import Request
 from starlette.responses import Response
 
-from . import background_tasks, globals, json, observables  # pylint: disable=redefined-builtin
+from . import background_tasks, context, core, json, observables
 
 request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None)
 
@@ -55,10 +56,10 @@ class PersistentDict(observables.ObservableDict):
         async def backup() -> None:
             async with aiofiles.open(self.filepath, 'w') as f:
                 await f.write(json.dumps(self))
-        if globals.loop:
+        if core.loop:
             background_tasks.create_lazy(backup(), name=self.filepath.stem)
         else:
-            globals.app.on_startup(backup())
+            core.app.on_startup(backup())
 
 
 class RequestTrackingMiddleware(BaseHTTPMiddleware):
@@ -75,18 +76,19 @@ class RequestTrackingMiddleware(BaseHTTPMiddleware):
 
 def set_storage_secret(storage_secret: Optional[str] = None) -> None:
     """Set storage_secret and add request tracking middleware."""
-    if any(m.cls == SessionMiddleware for m in globals.app.user_middleware):
+    if any(m.cls == SessionMiddleware for m in core.app.user_middleware):
         # NOTE not using "add_middleware" because it would be the wrong order
-        globals.app.user_middleware.append(Middleware(RequestTrackingMiddleware))
+        core.app.user_middleware.append(Middleware(RequestTrackingMiddleware))
     elif storage_secret is not None:
-        globals.app.add_middleware(RequestTrackingMiddleware)
-        globals.app.add_middleware(SessionMiddleware, secret_key=storage_secret)
+        core.app.add_middleware(RequestTrackingMiddleware)
+        core.app.add_middleware(SessionMiddleware, secret_key=storage_secret)
 
 
 class Storage:
 
     def __init__(self) -> None:
-        self._general = PersistentDict(globals.storage_path / 'storage_general.json')
+        self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve()
+        self._general = PersistentDict(self.path / 'storage_general.json')
         self._users: Dict[str, PersistentDict] = {}
 
     @property
@@ -99,7 +101,7 @@ class Storage:
         """
         request: Optional[Request] = request_contextvar.get()
         if request is None:
-            if globals.get_client() == globals.index_client:
+            if context.get_client().is_auto_index_client:
                 raise RuntimeError('app.storage.browser can only be used with page builder functions '
                                    '(https://nicegui.io/documentation/page)')
             raise RuntimeError('app.storage.browser needs a storage_secret passed in ui.run()')
@@ -119,13 +121,13 @@ class Storage:
         """
         request: Optional[Request] = request_contextvar.get()
         if request is None:
-            if globals.get_client() == globals.index_client:
+            if context.get_client().is_auto_index_client:
                 raise RuntimeError('app.storage.user can only be used with page builder functions '
                                    '(https://nicegui.io/documentation/page)')
             raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()')
         session_id = request.session['id']
         if session_id not in self._users:
-            self._users[session_id] = PersistentDict(globals.storage_path / f'storage_user_{session_id}.json')
+            self._users[session_id] = PersistentDict(self.path / f'storage_user_{session_id}.json')
         return self._users[session_id]
 
     @property
@@ -137,5 +139,5 @@ class Storage:
         """Clears all storage."""
         self._general.clear()
         self._users.clear()
-        for filepath in globals.storage_path.glob('storage_*.json'):
+        for filepath in self.path.glob('storage_*.json'):
             filepath.unlink()

+ 2 - 2
nicegui/ui.py

@@ -191,5 +191,5 @@ from .page_layout import Header as header
 from .page_layout import LeftDrawer as left_drawer
 from .page_layout import PageSticky as page_sticky
 from .page_layout import RightDrawer as right_drawer
-from .run import run
-from .run_with import run_with
+from .ui_run import run
+from .ui_run_with import run_with

+ 172 - 0
nicegui/ui_run.py

@@ -0,0 +1,172 @@
+import multiprocessing
+import os
+import sys
+from pathlib import Path
+from typing import Any, List, Literal, Optional, Tuple, Union
+
+import __main__
+from starlette.routing import Route
+from uvicorn.main import STARTUP_FAILURE
+from uvicorn.supervisors import ChangeReload, Multiprocess
+
+from . import air, core, helpers
+from . import native as native_module
+from .app_config import RunConfig
+from .client import Client
+from .language import Language
+from .logging import log
+from .server import CustomServerConfig, Server
+
+APP_IMPORT_STRING = 'nicegui:app'
+
+
+def run(*,
+        host: Optional[str] = None,
+        port: int = 8080,
+        title: str = 'NiceGUI',
+        viewport: str = 'width=device-width, initial-scale=1',
+        favicon: Optional[Union[str, Path]] = None,
+        dark: Optional[bool] = False,
+        language: Language = 'en-US',
+        binding_refresh_interval: float = 0.1,
+        reconnect_timeout: float = 3.0,
+        show: bool = True,
+        on_air: Optional[Union[str, Literal[True]]] = None,
+        native: bool = False,
+        window_size: Optional[Tuple[int, int]] = None,
+        fullscreen: bool = False,
+        frameless: bool = False,
+        reload: bool = True,
+        uvicorn_logging_level: str = 'warning',
+        uvicorn_reload_dirs: str = '.',
+        uvicorn_reload_includes: str = '*.py',
+        uvicorn_reload_excludes: str = '.*, .py[cod], .sw.*, ~*',
+        tailwind: bool = True,
+        prod_js: bool = True,
+        endpoint_documentation: Literal['none', 'internal', 'page', 'all'] = 'none',
+        storage_secret: Optional[str] = None,
+        **kwargs: Any,
+        ) -> None:
+    """ui.run
+
+    You can call `ui.run()` with optional arguments:
+
+    :param host: start server with this host (defaults to `'127.0.0.1` in native mode, otherwise `'0.0.0.0'`)
+    :param port: use this port (default: `8080`)
+    :param title: page title (default: `'NiceGUI'`, can be overwritten per page)
+    :param viewport: page meta viewport content (default: `'width=device-width, initial-scale=1'`, can be overwritten per page)
+    :param favicon: relative filepath, absolute URL to a favicon (default: `None`, NiceGUI icon will be used) or emoji (e.g. `'🚀'`, works for most browsers)
+    :param dark: whether to use Quasar's dark mode (default: `False`, use `None` for "auto" mode)
+    :param language: language for Quasar elements (default: `'en-US'`)
+    :param binding_refresh_interval: time between binding updates (default: `0.1` seconds, bigger is more CPU friendly)
+    :param reconnect_timeout: maximum time the server waits for the browser to reconnect (default: 3.0 seconds)
+    :param show: automatically open the UI in a browser tab (default: `True`)
+    :param on_air: tech preview: `allows temporary remote access <https://nicegui.io/documentation#nicegui_on_air>`_ if set to `True` (default: disabled)
+    :param native: open the UI in a native window of size 800x600 (default: `False`, deactivates `show`, automatically finds an open port)
+    :param window_size: open the UI in a native window with the provided size (e.g. `(1024, 786)`, default: `None`, also activates `native`)
+    :param fullscreen: open the UI in a fullscreen window (default: `False`, also activates `native`)
+    :param frameless: open the UI in a frameless window (default: `False`, also activates `native`)
+    :param reload: automatically reload the UI on file changes (default: `True`)
+    :param uvicorn_logging_level: logging level for uvicorn server (default: `'warning'`)
+    :param uvicorn_reload_dirs: string with comma-separated list for directories to be monitored (default is current working directory only)
+    :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 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 kwargs: additional keyword arguments are passed to `uvicorn.run`    
+    """
+    core.app._run_config = RunConfig(  # pylint: disable=protected-access
+        reload=reload,
+        title=title,
+        viewport=viewport,
+        favicon=favicon,
+        dark=dark,
+        language=language,
+        binding_refresh_interval=binding_refresh_interval,
+        reconnect_timeout=reconnect_timeout,
+        tailwind=tailwind,
+        prod_js=prod_js,
+    )
+    core.app.config.endpoint_documentation = endpoint_documentation
+
+    for route in core.app.routes:
+        if not isinstance(route, Route):
+            continue
+        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 Client.page_routes.values():
+            route.include_in_schema = endpoint_documentation in {'page', 'all'}
+
+    if on_air:
+        air.instance = air.Air('' if on_air is True else on_air)
+
+    if multiprocessing.current_process().name != 'MainProcess':
+        return
+
+    if reload and not hasattr(__main__, '__file__'):
+        log.warning('auto-reloading is only supported when running from a file')
+        core.app._run_config.reload = reload = False  # pylint: disable=protected-access
+
+    if fullscreen:
+        native = True
+    if frameless:
+        native = True
+    if window_size:
+        native = True
+    if native:
+        show = False
+        host = host or '127.0.0.1'
+        port = native_module.find_open_port()
+        width, height = window_size or (800, 600)
+        native_module.activate(host, port, title, width, height, fullscreen, frameless)
+    else:
+        host = host or '0.0.0.0'
+    assert host is not None
+
+    # NOTE: We save host and port in environment variables so the subprocess started in reload mode can access them.
+    os.environ['NICEGUI_HOST'] = host
+    os.environ['NICEGUI_PORT'] = str(port)
+
+    if show:
+        helpers.schedule_browser(host, port)
+
+    def split_args(args: str) -> List[str]:
+        return [a.strip() for a in args.split(',')]
+
+    # NOTE: The following lines are basically a copy of `uvicorn.run`, but keep a reference to the `server`.
+
+    config = CustomServerConfig(
+        APP_IMPORT_STRING if reload else core.app,
+        host=host,
+        port=port,
+        reload=reload,
+        reload_includes=split_args(uvicorn_reload_includes) if reload else None,
+        reload_excludes=split_args(uvicorn_reload_excludes) if reload else None,
+        reload_dirs=split_args(uvicorn_reload_dirs) if reload else None,
+        log_level=uvicorn_logging_level,
+        **kwargs,
+    )
+    config.storage_secret = storage_secret
+    config.method_queue = native_module.method_queue if native else None
+    config.response_queue = native_module.response_queue if native else None
+    Server.create_singleton(config)
+
+    if (reload or config.workers > 1) and not isinstance(config.app, str):
+        log.warning('You must pass the application as an import string to enable "reload" or "workers".')
+        sys.exit(1)
+
+    if config.should_reload:
+        sock = config.bind_socket()
+        ChangeReload(config, target=Server.instance.run, sockets=[sock]).run()
+    elif config.workers > 1:
+        sock = config.bind_socket()
+        Multiprocess(config, target=Server.instance.run, sockets=[sock]).run()
+    else:
+        Server.instance.run()
+    if config.uds:
+        os.remove(config.uds)  # pragma: py-win32
+
+    if not Server.instance.started and not config.should_reload and config.workers == 1:
+        sys.exit(STARTUP_FAILURE)

+ 17 - 14
nicegui/run_with.py → nicegui/ui_run_with.py

@@ -3,9 +3,10 @@ from typing import Optional, Union
 
 from fastapi import FastAPI
 
-from nicegui import globals, storage  # pylint: disable=redefined-builtin
-from nicegui.language import Language
-from nicegui.nicegui import handle_shutdown, handle_startup
+from . import core, storage
+from .app_config import RunConfig
+from .language import Language
+from .nicegui import handle_shutdown, handle_startup
 
 
 def run_with(
@@ -37,19 +38,21 @@ def run_with(
     :param prod_js: whether to use the production version of Vue and Quasar dependencies (default: `True`)
     :param storage_secret: secret key for browser-based storage (default: `None`, a value is required to enable ui.storage.individual and ui.storage.browser)
     """
-    globals.ui_run_has_been_called = True
-    globals.title = title
-    globals.viewport = viewport
-    globals.favicon = favicon
-    globals.dark = dark
-    globals.language = language
-    globals.binding_refresh_interval = binding_refresh_interval
-    globals.reconnect_timeout = reconnect_timeout
-    globals.tailwind = tailwind
-    globals.prod_js = prod_js
+    core.app._run_config = RunConfig(  # pylint: disable=protected-access
+        reload=False,
+        title=title,
+        viewport=viewport,
+        favicon=favicon,
+        dark=dark,
+        language=language,
+        binding_refresh_interval=binding_refresh_interval,
+        reconnect_timeout=reconnect_timeout,
+        tailwind=tailwind,
+        prod_js=prod_js,
+    )
 
     storage.set_storage_secret(storage_secret)
     app.on_event('startup')(lambda: handle_startup(with_welcome_message=False))
     app.on_event('shutdown')(handle_shutdown)
 
-    app.mount(mount_path, globals.app)
+    app.mount(mount_path, core.app)

+ 3 - 4
nicegui/welcome.py

@@ -3,8 +3,7 @@ from typing import List
 
 import ifaddr
 
-from . import globals  # pylint: disable=redefined-builtin
-from .run_executor import io_bound
+from . import core, run
 
 
 def _get_all_ips() -> List[str]:
@@ -19,10 +18,10 @@ async def print_message() -> None:
     print('NiceGUI ready to go ', end='', flush=True)
     host = os.environ['NICEGUI_HOST']
     port = os.environ['NICEGUI_PORT']
-    ips = set((await io_bound(_get_all_ips)) if host == '0.0.0.0' else [])
+    ips = set((await run.io_bound(_get_all_ips)) if host == '0.0.0.0' else [])
     ips.discard('127.0.0.1')
     urls = [(f'http://{ip}:{port}' if port != '80' else f'http://{ip}') for ip in ['localhost'] + sorted(ips)]
-    globals.app.urls.update(urls)
+    core.app.urls.update(urls)
     if len(urls) >= 2:
         urls[-1] = 'and ' + urls[-1]
     print(f'on {", ".join(urls)}', flush=True)

+ 2 - 3
prometheus.py

@@ -1,11 +1,10 @@
 import inspect
+import logging
 import uuid
 
 from fastapi import FastAPI, Request, Response
 from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
 
-from nicegui.globals import log
-
 EXCLUDED_USER_AGENTS = {'bot', 'spider', 'crawler', 'monitor', 'curl',
                         'wget', 'python-requests', 'kuma', 'health check'}
 
@@ -14,7 +13,7 @@ def start_monitor(app: FastAPI) -> None:
     try:
         import prometheus_client
     except ModuleNotFoundError:
-        log.info('Prometheus not installed, skipping monitoring')
+        logging.info('Prometheus not installed, skipping monitoring')
         return
 
     visits = prometheus_client.Counter('nicegui_page_visits', 'Number of real page visits',

+ 4 - 2
test_startup.sh

@@ -33,10 +33,12 @@ check main.py || error=1
 for path in examples/*
 do
     if test -f $path/requirements.txt; then
-        python3 -m pip install -r $path/requirements.txt || error=1 
+        sed '/^nicegui/d' $path/requirements.txt > $path/requirements.tmp.txt || error=1 # remove nicegui from requirements.txt
+        python3 -m pip install -r $path/requirements.tmp.txt || error=1
+        rm $path/requirements.tmp.txt || error=1
     fi
     if test -f $path/start.sh; then
-        check $path/start.sh dev || error=1 
+        check $path/start.sh dev || error=1
     elif test -f $path/main.py; then
         check $path/main.py || error=1
     fi

+ 14 - 16
tests/conftest.py

@@ -9,8 +9,7 @@ import pytest
 from selenium import webdriver
 from selenium.webdriver.chrome.service import Service
 
-from nicegui import Client, binding, globals  # pylint: disable=redefined-builtin
-from nicegui.elements import plotly, pyplot
+from nicegui import Client, app, binding, core
 from nicegui.page import page
 
 from .screen import Screen
@@ -45,22 +44,21 @@ def capabilities(capabilities: Dict) -> Dict:
 
 @pytest.fixture(autouse=True)
 def reset_globals() -> Generator[None, None, None]:
-    for path in {'/'}.union(globals.page_routes.values()):
-        globals.app.remove_route(path)
-    globals.app.openapi_schema = None
-    globals.app.middleware_stack = None
-    globals.app.user_middleware.clear()
+    for path in {'/'}.union(Client.page_routes.values()):
+        app.remove_route(path)
+    app.openapi_schema = None
+    app.middleware_stack = None
+    app.user_middleware.clear()
     # NOTE favicon routes must be removed separately because they are not "pages"
-    for route in globals.app.routes:
+    for route in app.routes:
         if route.path.endswith('/favicon.ico'):
-            globals.app.routes.remove(route)
-    importlib.reload(globals)
-    # repopulate globals.optional_features
-    importlib.reload(plotly)
-    importlib.reload(pyplot)
-    globals.app.storage.clear()
-    globals.index_client = Client(page('/'), shared=True).__enter__()
-    globals.app.get('/')(globals.index_client.build_response)
+            app.routes.remove(route)
+    importlib.reload(core)
+    Client.instances.clear()
+    Client.page_routes.clear()
+    Client.auto_index_client = Client(page('/'), shared=True).__enter__()
+    app.reset()
+    app.get('/')(Client.auto_index_client.build_response)
     binding.reset()
 
 

+ 7 - 5
tests/screen.py

@@ -2,7 +2,7 @@ import os
 import threading
 import time
 from contextlib import contextmanager
-from typing import List
+from typing import List, Optional
 
 import pytest
 from selenium import webdriver
@@ -12,7 +12,8 @@ from selenium.webdriver import ActionChains
 from selenium.webdriver.common.by import By
 from selenium.webdriver.remote.webelement import WebElement
 
-from nicegui import app, globals, ui  # pylint: disable=redefined-builtin
+from nicegui import app, ui
+from nicegui.server import Server
 
 from .test_helpers import TEST_DIR
 
@@ -25,7 +26,7 @@ class Screen:
     def __init__(self, selenium: webdriver.Chrome, caplog: pytest.LogCaptureFixture) -> None:
         self.selenium = selenium
         self.caplog = caplog
-        self.server_thread = None
+        self.server_thread: Optional[threading.Thread] = None
         self.ui_run_kwargs = {'port': self.PORT, 'show': False, 'reload': False}
         self.connected = threading.Event()
         app.on_connect(self.connected.set)
@@ -49,8 +50,9 @@ class Screen:
         """Stop the webserver."""
         self.close()
         self.caplog.clear()
-        globals.server.should_exit = True
-        self.server_thread.join()
+        Server.instance.should_exit = True
+        if self.server_thread:
+            self.server_thread.join()
 
     def open(self, path: str, timeout: float = 3.0) -> None:
         """Try to open the page until the server is ready or we time out.

+ 3 - 4
website/documentation.py

@@ -1,7 +1,6 @@
 import uuid
 
-from nicegui import app, events, ui
-from nicegui.globals import optional_features
+from nicegui import app, events, optional_features, ui
 
 from . import demo
 from .documentation_tools import element_demo, heading, intro_demo, load_demo, subheading, text_demo
@@ -132,10 +131,10 @@ def create_full() -> None:
     load_demo(ui.aggrid)
     load_demo(ui.chart)
     load_demo(ui.echart)
-    if 'matplotlib' in optional_features:
+    if optional_features.has('matplotlib'):
         load_demo(ui.pyplot)
         load_demo(ui.line_plot)
-    if 'plotly' in optional_features:
+    if optional_features.has('plotly'):
         load_demo(ui.plotly)
     load_demo(ui.linear_progress)
     load_demo(ui.circular_progress)

+ 2 - 2
website/documentation_tools.py

@@ -5,7 +5,7 @@ from typing import Callable, Optional, Union
 
 import docutils.core
 
-from nicegui import globals, ui
+from nicegui import context, ui
 from nicegui.binding import BindableProperty
 from nicegui.elements.markdown import apply_tailwind, remove_indentation
 
@@ -23,7 +23,7 @@ def create_anchor_name(text: str) -> str:
 
 
 def get_menu() -> ui.left_drawer:
-    return [element for element in globals.get_client().elements.values() if isinstance(element, ui.left_drawer)][0]
+    return [element for element in context.get_client().elements.values() if isinstance(element, ui.left_drawer)][0]
 
 
 def heading(text: str, *, make_menu_entry: bool = True) -> None:

+ 2 - 2
website/more_documentation/generic_events_documentation.py

@@ -1,4 +1,4 @@
-from nicegui import globals, ui
+from nicegui import context, ui
 
 from ..documentation_tools import text_demo
 
@@ -113,7 +113,7 @@ def more() -> None:
             </script>
         ''')
         # END OF DEMO
-        await globals.get_client().connected()
+        await context.get_client().connected()
         ui.run_javascript(f'''
             document.addEventListener('visibilitychange', () => {{
                 if (document.visibilityState === 'visible')

+ 3 - 3
website/more_documentation/query_documentation.py

@@ -1,4 +1,4 @@
-from nicegui import globals, ui
+from nicegui import context, ui
 
 from ..documentation_tools import text_demo
 
@@ -22,7 +22,7 @@ def more() -> None:
     def background_image():
         # ui.query('body').classes('bg-gradient-to-t from-blue-400 to-blue-100')
         # END OF DEMO
-        globals.get_slot_stack()[-1].parent.classes('bg-gradient-to-t from-blue-400 to-blue-100')
+        context.get_slot_stack()[-1].parent.classes('bg-gradient-to-t from-blue-400 to-blue-100')
 
     @text_demo('Modify default page padding', '''
         By default, NiceGUI provides a built-in padding around the content of the page.
@@ -30,7 +30,7 @@ def more() -> None:
     ''')
     def remove_padding():
         # ui.query('.nicegui-content').classes('p-0')
-        globals.get_slot_stack()[-1].parent.classes(remove='p-4')  # HIDE
+        context.get_slot_stack()[-1].parent.classes(remove='p-4')  # HIDE
         # with ui.column().classes('h-screen w-full bg-gray-400 justify-between'):
         with ui.column().classes('h-full w-full bg-gray-400 justify-between'):  # HIDE
             ui.label('top left')