diff --git a/requirements.txt b/requirements.txt index 8205133..c0a9f0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index 44655c4..6078c62 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -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 diff --git a/src/myfasthtml/core/bindings.py b/src/myfasthtml/core/bindings.py new file mode 100644 index 0000000..15985d4 --- /dev/null +++ b/src/myfasthtml/core/bindings.py @@ -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() diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 54dcd7b..25567fa 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -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) diff --git a/src/myfasthtml/core/constants.py b/src/myfasthtml/core/constants.py index 415d59c..3d3bfdf 100644 --- a/src/myfasthtml/core/constants.py +++ b/src/myfasthtml/core/constants.py @@ -1,4 +1,5 @@ ROUTE_ROOT = "/myfasthtml" class Routes: - Commands = "/commands" \ No newline at end of file + Commands = "/commands" + Bindings = "/bindings" \ No newline at end of file diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index 0483ae4..dc841d0 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -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.") diff --git a/src/myfasthtml/myfastapp.py b/src/myfasthtml/myfastapp.py index 9e35c66..d76eded 100644 --- a/src/myfasthtml/myfastapp.py +++ b/src/myfasthtml/myfastapp.py @@ -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 diff --git a/src/myfasthtml/test/testclient.py b/src/myfasthtml/test/testclient.py index 6fd1bfa..b083311 100644 --- a/src/myfasthtml/test/testclient.py +++ b/src/myfasthtml/test/testclient.py @@ -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): """ diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_bindings.py b/tests/core/test_bindings.py new file mode 100644 index 0000000..c9a2c97 --- /dev/null +++ b/tests/core/test_bindings.py @@ -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] diff --git a/tests/test_commands.py b/tests/core/test_commands.py similarity index 91% rename from tests/test_commands.py rename to tests/core/test_commands.py index 8aac933..60a0f81 100644 --- a/tests/test_commands.py +++ b/tests/core/test_commands.py @@ -8,7 +8,7 @@ def callback(): @pytest.fixture(autouse=True) -def test_reset_command_manager(): +def reset_command_manager(): CommandsManager.reset() diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..4dff820 --- /dev/null +++ b/tests/test_integration.py @@ -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 diff --git a/tests/test_integration_commands.py b/tests/test_integration_commands.py deleted file mode 100644 index 799f34d..0000000 --- a/tests/test_integration_commands.py +++ /dev/null @@ -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") diff --git a/tests/test_matches.py b/tests/testclient/test_matches.py similarity index 100% rename from tests/test_matches.py rename to tests/testclient/test_matches.py