I can bind elements
This commit is contained in:
@@ -6,11 +6,14 @@ apswutils==0.1.0
|
|||||||
argon2-cffi==25.1.0
|
argon2-cffi==25.1.0
|
||||||
argon2-cffi-bindings==25.1.0
|
argon2-cffi-bindings==25.1.0
|
||||||
beautifulsoup4==4.14.2
|
beautifulsoup4==4.14.2
|
||||||
|
build==1.3.0
|
||||||
certifi==2025.10.5
|
certifi==2025.10.5
|
||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
|
charset-normalizer==3.4.4
|
||||||
click==8.3.0
|
click==8.3.0
|
||||||
cryptography==46.0.3
|
cryptography==46.0.3
|
||||||
dnspython==2.8.0
|
dnspython==2.8.0
|
||||||
|
docutils==0.22.2
|
||||||
ecdsa==0.19.1
|
ecdsa==0.19.1
|
||||||
email-validator==2.3.0
|
email-validator==2.3.0
|
||||||
fastapi==0.120.0
|
fastapi==0.120.0
|
||||||
@@ -20,13 +23,25 @@ h11==0.16.0
|
|||||||
httpcore==1.0.9
|
httpcore==1.0.9
|
||||||
httptools==0.7.1
|
httptools==0.7.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
|
id==1.5.0
|
||||||
idna==3.11
|
idna==3.11
|
||||||
iniconfig==2.3.0
|
iniconfig==2.3.0
|
||||||
itsdangerous==2.2.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
|
oauthlib==3.3.1
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
|
pipdeptree==2.29.0
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
pyasn1==0.6.1
|
pyasn1==0.6.1
|
||||||
pycparser==2.23
|
pycparser==2.23
|
||||||
@@ -34,6 +49,7 @@ pydantic==2.12.3
|
|||||||
pydantic-settings==2.11.0
|
pydantic-settings==2.11.0
|
||||||
pydantic_core==2.41.4
|
pydantic_core==2.41.4
|
||||||
Pygments==2.19.2
|
Pygments==2.19.2
|
||||||
|
pyproject_hooks==1.2.0
|
||||||
pytest==8.4.2
|
pytest==8.4.2
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.1.1
|
||||||
@@ -41,13 +57,21 @@ python-fasthtml==0.12.30
|
|||||||
python-jose==3.5.0
|
python-jose==3.5.0
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
PyYAML==6.0.3
|
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
|
rsa==4.9.1
|
||||||
|
SecretStorage==3.4.0
|
||||||
six==1.17.0
|
six==1.17.0
|
||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
soupsieve==2.8
|
soupsieve==2.8
|
||||||
starlette==0.48.0
|
starlette==0.48.0
|
||||||
|
twine==6.2.0
|
||||||
typing-inspection==0.4.2
|
typing-inspection==0.4.2
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
|
urllib3==2.5.0
|
||||||
uvicorn==0.38.0
|
uvicorn==0.38.0
|
||||||
uvloop==0.22.1
|
uvloop==0.22.1
|
||||||
watchfiles==1.1.1
|
watchfiles==1.1.1
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
|
from myfasthtml.core.bindings import Binding
|
||||||
from myfasthtml.core.commands import Command
|
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:
|
class mk:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def button(element, command: Command = None, **kwargs):
|
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
||||||
return mk.manage_command(Button(element, **kwargs), command)
|
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def icon(icon, size=20,
|
def icon(icon, size=20,
|
||||||
@@ -16,6 +17,7 @@ class mk:
|
|||||||
can_hover=False,
|
can_hover=False,
|
||||||
cls='',
|
cls='',
|
||||||
command: Command = None,
|
command: Command = None,
|
||||||
|
binding: Binding = None,
|
||||||
**kwargs):
|
**kwargs):
|
||||||
merged_cls = merge_classes(f"mf-icon-{size}",
|
merged_cls = merge_classes(f"mf-icon-{size}",
|
||||||
'icon-btn' if can_select else '',
|
'icon-btn' if can_select else '',
|
||||||
@@ -23,11 +25,32 @@ class mk:
|
|||||||
cls,
|
cls,
|
||||||
kwargs)
|
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
|
@staticmethod
|
||||||
def manage_command(ft, command: Command):
|
def manage_command(ft, command: Command):
|
||||||
htmx = command.get_htmx_params() if command else {}
|
if command:
|
||||||
ft.attrs |= htmx
|
htmx = command.get_htmx_params()
|
||||||
|
ft.attrs |= htmx
|
||||||
|
|
||||||
return ft
|
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
|
||||||
|
|||||||
114
src/myfasthtml/core/bindings.py
Normal file
114
src/myfasthtml/core/bindings.py
Normal 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()
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
import logging
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fasthtml.fastapp import fast_app
|
|
||||||
|
|
||||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
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:
|
class BaseCommand:
|
||||||
@@ -98,31 +91,3 @@ class CommandsManager:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def reset():
|
def reset():
|
||||||
return CommandsManager.commands.clear()
|
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)
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
ROUTE_ROOT = "/myfasthtml"
|
ROUTE_ROOT = "/myfasthtml"
|
||||||
|
|
||||||
class Routes:
|
class Routes:
|
||||||
Commands = "/commands"
|
Commands = "/commands"
|
||||||
|
Bindings = "/bindings"
|
||||||
@@ -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):
|
def mount_if_not_exists(app, path: str, sub_app):
|
||||||
@@ -46,3 +54,83 @@ def merge_classes(*args):
|
|||||||
return " ".join(unique_elements)
|
return " ".join(unique_elements)
|
||||||
else:
|
else:
|
||||||
return None
|
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.")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Any
|
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.routes import setup_auth_routes
|
||||||
from myfasthtml.auth.utils import create_auth_beforeware
|
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):
|
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
|
# and put it back after the myfasthtml static files routes
|
||||||
app.routes.append(static_route_exts_get)
|
app.routes.append(static_route_exts_get)
|
||||||
|
|
||||||
# route the commands
|
# route the commands and the bindings
|
||||||
app.mount("/myfasthtml", commands_app)
|
app.mount("/myfasthtml", utils_app)
|
||||||
|
|
||||||
if mount_auth_app:
|
if mount_auth_app:
|
||||||
# Setup authentication routes
|
# Setup authentication routes
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fasthtml.common import FastHTML
|
|||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
from starlette.testclient import TestClient
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
from myfasthtml.core.commands import mount_commands
|
from myfasthtml.core.utils import mount_utils
|
||||||
|
|
||||||
verbs = {
|
verbs = {
|
||||||
'hx_get': 'GET',
|
'hx_get': 'GET',
|
||||||
@@ -130,8 +130,15 @@ class TestableElement:
|
|||||||
|
|
||||||
if data is not None:
|
if data is not None:
|
||||||
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||||
|
bag_to_use = data
|
||||||
elif json_data is not None:
|
elif json_data is not None:
|
||||||
headers['Content-Type'] = 'application/json'
|
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")
|
# .props contains the kwargs passed to the object (e.g., hx_post="/url")
|
||||||
element_attrs = self.my_ft.attrs or {}
|
element_attrs = self.my_ft.attrs or {}
|
||||||
@@ -151,11 +158,10 @@ class TestableElement:
|
|||||||
|
|
||||||
elif key == 'hx_vals':
|
elif key == 'hx_vals':
|
||||||
# hx_vals defines the JSON body, if not already provided by the test
|
# hx_vals defines the JSON body, if not already provided by the test
|
||||||
if json_data is None:
|
if isinstance(value, str):
|
||||||
if isinstance(value, str):
|
bag_to_use |= json.loads(value)
|
||||||
json_data = json.loads(value)
|
elif isinstance(value, dict):
|
||||||
elif isinstance(value, dict):
|
bag_to_use |= value
|
||||||
json_data = value
|
|
||||||
|
|
||||||
elif key.startswith('hx_'):
|
elif key.startswith('hx_'):
|
||||||
# Any other hx_* attribute is converted to an HTTP header
|
# Any other hx_* attribute is converted to an HTTP header
|
||||||
@@ -924,7 +930,7 @@ class MyTestClient:
|
|||||||
self.parent_levels = parent_levels
|
self.parent_levels = parent_levels
|
||||||
|
|
||||||
# make sure that the commands are mounted
|
# make sure that the commands are mounted
|
||||||
mount_commands(self.app)
|
mount_utils(self.app)
|
||||||
|
|
||||||
def open(self, path: str) -> Self:
|
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):
|
def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None):
|
||||||
if json_data is not None:
|
if json_data is not None:
|
||||||
json_data['session'] = self._session
|
json_data['session'] = self._session
|
||||||
|
if data is not None:
|
||||||
|
data['session'] = self._session
|
||||||
|
|
||||||
res = self.client.request(
|
res = self.client.request(
|
||||||
method,
|
method,
|
||||||
@@ -1088,7 +1096,7 @@ class MyTestClient:
|
|||||||
f"No element found matching selector '{selector}'."
|
f"No element found matching selector '{selector}'."
|
||||||
)
|
)
|
||||||
elif len(results) == 1:
|
elif len(results) == 1:
|
||||||
return TestableElement(self, results[0], results[0].name)
|
return self._testable_element_factory(results[0])
|
||||||
else:
|
else:
|
||||||
raise AssertionError(
|
raise AssertionError(
|
||||||
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
|
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
|
||||||
@@ -1148,6 +1156,12 @@ class MyTestClient:
|
|||||||
self._soup = BeautifulSoup(content, 'html.parser')
|
self._soup = BeautifulSoup(content, 'html.parser')
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
def _testable_element_factory(self, elt):
|
||||||
|
if elt.name == "input":
|
||||||
|
return TestableInput(self, elt)
|
||||||
|
else:
|
||||||
|
return TestableElement(self, elt, elt.name)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _find_visible_text_element(soup, text: str):
|
def _find_visible_text_element(soup, text: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
0
tests/core/__init__.py
Normal file
0
tests/core/__init__.py
Normal file
96
tests/core/test_bindings.py
Normal file
96
tests/core/test_bindings.py
Normal 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]
|
||||||
@@ -8,7 +8,7 @@ def callback():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def test_reset_command_manager():
|
def reset_command_manager():
|
||||||
CommandsManager.reset()
|
CommandsManager.reset()
|
||||||
|
|
||||||
|
|
||||||
79
tests/test_integration.py
Normal file
79
tests/test_integration.py
Normal 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
|
||||||
@@ -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")
|
|
||||||
Reference in New Issue
Block a user