Added icons

Updated README.md
Started test framework utils
This commit is contained in:
2025-10-24 12:24:10 +02:00
parent a4e122f4ea
commit 0f5fc696f0
25 changed files with 88199 additions and 2 deletions

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,109 @@
import logging
import uuid
from typing import Optional
from fasthtml.fastapp import fast_app
from myfasthtml.core.constants import Routes, ROUTE_ROOT
commands_app, 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()
@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()
return None

View File

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

View File

@@ -0,0 +1,342 @@
from bs4 import BeautifulSoup, Tag
from fasthtml.common import FastHTML
from starlette.testclient import TestClient
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, html_fragment):
"""
Initialize a testable element.
Args:
client: The MyTestClient instance.
ft: The FastHTML element representation.
"""
self.client = client
self.html_fragment = html_fragment
def click(self):
"""Click the element (to be implemented)."""
pass
def matches(self, ft):
"""Check if element matches given FastHTML element (to be implemented)."""
pass
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.parent_levels = parent_levels
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 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, str(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,3 @@
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ROUTE_ROOT, Routes

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