Selaa lähdekoodia

Introduce bindable dataclass fields (#3987)

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.

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
Aleksey Bobylev 3 kuukautta sitten
vanhempi
säilyke
1fb2d12eef

+ 2 - 1
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',

+ 67 - 2
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

+ 21 - 1
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'

+ 8 - 0
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.

+ 22 - 0
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')