Skip to content

Commit

Permalink
Copyable classes with bindable properties
Browse files Browse the repository at this point in the history
  • Loading branch information
balex89 committed Jan 22, 2025
1 parent 89ff381 commit bc67683
Show file tree
Hide file tree
Showing 2 changed files with 64 additions and 2 deletions.
45 changes: 44 additions & 1 deletion nicegui/binding.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import asyncio
import copyreg
import time
from collections import defaultdict
from collections.abc import Mapping
from typing import Any, Callable, DefaultDict, Dict, Iterable, List, Optional, Set, Tuple, Union
from typing import Any, Callable, DefaultDict, Dict, Iterable, List, Optional, Set, Tuple, Type, TypeVar, Union

from . import core
from .logging import log
Expand All @@ -11,8 +12,10 @@

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

T = TypeVar('T')

def _has_attribute(obj: Union[object, Mapping], name: str) -> Any:
if isinstance(obj, Mapping):
Expand Down Expand Up @@ -145,6 +148,8 @@ def __get__(self, owner: Any, _=None) -> Any:

def __set__(self, owner: Any, value: Any) -> None:
has_attr = hasattr(owner, '___' + self.name)
if not has_attr and type(owner) not in copyable_classes:
_make_copyable(type(owner))
value_changed = has_attr and getattr(owner, '___' + self.name) != value
if has_attr and not value_changed:
return
Expand Down Expand Up @@ -187,3 +192,41 @@ def reset() -> None:
bindings.clear()
bindable_properties.clear()
active_links.clear()


def _register_bindables(original_obj: T, copy_obj: T) -> None:
"""Ensure BindableProperties of an object copy are registered correctly.
:param original_obj: The object that was copied.
:param original_obj: The object copy.
"""
for attr_name in dir(original_obj):
if (id(original_obj), attr_name) in bindable_properties:
bindable_properties[(id(copy_obj), attr_name)] = copy_obj


def _register_bindables_pickle_function(obj: T) -> Tuple[Callable[..., T], Tuple[Any, ...]]:
"""Construct the "reduce tuple" of an object with a wrapped pickle function, that registers bindable attributes"
:param obj: The object to be reduced.
"""
reduced = obj.__reduce__()
if isinstance(reduced, str):
raise ValueError('Unexpected __reduce__() return type: str')
creator = reduced[0]

def creator_with_hook(*args, **kwargs) -> T:
obj_copy = creator(*args, **kwargs)
_register_bindables(obj, obj_copy)
return obj_copy

return (creator_with_hook,) + reduced[1:]


def _make_copyable(cls: Type[T]) -> None:
"""Modify the way `copy` module handles class instances so that `BindableProperty` attributes preserve bindability.
:param cls: The class to modify.
"""
copyreg.pickle(cls, _register_bindables_pickle_function)
copyable_classes.add(cls)
21 changes: 20 additions & 1 deletion tests/test_binding.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import copy
from typing import Dict, Optional, Tuple

from selenium.webdriver.common.keys import Keys

from nicegui import ui
from nicegui import binding, ui
from nicegui.testing import Screen


Expand Down Expand Up @@ -105,3 +106,21 @@ def test_missing_target_attribute(screen: Screen):

screen.open('/')
screen.should_contain("text='Hello'")


def test_copy_instance_with_bindable_property():
class TestClass:
x = binding.BindableProperty()
y = binding.BindableProperty()

def __init__(self):
self.x = 1
self.y = 2

original = TestClass()
duplicate = copy.copy(original)

assert (id(original), 'x') in binding.bindable_properties
assert (id(original), 'y') in binding.bindable_properties
assert (id(duplicate), 'x') in binding.bindable_properties
assert (id(duplicate), 'y') in binding.bindable_properties

0 comments on commit bc67683

Please sign in to comment.