Added Controls testing + documentation

This commit is contained in:
2025-12-05 19:17:21 +01:00
parent 1d20fb8650
commit 8e5fa7f752
37 changed files with 4868 additions and 363 deletions

View File

@@ -3,6 +3,7 @@
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--spacing: 0.25rem;
--text-xs: 0.6875rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-xl: 1.25rem;
@@ -11,6 +12,8 @@
--radius-md: 0.375rem;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
--properties-font-size: var(--text-xs);
--mf-tooltip-zindex: 10;
}
@@ -56,6 +59,26 @@
* Compatible with DaisyUI 5
*/
.mf-tooltip-container {
background: var(--color-base-200);
padding: 5px 10px;
border-radius: 4px;
pointer-events: none; /* Prevent interfering with mouse events */
font-size: 12px;
white-space: nowrap;
opacity: 0; /* Default to invisible */
visibility: hidden; /* Prevent interaction when invisible */
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
position: fixed; /* Keep it above other content and adjust position */
z-index: var(--mf-tooltip-zindex); /* Ensure it's on top */
}
.mf-tooltip-container[data-visible="true"] {
opacity: 1;
visibility: visible; /* Show tooltip */
transition: opacity 0.3s ease; /* No delay when becoming visible */
}
/* Main layout container using CSS Grid */
.mf-layout {
display: grid;
@@ -632,7 +655,6 @@
/* *************** Panel Component *************** */
/* *********************************************** */
/* Container principal du panel */
.mf-panel {
display: flex;
width: 100%;
@@ -641,7 +663,6 @@
position: relative;
}
/* Panel gauche */
.mf-panel-left {
position: relative;
flex-shrink: 0;
@@ -653,15 +674,13 @@
border-right: 1px solid var(--color-border-primary);
}
/* Panel principal (centre) */
.mf-panel-main {
flex: 1;
height: 100%;
overflow: auto;
min-width: 0; /* Important pour permettre le shrink du flexbox */
min-width: 0; /* Important to allow the shrinking of flexbox */
}
/* Panel droit */
.mf-panel-right {
position: relative;
flex-shrink: 0;
@@ -671,4 +690,79 @@
height: 100%;
overflow: auto;
border-left: 1px solid var(--color-border-primary);
padding: 0.5rem;
}
/* *********************************************** */
/* ************* Properties Component ************ */
/* *********************************************** */
/* Properties container */
.mf-properties {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Group card - using DaisyUI card styling */
.mf-properties-group-card {
background-color: var(--color-base-100);
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* Group header - gradient using DaisyUI primary color */
.mf-properties-group-header {
background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);
color: var(--color-primary-content);
padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75);
font-weight: 700;
font-size: var(--properties-font-size);
}
/* Group content area */
.mf-properties-group-content {
display: flex;
flex-direction: column;
}
/* Property row */
.mf-properties-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);
transition: background-color 0.15s ease;
gap: calc(var(--properties-font-size) * 0.75);
}
.mf-properties-row:last-child {
border-bottom: none;
}
.mf-properties-row:hover {
background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);
}
/* Property key - normal font */
.mf-properties-key {
font-weight: 600;
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
flex: 0 0 40%;
font-size: var(--properties-font-size);
}
/* Property value - monospace font */
.mf-properties-value {
font-family: var(--default-mono-font-family);
color: var(--color-base-content);
flex: 1;
text-align: right;
font-size: var(--properties-font-size);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -159,6 +159,113 @@ function initResizer(containerId, options = {}) {
});
}
function bindTooltipsWithDelegation(elementId) {
// To display the tooltip, the attribute 'data-tooltip' is mandatory => it contains the text to tooltip
// Then
// the 'truncate' to show only when the text is truncated
// the class 'mmt-tooltip' for force the display
console.info("bindTooltips on element " + elementId);
const element = document.getElementById(elementId);
const tooltipContainer = document.getElementById(`tt_${elementId}`);
if (!element) {
console.error(`Invalid element '${elementId}' container`);
return;
}
if (!tooltipContainer) {
console.error(`Invalid tooltip 'tt_${elementId}' container.`);
return;
}
// Add a single mouseenter and mouseleave listener to the parent element
element.addEventListener("mouseenter", (event) => {
//console.debug("Entering element", event.target)
const cell = event.target.closest("[data-tooltip]");
if (!cell) {
// console.debug(" No 'data-tooltip' attribute found. Stopping.");
return;
}
const no_tooltip = element.hasAttribute("mf-no-tooltip");
if (no_tooltip) {
// console.debug(" Attribute 'mmt-no-tooltip' found. Cancelling.");
return;
}
const content = cell.querySelector(".truncate") || cell;
const isOverflowing = content.scrollWidth > content.clientWidth;
const forceShow = cell.classList.contains("mf-tooltip");
if (isOverflowing || forceShow) {
const tooltipText = cell.getAttribute("data-tooltip");
if (tooltipText) {
const rect = cell.getBoundingClientRect();
const tooltipRect = tooltipContainer.getBoundingClientRect();
let top = rect.top - 30; // Above the cell
let left = rect.left;
// Adjust tooltip position to prevent it from going off-screen
if (top < 0) top = rect.bottom + 5; // Move below if no space above
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 5; // Prevent overflow right
}
// Apply styles for tooltip positioning
requestAnimationFrame(() => {
tooltipContainer.textContent = tooltipText;
tooltipContainer.setAttribute("data-visible", "true");
tooltipContainer.style.top = `${top}px`;
tooltipContainer.style.left = `${left}px`;
});
}
}
}, true); // Use capture phase for better delegation if needed
element.addEventListener("mouseleave", (event) => {
const cell = event.target.closest("[data-tooltip]");
if (cell) {
tooltipContainer.setAttribute("data-visible", "false");
}
}, true); // Use capture phase for better delegation if needed
}
function initLayout(elementId) {
initResizer(elementId);
bindTooltipsWithDelegation(elementId);
}
function disableTooltip() {
const elementId = tooltipElementId
// console.debug("disableTooltip on element " + elementId);
const element = document.getElementById(elementId);
if (!element) {
console.error(`Invalid element '${elementId}' container`);
return;
}
element.setAttribute("mmt-no-tooltip", "");
}
function enableTooltip() {
const elementId = tooltipElementId
// console.debug("enableTooltip on element " + elementId);
const element = document.getElementById(elementId);
if (!element) {
console.error(`Invalid element '${elementId}' container`);
return;
}
element.removeAttribute("mmt-no-tooltip");
}
function initBoundaries(elementId, updateUrl) {
function updateBoundaries() {
const container = document.getElementById(elementId);
@@ -363,7 +470,6 @@ function updateTabs(controllerId) {
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
console.log("Parsing combination", combinationStr, "=>", sequence);
let currentNode = root;
for (const keySet of sequence) {
@@ -1354,4 +1460,5 @@ function updateTabs(controllerId) {
detachGlobalListener();
}
};
})();
})();

View File

@@ -5,6 +5,10 @@ from myfasthtml.core.network_utils import from_parent_child_list
class CommandsDebugger(SingleInstance):
"""
Represents a debugger designed for visualizing and managing commands in a parent-child
hierarchical structure.
"""
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)

