Skip to content

Commit f9d1d4d

Browse files
committed
Copyable classes with bindable properties
1 parent 1fb2d12 commit f9d1d4d

File tree

2 files changed

+68
-4
lines changed

2 files changed

+68
-4
lines changed

nicegui/binding.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import copyreg
45
import dataclasses
56
import time
67
from collections import defaultdict
@@ -33,10 +34,11 @@
3334

3435
bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
3536
bindable_properties: Dict[Tuple[int, str], Any] = {}
37+
copyable_classes: Set[Type] = set()
3638
active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
3739

38-
T = TypeVar('T', bound=type)
39-
40+
TC = TypeVar('TC', bound=type)
41+
T = TypeVar('T')
4042

4143
def _has_attribute(obj: Union[object, Mapping], name: str) -> Any:
4244
if isinstance(obj, Mapping):
@@ -169,6 +171,8 @@ def __get__(self, owner: Any, _=None) -> Any:
169171

170172
def __set__(self, owner: Any, value: Any) -> None:
171173
has_attr = hasattr(owner, '___' + self.name)
174+
if not has_attr and type(owner) not in copyable_classes:
175+
_make_copyable(type(owner))
172176
value_changed = has_attr and getattr(owner, '___' + self.name) != value
173177
if has_attr and not value_changed:
174178
return
@@ -214,7 +218,7 @@ def reset() -> None:
214218

215219

216220
@dataclass_transform()
217-
def bindable_dataclass(cls: Optional[T] = None, /, *,
221+
def bindable_dataclass(cls: Optional[TC] = None, /, *,
218222
bindable_fields: Optional[Iterable[str]] = None,
219223
**kwargs: Any) -> Union[Type[DataclassInstance], IdentityFunction]:
220224
"""A decorator that transforms a class into a dataclass with bindable fields.
@@ -252,3 +256,41 @@ def wrap(cls_):
252256
bindable_property.__set_name__(dataclass, field_name)
253257
setattr(dataclass, field_name, bindable_property)
254258
return dataclass
259+
260+
261+
def _register_bindables(original_obj: T, copy_obj: T) -> None:
262+
"""Ensure BindableProperties of an object copy are registered correctly.
263+
264+
:param original_obj: The object that was copied.
265+
:param original_obj: The object copy.
266+
"""
267+
for attr_name in dir(original_obj):
268+
if (id(original_obj), attr_name) in bindable_properties:
269+
bindable_properties[(id(copy_obj), attr_name)] = copy_obj
270+
271+
272+
def _register_bindables_pickle_function(obj: T) -> Tuple[Callable[..., T], Tuple[Any, ...]]:
273+
"""Construct the "reduce tuple" of an object with a wrapped pickle function, that registers bindable attributes"
274+
275+
:param obj: The object to be reduced.
276+
"""
277+
reduced = obj.__reduce__()
278+
if isinstance(reduced, str):
279+
raise ValueError('Unexpected __reduce__() return type: str')
280+
creator = reduced[0]
281+
282+
def creator_with_hook(*args, **kwargs) -> T:
283+
obj_copy = creator(*args, **kwargs)
284+
_register_bindables(obj, obj_copy)
285+
return obj_copy
286+
287+
return (creator_with_hook,) + reduced[1:]
288+
289+
290+
def _make_copyable(cls: Type[T]) -> None:
291+
"""Modify the way `copy` module handles class instances so that `BindableProperty` attributes preserve bindability.
292+
293+
:param cls: The class to modify.
294+
"""
295+
copyreg.pickle(cls, _register_bindables_pickle_function)
296+
copyable_classes.add(cls)

tests/test_binding.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import copy
12
from typing import Dict, Optional, Tuple
23

34
from selenium.webdriver.common.keys import Keys
45

56
from nicegui import binding, ui
6-
from nicegui.testing import Screen
7+
from nicegui.testing import Screen, User
78

89

910
def test_ui_select_with_tuple_as_key(screen: Screen):
@@ -125,3 +126,24 @@ class TestClass:
125126
assert len(binding.bindings) == 2
126127
assert len(binding.active_links) == 1
127128
assert binding.active_links[0][1] == 'not_bindable'
129+
130+
131+
def test_copy_instance_with_bindable_property(user: User):
132+
class TestClass:
133+
x = binding.BindableProperty()
134+
y = binding.BindableProperty()
135+
136+
def __init__(self):
137+
self.x = 1
138+
self.y = 2
139+
140+
original = TestClass()
141+
duplicate = copy.copy(original)
142+
143+
ui.number().bind_value_from(original, 'x')
144+
ui.number().bind_value_from(original, 'y')
145+
ui.number().bind_value_from(duplicate, 'x')
146+
ui.number().bind_value_from(duplicate, 'y')
147+
148+
assert len(binding.bindings) == 4
149+
assert len(binding.active_links) == 0

0 commit comments

Comments
 (0)