2 Commits

Author SHA1 Message Date
063a89f143 I can use commands 2025-10-24 22:58:48 +02:00
0f5fc696f0 Added icons
Updated README.md
Started test framework utils
2025-10-24 12:24:10 +02:00
26 changed files with 88445 additions and 2 deletions

180
README.md
View File

@@ -1,3 +1,179 @@
# MyFastHtml Module # MyFastHtml
Set of tools to quickly create HTML pages using FastHTML. 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.
- A system for control state persistence.
---
## Features
- **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.
> _**Note:** Support for state persistence is currently under construction._
---
## Installation
Ensure you have Python >= 3.12 installed, then install the library with `pip`:
```bash
pip install myfasthtml
```
---
## Quick Start
Heres a simple example of creating an **interactive button** linked to a command:
### Example: Button with a Command
```python
from fasthtml.fastapp import fast_app
from myfasthtml.core.commands import Command
from myfasthtml.controls.button import mk_button
# 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 and define a route with a button
app, rt = fast_app(default_hdrs=False)
@rt("/")
def get_homepage():
return mk_button("Click Me!", command=hello_command)
```
- When the button is clicked, the `say_hello` command will be executed, and the server will return the response.
- HTMX automatically handles the client-server interaction behind the scenes.
---
## 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.
---
## 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.
#### 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}"
my_command = Command("custom", "Handles custom logic", custom_action)
# Get the HTMX parameters automatically
htmx_attrs = my_command.get_htmx_params()
print(htmx_attrs)
# Output:
# {
# "hx-post": "/commands",
# "hx-vals": '{"c_id": "unique-command-id"}'
# }
```
Use the `get_htmx_params()` method to directly integrate commands into HTML components.
---
## 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.
For detailed guidelines, see the [Contributing Section](./CONTRIBUTING.md) (coming soon).
---
## License
This project is licensed under the terms of the MIT License. See the `LICENSE` file for details.
---
## Technical Overview
### Project Structure
```
MyFastHtml
├── src
│ ├── myfasthtml/ # Main library code
│ │ ├── core/commands.py # Command definitions
│ │ ├── controls/button.py # Control helpers
│ │ └── pages/LoginPage.py # Predefined Login page
│ └── ...
├── tests # Unit and integration tests
├── LICENSE # License file (MIT)
├── README.md # Project documentation
└── pyproject.toml # Build configuration
```
### 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.
- **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.
#### 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.
---
## Entry Points
- `/commands`: Handles HTMX requests from the command attributes.
---
## Exceptions
No custom exceptions defined yet. (Placeholder for future use.)
```

34
requirements.txt Normal file
View File

@@ -0,0 +1,34 @@
anyio==4.11.0
apsw==3.50.4.0
apswutils==0.1.0
beautifulsoup4==4.14.2
certifi==2025.10.5
click==8.3.0
fastcore==1.8.13
fastlite==0.2.1
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
iniconfig==2.3.0
itsdangerous==2.2.0
oauthlib==3.3.1
packaging==25.0
pluggy==1.6.0
Pygments==2.19.2
pytest==8.4.2
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
python-fasthtml==0.12.30
python-multipart==0.0.20
PyYAML==6.0.3
six==1.17.0
sniffio==1.3.1
soupsieve==2.8
starlette==0.48.0
typing_extensions==4.15.0
uvicorn==0.38.0
uvloop==0.22.1
watchfiles==1.1.1
websockets==15.0.1

View File

View File

@@ -0,0 +1,11 @@
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

View File

