From 7aff68d15c2c160170e000743f5eb8662c59bb25 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 8 Dec 2024 08:56:37 +0100 Subject: [PATCH 01/25] extracted classes from storage.py and first minimal Redis sync for app.storage.general --- nicegui/__init__.py | 2 + nicegui/app/app.py | 4 +- nicegui/client.py | 3 +- nicegui/element.py | 3 +- nicegui/persistence/persistent_dict.py | 36 +++++++++++ nicegui/persistence/read_only_dict.py | 24 +++++++ nicegui/persistence/redis_persistent_dict.py | 56 ++++++++++++++++ nicegui/{ => persistence}/storage.py | 67 +++----------------- nicegui/server.py | 3 +- nicegui/ui_run_with.py | 3 +- 10 files changed, 136 insertions(+), 65 deletions(-) create mode 100644 nicegui/persistence/persistent_dict.py create mode 100644 nicegui/persistence/read_only_dict.py create mode 100644 nicegui/persistence/redis_persistent_dict.py rename nicegui/{ => persistence}/storage.py (77%) diff --git a/nicegui/__init__.py b/nicegui/__init__.py index a1b2f4726..08e91a44a 100644 --- a/nicegui/__init__.py +++ b/nicegui/__init__.py @@ -5,6 +5,7 @@ from .context import context from .element_filter import ElementFilter from .nicegui import app +from .persistence import storage from .tailwind import Tailwind from .version import __version__ @@ -20,5 +21,6 @@ 'elements', 'html', 'run', + 'storage', 'ui', ] diff --git a/nicegui/app/app.py b/nicegui/app/app.py index a0d75c074..87d926ddb 100644 --- a/nicegui/app/app.py +++ b/nicegui/app/app.py @@ -15,9 +15,9 @@ from ..logging import log from ..native import NativeConfig from ..observables import ObservableSet +from ..persistence.storage import Storage from ..server import Server from ..staticfiles import CacheControlledStaticFiles -from ..storage import Storage from .app_config import AppConfig from .range_response import get_range_response @@ -39,7 +39,7 @@ def __init__(self, **kwargs) -> None: self._state: State = State.STOPPED self.config = AppConfig() - self._startup_handlers: List[Union[Callable[..., Any], Awaitable]] = [] + self._startup_handlers: List[Union[Callable[..., Any], Awaitable]] = [self.storage.general.initialize,] self._shutdown_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self._connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self._disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] diff --git a/nicegui/client.py b/nicegui/client.py index b1e883927..56d816bed 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -13,7 +13,7 @@ from fastapi.templating import Jinja2Templates from typing_extensions import Self -from . import background_tasks, binding, core, helpers, json, storage +from . import background_tasks, binding, core, helpers, json from .awaitable_response import AwaitableResponse from .dependencies import generate_resources from .element import Element @@ -22,6 +22,7 @@ from .logging import log from .observables import ObservableDict from .outbox import Outbox +from .persistence import storage from .version import __version__ if TYPE_CHECKING: diff --git a/nicegui/element.py b/nicegui/element.py index e79d81127..73d99527f 100644 --- a/nicegui/element.py +++ b/nicegui/element.py @@ -8,13 +8,14 @@ from typing_extensions import Self -from . import core, events, helpers, json, storage +from . import core, events, helpers, json from .awaitable_response import AwaitableResponse, NullResponse from .classes import Classes from .context import context from .dependencies import Component, Library, register_library, register_resource, register_vue_component from .elements.mixins.visibility import Visibility from .event_listener import EventListener +from .persistence import storage from .props import Props from .slot import Slot from .style import Style diff --git a/nicegui/persistence/persistent_dict.py b/nicegui/persistence/persistent_dict.py new file mode 100644 index 000000000..5f70573ab --- /dev/null +++ b/nicegui/persistence/persistent_dict.py @@ -0,0 +1,36 @@ +from pathlib import Path +from typing import Optional + +import aiofiles + +from nicegui import background_tasks, core, json, observables +from nicegui.logging import log + + +class PersistentDict(observables.ObservableDict): + + def __init__(self, filepath: Path, encoding: Optional[str] = None, *, indent: bool = False) -> None: + self.filepath = filepath + self.encoding = encoding + self.indent = indent + try: + data = json.loads(filepath.read_text(encoding)) if filepath.exists() else {} + except Exception: + log.warning(f'Could not load storage file {filepath}') + data = {} + super().__init__(data, on_change=self.backup) + + def backup(self) -> None: + """Back up the data to the given file path.""" + if not self.filepath.exists(): + if not self: + return + self.filepath.parent.mkdir(exist_ok=True) + + async def backup() -> None: + async with aiofiles.open(self.filepath, 'w', encoding=self.encoding) as f: + await f.write(json.dumps(self, indent=self.indent)) + if core.loop: + background_tasks.create_lazy(backup(), name=self.filepath.stem) + else: + core.app.on_startup(backup()) diff --git a/nicegui/persistence/read_only_dict.py b/nicegui/persistence/read_only_dict.py new file mode 100644 index 000000000..2af9a0e23 --- /dev/null +++ b/nicegui/persistence/read_only_dict.py @@ -0,0 +1,24 @@ +from collections.abc import MutableMapping +from typing import Any, Dict, Iterator + + +class ReadOnlyDict(MutableMapping): + + def __init__(self, data: Dict[Any, Any], write_error_message: str = 'Read-only dict') -> None: + self._data: Dict[Any, Any] = data + self._write_error_message: str = write_error_message + + def __getitem__(self, item: Any) -> Any: + return self._data[item] + + def __setitem__(self, key: Any, value: Any) -> None: + raise TypeError(self._write_error_message) + + def __delitem__(self, key: Any) -> None: + raise TypeError(self._write_error_message) + + def __iter__(self) -> Iterator: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py new file mode 100644 index 000000000..3ba97bcb2 --- /dev/null +++ b/nicegui/persistence/redis_persistent_dict.py @@ -0,0 +1,56 @@ +import redis.asyncio as redis + +from nicegui import background_tasks, core, json, observables +from nicegui.logging import log + + +class RedisDict(observables.ObservableDict): + + def __init__(self, redis_url: str = 'redis://localhost:6379', key_prefix: str = 'nicegui:', encoding: str = 'utf-8') -> None: + self.redis_client = redis.from_url(redis_url) + self.pubsub = self.redis_client.pubsub() + self.key_prefix = key_prefix + self.encoding = encoding + + # Initialize with empty data first + super().__init__({}, on_change=self.backup) + + async def initialize(self) -> None: + """Load initial data from Redis and start listening for changes.""" + try: + data = await self._load_data() + self.update(data) + except Exception: + log.warning(f'Could not load data from Redis with prefix {self.key_prefix}') + + await self._listen_for_changes() + + async def _load_data(self) -> dict: + data = await self.redis_client.get(self.key_prefix + 'data') + return json.loads(data) if data else {} + + async def _listen_for_changes(self) -> None: + await self.pubsub.subscribe(self.key_prefix + 'changes') + async for message in self.pubsub.listen(): + if message['type'] == 'message': + new_data = json.loads(message['data']) + if new_data != self: + self.update(new_data) + + def backup(self) -> None: + """Back up the data to Redis and notify other instances.""" + async def backup() -> None: + pipeline = self.redis_client.pipeline() + pipeline.set(self.key_prefix + 'data', json.dumps(self)) + pipeline.publish(self.key_prefix + 'changes', json.dumps(self)) + await pipeline.execute() + if core.loop: + background_tasks.create_lazy(backup(), name=f'redis-{self.key_prefix}') + else: + core.app.on_startup(backup()) + + async def close(self) -> None: + """Close Redis connection and subscription.""" + await self.pubsub.unsubscribe() + await self.pubsub.close() + await self.redis_client.close() diff --git a/nicegui/storage.py b/nicegui/persistence/storage.py similarity index 77% rename from nicegui/storage.py rename to nicegui/persistence/storage.py index a72b8c9d9..87b82caea 100644 --- a/nicegui/storage.py +++ b/nicegui/persistence/storage.py @@ -3,79 +3,28 @@ import os import time import uuid -from collections.abc import MutableMapping from datetime import timedelta from pathlib import Path -from typing import Any, Dict, Iterator, Optional, Union +from typing import Dict, Optional, Union -import aiofiles from starlette.middleware import Middleware from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.middleware.sessions import SessionMiddleware from starlette.requests import Request from starlette.responses import Response -from . import background_tasks, core, json, observables -from .context import context -from .logging import log -from .observables import ObservableDict +from .. import core, observables +from ..context import context +from ..observables import ObservableDict +from .persistent_dict import PersistentDict +from .read_only_dict import ReadOnlyDict +from .redis_persistent_dict import RedisDict request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None) PURGE_INTERVAL = timedelta(minutes=5).total_seconds() -class ReadOnlyDict(MutableMapping): - - def __init__(self, data: Dict[Any, Any], write_error_message: str = 'Read-only dict') -> None: - self._data: Dict[Any, Any] = data - self._write_error_message: str = write_error_message - - def __getitem__(self, item: Any) -> Any: - return self._data[item] - - def __setitem__(self, key: Any, value: Any) -> None: - raise TypeError(self._write_error_message) - - def __delitem__(self, key: Any) -> None: - raise TypeError(self._write_error_message) - - def __iter__(self) -> Iterator: - return iter(self._data) - - def __len__(self) -> int: - return len(self._data) - - -class PersistentDict(observables.ObservableDict): - - def __init__(self, filepath: Path, encoding: Optional[str] = None, *, indent: bool = False) -> None: - self.filepath = filepath - self.encoding = encoding - self.indent = indent - try: - data = json.loads(filepath.read_text(encoding)) if filepath.exists() else {} - except Exception: - log.warning(f'Could not load storage file {filepath}') - data = {} - super().__init__(data, on_change=self.backup) - - def backup(self) -> None: - """Back up the data to the given file path.""" - if not self.filepath.exists(): - if not self: - return - self.filepath.parent.mkdir(exist_ok=True) - - async def backup() -> None: - async with aiofiles.open(self.filepath, 'w', encoding=self.encoding) as f: - await f.write(json.dumps(self, indent=self.indent)) - if core.loop: - background_tasks.create_lazy(backup(), name=self.filepath.stem) - else: - core.app.on_startup(backup()) - - class RequestTrackingMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: @@ -105,7 +54,7 @@ class Storage: def __init__(self) -> None: self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve() self.max_tab_storage_age = timedelta(days=30).total_seconds() - self._general = PersistentDict(self.path / 'storage-general.json', encoding='utf-8') + self._general = RedisDict() # PersistentDict(self.path / 'storage-general.json', encoding='utf-8') self._users: Dict[str, PersistentDict] = {} self._tabs: Dict[str, observables.ObservableDict] = {} diff --git a/nicegui/server.py b/nicegui/server.py index 62d42fd2b..6905dc7ac 100644 --- a/nicegui/server.py +++ b/nicegui/server.py @@ -6,8 +6,9 @@ import uvicorn -from . import core, storage +from . import core from .native import native +from .persistence import storage class CustomServerConfig(uvicorn.Config): diff --git a/nicegui/ui_run_with.py b/nicegui/ui_run_with.py index 8d69e2bba..e5e5a6812 100644 --- a/nicegui/ui_run_with.py +++ b/nicegui/ui_run_with.py @@ -4,10 +4,11 @@ from fastapi import FastAPI -from . import core, storage +from . import core from .air import Air from .language import Language from .nicegui import _shutdown, _startup +from .persistence import storage def run_with( From c98a79dc3ae8ad42f2241f67eff649ae7c20ba70 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 8 Dec 2024 09:15:50 +0100 Subject: [PATCH 02/25] cleanup --- nicegui/app/app.py | 2 +- nicegui/persistence/redis_persistent_dict.py | 20 +++++--------------- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/nicegui/app/app.py b/nicegui/app/app.py index 87d926ddb..da7579e3e 100644 --- a/nicegui/app/app.py +++ b/nicegui/app/app.py @@ -40,7 +40,7 @@ def __init__(self, **kwargs) -> None: self.config = AppConfig() self._startup_handlers: List[Union[Callable[..., Any], Awaitable]] = [self.storage.general.initialize,] - self._shutdown_handlers: List[Union[Callable[..., Any], Awaitable]] = [] + self._shutdown_handlers: List[Union[Callable[..., Any], Awaitable]] = [self.storage.general.close] self._connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self._disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self._exception_handlers: List[Callable[..., Any]] = [log.exception] diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index 3ba97bcb2..17e38ad04 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -11,25 +11,15 @@ def __init__(self, redis_url: str = 'redis://localhost:6379', key_prefix: str = self.pubsub = self.redis_client.pubsub() self.key_prefix = key_prefix self.encoding = encoding - - # Initialize with empty data first - super().__init__({}, on_change=self.backup) + super().__init__({}, on_change=self.publish) async def initialize(self) -> None: """Load initial data from Redis and start listening for changes.""" try: - data = await self._load_data() - self.update(data) + data = await self.redis_client.get(self.key_prefix + 'data') + self.update(json.loads(data) if data else {}) except Exception: log.warning(f'Could not load data from Redis with prefix {self.key_prefix}') - - await self._listen_for_changes() - - async def _load_data(self) -> dict: - data = await self.redis_client.get(self.key_prefix + 'data') - return json.loads(data) if data else {} - - async def _listen_for_changes(self) -> None: await self.pubsub.subscribe(self.key_prefix + 'changes') async for message in self.pubsub.listen(): if message['type'] == 'message': @@ -37,8 +27,8 @@ async def _listen_for_changes(self) -> None: if new_data != self: self.update(new_data) - def backup(self) -> None: - """Back up the data to Redis and notify other instances.""" + def publish(self) -> None: + """Publish the data to Redis and notify other instances.""" async def backup() -> None: pipeline = self.redis_client.pipeline() pipeline.set(self.key_prefix + 'data', json.dumps(self)) From 8acae8097938aa4959807e9bf4a533db29a8cd15 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 06:28:22 +0100 Subject: [PATCH 03/25] restructure location of files to ensure we do not break existing code --- nicegui/__init__.py | 3 +-- nicegui/app/app.py | 2 +- nicegui/client.py | 3 +-- nicegui/element.py | 3 +-- ...istent_dict.py => file_persistent_dict.py} | 2 +- nicegui/persistence/redis_persistent_dict.py | 2 +- nicegui/server.py | 3 +-- nicegui/{persistence => }/storage.py | 23 ++++++++++--------- nicegui/ui_run_with.py | 3 +-- 9 files changed, 20 insertions(+), 24 deletions(-) rename nicegui/persistence/{persistent_dict.py => file_persistent_dict.py} (95%) rename nicegui/{persistence => }/storage.py (91%) diff --git a/nicegui/__init__.py b/nicegui/__init__.py index 08e91a44a..a3b27dfe6 100644 --- a/nicegui/__init__.py +++ b/nicegui/__init__.py @@ -1,11 +1,10 @@ -from . import elements, html, run, ui +from . import elements, html, run, storage, ui from .api_router import APIRouter from .app.app import App from .client import Client from .context import context from .element_filter import ElementFilter from .nicegui import app -from .persistence import storage from .tailwind import Tailwind from .version import __version__ diff --git a/nicegui/app/app.py b/nicegui/app/app.py index da7579e3e..605f8bf99 100644 --- a/nicegui/app/app.py +++ b/nicegui/app/app.py @@ -15,9 +15,9 @@ from ..logging import log from ..native import NativeConfig from ..observables import ObservableSet -from ..persistence.storage import Storage from ..server import Server from ..staticfiles import CacheControlledStaticFiles +from ..storage import Storage from .app_config import AppConfig from .range_response import get_range_response diff --git a/nicegui/client.py b/nicegui/client.py index 56d816bed..b1e883927 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -13,7 +13,7 @@ from fastapi.templating import Jinja2Templates from typing_extensions import Self -from . import background_tasks, binding, core, helpers, json +from . import background_tasks, binding, core, helpers, json, storage from .awaitable_response import AwaitableResponse from .dependencies import generate_resources from .element import Element @@ -22,7 +22,6 @@ from .logging import log from .observables import ObservableDict from .outbox import Outbox -from .persistence import storage from .version import __version__ if TYPE_CHECKING: diff --git a/nicegui/element.py b/nicegui/element.py index 73d99527f..e79d81127 100644 --- a/nicegui/element.py +++ b/nicegui/element.py @@ -8,14 +8,13 @@ from typing_extensions import Self -from . import core, events, helpers, json +from . import core, events, helpers, json, storage from .awaitable_response import AwaitableResponse, NullResponse from .classes import Classes from .context import context from .dependencies import Component, Library, register_library, register_resource, register_vue_component from .elements.mixins.visibility import Visibility from .event_listener import EventListener -from .persistence import storage from .props import Props from .slot import Slot from .style import Style diff --git a/nicegui/persistence/persistent_dict.py b/nicegui/persistence/file_persistent_dict.py similarity index 95% rename from nicegui/persistence/persistent_dict.py rename to nicegui/persistence/file_persistent_dict.py index 5f70573ab..7a882e583 100644 --- a/nicegui/persistence/persistent_dict.py +++ b/nicegui/persistence/file_persistent_dict.py @@ -7,7 +7,7 @@ from nicegui.logging import log -class PersistentDict(observables.ObservableDict): +class FilePersistentDict(observables.ObservableDict): def __init__(self, filepath: Path, encoding: Optional[str] = None, *, indent: bool = False) -> None: self.filepath = filepath diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index 17e38ad04..6b9a38f78 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -4,7 +4,7 @@ from nicegui.logging import log -class RedisDict(observables.ObservableDict): +class RedisPersistentDict(observables.ObservableDict): def __init__(self, redis_url: str = 'redis://localhost:6379', key_prefix: str = 'nicegui:', encoding: str = 'utf-8') -> None: self.redis_client = redis.from_url(redis_url) diff --git a/nicegui/server.py b/nicegui/server.py index 6905dc7ac..62d42fd2b 100644 --- a/nicegui/server.py +++ b/nicegui/server.py @@ -6,9 +6,8 @@ import uvicorn -from . import core +from . import core, storage from .native import native -from .persistence import storage class CustomServerConfig(uvicorn.Config): diff --git a/nicegui/persistence/storage.py b/nicegui/storage.py similarity index 91% rename from nicegui/persistence/storage.py rename to nicegui/storage.py index 87b82caea..8e7376728 100644 --- a/nicegui/persistence/storage.py +++ b/nicegui/storage.py @@ -13,12 +13,12 @@ from starlette.requests import Request from starlette.responses import Response -from .. import core, observables -from ..context import context -from ..observables import ObservableDict -from .persistent_dict import PersistentDict -from .read_only_dict import ReadOnlyDict -from .redis_persistent_dict import RedisDict +from . import core, observables +from .context import context +from .observables import ObservableDict +from .persistence.file_persistent_dict import FilePersistentDict +from .persistence.read_only_dict import ReadOnlyDict +from .persistence.redis_persistent_dict import RedisPersistentDict request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None) @@ -54,8 +54,8 @@ class Storage: def __init__(self) -> None: self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve() self.max_tab_storage_age = timedelta(days=30).total_seconds() - self._general = RedisDict() # PersistentDict(self.path / 'storage-general.json', encoding='utf-8') - self._users: Dict[str, PersistentDict] = {} + self._general = RedisPersistentDict() # PersistentDict(self.path / 'storage-general.json', encoding='utf-8') + self._users: Dict[str, FilePersistentDict] = {} self._tabs: Dict[str, observables.ObservableDict] = {} @property @@ -82,7 +82,7 @@ def browser(self) -> Union[ReadOnlyDict, Dict]: return request.session @property - def user(self) -> PersistentDict: + def user(self) -> FilePersistentDict: """Individual user storage that is persisted on the server (where NiceGUI is executed). The data is stored in a file on the server. @@ -98,7 +98,8 @@ def user(self) -> PersistentDict: raise RuntimeError('app.storage.user can only be used within a UI context') session_id = request.session['id'] if session_id not in self._users: - self._users[session_id] = PersistentDict(self.path / f'storage-user-{session_id}.json', encoding='utf-8') + self._users[session_id] = FilePersistentDict( + self.path / f'storage-user-{session_id}.json', encoding='utf-8') return self._users[session_id] @staticmethod @@ -109,7 +110,7 @@ def _is_in_auto_index_context() -> bool: return False # no client @property - def general(self) -> PersistentDict: + def general(self) -> FilePersistentDict: """General storage shared between all users that is persisted on the server (where NiceGUI is executed).""" return self._general diff --git a/nicegui/ui_run_with.py b/nicegui/ui_run_with.py index e5e5a6812..8d69e2bba 100644 --- a/nicegui/ui_run_with.py +++ b/nicegui/ui_run_with.py @@ -4,11 +4,10 @@ from fastapi import FastAPI -from . import core +from . import core, storage from .air import Air from .language import Language from .nicegui import _shutdown, _startup -from .persistence import storage def run_with( From a387927a3348cf526f84604bc16ec1a4e30cd3e2 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 06:46:15 +0100 Subject: [PATCH 04/25] add documentation for tab storage --- nicegui/storage.py | 3 ++- .../content/storage_documentation.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/nicegui/storage.py b/nicegui/storage.py index 8e7376728..1456569f0 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -53,7 +53,8 @@ class Storage: def __init__(self) -> None: self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve() - self.max_tab_storage_age = timedelta(days=30).total_seconds() + self.max_tab_storage_age: float = timedelta(days=30).total_seconds() + """Maximum age in seconds before tab storage is automatically purged. Defaults to 30 days.""" self._general = RedisPersistentDict() # PersistentDict(self.path / 'storage-general.json', encoding='utf-8') self._users: Dict[str, FilePersistentDict] = {} self._tabs: Dict[str, observables.ObservableDict] = {} diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py index 9a29faae1..76574e110 100644 --- a/website/documentation/content/storage_documentation.py +++ b/website/documentation/content/storage_documentation.py @@ -132,6 +132,22 @@ def tab_storage(): ui.button('Reload page', on_click=ui.navigate.reload) +@doc.demo('Maximum age of tab storage', ''' + By default, the tab storage is kept for 30 days. + You can change this by setting `app.storage.max_tab_storage_age`. +''') +def max_tab_storage_age(): + from nicegui import app + from datetime import timedelta + # app.storage.max_tab_storage_age = timedelta(minutes=1).total_seconds() + ui.label(f'Tab storage age: {timedelta(minutes=1).total_seconds()} seconds') # HIDE + + @ui.page('/') + def index(): + # ui.label(f'Tab storage age: {app.storage.tab.age} seconds') + pass # HIDE + + @doc.demo('Short-term memory', ''' The goal of `app.storage.client` is to store data only for the duration of the current page visit. In difference to data stored in `app.storage.tab` From c72a11e35eccd21fee16642aecaf7d6a999c941e Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 07:09:29 +0100 Subject: [PATCH 05/25] only use redis if NICEGUI_REDIS_URL is provided as environment variable --- nicegui/persistence/file_persistent_dict.py | 6 ++++-- nicegui/persistence/persistent_dict.py | 11 +++++++++++ nicegui/persistence/redis_persistent_dict.py | 8 +++++--- nicegui/storage.py | 6 +++++- 4 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 nicegui/persistence/persistent_dict.py diff --git a/nicegui/persistence/file_persistent_dict.py b/nicegui/persistence/file_persistent_dict.py index 7a882e583..50a58679d 100644 --- a/nicegui/persistence/file_persistent_dict.py +++ b/nicegui/persistence/file_persistent_dict.py @@ -3,11 +3,13 @@ import aiofiles -from nicegui import background_tasks, core, json, observables +from nicegui import background_tasks, core, json from nicegui.logging import log +from .persistent_dict import PersistentDict -class FilePersistentDict(observables.ObservableDict): + +class FilePersistentDict(PersistentDict): def __init__(self, filepath: Path, encoding: Optional[str] = None, *, indent: bool = False) -> None: self.filepath = filepath diff --git a/nicegui/persistence/persistent_dict.py b/nicegui/persistence/persistent_dict.py new file mode 100644 index 000000000..ce113ec4c --- /dev/null +++ b/nicegui/persistence/persistent_dict.py @@ -0,0 +1,11 @@ + +from nicegui import observables + + +class PersistentDict(observables.ObservableDict): + + async def initialize(self) -> None: + """Load initial data from the persistence layer.""" + + async def close(self) -> None: + """Clean up the persistence layer.""" diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index 6b9a38f78..d8f0d16f0 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -1,12 +1,14 @@ import redis.asyncio as redis -from nicegui import background_tasks, core, json, observables +from nicegui import background_tasks, core, json from nicegui.logging import log +from .persistent_dict import PersistentDict -class RedisPersistentDict(observables.ObservableDict): - def __init__(self, redis_url: str = 'redis://localhost:6379', key_prefix: str = 'nicegui:', encoding: str = 'utf-8') -> None: +class RedisPersistentDict(PersistentDict): + + def __init__(self, redis_url: str, key_prefix: str = 'nicegui:', encoding: str = 'utf-8') -> None: self.redis_client = redis.from_url(redis_url) self.pubsub = self.redis_client.pubsub() self.key_prefix = key_prefix diff --git a/nicegui/storage.py b/nicegui/storage.py index 1456569f0..48d17094d 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -53,9 +53,13 @@ class Storage: def __init__(self) -> None: self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve() + """Path to use for local persistence. Defaults to '.nicegui'.""" self.max_tab_storage_age: float = timedelta(days=30).total_seconds() """Maximum age in seconds before tab storage is automatically purged. Defaults to 30 days.""" - self._general = RedisPersistentDict() # PersistentDict(self.path / 'storage-general.json', encoding='utf-8') + self.redis_url = os.environ.get('NICEGUI_REDIS_URL', None) + """URL to use for shared persistent storage via Redis. Defaults to None, which means local file storage is used.""" + self._general = RedisPersistentDict(self.redis_url) if self.redis_url \ + else FilePersistentDict(self.path / 'storage-general.json', encoding='utf-8') self._users: Dict[str, FilePersistentDict] = {} self._tabs: Dict[str, observables.ObservableDict] = {} From be819e6f32b91a69dc563b4b9362c17add9cf203 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 09:40:57 +0100 Subject: [PATCH 06/25] implement redis storage for app.storage.user and began with redis_storage example --- docker-compose.yml | 1 - examples/redis_storage/Dockerfile | 3 ++ examples/redis_storage/docker-compose.yml | 35 ++++++++++++++++ examples/redis_storage/main.py | 11 +++++ nicegui/app/app.py | 2 +- nicegui/persistence/__init__.py | 11 +++++ nicegui/persistence/redis_persistent_dict.py | 19 +++++---- nicegui/storage.py | 43 +++++++++++++------- 8 files changed, 99 insertions(+), 26 deletions(-) create mode 100644 examples/redis_storage/Dockerfile create mode 100644 examples/redis_storage/docker-compose.yml create mode 100644 examples/redis_storage/main.py create mode 100644 nicegui/persistence/__init__.py diff --git a/docker-compose.yml b/docker-compose.yml index b23b9b0cf..981e0fd37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,6 @@ services: image: traefik:v2.3 command: - --providers.docker - - --api.insecure=true - --accesslog # http access log - --log #Traefik log, for configurations and errors - --api # Enable the Dashboard and API diff --git a/examples/redis_storage/Dockerfile b/examples/redis_storage/Dockerfile new file mode 100644 index 000000000..6c46c0e86 --- /dev/null +++ b/examples/redis_storage/Dockerfile @@ -0,0 +1,3 @@ +FROM zauberzeug/nicegui:2.8.0 + +RUN python -m pip install redis diff --git a/examples/redis_storage/docker-compose.yml b/examples/redis_storage/docker-compose.yml new file mode 100644 index 000000000..b289b486c --- /dev/null +++ b/examples/redis_storage/docker-compose.yml @@ -0,0 +1,35 @@ +services: + x-nicegui: &nicegui-service + build: + context: . + environment: + - NICEGUI_REDIS_URL=redis://redis:6379 + volumes: + - ./:/app + - ../../nicegui:/app/nicegui + labels: + - traefik.enable=true + - traefik.http.routers.nicegui.rule=PathPrefix(`/`) + - traefik.http.services.nicegui.loadbalancer.server.port=8080 + - traefik.http.services.nicegui.loadbalancer.sticky.cookie=true + + nicegui1: + <<: *nicegui-service + + nicegui2: + <<: *nicegui-service + + redis: + image: redis:alpine + ports: + - "6379:6379" + + proxy: + image: traefik:v2.10 + command: + - --providers.docker + - --entrypoints.web.address=:80 + ports: + - "8080:80" + volumes: + - /var/run/docker.sock:/var/run/docker.sock diff --git a/examples/redis_storage/main.py b/examples/redis_storage/main.py new file mode 100644 index 000000000..f490640e0 --- /dev/null +++ b/examples/redis_storage/main.py @@ -0,0 +1,11 @@ + +from nicegui import app, ui + + +@ui.page('/') +def index(): + ui.input('general').bind_value(app.storage.general, 'text') + ui.input('user').bind_value(app.storage.user, 'text') + + +ui.run(storage_secret='your private key to secure the browser session cookie') diff --git a/nicegui/app/app.py b/nicegui/app/app.py index 605f8bf99..753d0c1ed 100644 --- a/nicegui/app/app.py +++ b/nicegui/app/app.py @@ -40,7 +40,7 @@ def __init__(self, **kwargs) -> None: self.config = AppConfig() self._startup_handlers: List[Union[Callable[..., Any], Awaitable]] = [self.storage.general.initialize,] - self._shutdown_handlers: List[Union[Callable[..., Any], Awaitable]] = [self.storage.general.close] + self._shutdown_handlers: List[Union[Callable[..., Any], Awaitable]] = [self.storage.on_shutdown] self._connect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self._disconnect_handlers: List[Union[Callable[..., Any], Awaitable]] = [] self._exception_handlers: List[Callable[..., Any]] = [log.exception] diff --git a/nicegui/persistence/__init__.py b/nicegui/persistence/__init__.py new file mode 100644 index 000000000..64334ee46 --- /dev/null +++ b/nicegui/persistence/__init__.py @@ -0,0 +1,11 @@ +from .file_persistent_dict import FilePersistentDict +from .persistent_dict import PersistentDict +from .read_only_dict import ReadOnlyDict +from .redis_persistent_dict import RedisPersistentDict + +__all__ = [ + 'FilePersistentDict', + 'PersistentDict', + 'ReadOnlyDict', + 'RedisPersistentDict', +] diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index d8f0d16f0..6303bff94 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -8,21 +8,21 @@ class RedisPersistentDict(PersistentDict): - def __init__(self, redis_url: str, key_prefix: str = 'nicegui:', encoding: str = 'utf-8') -> None: + def __init__(self, redis_url: str, id: str, key_prefix: str = 'nicegui:') -> None: # pylint: disable=redefined-builtin self.redis_client = redis.from_url(redis_url) self.pubsub = self.redis_client.pubsub() - self.key_prefix = key_prefix - self.encoding = encoding + self.key = key_prefix + id super().__init__({}, on_change=self.publish) async def initialize(self) -> None: """Load initial data from Redis and start listening for changes.""" try: - data = await self.redis_client.get(self.key_prefix + 'data') + data = await self.redis_client.get(self.key) + print(f'loading data: {data} for {self.key}') self.update(json.loads(data) if data else {}) except Exception: - log.warning(f'Could not load data from Redis with prefix {self.key_prefix}') - await self.pubsub.subscribe(self.key_prefix + 'changes') + log.warning(f'Could not load data from Redis with key {self.key}') + await self.pubsub.subscribe(self.key + 'changes') async for message in self.pubsub.listen(): if message['type'] == 'message': new_data = json.loads(message['data']) @@ -32,12 +32,13 @@ async def initialize(self) -> None: def publish(self) -> None: """Publish the data to Redis and notify other instances.""" async def backup() -> None: + print(f'backup {self.key} with {json.dumps(self)}') pipeline = self.redis_client.pipeline() - pipeline.set(self.key_prefix + 'data', json.dumps(self)) - pipeline.publish(self.key_prefix + 'changes', json.dumps(self)) + pipeline.set(self.key, json.dumps(self)) + pipeline.publish(self.key + 'changes', json.dumps(self)) await pipeline.execute() if core.loop: - background_tasks.create_lazy(backup(), name=f'redis-{self.key_prefix}') + background_tasks.create_lazy(backup(), name=f'redis-{self.key}') else: core.app.on_startup(backup()) diff --git a/nicegui/storage.py b/nicegui/storage.py index 48d17094d..bc9fcbd43 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -13,12 +13,12 @@ from starlette.requests import Request from starlette.responses import Response +from nicegui import background_tasks + from . import core, observables from .context import context from .observables import ObservableDict -from .persistence.file_persistent_dict import FilePersistentDict -from .persistence.read_only_dict import ReadOnlyDict -from .persistence.redis_persistent_dict import RedisPersistentDict +from .persistence import FilePersistentDict, PersistentDict, ReadOnlyDict, RedisPersistentDict request_contextvar: contextvars.ContextVar[Optional[Request]] = contextvars.ContextVar('request_var', default=None) @@ -50,19 +50,26 @@ def set_storage_secret(storage_secret: Optional[str] = None) -> None: class Storage: secret: Optional[str] = None + """Secret key for session storage.""" + path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve() + """Path to use for local persistence. Defaults to '.nicegui'.""" + redis_url = os.environ.get('NICEGUI_REDIS_URL', None) + """URL to use for shared persistent storage via Redis. Defaults to None, which means local file storage is used.""" def __init__(self) -> None: - self.path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve() - """Path to use for local persistence. Defaults to '.nicegui'.""" self.max_tab_storage_age: float = timedelta(days=30).total_seconds() """Maximum age in seconds before tab storage is automatically purged. Defaults to 30 days.""" - self.redis_url = os.environ.get('NICEGUI_REDIS_URL', None) - """URL to use for shared persistent storage via Redis. Defaults to None, which means local file storage is used.""" - self._general = RedisPersistentDict(self.redis_url) if self.redis_url \ - else FilePersistentDict(self.path / 'storage-general.json', encoding='utf-8') - self._users: Dict[str, FilePersistentDict] = {} + self._general = Storage.create_persistent_dict('general') + self._users: Dict[str, PersistentDict] = {} self._tabs: Dict[str, observables.ObservableDict] = {} + @staticmethod + def create_persistent_dict(id: str) -> PersistentDict: + if Storage.redis_url: + return RedisPersistentDict(Storage.redis_url, f'user-{id}') + else: + return FilePersistentDict(Storage.path / f'storage-user-{id}.json', encoding='utf-8') + @property def browser(self) -> Union[ReadOnlyDict, Dict]: """Small storage that is saved directly within the user's browser (encrypted cookie). @@ -87,10 +94,10 @@ def browser(self) -> Union[ReadOnlyDict, Dict]: return request.session @property - def user(self) -> FilePersistentDict: + def user(self) -> PersistentDict: """Individual user storage that is persisted on the server (where NiceGUI is executed). - The data is stored in a file on the server. + The data is stored on the server. It is shared between all browser tabs by identifying the user via session cookie ID. """ request: Optional[Request] = request_contextvar.get() @@ -103,8 +110,8 @@ def user(self) -> FilePersistentDict: raise RuntimeError('app.storage.user can only be used within a UI context') session_id = request.session['id'] if session_id not in self._users: - self._users[session_id] = FilePersistentDict( - self.path / f'storage-user-{session_id}.json', encoding='utf-8') + self._users[session_id] = Storage.create_persistent_dict(session_id) + background_tasks.create(self._users[session_id].initialize(), name=f'user-{session_id}') return self._users[session_id] @staticmethod @@ -115,7 +122,7 @@ def _is_in_auto_index_context() -> bool: return False # no client @property - def general(self) -> FilePersistentDict: + def general(self) -> PersistentDict: """General storage shared between all users that is persisted on the server (where NiceGUI is executed).""" return self._general @@ -174,3 +181,9 @@ def clear(self) -> None: filepath.unlink() if self.path.exists(): self.path.rmdir() + + async def on_shutdown(self) -> None: + """Close all persistent storage.""" + for user in self._users.values(): + await user.close() + await self._general.close() From 7fb3c880f7e976c696ddf67cdfddebd7e1f76dc9 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 09:53:32 +0100 Subject: [PATCH 07/25] make redis an optional dependency --- nicegui/optional_features.py | 1 + nicegui/persistence/redis_persistent_dict.py | 13 +++++++++++-- pyproject.toml | 3 ++- release.dockerfile | 3 ++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/nicegui/optional_features.py b/nicegui/optional_features.py index 89fe0e2e8..62e6d1844 100644 --- a/nicegui/optional_features.py +++ b/nicegui/optional_features.py @@ -12,6 +12,7 @@ 'pyecharts', 'webview', 'sass', + 'redis', ] diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index 6303bff94..a42c715c5 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -1,14 +1,23 @@ -import redis.asyncio as redis - from nicegui import background_tasks, core, json from nicegui.logging import log +from .. import optional_features + +try: + import redis.asyncio as redis + optional_features.register('redis') +except ImportError: + pass + + from .persistent_dict import PersistentDict class RedisPersistentDict(PersistentDict): def __init__(self, redis_url: str, id: str, key_prefix: str = 'nicegui:') -> None: # pylint: disable=redefined-builtin + if not optional_features.has('redis'): + raise ImportError('Redis is not installed. Please run "pip install nicegui[redis]".') self.redis_client = redis.from_url(redis_url) self.pubsub = self.redis_client.pubsub() self.key = key_prefix + id diff --git a/pyproject.toml b/pyproject.toml index 27f8ddc33..26b7fd974 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ docutils = ">=0.19.0" requests = ">=2.32.0" # https://github.com/zauberzeug/nicegui/security/dependabot/33 urllib3 = ">=1.26.18,!=2.0.0,!=2.0.1,!=2.0.2,!=2.0.3,!=2.0.4,!=2.0.5,!=2.0.6,!=2.0.7,!=2.1.0,!=2.2.0,!=2.2.1" # https://github.com/zauberzeug/nicegui/security/dependabot/34 certifi = ">=2024.07.04" # https://github.com/zauberzeug/nicegui/security/dependabot/35 +redis = { version = ">=4.0.0", optional = true } [tool.poetry.extras] native = ["pywebview"] @@ -42,7 +43,7 @@ plotly = ["plotly"] matplotlib = ["matplotlib"] highcharts = ["nicegui-highcharts"] sass = ["libsass"] - +redis = ["redis"] [tool.poetry.group.dev.dependencies] autopep8 = ">=1.5.7,<3.0.0" debugpy = "^1.3.0" diff --git a/release.dockerfile b/release.dockerfile index 6e9c4020e..dfdcfac97 100644 --- a/release.dockerfile +++ b/release.dockerfile @@ -27,7 +27,8 @@ RUN python -m pip install \ pytest \ requests \ latex2mathml \ - selenium + selenium \ + redis WORKDIR /app From 31c9a237d195a4457d9e122caaf50564e7e8ecdc Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 11:10:21 +0100 Subject: [PATCH 08/25] ensure we have an async initialized app.storage.user --- examples/redis_storage/main.py | 2 +- nicegui/page.py | 1 + nicegui/persistence/redis_persistent_dict.py | 14 +++++++++----- nicegui/storage.py | 9 +++++---- 4 files changed, 16 insertions(+), 10 deletions(-) mode change 100644 => 100755 examples/redis_storage/main.py diff --git a/examples/redis_storage/main.py b/examples/redis_storage/main.py old mode 100644 new mode 100755 index f490640e0..67bc7101a --- a/examples/redis_storage/main.py +++ b/examples/redis_storage/main.py @@ -1,4 +1,4 @@ - +#! /usr/bin/env python3 from nicegui import app, ui diff --git a/nicegui/page.py b/nicegui/page.py index f6493ba80..66f14fc6c 100644 --- a/nicegui/page.py +++ b/nicegui/page.py @@ -109,6 +109,7 @@ def check_for_late_return_value(task: asyncio.Task) -> None: @wraps(func) async def decorated(*dec_args, **dec_kwargs) -> Response: request = dec_kwargs['request'] + await core.app.storage._create_user_storage(request.session['id']) # pylint: disable=protected-access # NOTE cleaning up the keyword args so the signature is consistent with "func" again dec_kwargs = {k: v for k, v in dec_kwargs.items() if k in parameters_of_decorated_func} with Client(self, request=request) as client: diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index a42c715c5..585460b80 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -32,11 +32,15 @@ async def initialize(self) -> None: except Exception: log.warning(f'Could not load data from Redis with key {self.key}') await self.pubsub.subscribe(self.key + 'changes') - async for message in self.pubsub.listen(): - if message['type'] == 'message': - new_data = json.loads(message['data']) - if new_data != self: - self.update(new_data) + + async def listen(): + async for message in self.pubsub.listen(): + if message['type'] == 'message': + new_data = json.loads(message['data']) + if new_data != self: + self.update(new_data) + + background_tasks.create(listen(), name=f'redis-listen-{self.key}') def publish(self) -> None: """Publish the data to Redis and notify other instances.""" diff --git a/nicegui/storage.py b/nicegui/storage.py index bc9fcbd43..da40172d4 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -13,8 +13,6 @@ from starlette.requests import Request from starlette.responses import Response -from nicegui import background_tasks - from . import core, observables from .context import context from .observables import ObservableDict @@ -109,10 +107,13 @@ def user(self) -> PersistentDict: raise RuntimeError('app.storage.user needs a storage_secret passed in ui.run()') raise RuntimeError('app.storage.user can only be used within a UI context') session_id = request.session['id'] + assert session_id in self._users, f'user storage for {session_id} should be created before accessing it' + return self._users[session_id] + + async def _create_user_storage(self, session_id: str) -> None: if session_id not in self._users: self._users[session_id] = Storage.create_persistent_dict(session_id) - background_tasks.create(self._users[session_id].initialize(), name=f'user-{session_id}') - return self._users[session_id] + await self._users[session_id].initialize() @staticmethod def _is_in_auto_index_context() -> bool: From 74dffcfd4e33ea00bd4484ebddf620486a51188b Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 11:14:02 +0100 Subject: [PATCH 09/25] fix argument passing --- nicegui/persistence/redis_persistent_dict.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index 585460b80..d9ae6e838 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -21,7 +21,7 @@ def __init__(self, redis_url: str, id: str, key_prefix: str = 'nicegui:') -> Non self.redis_client = redis.from_url(redis_url) self.pubsub = self.redis_client.pubsub() self.key = key_prefix + id - super().__init__({}, on_change=self.publish) + super().__init__(data={}, on_change=self.publish) async def initialize(self) -> None: """Load initial data from Redis and start listening for changes.""" From 740dd9efbf29aab053fa79eb1bbf5fc34820e0a4 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 11:17:13 +0100 Subject: [PATCH 10/25] also use new async init for file storage --- nicegui/persistence/file_persistent_dict.py | 10 ++++++---- nicegui/persistence/redis_persistent_dict.py | 1 - 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/nicegui/persistence/file_persistent_dict.py b/nicegui/persistence/file_persistent_dict.py index 50a58679d..04f0e7343 100644 --- a/nicegui/persistence/file_persistent_dict.py +++ b/nicegui/persistence/file_persistent_dict.py @@ -15,12 +15,14 @@ def __init__(self, filepath: Path, encoding: Optional[str] = None, *, indent: bo self.filepath = filepath self.encoding = encoding self.indent = indent + super().__init__(data={}, on_change=self.backup) + + async def initialize(self) -> None: try: - data = json.loads(filepath.read_text(encoding)) if filepath.exists() else {} + data = json.loads(self.filepath.read_text(self.encoding)) if self.filepath.exists() else {} + self.update(data) except Exception: - log.warning(f'Could not load storage file {filepath}') - data = {} - super().__init__(data, on_change=self.backup) + log.warning(f'Could not load storage file {self.filepath}') def backup(self) -> None: """Back up the data to the given file path.""" diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index d9ae6e838..94c2d73f4 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -27,7 +27,6 @@ async def initialize(self) -> None: """Load initial data from Redis and start listening for changes.""" try: data = await self.redis_client.get(self.key) - print(f'loading data: {data} for {self.key}') self.update(json.loads(data) if data else {}) except Exception: log.warning(f'Could not load data from Redis with key {self.key}') From 5045b2d15bf5ae3f8ccfd58f10616b6e64782e6a Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 11:53:18 +0100 Subject: [PATCH 11/25] update lock file --- poetry.lock | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 7852a7074..84e1b7bba 100644 --- a/poetry.lock +++ b/poetry.lock @@ -3199,6 +3199,24 @@ packaging = "*" [package.extras] test = ["pytest (>=6,!=7.0.0,!=7.0.1)", "pytest-cov (>=3.0.0)", "pytest-qt"] +[[package]] +name = "redis" +version = "5.2.1" +description = "Python client for Redis database and key-value store" +optional = true +python-versions = ">=3.8" +files = [ + {file = "redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4"}, + {file = "redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + +[package.extras] +hiredis = ["hiredis (>=3.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] + [[package]] name = "requests" version = "2.32.3" @@ -4151,9 +4169,10 @@ highcharts = ["nicegui-highcharts"] matplotlib = ["matplotlib"] native = ["pywebview"] plotly = ["plotly"] +redis = ["redis"] sass = ["libsass"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "35e8fa566398a4abf0e9eb7dea6fa6b8cf613d9fb97736b95d00b65923cb9dbb" +content-hash = "97c67d0f5f6465fd018f90e16911fdc5687b334147125bef32d22317a2538e04" From 285001b7d60d1e33231bfec5554babcfecf7dd1c Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 11:53:55 +0100 Subject: [PATCH 12/25] make loading from local persistence async --- nicegui/persistence/file_persistent_dict.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nicegui/persistence/file_persistent_dict.py b/nicegui/persistence/file_persistent_dict.py index 04f0e7343..1d4ca98cd 100644 --- a/nicegui/persistence/file_persistent_dict.py +++ b/nicegui/persistence/file_persistent_dict.py @@ -19,7 +19,11 @@ def __init__(self, filepath: Path, encoding: Optional[str] = None, *, indent: bo async def initialize(self) -> None: try: - data = json.loads(self.filepath.read_text(self.encoding)) if self.filepath.exists() else {} + if self.filepath.exists(): + async with aiofiles.open(self.filepath, encoding=self.encoding) as f: + data = json.loads(await f.read()) + else: + data = {} self.update(data) except Exception: log.warning(f'Could not load storage file {self.filepath}') From 37a717c05b0152d8e66a5fc9e41cd61a420424e3 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 11:56:24 +0100 Subject: [PATCH 13/25] cleanup --- nicegui/persistence/redis_persistent_dict.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index 94c2d73f4..d88116671 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -44,7 +44,6 @@ async def listen(): def publish(self) -> None: """Publish the data to Redis and notify other instances.""" async def backup() -> None: - print(f'backup {self.key} with {json.dumps(self)}') pipeline = self.redis_client.pipeline() pipeline.set(self.key, json.dumps(self)) pipeline.publish(self.key + 'changes', json.dumps(self)) From deaf35d68ab5040bcd13290e839a8353dc6ec5e1 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 12:12:40 +0100 Subject: [PATCH 14/25] load persistence in middleware to ensure it's available for fastAPI calls etc --- nicegui/page.py | 1 - nicegui/storage.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/nicegui/page.py b/nicegui/page.py index 66f14fc6c..f6493ba80 100644 --- a/nicegui/page.py +++ b/nicegui/page.py @@ -109,7 +109,6 @@ def check_for_late_return_value(task: asyncio.Task) -> None: @wraps(func) async def decorated(*dec_args, **dec_kwargs) -> Response: request = dec_kwargs['request'] - await core.app.storage._create_user_storage(request.session['id']) # pylint: disable=protected-access # NOTE cleaning up the keyword args so the signature is consistent with "func" again dec_kwargs = {k: v for k, v in dec_kwargs.items() if k in parameters_of_decorated_func} with Client(self, request=request) as client: diff --git a/nicegui/storage.py b/nicegui/storage.py index da40172d4..6c92acd7e 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -30,6 +30,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - if 'id' not in request.session: request.session['id'] = str(uuid.uuid4()) request.state.responded = False + await core.app.storage._create_user_storage(request.session['id']) # pylint: disable=protected-access response = await call_next(request) request.state.responded = True return response From 224eed5286eff22511d8ee643359deeffac77766 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 16:31:08 +0100 Subject: [PATCH 15/25] fix storage id naming --- nicegui/storage.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nicegui/storage.py b/nicegui/storage.py index 6c92acd7e..99e387728 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -63,11 +63,11 @@ def __init__(self) -> None: self._tabs: Dict[str, observables.ObservableDict] = {} @staticmethod - def create_persistent_dict(id: str) -> PersistentDict: + def create_persistent_dict(id: str) -> PersistentDict: # pylint: disable=redefined-builtin if Storage.redis_url: - return RedisPersistentDict(Storage.redis_url, f'user-{id}') + return RedisPersistentDict(Storage.redis_url, id) else: - return FilePersistentDict(Storage.path / f'storage-user-{id}.json', encoding='utf-8') + return FilePersistentDict(Storage.path / f'storage-{id}.json', encoding='utf-8') @property def browser(self) -> Union[ReadOnlyDict, Dict]: @@ -113,7 +113,7 @@ def user(self) -> PersistentDict: async def _create_user_storage(self, session_id: str) -> None: if session_id not in self._users: - self._users[session_id] = Storage.create_persistent_dict(session_id) + self._users[session_id] = Storage.create_persistent_dict(f'user-{session_id}') await self._users[session_id].initialize() @staticmethod From c3df3849486e5b49ebf62307967bc57ad2f5c261 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 19:12:33 +0100 Subject: [PATCH 16/25] app.storage.tab now works with redis storage --- examples/redis_storage/main.py | 4 +++- nicegui/client.py | 1 + nicegui/persistence/file_persistent_dict.py | 4 ++++ nicegui/persistence/redis_persistent_dict.py | 4 ++++ nicegui/storage.py | 16 ++++++++++++---- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/examples/redis_storage/main.py b/examples/redis_storage/main.py index 67bc7101a..b63cbe49a 100755 --- a/examples/redis_storage/main.py +++ b/examples/redis_storage/main.py @@ -3,9 +3,11 @@ @ui.page('/') -def index(): +async def index(): ui.input('general').bind_value(app.storage.general, 'text') ui.input('user').bind_value(app.storage.user, 'text') + await ui.context.client.connected() + ui.input('tab').bind_value(app.storage.tab, 'text') ui.run(storage_secret='your private key to secure the browser session cookie') diff --git a/nicegui/client.py b/nicegui/client.py index b1e883927..308001c1f 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -170,6 +170,7 @@ async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> if time.time() > deadline: raise TimeoutError(f'No connection after {timeout} seconds') await asyncio.sleep(check_interval) + await core.app.storage.create_tab_storage(self.tab_id) self.is_waiting_for_connection = False async def disconnected(self, check_interval: float = 0.1) -> None: diff --git a/nicegui/persistence/file_persistent_dict.py b/nicegui/persistence/file_persistent_dict.py index 1d4ca98cd..5bf6973d0 100644 --- a/nicegui/persistence/file_persistent_dict.py +++ b/nicegui/persistence/file_persistent_dict.py @@ -42,3 +42,7 @@ async def backup() -> None: background_tasks.create_lazy(backup(), name=self.filepath.stem) else: core.app.on_startup(backup()) + + def clear(self) -> None: + super().clear() + self.filepath.unlink(missing_ok=True) diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index d88116671..35a3706b8 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -58,3 +58,7 @@ async def close(self) -> None: await self.pubsub.unsubscribe() await self.pubsub.close() await self.redis_client.close() + + def clear(self) -> None: + super().clear() + self.redis_client.delete(self.key) diff --git a/nicegui/storage.py b/nicegui/storage.py index 99e387728..7dd8037e2 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -60,7 +60,7 @@ def __init__(self) -> None: """Maximum age in seconds before tab storage is automatically purged. Defaults to 30 days.""" self._general = Storage.create_persistent_dict('general') self._users: Dict[str, PersistentDict] = {} - self._tabs: Dict[str, observables.ObservableDict] = {} + self._tabs: Dict[str, PersistentDict] = {} @staticmethod def create_persistent_dict(id: str) -> PersistentDict: # pylint: disable=redefined-builtin @@ -151,20 +151,28 @@ def tab(self) -> observables.ObservableDict: raise RuntimeError('app.storage.tab can only be used with a client connection; ' 'see https://nicegui.io/documentation/page#wait_for_client_connection to await it') assert client.tab_id is not None - if client.tab_id not in self._tabs: - self._tabs[client.tab_id] = observables.ObservableDict() + assert client.tab_id in self._tabs, f'tab storage for {client.tab_id} should be created before accessing it' return self._tabs[client.tab_id] + async def create_tab_storage(self, tab_id: str) -> None: + """Create tab storage for the given tab ID.""" + if tab_id not in self._tabs: + self._tabs[tab_id] = Storage.create_persistent_dict(f'tab-{tab_id}') + await self._tabs[tab_id].initialize() + def copy_tab(self, old_tab_id: str, tab_id: str) -> None: """Copy the tab storage to a new tab. (For internal use only.)""" if old_tab_id in self._tabs: - self._tabs[tab_id] = observables.ObservableDict(self._tabs[old_tab_id].copy()) + self._tabs[tab_id] = Storage.create_persistent_dict(f'tab-{tab_id}') + self._tabs[tab_id].update(self._tabs[old_tab_id]) async def prune_tab_storage(self) -> None: """Regularly prune tab storage that is older than the configured `max_tab_storage_age`.""" while True: for tab_id, tab in list(self._tabs.items()): if time.time() > tab.last_modified + self.max_tab_storage_age: + tab.clear() + await tab.close() del self._tabs[tab_id] await asyncio.sleep(PURGE_INTERVAL) From 2dd7ed5aff8a8705e091382523393db5c15ce2fe Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 19:30:40 +0100 Subject: [PATCH 17/25] allow to configure redis key prefix and add some documentation --- nicegui/storage.py | 4 +++- .../content/section_configuration_deployment.py | 2 ++ website/documentation/content/storage_documentation.py | 9 +++++++++ website/examples.py | 1 + 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/nicegui/storage.py b/nicegui/storage.py index 7dd8037e2..ad3135032 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -54,6 +54,8 @@ class Storage: """Path to use for local persistence. Defaults to '.nicegui'.""" redis_url = os.environ.get('NICEGUI_REDIS_URL', None) """URL to use for shared persistent storage via Redis. Defaults to None, which means local file storage is used.""" + redis_key_prefix = os.environ.get('NICEGUI_REDIS_KEY_PREFIX', 'nicegui:') + """Prefix for Redis keys. Defaults to 'nicegui:'.""" def __init__(self) -> None: self.max_tab_storage_age: float = timedelta(days=30).total_seconds() @@ -65,7 +67,7 @@ def __init__(self) -> None: @staticmethod def create_persistent_dict(id: str) -> PersistentDict: # pylint: disable=redefined-builtin if Storage.redis_url: - return RedisPersistentDict(Storage.redis_url, id) + return RedisPersistentDict(Storage.redis_url, id, Storage.redis_key_prefix) else: return FilePersistentDict(Storage.path / f'storage-{id}.json', encoding='utf-8') diff --git a/website/documentation/content/section_configuration_deployment.py b/website/documentation/content/section_configuration_deployment.py index 7b012d69b..901f4b289 100644 --- a/website/documentation/content/section_configuration_deployment.py +++ b/website/documentation/content/section_configuration_deployment.py @@ -72,6 +72,8 @@ def native_mode_demo(): - `NICEGUI_STORAGE_PATH` (default: local ".nicegui") can be set to change the location of the storage files. - `MARKDOWN_CONTENT_CACHE_SIZE` (default: 1000): The maximum number of Markdown content snippets that are cached in memory. - `RST_CONTENT_CACHE_SIZE` (default: 1000): The maximum number of ReStructuredText content snippets that are cached in memory. + - `NICEGUI_REDIS_URL` (default: None, means local file storage): The URL of the Redis server to use for shared persistent storage. + - `NICEGUI_REDIS_KEY_PREFIX` (default: 'nicegui:'): The prefix for Redis keys. ''') def env_var_demo(): from nicegui.elements import markdown diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py index 76574e110..178321e51 100644 --- a/website/documentation/content/storage_documentation.py +++ b/website/documentation/content/storage_documentation.py @@ -178,3 +178,12 @@ def short_term_memory(): You can change this to an indentation of 2 spaces by setting `app.storage.general.indent = True` or `app.storage.user.indent = True`. ''') + + +doc.text('Redis storage', ''' + You can use Redis for storage as an alternative to the default file storage. + This is useful if you have multiple NiceGUI instances and want to share data across them. + + To activate this feature install the `redis` package to be installed (`pip install nicegui[redis]`) and provide the `NICEGUI_REDIS_URL` environment variable to point to your Redis server. + Our [Redis storage example](https://github.com/zauberzeug/nicegui/tree/main/examples/redis_storage) shows how you can setup it up with a reverse proxy or load balancer. +''') diff --git a/website/examples.py b/website/examples.py index a95aec98f..bad26531b 100644 --- a/website/examples.py +++ b/website/examples.py @@ -69,4 +69,5 @@ def __post_init__(self) -> None: Example('NGINX HTTPS', 'Use NGINX to serve a NiceGUI app with HTTPS'), Example('Node Module Integration', 'Use NPM to add dependencies to a NiceGUI app'), Example('Signature Pad', 'A custom element based on [signature_pad](https://www.npmjs.com/package/signature_pad'), + Example('Redis Storage', 'Use Redis storage to share data across multiple instances behind a reverse proxy or load balancer'), ] From 26791029857c6852213ee3018ea9c0eace8fca30 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sun, 15 Dec 2024 19:31:57 +0100 Subject: [PATCH 18/25] ensure we always have a tab storage available --- nicegui/client.py | 1 - nicegui/nicegui.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/nicegui/client.py b/nicegui/client.py index 308001c1f..b1e883927 100644 --- a/nicegui/client.py +++ b/nicegui/client.py @@ -170,7 +170,6 @@ async def connected(self, timeout: float = 3.0, check_interval: float = 0.1) -> if time.time() > deadline: raise TimeoutError(f'No connection after {timeout} seconds') await asyncio.sleep(check_interval) - await core.app.storage.create_tab_storage(self.tab_id) self.is_waiting_for_connection = False async def disconnected(self, check_interval: float = 0.1) -> None: diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py index c08115465..d058f4152 100644 --- a/nicegui/nicegui.py +++ b/nicegui/nicegui.py @@ -176,6 +176,7 @@ async def _on_handshake(sid: str, data: Dict[str, str]) -> bool: client.environ = sio.get_environ(sid) await sio.enter_room(sid, client.id) client.handle_handshake() + await core.app.storage.create_tab_storage(client.tab_id) return True From 004d7c5cb22d004fa0430925cdebc51ab4c5bce7 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Mon, 16 Dec 2024 10:32:45 +0100 Subject: [PATCH 19/25] make method private --- nicegui/storage.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nicegui/storage.py b/nicegui/storage.py index ad3135032..78de0e8fa 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -60,12 +60,12 @@ class Storage: def __init__(self) -> None: self.max_tab_storage_age: float = timedelta(days=30).total_seconds() """Maximum age in seconds before tab storage is automatically purged. Defaults to 30 days.""" - self._general = Storage.create_persistent_dict('general') + self._general = Storage._create_persistent_dict('general') self._users: Dict[str, PersistentDict] = {} self._tabs: Dict[str, PersistentDict] = {} @staticmethod - def create_persistent_dict(id: str) -> PersistentDict: # pylint: disable=redefined-builtin + def _create_persistent_dict(id: str) -> PersistentDict: # pylint: disable=redefined-builtin if Storage.redis_url: return RedisPersistentDict(Storage.redis_url, id, Storage.redis_key_prefix) else: @@ -115,7 +115,7 @@ def user(self) -> PersistentDict: async def _create_user_storage(self, session_id: str) -> None: if session_id not in self._users: - self._users[session_id] = Storage.create_persistent_dict(f'user-{session_id}') + self._users[session_id] = Storage._create_persistent_dict(f'user-{session_id}') await self._users[session_id].initialize() @staticmethod @@ -159,13 +159,13 @@ def tab(self) -> observables.ObservableDict: async def create_tab_storage(self, tab_id: str) -> None: """Create tab storage for the given tab ID.""" if tab_id not in self._tabs: - self._tabs[tab_id] = Storage.create_persistent_dict(f'tab-{tab_id}') + self._tabs[tab_id] = Storage._create_persistent_dict(f'tab-{tab_id}') await self._tabs[tab_id].initialize() def copy_tab(self, old_tab_id: str, tab_id: str) -> None: """Copy the tab storage to a new tab. (For internal use only.)""" if old_tab_id in self._tabs: - self._tabs[tab_id] = Storage.create_persistent_dict(f'tab-{tab_id}') + self._tabs[tab_id] = Storage._create_persistent_dict(f'tab-{tab_id}') self._tabs[tab_id].update(self._tabs[old_tab_id]) async def prune_tab_storage(self) -> None: From 7c411e805bc70ffa2c94078a5b9cf9979742162c Mon Sep 17 00:00:00 2001 From: Falko Schindler Date: Mon, 16 Dec 2024 12:14:06 +0100 Subject: [PATCH 20/25] code review --- examples/redis_storage/main.py | 2 +- nicegui/app/app.py | 7 +++++-- nicegui/nicegui.py | 2 +- nicegui/optional_features.py | 4 ++-- nicegui/persistence/persistent_dict.py | 4 +++- nicegui/persistence/redis_persistent_dict.py | 14 +++++--------- nicegui/storage.py | 16 ++++++++++------ .../content/section_configuration_deployment.py | 2 +- .../content/storage_documentation.py | 6 ++++-- 9 files changed, 32 insertions(+), 25 deletions(-) diff --git a/examples/redis_storage/main.py b/examples/redis_storage/main.py index b63cbe49a..c46029ec0 100755 --- a/examples/redis_storage/main.py +++ b/examples/redis_storage/main.py @@ -1,4 +1,4 @@ -#! /usr/bin/env python3 +#!/usr/bin/env python3 from nicegui import app, ui diff --git a/nicegui/app/app.py b/nicegui/app/app.py index 753d0c1ed..c2f296322 100644 --- a/nicegui/app/app.py +++ b/nicegui/app/app.py @@ -39,12 +39,15 @@ def __init__(self, **kwargs) -> None: self._state: State = State.STOPPED self.config = AppConfig() - self._startup_handlers: List[Union[Callable[..., Any], Awaitable]] = [self.storage.general.initialize,] - self._shutdown_handlers: List[Union[Callable[..., Any], Awaitable]] = [self.storage.on_shutdown] + 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] + self.on_startup(self.storage.general.initialize) + self.on_shutdown(self.storage.on_shutdown) + @property def is_starting(self) -> bool: """Return whether NiceGUI is starting.""" diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py index d058f4152..c932d01a2 100644 --- a/nicegui/nicegui.py +++ b/nicegui/nicegui.py @@ -176,7 +176,7 @@ async def _on_handshake(sid: str, data: Dict[str, str]) -> bool: client.environ = sio.get_environ(sid) await sio.enter_room(sid, client.id) client.handle_handshake() - await core.app.storage.create_tab_storage(client.tab_id) + await core.app.storage._create_tab_storage(client.tab_id) # pylint: disable=protected-access return True diff --git a/nicegui/optional_features.py b/nicegui/optional_features.py index 62e6d1844..dac67191f 100644 --- a/nicegui/optional_features.py +++ b/nicegui/optional_features.py @@ -10,9 +10,9 @@ 'plotly', 'polars', 'pyecharts', - 'webview', - 'sass', 'redis', + 'sass', + 'webview', ] diff --git a/nicegui/persistence/persistent_dict.py b/nicegui/persistence/persistent_dict.py index ce113ec4c..c22817ccc 100644 --- a/nicegui/persistence/persistent_dict.py +++ b/nicegui/persistence/persistent_dict.py @@ -1,9 +1,11 @@ +import abc from nicegui import observables -class PersistentDict(observables.ObservableDict): +class PersistentDict(observables.ObservableDict, abc.ABC): + @abc.abstractmethod async def initialize(self) -> None: """Load initial data from the persistence layer.""" diff --git a/nicegui/persistence/redis_persistent_dict.py b/nicegui/persistence/redis_persistent_dict.py index 35a3706b8..26ef8aea3 100644 --- a/nicegui/persistence/redis_persistent_dict.py +++ b/nicegui/persistence/redis_persistent_dict.py @@ -1,7 +1,6 @@ -from nicegui import background_tasks, core, json -from nicegui.logging import log - -from .. import optional_features +from .. import background_tasks, core, json, optional_features +from ..logging import log +from .persistent_dict import PersistentDict try: import redis.asyncio as redis @@ -10,15 +9,12 @@ pass -from .persistent_dict import PersistentDict - - class RedisPersistentDict(PersistentDict): - def __init__(self, redis_url: str, id: str, key_prefix: str = 'nicegui:') -> None: # pylint: disable=redefined-builtin + def __init__(self, *, url: str, id: str, key_prefix: str = 'nicegui:') -> None: # pylint: disable=redefined-builtin if not optional_features.has('redis'): raise ImportError('Redis is not installed. Please run "pip install nicegui[redis]".') - self.redis_client = redis.from_url(redis_url) + self.redis_client = redis.from_url(url) self.pubsub = self.redis_client.pubsub() self.key = key_prefix + id super().__init__(data={}, on_change=self.publish) diff --git a/nicegui/storage.py b/nicegui/storage.py index 78de0e8fa..5f7017128 100644 --- a/nicegui/storage.py +++ b/nicegui/storage.py @@ -50,16 +50,20 @@ def set_storage_secret(storage_secret: Optional[str] = None) -> None: class Storage: secret: Optional[str] = None """Secret key for session storage.""" + path = Path(os.environ.get('NICEGUI_STORAGE_PATH', '.nicegui')).resolve() - """Path to use for local persistence. Defaults to '.nicegui'.""" + """Path to use for local persistence. Defaults to ".nicegui".""" + redis_url = os.environ.get('NICEGUI_REDIS_URL', None) """URL to use for shared persistent storage via Redis. Defaults to None, which means local file storage is used.""" + redis_key_prefix = os.environ.get('NICEGUI_REDIS_KEY_PREFIX', 'nicegui:') - """Prefix for Redis keys. Defaults to 'nicegui:'.""" + """Prefix for Redis keys. Defaults to "nicegui:".""" + + max_tab_storage_age: float = timedelta(days=30).total_seconds() + """Maximum age in seconds before tab storage is automatically purged. Defaults to 30 days.""" def __init__(self) -> None: - self.max_tab_storage_age: float = timedelta(days=30).total_seconds() - """Maximum age in seconds before tab storage is automatically purged. Defaults to 30 days.""" self._general = Storage._create_persistent_dict('general') self._users: Dict[str, PersistentDict] = {} self._tabs: Dict[str, PersistentDict] = {} @@ -67,7 +71,7 @@ def __init__(self) -> None: @staticmethod def _create_persistent_dict(id: str) -> PersistentDict: # pylint: disable=redefined-builtin if Storage.redis_url: - return RedisPersistentDict(Storage.redis_url, id, Storage.redis_key_prefix) + return RedisPersistentDict(url=Storage.redis_url, id=id, key_prefix=Storage.redis_key_prefix) else: return FilePersistentDict(Storage.path / f'storage-{id}.json', encoding='utf-8') @@ -156,7 +160,7 @@ def tab(self) -> observables.ObservableDict: assert client.tab_id in self._tabs, f'tab storage for {client.tab_id} should be created before accessing it' return self._tabs[client.tab_id] - async def create_tab_storage(self, tab_id: str) -> None: + async def _create_tab_storage(self, tab_id: str) -> None: """Create tab storage for the given tab ID.""" if tab_id not in self._tabs: self._tabs[tab_id] = Storage._create_persistent_dict(f'tab-{tab_id}') diff --git a/website/documentation/content/section_configuration_deployment.py b/website/documentation/content/section_configuration_deployment.py index 901f4b289..71b1759d1 100644 --- a/website/documentation/content/section_configuration_deployment.py +++ b/website/documentation/content/section_configuration_deployment.py @@ -73,7 +73,7 @@ def native_mode_demo(): - `MARKDOWN_CONTENT_CACHE_SIZE` (default: 1000): The maximum number of Markdown content snippets that are cached in memory. - `RST_CONTENT_CACHE_SIZE` (default: 1000): The maximum number of ReStructuredText content snippets that are cached in memory. - `NICEGUI_REDIS_URL` (default: None, means local file storage): The URL of the Redis server to use for shared persistent storage. - - `NICEGUI_REDIS_KEY_PREFIX` (default: 'nicegui:'): The prefix for Redis keys. + - `NICEGUI_REDIS_KEY_PREFIX` (default: "nicegui:"): The prefix for Redis keys. ''') def env_var_demo(): from nicegui.elements import markdown diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py index 178321e51..7718a5ef6 100644 --- a/website/documentation/content/storage_documentation.py +++ b/website/documentation/content/storage_documentation.py @@ -184,6 +184,8 @@ def short_term_memory(): You can use Redis for storage as an alternative to the default file storage. This is useful if you have multiple NiceGUI instances and want to share data across them. - To activate this feature install the `redis` package to be installed (`pip install nicegui[redis]`) and provide the `NICEGUI_REDIS_URL` environment variable to point to your Redis server. - Our [Redis storage example](https://github.com/zauberzeug/nicegui/tree/main/examples/redis_storage) shows how you can setup it up with a reverse proxy or load balancer. + To activate this feature install the `redis` package (`pip install nicegui[redis]`) + and provide the `NICEGUI_REDIS_URL` environment variable to point to your Redis server. + Our [Redis storage example](https://github.com/zauberzeug/nicegui/tree/main/examples/redis_storage) shows + how you can setup it up with a reverse proxy or load balancer. ''') From be9afd056983cd2e658d0a986ac484a052f1c4e1 Mon Sep 17 00:00:00 2001 From: Falko Schindler Date: Mon, 16 Dec 2024 14:56:45 +0100 Subject: [PATCH 21/25] re-order columns --- .../content/storage_documentation.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py index 7718a5ef6..e6710b687 100644 --- a/website/documentation/content/storage_documentation.py +++ b/website/documentation/content/storage_documentation.py @@ -45,18 +45,18 @@ The following table will help you to choose storage. - | Storage type | `tab` | `client` | `user` | `general` | `browser` | - |-----------------------------|--------|----------|--------|-----------|-----------| - | Location | Server | Server | Server | Server | Browser | - | Across tabs | No | No | Yes | Yes | Yes | - | Across browsers | No | No | No | Yes | No | - | Across server restarts | No | No | No | Yes | No | - | Across page reloads | Yes | No | Yes | Yes | Yes | - | Needs page builder function | Yes | Yes | Yes | No | Yes | - | Needs client connection | Yes | No | No | No | No | - | Write only before response | No | No | No | No | Yes | - | Needs serializable data | No | No | Yes | Yes | Yes | - | Needs `storage_secret` | No | No | Yes | No | Yes | + | Storage type | `client` | `tab` | `browser` | `user` | `general` | + |-----------------------------|----------|--------|-----------|--------|-----------| + | Location | Server | Server | Browser | Server | Server | + | Across tabs | No | No | Yes | Yes | Yes | + | Across browsers | No | No | No | No | Yes | + | Across server restarts | No | No | No | No | Yes | + | Across page reloads | No | Yes | Yes | Yes | Yes | + | Needs page builder function | Yes | Yes | Yes | Yes | No | + | Needs client connection | No | Yes | No | No | No | + | Write only before response | No | No | Yes | No | No | + | Needs serializable data | No | No | Yes | Yes | Yes | + | Needs `storage_secret` | No | No | Yes | Yes | No | ''') def storage_demo(): from nicegui import app From d85602f35ae80a47a2928889f2114518ab81fd44 Mon Sep 17 00:00:00 2001 From: Falko Schindler Date: Mon, 16 Dec 2024 14:57:23 +0100 Subject: [PATCH 22/25] tab and user survive server restarts --- website/documentation/content/storage_documentation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py index e6710b687..71e518773 100644 --- a/website/documentation/content/storage_documentation.py +++ b/website/documentation/content/storage_documentation.py @@ -50,7 +50,7 @@ | Location | Server | Server | Browser | Server | Server | | Across tabs | No | No | Yes | Yes | Yes | | Across browsers | No | No | No | No | Yes | - | Across server restarts | No | No | No | No | Yes | + | Across server restarts | No | Yes | No | Yes | Yes | | Across page reloads | No | Yes | Yes | Yes | Yes | | Needs page builder function | Yes | Yes | Yes | Yes | No | | Needs client connection | No | Yes | No | No | No | From 6c03b99dcca284dbecce0ad42a24fb4e746a3468 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 11 Jan 2025 05:58:05 +0100 Subject: [PATCH 23/25] fix max_tab_storage_age demo --- website/documentation/content/storage_documentation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py index 71e518773..ae9fa846d 100644 --- a/website/documentation/content/storage_documentation.py +++ b/website/documentation/content/storage_documentation.py @@ -144,7 +144,7 @@ def max_tab_storage_age(): @ui.page('/') def index(): - # ui.label(f'Tab storage age: {app.storage.tab.age} seconds') + # ui.label(f'Tab storage age: {app.storage.max_tab_storage_age} seconds') pass # HIDE From e2a0d45282a9c8fa14477bbfc044d51b006b9798 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 11 Jan 2025 06:20:40 +0100 Subject: [PATCH 24/25] mention limitations in documentation --- .../documentation/content/storage_documentation.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/website/documentation/content/storage_documentation.py b/website/documentation/content/storage_documentation.py index ae9fa846d..4dfe04288 100644 --- a/website/documentation/content/storage_documentation.py +++ b/website/documentation/content/storage_documentation.py @@ -181,11 +181,21 @@ def short_term_memory(): doc.text('Redis storage', ''' - You can use Redis for storage as an alternative to the default file storage. + You can use [Redis](https://redis.io/) for storage as an alternative to the default file storage. This is useful if you have multiple NiceGUI instances and want to share data across them. To activate this feature install the `redis` package (`pip install nicegui[redis]`) and provide the `NICEGUI_REDIS_URL` environment variable to point to your Redis server. Our [Redis storage example](https://github.com/zauberzeug/nicegui/tree/main/examples/redis_storage) shows how you can setup it up with a reverse proxy or load balancer. + + Please note that the Redis sync always contains all the data, not only the changed values. + + - For `app.storage.general` this is the whole dictionary. + - For `app.storage.user` it's all the data of the user. + - For `app.storage.tab` it's all the data stored for this specific tab. + + If you have large data sets, we suggest to use a database instead. + See our [database example](https://github.com/zauberzeug/nicegui/blob/main/examples/sqlite_database/main.py) for a demo with SQLite. + But of course to sync between multiple instances you should replace SQLite with PostgreSQL or similar. ''') From dad02ce7f664f28d9d04f88f519e56956a9e82f2 Mon Sep 17 00:00:00 2001 From: Rodja Trappe Date: Sat, 11 Jan 2025 06:35:52 +0100 Subject: [PATCH 25/25] fix typing --- nicegui/nicegui.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py index c7f18fb52..756660d3d 100644 --- a/nicegui/nicegui.py +++ b/nicegui/nicegui.py @@ -176,6 +176,7 @@ async def _on_handshake(sid: str, data: Dict[str, Any]) -> bool: client.environ = sio.get_environ(sid) await sio.enter_room(sid, client.id) client.handle_handshake(data.get('next_message_id')) + assert client.tab_id is not None await core.app.storage._create_tab_storage(client.tab_id) # pylint: disable=protected-access return True