View File

@@ -0,0 +1,59 @@
from typing import Optional
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, DataGridFooterConf, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
class DatagridState(DbObject):
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
self.sidebar_visible: bool = False
self.selected_view: str = None
self.row_index: bool = False
self.columns: list[DataGridColumnState] = []
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
self.headers: list[DataGridHeaderFooterConf] = []
self.footers: list[DataGridHeaderFooterConf] = []
self.sorted: list = []
self.filtered: dict = {}
self.edition: DatagridEditionState = DatagridEditionState()
self.selection: DatagridSelectionState = DatagridSelectionState()
class DatagridSettings(DbObject):
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
self.file_name: Optional[str] = None
self.selected_sheet_name: Optional[str] = None
self.header_visible: bool = True
self.filter_all_visible: bool = True
self.views_visible: bool = True
self.open_file_visible: bool = True
self.open_settings_visible: bool = True
class Commands(BaseCommands):
pass
class DataGrid(MultipleInstance):
def __init__(self, parent, settings=None, _id=None):
super().__init__(parent, _id=_id)
self._settings = DatagridSettings(self).update(settings)
self._state = DatagridState(self)
self.commands = Commands(self)
def render(self):
return Div(
self._id
)
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,58 @@
import pandas as pd
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance, InstancesManager
from myfasthtml.icons.fluent_p1 import table_add20_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular
class Commands(BaseCommands):
def upload_from_source(self):
return Command("UploadFromSource", "Upload from source", self._owner.upload_from_source)
def new_grid(self):
return Command("NewGrid", "New grid", self._owner.new_grid)
def open_from_excel(self, tab_id, get_content_callback):
excel_content = get_content_callback()
return Command("OpenFromExcel", "Open from Excel", self._owner.open_from_excel, tab_id, excel_content)
class DataGridsManager(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self.tree = TreeView(self, _id="-treeview")
self.commands = Commands(self)
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
def upload_from_source(self):
from myfasthtml.controls.FileUpload import FileUpload
file_upload = FileUpload(self, _id="-file-upload", auto_register=False)
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
tab_id = self._tabs_manager.add_tab("Upload Datagrid", file_upload)
file_upload.on_ok = self.commands.open_from_excel(tab_id, file_upload.get_content)
return self._tabs_manager.show_tab(tab_id)
def open_from_excel(self, tab_id, excel_content):
df = pd.read_excel(excel_content)
content = df.to_html(index=False)
self._tabs_manager.switch(tab_id, content)
def render(self):
return Div(
Div(
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
mk.icon(table_add20_regular, tooltip="New grid"),
cls="flex"
),
self.tree,
id=self._id,
)
def __ft__(self):
return self.render()

View File

@@ -22,6 +22,12 @@ class DropdownState:
class Dropdown(MultipleInstance):
"""
Represents a dropdown component that can be toggled open or closed. This class is used
to create interactive dropdown elements, allowing for container and button customization.
The dropdown provides functionality to manage its state, including opening, closing, and
handling user interactions.
"""
def __init__(self, parent, content=None, button=None, _id=None):
super().__init__(parent, _id=_id)
self.button = Div(button) if not isinstance(button, FT) else button

View File

@@ -6,7 +6,7 @@ from fastapi import UploadFile
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
@@ -24,6 +24,7 @@ class FileUploadState(DbObject):
self.ns_file_name: str | None = None
self.ns_sheets_names: list | None = None
self.ns_selected_sheet_name: str | None = None
self.ns_file_content: bytes | None = None
class Commands(BaseCommands):
@@ -35,17 +36,25 @@ class Commands(BaseCommands):
class FileUpload(MultipleInstance):
"""
Represents a file upload component.
This class provides functionality to handle the uploading process of a file,
extract sheet names from an Excel file, and enables users to select a specific
sheet for further processing. It integrates commands and state management
to ensure smooth operation within a parent application.
"""
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
def __init__(self, parent, _id=None, **kwargs):
super().__init__(parent, _id=_id, **kwargs)
self.commands = Commands(self)
self._state = FileUploadState(self)
def upload_file(self, file: UploadFile):
logger.debug(f"upload_file: {file=}")
if file:
file_content = file.file.read()
self._state.ns_sheets_names = self.get_sheets_names(file_content)
self._state.ns_file_content = file.file.read()
self._state.ns_sheets_names = self.get_sheets_names(self._state.ns_file_content)
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
return self.mk_sheet_selector()
@@ -64,6 +73,10 @@ class FileUpload(MultipleInstance):
cls="select select-bordered select-sm w-full ml-2"
)
def get_content(self):
return self._state.ns_file_content
@staticmethod
def get_sheets_names(file_content):
try:

View File

