Refactored Binding for better concern consideration

This commit is contained in:
2025-11-02 18:46:44 +01:00
parent 7553c28f8e
commit 9696e67910
7 changed files with 459 additions and 84 deletions

View File

@@ -1,8 +1,8 @@
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.core.bindings import Binding from myfasthtml.core.bindings import Binding, BooleanConverter, DetectionMode, UpdateMode
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.utils import merge_classes, get_default_ft_attr from myfasthtml.core.utils import merge_classes, get_default_ft_attr, is_checkbox
class mk: class mk:
@@ -37,17 +37,28 @@ class mk:
@staticmethod @staticmethod
def manage_binding(ft, binding: Binding): def manage_binding(ft, binding: Binding):
if binding: if not binding:
if ft.tag in ["input"]: return ft
# update the component to post on the correct route input and forms only
htmx = binding.get_htmx_params()
ft.attrs |= htmx
# update the binding with the ft
ft_attr = binding.ft_attr or get_default_ft_attr(ft)
ft_name = ft.attrs.get("name")
binding.bind_ft(ft, ft_name, ft_attr) # force the ft
if ft.tag in ["input"]:
# update the component to post on the correct route input and forms only
htmx = binding.get_htmx_params()
ft.attrs |= htmx
# update the binding with the ft
ft_attr = binding.ft_attr or get_default_ft_attr(ft)
ft_name = ft.attrs.get("name")
if is_checkbox(ft):
data_converter = BooleanConverter()
detection_mode = DetectionMode.AttributePresence
update_mode = UpdateMode.AttributePresence
else:
data_converter = None
detection_mode = None
update_mode = None
binding.bind_ft(ft, ft_name, ft_attr, data_converter, detection_mode, update_mode) # force the ft
return ft return ft
@staticmethod @staticmethod

View File