@@ -0,0 +1,122 @@
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:
"""
Represents the base command class for defining executable actions.
This class serves as a foundation for commands that can be registered,
executed, and utilized within a system. Each command has a unique identifier,
a name, and a description. Commands should override the `execute` method
to provide specific functionality.
:ivar id: A unique identifier for the command.
:type id: uuid.UUID
:ivar name: The name of the command.
:type name: str
:ivar description: A brief description of the command's functionality.
:type description: str
"""
def __init__(self, name, description):
self.id = uuid.uuid4()
self.name = name
self.description = description
# register the command
CommandsManager.register(self)
def get_htmx_params(self):
return {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-vals": f'{{"c_id": "{self.id}"}}',
}
def execute(self):
raise NotImplementedError
class Command(BaseCommand):
"""
Represents a command that encapsulates a callable action with parameters.
This class is designed to hold a defined action (callback) alongside its arguments
and keyword arguments.
:ivar name: The name of the command.
:type name: str
:ivar description: A brief description of the command.
:type description: str
:ivar callback: The function or callable to be executed.
:type callback: Callable
:ivar args: Positional arguments to be passed to the callback.
:type args: tuple
:ivar kwargs: Keyword arguments to be passed to the callback.
:type kwargs: dict
"""
def __init__(self, name, description, callback, *args, **kwargs):
super().__init__(name, description)
self.callback = callback
self.args = args
self.kwargs = kwargs
def execute(self):
return self.callback(*self.args, **self.kwargs)
def __str__(self):
return f"Command({self.name})"
class CommandsManager:
commands = {}
@staticmethod
def register(command: BaseCommand):
CommandsManager.commands[str(command.id)] = command
@staticmethod
def get_command(command_id: str) -> Optional[BaseCommand]:
return CommandsManager.commands.get(command_id)
@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

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

View File

