Compare commits
2 Commits
3721bb7ad7
...
aaba6a5468
| Author | SHA1 | Date | |
|---|---|---|---|
| aaba6a5468 | |||
| 991a6f07ff |
@@ -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
|
||||
|
||||
15
src/myfasthtml/assets/myfasthtml.css
Normal file
15
src/myfasthtml/assets/myfasthtml.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.mf-icon-20 {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.mf-icon-16 {
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
56
src/myfasthtml/controls/helpers.py
Normal file
56
src/myfasthtml/controls/helpers.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.core.bindings import Binding
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.utils import merge_classes, get_default_ft_attr
|
||||
|
||||
|
||||
class mk:
|
||||
|
||||
@staticmethod
|
||||
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,
|
||||
can_select=True,
|
||||
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 '',
|
||||
'mmt-btn' if can_hover else '',
|
||||
cls,
|
||||
kwargs)
|
||||
|
||||
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||
|
||||
@staticmethod
|
||||
def manage_command(ft, command: Command):
|
||||
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
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
ROUTE_ROOT = "/myfasthtml"
|
||||
|
||||
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):
|
||||
@@ -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.")
|
||||
|
||||
0
src/myfasthtml/docs/__init__.py
Normal file
0
src/myfasthtml/docs/__init__.py
Normal file
26
src/myfasthtml/docs/clickme.py
Normal file
26
src/myfasthtml/docs/clickme.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from fasthtml import serve
|
||||
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
|
||||
# Define a simple command action
|
||||
def say_hello():
|
||||
return "Hello, FastHtml!"
|
||||
|
||||
|
||||
# Create the command
|
||||
hello_command = Command("say_hello", "Responds with a greeting", say_hello)
|
||||
|
||||
# Create the app
|
||||
app, rt = create_app(protect_routes=False)
|
||||
|
||||
|
||||
@rt("/")
|
||||
def get_homepage():
|
||||
return mk.button("Click Me!", command=hello_command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
25
src/myfasthtml/docs/command_with_htmx_params.py
Normal file
25
src/myfasthtml/docs/command_with_htmx_params.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from fasthtml import serve
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.icons.fa import icon_home
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
app, rt = create_app(protect_routes=False)
|
||||
|
||||
|
||||
def change_text():
|
||||
return "New text"
|
||||
|
||||
|
||||
command = Command("change_text", "change the text", change_text).htmx(target="#text")
|
||||
|
||||
|
||||
@rt("/")
|
||||
def index():
|
||||
return mk.button(Div(mk.icon(icon_home), Div("Hello World", id="text"), cls="flex"), command=command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
15
src/myfasthtml/docs/helloworld.py
Normal file
15
src/myfasthtml/docs/helloworld.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from fasthtml import serve
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
app, rt = create_app(protect_routes=False)
|
||||
|
||||
|
||||
@rt("/")
|
||||
def get_homepage():
|
||||
return Div("Hello, FastHtml!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve(port=5002)
|
||||
@@ -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
|
||||
|
||||
0
src/myfasthtml/test/__init__.py
Normal file
0
src/myfasthtml/test/__init__.py
Normal 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
|
||||
@@ -345,10 +351,6 @@ class TestableElement:
|
||||
elif options:
|
||||
self.fields[name] = options[0]['value']
|
||||
|
||||
def _get_my_ft(self, element: Tag):
|
||||
_inner = element.find(self.tag) if self.tag and self.tag != element.name else element
|
||||
return MyFT(_inner.name, _inner.attrs)
|
||||
|
||||
@staticmethod
|
||||
def _get_input_identifier(input_field, counter):
|
||||
"""
|
||||
@@ -432,14 +434,6 @@ class TestableElement:
|
||||
# Default to string
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _get_element(html_fragment: str):
|
||||
html_fragment = html_fragment.strip()
|
||||
if (not html_fragment.startswith('<div') and
|
||||
not html_fragment.startswith('<form')):
|
||||
html_fragment = "<div>" + html_fragment + "</div>"
|
||||
return BeautifulSoup(html_fragment, 'html.parser').find()
|
||||
|
||||
@staticmethod
|
||||
def _parse(tag, html_fragment: str):
|
||||
elt = BeautifulSoup(html_fragment, 'html.parser')
|
||||
@@ -801,8 +795,8 @@ class TestableForm(TestableElement):
|
||||
# return value
|
||||
|
||||
|
||||
class TestableInput(TestableElement):
|
||||
def __init__(self, client, source):
|
||||
class TestableControl(TestableElement):
|
||||
def __init__(self, client, source, tag):
|
||||
super().__init__(client, source, "input")
|
||||
assert len(self.fields) <= 1
|
||||
self._input_name = next(iter(self.fields))
|
||||
@@ -815,12 +809,40 @@ class TestableInput(TestableElement):
|
||||
def value(self):
|
||||
return self.fields[self._input_name]
|
||||
|
||||
def _send_value(self):
|
||||
if self._input_name and self._support_htmx():
|
||||
return self._send_htmx_request(data={self._input_name: self.value})
|
||||
return None
|
||||
|
||||
|
||||
class TestableInput(TestableControl):
|
||||
def __init__(self, client, source):
|
||||
super().__init__(client, source, "input")
|
||||
|
||||
def send(self, value):
|
||||
self.fields[self.name] = value
|
||||
if self.name and self._support_htmx():
|
||||
return self._send_htmx_request(data={self.name: self.value})
|
||||
|
||||
return None
|
||||
return self._send_value()
|
||||
|
||||
|
||||
class TestableCheckbox(TestableControl):
|
||||
def __init__(self, client, source):
|
||||
super().__init__(client, source, "input")
|
||||
|
||||
@property
|
||||
def is_checked(self):
|
||||
return self.fields[self._input_name] == True
|
||||
|
||||
def check(self):
|
||||
self.fields[self._input_name] = True
|
||||
return self._send_value()
|
||||
|
||||
def uncheck(self):
|
||||
self.fields[self._input_name] = False
|
||||
return self._send_value()
|
||||
|
||||
def toggle(self):
|
||||
self.fields[self._input_name] = not self.fields[self._input_name]
|
||||
return self._send_value()
|
||||
|
||||
|
||||
# def get_value(tag):
|
||||
@@ -908,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:
|
||||
"""
|
||||
@@ -936,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,
|
||||
@@ -1072,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."
|
||||
@@ -1132,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/controls/__init__.py
Normal file
0
tests/controls/__init__.py
Normal file
48
tests/controls/test_helpers.py
Normal file
48
tests/controls/test_helpers.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import pytest
|
||||
from fasthtml.components import *
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.test.matcher import matches
|
||||
from myfasthtml.test.testclient import MyTestClient
|
||||
|
||||
|
||||
@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_mk_button():
|
||||
button = mk.button('button')
|
||||
expected = Button('button')
|
||||
|
||||
assert matches(button, expected)
|
||||
|
||||
|
||||
def test_i_can_mk_button_with_attrs():
|
||||
button = mk.button('button', id="button_id", class_="button_class")
|
||||
expected = Button('button', id="button_id", class_="button_class")
|
||||
assert matches(button, expected)
|
||||
|
||||
|
||||
def test_i_can_mk_button_with_command(user, rt):
|
||||
def new_value(value): return value
|
||||
|
||||
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")
|
||||
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)
|
||||
def test_reset_command_manager():
|
||||
def reset_command_manager():
|
||||
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")
|
||||
59
tests/testclient/test_testable_checkbox.py
Normal file
59
tests/testclient/test_testable_checkbox.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from myfasthtml.test.testclient import MyTestClient, TestableCheckbox
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_app():
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
return test_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rt(test_app):
|
||||
return test_app.route
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(test_app):
|
||||
return MyTestClient(test_app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("html,expected_value", [
|
||||
('<input type="checkbox" name="male" checked />', True),
|
||||
('<input type="checkbox" name="male" />', False),
|
||||
])
|
||||
def test_i_can_read_input(test_client, html, expected_value):
|
||||
input_elt = TestableCheckbox(test_client, html)
|
||||
|
||||
assert input_elt.name == "male"
|
||||
assert input_elt.value == expected_value
|
||||
|
||||
|
||||
def test_i_can_read_input_with_label(test_client):
|
||||
html = '''<label for="uid">Male</label><input id="uid" type="checkbox" name="male" checked />'''
|
||||
|
||||
input_elt = TestableCheckbox(test_client, html)
|
||||
assert input_elt.fields_mapping == {"Male": "male"}
|
||||
assert input_elt.name == "male"
|
||||
assert input_elt.value == True
|
||||
|
||||
|
||||
def test_i_can_check_checkbox(test_client, rt):
|
||||
html = '''<input type="checkbox" name="male" hx_post="/submit"/>'''
|
||||
|
||||
@rt('/submit')
|
||||
def post(male: bool):
|
||||
return f"Checkbox received {male=}"
|
||||
|
||||
input_elt = TestableCheckbox(test_client, html)
|
||||
|
||||
input_elt.check()
|
||||
assert test_client.get_content() == "Checkbox received male=True"
|
||||
|
||||
input_elt.uncheck()
|
||||
assert test_client.get_content() == "Checkbox received male=False"
|
||||
|
||||
input_elt.toggle()
|
||||
assert test_client.get_content() == "Checkbox received male=True"
|
||||
51
tests/testclient/test_testable_input.py
Normal file
51
tests/testclient/test_testable_input.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from myfasthtml.test.testclient import TestableInput, MyTestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_app():
|
||||
test_app, rt = fast_app(default_hdrs=False)
|
||||
return test_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rt(test_app):
|
||||
return test_app.route
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(test_app):
|
||||
return MyTestClient(test_app)
|
||||
|
||||
|
||||
def test_i_can_read_input(test_client):
|
||||
html = '''<input type="text" name="username" value="john_doe" />'''
|
||||
|
||||
input_elt = TestableInput(test_client, html)
|
||||
|
||||
assert input_elt.name == "username"
|
||||
assert input_elt.value == "john_doe"
|
||||
|
||||
|
||||
def test_i_can_read_input_with_label(test_client):
|
||||
html = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />'''
|
||||
|
||||
input_elt = TestableInput(test_client, html)
|
||||
assert input_elt.fields_mapping == {"Username": "username"}
|
||||
assert input_elt.name == "username"
|
||||
assert input_elt.value == "john_doe"
|
||||
|
||||
|
||||
def test_i_can_send_values(test_client, rt):
|
||||
html = '''<input type="text" name="username" value="john_doe" hx_post="/submit"/>'''
|
||||
|
||||
@rt('/submit')
|
||||
def post(username: str):
|
||||
return f"Input received {username=}"
|
||||
|
||||
input_elt = TestableInput(test_client, html)
|
||||
input_elt.send("another name")
|
||||
|
||||
assert test_client.get_content() == "Input received username='another name'"
|
||||
Reference in New Issue
Block a user