Browse Source

Copyable classes with bindable properties (#4251)

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.

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
Aleksey Bobylev 2 months ago
parent
commit
743c205364
2 changed files with 53 additions and 3 deletions
  1. 26 2
      nicegui/binding.py
  2. 27 1
      tests/test_binding.py

+ 26 - 2
nicegui/binding.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import asyncio
 import asyncio
+import copyreg
 import dataclasses
 import dataclasses
 import time
 import time
 import weakref
 import weakref
@@ -36,7 +37,8 @@ bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], weakref.finalize] = {}
 bindable_properties: Dict[Tuple[int, str], weakref.finalize] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 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:
 def _has_attribute(obj: Union[object, Mapping], name: str) -> Any:
@@ -170,6 +172,8 @@ class BindableProperty:
 
 
     def __set__(self, owner: Any, value: Any) -> None:
     def __set__(self, owner: Any, value: Any) -> None:
         has_attr = hasattr(owner, '___' + self.name)
         has_attr = hasattr(owner, '___' + self.name)
+        if not has_attr:
+            _make_copyable(type(owner))
         value_changed = has_attr and getattr(owner, '___' + self.name) != value
         value_changed = has_attr and getattr(owner, '___' + self.name) != value
         if has_attr and not value_changed:
         if has_attr and not value_changed:
             return
             return
@@ -217,7 +221,7 @@ def reset() -> None:
 
 
 
 
 @dataclass_transform()
 @dataclass_transform()
-def bindable_dataclass(cls: Optional[T] = None, /, *,
+def bindable_dataclass(cls: Optional[TC] = None, /, *,
                        bindable_fields: Optional[Iterable[str]] = None,
                        bindable_fields: Optional[Iterable[str]] = None,
                        **kwargs: Any) -> Union[Type[DataclassInstance], IdentityFunction]:
                        **kwargs: Any) -> Union[Type[DataclassInstance], IdentityFunction]:
     """A decorator that transforms a class into a dataclass with bindable fields.
     """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)
         bindable_property.__set_name__(dataclass, field_name)
         setattr(dataclass, field_name, bindable_property)
         setattr(dataclass, field_name, bindable_property)
     return dataclass
     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)

+ 27 - 1
tests/test_binding.py

@@ -1,10 +1,11 @@
+import copy
 import weakref
 import weakref
 from typing import Dict, Optional, Tuple
 from typing import Dict, Optional, Tuple
 
 
 from selenium.webdriver.common.keys import Keys
 from selenium.webdriver.common.keys import Keys
 
 
 from nicegui import binding, ui
 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):
 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'
     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):
 def test_automatic_cleanup(screen: Screen):
     class Model:
     class Model:
         value = binding.BindableProperty()
         value = binding.BindableProperty()