I can bind elements

This commit is contained in:
2025-11-01 23:44:18 +01:00
parent 991a6f07ff
commit aaba6a5468
14 changed files with 463 additions and 109 deletions

View File

@@ -6,11 +6,14 @@ apswutils==0.1.0
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
beautifulsoup4==4.14.2
build==1.3.0
certifi==2025.10.5
cffi==2.0.0
charset-normalizer==3.4.4
click==8.3.0
cryptography==46.0.3
dnspython==2.8.0
docutils==0.22.2
ecdsa==0.19.1
email-validator==2.3.0
fastapi==0.120.0
@@ -20,13 +23,25 @@ h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
id==1.5.0
idna==3.11
iniconfig==2.3.0
itsdangerous==2.2.0
-e git+ssh://git@sheerka.synology.me:1010/kodjo/MyAuth.git@0138ac247a4a53dc555b94ec13119eba16e1db68#egg=myauth
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.3.0
jeepney==0.9.0
keyring==25.6.0
markdown-it-py==4.0.0
mdurl==0.1.2
more-itertools==10.8.0
myauth==0.2.0
myutils==0.1.0
nh3==0.3.1
oauthlib==3.3.1
packaging==25.0
passlib==1.7.4
pipdeptree==2.29.0
pluggy==1.6.0
pyasn1==0.6.1
pycparser==2.23
@@ -34,6 +49,7 @@ pydantic==2.12.3
pydantic-settings==2.11.0
pydantic_core==2.41.4
Pygments==2.19.2
pyproject_hooks==1.2.0
pytest==8.4.2
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
@@ -41,13 +57,21 @@ python-fasthtml==0.12.30
python-jose==3.5.0
python-multipart==0.0.20
PyYAML==6.0.3
readme_renderer==44.0
requests==2.32.5
requests-toolbelt==1.0.0
rfc3986==2.0.0
rich==14.2.0
rsa==4.9.1
SecretStorage==3.4.0
six==1.17.0
sniffio==1.3.1
soupsieve==2.8
starlette==0.48.0
twine==6.2.0
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.5.0
uvicorn==0.38.0
uvloop==0.22.1
watchfiles==1.1.1

View File

@@ -1,14 +1,15 @@
from fasthtml.components import *
from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command
from myfasthtml.core.utils import merge_classes
from myfasthtml.core.utils import merge_classes, get_default_ft_attr
class mk:
@staticmethod
def button(element, command: Command = None, **kwargs):
return mk.manage_command(Button(element, **kwargs), command)
def button(element, command: Command = None, binding: Binding = None, **kwargs):
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
@staticmethod
def icon(icon, size=20,
@@ -16,6 +17,7 @@ class mk:
can_hover=False,
cls='',
command: Command = None,
binding: Binding = None,
**kwargs):
merged_cls = merge_classes(f"mf-icon-{size}",
'icon-btn' if can_select else '',
@@ -23,11 +25,32 @@ class mk:
cls,
kwargs)
return mk.manage_command(Div(icon, cls=merged_cls, **kwargs), command)
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
@staticmethod
def manage_command(ft, command: Command):
htmx = command.get_htmx_params() if command else {}
ft.attrs |= htmx
if command:
htmx = command.get_htmx_params()
ft.attrs |= htmx
return ft
@staticmethod
def manage_binding(ft, binding: Binding):
if binding:
# update the component to post on the correct route
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
return ft
@staticmethod
def mk(ft, command: Command = None, binding: Binding = None):
ft = mk.manage_command(ft, command)
ft = mk.manage_binding(ft, binding)
return ft

View File