@@ -1,10 +1,10 @@
import logging import logging
import uuid import uuid
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional, Any
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from myutils.observable import make_observable, bind, collect_return_values from myutils.observable import make_observable, bind, collect_return_values, unbind
from myfasthtml.core.constants import Routes, ROUTE_ROOT from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.utils import get_default_attr from myfasthtml.core.utils import get_default_attr
@@ -105,44 +105,34 @@ class BooleanConverter(DataConverter):
class Binding: class Binding:
def __init__(self, data, def __init__(self, data: Any, attr: str = None):
attr=None,
data_converter: DataConverter = None,
ft=None,
ft_name=None,
ft_attr=None,
detection_mode: DetectionMode = DetectionMode.ValueChange,
update_mode: UpdateMode = UpdateMode.ValueChange):
""" """
Creates a new binding object between a data object used as a pivot and an HTML element. Creates a new binding object between a data object and an HTML element.
The same pivot object must be used for different bindings. The binding is not active until bind_ft() is called.
This will allow the binding between the HTML elements
Args:
:param data: object used as a pivot data: Object used as a pivot
:param attr: attribute of the data object attr: Attribute of the data object to bind
:param ft: HTML element to bind to
:param ft_name: name of the HTML element to bind to (send by the form)
:param ft_attr: value of the attribute to bind to (send by the form)
""" """
self.id = uuid.uuid4() self.id = uuid.uuid4()
self.htmx_extra = {} self.htmx_extra = {}
self.data = data self.data = data
self.data_attr = attr or get_default_attr(data) self.data_attr = attr or get_default_attr(data)
self.data_converter = data_converter
self.ft = self._safe_ft(ft)
self.ft_name = ft_name
self.ft_attr = ft_attr
self.detection_mode = detection_mode
self.update_mode = update_mode
self._detection = self._factory(detection_mode) # UI-related attributes (configured later via bind_ft)
self._update = self._factory(update_mode) self.ft = None
self.ft_name = None
self.ft_attr = None
self.data_converter = None
self.detection_mode = DetectionMode.ValueChange
self.update_mode = UpdateMode.ValueChange
make_observable(self.data) # Strategy objects (configured later)
bind(self.data, self.data_attr, self.notify) self._detection = None
self._update = None
# register the command # Activation state
BindingsManager.register(self) self._is_active = False
def bind_ft(self, def bind_ft(self,
ft, ft,
@@ -152,25 +142,43 @@ class Binding:
detection_mode: DetectionMode = None, detection_mode: DetectionMode = None,
update_mode: UpdateMode = None): update_mode: UpdateMode = None):
""" """
Update the elements to bind to Configure the UI element and activate the binding.
:param ft:
:param name: Args:
:param attr: ft: HTML element to bind to
:param data_converter: name: Name of the HTML element (sent by the form)
:param detection_mode: attr: Attribute of the HTML element to bind to
:param update_mode: data_converter: Optional converter for data transformation
:return: detection_mode: How to detect changes from UI
update_mode: How to update the UI element
Returns:
self for method chaining
""" """
# Deactivate if already active
if self._is_active:
self.deactivate()
# Configure UI elements
self.ft = self._safe_ft(ft) self.ft = self._safe_ft(ft)
self.ft_name = name self.ft_name = name
self.ft_attr = attr or self.ft_attr self.ft_attr = attr
self.data_converter = data_converter or self.data_converter
self.detection_mode = detection_mode or self.detection_mode
self.update_mode = update_mode or self.update_mode
# Update optional parameters if provided
if data_converter is not None:
self.data_converter = data_converter
if detection_mode is not None:
self.detection_mode = detection_mode
if update_mode is not None:
self.update_mode = update_mode
# Create strategy objects
self._detection = self._factory(self.detection_mode) self._detection = self._factory(self.detection_mode)
self._update = self._factory(self.update_mode) self._update = self._factory(self.update_mode)
# Activate the binding
self.activate()
return self return self
def get_htmx_params(self): def get_htmx_params(self):
@@ -180,6 +188,21 @@ class Binding:
} }
def notify(self, old, new): def notify(self, old, new):
"""
Callback when the data attribute changes.
Updates the UI element accordingly.
Args:
old: Previous value
new: New value
Returns:
Updated ft element
"""
if not self._is_active:
logger.warning(f"Binding '{self.id}' received notification but is not active")
return None
logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'") logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'")
self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, new) self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, new)
@@ -198,6 +221,53 @@ class Binding:
logger.debug(f"Nothing to trigger in {values}.") logger.debug(f"Nothing to trigger in {values}.")
return None return None
def activate(self):
"""
Activate the binding by setting up observers and registering it.
Should only be called after the binding is fully configured.
Raises:
ValueError: If the binding is not fully configured
"""
if self._is_active:
logger.warning(f"Binding '{self.id}' is already active")
return
# Validate configuration
self._validate_configuration()
# Setup observable
make_observable(self.data)
bind(self.data, self.data_attr, self.notify)
# Register in manager
BindingsManager.register(self)
# Mark as active
self._is_active = True
logger.debug(f"Binding '{self.id}' activated for {self.data_attr}")
def deactivate(self):
"""
Deactivate the binding by removing observers and unregistering it.
Can be called multiple times safely.
"""
if not self._is_active:
logger.debug(f"Binding '{self.id}' is not active, nothing to deactivate")
return
# Remove observer
unbind(self.data, self.data_attr, self.notify)
# Unregister from manager
BindingsManager.unregister(self.id)
# Mark as inactive
self._is_active = False
logger.debug(f"Binding '{self.id}' deactivated")
@staticmethod @staticmethod
def _safe_ft(ft): def _safe_ft(ft):
""" """
@@ -228,6 +298,25 @@ class Binding:
else: else:
raise ValueError(f"Invalid detection mode: {mode}") raise ValueError(f"Invalid detection mode: {mode}")
def _validate_configuration(self):
"""
Validate that the binding is fully configured before activation.
Raises:
ValueError: If required configuration is missing
"""
if self.ft is None:
raise ValueError(f"Binding '{self.id}': ft element is required")
# if self.ft_name is None:
# raise ValueError(f"Binding '{self.id}': ft_name is required")
if self._detection is None:
raise ValueError(f"Binding '{self.id}': detection strategy not initialized")
if self._update is None:
raise ValueError(f"Binding '{self.id}': update strategy not initialized")
def htmx(self, trigger=None): def htmx(self, trigger=None):
if trigger: if trigger:
self.htmx_extra["hx-trigger"] = trigger self.htmx_extra["hx-trigger"] = trigger
@@ -241,6 +330,17 @@ class BindingsManager:
def register(binding: Binding): def register(binding: Binding):
BindingsManager.bindings[str(binding.id)] = binding BindingsManager.bindings[str(binding.id)] = binding
@staticmethod
def unregister(binding_id: str):
"""
Unregister a binding from the manager.
Args:
binding_id: ID of the binding to unregister
"""
if str(binding_id) in BindingsManager.bindings:
del BindingsManager.bindings[str(binding_id)]
@staticmethod @staticmethod
def get_binding(binding_id: str) -> Optional[Binding]: def get_binding(binding_id: str) -> Optional[Binding]:
return BindingsManager.bindings.get(str(binding_id)) return BindingsManager.bindings.get(str(binding_id))

