Skip to content

Commit

Permalink
Introduce bindable dataclass fields (#3987)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
balex89 and falkoschindler authored Jan 23, 2025
1 parent 5032ca4 commit 1fb2d12
Show file tree
Hide file tree
Showing 5 changed files with 120 additions and 4 deletions.
3 changes: 2 additions & 1 deletion nicegui/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,6 +16,7 @@
'Tailwind',
'__version__',
'app',
'binding',
'context',
'elements',
'html',
Expand Down
69 changes: 67 additions & 2 deletions nicegui/binding.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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
22 changes: 21 additions & 1 deletion tests/test_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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 +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 changes: 8 additions & 0 deletions website/documentation/content/overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions website/documentation/content/section_binding_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,25 @@ def __init__(self):
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')

0 comments on commit 1fb2d12

Please sign in to comment.