@@ -0,0 +1,463 @@
import json
import uuid
from dataclasses import dataclass
from bs4 import BeautifulSoup, Tag
from fastcore.xml import FT, to_xml
from fasthtml.common import FastHTML
from starlette.responses import Response
from starlette.testclient import TestClient
from myfasthtml.core.commands import mount_commands
@dataclass
class MyFT:
tag: str
attrs: dict
class TestableElement:
"""
Represents an HTML element that can be interacted with in tests.
This class will be used for future interactions like clicking elements
or verifying element properties.
"""
def __init__(self, client, source):
"""
Initialize a testable element.
Args:
client: The MyTestClient instance.
ft: The FastHTML element representation.
"""
self.client = client
if isinstance(source, str):
self.html_fragment = source
tag = BeautifulSoup(source, 'html.parser').find()
self.ft = MyFT(tag.name, tag.attrs)
elif isinstance(source, Tag):
self.html_fragment = str(source)
self.ft = MyFT(source.name, source.attrs)
elif isinstance(source, FT):
self.ft = source
self.html_fragment = to_xml(source).strip()
else:
raise ValueError(f"Invalid source '{source}' for TestableElement.")
def click(self):
"""Click the element (to be implemented)."""
return self._send_htmx_request()
def matches(self, ft):
"""Check if element matches given FastHTML element (to be implemented)."""
pass
def _send_htmx_request(self, json_data: dict | None = None, data: dict | None = None) -> Response:
"""
Simulates an HTMX request in Python for unit testing.
This function reads the 'hx-*' attributes from the FastHTML object
to determine the HTTP method, URL, headers, and body of the request,
then executes it via the TestClient.
Args:
data: (Optional) A dict for form data
(sends as 'application/x-www-form-urlencoded').
json_data: (Optional) A dict for JSON data
(sends as 'application/json').
Takes precedence over 'hx_vals'.
Returns:
The Response object from the simulated request.
"""
# The essential header for FastHTML (and HTMX) to identify the request
headers = {"HX-Request": "true"}
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',
}
# .props contains the kwargs passed to the object (e.g., hx_post="/url")
element_attrs = self.ft.attrs or {}
# Build the attributes
for key, value in element_attrs.items():
# sanitize the key
key = key.lower().strip()
if key.startswith('hx-'):
key = 'hx_' + key[3:]
if key in verbs:
# Verb attribute: defines the method and URL
method = verbs[key]
url = str(value)
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
elif key.startswith('hx_'):
# Any other hx_* attribute is converted to an HTTP header
# e.g.: 'hx_target' -> 'HX-Target'
header_name = '-'.join(p.capitalize() for p in key.split('_'))
headers[header_name] = str(value)
# Sanity check
if url is None:
raise ValueError(
f"The <{self.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)
class MyTestClient:
"""
A test client helper for FastHTML applications that provides
a more user-friendly API for testing HTML responses.
This class wraps Starlette's TestClient and provides methods
to verify page content in a way similar to NiceGui's test fixtures.
"""
def __init__(self, app: FastHTML, parent_levels: int = 1):
"""
Initialize the test client.
Args:
app: The FastHTML application to test.
parent_levels: Number of parent levels to show in error messages (default: 1).
"""
self.app = app
self.client = TestClient(app)
self._content = None
self._soup = None
self._session = str(uuid.uuid4())
self.parent_levels = parent_levels
# make sure that the commands are mounted
mount_commands(self.app)
def open(self, path: str):
"""
Open a page and store its content for subsequent assertions.
Args:
path: The URL path to request (e.g., '/home', '/api/users').
Returns:
self: Returns the client instance for method chaining.
Raises:
AssertionError: If the response status code is not 200.
"""
res = self.client.get(path)
assert res.status_code == 200, (
f"Failed to open '{path}'. "
f"status code={res.status_code} : reason='{res.text}'"
)
self.set_content(res.text)
return self
def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None):
json_data['session'] = self._session
res = self.client.request(
method,
url,
headers=headers,
data=data, # For form data
json=json_data # For JSON bodies (e.g., from hx_vals)
)
assert res.status_code == 200, (
f"Failed to send request '{method=}', {url=}. "
f"status code={res.status_code} : reason='{res.text}'"
)
self.set_content(res.text)
return self
def should_see(self, text: str):
"""
Assert that the given text is present in the visible page content.
This method parses the HTML and searches only in the visible text,
ignoring HTML tags and attributes.
Args:
text: The text string to search for (case-sensitive).
Returns:
self: Returns the client instance for method chaining.
Raises:
AssertionError: If the text is not found in the page content.
ValueError: If no page has been opened yet.
"""
if self._content is None:
raise ValueError(
"No page content available. Call open() before should_see()."
)
visible_text = self._soup.get_text()
if text not in visible_text:
# Provide a snippet of the actual content for debugging
snippet_length = 200
content_snippet = (
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"
f"Visible content (first {snippet_length} chars): {content_snippet}"
)
return self
def should_not_see(self, text: str):
"""
Assert that the given text is NOT present in the visible page content.
This method parses the HTML and searches only in the visible text,
ignoring HTML tags and attributes.
Args:
text: The text string that should not be present (case-sensitive).
Returns:
self: Returns the client instance for method chaining.
Raises:
AssertionError: If the text is found in the page content.
ValueError: If no page has been opened yet.
"""
if self._content is None:
raise ValueError(
"No page content available. Call open() before should_not_see()."
)
visible_text = self._soup.get_text()
if text in visible_text:
element = self._find_visible_text_element(self._soup, text)
if element:
context = self._format_element_with_context(element, self.parent_levels)
error_msg = (
f"Expected NOT to see '{text}' in page content but it was found.\n"
f"Found in:\n{context}"
)
else:
error_msg = (
f"Expected NOT to see '{text}' in page content but it was found.\n"
f"Unable to locate the element containing this text."
)
raise AssertionError(error_msg)
return self
def find_element(self, selector: str):
"""
Find a single HTML element using a CSS selector.
This method searches for elements matching the given CSS selector.
It expects to find exactly one matching element.
Args:
selector: A CSS selector string (e.g., '#my-id', '.my-class', 'button.primary').
Returns:
TestableElement: A testable element wrapping the HTML fragment.
Raises:
ValueError: If no page has been opened yet.
AssertionError: If no element or multiple elements match the selector.
Examples:
element = client.open('/').find_element('#login-button')
element = client.find_element('button.primary')
"""
if self._content is None:
raise ValueError(
"No page content available. Call open() before find_element()."
)
results = self._soup.select(selector)
if len(results) == 0:
raise AssertionError(
f"No element found matching selector '{selector}'."
)
elif len(results) == 1:
return TestableElement(self, results[0])
else:
raise AssertionError(
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
)
def get_content(self) -> str:
"""
Get the raw HTML content of the last opened page.
Returns:
The HTML content as a string, or None if no page has been opened.
"""
return self._content
def set_content(self, content: str):
"""
Set the HTML content and parse it with BeautifulSoup.
Args:
content: The HTML content string to set.
"""
self._content = content
self._soup = BeautifulSoup(content, 'html.parser')
@staticmethod
def _find_visible_text_element(soup, text: str):
"""
Find the first element containing the visible text.
This method traverses the BeautifulSoup tree to find the first element
whose visible text content (including descendants) contains the search text.
Args:
soup: BeautifulSoup object representing the parsed HTML.
text: The text to search for.
Returns:
BeautifulSoup element containing the text, or None if not found.
"""
# Traverse all elements in the document
for element in soup.descendants:
# Skip NavigableString nodes, we want Tag elements
if not isinstance(element, Tag):
continue
# Get visible text of this element and its descendants
element_text = element.get_text()
# Check if our search text is in this element's visible text
if text in element_text:
# Found it! But we want the smallest element containing the text
# So let's check if any of its children also contain the text
found_in_child = False
for child in element.children:
if isinstance(child, Tag) and text in child.get_text():
found_in_child = True
break
# If no child contains the text, this is our target element
if not found_in_child:
return element
return None
@staticmethod
def _indent_html(html_str: str, indent: int = 2):
"""
Add indentation to HTML string.
Args:
html_str: HTML string to indent.
indent: Number of spaces for indentation.
Returns:
str: Indented HTML string.
"""
lines = html_str.split('\n')
indented_lines = [' ' * indent + line for line in lines if line.strip()]
return '\n'.join(indented_lines)
def _format_element_with_context(self, element, parent_levels: int):
"""
Format an element with its parent context for display.
Args:
element: BeautifulSoup element to format.
parent_levels: Number of parent levels to include.
Returns:
str: Formatted HTML string with indentation.
"""
# Collect the element and its parents
elements_to_show = [element]
current = element
for _ in range(parent_levels):
if current.parent and current.parent.name: # Skip NavigableString parents
elements_to_show.insert(0, current.parent)
current = current.parent
else:
break
# Format the top-level element with proper indentation
if len(elements_to_show) == 1:
return self._indent_html(str(element), indent=2)
# Build the nested structure
result = self._build_nested_context(elements_to_show, element)
return self._indent_html(result, indent=2)
def _build_nested_context(self, elements_chain, target_element):
"""
Build nested HTML context showing parents and target element.
Args:
elements_chain: List of elements from outermost parent to target.
target_element: The element that contains the searched text.
Returns:
str: Nested HTML structure.
"""
if len(elements_chain) == 1:
return str(target_element)
# Get the outermost element
outer = elements_chain[0]
# Start with opening tag
result = f"<{outer.name}"
if outer.attrs:
attrs = ' '.join(f'{k}="{v}"' if not isinstance(v, list) else f'{k}="{" ".join(v)}"'
for k, v in outer.attrs.items())
result += f" {attrs}"
result += ">\n"
# Add nested content
if len(elements_chain) == 2:
# This is the target element
result += self._indent_html(str(target_element), indent=2) + "\n"
else:
# Recursive call for deeper nesting
nested = self._build_nested_context(elements_chain[1:], target_element)
result += self._indent_html(nested, indent=2) + "\n"
# Closing tag
result += f"</{outer.name}>"
return result

