Repository: zauberzeug/nicegui
Pull request #3987 (2024-11-13 15:56:13)

Introduce bindable dataclass fields
This PR follows up an idea #3957 to introduce bindable `dataclasses.dataclass` fields.

The main challenge is to marry `bindableProperty` with `dataclasses.field`, and by that preserve native dataclass features.
The proposed idea is to use a wrapper around dataclasses.field that updates passed `metadata`, adding nicegui-specific options (for now just a "bindable" flag). Then use it to add `bindableProperties` retroactively on dataclass type postprocessing (much like it is done in [ dataclasses_json.config](https://github.com/lidatong/dataclasses-json?tab=readme-ov-file#encode-or-decode-from-camelcase-or-kebab-case))

# Usage example
```py
@bindable_dataclass
@dataclass
class MyClass:
    x: float = dataclass_bindable_field(default=1.0)
```

# Known tradeoffs
- Access to default field value through class attribute (e.g. `MyClass.x`) is lost.

balex89: > But if 99% of our users want to mark all fields as bindable anyway, your initial approach of converting all fields would be much simpler and nicer to use

@falkoschindler, I do agree on making that a default behavior. And yet, shouldn't we leave an option to specify fields explicitly for those 1%? For instance: 
```py
@bindable_dataclass
class A:
    x: int  # bindable
    y: int  # bindable
    z: int  # bindable

@bindable_dataclass(bindable_fields=['x', 'y'])
class B:
    x: int  # bindable
    y: int  # bindable
    z: int  # not bindable
```
That doesn't seem to complicate implementation or interface much while providing full control on attribute picking, if ever needed.

As was said above, we forward additional `kwargs` to `dataclass()`.
falkoschindler: @balex89 You're right. Even though we could add such an additional attribute later on demand, we can as well add it right now. 👍
balex89: @falkoschindler, I pushed some improvements. Futher down your list:

1. Type hints optimized.
2. Using `slots=True` now forbidden and raises a `ValueError`. Also i tested every other option and found `frozen=True` also complains about attribute setting. So now both this options are handled.
3. As far as i understand, your test does not perform as you might expect because of recursive value update propagation. When you, say, use a `slider`, this event calls `_propagate` on  `slider` value, which calls it on dataclass field value, witch eventually immediately updates `number` value. 
I added this code lines to your test to make difference observable:
    ```py
    ui.timer(0.2, lambda: list(map(
        lambda attr, value: setattr(a, attr, value),
        ['x', 'y'], [(a.x + 1) % 11] * 2
    )))
    
    ui.run(binding_refresh_interval=2.0)
    ```
    Here i automatically update both fields rapidly. Setting a huge `binding_refresh_interval` will show the difference. 
balex89: Hi @falkoschindler!
Issues with mypy seem to be fixed now. 

Turns out I initially misused `dataclass_transform` arbitrary param option. It has nothing to do with dataclass decorator params extension.

Patch:
diff --git a/nicegui/__init__.py b/nicegui/__init__.py
index a3b27dfe..6a1cff6a 100644
--- a/nicegui/__init__.py
+++ b/nicegui/__init__.py
@@ -1,4 +1,4 @@
-from . import elements, html, run, storage, ui
+from . import binding, elements, html, run, storage, ui
 from .api_router import APIRouter
 from .app.app import App
 from .client import Client
@@ -16,6 +16,7 @@ __all__ = [
     'Tailwind',
     '__version__',
     'app',
+    'binding',
     'context',
     'elements',
     'html',
diff --git a/nicegui/binding.py b/nicegui/binding.py
index 1854cc93..d047d958 100644
--- a/nicegui/binding.py
+++ b/nicegui/binding.py
@@ -1,18 +1,42 @@
+from __future__ import annotations
+
 import asyncio
+import dataclasses
 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 (
+    TYPE_CHECKING,
+    Any,
+    Callable,
+    DefaultDict,
+    Dict,
+    Iterable,
+    List,
+    Mapping,
+    Optional,
+    Set,
+    Tuple,
+    Type,
+    TypeVar,
+    Union,
+)
+
+from typing_extensions import dataclass_transform
 
 from . import core
 from .logging import log
 
+if TYPE_CHECKING:
+    from _typeshed import DataclassInstance, IdentityFunction
+
 MAX_PROPAGATION_TIME = 0.01
 
 bindings: DefaultDict[Tuple[int, str], List] = defaultdict(list)
 bindable_properties: Dict[Tuple[int, str], Any] = {}
 active_links: List[Tuple[Any, str, Any, str, Callable[[Any], Any]]] = []
 
+T = TypeVar('T', bound=type)
+
 
 def _has_attribute(obj: Union[object, Mapping], name: str) -> Any:
     if isinstance(obj, Mapping):
@@ -187,3 +211,44 @@ def reset() -> None:
     bindings.clear()
     bindable_properties.clear()
     active_links.clear()
+
+
+@dataclass_transform()
+def bindable_dataclass(cls: Optional[T] = 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.
+
+    This decorator extends the functionality of ``dataclasses.dataclass`` by making specified fields bindable.
+    If ``bindable_fields`` is provided, only the listed fields are made bindable.
+    Otherwise, all fields are made bindable by default.
+
+    *Added in version 2.11.0*
+
+    :param cls: class to be transformed into a dataclass
+    :param bindable_fields: optional list of field names to make bindable (defaults to all fields)
+    :param kwargs: optional keyword arguments to be forwarded to ``dataclasses.dataclass``.
+    Usage of ``slots=True`` and ``frozen=True`` are not supported and will raise a ValueError.
+
+    :return: resulting dataclass type
+    """
+    if cls is None:
+        def wrap(cls_):
+            return bindable_dataclass(cls_, bindable_fields=bindable_fields, **kwargs)
+        return wrap
+
+    for unsupported_option in ('slots', 'frozen'):
+        if kwargs.get(unsupported_option):
+            raise ValueError(f'`{unsupported_option}=True` is not supported with bindable_dataclass')
+
+    dataclass: Type[DataclassInstance] = dataclasses.dataclass(**kwargs)(cls)
+    field_names = set(field.name for field in dataclasses.fields(dataclass))
+    if bindable_fields is None:
+        bindable_fields = field_names
+    for field_name in bindable_fields:
+        if field_name not in field_names:
+            raise ValueError(f'"{field_name}" is not a dataclass field')
+        bindable_property = BindableProperty()
+        bindable_property.__set_name__(dataclass, field_name)
+        setattr(dataclass, field_name, bindable_property)
+    return dataclass
diff --git a/website/documentation/content/overview.py b/website/documentation/content/overview.py
index 1ca72a6a..ecbd75a1 100644
--- a/website/documentation/content/overview.py
+++ b/website/documentation/content/overview.py
@@ -451,6 +451,14 @@ def map_of_nicegui():
         - [`run.cpu_bound()`](/documentation/section_action_events#running_cpu-bound_tasks): run a CPU-bound function in a separate process
         - [`run.io_bound()`](/documentation/section_action_events#running_i_o-bound_tasks): run an IO-bound function in a separate thread
 
+        #### `binding`
+
+        [Bind properties of objects to each other](/documentation/section_binding_properties).
+
+        - [`binding.BindableProperty`](/documentation/section_binding_properties#bindable_properties_for_maximum_performance): bindable properties for maximum performance
+        - [`binding.bindable_dataclass()`](/documentation/section_binding_properties#bindable_dataclass): create a dataclass with bindable properties
+        - `binding.bind()`, `binding.bind_from()`, `binding.bind_to()`: methods to bind two properties
+
         #### `observables`
 
         Observable collections that notify observers when their contents change.
diff --git a/website/documentation/content/section_binding_properties.py b/website/documentation/content/section_binding_properties.py
index 63908fb2..3887fc5b 100644
--- a/website/documentation/content/section_binding_properties.py
+++ b/website/documentation/content/section_binding_properties.py
@@ -111,3 +111,25 @@ def bindable_properties():
     ui.slider(min=1, max=3).bind_value(demo, 'number')
     ui.toggle({1: 'A', 2: 'B', 3: 'C'}).bind_value(demo, 'number')
     ui.number(min=1, max=3).bind_value(demo, 'number')
+
+
+@doc.demo('Bindable dataclass', '''
+    The `bindable_dataclass` decorator provides a convenient way to create classes with bindable properties.
+    It extends the functionality of Python's standard `dataclasses.dataclass` decorator
+    by automatically making all dataclass fields bindable.
+    This eliminates the need to manually declare each field as a `BindableProperty`
+    while retaining all the benefits of regular dataclasses.
+
+    *Added in version 2.11.0*
+''')
+def bindable_dataclass():
+    from nicegui import binding
+
+    @binding.bindable_dataclass
+    class Demo:
+        number: int = 1
+
+    demo = Demo()
+    ui.slider(min=1, max=3).bind_value(demo, 'number')
+    ui.toggle({1: 'A', 2: 'B', 3: 'C'}).bind_value(demo, 'number')
+    ui.number(min=1, max=3).bind_value(demo, 'number')


Test patch:
diff --git a/tests/test_binding.py b/tests/test_binding.py
index 0aa015db..2c0ad2e3 100644
--- a/tests/test_binding.py
+++ b/tests/test_binding.py
@@ -2,7 +2,7 @@ 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
 
 
@@ -105,3 +105,23 @@ def test_missing_target_attribute(screen: Screen):
 
     screen.open('/')
     screen.should_contain("text='Hello'")
+
+
+def test_bindable_dataclass(screen: Screen):
+    @binding.bindable_dataclass(bindable_fields=['bindable'])
+    class TestClass:
+        not_bindable: str = 'not_bindable_text'
+        bindable: str = 'bindable_text'
+
+    instance = TestClass()
+
+    ui.label().bind_text_from(instance, 'not_bindable')
+    ui.label().bind_text_from(instance, 'bindable')
+
+    screen.open('/')
+    screen.should_contain('not_bindable_text')
+    screen.should_contain('bindable_text')
+
+    assert len(binding.bindings) == 2
+    assert len(binding.active_links) == 1
+    assert binding.active_links[0][1] == 'not_bindable'