@@ -0,0 +1,114 @@
import logging
import uuid
from typing import Optional
from fasthtml.fastapp import fast_app
from myutils.observable import make_observable, bind, collect_return_values
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.utils import get_default_attr
bindings_app, bindings_rt = fast_app()
logger = logging.getLogger("Bindings")
class Binding:
def __init__(self, data, attr=None, ft=None, ft_name=None, ft_attr=None):
"""
Creates a new binding object between a data object used as a pivot and an HTML element.
The same pivot object must be used for different bindings.
This will allow the binding between the HTML elements
:param data: object used as a pivot
:param attr: attribute of the data object
: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.htmx_extra = {}
self.data = data
self.data_attr = attr or get_default_attr(data)
self.ft = self._safe_ft(ft)
self.ft_name = ft_name
self.ft_attr = ft_attr
make_observable(self.data)
bind(self.data, self.data_attr, self.notify)
# register the command
BindingsManager.register(self)
def bind_ft(self, ft, name, attr=None):
"""
Update the elements to bind to
:param ft:
:param name:
:param attr:
:return:
"""
self.ft = self._safe_ft(ft)
self.ft_name = name
self.ft_attr = attr
def get_htmx_params(self):
return self.htmx_extra | {
"hx-post": f"{ROUTE_ROOT}{Routes.Bindings}",
"hx-vals": f'{{"b_id": "{self.id}"}}',
}
def notify(self, old, new):
logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'")
if self.ft_attr is None:
self.ft.children = (new,)
else:
self.ft.attrs[self.ft_attr] = new
self.ft.attrs["hx-swap-oob"] = "true"
return self.ft
def update(self, values: dict):
logger.debug(f"Binding '{self.id}': Updating with {values=}.")
for key, value in values.items():
if key == self.ft_name:
setattr(self.data, self.data_attr, value)
res = collect_return_values(self.data)
return res
else:
logger.debug(f"Nothing to trigger in {values}.")
return None
@staticmethod
def _safe_ft(ft):
"""
Make sure the ft has an id.
:param ft:
:return:
"""
if ft is None:
return None
if ft.attrs.get("id", None) is None:
ft.attrs["id"] = str(uuid.uuid4())
return ft
def htmx(self, trigger=None):
if trigger:
self.htmx_extra["hx-trigger"] = trigger
return self
class BindingsManager:
bindings = {}
@staticmethod
def register(binding: Binding):
BindingsManager.bindings[str(binding.id)] = binding
@staticmethod
def get_binding(binding_id: str) -> Optional[Binding]:
return BindingsManager.bindings.get(str(binding_id))
@staticmethod
def reset():
return BindingsManager.bindings.clear()

View File

@@ -1,14 +1,7 @@
import logging
import uuid
from typing import Optional
from fasthtml.fastapp import fast_app
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.utils import mount_if_not_exists
commands_app, commands_rt = fast_app()
logger = logging.getLogger("Commands")
class BaseCommand:
@@ -98,31 +91,3 @@ class CommandsManager:
@staticmethod
def reset():
return CommandsManager.commands.clear()
@commands_rt(Routes.Commands)
def post(session: str, c_id: str):
"""
Default routes for all commands.
:param session:
:param c_id:
:return:
"""
logger.debug(f"Entering {Routes.Commands} with {session=}, {c_id=}")
command = CommandsManager.get_command(c_id)
if command:
return command.execute()
raise ValueError(f"Command with ID '{c_id}' not found.")
def mount_commands(app):
"""
Mounts the commands_app to the given application instance if the route does not already exist.
:param app: The application instance to which the commands_app will be mounted.
:type app: Any
:return: Returns the result of the mount operation performed by mount_if_not_exists.
:rtype: Any
"""
return mount_if_not_exists(app, ROUTE_ROOT, commands_app)

View File

@@ -1,4 +1,5 @@
ROUTE_ROOT = "/myfasthtml"
class Routes:
Commands = "/commands"
Commands = "/commands"
Bindings = "/bindings"

View File

@@ -1,4 +1,12 @@
from starlette.routing import Mount
import logging
from fasthtml.fastapp import fast_app
from starlette.routing import Mount, Route
from myfasthtml.core.constants import Routes, ROUTE_ROOT
utils_app, utils_rt = fast_app()
logger = logging.getLogger("Commands")
def mount_if_not_exists(app, path: str, sub_app):
@@ -46,3 +54,83 @@ def merge_classes(*args):
return " ".join(unique_elements)
else:
return None
def debug_routes(app):
for route in app.router.routes:
if isinstance(route, Mount):
for sub_route in route.app.router.routes:
print(f"path={route.path}{sub_route.path}, method={sub_route.methods}, endpoint={sub_route.endpoint}")
elif isinstance(route, Route):
print(f"path={route.path}, methods={route.methods}, endpoint={route.endpoint}")
def mount_utils(app):
"""
Mounts the commands_app to the given application instance if the route does not already exist.
:param app: The application instance to which the commands_app will be mounted.
:type app: Any
:return: Returns the result of the mount operation performed by mount_if_not_exists.
:rtype: Any
"""
return mount_if_not_exists(app, ROUTE_ROOT, utils_app)
def get_default_ft_attr(ft):
"""
for every type of HTML element (ft) gives the default attribute to use for binding
:param ft:
:return:
"""
if ft.tag == "input":
if ft.attrs.get("type") == "checkbox":
return "checked"
elif ft.attrs.get("type") == "radio":
return "checked"
elif ft.attrs.get("type") == "file":
return "files"
else:
return "value"
else:
return None # indicate that the content of the FT should be updated
def get_default_attr(data):
all_attrs = data.__dict__.keys()
return next(iter(all_attrs))
@utils_rt(Routes.Commands)
def post(session: str, c_id: str):
"""
Default routes for all commands.
:param session:
:param c_id:
:return:
"""
logger.debug(f"Entering {Routes.Commands} with {session=}, {c_id=}")
from myfasthtml.core.commands import CommandsManager
command = CommandsManager.get_command(c_id)
if command:
return command.execute()
raise ValueError(f"Command with ID '{c_id}' not found.")
@utils_rt(Routes.Bindings)
def post(session: str, b_id: str, values: dict):
"""
Default routes for all bindings.
:param session:
:param b_id:
:param values:
:return:
"""
logger.debug(f"Entering {Routes.Bindings} with {session=}, {b_id=}, {values=}")
from myfasthtml.core.bindings import BindingsManager
binding = BindingsManager.get_binding(b_id)
if binding:
return binding.update(values)
raise ValueError(f"Binding with ID '{b_id}' not found.")

View File

@@ -1,3 +1,4 @@
import logging
from importlib.resources import files
from pathlib import Path
from typing import Optional, Any
@@ -8,7 +9,9 @@ from starlette.responses import Response
from myfasthtml.auth.routes import setup_auth_routes
from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.commands import commands_app
from myfasthtml.core.utils import utils_app
logger = logging.getLogger("MyFastHtml")
def get_asset_path(filename):
@@ -85,8 +88,8 @@ def create_app(daisyui: Optional[bool] = True,
# and put it back after the myfasthtml static files routes
app.routes.append(static_route_exts_get)
# route the commands
app.mount("/myfasthtml", commands_app)
# route the commands and the bindings
app.mount("/myfasthtml", utils_app)
if mount_auth_app:
# Setup authentication routes

View File

@@ -10,7 +10,7 @@ from fasthtml.common import FastHTML
from starlette.responses import Response
from starlette.testclient import TestClient
from myfasthtml.core.commands import mount_commands
from myfasthtml.core.utils import mount_utils
verbs = {
'hx_get': 'GET',
@@ -130,8 +130,15 @@ class TestableElement:
if data is not None:
headers['Content-Type'] = 'application/x-www-form-urlencoded'
bag_to_use = data
elif json_data is not None:
headers['Content-Type'] = 'application/json'
bag_to_use = json_data
else:
# default to json_data
headers['Content-Type'] = 'application/json'
json_data = {}
bag_to_use = json_data
# .props contains the kwargs passed to the object (e.g., hx_post="/url")
element_attrs = self.my_ft.attrs or {}
@@ -151,11 +158,10 @@ class TestableElement:
elif key == 'hx_vals':
# hx_vals defines the JSON body, if not already provided by the test
if json_data is None:
if isinstance(value, str):
json_data = json.loads(value)
elif isinstance(value, dict):
json_data = value
if isinstance(value, str):
bag_to_use |= json.loads(value)
elif isinstance(value, dict):
bag_to_use |= value
elif key.startswith('hx_'):
# Any other hx_* attribute is converted to an HTTP header
@@ -924,7 +930,7 @@ class MyTestClient:
self.parent_levels = parent_levels
# make sure that the commands are mounted
mount_commands(self.app)
mount_utils(self.app)
def open(self, path: str) -> Self:
"""
@@ -952,6 +958,8 @@ class MyTestClient:
def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None):
if json_data is not None:
json_data['session'] = self._session
if data is not None:
data['session'] = self._session
res = self.client.request(
method,
@@ -1088,7 +1096,7 @@ class MyTestClient:
f"No element found matching selector '{selector}'."
)
elif len(results) == 1:
return TestableElement(self, results[0], results[0].name)
return self._testable_element_factory(results[0])
else:
raise AssertionError(
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
@@ -1148,6 +1156,12 @@ class MyTestClient:
self._soup = BeautifulSoup(content, 'html.parser')
return self
def _testable_element_factory(self, elt):
if elt.name == "input":
return TestableInput(self, elt)
else:
return TestableElement(self, elt, elt.name)
@staticmethod
def _find_visible_text_element(soup, text: str):
"""

0
tests/core/__init__.py Normal file
View File

View File

@@ -0,0 +1,96 @@
from dataclasses import dataclass
import pytest
from fasthtml.components import Label, Input
from myutils.observable import collect_return_values
from myfasthtml.core.bindings import BindingsManager, Binding
@dataclass
class Data:
value: str = "Hello World"
@pytest.fixture(autouse=True)
def reset_binding_manager():
BindingsManager.reset()
@pytest.fixture()
def data():
return Data()
def test_i_can_register_a_binding(data):
binding = Binding(data, "value")
assert binding.id is not None
assert binding.data is data
assert binding.data_attr == 'value'
def test_i_can_register_a_binding_with_default_attr(data):
binding = Binding(data)
assert binding.id is not None
assert binding.data is data
assert binding.data_attr == 'value'
def test_i_can_retrieve_a_registered_binding(data):
binding = Binding(data)
assert BindingsManager.get_binding(binding.id) is binding
def test_i_can_reset_bindings(data):
Binding(data)
assert len(BindingsManager.bindings) != 0
BindingsManager.reset()
assert len(BindingsManager.bindings) == 0
def test_i_can_bind_an_element_to_a_binding(data):
elt = Label("hello", id="label_id")
Binding(data, ft=elt)
data.value = "new value"
assert elt.children[0] == "new value"
assert elt.attrs["hx-swap-oob"] == "true"
assert elt.attrs["id"] == "label_id"
def test_i_can_bind_an_element_attr_to_a_binding(data):
elt = Input(value="somme value", id="input_id")
Binding(data, ft=elt, ft_attr="value")
data.value = "new value"
assert elt.attrs["value"] == "new value"
assert elt.attrs["hx-swap-oob"] == "true"
assert elt.attrs["id"] == "input_id"
def test_bound_element_has_an_id():
elt = Label("hello")
assert elt.attrs.get("id", None) is None
Binding(Data(), ft=elt)
assert elt.attrs.get("id", None) is not None
def test_i_can_collect_updates_values(data):
elt = Label("hello")
Binding(data, ft=elt)
data.value = "new value"
collected = collect_return_values(data)
assert collected == [elt]
# a second time to ensure no side effect
data.value = "another value"
collected = collect_return_values(data)
assert collected == [elt]

View File

@@ -8,7 +8,7 @@ def callback():
@pytest.fixture(autouse=True)
def test_reset_command_manager():
def reset_command_manager():
CommandsManager.reset()

79
tests/test_integration.py Normal file
View File

@@ -0,0 +1,79 @@
from dataclasses import dataclass
import pytest
from fasthtml.components import Input, Label
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.test.testclient import MyTestClient, TestableElement
def new_value(value):
return value
@dataclass
class Data:
value: str
@pytest.fixture()
def user():
test_app, rt = fast_app(default_hdrs=False)
user = MyTestClient(test_app)
return user
@pytest.fixture()
def rt(user):
return user.app.route
class TestingCommand:
def test_i_can_trigger_a_command(self, user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
testable = TestableElement(user, mk.button('button', command))
testable.click()
assert user.get_content() == "this is my new value"
def test_error_is_raised_when_command_is_not_found(self, user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
CommandsManager.reset()
testable = TestableElement(user, mk.button('button', command))
with pytest.raises(ValueError) as exc_info:
testable.click()
assert "not found." in str(exc_info.value)
def test_i_can_play_a_complex_scenario(self, user, rt):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
@rt('/')
def get(): return mk.button('button', command)
user.open("/")
user.should_see("button")
user.find_element("button").click()
user.should_see("this is my new value")
class TestingBindings:
def test_i_can_bind_elements(self, user, rt):
@rt("/")
def index():
data = Data("hello world")
input_elt = Input(name="input_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data, ft_attr="value"))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
user.should_see("")
testable_input = user.find_element("input")
testable_input.send("new value")
user.should_see("new value") # the one from the label

View File

@@ -1,53 +0,0 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.test.testclient import MyTestClient, TestableElement
def new_value(value):
return value
@pytest.fixture()
def user():
test_app, rt = fast_app(default_hdrs=False)
user = MyTestClient(test_app)
return user
@pytest.fixture()
def rt(user):
return user.app.route
def test_i_can_trigger_a_command(user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
testable = TestableElement(user, mk.button('button', command))
testable.click()
assert user.get_content() == "this is my new value"
def test_error_is_raised_when_command_is_not_found(user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
CommandsManager.reset()
testable = TestableElement(user, mk.button('button', command))
with pytest.raises(ValueError) as exc_info:
testable.click()
assert "not found." in str(exc_info.value)
def test_i_can_play_a_complex_scenario(user, rt):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
@rt('/')
def get(): return mk.button('button', command)
user.open("/")
user.should_see("button")
user.find_element("button").click()
user.should_see("this is my new value")