View File

@@ -0,0 +1,19 @@
from starlette.routing import Mount
def mount_if_not_exists(app, path: str, sub_app):
"""
Mounts a sub-application only if no Mount object already exists
at the specified path in the main application's router.
"""
is_mounted = False
for route in app.router.routes:
if isinstance(route, Mount):
if route.path == path:
is_mounted = True
break
if not is_mounted:
app.mount(path, app=sub_app)

View File

@@ -0,0 +1,21 @@
# Generate Icons
Create the icons files for FastHtml applications.
## How to generate the svg files
```sh
npm i -D @sicons/fa
npm i -D @sicons/fluent
npm i -D @sicons/ionicons4
npm i -D @sicons/ionicons5
npm i -D @sicons/antd
npm i -D @sicons/material
npm i -D @sicons/tabler
npm i -D @sicons/carbon
```
Update the root folder in `update_icons.py` to point to the root folder of the icons.
##
```sh
python update_icons.py
```

View File

1679
src/myfasthtml/icons/antd.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1617
src/myfasthtml/icons/fa.py Normal file

File diff suppressed because one or more lines are too long

30900
src/myfasthtml/icons/fluent.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

10870
src/myfasthtml/icons/tabler.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
import os
root_folder = "/home/kodjo/Dev/MyDocManager/src/frontend/node_modules/@sicons"
import re
def pascal_to_snake(name: str) -> str:
"""Convert a PascalCase or CamelCase string to snake_case."""
# Insert underscore before capital letters (except the first one)
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.lower()
def create_icons(file, icon_folder):
for filename in os.listdir(f"{root_folder}/{icon_folder}"):
print("#", end='')
if not filename.endswith(".svg"):
continue
with open(f"{root_folder}/{icon_folder}/{filename}", "r") as f_read:
svg_content = f_read.read().strip()
icon_name = "icon_" + pascal_to_snake(filename.split('.')[0])
file.write(f"{icon_name} = NotStr('''{svg_content}''')\n")
print("")
if __name__ == "__main__":
for folder in ["antd", "material", "carbon", "fa", "fluent", "ionicons4", "ionicons5", "tabler"]:
# for folder in ["antd"]:
print(f"Processing icons for {folder}")
with open(f"{folder}.py", "w") as f_write:
# Add README.md content to the top of the file
if os.path.exists(f"{root_folder}/{folder}/README.md"):
with open(f"{root_folder}/{folder}/README.md", "r") as f_readme:
for line in f_readme:
if line.startswith("#"):
f_write.write(line)
else:
f_write.write(f"# {line}")
f_write.write("\n\n")
# Add imports
f_write.write("from fastcore.basics import NotStr\n\n")
# Add icons
create_icons(f_write, folder)

