Repository: zauberzeug/nicegui
Pull request #4251 (2025-01-22 12:24:47)

Copyable classes with bindable properties
This PR follows #3995 issue discussion. 

I went with [Option  3](https://github.com/zauberzeug/nicegui/issues/3995#issuecomment-2598113869) for a list of reasons:
1. It uncompromisingly covers all aspects of the issue, including tracking of bindable attributes;
2. It doesn't really affect performance much, if done right (see below);
3. It isn't in fact as ridiculously complicated in terms of implementation as it might seem, once you see that it only adds a wrapper for function that reproduces an object instance. 

## Impact on performance 

Since we modify `BindableProperty` setter method, i ran a simple test to measure impact on code performance when object is a) initialized and b) updated:
```py
class TestClass:

    x = BindableProperty()

    def __init__(self):
        self.x = 1

    def update(self):
        self.x = 2

init_time = timeit.timeit(lambda: TestClass(), number=1_000_000)
a = TestClass()
update_time = timeit.timeit(lambda: a.update(), number=1_000_000)
print(f'creation: {init_time:.6f} s, update: {update_time:.6f} s')
````
I ran this test several times for the current and updated `BindableProperty` implementations and got following average figures:
1. For initialization it adds up to 5% performance time on average;
2. For update it adds ~1% (or less) time on average.

Since a user, aiming to create millions of instances, each meant for binding, will probably face more sever issues for other reasons, the real-life overhead seems to be insignificant.


Patch:
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)


Test patch:
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()