4 Commits

Author SHA1 Message Date
c3d6958c1a I can bind checkbox (needs refactoring) 2025-11-02 10:11:15 +01:00
aaba6a5468 I can bind elements 2025-11-01 23:44:18 +01:00
991a6f07ff Added TestableCheckbox 2025-10-31 21:49:12 +01:00
3721bb7ad7 Added TestableInput 2025-10-31 21:11:56 +01:00
33 changed files with 1666 additions and 340 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ app.egg-info
htmlcov
.cache
.venv
src/main.py
tests/settings_from_unit_testing.json
tests/TestDBEngineRoot
tests/*.png

View File

@@ -1,6 +1,7 @@
# MyFastHtml
A utility library designed to simplify the development of FastHtml applications by providing:
- Predefined pages for common functionalities (e.g., authentication, user management).
- A command management system to facilitate client-server interactions.
- Helpers to create interactive controls more easily.
@@ -10,7 +11,8 @@ A utility library designed to simplify the development of FastHtml applications
## Features
- **Dynamic HTML with HTMX**: Simplify dynamic interaction using attributes like `hx-post` and custom routes like `/commands`.
- **Dynamic HTML with HTMX**: Simplify dynamic interaction using attributes like `hx-post` and custom routes like
`/commands`.
- **Command management**: Write server-side logic in Python while abstracting the complexities of HTMX.
- **Control helpers**: Easily create reusable components like buttons.
- **Predefined Pages (Roadmap)**: Include common pages like login, user management, and customizable dashboards.
@@ -31,28 +33,59 @@ pip install myfasthtml
## Quick Start
Heres a simple example of creating an **interactive button** linked to a command:
### FastHtml Application
### Example: Button with a Command
To create a simple FastHtml application, you can use the `create_app` function:
```python
from fasthtml.fastapp import fast_app
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)
```
### Button with a Command
```python
from fasthtml import serve
from myfasthtml.controls.helpers import mk_button
from myfasthtml.core.commands import Command
from myfasthtml.controls.button import mk_button
from myfasthtml.myfastapp import create_app
# Define a simple command action
def say_hello():
return "Hello, FastHtml!"
return "Hello, FastHtml!"
# Create the command
hello_command = Command("say_hello", "Responds with a greeting", say_hello)
# Create the app and define a route with a button
app, rt = fast_app(default_hdrs=False)
# Create the app
app, rt = create_app(protect_routes=False)
@rt("/")
def get_homepage():
return mk_button("Click Me!", command=hello_command)
return mk_button("Click Me!", command=hello_command)
if __name__ == "__main__":
serve(port=5002)
```
- When the button is clicked, the `say_hello` command will be executed, and the server will return the response.
@@ -63,31 +96,40 @@ def get_homepage():
## Planned Features (Roadmap)
### Predefined Pages
The library will include predefined pages for:
- **Authentication**: Login, signup, password reset.
- **User Management**: User profile and administration pages.
- **Dashboard Templates**: Fully customizable dashboard components.
- **Error Pages**: Detailed and styled error messages (e.g., 404, 500).
### State Persistence
Controls will have their state automatically synchronized between the client and the server. This feature is currently under construction.
Controls will have their state automatically synchronized between the client and the server. This feature is currently
under construction.
---
## Advanced Features
### Command Management System
Commands allow you to simplify frontend/backend interaction. Instead of writing HTMX attributes manually, you can define Python methods and handle them as commands.
Commands allow you to simplify frontend/backend interaction. Instead of writing HTMX attributes manually, you can define
Python methods and handle them as commands.
#### Example
Heres how `Command` simplifies dynamic interaction:
```python
from myfasthtml.core.commands import Command
# Define a command
def custom_action(data):
return f"Received: {data}"
return f"Received: {data}"
my_command = Command("custom", "Handles custom logic", custom_action)
@@ -109,6 +151,7 @@ Use the `get_htmx_params()` method to directly integrate commands into HTML comp
## Contributing
We welcome contributions! To get started:
1. Fork the repository.
2. Create a feature branch.
3. Submit a pull request with clear descriptions of your changes.
@@ -144,26 +187,32 @@ MyFastHtml
### Notable Classes and Methods
#### 1. `Command`
Represents a backend action with server communication.
- **Attributes:**
- `id`: Unique identifier for the command.
- `name`: Command name (e.g., `say_hello`).
- `description`: Description of the command.
- `id`: Unique identifier for the command.
- `name`: Command name (e.g., `say_hello`).
- `description`: Description of the command.
- **Method:** `get_htmx_params()` generates HTMX attributes.
#### 2. `mk_button`
Simplifies the creation of interactive buttons linked to commands.
- **Arguments:**
- `element` (str): The label for the button.
- `command` (Command): Command associated with the button.
- `kwargs`: Additional button attributes.
- `element` (str): The label for the button.
- `command` (Command): Command associated with the button.
- `kwargs`: Additional button attributes.
#### 3. `LoginPage`
Predefined login page that provides a UI template ready for integration.
- **Constructor Parameters:**
- `settings_manager`: Configuration/settings object.
- `error_message`: Optional error message to display.
- `success_message`: Optional success message to display.
- `settings_manager`: Configuration/settings object.
- `error_message`: Optional error message to display.
- `success_message`: Optional success message to display.
---
@@ -177,7 +226,6 @@ Predefined login page that provides a UI template ready for integration.
No custom exceptions defined yet. (Placeholder for future use.)
## Relase History
* 0.1.0 : First release

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

@@ -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;
}

View File

@@ -1,11 +0,0 @@
from fasthtml.components import *
from myfasthtml.core.commands import Command
def mk_button(element, command: Command = None, **kwargs):
if command is None:
return Button(element, **kwargs)
htmx = command.get_htmx_params()
return Button(element, **htmx, **kwargs)

View 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

View File

@@ -0,0 +1,250 @@
import logging
import uuid
from enum import Enum
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 UpdateMode(Enum):
ValueChange = "ValueChange"
AttributePresence = "AttributePresence"
class DetectionMode(Enum):
ValueChange = "ValueChange"
AttributePresence = "AttributePresence"
class AttrChangedDetection:
"""
Base class for detecting changes in an attribute of a data object.
The when a modification is triggered we can
* Search for the attribute that is modified (usual case)
* Look if the attribute is present in the data object (for example when a checkbox is toggled)
"""
def __init__(self, attr):
self.attr = attr
def matches(self, values):
pass
class ValueChangedDetection(AttrChangedDetection):
"""
Search for the attribute that is modified.
"""
def matches(self, values):
for key, value in values.items():
if key == self.attr:
return True, value
return False, None
class AttrPresentDetection(AttrChangedDetection):
"""
Search if the attribute is present in the data object.
"""
def matches(self, values):
return True, values.get(self.attr, None)
class FtUpdate:
def update(self, ft, ft_name, ft_attr, old, new):
pass
class ValueChangeFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new):
# simple mode, just update the text or the attribute
if ft_attr is None:
ft.children = (new,)
else:
ft.attrs[ft_attr] = new
return ft
class AttributePresenceFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new):
# attribute presence mode, toggle the attribute (add or remove it)
if ft_attr is None:
ft.children = (bool(new),)
else:
ft.attrs[ft_attr] = "true" if new else None # FastHtml auto remove None attributes
return ft
class DataConverter:
def convert(self, data):
pass
class BooleanConverter(DataConverter):
def convert(self, data):
if data is None:
return False
if isinstance(data, int):
return data != 0
if str(data).lower() in ("true", "yes", "on"):
return True
return False
class Binding:
def __init__(self, data,
attr=None,
data_converter: DataConverter = None,
ft=None,
ft_name=None,
ft_attr=None,
detection_mode: DetectionMode = DetectionMode.ValueChange,
update_mode: UpdateMode = UpdateMode.ValueChange):
"""
Creates a new binding object between a data object used as a pivot and an HTML element.
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.data_converter = data_converter
self.ft = self._safe_ft(ft)
self.ft_name = ft_name
self.ft_attr = ft_attr
self.detection_mode = detection_mode
self.update_mode = update_mode
self._detection = self._factory(detection_mode)
self._update = self._factory(update_mode)
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,
data_converter: DataConverter = None,
detection_mode: DetectionMode = None,
update_mode: UpdateMode = None):
"""
Update the elements to bind to
:param ft:
:param name:
:param attr:
:param data_converter:
:param detection_mode:
:param update_mode:
:return:
"""
self.ft = self._safe_ft(ft)
self.ft_name = name
self.ft_attr = attr or self.ft_attr
self.data_converter = data_converter or self.data_converter
self.detection_mode = detection_mode or self.detection_mode
self.update_mode = update_mode or self.update_mode
self._detection = self._factory(self.detection_mode)
self._update = self._factory(self.update_mode)
return self
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}'")
self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, 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=}.")
matches, value = self._detection.matches(values)
if matches:
setattr(self.data, self.data_attr, self.data_converter.convert(value) if self.data_converter else 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 _factory(self, mode):
if mode == DetectionMode.ValueChange:
return ValueChangedDetection(self.ft_name)
elif mode == DetectionMode.AttributePresence:
return AttrPresentDetection(self.ft_name)
elif mode == UpdateMode.ValueChange:
return ValueChangeFtUpdate()
elif mode == UpdateMode.AttributePresence:
return AttributePresenceFtUpdate()
else:
raise ValueError(f"Invalid detection mode: {mode}")
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:
@@ -32,12 +25,13 @@ class BaseCommand:
self.id = uuid.uuid4()
self.name = name
self.description = description
self.htmx_extra = {}
# register the command
CommandsManager.register(self)
def get_htmx_params(self):
return {
return self.htmx_extra | {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-vals": f'{{"c_id": "{self.id}"}}',
}
@@ -45,6 +39,11 @@ class BaseCommand:
def execute(self):
raise NotImplementedError
def htmx(self, target=None):
if target:
self.htmx_extra["hx-target"] = target
return self
class Command(BaseCommand):
"""
@@ -92,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

@@ -2,3 +2,4 @@ ROUTE_ROOT = "/myfasthtml"
class Routes:
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):
@@ -17,3 +25,112 @@ def mount_if_not_exists(app, path: str, sub_app):
if not is_mounted:
app.mount(path, app=sub_app)
def merge_classes(*args):
all_elements = []
for element in args:
if element is None or element == '':
continue
if isinstance(element, (tuple, list, set)):
all_elements.extend(element)
elif isinstance(element, dict):
if "cls" in element:
all_elements.append(element.pop("cls"))
elif "class" in element:
all_elements.append(element.pop("class"))
elif isinstance(element, str):
all_elements.append(element)
else:
raise ValueError(f"Cannot merge {element} of type {type(element)}")
if all_elements:
# Remove duplicates while preserving order
unique_elements = list(dict.fromkeys(all_elements))
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

View 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)

View 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)

View 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)

View File

@@ -1,3 +1,4 @@
import logging
from importlib.resources import files
from pathlib import Path
from typing import Optional, Any
@@ -8,6 +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.utils import utils_app
logger = logging.getLogger("MyFastHtml")
def get_asset_path(filename):
@@ -25,7 +29,7 @@ def get_asset_content(filename):
return get_asset_path(filename).read_text()
def create_app(daisyui: Optional[bool] = False,
def create_app(daisyui: Optional[bool] = True,
protect_routes: Optional[bool] = True,
mount_auth_app: Optional[bool] = False,
**kwargs) -> Any:
@@ -50,10 +54,10 @@ def create_app(daisyui: Optional[bool] = False,
:return: A tuple containing the FastHtml application instance and the associated router.
:rtype: Any
"""
hdrs = []
hdrs = [Link(href="/myfasthtml/myfasthtml.css", rel="stylesheet", type="text/css")]
if daisyui:
hdrs = [
hdrs += [
Link(href="/myfasthtml/daisyui-5.css", rel="stylesheet", type="text/css"),
Link(href="/myfasthtml/daisyui-5-themes.css", rel="stylesheet", type="text/css"),
Script(src="/myfasthtml/tailwindcss-browser@4.js"),
@@ -84,6 +88,9 @@ def create_app(daisyui: Optional[bool] = False,
# and put it back after the myfasthtml static files routes
app.routes.append(static_route_exts_get)
# route the commands and the bindings
app.mount("/myfasthtml", utils_app)
if mount_auth_app:
# Setup authentication routes
setup_auth_routes(app, rt)

View File

View File

@@ -3,7 +3,7 @@ from dataclasses import dataclass
from fastcore.basics import NotStr
from myfasthtml.core.testclient import MyFT
from myfasthtml.test.testclient import MyFT
class Predicate:

View File

@@ -10,7 +10,15 @@ 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',
'hx_post': 'POST',
'hx_put': 'PUT',
'hx_delete': 'DELETE',
'hx_patch': 'PATCH',
}
@dataclass
@@ -29,7 +37,7 @@ class TestableElement:
or verifying element properties.
"""
def __init__(self, client, source):
def __init__(self, client, source, tag=None):
"""
Initialize a testable element.
@@ -39,18 +47,35 @@ class TestableElement:
"""
self.client = client
if isinstance(source, str):
self.html_fragment = source
tag = BeautifulSoup(source, 'html.parser').find()
self.ft = MyFT(tag.name, tag.attrs)
self.html_fragment = source.strip()
elif isinstance(source, Tag):
self.html_fragment = str(source)
self.ft = MyFT(source.name, source.attrs)
self.html_fragment = str(source).strip()
elif isinstance(source, FT):
self.ft = source
self.html_fragment = to_xml(source).strip()
else:
raise ValueError(f"Invalid source '{source}' for TestableElement.")
self.tag, self.element, self.my_ft = self._parse(tag, self.html_fragment)
self.fields_mapping = {} # link between the input label and the input name
self.fields = {} # Values of the fields {name: value}
self.select_fields = {} # list of possible options for 'select' input fields
self._update_fields_mapping()
self._update_fields()
def fill(self, **kwargs):
"""
Fill the form with the given data.
Args:
**kwargs: Field names and their values to fill in the form.
"""
for name, value in kwargs.items():
field_name = self._translate(name)
if field_name not in self.fields:
raise ValueError(f"Invalid field name '{name}'.")
self.fields[self._translate(name)] = value
def click(self):
"""Click the element (to be implemented)."""
return self._send_htmx_request()
@@ -59,6 +84,26 @@ class TestableElement:
"""Check if element matches given FastHTML element (to be implemented)."""
pass
def _translate(self, field):
"""
Translate a given field using a predefined mapping. If the field is not found
in the mapping, the original field is returned unmodified.
:param field: The field name to be translated.
:type field: str
:return: The translated field name if present in the mapping, or the original
field name if no mapping exists for it.
:rtype: str
"""
return self.fields_mapping.get(field, field)
def _support_htmx(self):
"""Check if the element supports HTMX."""
return ('hx_get' in self.my_ft.attrs or
'hx-get' in self.my_ft.attrs or
'hx_post' in self.my_ft.attrs or
'hx-post' in self.my_ft.attrs)
def _send_htmx_request(self, json_data: dict | None = None, data: dict | None = None) -> Response:
"""
Simulates an HTMX request in Python for unit testing.
@@ -83,16 +128,20 @@ class TestableElement:
method = "GET" # HTMX defaults to GET if not specified
url = None
verbs = {
'hx_get': 'GET',
'hx_post': 'POST',
'hx_put': 'PUT',
'hx_delete': 'DELETE',
'hx_patch': 'PATCH',
}
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.ft.attrs or {}
element_attrs = self.my_ft.attrs or {}
# Build the attributes
for key, value in element_attrs.items():
@@ -109,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
@@ -124,180 +172,13 @@ class TestableElement:
# Sanity check
if url is None:
raise ValueError(
f"The <{self.ft.tag}> element has no HTMX verb attribute "
f"The <{self.my_ft.tag}> element has no HTMX verb attribute "
"(e.g., hx_get, hx_post) to define a URL."
)
# Send the request
return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data)
def _support_htmx(self):
"""Check if the element supports HTMX."""
return ('hx_get' in self.ft.attrs or
'hx-get' in self.ft.attrs or
'hx_post' in self.ft.attrs or
'hx-post' in self.ft.attrs)
class TestableForm(TestableElement):
"""
Represents an HTML form that can be filled and submitted in tests.
"""
def __init__(self, client, source):
"""
Initialize a testable form.
Args:
client: The MyTestClient instance.
source: The source HTML string containing a form.
"""
super().__init__(client, source)
self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form')
self.fields_mapping = {} # link between the input label and the input name
self.fields = {} # field name; field value
self.select_fields = {} # list of possible options for 'select' input fields
self._update_fields_mapping()
self.update_fields()
def update_fields(self):
"""
Update the fields dictionary with current form values and their proper types.
This method processes all input and select elements in the form:
- Determines the appropriate Python type (str, int, float, bool) based on
the HTML input type attribute and/or the value itself
- For select elements, populates self.select_fields with available options
- Stores the final typed values in self.fields
Type conversion priority:
1. HTML type attribute (checkbox bool, number int/float, etc.)
2. Value analysis fallback for ambiguous types (text/hidden/absent type)
"""
self.fields = {}
self.select_fields = {}
# Process input fields
for input_field in self.form.find_all('input'):
name = input_field.get('name')
if not name:
continue
input_type = input_field.get('type', 'text').lower()
raw_value = input_field.get('value', '')
# Type conversion based on input type
if input_type == 'checkbox':
# Checkbox: bool based on 'checked' attribute
self.fields[name] = input_field.has_attr('checked')
elif input_type == 'radio':
# Radio: str value (only if checked)
if input_field.has_attr('checked'):
self.fields[name] = raw_value
elif name not in self.fields:
# If no radio is checked yet, don't set a default
pass
elif input_type == 'number':
# Number: int or float based on value
self.fields[name] = self._convert_number(raw_value)
else:
# Other types (text, hidden, email, password, etc.): analyze value
self.fields[name] = self._convert_value(raw_value)
# Process select fields
for select_field in self.form.find_all('select'):
name = select_field.get('name')
if not name:
continue
# Extract all options
options = []
selected_value = None
for option in select_field.find_all('option'):
option_value = option.get('value', option.get_text(strip=True))
option_text = option.get_text(strip=True)
options.append({
'value': option_value,
'text': option_text
})
# Track selected option
if option.has_attr('selected'):
selected_value = option_value
# Store options list
self.select_fields[name] = options
# Store selected value (or first option if none selected)
if selected_value is not None:
self.fields[name] = selected_value
elif options:
self.fields[name] = options[0]['value']
def fill(self, **kwargs):
"""
Fill the form with the given data.
Args:
**kwargs: Field names and their values to fill in the form.
"""
for name, value in kwargs.items():
field_name = self.translate(name)
if field_name not in self.fields:
raise ValueError(f"Invalid field name '{name}'.")
self.fields[self.translate(name)] = value
def submit(self):
"""
Submit the form.
This method handles both HTMX-enabled forms and classic HTML form submissions:
- If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request
- Otherwise, simulates a classic browser form submission using the form's
action and method attributes
Returns:
The response from the form submission.
Raises:
ValueError: If the form has no action attribute for classic submission.
"""
# Check if the form supports HTMX
if self._support_htmx():
return self._send_htmx_request(data=self.fields)
# Classic form submission
action = self.form.get('action')
if not action or action.strip() == '':
raise ValueError(
"The form has no 'action' attribute. "
"Cannot submit a classic form without a target URL."
)
method = self.form.get('method', 'post').upper()
# Prepare headers for classic form submission
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
# Send the request via the client
return self.client.send_request(
method=method,
url=action,
headers=headers,
data=self.fields
)
def translate(self, field):
return self.fields_mapping.get(field, field)
def _update_fields_mapping(self):
"""
Build a mapping between label text and input field names.
@@ -319,16 +200,16 @@ class TestableForm(TestableElement):
unnamed_counter = 0
# Get all inputs in the form
all_inputs = self.form.find_all('input')
all_inputs = self.element.find_all('input')
# Priority 1 & 2: Explicit association (for/id) and implicit (nested)
for label in self.form.find_all('label'):
for label in self.element.find_all('label'):
label_text = label.get_text(strip=True)
# Check for explicit association via 'for' attribute
label_for = label.get('for')
if label_for:
input_field = self.form.find('input', id=label_for)
input_field = self.element.find('input', id=label_for)
if input_field:
input_name = self._get_input_identifier(input_field, unnamed_counter)
if input_name.startswith('unnamed_'):
@@ -348,7 +229,7 @@ class TestableForm(TestableElement):
continue
# Priority 3 & 4: Parent-level associations
for label in self.form.find_all('label'):
for label in self.element.find_all('label'):
label_text = label.get_text(strip=True)
# Skip if this label was already processed
@@ -391,6 +272,85 @@ class TestableForm(TestableElement):
unnamed_counter += 1
self.fields_mapping[input_name] = input_name
def _update_fields(self):
"""
Update the fields dictionary with current form values and their proper types.
This method processes all input and select elements in the form:
- Determines the appropriate Python type (str, int, float, bool) based on
the HTML input type attribute and/or the value itself
- For select elements, populates self.select_fields with available options
- Stores the final typed values in self.fields
Type conversion priority:
1. HTML type attribute (checkbox bool, number int/float, etc.)
2. Value analysis fallback for ambiguous types (text/hidden/absent type)
"""
self.fields = {}
self.select_fields = {}
# Process input fields
for input_field in self.element.find_all('input'):
name = input_field.get('name')
if not name:
continue
input_type = input_field.get('type', 'text').lower()
raw_value = input_field.get('value', '')
# Type conversion based on input type
if input_type == 'checkbox':
# Checkbox: bool based on 'checked' attribute
self.fields[name] = input_field.has_attr('checked')
elif input_type == 'radio':
# Radio: str value (only if checked)
if input_field.has_attr('checked'):
self.fields[name] = raw_value
elif name not in self.fields:
# If no radio is checked yet, don't set a default
pass
elif input_type == 'number':
# Number: int or float based on value
self.fields[name] = self._convert_number(raw_value)
else:
# Other types (text, hidden, email, password, etc.): analyze value
self.fields[name] = self._convert_value(raw_value)
# Process select fields
for select_field in self.element.find_all('select'):
name = select_field.get('name')
if not name:
continue
# Extract all options
options = []
selected_value = None
for option in select_field.find_all('option'):
option_value = option.get('value', option.get_text(strip=True))
option_text = option.get_text(strip=True)
options.append({
'value': option_value,
'text': option_text
})
# Track selected option
if option.has_attr('selected'):
selected_value = option_value
# Store options list
self.select_fields[name] = options
# Store selected value (or first option if none selected)
if selected_value is not None:
self.fields[name] = selected_value
elif options:
self.fields[name] = options[0]['value']
@staticmethod
def _get_input_identifier(input_field, counter):
"""
@@ -474,6 +434,476 @@ class TestableForm(TestableElement):
# Default to string
return value
@staticmethod
def _parse(tag, html_fragment: str):
elt = BeautifulSoup(html_fragment, 'html.parser')
if len(elt) == 0:
raise ValueError(f"No HTML element found in: {html_fragment}")
if len(elt) == 1:
elt = elt.find()
elt_tag = elt.name
if tag is not None and tag != elt_tag:
raise ValueError(f"Tag '{tag}' does not match with '{html_fragment}'.")
my_ft = MyFT(elt_tag, elt.attrs)
if elt_tag != "form":
elt = BeautifulSoup(f"<form>{html_fragment}</form>", 'html.parser')
return elt_tag, elt, my_ft
else:
if tag is None:
raise ValueError(f"Multiple elements found in {html_fragment}. Please specify a tag.")
elt = BeautifulSoup(f"<form>{html_fragment}</form>", 'html.parser')
_inner = elt.find(tag)
my_ft = MyFT(_inner.name, _inner.attrs)
return tag, elt.find(), my_ft
class TestableForm(TestableElement):
"""
Represents an HTML form that can be filled and submitted in tests.
"""
def __init__(self, client, source):
"""
Initialize a testable form.
Args:
client: The MyTestClient instance.
source: The source HTML string containing a form.
"""
super().__init__(client, source, "form")
# self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form')
# self.fields_mapping = {} # link between the input label and the input name
# self.fields = {} # field name; field value
# self.select_fields = {} # list of possible options for 'select' input fields
#
# self._update_fields_mapping()
# self.update_fields()
# def update_fields(self):
# """
# Update the fields dictionary with current form values and their proper types.
#
# This method processes all input and select elements in the form:
# - Determines the appropriate Python type (str, int, float, bool) based on
# the HTML input type attribute and/or the value itself
# - For select elements, populates self.select_fields with available options
# - Stores the final typed values in self.fields
#
# Type conversion priority:
# 1. HTML type attribute (checkbox → bool, number → int/float, etc.)
# 2. Value analysis fallback for ambiguous types (text/hidden/absent type)
# """
# self.fields = {}
# self.select_fields = {}
#
# # Process input fields
# for input_field in self.form.find_all('input'):
# name = input_field.get('name')
# if not name:
# continue
#
# input_type = input_field.get('type', 'text').lower()
# raw_value = input_field.get('value', '')
#
# # Type conversion based on input type
# if input_type == 'checkbox':
# # Checkbox: bool based on 'checked' attribute
# self.fields[name] = input_field.has_attr('checked')
#
# elif input_type == 'radio':
# # Radio: str value (only if checked)
# if input_field.has_attr('checked'):
# self.fields[name] = raw_value
# elif name not in self.fields:
# # If no radio is checked yet, don't set a default
# pass
#
# elif input_type == 'number':
# # Number: int or float based on value
# self.fields[name] = self._convert_number(raw_value)
#
# else:
# # Other types (text, hidden, email, password, etc.): analyze value
# self.fields[name] = self._convert_value(raw_value)
#
# # Process select fields
# for select_field in self.form.find_all('select'):
# name = select_field.get('name')
# if not name:
# continue
#
# # Extract all options
# options = []
# selected_value = None
#
# for option in select_field.find_all('option'):
# option_value = option.get('value', option.get_text(strip=True))
# option_text = option.get_text(strip=True)
#
# options.append({
# 'value': option_value,
# 'text': option_text
# })
#
# # Track selected option
# if option.has_attr('selected'):
# selected_value = option_value
#
# # Store options list
# self.select_fields[name] = options
#
# # Store selected value (or first option if none selected)
# if selected_value is not None:
# self.fields[name] = selected_value
# elif options:
# self.fields[name] = options[0]['value']
#
def submit(self):
"""
Submit the form.
This method handles both HTMX-enabled forms and classic HTML form submissions:
- If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request
- Otherwise, simulates a classic browser form submission using the form's
action and method attributes
Returns:
The response from the form submission.
Raises:
ValueError: If the form has no action attribute for classic submission.
"""
# Check if the form supports HTMX
if self._support_htmx():
return self._send_htmx_request(data=self.fields)
# Classic form submission
action = self.element.get('action')
if not action or action.strip() == '':
raise ValueError(
"The form has no 'action' attribute. "
"Cannot submit a classic form without a target URL."
)
method = self.element.get('method', 'post').upper()
# Prepare headers for classic form submission
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
# Send the request via the client
return self.client.send_request(
method=method,
url=action,
headers=headers,
data=self.fields
)
# def _translate(self, field):
# """
# Translate a given field using a predefined mapping. If the field is not found
# in the mapping, the original field is returned unmodified.
#
# :param field: The field name to be translated.
# :type field: str
# :return: The translated field name if present in the mapping, or the original
# field name if no mapping exists for it.
# :rtype: str
# """
# return self.fields_mapping.get(field, field)
#
# def _update_fields_mapping(self):
# """
# Build a mapping between label text and input field names.
#
# This method finds all labels in the form and associates them with their
# corresponding input fields using the following priority order:
# 1. Explicit association via 'for' attribute matching input 'id'
# 2. Implicit association (label contains the input)
# 3. Parent-level association with 'for'/'id'
# 4. Proximity association (siblings in same parent)
# 5. No label (use input name as key)
#
# The mapping is stored in self.fields_mapping as {label_text: input_name}.
# For inputs without a name, the id is used. If neither exists, a generic
# key like "unnamed_0" is generated.
# """
# self.fields_mapping = {}
# processed_inputs = set()
# unnamed_counter = 0
#
# # Get all inputs in the form
# all_inputs = self.form.find_all('input')
#
# # Priority 1 & 2: Explicit association (for/id) and implicit (nested)
# for label in self.form.find_all('label'):
# label_text = label.get_text(strip=True)
#
# # Check for explicit association via 'for' attribute
# label_for = label.get('for')
# if label_for:
# input_field = self.form.find('input', id=label_for)
# if input_field:
# input_name = self._get_input_identifier(input_field, unnamed_counter)
# if input_name.startswith('unnamed_'):
# unnamed_counter += 1
# self.fields_mapping[label_text] = input_name
# processed_inputs.add(id(input_field))
# continue
#
# # Check for implicit association (label contains input)
# input_field = label.find('input')
# if input_field:
# input_name = self._get_input_identifier(input_field, unnamed_counter)
# if input_name.startswith('unnamed_'):
# unnamed_counter += 1
# self.fields_mapping[label_text] = input_name
# processed_inputs.add(id(input_field))
# continue
#
# # Priority 3 & 4: Parent-level associations
# for label in self.form.find_all('label'):
# label_text = label.get_text(strip=True)
#
# # Skip if this label was already processed
# if label_text in self.fields_mapping:
# continue
#
# parent = label.parent
# if parent:
# input_found = False
#
# # Priority 3: Look for sibling input with matching for/id
# label_for = label.get('for')
# if label_for:
# for sibling in parent.find_all('input'):
# if sibling.get('id') == label_for and id(sibling) not in processed_inputs:
# input_name = self._get_input_identifier(sibling, unnamed_counter)
# if input_name.startswith('unnamed_'):
# unnamed_counter += 1
# self.fields_mapping[label_text] = input_name
# processed_inputs.add(id(sibling))
# input_found = True
# break
#
# # Priority 4: Fallback to proximity if no input found yet
# if not input_found:
# for sibling in parent.find_all('input'):
# if id(sibling) not in processed_inputs:
# input_name = self._get_input_identifier(sibling, unnamed_counter)
# if input_name.startswith('unnamed_'):
# unnamed_counter += 1
# self.fields_mapping[label_text] = input_name
# processed_inputs.add(id(sibling))
# break
#
# # Priority 5: Inputs without labels
# for input_field in all_inputs:
# if id(input_field) not in processed_inputs:
# input_name = self._get_input_identifier(input_field, unnamed_counter)
# if input_name.startswith('unnamed_'):
# unnamed_counter += 1
# self.fields_mapping[input_name] = input_name
#
# @staticmethod
# def _get_input_identifier(input_field, counter):
# """
# Get the identifier for an input field.
#
# Args:
# input_field: The BeautifulSoup Tag object representing the input.
# counter: Current counter for unnamed inputs.
#
# Returns:
# The input name, id, or a generated "unnamed_X" identifier.
# """
# if input_field.get('name'):
# return input_field['name']
# elif input_field.get('id'):
# return input_field['id']
# else:
# return f"unnamed_{counter}"
#
# @staticmethod
# def _convert_number(value):
# """
# Convert a string value to int or float.
#
# Args:
# value: String value to convert.
#
# Returns:
# int, float, or empty string if conversion fails.
# """
# if not value or value.strip() == '':
# return ''
#
# try:
# # Try float first to detect decimal numbers
# if '.' in value or 'e' in value.lower():
# return float(value)
# else:
# return int(value)
# except ValueError:
# return value
#
# @staticmethod
# def _convert_value(value):
# """
# Analyze and convert a value to its appropriate type.
#
# Conversion priority:
# 1. Boolean keywords (true/false)
# 2. Float (contains decimal point)
# 3. Int (numeric)
# 4. Empty string
# 5. String (default)
#
# Args:
# value: String value to convert.
#
# Returns:
# Converted value with appropriate type (bool, float, int, or str).
# """
# if not value or value.strip() == '':
# return ''
#
# value_lower = value.lower().strip()
#
# # Check for boolean
# if value_lower in ('true', 'false'):
# return value_lower == 'true'
#
# # Check for numeric values
# try:
# # Check for float (has decimal point or scientific notation)
# if '.' in value or 'e' in value_lower:
# return float(value)
# # Try int
# else:
# return int(value)
# except ValueError:
# pass
#
# # Default to string
# return value
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))
@property
def name(self):
return self._input_name
@property
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
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):
# """Return the current user-facing value of an HTML input-like element."""
# if tag.name == 'input':
# t = tag.get('type', 'text').lower()
# if t in ('checkbox', 'radio'):
# # For checkbox/radio: return True/False if checked, else value if defined
# return tag.has_attr('checked')
# return tag.get('value', '')
#
# elif tag.name == 'textarea':
# # Textarea content is its text, not an attribute
# return tag.text or ''
#
# elif tag.name == 'select':
# # For <select>, get selected option value (or text if no value attr)
# selected = tag.find('option', selected=True)
# if selected:
# return selected.get('value', selected.text)
# first = tag.find('option')
# return first.get('value', first.text) if first else ''
#
# else:
# raise TypeError(f"Unsupported tag: <{tag.name}>")
#
#
# def _update_value(tag, new_value):
# """Simulate user input by updating the value of <input>, <textarea>, or <select>."""
# if tag.name == 'input':
# t = tag.get('type', 'text').lower()
# if t in ('checkbox', 'radio'):
# # For checkbox/radio: treat True/False as checked/unchecked
# if isinstance(new_value, bool):
# if new_value:
# tag['checked'] = ''
# elif 'checked' in tag.attrs:
# del tag.attrs['checked']
# else:
# tag['value'] = str(new_value)
# else:
# tag['value'] = str(new_value)
#
# elif tag.name == 'textarea':
# tag.string = str(new_value)
#
# elif tag.name == 'select':
# # Deselect all options
# for option in tag.find_all('option'):
# option.attrs.pop('selected', None)
# # Select matching one by value or text
# matched = tag.find('option', value=str(new_value))
# if matched:
# matched['selected'] = ''
# else:
# matched = tag.find('option', string=str(new_value))
# if matched:
# matched['selected'] = ''
#
# else:
# raise TypeError(f"Unsupported tag: <{tag.name}>")
class MyTestClient:
"""
@@ -500,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:
"""
@@ -528,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,
@@ -566,7 +998,6 @@ class MyTestClient:
def clean_text(txt):
return "\n".join(line for line in txt.splitlines() if line.strip())
if self._content is None:
raise ValueError(
"No page content available. Call open() before should_see()."
@@ -578,9 +1009,9 @@ class MyTestClient:
# Provide a snippet of the actual content for debugging
snippet_length = 200
content_snippet = clean_text(
visible_text[:snippet_length] + "..."
if len(visible_text) > snippet_length
else visible_text
visible_text[:snippet_length] + "..."
if len(visible_text) > snippet_length
else visible_text
)
raise AssertionError(
f"Expected to see '{text}' in page content but it was not found.\n"
@@ -665,7 +1096,7 @@ class MyTestClient:
f"No element found matching selector '{selector}'."
)
elif len(results) == 1:
return TestableElement(self, results[0])
return self._testable_element_factory(results[0])
else:
raise AssertionError(
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
@@ -695,7 +1126,7 @@ class MyTestClient:
remaining = []
for form in results:
testable_form = TestableForm(self, form)
if all(testable_form.translate(field) in testable_form.fields for field in fields):
if all(testable_form._translate(field) in testable_form.fields for field in fields):
remaining.append(testable_form)
if len(remaining) == 1:
@@ -725,6 +1156,14 @@ class MyTestClient:
self._soup = BeautifulSoup(content, 'html.parser')
return self
def _testable_element_factory(self, elt):
if elt.name == "input":
if elt.get("type") == "checkbox":
return TestableCheckbox(self, elt)
return TestableInput(self, elt)
else:
return TestableElement(self, elt, elt.name)
@staticmethod
def _find_visible_text_element(soup, text: str):
"""

View File

@@ -7,7 +7,7 @@ from fasthtml.fastapp import fast_app
import myfasthtml.auth.utils
from myfasthtml.auth.routes import setup_auth_routes
from myfasthtml.auth.utils import create_auth_beforeware, register_user
from myfasthtml.core.testclient import MyTestClient
from myfasthtml.test.testclient import MyTestClient
@dataclass

View File

@@ -2,7 +2,7 @@ import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.testclient import MyTestClient
from myfasthtml.test.testclient import MyTestClient
def test_non_protected_route():
app, rt = fast_app()

View File

View 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
View File

134
tests/core/test_bindings.py Normal file
View File

@@ -0,0 +1,134 @@
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, DetectionMode
@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]
def test_i_can_react_to_value_change(data):
elt = Input(name="input_elt", value="hello")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="value")
res = binding.update({"input_elt": "new value"})
assert len(res) == 1
def test_i_do_not_react_to_other_value_change(data):
elt = Input(name="input_elt", value="hello")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="value")
res = binding.update({"other_input_elt": "new value"})
assert res is None
def test_i_can_react_to_attr_presence(data):
elt = Input(name="input_elt", type="checkbox")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="checked",
detection_mode=DetectionMode.AttributePresence)
res = binding.update({"checked": "true"})
assert len(res) == 1
def test_i_can_react_to_attr_non_presence(data):
elt = Input(name="input_elt", type="checkbox")
binding = Binding(data, ft=elt, ft_name="input_elt", ft_attr="checked",
detection_mode=DetectionMode.AttributePresence)
res = binding.update({})
assert len(res) == 1

View File

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

103
tests/test_integration.py Normal file
View File

@@ -0,0 +1,103 @@
from dataclasses import dataclass
from typing import Any
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, DetectionMode, UpdateMode, BooleanConverter
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.test.testclient import MyTestClient, TestableElement
def new_value(value):
return value
@dataclass
class Data:
value: Any
@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_input(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
def test_i_can_bind_checkbox(self, user, rt):
@rt("/")
def index():
data = Data(True)
input_elt = Input(name="input_name", type="checkbox")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data, ft_attr="checked",
detection_mode=DetectionMode.AttributePresence,
update_mode=UpdateMode.AttributePresence,
data_converter=BooleanConverter()))
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.check()
user.should_see("True")
testable_input.uncheck()
user.should_see("False")

View File

@@ -1,53 +0,0 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.controls.button import mk_button
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.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")

View File

@@ -2,9 +2,9 @@ import pytest
from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.core.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput
from myfasthtml.core.testclient import MyFT
from myfasthtml.test.testclient import MyFT
@pytest.mark.parametrize('actual, expected', [

View File

@@ -2,7 +2,7 @@ import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement, TestableForm
from myfasthtml.test.testclient import MyTestClient, TestableElement, TestableForm
class TestMyTestClientOpen:

View 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"

View File

@@ -2,14 +2,14 @@ import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement, MyFT
from myfasthtml.test.testclient import MyTestClient, TestableElement, MyFT
def test_i_can_create_testable_element_from_ft():
ft = Div("hello world", id="test")
testable_element = TestableElement(None, ft)
assert testable_element.ft == ft
assert testable_element.my_ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'
@@ -17,7 +17,7 @@ def test_i_can_create_testable_element_from_str():
ft = '<div id="test">hello world</div>'
testable_element = TestableElement(None, ft)
assert testable_element.ft == MyFT('div', {'id': 'test'})
assert testable_element.my_ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'
@@ -27,7 +27,7 @@ def test_i_can_create_testable_element_from_beautifulsoup_element():
tag = BeautifulSoup(ft, 'html.parser').div
testable_element = TestableElement(None, tag)
assert testable_element.ft == MyFT('div', {'id': 'test'})
assert testable_element.my_ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'

View File

@@ -1,7 +1,7 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import TestableForm, MyTestClient
from myfasthtml.test.testclient import TestableForm, MyTestClient
@pytest.fixture

View 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'"