View File

@@ -0,0 +1,86 @@
from fasthtml.components import *
class LoginPage:
def __init__(self, settings_manager, error_message=None, success_message=None):
self.settings_manager = settings_manager
self.error_message = error_message
self.success_message = success_message
def render(self):
message_alert = None
if self.error_message:
message_alert = Div(
P(self.error_message, cls="text-sm"),
cls="bg-error border border-red-400 text-red-700 px-4 py-3 rounded mb-4"
)
elif self.success_message:
message_alert = Div(
P(self.success_message, cls="text-sm"),
cls="bg-success border border-green-400 text-green-700 px-4 py-3 rounded mb-4"
)
return Div(
# Page title
H1("Sign In", cls="text-3xl font-bold text-center mb-6"),
# Login Form
Div(
# Message alert
message_alert if message_alert else "",
# Email login form
Form(
# Email field
Div(
Label("Email", For="email", cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type="email",
id="email",
name="email",
placeholder="you@example.com",
required=True,
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
),
cls="mb-4"
),
# Password field
Div(
Label("Password", For="password", cls="block text-sm font-medium text-gray-700 mb-1"),
Input(
type="password",
id="password",
name="password",
placeholder="Your password",
required=True,
cls="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
),
cls="mb-6"
),
# Submit button
Button(
"Sign In",
type="submit",
cls="btn w-full font-bold py-2 px-4 rounded"
),
action=ROUTE_ROOT + Routes.LoginByEmail,
method="post",
cls="mb-6"
),
# Registration link
Div(
P(
"Don't have an account? ",
A("Register here", href="/register", cls="text-blue-600 hover:underline"),
cls="text-sm text-gray-600 text-center"
)
),
cls="p-8 rounded-lg shadow-2xl max-w-md mx-auto"
)
)

