Repository: zauberzeug/nicegui
Pull request #4388 (2025-02-26 07:07:34)

Pass referrer_path to /login as a query parameter
Don't use app.storage.user['referrer_path'] because it can get confused if multiple tabs are used by the user. Instead pass the referrer_path as the URL query parameter "r" to /login.


Patch:
diff --git a/.github/workflows/citation.yml b/.github/workflows/citation.yml
index 8e5bb9f8..35071396 100644
--- a/.github/workflows/citation.yml
+++ b/.github/workflows/citation.yml
@@ -17,6 +17,6 @@ jobs:
           github_token: ${{ secrets.GITHUB_TOKEN }}
           zenodo_token: ${{ secrets.ZENODO_TOKEN }}
           zenodo_deposition_id: 13623775
-          zenodo_publish: true
+          zenodo_publish: false
           commit_message: "Update CITATION.cff"
           citation_cff: true
diff --git a/.vscode/settings.json b/.vscode/settings.json
index b72710b4..280468d1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,4 @@
 {
-  "autopep8.args": ["--max-line-length=120"],
   "editor.defaultFormatter": "esbenp.prettier-vscode",
   "editor.formatOnSave": true,
   "prettier.printWidth": 120,
diff --git a/CITATION.cff b/CITATION.cff
index 34c1491a..a5103ccb 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -11,7 +11,7 @@ title: 'NiceGUI: Web-based user interfaces with Python. The nice way.'
 version: 2.11.1
 date-released: '2025-02-11'
 url: https://github.com/zauberzeug/nicegui
-doi: 10.5281/zenodo.14850111
+doi: 10.5281/zenodo.7785516
 identifiers:
   - description: DOI for this application's record on Zenodo
     type: doi
diff --git a/README.md b/README.md
index facf84ea..8906b77a 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
 <a href="https://nicegui.io/#about">
   <img src="https://raw.githubusercontent.com/zauberzeug/nicegui/main/screenshot.png"
-    width="200" align="right" alt="Try online!" />
+    width="200" align="right" alt="Try online!"
+    style="background: white; position: relative; z-index: 1;"
+  />
 </a>
 
 # NiceGUI
@@ -23,6 +25,7 @@ NiceGUI is available as [PyPI package](https://pypi.org/project/nicegui/), [Dock
 [![GitHub issues](https://img.shields.io/github/issues/zauberzeug/nicegui?color=blue)](https://github.com/zauberzeug/nicegui/issues)
 [![GitHub forks](https://img.shields.io/github/forks/zauberzeug/nicegui)](https://github.com/zauberzeug/nicegui/network)
 [![GitHub stars](https://img.shields.io/github/stars/zauberzeug/nicegui)](https://github.com/zauberzeug/nicegui/stargazers)
+[![DOI](https://zenodo.org/badge/365250183.svg)](https://doi.org/10.5281/zenodo.7785516)
 
 ## Features
 
diff --git a/examples/authentication/main.py b/examples/authentication/main.py
index 81f46ba8..5a7dcf36 100755
--- a/examples/authentication/main.py
+++ b/examples/authentication/main.py
@@ -28,8 +28,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
     async def dispatch(self, request: Request, call_next):
         if not app.storage.user.get('authenticated', False):
             if not request.url.path.startswith('/_nicegui') 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 RedirectResponse(f'/login?redirect_to={request.url.path}')
         return await call_next(request)
 
 
@@ -53,11 +52,11 @@ def test_page() -> None:
 
 
 @ui.page('/login')
-def login() -> Optional[RedirectResponse]:
+def login(redirect_to: str = '/') -> Optional[RedirectResponse]:
     def try_login() -> None:  # local function to avoid passing username and password as arguments
         if passwords.get(username.value) == password.value:
             app.storage.user.update({'username': username.value, 'authenticated': True})
-            ui.navigate.to(app.storage.user.get('referrer_path', '/'))  # go back to where the user wanted to go
+            ui.navigate.to(redirect_to)  # go back to where the user wanted to go
         else:
             ui.notify('Wrong username or password', color='negative')
 
diff --git a/nicegui/app/app.py b/nicegui/app/app.py
index 02e1d33e..cc7fc7d9 100644
--- a/nicegui/app/app.py
+++ b/nicegui/app/app.py
@@ -46,7 +46,6 @@ class App(FastAPI):
         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
diff --git a/nicegui/binding.py b/nicegui/binding.py
index ee857420..2c2bf8fe 100644
--- a/nicegui/binding.py
+++ b/nicegui/binding.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import asyncio
+import copyreg
 import dataclasses
 import time
 import weakref
@@ -36,7 +37,8 @@ bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], weakref.finalize] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 
-T = TypeVar('T', bound=type)
+TC = TypeVar('TC', bound=type)
+T = TypeVar('T')
 
 
 def _has_attribute(obj: Union[object, Mapping], name: str) -> Any:
@@ -170,6 +172,8 @@ class BindableProperty:
 
     def __set__(self, owner: Any, value: Any) -> None:
         has_attr = hasattr(owner, '___' + self.name)
+        if not has_attr:
+            _make_copyable(type(owner))
         value_changed = has_attr and getattr(owner, '___' + self.name) != value
         if has_attr and not value_changed:
             return
@@ -217,7 +221,7 @@ def reset() -> None:
 
 
 @dataclass_transform()
-def bindable_dataclass(cls: Optional[T] = None, /, *,
+def bindable_dataclass(cls: Optional[TC] = None, /, *,
                        bindable_fields: Optional[Iterable[str]] = None,
                        **kwargs: Any) -> Union[Type[DataclassInstance], IdentityFunction]:
     """A decorator that transforms a class into a dataclass with bindable fields.
@@ -255,3 +259,23 @@ def bindable_dataclass(cls: Optional[T] = None, /, *,
         bindable_property.__set_name__(dataclass, field_name)
         setattr(dataclass, field_name, bindable_property)
     return dataclass
+
+
+def _make_copyable(cls: Type[T]) -> None:
+    """Tell the copy module to update the ``bindable_properties`` dictionary when an object is copied."""
+    if cls in copyreg.dispatch_table:
+        return
+
+    def _pickle_function(obj: T) -> Tuple[Callable[..., T], Tuple[Any, ...]]:
+        reduced = obj.__reduce__()
+        assert isinstance(reduced, tuple)
+        creator = reduced[0]
+
+        def creator_with_hook(*args, **kwargs) -> T:
+            copy = creator(*args, **kwargs)
+            for attr_name in dir(obj):
+                if (id(obj), attr_name) in bindable_properties:
+                    bindable_properties[(id(copy), attr_name)] = copy
+            return copy
+        return (creator_with_hook, *reduced[1:])
+    copyreg.pickle(cls, _pickle_function)
diff --git a/nicegui/elements/leaflet.js b/nicegui/elements/leaflet.js
index f54f7f41..935815a7 100644
--- a/nicegui/elements/leaflet.js
+++ b/nicegui/elements/leaflet.js
@@ -17,8 +17,8 @@ export default {
     await Promise.all([
       loadResource(window.path_prefix + `${this.resource_path}/leaflet/leaflet.css`),
       loadResource(window.path_prefix + `${this.resource_path}/leaflet/leaflet.js`),
-      ...this.additional_resources.map((resource) => loadResource(resource)),
     ]);
+    await Promise.all(this.additional_resources.map((resource) => loadResource(resource)));
     if (this.draw_control) {
       await Promise.all([
         loadResource(window.path_prefix + `${this.resource_path}/leaflet-draw/leaflet.draw.css`),
diff --git a/nicegui/nicegui.py b/nicegui/nicegui.py
index 1d9d2bc2..f03df8df 100644
--- a/nicegui/nicegui.py
+++ b/nicegui/nicegui.py
@@ -43,8 +43,8 @@ class SocketIoApp(socketio.ASGIApp):
 
 
 core.app = app = App(default_response_class=NiceGUIJSONResponse, lifespan=_lifespan)
-# NOTE we use custom json module which wraps orjson
-core.sio = sio = socketio.AsyncServer(async_mode='asgi', cors_allowed_origins='*', json=json)
+core.app.storage.general.initialize_sync()
+core.sio = sio = socketio.AsyncServer(async_mode='asgi', cors_allowed_origins='*', json=json)  # custom orjson wrapper
 sio_app = SocketIoApp(socketio_server=sio, socketio_path='/socket.io')
 app.mount('/_nicegui_ws/', sio_app)
 
diff --git a/nicegui/persistence/file_persistent_dict.py b/nicegui/persistence/file_persistent_dict.py
index 5bf6973d..8b5a60f5 100644
--- a/nicegui/persistence/file_persistent_dict.py
+++ b/nicegui/persistence/file_persistent_dict.py
@@ -28,6 +28,16 @@ class FilePersistentDict(PersistentDict):
         except Exception:
             log.warning(f'Could not load storage file {self.filepath}')
 
+    def initialize_sync(self) -> None:
+        try:
+            if self.filepath.exists():
+                data = json.loads(self.filepath.read_text(encoding=self.encoding))
+            else:
+                data = {}
+            self.update(data)
+        except Exception:
+            log.warning(f'Could not load storage file {self.filepath}')
+
     def backup(self) -> None:
         """Back up the data to the given file path."""
         if not self.filepath.exists():
diff --git a/nicegui/persistence/persistent_dict.py b/nicegui/persistence/persistent_dict.py
index c22817cc..c5b1f412 100644
--- a/nicegui/persistence/persistent_dict.py
+++ b/nicegui/persistence/persistent_dict.py
@@ -9,5 +9,9 @@ class PersistentDict(observables.ObservableDict, abc.ABC):
     async def initialize(self) -> None:
         """Load initial data from the persistence layer."""
 
+    @abc.abstractmethod
+    def initialize_sync(self) -> None:
+        """Load initial data from the persistence layer in a synchronous context."""
+
     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 1725df80..b5955eab 100644
--- a/nicegui/persistence/redis_persistent_dict.py
+++ b/nicegui/persistence/redis_persistent_dict.py
@@ -3,6 +3,7 @@ from ..logging import log
 from .persistent_dict import PersistentDict
 
 try:
+    import redis as redis_sync
     import redis.asyncio as redis
     optional_features.register('redis')
 except ImportError:
@@ -14,6 +15,7 @@ class RedisPersistentDict(PersistentDict):
     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.url = url
         self.redis_client = redis.from_url(
             url,
             health_check_interval=10,
@@ -30,18 +32,39 @@ class RedisPersistentDict(PersistentDict):
         try:
             data = await self.redis_client.get(self.key)
             self.update(json.loads(data) if data else {})
+            self._start_listening()
         except Exception:
             log.warning(f'Could not load data from Redis with key {self.key}')
-        await self.pubsub.subscribe(self.key + 'changes')
 
+    def initialize_sync(self) -> None:
+        """Load initial data from Redis and start listening for changes in a synchronous context."""
+        with redis_sync.from_url(
+            self.url,
+            health_check_interval=10,
+            socket_connect_timeout=5,
+            retry_on_timeout=True,
+            socket_keepalive=True,
+        ) as redis_client_sync:
+            try:
+                data = redis_client_sync.get(self.key)
+                self.update(json.loads(data) if data else {})
+                self._start_listening()
+            except Exception:
+                log.warning(f'Could not load data from Redis with key {self.key}')
+
+    def _start_listening(self) -> None:
         async def listen():
+            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)
 
-        background_tasks.create(listen(), name=f'redis-listen-{self.key}')
+        if core.loop and core.loop.is_running():
+            background_tasks.create(listen(), name=f'redis-listen-{self.key}')
+        else:
+            core.app.on_startup(listen())
 
     def publish(self) -> None:
         """Publish the data to Redis and notify other instances."""
diff --git a/pyproject.toml b/pyproject.toml
index 97f2add1..4171a9d5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -116,6 +116,9 @@ module = [
 ]
 ignore_missing_imports = true
 
+[tool.autopep8]
+max-line-length = 120
+
 [tool.ruff]
 target-version = "py38"
 indent-width = 4


Test patch:
diff --git a/nicegui/testing/user_plugin.py b/nicegui/testing/user_plugin.py
index 5bed990e..0ca74207 100644
--- a/nicegui/testing/user_plugin.py
+++ b/nicegui/testing/user_plugin.py
@@ -5,6 +5,7 @@ import httpx
 import pytest
 
 from nicegui import Client, core, ui
+from nicegui.functions.download import download
 from nicegui.functions.navigate import Navigate
 from nicegui.functions.notify import notify
 
@@ -46,6 +47,7 @@ async def user(nicegui_reset_globals,  # noqa: F811, pylint: disable=unused-argu
             yield User(client)
     ui.navigate = Navigate()
     ui.notify = notify
+    ui.download = download
 
 
 @pytest.fixture
@@ -59,3 +61,4 @@ async def create_user(nicegui_reset_globals,  # noqa: F811, pylint: disable=unus
         yield lambda: User(httpx.AsyncClient(transport=httpx.ASGITransport(core.app), base_url='http://test'))
     ui.navigate = Navigate()
     ui.notify = notify
+    ui.download = download
diff --git a/tests/test_binding.py b/tests/test_binding.py
index 19d539dd..3c469337 100644
--- a/tests/test_binding.py
+++ b/tests/test_binding.py
@@ -1,10 +1,11 @@
+import copy
 import weakref
 from typing import Dict, Optional, Tuple
 
 from selenium.webdriver.common.keys import Keys
 
 from nicegui import binding, ui
-from nicegui.testing import Screen
+from nicegui.testing import Screen, User
 
 
 def test_ui_select_with_tuple_as_key(screen: Screen):
@@ -128,6 +129,31 @@ def test_bindable_dataclass(screen: Screen):
     assert binding.active_links[0][1] == 'not_bindable'
 
 
+async def test_copy_instance_with_bindable_property(user: User):
+    @binding.bindable_dataclass
+    class Number:
+        value: int = 1
+
+    x = Number()
+    y = copy.copy(x)
+
+    ui.label().bind_text_from(x, 'value', lambda v: f'x={v}')
+    assert len(binding.bindings) == 1
+    assert len(binding.active_links) == 0
+
+    ui.label().bind_text_from(y, 'value', lambda v: f'y={v}')
+    assert len(binding.bindings) == 2
+    assert len(binding.active_links) == 0
+
+    await user.open('/')
+    await user.should_see('x=1')
+    await user.should_see('y=1')
+
+    y.value = 2
+    await user.should_see('x=1')
+    await user.should_see('y=2')
+
+
 def test_automatic_cleanup(screen: Screen):
     class Model:
         value = binding.BindableProperty()