@@ -1,5 +1,7 @@
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.Properties import Properties
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.network_utils import from_parent_child_list
@@ -8,12 +10,26 @@ class InstancesDebugger(SingleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self._panel = Panel(self, _id="-panel")
self._command = Command("ShowInstance",
"Display selected Instance",
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
def render(self):
nodes, edges = self._get_nodes_and_edges()
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
return self._panel.set_main(vis_network)
def on_network_event(self, event_data: dict):
session, instance_id = event_data["nodes"][0].split("#")
properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
"State": {"_name": "_state._name", "*": "_state"},
"Commands": {"*": "commands"},
}
return self._panel.set_right(Properties(self,
InstancesManager.get(session, instance_id),
properties_def,
_id="-properties"))
def _get_nodes_and_edges(self):
instances = self._get_instances()
nodes, edges = from_parent_child_list(

View File

@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
class Keyboard(MultipleInstance):
"""
Represents a keyboard with customizable key combinations support.
The Keyboard class allows managing key combinations and their corresponding
actions for a given parent object.
"""
def __init__(self, parent, combinations=None, _id=None):
super().__init__(parent, _id=_id)
self.combinations = combinations or {}

View File

@@ -17,15 +17,17 @@ from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.utils import get_id
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
from myfasthtml.icons.fluent import panel_left_contract20_regular as left_drawer_contract
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_expand
from myfasthtml.icons.fluent_p1 import panel_right_contract20_regular as right_drawer_contract
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_expand
logger = logging.getLogger("LayoutControl")
class LayoutState(DbObject):
def __init__(self, owner):
super().__init__(owner)
def __init__(self, owner, name=None):
super().__init__(owner, name=name)
with self.initializing():
self.left_drawer_open: bool = True
self.right_drawer_open: bool = True
@@ -37,12 +39,13 @@ class Commands(BaseCommands):
def toggle_drawer(self, side: Literal["left", "right"]):
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
def update_drawer_width(self, side: Literal["left", "right"]):
def update_drawer_width(self, side: Literal["left", "right"], width: int = None):
"""
Create a command to update drawer width.
Args:
side: Which drawer to update ("left" or "right")
width: New width in pixels. Given by the HTMX request
Returns:
Command: Command object for updating drawer width
@@ -114,7 +117,7 @@ class Layout(SingleInstance):
# Content storage
self._main_content = None
self._state = LayoutState(self)
self._state = LayoutState(self, "default_layout")
self._boundaries = Boundaries(self)
self.commands = Commands(self)
self.left_drawer = self.Content(self)
@@ -123,16 +126,6 @@ class Layout(SingleInstance):
self.header_right = self.Content(self)
self.footer_left = self.Content(self)
self.footer_right = self.Content(self)
self._footer_content = None
def set_footer(self, content):
"""
Set the footer content.
Args:
content: FastHTML component(s) or content for the footer
"""
self._footer_content = content
def set_main(self, content):
"""
@@ -145,6 +138,18 @@ class Layout(SingleInstance):
return self
def toggle_drawer(self, side: Literal["left", "right"]):
"""
Toggle the state of a drawer (open or close) based on the specified side. This
method also generates the corresponding icon and drawer elements for the
selected side.
:param side: The side of the drawer to toggle. Must be either "left" or "right".
:type side: Literal["left", "right"]
:return: A tuple containing the updated drawer icon and drawer elements for
the specified side.
:rtype: Tuple[Any, Any]
:raises ValueError: If the provided `side` is not "left" or "right".
"""
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
if side == "left":
self._state.left_drawer_open = not self._state.left_drawer_open
@@ -190,15 +195,17 @@ class Layout(SingleInstance):
return Header(
Div( # left
self._mk_left_drawer_icon(),
*self.header_left.get_content(),
cls="flex gap-1"
*self._mk_content_wrapper(self.header_left, horizontal=True, show_group_name=False).children,
cls="flex gap-1",
id=f"{self._id}_hl"
),
Div( # right
*self.header_right.get_content()[None],
*self._mk_content_wrapper(self.header_right, horizontal=True, show_group_name=False).children,
UserProfile(self),
cls="flex gap-1"
cls="flex gap-1",
id=f"{self._id}_hr"
),
cls="mf-layout-header"
cls="mf-layout-header",
)
def _mk_footer(self):
@@ -208,9 +215,17 @@ class Layout(SingleInstance):
Returns:
Footer: FastHTML Footer component
"""
footer_content = self._footer_content if self._footer_content else ""
return Footer(
footer_content,
Div( # left
*self._mk_content_wrapper(self.footer_left, horizontal=True, show_group_name=False).children,
cls="flex gap-1",
id=f"{self._id}_fl"
),
Div( # right
*self._mk_content_wrapper(self.footer_right, horizontal=True, show_group_name=False).children,
cls="flex gap-1",
id=f"{self._id}_fr"
),
cls="mf-layout-footer footer sm:footer-horizontal"
)
@@ -277,7 +292,14 @@ class Layout(SingleInstance):
# Wrap content in scrollable container
content_wrapper = Div(
*self.right_drawer.get_content(),
*[
(
Div(cls="divider") if index > 0 else None,
group_ft,
*[item for item in self.right_drawer.get_content()[group_name]]
)
for index, (group_name, group_ft) in enumerate(self.right_drawer.get_groups())
],
cls="mf-layout-drawer-content"
)
@@ -290,15 +312,29 @@ class Layout(SingleInstance):
)
def _mk_left_drawer_icon(self):
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
return mk.icon(left_drawer_contract if self._state.left_drawer_open else left_drawer_expand,
id=f"{self._id}_ldi",
command=self.commands.toggle_drawer("left"))
def _mk_right_drawer_icon(self):
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
return mk.icon(right_drawer_contract if self._state.right_drawer_open else right_drawer_expand,
id=f"{self._id}_rdi",
command=self.commands.toggle_drawer("right"))
@staticmethod
def _mk_content_wrapper(content: Content, show_group_name: bool = True, horizontal: bool = False):
return Div(
*[
(
Div(cls=f"divider {'divider-horizontal' if horizontal else ''}") if index > 0 else None,
group_ft if show_group_name else None,
*[item for item in content.get_content()[group_name]]
)
for index, (group_name, group_ft) in enumerate(content.get_groups())
],
cls="mf-layout-drawer-content"
)
def render(self):
"""
Render the complete layout.
@@ -309,12 +345,13 @@ class Layout(SingleInstance):
# Wrap everything in a container div
return Div(
Div(id=f"tt_{self._id}", cls="mf-tooltip-container"), # container for the tooltips
self._mk_header(),
self._mk_left_drawer(),
self._mk_main(),
self._mk_right_drawer(),
self._mk_footer(),
Script(f"initResizer('{self._id}');"),
Script(f"initLayout('{self._id}');"),
id=self._id,
cls="mf-layout",
)

View File

@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
class Mouse(MultipleInstance):
"""
Represents a mechanism to manage mouse event combinations and their associated commands.
This class is used to add, manage, and render mouse event sequences with corresponding
commands, providing a flexible way to handle mouse interactions programmatically.
"""
def __init__(self, parent, _id=None, combinations=None):
super().__init__(parent, _id=_id)
self.combinations = combinations or {}

View File

@@ -38,6 +38,15 @@ class Commands(BaseCommands):
class Panel(MultipleInstance):
"""
Represents a user interface panel that supports customizable left, main, and right components.
The `Panel` class is used to create and manage a panel layout with optional left, main,
and right sections. It provides functionality to set the components of the panel, toggle
sides, and adjust the width of the sides dynamically. The class also handles rendering
the panel with appropriate HTML elements and JavaScript for interactivity.
"""
def __init__(self, parent, conf=None, _id=None):
super().__init__(parent, _id=_id)
self.conf = conf or PanelConf()
@@ -58,35 +67,35 @@ class Panel(MultipleInstance):
def set_right(self, right):
self._right = right
return self
return Div(self._right, id=f"{self._id}_r")
def set_left(self, left):
self._left = left
return self
return Div(self._left, id=f"{self._id}_l")
def _mk_right(self):
if not self.conf.right:
return None
resizer = Div(
cls="mf-resizer mf-resizer-right",
data_command_id=self.commands.update_side_width("right").id,
data_side="right"
)
return Div(resizer, self._right, cls="mf-panel-right")
return Div(resizer, Div(self._right, id=f"{self._id}_r"), cls="mf-panel-right")
def _mk_left(self):
if not self.conf.left:
return None
resizer = Div(
cls="mf-resizer mf-resizer-left",
data_command_id=self.commands.update_side_width("left").id,
data_side="left"
)
return Div(self._left, resizer, cls="mf-panel-left")
return Div(Div(self._left, id=f"{self._id}_l"), resizer, cls="mf-panel-left")
def render(self):
return Div(

View File

@@ -0,0 +1,50 @@
from fasthtml.components import Div
from myutils.ProxyObject import ProxyObject
from myfasthtml.core.instances import MultipleInstance
class Properties(MultipleInstance):
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
super().__init__(parent, _id=_id)
self.obj = obj
self.groups = groups
self.properties_by_group = self._create_properties_by_group()
def set_obj(self, obj, groups: dict = None):
self.obj = obj
self.groups = groups
self.properties_by_group = self._create_properties_by_group()
def render(self):
return Div(
*[
Div(
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
Div(
*[
Div(
Div(k, cls="mf-properties-key"),
Div(str(v), cls="mf-properties-value", title=str(v)),
cls="mf-properties-row"
)
for k, v in proxy.as_dict().items()
],
cls="mf-properties-group-content"
),
cls="mf-properties-group-card"
)
for group_name, proxy in self.properties_by_group.items()
],
id=self._id,
cls="mf-properties"
)
def _create_properties_by_group(self):
if self.groups is None:
return {None: ProxyObject(self.obj, {"*": ""})}
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
def __ft__(self):
return self.render()

View File

@@ -21,6 +21,21 @@ class Commands(BaseCommands):
class Search(MultipleInstance):
"""
Represents a component for managing and filtering a list of items.
It uses fuzzy matching and subsequence matching to filter items.
:ivar items_names: The name of the items used to filter.
:type items_names: str
:ivar items: The first set of items to filter.
:type items: list
:ivar filtered: A copy of the `items` list, representing the filtered items after a search operation.
:type filtered: list
:ivar get_attr: Callable function to extract string values from items for filtering.
:type get_attr: Callable[[Any], str]
:ivar template: Callable function to define how filtered items are rendered.
:type template: Callable[[Any], Any]
"""
def __init__(self,
parent: BaseInstance,
_id=None,

View File

@@ -102,7 +102,11 @@ class TabsManager(MultipleInstance):
tab_config = self._state.tabs[tab_id]
if tab_config["component_type"] is None:
return None
return InstancesManager.get(self._session, tab_config["component_id"])
try:
return InstancesManager.get(self._session, tab_config["component_id"])
except Exception as e:
logger.error(f"Error while retrieving tab content: {e}")
return Div("Tab not found.")
@staticmethod
def _get_tab_count():
@@ -203,6 +207,11 @@ class TabsManager(MultipleInstance):
logger.debug(f" Content already exists. Just switch.")
return self._mk_tabs_controller()
def switch_tab(self, tab_id, label, component, activate=True):
logger.debug(f"switch_tab {label=}, component={component}, activate={activate}")
self._add_or_update_tab(tab_id, label, component, activate)
return self.show_tab(tab_id) #
def close_tab(self, tab_id: str):
"""
Close a tab and remove it from the tabs manager.
@@ -382,6 +391,34 @@ class TabsManager(MultipleInstance):
def _get_tab_list(self):
return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs]
def _add_or_update_tab(self, tab_id, label, component, activate):
state = self._state.copy()
# Extract component ID if the component has a get_id() method
component_type, component_id = None, None
if isinstance(component, BaseInstance):
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
component_id = component.get_id()
# Add tab metadata to state
state.tabs[tab_id] = {
'id': tab_id,
'label': label,
'component_type': component_type,
'component_id': component_id
}
# Add the content
state._tabs_content[tab_id] = component
# Activate tab if requested
if activate:
state.active_tab = tab_id
# finally, update the state
self._state.update(state)
self._search.set_items(self._get_tab_list())
def update_boundaries(self):
return Script(f"updateBoundaries('{self._id}');")

View File

@@ -334,12 +334,11 @@ class TreeView(MultipleInstance):
# Toggle icon
toggle = mk.icon(
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ",
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else None,
command=self.commands.toggle_node(node_id))
# Label or input for editing
if is_editing:
# TODO: Bind input to save_rename (Enter) and cancel_rename (Escape)
label_element = mk.mk(Input(
name="node_label",
value=node.label,
@@ -357,7 +356,6 @@ class TreeView(MultipleInstance):
label_element,
self._render_action_buttons(node_id),
cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}",
data_node_id=node_id,
style=f"padding-left: {level * 20}px"
)
@@ -372,7 +370,8 @@ class TreeView(MultipleInstance):
return Div(
node_element,
*children_elements,
cls="mf-treenode-container"
cls="mf-treenode-container",
data_node_id=node_id,
)
def render(self):
@@ -390,7 +389,7 @@ class TreeView(MultipleInstance):
return Div(
*[self._render_node(node_id) for node_id in root_nodes],
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"),
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard"),
id=self._id,
cls="mf-treeview"
)

View File

@@ -25,19 +25,33 @@ class VisNetworkState(DbObject):
},
"physics": {"enabled": True}
}
self.events_handlers: dict = {} # {event_name: command_url}
class VisNetwork(MultipleInstance):
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None, events_handlers=None):
super().__init__(parent, _id=_id)
logger.debug(f"VisNetwork created with id: {self._id}")
# possible events (expected in snake_case
# - select_node → selectNode
# - select → select
# - click → click
# - double_click → doubleClick
self._state = VisNetworkState(self)
self._update_state(nodes, edges, options)
# Convert Commands to URLs
handlers_htmx_options = {
event_name: command.ajax_htmx_options()
for event_name, command in events_handlers.items()
} if events_handlers else {}
self._update_state(nodes, edges, options, handlers_htmx_options)
def _update_state(self, nodes, edges, options):
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
if not nodes and not edges and not options:
def _update_state(self, nodes, edges, options, events_handlers=None):
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}, {events_handlers=}")
if not nodes and not edges and not options and not events_handlers:
return
state = self._state.copy()
@@ -47,6 +61,8 @@ class VisNetwork(MultipleInstance):
state.edges = edges
if options is not None:
state.options = options
if events_handlers is not None:
state.events_handlers = events_handlers
self._state.update(state)
@@ -70,6 +86,34 @@ class VisNetwork(MultipleInstance):
# Convert Python options to JS
js_options = json.dumps(self._state.options, indent=2)
# Map Python event names to vis-network event names
event_name_map = {
"select_node": "selectNode",
"select": "select",
"click": "click",
"double_click": "doubleClick"
}
# Generate event handlers JavaScript
event_handlers_js = ""
for event_name, command_htmx_options in self._state.events_handlers.items():
vis_event_name = event_name_map.get(event_name, event_name)
event_handlers_js += f"""
network.on('{vis_event_name}', function(params) {{
const event_data = {{
event_name: '{event_name}',
nodes: params.nodes,
edges: params.edges,
pointer: params.pointer
}};
htmx.ajax('POST', '{command_htmx_options['url']}', {{
values: {{event_data: JSON.stringify(event_data)}},
target: '{command_htmx_options['target']}',
swap: '{command_htmx_options['swap']}'
}});
}});
"""
return (
Div(
id=self._id,
@@ -92,6 +136,7 @@ class VisNetwork(MultipleInstance):
}};
const options = {js_options};
const network = new vis.Network(container, data, options);
{event_handlers_js}
}})();
""")
)

View File

@@ -0,0 +1,49 @@
from dataclasses import dataclass, field
from myfasthtml.core.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType
@dataclass
class DataGridRowState:
row_id: int
visible: bool = True
height: int | None = None
@dataclass
class DataGridColumnState:
col_id: str # name of the column: cannot be changed
col_index: int # index of the column in the dataframe: cannot be changed
title: str = None
type: ColumnType = ColumnType.Text
visible: bool = True
usable: bool = True
width: int = DEFAULT_COLUMN_WIDTH
@dataclass
class DatagridEditionState:
under_edition: tuple[int, int] | None = None
previous_under_edition: tuple[int, int] | None = None
@dataclass
class DatagridSelectionState:
selected: tuple[int, int] | None = None
last_selected: tuple[int, int] | None = None
selection_mode: str = None # valid values are "row", "column" or None for "cell"
extra_selected: list[tuple[str, str | int]] = field(default_factory=list) # list(tuple(selection_mode, element_id))
last_extra_selected: tuple[int, int] = None
@dataclass
class DataGridHeaderFooterConf:
conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id
@dataclass
class DatagridView:
name: str
type: ViewType = ViewType.Table
columns: list[DataGridColumnState] = None

View File

@@ -50,6 +50,7 @@ class mk:
size=20,
can_select=True,
can_hover=False,
tooltip=None,
cls='',
command: Command = None,
binding: Binding = None,
@@ -65,6 +66,7 @@ class mk:
:param size: The size of the icon, specified in pixels. Defaults to 20.
:param can_select: Indicates whether the icon can be selected. Defaults to True.
:param can_hover: Indicates whether the icon reacts to hovering. Defaults to False.
:param tooltip:
:param cls: A string of custom CSS classes to be added to the icon container.
:param command: The command object defining the function to be executed on icon interaction.
:param binding: The binding object for configuring additional event listeners on the icon.
@@ -79,6 +81,10 @@ class mk:
cls,
kwargs)
if tooltip:
merged_cls = merge_classes(merged_cls, "mf-tooltip")
kwargs["data-tooltip"] = tooltip
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
@staticmethod

View File

@@ -1,4 +1,5 @@
import inspect
import json
import uuid
from typing import Optional
@@ -97,6 +98,14 @@ class BaseCommand:
def url(self):
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
def ajax_htmx_options(self):
return {
"url": self.url,
"target": self._htmx_extra.get("hx-target", "this"),
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
"values": {}
}
def get_ft(self):
return self._ft
@@ -126,7 +135,7 @@ class Command(BaseCommand):
def __init__(self, name, description, callback, *args, **kwargs):
super().__init__(name, description)
self.callback = callback
self.callback_parameters = dict(inspect.signature(callback).parameters)
self.callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
self.args = args
self.kwargs = kwargs
@@ -141,8 +150,17 @@ class Command(BaseCommand):
return float(value)
elif param.annotation == list:
return value.split(",")
elif param.annotation == dict:
return json.loads(value)
return value
def ajax_htmx_options(self):
res = super().ajax_htmx_options()
if self.kwargs:
res["values"] |= self.kwargs
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
return res
def execute(self, client_response: dict = None):
ret_from_bindings = []

View File

@@ -1,5 +1,39 @@
from enum import Enum
DEFAULT_COLUMN_WIDTH = 100
ROUTE_ROOT = "/myfasthtml"
class Routes:
Commands = "/commands"
Bindings = "/bindings"
Bindings = "/bindings"
class ColumnType(Enum):
RowIndex = "RowIndex"
Text = "Text"
Number = "Number"
Datetime = "DateTime"
Bool = "Boolean"
Choice = "Choice"
List = "List"
class ViewType(Enum):
Table = "Table"
Chart = "Chart"
Form = "Form"
class FooterAggregation(Enum):
Sum = "Sum"
Mean = "Mean"
Min = "Min"
Max = "Max"
Count = "Count"
FilteredSum = "FilteredSum"
FilteredMean = "FilteredMean"
FilteredMin = "FilteredMin"
FilteredMax = "FilteredMax"
FilteredCount = "FilteredCount"

View File

@@ -39,7 +39,7 @@ class DbObject:
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
self._owner = owner
self._name = name or self.__class__.__name__
self._name = name or owner.get_full_id()
self._db_manager = db_manager or DbManager(self._owner)
self._finalize_initialization()
@@ -112,6 +112,7 @@ class DbObject:
setattr(self, k, v)
self._save_self()
self._initializing = old_state
return self
def copy(self):
as_dict = self._get_properties().copy()

View File

@@ -84,7 +84,7 @@ class BaseInstance:
return self._prefix
def get_full_id(self) -> str:
return f"{InstancesManager.get_session_id(self._session)}-{self._id}"
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
def get_full_parent_id(self) -> Optional[str]:
parent = self.get_parent()
@@ -176,11 +176,22 @@ class InstancesManager:
:param instance_id:
:return:
"""
key = (InstancesManager.get_session_id(session), instance_id)
session_id = InstancesManager.get_session_id(session)
key = (session_id, instance_id)
return InstancesManager.instances[key]
@staticmethod
def get_by_type(session: dict, cls: type):
session_id = InstancesManager.get_session_id(session)
res = [i for s, i in InstancesManager.instances.items() if s[0] == session_id and isinstance(i, cls)]
assert len(res) <= 1, f"Multiple instances of type {cls.__name__} found"
assert len(res) > 0, f"No instance of type {cls.__name__} found"
return res[0]
@staticmethod
def get_session_id(session):
if isinstance(session, str):
return session
if session is None:
return "** NOT LOGGED IN **"
if "user_info" not in session:

View File

@@ -3,6 +3,7 @@ from collections.abc import Callable
ROOT_COLOR = "#ff9999"
GHOST_COLOR = "#cccccc"
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
"""
Convert a list of nested dictionaries to vis.js nodes and edges format.

View File

@@ -1,9 +1,12 @@
import re
from dataclasses import dataclass
from typing import Optional, Any
from fastcore.basics import NotStr
from fastcore.xml import FT
from myfasthtml.core.utils import quoted_str
from myfasthtml.core.commands import BaseCommand
from myfasthtml.core.utils import quoted_str, snake_to_pascal
from myfasthtml.test.testclient import MyFT
MISSING_ATTR = "** MISSING **"
@@ -17,7 +20,18 @@ class Predicate:
raise NotImplementedError
def __str__(self):
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
if self.value is None:
str_value = ''
elif isinstance(self.value, str):
str_value = self.value
elif isinstance(self.value, (list, tuple)):
if len(self.value) == 1:
str_value = self.value[0]
else:
str_value = str(self.value)
else:
str_value = str(self.value)
return f"{self.__class__.__name__}({str_value})"
def __repr__(self):
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
@@ -47,20 +61,33 @@ class StartsWith(AttrPredicate):
return actual.startswith(self.value)
class Contains(AttrPredicate):
class EndsWith(AttrPredicate):
def __init__(self, value):
super().__init__(value)
def validate(self, actual):
return self.value in actual
return actual.endswith(self.value)
class Contains(AttrPredicate):
def __init__(self, *value, _word=False):
super().__init__(value)
self._word = _word
def validate(self, actual):
if self._word:
words = actual.split()
return all(val in words for val in self.value)
else:
return all(val in actual for val in self.value)
class DoesNotContain(AttrPredicate):
def __init__(self, value):
def __init__(self, *value):
super().__init__(value)
def validate(self, actual):
return self.value not in actual
return all(val not in actual for val in self.value)
class AnyValue(AttrPredicate):
@@ -75,6 +102,14 @@ class AnyValue(AttrPredicate):
return actual is not None
class Regex(AttrPredicate):
def __init__(self, pattern):
super().__init__(pattern)
def validate(self, actual):
return re.match(self.value, actual) is not None
class ChildrenPredicate(Predicate):
"""
Predicate given as a child of an element.
@@ -116,23 +151,142 @@ class AttributeForbidden(ChildrenPredicate):
return element
class HasHtmx(ChildrenPredicate):
def __init__(self, command: BaseCommand = None, **htmx_params):
super().__init__(None)
self.command = command
if command:
self.htmx_params = command.get_htmx_params() | htmx_params
else:
self.htmx_params = htmx_params
self.htmx_params = {k.replace("hx_", "hx-"): v for k, v in self.htmx_params.items()}
def validate(self, actual):
return all(actual.attrs.get(k) == v for k, v in self.htmx_params.items())
def to_debug(self, element):
for k, v in self.htmx_params.items():
element.attrs[k] = v
return element
class TestObject:
def __init__(self, cls, **kwargs):
self.cls = cls
self.attrs = kwargs
class TestIcon(TestObject):
def __init__(self, name: Optional[str] = '', command=None):
super().__init__("div")
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
self.children = [
TestObject(NotStr, s=Regex(f'<svg name="\\w+-{self.name}'))
]
if command:
self.attrs |= command.get_htmx_params()
def __str__(self):
return f'<div><svg name="{self.name}" .../></div>'
class TestIconNotStr(TestObject):
def __init__(self, name: Optional[str] = ''):
super().__init__(NotStr)
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
self.attrs["s"] = Regex(f'<svg name="\\w+-{self.name}')
def __str__(self):
return f'<svg name="{self.name}" .../>'
class TestCommand(TestObject):
def __init__(self, name, **kwargs):
super().__init__("Command", **kwargs)
self.attrs = {"name": name} | kwargs # name should be first
class TestScript(TestObject):
def __init__(self, script):
super().__init__("script")
self.script = script
self.children = [
NotStr(self.script),
]
@dataclass
class DoNotCheck:
desc: str = None
@dataclass
class Skip:
element: Any
desc: str = None
def _get_type(x):
if hasattr(x, "tag"):
return x.tag
if isinstance(x, (TestObject, TestIcon)):
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
return type(x).__name__
def _get_attr(x, attr):
if hasattr(x, "attrs"):
return x.attrs.get(attr, MISSING_ATTR)
if not hasattr(x, attr):
return MISSING_ATTR
return getattr(x, attr, MISSING_ATTR)
def _get_attributes(x):
"""Get the attributes dict from an element."""
if hasattr(x, "attrs"):
return x.attrs
return {}
def _get_children(x):
"""Get the children list from an element."""
if hasattr(x, "children"):
return x.children
return []
def _str_element(element, expected=None, keep_open=None):
# compare to itself if no expected element is provided
if expected is None:
expected = element
if hasattr(element, "tag"):
# the attributes are compared to the expected element
elt_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in
[attr_name for attr_name in _get_attributes(expected) if attr_name is not None]}
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
tag_str = f"({element.tag} {elt_attrs_str}"
# manage the closing tag
if keep_open is False:
tag_str += " ...)" if len(element.children) > 0 else ")"
elif keep_open is True:
tag_str += "..." if elt_attrs_str == "" else " ..."
else:
# close the tag if there are no children
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
if len(not_special_children) == 0: tag_str += ")"
return tag_str
else:
return quoted_str(element)
class ErrorOutput:
def __init__(self, path, element, expected):
self.path = path
@@ -157,14 +311,14 @@ class ErrorOutput:
# first render the path hierarchy
for p in self.path.split(".")[:-1]:
elt_name, attr_name, attr_value = self._unconstruct_path_item(p)
path_str = self._str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
path_str = _str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
self._add_to_output(f"{path_str}")
self.indent += " "
# then render the element
if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
# display the tag and its attributes
tag_str = self._str_element(self.element, self.expected)
tag_str = _str_element(self.element, self.expected)
self._add_to_output(tag_str)
# Try to show where the differences are
@@ -187,7 +341,7 @@ class ErrorOutput:
# display the child
element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False)
child_str = _str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str)
# manage errors (only when the expected is a FT element
@@ -203,11 +357,11 @@ class ErrorOutput:
self._add_to_output(")")
elif isinstance(self.expected, TestObject):
cls = _mytype(self.element)
attrs = {attr_name: _mygetattr(self.element, attr_name) for attr_name in self.expected.attrs}
cls = _get_type(self.element)
attrs = {attr_name: _get_attr(self.element, attr_name) for attr_name in self.expected.attrs}
self._add_to_output(f"({cls} {_str_attrs(attrs)})")
# Try to show where the differences are
error_str = self._detect_error_2(self.element, self.expected)
error_str = self._detect_error(self.element, self.expected)
if error_str:
self._add_to_output(error_str)
@@ -221,79 +375,36 @@ class ErrorOutput:
def _add_to_output(self, msg):
self.output.append(f"{self.indent}{msg}")
@staticmethod
def _str_element(element, expected=None, keep_open=None):
# compare to itself if no expected element is provided
if expected is None:
expected = element
if hasattr(element, "tag"):
# the attributes are compared to the expected element
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in
[attr_name for attr_name in expected.attrs if attr_name is not None]}
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
tag_str = f"({element.tag} {elt_attrs_str}"
# manage the closing tag
if keep_open is False:
tag_str += " ...)" if len(element.children) > 0 else ")"
elif keep_open is True:
tag_str += "..." if elt_attrs_str == "" else " ..."
else:
# close the tag if there are no children
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
if len(not_special_children) == 0: tag_str += ")"
return tag_str
else:
return quoted_str(element)
def _detect_error(self, element, expected):
if hasattr(expected, "tag") and hasattr(element, "tag"):
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in expected.attrs}
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
not self._matches(attr_value, expected.attrs[attr_name])]
if attrs_in_error:
elt_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
for name, value in elt_attrs.items())
error_str = f" {tag_str} {elt_attrs_error}"
return error_str
else:
if not self._matches(element, expected):
return len(str(element)) * "^"
return None
def _detect_error_2(self, element, expected):
"""
Too lazy to refactor original _detect_error
:param element:
:param expected:
:return:
Detect errors between element and expected, returning a visual marker string.
Unified version that handles both FT elements and TestObjects.
"""
# For elements with structure (FT or TestObject)
if hasattr(expected, "tag") or isinstance(expected, TestObject):
element_cls = _mytype(element)
expected_cls = _mytype(expected)
str_tag_error = (" " if self._matches(element_cls, expected_cls) else "^") * len(element_cls)
element_type = _get_type(element)
expected_type = _get_type(expected)
type_error = (" " if element_type == expected_type else "^") * len(element_type)
element_attrs = {attr_name: _mygetattr(element, attr_name) for attr_name in expected.attrs}
expected_attrs = {attr_name: _mygetattr(expected, attr_name) for attr_name in expected.attrs}
element_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in _get_attributes(expected)}
expected_attrs = {attr_name: _get_attr(expected, attr_name) for attr_name in _get_attributes(expected)}
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
not self._matches(attr_value, expected_attrs[attr_name])}
str_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
for name, value in element_attrs.items())
if str_attrs_error.strip() or str_tag_error.strip():
return f" {str_tag_error} {str_attrs_error}"
else:
return None
attrs_error = " ".join(
len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
for name, value in element_attrs.items()
)
if type_error.strip() or attrs_error.strip():
return f" {type_error} {attrs_error}"
return None
# For simple values
else:
if not self._matches(element, expected):
return len(str(element)) * "^"
return None
return None
@staticmethod
def _matches(element, expected):
@@ -343,39 +454,221 @@ class ErrorComparisonOutput:
return "\n".join(output)
def matches(actual, expected, path=""):
def print_path(p):
return f"Path : '{p}'\n" if p else ""
class Matcher:
"""
Matcher class for comparing actual and expected elements.
Provides flexible comparison with support for predicates, nested structures,
and detailed error reporting.
"""
def _type(x):
return type(x)
def __init__(self):
self.path = ""
def _debug(elt):
return str(elt) if elt else "None"
def matches(self, actual, expected):
"""
Compare actual and expected elements.
Args:
actual: The actual element to compare
expected: The expected element or pattern
Returns:
True if elements match, raises AssertionError otherwise
"""
if actual is not None and expected is None:
self._assert_error("Actual is not None while expected is None", _actual=actual)
if isinstance(expected, DoNotCheck):
return True
if actual is None and expected is not None:
self._assert_error("Actual is None while expected is ", _expected=expected)
# set the path
current_path = self._get_current_path(actual)
original_path = self.path
self.path = self.path + "." + current_path if self.path else current_path
try:
self._match_elements(actual, expected)
finally:
# restore the original path for sibling comparisons
self.path = original_path
return True
def _debug_compare(a, b):
actual_out = ErrorOutput(path, a, b)
expected_out = ErrorOutput(path, b, b)
def _match_elements(self, actual, expected):
"""Internal method that performs the actual comparison logic."""
if isinstance(expected, TestObject) or hasattr(expected, "tag"):
self._match_element(actual, expected)
return
if isinstance(expected, Predicate):
assert expected.validate(actual), \
self._error_msg(f"The condition '{expected}' is not satisfied.",
_actual=actual,
_expected=expected)
return
assert _get_type(actual) == _get_type(expected), \
self._error_msg("The types are different.", _actual=actual, _expected=expected)
if isinstance(expected, (list, tuple)):
self._match_list(actual, expected)
elif isinstance(expected, dict):
self._match_dict(actual, expected)
elif isinstance(expected, NotStr):
self._match_notstr(actual, expected)
else:
assert actual == expected, self._error_msg("The values are different",
_actual=actual,
_expected=expected)
def _match_element(self, actual, expected):
"""Match a TestObject or FT element."""
# Validate the type/tag
assert _get_type(actual) == _get_type(expected), \
self._error_msg("The types are different.", _actual=_get_type(actual), _expected=_get_type(expected))
# Special conditions (ChildrenPredicate)
expected_children = _get_children(expected)
for predicate in [c for c in expected_children if isinstance(c, ChildrenPredicate)]:
assert predicate.validate(actual), \
self._error_msg(f"The condition '{predicate}' is not satisfied.",
_actual=actual,
_expected=predicate.to_debug(expected))
# Compare the attributes
expected_attrs = _get_attributes(expected)
for expected_attr, expected_value in expected_attrs.items():
actual_value = _get_attr(actual, expected_attr)
# Check if attribute exists
if actual_value == MISSING_ATTR:
self._assert_error(f"'{expected_attr}' is not found in Actual. (attributes: {self._str_attrs(actual)})",
_actual=actual,
_expected=expected)
# Handle Predicate values
if isinstance(expected_value, Predicate):
assert expected_value.validate(actual_value), \
self._error_msg(f"The condition '{expected_value}' is not satisfied.",
_actual=actual,
_expected=expected)
# Handle TestObject recursive matching
elif isinstance(expected, TestObject):
try:
self.matches(actual_value, expected_value)
except AssertionError as e:
match = re.search(r"Error : (.+?)\n", str(e))
if match:
self._assert_error(f"{match.group(1)} for '{expected_attr}'.",
_actual=actual_value,
_expected=expected_value)
else:
self._assert_error(f"The values are different for '{expected_attr}'.",
_actual=actual_value,
_expected=expected_value)
# Handle regular value comparison
else:
assert actual_value == expected_value, \
self._error_msg(f"The values are different for '{expected_attr}'.",
_actual=actual,
_expected=expected)
# Compare the children (only if present)
if expected_children:
# Filter out Predicate children
expected_children = [c for c in expected_children if not isinstance(c, Predicate)]
actual_children = _get_children(actual)
if len(actual_children) < len(expected_children):
self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected)
actual_child_index, expected_child_index = 0, 0
while expected_child_index < len(expected_children):
if actual_child_index >= len(actual_children):
self._assert_error("Nothing more to skip.", _actual=actual, _expected=expected)
actual_child = actual_children[actual_child_index]
expected_child = expected_children[expected_child_index]
if isinstance(expected_child, Skip):
try:
# if this is the element to skip, skip it and continue
self._match_element(actual_child, expected_child.element)
actual_child_index += 1
continue
except AssertionError:
# otherwise try to match with the following element
expected_child_index += 1
continue
assert self.matches(actual_child, expected_child)
actual_child_index += 1
expected_child_index += 1
def _match_list(self, actual, expected):
"""Match list or tuple."""
if len(actual) < len(expected):
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
if len(actual) > len(expected):
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
for actual_child, expected_child in zip(actual, expected):
assert self.matches(actual_child, expected_child)
def _match_dict(self, actual, expected):
"""Match dictionary."""
if len(actual) < len(expected):
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
if len(actual) > len(expected):
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
for k, v in expected.items():
assert self.matches(actual[k], v)
def _match_notstr(self, actual, expected):
"""Match NotStr type."""
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
assert to_compare.startswith(expected.s), self._error_msg("Notstr values are different: ",
_actual=to_compare,
_expected=expected.s)
def _print_path(self):
"""Format the current path for error messages."""
return f"Path : '{self.path}'\n" if self.path else ""
def _debug_compare(self, a, b):
"""Generate a comparison debug output."""
actual_out = ErrorOutput(self.path, a, b)
expected_out = ErrorOutput(self.path, b, b)
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
return comparison_out.render()
def _error_msg(msg, _actual=None, _expected=None):
def _error_msg(self, msg, _actual=None, _expected=None):
"""Generate an error message with debug information."""
if _actual is None and _expected is None:
debug_info = ""
elif _actual is None:
debug_info = _debug(_expected)
debug_info = self._debug(_expected)
elif _expected is None:
debug_info = _debug(_actual)
debug_info = self._debug(_actual)
else:
debug_info = _debug_compare(_actual, _expected)
debug_info = self._debug_compare(_actual, _expected)
return f"{print_path(path)}Error : {msg}\n{debug_info}"
return f"{self._print_path()}Error : {msg}\n{debug_info}"
def _assert_error(msg, _actual=None, _expected=None):
assert False, _error_msg(msg, _actual=_actual, _expected=_expected)
def _assert_error(self, msg, _actual=None, _expected=None):
"""Raise an assertion error with formatted message."""
assert False, self._error_msg(msg, _actual=_actual, _expected=_expected)
@staticmethod
def _get_current_path(elt):
"""Get the path representation of an element."""
if hasattr(elt, "tag"):
res = f"{elt.tag}"
if "id" in elt.attrs:
@@ -388,192 +681,127 @@ def matches(actual, expected, path=""):
else:
return elt.__class__.__name__
if actual is not None and expected is None:
_assert_error("Actual is not None while expected is None", _actual=actual)
@staticmethod
def _str_attrs(element):
"""Format attributes as a string."""
attrs = _get_attributes(element)
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
if isinstance(expected, DoNotCheck):
return True
if actual is None and expected is not None:
_assert_error("Actual is None while expected is ", _expected=expected)
# set the path
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
if isinstance(expected, TestObject):
assert _mytype(actual) == _mytype(expected), _error_msg("The types are different: ",
_actual=actual,
_expected=expected)
for attr, value in expected.attrs.items():
assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.",
_actual=actual,
_expected=expected)
try:
matches(getattr(actual, attr), value)
except AssertionError as e:
match = re.search(r"Error : (.+?)\n", str(e))
if match:
assert False, _error_msg(f"{match.group(1)} for '{attr}':",
_actual=getattr(actual, attr),
_expected=value)
assert False, _error_msg(f"The values are different for '{attr}': ",
_actual=getattr(actual, attr),
_expected=value)
return True
if isinstance(expected, Predicate):
assert expected.validate(actual), \
_error_msg(f"The condition '{expected}' is not satisfied.",
_actual=actual,
_expected=expected)
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
_error_msg("The types are different: ", _actual=actual, _expected=expected)
if isinstance(expected, (list, tuple)):
if len(actual) < len(expected):
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
if len(actual) > len(expected):
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
for actual_child, expected_child in zip(actual, expected):
assert matches(actual_child, expected_child, path=path)
elif isinstance(expected, dict):
if len(actual) < len(expected):
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
if len(actual) > len(expected):
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
for k, v in expected.items():
assert matches(actual[k], v, path=f"{path}[{k}={v}]")
elif isinstance(expected, NotStr):
to_compare = actual.s.lstrip('\n').lstrip()
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
_actual=to_compare,
_expected=expected.s)
elif hasattr(expected, "tag"):
# validate the tags names
assert actual.tag == expected.tag, _error_msg("The elements are different.",
_actual=actual.tag,
_expected=expected.tag)
# special conditions
for predicate in [c for c in expected.children if isinstance(c, ChildrenPredicate)]:
assert predicate.validate(actual), \
_error_msg(f"The condition '{predicate}' is not satisfied.",
_actual=actual,
_expected=predicate.to_debug(expected))
# compare the attributes
for expected_attr, expected_value in expected.attrs.items():
assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual.",
_actual=actual,
_expected=expected)
if isinstance(expected_value, Predicate):
assert expected_value.validate(actual.attrs[expected_attr]), \
_error_msg(f"The condition '{expected_value}' is not satisfied.",
_actual=actual,
_expected=expected)
else:
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
_error_msg(f"The values are different for '{expected_attr}': ",
_actual=actual,
_expected=expected)
# compare the children
expected_children = [c for c in expected.children if not isinstance(c, Predicate)]
if len(actual.children) < len(expected_children):
_assert_error("Actual is lesser than expected: ", _actual=actual, _expected=expected)
for actual_child, expected_child in zip(actual.children, expected_children):
assert matches(actual_child, expected_child, path=path)
else:
assert actual == expected, _error_msg("The values are different",
_actual=actual,
_expected=expected)
return True
@staticmethod
def _debug(elt):
"""Format an element for debug output."""
return _str_element(elt, keep_open=False) if elt else "None"
def matches(actual, expected, path=""):
"""
Compare actual and expected elements.
This is a convenience wrapper around the Matcher class.
Args:
actual: The actual element to compare
expected: The expected element or pattern
path: Optional initial path for error reporting
Returns:
True if elements match, raises AssertionError otherwise
"""
matcher = Matcher()
matcher.path = path
return matcher.matches(actual, expected)
def find(ft, expected):
res = []
"""
Find all occurrences of an expected element within a FastHTML tree.
Args:
ft: A FastHTML element or list of elements to search in
expected: The element pattern to find
Returns:
List of matching elements
Raises:
AssertionError: If no matching elements are found
"""
def _type(x):
return type(x)
def _same(_ft, _expected):
if _ft == _expected:
def _elements_match(actual, expected):
"""Check if two elements are the same based on tag, attributes, and children."""
if isinstance(expected, DoNotCheck):
return True
if _ft.tag != _expected.tag:
if isinstance(expected, NotStr):
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
return to_compare.startswith(expected.s)
if isinstance(actual, NotStr) and _get_type(actual) != _get_type(expected):
return False # to manage the unexpected __eq__ behavior of NotStr
if not isinstance(expected, (TestObject, FT)):
return actual == expected
# Compare tags
if _get_type(actual) != _get_type(expected):
return False
for attr in _expected.attrs:
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]:
# Compare attributes
expected_attrs = _get_attributes(expected)
for attr_name, expected_attr_value in expected_attrs.items():
actual_attr_value = _get_attr(actual, attr_name)
# attribute is missing
if actual_attr_value == MISSING_ATTR:
return False
# manage predicate values
if isinstance(expected_attr_value, Predicate):
return expected_attr_value.validate(actual_attr_value)
# finally compare values
return actual_attr_value == expected_attr_value
for expected_child in _expected.children:
for ft_child in _ft.children:
if _same(ft_child, expected_child):
break
else:
# Compare children recursively
expected_children = _get_children(expected)
actual_children = _get_children(actual)
for expected_child in expected_children:
# Check if this expected child exists somewhere in actual children
if not any(_elements_match(actual_child, expected_child) for actual_child in actual_children):
return False
return True
def _find(current, current_expected):
def _search_tree(current, pattern):
"""Recursively search for pattern in the tree rooted at current."""
# Check if current element matches
matches = []
if _elements_match(current, pattern):
matches.append(current)
if _type(current) != _type(current_expected):
return []
# Recursively search in children, in the case that the pattern also appears in children
for child in _get_children(current):
matches.extend(_search_tree(child, pattern))
if not hasattr(current, "tag"):
return [current] if current == current_expected else []
_found = []
if _same(current, current_expected):
_found.append(current)
# look at the children
for child in current.children:
_found.extend(_find(child, current_expected))
return _found
return matches
ft_as_list = [ft] if not isinstance(ft, (list, tuple, set)) else ft
# Normalize input to list
elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft]
for current_ft in ft_as_list:
found = _find(current_ft, expected)
res.extend(found)
# Search in all provided elements
all_matches = []
for element in elements_to_search:
all_matches.extend(_search_tree(element, expected))
if len(res) == 0:
# Raise error if nothing found
if not all_matches:
raise AssertionError(f"No element found for '{expected}'")
return res
return all_matches
def _mytype(x):
if hasattr(x, "tag"):
return x.tag
if isinstance(x, TestObject):
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
return type(x).__name__
def _mygetattr(x, attr):
if hasattr(x, "attrs"):
return x.attrs.get(attr, MISSING_ATTR)
if not hasattr(x, attr):
return MISSING_ATTR
return getattr(x, attr, MISSING_ATTR)
def find_one(ft, expected):
found = find(ft, expected)
assert len(found) == 1, f"Found {len(found)} elements for '{expected}'"
return found[0]
def _str_attrs(attrs: dict):