View File

@@ -1,9 +1,12 @@
import logging import logging
from bs4 import Tag
from fastcore.xml import FT
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
from myfasthtml.core.constants import Routes, ROUTE_ROOT from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.test.MyFT import MyFT
utils_app, utils_rt = fast_app() utils_app, utils_rt = fast_app()
logger = logging.getLogger("Commands") logger = logging.getLogger("Commands")
@@ -101,6 +104,15 @@ def get_default_attr(data):
return next(iter(all_attrs)) return next(iter(all_attrs))
def is_checkbox(elt):
if isinstance(elt, (FT, MyFT)):
return elt.tag == "input" and elt.attrs.get("type", None) == "checkbox"
elif isinstance(elt, Tag):
return elt.name == "input" and elt.attrs.get("type", None) == "checkbox"
else:
return False
@utils_rt(Routes.Commands) @utils_rt(Routes.Commands)
def post(session: str, c_id: str): def post(session: str, c_id: str):
""" """

View File

@@ -0,0 +1,9 @@
from dataclasses import dataclass, field
@dataclass
class MyFT:
tag: str
attrs: dict
children: list['MyFT'] = field(default_factory=list)
text: str | None = None

View File

@@ -1,7 +1,5 @@
import dataclasses
import json import json
import uuid import uuid
from dataclasses import dataclass
from typing import Self from typing import Self
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
@@ -11,6 +9,7 @@ from starlette.responses import Response
from starlette.testclient import TestClient from starlette.testclient import TestClient
from myfasthtml.core.utils import mount_utils from myfasthtml.core.utils import mount_utils
from myfasthtml.test.MyFT import MyFT
verbs = { verbs = {
'hx_get': 'GET', 'hx_get': 'GET',
@@ -21,14 +20,6 @@ verbs = {
} }
@dataclass
class MyFT:
tag: str
attrs: dict
children: list['MyFT'] = dataclasses.field(default_factory=list)
text: str | None = None
class TestableElement: class TestableElement:
""" """
Represents an HTML element that can be interacted with in tests. Represents an HTML element that can be interacted with in tests.

View File

@@ -4,7 +4,13 @@ import pytest
from fasthtml.components import Label, Input from fasthtml.components import Label, Input
from myutils.observable import collect_return_values from myutils.observable import collect_return_values
from myfasthtml.core.bindings import BindingsManager, Binding, DetectionMode from myfasthtml.core.bindings import (
BindingsManager,
Binding,
DetectionMode,
UpdateMode,
BooleanConverter
)
@dataclass @dataclass
@@ -39,12 +45,15 @@ def test_i_can_register_a_binding_with_default_attr(data):
def test_i_can_retrieve_a_registered_binding(data): def test_i_can_retrieve_a_registered_binding(data):
binding = Binding(data) elt = Label("hello", id="label_id")
binding = Binding(data).bind_ft(elt, name="label_name")
assert BindingsManager.get_binding(binding.id) is binding assert BindingsManager.get_binding(binding.id) is binding
def test_i_can_reset_bindings(data): def test_i_can_reset_bindings(data):
Binding(data) elt = Label("hello", id="label_id")
Binding(data).bind_ft(elt, name="label_name")
assert len(BindingsManager.bindings) != 0 assert len(BindingsManager.bindings) != 0
BindingsManager.reset() BindingsManager.reset()
@@ -53,7 +62,7 @@ def test_i_can_reset_bindings(data):
def test_i_can_bind_an_element_to_a_binding(data): def test_i_can_bind_an_element_to_a_binding(data):
elt = Label("hello", id="label_id") elt = Label("hello", id="label_id")
Binding(data, ft=elt) Binding(data).bind_ft(elt, name="label_name")
data.value = "new value" data.value = "new value"
@@ -63,9 +72,9 @@ def test_i_can_bind_an_element_to_a_binding(data):
def test_i_can_bind_an_element_attr_to_a_binding(data): def test_i_can_bind_an_element_attr_to_a_binding(data):
elt = Input(value="somme value", id="input_id") elt = Input(value="some value", id="input_id")
Binding(data, ft=elt, ft_attr="value") Binding(data).bind_ft(elt, name="input_name", attr="value")
data.value = "new value" data.value = "new value"
@@ -78,13 +87,13 @@ def test_bound_element_has_an_id():
elt = Label("hello") elt = Label("hello")
assert elt.attrs.get("id", None) is None assert elt.attrs.get("id", None) is None
Binding(Data(), ft=elt) Binding(Data()).bind_ft(elt, name="label_name")
assert elt.attrs.get("id", None) is not None assert elt.attrs.get("id", None) is not None
def test_i_can_collect_updates_values(data): def test_i_can_collect_updates_values(data):
elt = Label("hello") elt = Label("hello")
Binding(data, ft=elt) Binding(data).bind_ft(elt, name="label_name")
data.value = "new value" data.value = "new value"
collected = collect_return_values(data) collected = collect_return_values(data)
@@ -98,7 +107,7 @@ def test_i_can_collect_updates_values(data):
def test_i_can_react_to_value_change(data): def test_i_can_react_to_value_change(data):
elt = Input(name="input_elt", value="hello") elt = Input(name="input_elt", value="hello")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="value") binding = Binding(data).bind_ft(elt, name="input_elt", attr="value")
res = binding.update({"input_elt": "new value"}) res = binding.update({"input_elt": "new value"})
@@ -107,7 +116,7 @@ def test_i_can_react_to_value_change(data):
def test_i_do_not_react_to_other_value_change(data): def test_i_do_not_react_to_other_value_change(data):
elt = Input(name="input_elt", value="hello") elt = Input(name="input_elt", value="hello")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="value") binding = Binding(data).bind_ft(elt, name="input_elt", attr="value")
res = binding.update({"other_input_elt": "new value"}) res = binding.update({"other_input_elt": "new value"})
@@ -116,8 +125,12 @@ def test_i_do_not_react_to_other_value_change(data):
def test_i_can_react_to_attr_presence(data): def test_i_can_react_to_attr_presence(data):
elt = Input(name="input_elt", type="checkbox") elt = Input(name="input_elt", type="checkbox")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="checked", binding = Binding(data).bind_ft(
detection_mode=DetectionMode.AttributePresence) elt,
name="input_elt",
attr="checked",
detection_mode=DetectionMode.AttributePresence
)
res = binding.update({"checked": "true"}) res = binding.update({"checked": "true"})
@@ -126,9 +139,251 @@ def test_i_can_react_to_attr_presence(data):
def test_i_can_react_to_attr_non_presence(data): def test_i_can_react_to_attr_non_presence(data):
elt = Input(name="input_elt", type="checkbox") elt = Input(name="input_elt", type="checkbox")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="checked", binding = Binding(data).bind_ft(
detection_mode=DetectionMode.AttributePresence) elt,
name="input_elt",
attr="checked",
detection_mode=DetectionMode.AttributePresence
)
res = binding.update({}) res = binding.update({})
assert len(res) == 1 assert len(res) == 1
def test_i_can_create_a_binding_without_activation(data):
"""
A binding created without calling bind_ft should not be active.
"""
binding = Binding(data, "value")
assert binding._is_active is False
assert binding.ft is None
assert binding.ft_name is None
assert binding.ft_attr is None
assert BindingsManager.get_binding(binding.id) is None
def test_i_can_activate_binding_via_bind_ft(data):
"""
Calling bind_ft should automatically activate the binding.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.bind_ft(elt, name="label_name")
assert binding._is_active is True
assert binding.ft is elt
assert binding.ft_name == "label_name"
assert BindingsManager.get_binding(binding.id) is binding
def test_i_cannot_notify_when_not_active(data):
"""
A non-active binding should not update the UI when data changes.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.ft = elt
binding.ft_name = "label_name"
# Change data without activating the binding
result = binding.notify("old", "new")
assert result is None
assert elt.children[0] == "hello" # Should not have changed
def test_i_can_deactivate_a_binding(data):
"""
Deactivating a binding should clean up observers and unregister it.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
assert binding._is_active is True
assert BindingsManager.get_binding(binding.id) is binding
binding.deactivate()
assert binding._is_active is False
assert BindingsManager.get_binding(binding.id) is None
def test_i_can_reactivate_a_binding(data):
"""
After deactivation, a binding can be reactivated by calling bind_ft again.
"""
elt1 = Label("hello", id="label_id_1")
binding = Binding(data, "value").bind_ft(elt1, name="label_name_1")
binding.deactivate()
assert binding._is_active is False
elt2 = Label("world", id="label_id_2")
binding.bind_ft(elt2, name="label_name_2")
assert binding._is_active is True
assert binding.ft is elt2
assert binding.ft_name == "label_name_2"
def test_bind_ft_deactivates_before_reconfiguring(data):
"""
Calling bind_ft on an active binding should deactivate it first,
then reconfigure and reactivate.
"""
elt1 = Label("hello", id="label_id_1")
elt2 = Label("world", id="label_id_2")
binding = Binding(data, "value").bind_ft(elt1, name="label_name_1")
# Change data to verify old binding works
data.value = "updated"
assert elt1.children[0] == "updated"
# Reconfigure with new element
binding.bind_ft(elt2, name="label_name_2")
# Change data again
data.value = "final"
# Old element should not update
assert elt1.children[0] == "updated"
# New element should update
assert elt2.children[0] == "final"
def test_deactivate_can_be_called_multiple_times(data):
"""
Calling deactivate multiple times should be safe (idempotent).
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
binding.deactivate()
binding.deactivate() # Should not raise an error
binding.deactivate() # Should not raise an error
assert binding._is_active is False
def test_i_cannot_activate_without_configuration(data):
"""
Calling activate directly without proper configuration should raise ValueError.
"""
binding = Binding(data, "value")
with pytest.raises(ValueError, match="ft element is required"):
binding.activate()
def test_activation_validates_ft_name(data):
"""
Activation should fail if ft_name is not configured.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.ft = elt
binding._detection = binding._factory(DetectionMode.ValueChange)
binding._update = binding._factory(UpdateMode.ValueChange)
with pytest.raises(ValueError, match="ft_name is required"):
binding.activate()
def test_activation_validates_strategies(data):
"""
Activation should fail if detection/update strategies are not initialized.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.ft = elt
binding.ft_name = "label_name"
with pytest.raises(ValueError, match="detection strategy not initialized"):
binding.activate()
def test_i_can_chain_bind_ft_calls(data):
"""
bind_ft should return self for method chaining.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
assert isinstance(binding, Binding)
assert binding._is_active is True
def test_bind_ft_updates_optional_parameters(data):
"""
bind_ft should update optional parameters if provided.
"""
elt = Input(name="input_elt", type="checkbox")
binding = Binding(data, "value")
binding.bind_ft(
elt,
name="input_elt",
attr="checked",
data_converter=BooleanConverter(),
detection_mode=DetectionMode.AttributePresence,
update_mode=UpdateMode.AttributePresence
)
assert binding.detection_mode == DetectionMode.AttributePresence
assert binding.update_mode == UpdateMode.AttributePresence
assert isinstance(binding.data_converter, BooleanConverter)
def test_deactivated_binding_does_not_update_on_data_change(data):
"""
After deactivation, changes to data should not update the UI element.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
# Verify it works when active
data.value = "first update"
assert elt.children[0] == "first update"
# Deactivate
binding.deactivate()
# Change data - element should NOT update
data.value = "second update"
assert elt.children[0] == "first update"
def test_multiple_bindings_can_coexist(data):
"""
Multiple bindings can be created and managed independently.
"""
elt1 = Label("hello", id="label_id_1")
elt2 = Input(value="world", id="input_id_2")
binding1 = Binding(data, "value").bind_ft(elt1, name="label_name")
binding2 = Binding(data, "value").bind_ft(elt2, name="input_name", attr="value")
assert len(BindingsManager.bindings) == 2
assert binding1._is_active is True
assert binding2._is_active is True
# Change data - both should update
data.value = "updated"
assert elt1.children[0] == "updated"
assert elt2.attrs["value"] == "updated"
# Deactivate one
binding1.deactivate()
assert len(BindingsManager.bindings) == 1
# Change data - only binding2 should update
data.value = "final"
assert elt1.children[0] == "updated" # Not changed
assert elt2.attrs["value"] == "final" # Changed

View File

@@ -69,7 +69,7 @@ class TestingBindings:
data = Data("hello world") data = Data("hello world")
input_elt = Input(name="input_name") input_elt = Input(name="input_name")
label_elt = Label() label_elt = Label()
mk.manage_binding(input_elt, Binding(data, ft_attr="value")) mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data)) mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt return input_elt, label_elt
@@ -85,10 +85,7 @@ class TestingBindings:
data = Data(True) data = Data(True)
input_elt = Input(name="input_name", type="checkbox") input_elt = Input(name="input_name", type="checkbox")
label_elt = Label() label_elt = Label()
mk.manage_binding(input_elt, Binding(data, ft_attr="checked", mk.manage_binding(input_elt, Binding(data))
detection_mode=DetectionMode.AttributePresence,
update_mode=UpdateMode.AttributePresence,
data_converter=BooleanConverter()))
mk.manage_binding(label_elt, Binding(data)) mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt return input_elt, label_elt