View File

25
tests/test_commands.py Normal file
View File

@@ -0,0 +1,25 @@
import pytest
from myfasthtml.core.commands import Command, CommandsManager
def callback():
return "Hello World"
@pytest.fixture(autouse=True)
def test_reset_command_manager():
CommandsManager.reset()
def test_i_can_create_a_command_with_no_params():
command = Command('test', 'Command description', callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
assert command.execute() == "Hello World"
def test_command_are_registered():
command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command

View File

@@ -0,0 +1,53 @@
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")

326
tests/test_mytestclient.py Normal file
View File

@@ -0,0 +1,326 @@
import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement
def test_i_can_open_a_page():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get(): return "hello world"
client.open("/")
assert client.get_content() == "hello world"
def test_i_can_open_a_page_when_html():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get(): return Div("hello world")
client.open("/")
assert client.get_content() == ' <!doctype html>\n <html>\n <head>\n <title>FastHTML page</title>\n <link rel="canonical" href="http://testserver/">\n </head>\n <body>\n <div>hello world</div>\n </body>\n </html>\n'
def test_i_cannot_open_a_page_not_defined():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(AssertionError) as exc_info:
client.open("/not_found")
assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'"
def test_i_can_see_text_in_plain_response():
"""Test that should_see() works with plain text responses."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
client.open("/").should_see("hello world")
def test_i_can_see_text_in_html_response():
"""Test that should_see() extracts visible text from HTML responses."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>This is a test</p></body></html>"
client.open("/").should_see("Welcome").should_see("This is a test")
def test_i_can_see_text_ignoring_html_tags():
"""Test that should_see() searches in visible text only, not in HTML tags."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container">Content</div>'
# Should find the visible text
client.open("/").should_see("Content")
# Should NOT find text that's only in attributes/tags
with pytest.raises(AssertionError) as exc_info:
client.should_see("container")
assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value)
def test_i_cannot_see_text_that_is_not_present():
"""Test that should_see() raises AssertionError when text is not found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_see("goodbye")
assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value)
assert "hello world" in str(exc_info.value)
def test_i_cannot_call_should_see_without_opening_page():
"""Test that should_see() raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.should_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_see()."
def test_i_can_verify_text_is_not_present():
"""Test that should_not_see() works when text is absent."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
client.open("/").should_not_see("goodbye")
def test_i_cannot_use_should_not_see_when_text_is_present():
"""Test that should_not_see() raises AssertionError when text is found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello")
error_message = str(exc_info.value)
assert "Expected NOT to see 'hello' in page content but it was found" in error_message
def test_i_cannot_call_should_not_see_without_opening_page():
"""Test that should_not_see() raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.should_not_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_not_see()."
def test_i_can_chain_multiple_assertions():
"""Test that assertions can be chained together."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>Content here</p></body></html>"
# Chain multiple assertions
client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error")
def test_i_can_see_element_context_when_text_should_not_be_seen():
"""Test that the HTML element containing the text is displayed with parent context."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p class="content">forbidden text</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("forbidden text")
error_message = str(exc_info.value)
assert "Found in:" in error_message
assert '<p class="content">forbidden text</p>' in error_message
assert '<div class="container">' in error_message
def test_i_can_configure_parent_levels_in_constructor():
"""Test that parent_levels parameter controls the number of parent levels shown."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app, parent_levels=2)
@rt('/')
def get():
return '<body><div class="wrapper"><div class="container"><p>error</p></div></div></body>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("error")
error_message = str(exc_info.value)
assert '<p>error</p>' in error_message
assert '<div class="container">' in error_message
assert '<div class="wrapper">' in error_message
def test_i_can_find_text_in_nested_elements():
"""Test that the smallest element containing the text is found in nested structures."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div><section><article><p class="target">nested text</p></article></section></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("nested text")
error_message = str(exc_info.value)
# Should find the <p> element, not the outer <div>
assert '<p class="target">nested text</p>' in error_message
def test_i_can_find_fragmented_text_across_tags():
"""Test that text fragmented across multiple tags is correctly found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<p class="message">hel<span>lo</span> world</p>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello world")
error_message = str(exc_info.value)
# Should find the parent <p> element that contains the full text
assert '<p class="message">' in error_message
def test_i_do_not_find_text_in_html_attributes():
"""Test that text in HTML attributes is not considered as visible text."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="error message" title="error info">Success</div>'
# "error" is in attributes but not in visible text
client.open("/").should_not_see("error")
# "Success" is in visible text
with pytest.raises(AssertionError):
client.should_not_see("Success")
@pytest.mark.parametrize("selector,expected_tag", [
("#unique-id", '<div class="main wrapper"'),
(".home-link", '<a class="link home-link"'),
("div > div", '<div class="content"'),
("div span", "<span"),
("[data-type]", '<a class="link home-link"'),
('[data-type="navigation"]', '<a class="link home-link"'),
('[class~="link"]', '<a class="link home-link"'),
('[href^="/home"]', '<a class="link home-link"'),
('[href$="about"]', '<a href="/about">'),
('[data-author*="john"]', '<a class="link home-link"'),
])
def test_i_can_find_element(selector, expected_tag):
"""Test that find_element works with various CSS selectors."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div id="unique-id" class="main wrapper">
<a href="/home" class="link home-link" data-type="navigation" data-author="john-doe">Home</a>
<a href="/about">About</a>
<div class="content">
<span class="text">Content</span>
</div>
</div>
'''
element = client.open("/").find_element(selector)
assert element is not None
assert isinstance(element, TestableElement)
assert expected_tag in element.html_fragment
def test_i_cannot_find_element_when_none_exists():
"""Test that find_element raises AssertionError when no element matches."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p>Content</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element("#non-existent")
assert "No element found matching selector '#non-existent'" in str(exc_info.value)
def test_i_cannot_find_element_when_multiple_exist():
"""Test that find_element raises AssertionError when multiple elements match."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div><p class="text">First</p><p class="text">Second</p><p class="text">Third</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element(".text")
error_message = str(exc_info.value)
assert "Found 3 elements matching selector '.text'" in error_message
assert "Expected exactly 1" in error_message
def test_i_cannot_call_find_element_without_opening_page():
"""Test that find_element raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.find_element("#any-selector")
assert str(exc_info.value) == "No page content available. Call open() before find_element()."

View File

@@ -0,0 +1,69 @@
import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.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.html_fragment == '<div id="test">hello world</div>'
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.html_fragment == '<div id="test">hello world</div>'
def test_i_can_create_testable_element_from_beautifulsoup_element():
ft = '<div id="test">hello world</div>'
from bs4 import BeautifulSoup
tag = BeautifulSoup(ft, 'html.parser').div
testable_element = TestableElement(None, tag)
assert testable_element.ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'
def test_i_cannot_create_testable_element_from_other_type():
with pytest.raises(ValueError) as exc_info:
TestableElement(None, 123)
assert str(exc_info.value) == "Invalid source '123' for TestableElement."
def test_i_can_click():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
ft = Div(
hx_post="/search",
hx_target="#results",
hx_swap="innerHTML",
hx_vals='{"attr": "attr_value"}'
)
testable_element = TestableElement(client, ft)
@rt('/search')
def post(hx_target: str, hx_swap: str, attr: str): # hx_post is used to the Verb. It's not a parameter
return f"received {hx_target=}, {hx_swap=}, {attr=}"
testable_element.click()
assert client.get_content() == "received hx_target='#results', hx_swap='innerHTML', attr='attr_value'"
def test_i_cannot_test_when_not_clickable():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
ft = Div("hello world")
testable_element = TestableElement(client, ft)
with pytest.raises(ValueError) as exc_info:
testable_element.click()
assert str(exc_info.value) == "The <div> element has no HTMX verb attribute (e.g., hx_get, hx_post) to define a URL."