Improved Command class management.

This commit is contained in:
2025-12-19 21:12:55 +01:00
parent b26abc4257
commit 1347f12618
23 changed files with 349 additions and 169 deletions

View File

@@ -173,7 +173,7 @@ pip install -e .
Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`. Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`.
**Key classes:** **Key classes:**
- `BaseCommand`: Base class for all commands with HTMX integration - `Command`: Base class for all commands with HTMX integration
- `Command`: Standard command that executes a Python callable - `Command`: Standard command that executes a Python callable
- `LambdaCommand`: Inline command for simple operations - `LambdaCommand`: Inline command for simple operations
- `CommandsManager`: Global registry for command execution - `CommandsManager`: Global registry for command execution

View File

@@ -1,7 +1,6 @@
from fasthtml.xtend import Script from fasthtml.xtend import Script
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance from myfasthtml.core.instances import SingleInstance

View File

@@ -1,6 +1,6 @@
from myfasthtml.controls.VisNetwork import VisNetwork from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.commands import CommandsManager from myfasthtml.core.commands import CommandsManager
from myfasthtml.core.instances import SingleInstance from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.network_utils import from_parent_child_list from myfasthtml.core.network_utils import from_parent_child_list
@@ -9,22 +9,18 @@ class CommandsDebugger(SingleInstance):
Represents a debugger designed for visualizing and managing commands in a parent-child Represents a debugger designed for visualizing and managing commands in a parent-child
hierarchical structure. hierarchical structure.
""" """
def __init__(self, parent, _id=None): def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
def render(self): def render(self):
commands = self._get_commands() nodes, edges = self._get_nodes_and_edges()
nodes, edges = from_parent_child_list(commands,
id_getter=lambda x: str(x.id),
label_getter=lambda x: x.name,
parent_getter=lambda x: str(self.get_command_parent(x))
)
vis_network = VisNetwork(self, nodes=nodes, edges=edges) vis_network = VisNetwork(self, nodes=nodes, edges=edges)
return vis_network return vis_network
@staticmethod @staticmethod
def get_command_parent(command): def get_command_parent_from_ft(command):
if (ft := command.get_ft()) is None: if (ft := command.get_ft()) is None:
return None return None
if hasattr(ft, "get_id") and callable(ft.get_id): if hasattr(ft, "get_id") and callable(ft.get_id):
@@ -36,6 +32,30 @@ class CommandsDebugger(SingleInstance):
return None return None
@staticmethod
def get_command_parent_from_instance(command):
if command.owner is None:
return None
return command.owner.get_full_id()
def _get_nodes_and_edges(self):
commands = self._get_commands()
nodes, edges = from_parent_child_list(commands,
id_getter=lambda x: str(x.id),
label_getter=lambda x: x.name,
parent_getter=lambda x: str(self.get_command_parent_from_instance(x)),
ghost_label_getter=lambda x: InstancesManager.get(*x.split("#")).get_id()
)
for edge in edges:
edge["color"] = "blue"
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
for node in nodes:
node["shape"] = "box"
return nodes, edges
def _get_commands(self): def _get_commands(self):
return list(CommandsManager.commands.values()) return list(CommandsManager.commands.values())

View File

@@ -10,7 +10,7 @@ from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command, LambdaCommand
from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance, InstancesManager from myfasthtml.core.instances import MultipleInstance, InstancesManager
from myfasthtml.icons.fluent_p1 import table_add20_regular from myfasthtml.icons.fluent_p1 import table_add20_regular
@@ -51,22 +51,29 @@ class Commands(BaseCommands):
"Open from Excel", "Open from Excel",
self._owner, self._owner,
self._owner.open_from_excel, self._owner.open_from_excel,
tab_id, args=[tab_id,
file_upload).htmx(target=f"#{self._owner._tree.get_id()}") file_upload]).htmx(target=f"#{self._owner._tree.get_id()}")
def clear_tree(self): def clear_tree(self):
return Command("ClearTree", return Command("ClearTree",
"Clear tree", "Clear tree",
self._owner, self._owner,
self._owner.clear_tree).htmx(target=f"#{self._owner._tree.get_id()}") self._owner.clear_tree).htmx(target=f"#{self._owner._tree.get_id()}")
def show_document(self):
return LambdaCommand(self._owner, lambda: print("show_document"))
class DataGridsManager(MultipleInstance): class DataGridsManager(MultipleInstance):
def __init__(self, parent, _id=None): def __init__(self, parent, _id=None):
if not getattr(self, "_is_new_instance", False):
# Skip __init__ if instance already existed
return
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.commands = Commands(self) self.commands = Commands(self)
self._state = DataGridsState(self) self._state = DataGridsState(self)
self._tree = self._mk_tree() self._tree = self._mk_tree()
self._tree.bind_command("SelectNode", self.commands.show_document())
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager) self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
def upload_from_source(self): def upload_from_source(self):
@@ -86,7 +93,7 @@ class DataGridsManager(MultipleInstance):
name=file_upload.get_sheet_name(), name=file_upload.get_sheet_name(),
type="excel", type="excel",
tab_id=tab_id, tab_id=tab_id,
datagrid_id=None datagrid_id=dg.get_id()
) )
self._state.elements = self._state.elements + [document] self._state.elements = self._state.elements + [document]
parent_id = self._tree.ensure_path(document.namespace) parent_id = self._tree.ensure_path(document.namespace)
@@ -110,7 +117,7 @@ class DataGridsManager(MultipleInstance):
tree = TreeView(self, _id="-treeview") tree = TreeView(self, _id="-treeview")
for element in self._state.elements: for element in self._state.elements:
parent_id = tree.ensure_path(element.namespace) parent_id = tree.ensure_path(element.namespace)
tree.add_node(TreeNode(label=element.name, type=element.type, parent=parent_id)) tree.add_node(TreeNode(label=element.name, type=element.type, parent=parent_id, id=element.datagrid_id))
return tree return tree
def render(self): def render(self):

View File

@@ -34,6 +34,7 @@ class Dropdown(MultipleInstance):
The dropdown provides functionality to manage its state, including opening, closing, and The dropdown provides functionality to manage its state, including opening, closing, and
handling user interactions. handling user interactions.
""" """
def __init__(self, parent, content=None, button=None, _id=None): def __init__(self, parent, content=None, button=None, _id=None):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.button = Div(button) if not isinstance(button, FT) else button self.button = Div(button) if not isinstance(button, FT) else button

View File

@@ -37,7 +37,7 @@ class InstancesDebugger(SingleInstance):
instances, instances,
id_getter=lambda x: x.get_full_id(), id_getter=lambda x: x.get_full_id(),
label_getter=lambda x: f"{x.get_id()}", label_getter=lambda x: f"{x.get_id()}",
parent_getter=lambda x: x.get_full_parent_id() parent_getter=lambda x: x.get_parent_full_id()
) )
for edge in edges: for edge in edges:
edge["color"] = "green" edge["color"] = "green"

View File

@@ -2,7 +2,7 @@ import json
from fasthtml.xtend import Script from fasthtml.xtend import Script
from myfasthtml.core.commands import BaseCommand from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
@@ -17,7 +17,7 @@ class Keyboard(MultipleInstance):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.combinations = combinations or {} self.combinations = combinations or {}
def add(self, sequence: str, command: BaseCommand): def add(self, sequence: str, command: Command):
self.combinations[sequence] = command self.combinations[sequence] = command
return self return self

View File

@@ -40,7 +40,8 @@ class Commands(BaseCommands):
return Command("ToggleDrawer", return Command("ToggleDrawer",
f"Toggle {side} layout drawer", f"Toggle {side} layout drawer",
self._owner, self._owner,
self._owner.toggle_drawer, side) self._owner.toggle_drawer,
args=[side])
def update_drawer_width(self, side: Literal["left", "right"], width: int = None): def update_drawer_width(self, side: Literal["left", "right"], width: int = None):
""" """
@@ -57,7 +58,7 @@ class Commands(BaseCommands):
f"Update {side} drawer width", f"Update {side} drawer width",
self._owner, self._owner,
self._owner.update_drawer_width, self._owner.update_drawer_width,
side) args=[side])
class Layout(SingleInstance): class Layout(SingleInstance):

View File

@@ -2,7 +2,7 @@ import json
from fasthtml.xtend import Script from fasthtml.xtend import Script
from myfasthtml.core.commands import BaseCommand from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
@@ -17,7 +17,7 @@ class Mouse(MultipleInstance):
super().__init__(parent, _id=_id) super().__init__(parent, _id=_id)
self.combinations = combinations or {} self.combinations = combinations or {}
def add(self, sequence: str, command: BaseCommand): def add(self, sequence: str, command: Command):
self.combinations[sequence] = command self.combinations[sequence] = command
return self return self

View File

@@ -21,7 +21,7 @@ class Commands(BaseCommands):
f"Toggle {side} side panel", f"Toggle {side} side panel",
self._owner, self._owner,
self._owner.toggle_side, self._owner.toggle_side,
side) args=[side])
def update_side_width(self, side: Literal["left", "right"]): def update_side_width(self, side: Literal["left", "right"]):
""" """
@@ -37,7 +37,7 @@ class Commands(BaseCommands):
f"Update {side} side panel width", f"Update {side} side panel width",
self._owner, self._owner,
self._owner.update_side_width, self._owner.update_side_width,
side) args=[side])
class Panel(MultipleInstance): class Panel(MultipleInstance):

View File

@@ -62,25 +62,25 @@ class Commands(BaseCommands):
"Activate or show a specific tab", "Activate or show a specific tab",
self._owner, self._owner,
self._owner.show_tab, self._owner.show_tab,
tab_id, args=[tab_id,
True, True,
False).htmx(target=f"#{self._id}-controller", swap="outerHTML") False]).htmx(target=f"#{self._id}-controller", swap="outerHTML")
def close_tab(self, tab_id): def close_tab(self, tab_id):
return Command(f"{self._prefix}CloseTab", return Command(f"{self._prefix}CloseTab",
"Close a specific tab", "Close a specific tab",
self._owner, self._owner,
self._owner.close_tab, self._owner.close_tab,
tab_id).htmx(target=f"#{self._id}-controller", swap="outerHTML") args=[tab_id]).htmx(target=f"#{self._id}-controller", swap="outerHTML")
def add_tab(self, label: str, component: Any, auto_increment=False): def add_tab(self, label: str, component: Any, auto_increment=False):
return Command(f"{self._prefix}AddTab", return Command(f"{self._prefix}AddTab",
"Add a new tab", "Add a new tab",
self._owner, self._owner,
self._owner.on_new_tab, self._owner.on_new_tab,
label, args=[label,
component, component,
auto_increment).htmx(target=f"#{self._id}-controller", swap="outerHTML") auto_increment]).htmx(target=f"#{self._id}-controller", swap="outerHTML")
class TabsManager(MultipleInstance): class TabsManager(MultipleInstance):

View File

@@ -13,7 +13,7 @@ from fasthtml.components import Div, Input, Span
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.Keyboard import Keyboard from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.helpers import mk from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command, CommandTemplate
from myfasthtml.core.dbmanager import DbObject from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular
@@ -37,7 +37,7 @@ class TreeNode:
type: str = "default" type: str = "default"
parent: Optional[str] = None parent: Optional[str] = None
children: list[str] = field(default_factory=list) children: list[str] = field(default_factory=list)
bag: Optional[dict] = None # to keep extra info bag: Optional[dict] = None # to keep extra info
class TreeViewState(DbObject): class TreeViewState(DbObject):
@@ -71,7 +71,9 @@ class Commands(BaseCommands):
f"Toggle node {node_id}", f"Toggle node {node_id}",
self._owner, self._owner,
self._owner._toggle_node, self._owner._toggle_node,
node_id).htmx(target=f"#{self._owner.get_id()}") kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-ToggleNode"
).htmx(target=f"#{self._owner.get_id()}")
def add_child(self, parent_id: str): def add_child(self, parent_id: str):
"""Create command to add a child node.""" """Create command to add a child node."""
@@ -79,7 +81,9 @@ class Commands(BaseCommands):
f"Add child to {parent_id}", f"Add child to {parent_id}",
self._owner, self._owner,
self._owner._add_child, self._owner._add_child,
parent_id).htmx(target=f"#{self._owner.get_id()}") kwargs={"parent_id": parent_id},
key=f"{self._owner.get_safe_parent_key()}-AddChild"
).htmx(target=f"#{self._owner.get_id()}")
def add_sibling(self, node_id: str): def add_sibling(self, node_id: str):
"""Create command to add a sibling node.""" """Create command to add a sibling node."""
@@ -87,7 +91,8 @@ class Commands(BaseCommands):
f"Add sibling to {node_id}", f"Add sibling to {node_id}",
self._owner, self._owner,
self._owner._add_sibling, self._owner._add_sibling,
node_id kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-AddSibling"
).htmx(target=f"#{self._owner.get_id()}") ).htmx(target=f"#{self._owner.get_id()}")
def start_rename(self, node_id: str): def start_rename(self, node_id: str):
@@ -96,7 +101,9 @@ class Commands(BaseCommands):
f"Start renaming {node_id}", f"Start renaming {node_id}",
self._owner, self._owner,
self._owner._start_rename, self._owner._start_rename,
node_id).htmx(target=f"#{self._owner.get_id()}") kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-StartRename"
).htmx(target=f"#{self._owner.get_id()}")
def save_rename(self, node_id: str): def save_rename(self, node_id: str):
"""Create command to save renamed node.""" """Create command to save renamed node."""
@@ -104,14 +111,18 @@ class Commands(BaseCommands):
f"Save rename for {node_id}", f"Save rename for {node_id}",
self._owner, self._owner,
self._owner._save_rename, self._owner._save_rename,
node_id).htmx(target=f"#{self._owner.get_id()}") kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-SaveRename"
).htmx(target=f"#{self._owner.get_id()}")
def cancel_rename(self): def cancel_rename(self):
"""Create command to cancel renaming.""" """Create command to cancel renaming."""
return Command("CancelRename", return Command("CancelRename",
"Cancel rename", "Cancel rename",
self._owner, self._owner,
self._owner._cancel_rename).htmx(target=f"#{self._owner.get_id()}") self._owner._cancel_rename,
key=f"{self._owner.get_safe_parent_key()}-CancelRename"
).htmx(target=f"#{self._owner.get_id()}")
def delete_node(self, node_id: str): def delete_node(self, node_id: str):
"""Create command to delete a node.""" """Create command to delete a node."""
@@ -119,7 +130,9 @@ class Commands(BaseCommands):
f"Delete node {node_id}", f"Delete node {node_id}",
self._owner, self._owner,
self._owner._delete_node, self._owner._delete_node,
node_id).htmx(target=f"#{self._owner.get_id()}") kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-DeleteNode"
).htmx(target=f"#{self._owner.get_id()}")
def select_node(self, node_id: str): def select_node(self, node_id: str):
"""Create command to select a node.""" """Create command to select a node."""
@@ -127,7 +140,9 @@ class Commands(BaseCommands):
f"Select node {node_id}", f"Select node {node_id}",
self._owner, self._owner,
self._owner._select_node, self._owner._select_node,
node_id).htmx(target=f"#{self._owner.get_id()}") kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-SelectNode"
).htmx(target=f"#{self._owner.get_id()}")
class TreeView(MultipleInstance): class TreeView(MultipleInstance):
@@ -394,7 +409,7 @@ class TreeView(MultipleInstance):
name="node_label", name="node_label",
value=node.label, value=node.label,
cls="mf-treenode-input input input-sm" cls="mf-treenode-input input input-sm"
), command=self.commands.save_rename(node_id)) ), command=CommandTemplate("TreeView.SaveRename", self.commands.save_rename, args=[node_id]))
else: else:
label_element = mk.mk( label_element = mk.mk(
Span(node.label, cls="mf-treenode-label text-sm"), Span(node.label, cls="mf-treenode-label text-sm"),

View File

@@ -1,7 +1,7 @@
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.core.bindings import Binding from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command from myfasthtml.core.commands import Command, CommandTemplate
from myfasthtml.core.utils import merge_classes from myfasthtml.core.utils import merge_classes
@@ -14,7 +14,7 @@ class Ids:
class mk: class mk:
@staticmethod @staticmethod
def button(element, command: Command = None, binding: Binding = None, **kwargs): def button(element, command: Command | CommandTemplate = None, binding: Binding = None, **kwargs):
""" """
Defines a static method for creating a Button object with specific configurations. Defines a static method for creating a Button object with specific configurations.
@@ -33,7 +33,7 @@ class mk:
@staticmethod @staticmethod
def dialog_buttons(ok_title: str = "OK", def dialog_buttons(ok_title: str = "OK",
cancel_title: str = "Cancel", cancel_title: str = "Cancel",
on_ok: Command = None, on_ok: Command | CommandTemplate = None,
on_cancel: Command = None, on_cancel: Command = None,
cls=None): cls=None):
return Div( return Div(
@@ -52,7 +52,7 @@ class mk:
can_hover=False, can_hover=False,
tooltip=None, tooltip=None,
cls='', cls='',
command: Command = None, command: Command | CommandTemplate = None,
binding: Binding = None, binding: Binding = None,
**kwargs): **kwargs):
""" """
@@ -92,7 +92,7 @@ class mk:
icon=None, icon=None,
size: str = "sm", size: str = "sm",
cls='', cls='',
command: Command = None, command: Command | CommandTemplate = None,
binding: Binding = None, binding: Binding = None,
**kwargs): **kwargs):
merged_cls = merge_classes("flex", cls, kwargs) merged_cls = merge_classes("flex", cls, kwargs)
@@ -109,7 +109,10 @@ class mk:
replace("xl", "32")) replace("xl", "32"))
@staticmethod @staticmethod
def manage_command(ft, command: Command): def manage_command(ft, command: Command | CommandTemplate):
if isinstance(command, CommandTemplate):
command = command.command
if command: if command:
ft = command.bind_ft(ft) ft = command.bind_ft(ft)
@@ -130,7 +133,7 @@ class mk:
return ft return ft
@staticmethod @staticmethod
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True): def mk(ft, command: Command | CommandTemplate = None, binding: Binding = None, init_binding=True):
ft = mk.manage_command(ft, command) if command else ft ft = mk.manage_command(ft, command) if command else ft
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
return ft return ft

View File

@@ -3,12 +3,13 @@ import json
import uuid import uuid
from typing import Optional from typing import Optional
from myutils.observable import NotObservableError, ObservableEvent, add_event_listener, remove_event_listener from myutils.observable import NotObservableError, ObservableResultCollector
from myfasthtml.core.constants import Routes, ROUTE_ROOT from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.utils import flatten
class BaseCommand: class Command:
""" """
Represents the base command class for defining executable actions. Represents the base command class for defining executable actions.
@@ -25,28 +26,80 @@ class BaseCommand:
:type description: str :type description: str
""" """
def __init__(self, name, description, owner=None, auto_register=True): def __init__(self, name,
description,
owner=None,
callback=None,
args: list = None,
kwargs: dict = None,
key=None,
auto_register=True):
self.id = uuid.uuid4() self.id = uuid.uuid4()
self.name = name self.name = name
self.description = description self.description = description
self.owner = owner self.owner = owner
self.callback = callback
self.default_args = args or []
self.default_kwargs = kwargs or {}
self._htmx_extra = {} self._htmx_extra = {}
self._bindings = [] self._bindings = []
self._ft = None self._ft = None
self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
self._key = key
# register the command # register the command
if auto_register: if auto_register:
CommandsManager.register(self) if key in CommandsManager.commands_by_key:
self.id = CommandsManager.commands_by_key[key].id
else:
CommandsManager.register(self)
def get_key(self):
return self._key
def get_htmx_params(self): def get_htmx_params(self):
return { res = {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-swap": "outerHTML", "hx-swap": "outerHTML",
"hx-vals": {"c_id": f"{self.id}"}, "hx-vals": {"c_id": f"{self.id}"},
} | self._htmx_extra }
for k, v in self._htmx_extra.items():
if k == "hx-post":
continue # cannot override this one
elif k == "hx-vals":
res["hx-vals"] |= v
else:
res[k] = v
# kwarg are given to the callback as values
res["hx-vals"] |= self.default_kwargs
return res
def execute(self, client_response: dict = None): def execute(self, client_response: dict = None):
raise NotImplementedError with ObservableResultCollector(self._bindings) as collector:
kwargs = self._create_kwargs(self.default_kwargs,
client_response,
{"client_response": client_response or {}})
ret = self.callback(*self.default_args, **kwargs)
ret_from_bound_commands = []
if self.owner:
for command in self.owner.get_bound_commands(self.name):
r = command.execute(client_response)
ret_from_bound_commands.append(r) # it will be flatten if needed later
all_ret = flatten(ret, ret_from_bound_commands, collector.results)
# Set the hx-swap-oob attribute on all elements returned by the callback
for r in all_ret[1:]:
if (hasattr(r, 'attrs')
and "hx-swap-oob" not in r.attrs
and r.get("id", None) is not None):
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
return all_ret[0] if len(all_ret) == 1 else all_ret
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None): def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None):
# Note that the default value is the same than in get_htmx_params() # Note that the default value is the same than in get_htmx_params()
@@ -101,49 +154,22 @@ class BaseCommand:
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}" return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
def ajax_htmx_options(self): def ajax_htmx_options(self):
return { res = {
"url": self.url, "url": self.url,
"target": self._htmx_extra.get("hx-target", "this"), "target": self._htmx_extra.get("hx-target", "this"),
"swap": self._htmx_extra.get("hx-swap", "outerHTML"), "swap": self._htmx_extra.get("hx-swap", "outerHTML"),
"values": {} "values": self.default_kwargs
} }
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
return res
def get_ft(self): def get_ft(self):
return self._ft return self._ft
def __str__(self):
return f"Command({self.name})"
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, owner, callback, *args, **kwargs):
super().__init__(name, description, owner=owner)
self.callback = callback
self.callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
self.args = args
self.kwargs = kwargs
def _cast_parameter(self, key, value): def _cast_parameter(self, key, value):
if key in self.callback_parameters: if key in self._callback_parameters:
param = self.callback_parameters[key] param = self._callback_parameters[key]
if param.annotation == bool: if param.annotation == bool:
return value == "true" return value == "true"
elif param.annotation == int: elif param.annotation == int:
@@ -156,72 +182,59 @@ class Command(BaseCommand):
return json.loads(value) return json.loads(value)
return value return value
def ajax_htmx_options(self): def _create_kwargs(self, *args):
res = super().ajax_htmx_options() """
if self.kwargs: Try to recreate the requested kwargs from the client response and the default kwargs.
res["values"] |= self.kwargs :param args:
res["values"]["c_id"] = f"{self.id}" # cannot be overridden :return:
"""
all_args = {}
for arg in [arg for arg in args if arg is not None]:
all_args |= arg
res = {}
for k, v in self._callback_parameters.items():
if k in all_args:
res[k] = self._cast_parameter(k, all_args[k])
return res return res
def execute(self, client_response: dict = None): def __str__(self):
ret_from_bindings = [] return f"Command({self.name})"
def binding_result_callback(attr, old, new, results):
ret_from_bindings.extend(results)
for data in self._bindings:
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
new_kwargs = self.kwargs.copy()
if client_response:
for k, v in client_response.items():
if k in self.callback_parameters:
new_kwargs[k] = self._cast_parameter(k, v)
if 'client_response' in self.callback_parameters:
new_kwargs['client_response'] = client_response
ret = self.callback(*self.args, **new_kwargs)
for data in self._bindings:
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
# Set the hx-swap-oob attribute on all elements returned by the callback
if isinstance(ret, (list, tuple)):
for r in ret[1:]:
if (hasattr(r, 'attrs')
and "hx-swap-oob" not in r.attrs
and r.get("id", None) is not None):
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
if not ret_from_bindings:
return ret
if isinstance(ret, (list, tuple)):
return list(ret) + ret_from_bindings
else:
return [ret] + ret_from_bindings
class LambdaCommand(Command): class LambdaCommand(Command):
def __init__(self, owner, delegate, name="LambdaCommand", description="Lambda Command"): def __init__(self, owner, delegate, name="LambdaCommand", description="Lambda Command"):
super().__init__(name, description, owner, delegate) super().__init__(name, description, owner, delegate)
self.htmx(target=None) self.htmx(target=None)
def execute(self, client_response: dict = None):
return self.callback(client_response) class CommandTemplate:
def __init__(self, key, command_type, args: list = None, kwargs: dict = None):
self.key = key
args = args or []
kwargs = kwargs or {}
self.command = CommandsManager.get_command_by_key(key) or command_type(*args, **kwargs)
class CommandsManager: class CommandsManager:
commands = {} commands = {} # by_id
commands_by_key = {}
@staticmethod @staticmethod
def register(command: BaseCommand): def register(command: Command):
CommandsManager.commands[str(command.id)] = command CommandsManager.commands[str(command.id)] = command
if (key := command.get_key()) is not None:
CommandsManager.commands_by_key[key] = command
@staticmethod @staticmethod
def get_command(command_id: str) -> Optional[BaseCommand]: def get_command(command_id: str) -> Optional[Command]:
return CommandsManager.commands.get(command_id) return CommandsManager.commands.get(command_id)
@staticmethod
def get_command_by_key(key):
return CommandsManager.commands_by_key.get(key, None)
@staticmethod @staticmethod
def reset(): def reset():
return CommandsManager.commands.clear() CommandsManager.commands.clear()
CommandsManager.commands_by_key.clear()

View File

@@ -67,6 +67,7 @@ class BaseInstance:
self._session = session or (parent.get_session() if parent else None) self._session = session or (parent.get_session() if parent else None)
self._id = self.compute_id(_id, parent) self._id = self.compute_id(_id, parent)
self._prefix = self._id if isinstance(self, (UniqueInstance, SingleInstance)) else self.compute_prefix() self._prefix = self._id if isinstance(self, (UniqueInstance, SingleInstance)) else self.compute_prefix()
self._bound_commands = {}
if auto_register: if auto_register:
InstancesManager.register(self._session, self) InstancesManager.register(self._session, self)
@@ -80,16 +81,26 @@ class BaseInstance:
def get_parent(self) -> Optional['BaseInstance']: def get_parent(self) -> Optional['BaseInstance']:
return self._parent return self._parent
def get_safe_parent_key(self):
return self.get_parent_full_id() if self.get_parent() else self.get_full_id()
def get_prefix(self) -> str: def get_prefix(self) -> str:
return self._prefix return self._prefix
def get_full_id(self) -> str: 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]: def get_parent_full_id(self) -> Optional[str]:
parent = self.get_parent() parent = self.get_parent()
return parent.get_full_id() if parent else None return parent.get_full_id() if parent else None
def bind_command(self, command, command_to_bind):
command_name = command.name if hasattr(command, "name") else command
self._bound_commands.setdefault(command_name, []).append(command_to_bind)
def get_bound_commands(self, command_name):
return self._bound_commands.get(command_name, [])
@classmethod @classmethod
def compute_prefix(cls): def compute_prefix(cls):
return f"mf-{pascal_to_snake(cls.__name__)}" return f"mf-{pascal_to_snake(cls.__name__)}"

View File

@@ -150,6 +150,7 @@ def from_parent_child_list(
id_getter: Callable = None, id_getter: Callable = None,
label_getter: Callable = None, label_getter: Callable = None,
parent_getter: Callable = None, parent_getter: Callable = None,
ghost_label_getter: Callable = lambda node: str(node),
ghost_color: str = GHOST_COLOR, ghost_color: str = GHOST_COLOR,
root_color: str | None = ROOT_COLOR root_color: str | None = ROOT_COLOR
) -> tuple[list, list]: ) -> tuple[list, list]:
@@ -161,6 +162,7 @@ def from_parent_child_list(
id_getter: callback to extract node ID id_getter: callback to extract node ID
label_getter: callback to extract node label label_getter: callback to extract node label
parent_getter: callback to extract parent ID parent_getter: callback to extract parent ID
ghost_label_getter: callback to extract label for ghost nodes
ghost_color: color for ghost nodes (referenced parents) ghost_color: color for ghost nodes (referenced parents)
root_color: color for root nodes (nodes without parent) root_color: color for root nodes (nodes without parent)
@@ -225,7 +227,7 @@ def from_parent_child_list(
ghost_nodes.add(parent_id) ghost_nodes.add(parent_id)
nodes.append({ nodes.append({
"id": parent_id, "id": parent_id,
"label": str(parent_id), "label": ghost_label_getter(parent_id),
"color": ghost_color "color": ghost_color
}) })

View File

@@ -262,6 +262,28 @@ def snake_to_pascal(name: str) -> str:
return ''.join(word.capitalize() for word in parts if word) return ''.join(word.capitalize() for word in parts if word)
def flatten(*args):
"""
Flattens nested lists or tuples into a single list. This utility function takes
any number of arguments, iterating recursively through any nested lists or
tuples, and returns a flat list containing all the elements.
:param args: Arbitrary number of arguments, which can include nested lists or
tuples to be flattened.
:type args: Any
:return: A flat list containing all the elements from the input, preserving the
order of elements as they are recursively extracted from nested
structures.
:rtype: list
"""
res = []
for arg in args:
if isinstance(arg, (list, tuple)):
res.extend(flatten(*arg))
else:
res.append(arg)
return res
@utils_rt(Routes.Commands) @utils_rt(Routes.Commands)
def post(session, c_id: str, client_response: dict = None): def post(session, c_id: str, client_response: dict = None):
""" """

View File

@@ -5,7 +5,7 @@ from typing import Optional, Any
from fastcore.basics import NotStr from fastcore.basics import NotStr
from fastcore.xml import FT from fastcore.xml import FT
from myfasthtml.core.commands import BaseCommand from myfasthtml.core.commands import Command
from myfasthtml.core.utils import quoted_str, snake_to_pascal from myfasthtml.core.utils import quoted_str, snake_to_pascal
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
@@ -160,7 +160,7 @@ class AttributeForbidden(ChildrenPredicate):
class HasHtmx(ChildrenPredicate): class HasHtmx(ChildrenPredicate):
def __init__(self, command: BaseCommand = None, **htmx_params): def __init__(self, command: Command = None, **htmx_params):
super().__init__(None) super().__init__(None)
self.command = command self.command = command
if command: if command:

View File

@@ -45,7 +45,7 @@ def test_i_can_mk_button_with_attrs():
def test_i_can_mk_button_with_command(user, rt): def test_i_can_mk_button_with_command(user, rt):
def new_value(value): return value def new_value(value): return value
command = Command('test', 'TestingCommand', None, new_value, "this is my new value") command = Command('test', 'TestingCommand', None, new_value, args=["this is my new value"])
@rt('/') @rt('/')
def get(): return mk.button('button', command) def get(): return mk.button('button', command)

View File

@@ -464,20 +464,20 @@ class TestTreeviewBehaviour:
def test_i_cannot_ensure_path_with_none(self, root_instance): def test_i_cannot_ensure_path_with_none(self, root_instance):
"""Test that ensure_path raises ValueError when path is None.""" """Test that ensure_path raises ValueError when path is None."""
tree_view = TreeView(root_instance) tree_view = TreeView(root_instance)
with pytest.raises(ValueError, match="Invalid path.*None"): with pytest.raises(ValueError, match="Invalid path.*None"):
tree_view.ensure_path(None) tree_view.ensure_path(None)
def test_i_cannot_ensure_path_with_empty_string(self, root_instance): def test_i_cannot_ensure_path_with_empty_string(self, root_instance):
"""Test that ensure_path raises ValueError for empty strings after stripping.""" """Test that ensure_path raises ValueError for empty strings after stripping."""
tree_view = TreeView(root_instance) tree_view = TreeView(root_instance)
with pytest.raises(ValueError, match="Invalid path.*empty"): with pytest.raises(ValueError, match="Invalid path.*empty"):
tree_view.ensure_path(" ") tree_view.ensure_path(" ")
with pytest.raises(ValueError, match="Invalid path.*empty"): with pytest.raises(ValueError, match="Invalid path.*empty"):
tree_view.ensure_path("") tree_view.ensure_path("")
with pytest.raises(ValueError, match="Invalid path.*empty"): with pytest.raises(ValueError, match="Invalid path.*empty"):
tree_view.ensure_path("...") tree_view.ensure_path("...")
@@ -531,44 +531,57 @@ class TestTreeviewBehaviour:
def test_i_cannot_ensure_path_with_only_spaces_parts(self, root_instance): def test_i_cannot_ensure_path_with_only_spaces_parts(self, root_instance):
"""Test that ensure_path raises ValueError for path parts with only spaces.""" """Test that ensure_path raises ValueError for path parts with only spaces."""
tree_view = TreeView(root_instance) tree_view = TreeView(root_instance)
with pytest.raises(ValueError, match="Invalid path"): with pytest.raises(ValueError, match="Invalid path"):
tree_view.ensure_path("folder1. .folder2") tree_view.ensure_path("folder1. .folder2")
def test_ensure_path_returns_last_node_id(self, root_instance): def test_ensure_path_returns_last_node_id(self, root_instance):
"""Test that ensure_path returns the ID of the last node in the path.""" """Test that ensure_path returns the ID of the last node in the path."""
tree_view = TreeView(root_instance) tree_view = TreeView(root_instance)
# Create a path and get the returned ID # Create a path and get the returned ID
returned_id = tree_view.ensure_path("folder1.folder2.folder3") returned_id = tree_view.ensure_path("folder1.folder2.folder3")
# Verify the returned ID is not None # Verify the returned ID is not None
assert returned_id is not None assert returned_id is not None
# Verify the returned ID corresponds to folder3 # Verify the returned ID corresponds to folder3
assert returned_id in tree_view._state.items assert returned_id in tree_view._state.items
assert tree_view._state.items[returned_id].label == "folder3" assert tree_view._state.items[returned_id].label == "folder3"
# Verify we can use this ID to add a child # Verify we can use this ID to add a child
leaf = TreeNode(label="file.txt", type="file") leaf = TreeNode(label="file.txt", type="file")
tree_view.add_node(leaf, parent_id=returned_id) tree_view.add_node(leaf, parent_id=returned_id)
assert leaf.parent == returned_id assert leaf.parent == returned_id
assert leaf.id in tree_view._state.items[returned_id].children assert leaf.id in tree_view._state.items[returned_id].children
def test_ensure_path_returns_existing_node_id(self, root_instance): def test_ensure_path_returns_existing_node_id(self, root_instance):
"""Test that ensure_path returns ID even when path already exists.""" """Test that ensure_path returns ID even when path already exists."""
tree_view = TreeView(root_instance) tree_view = TreeView(root_instance)
# Create initial path # Create initial path
first_id = tree_view.ensure_path("folder1.folder2") first_id = tree_view.ensure_path("folder1.folder2")
# Ensure same path again # Ensure same path again
second_id = tree_view.ensure_path("folder1.folder2") second_id = tree_view.ensure_path("folder1.folder2")
# Should return the same ID # Should return the same ID
assert first_id == second_id assert first_id == second_id
assert tree_view._state.items[first_id].label == "folder2" assert tree_view._state.items[first_id].label == "folder2"
def test_i_can_add_the_same_node_id_twice(self, root_instance):
"""Test that adding a node with the same ID as an existing node raises ValueError."""
tree_view = TreeView(root_instance)
node1 = TreeNode(label="Node", type="folder", id="existing_id")
tree_view.add_node(node1)
node2 = TreeNode(label="Other Node", type="folder", id="existing_id")
tree_view.add_node(node2)
assert len(tree_view._state.items) == 1, "Node should not have been added to items"
assert tree_view._state.items[node1.id] == node2, "Node should not have been replaced"
class TestTreeViewRender: class TestTreeViewRender:

View File

@@ -33,9 +33,15 @@ class TestCommandDefault:
assert command.description == 'Command description' assert command.description == 'Command description'
assert command.execute() == "Hello World" assert command.execute() == "Hello World"
def test_command_are_registered(self): def test_commands_are_registered(self):
command = Command('test', 'Command description', None, callback) command = Command('test', 'Command description', None, callback)
assert CommandsManager.commands.get(str(command.id)) is command assert CommandsManager.commands.get(str(command.id)) is command
def test_commands_with_the_same_key_share_the_same_id(self):
command1 = Command('test', 'Command description', None, None, key="test_key")
command2 = Command('test', 'Command description', None, None, key="test_key")
assert command1.id is command2.id
class TestCommandBind: class TestCommandBind:
@@ -74,7 +80,7 @@ class TestCommandBind:
res = command.execute() res = command.execute()
assert res == ["another callback result", ("hello", "new value")] assert res == ["another callback result", "hello", "new value"]
def test_i_can_bind_a_command_to_an_observable_2(self): def test_i_can_bind_a_command_to_an_observable_2(self):
data = Data("hello") data = Data("hello")
@@ -92,7 +98,7 @@ class TestCommandBind:
res = command.execute() res = command.execute()
assert res == ["another 1", "another 2", ("hello", "new value")] assert res == ["another 1", "another 2", "hello", "new value"]
def test_by_default_swap_is_set_to_outer_html(self): def test_by_default_swap_is_set_to_outer_html(self):
command = Command('test', 'Command description', None, callback) command = Command('test', 'Command description', None, callback)
@@ -120,6 +126,15 @@ class TestCommandBind:
assert "hx_swap_oob" not in res[0].attrs assert "hx_swap_oob" not in res[0].attrs
assert res[1].attrs["hx-swap-oob"] == "true" assert res[1].attrs["hx-swap-oob"] == "true"
assert res[3].attrs["hx-swap-oob"] == "true" assert res[3].attrs["hx-swap-oob"] == "true"
def test_i_can_send_parameters(self):
command = Command('test', 'Command description', None, None, kwargs={"param": "value"}) # callback is not important
elt = Button()
updated = command.bind_ft(elt)
hx_vals = updated.attrs["hx-vals"]
assert 'param' in hx_vals
assert hx_vals['param'] == 'value'
class TestCommandExecute: class TestCommandExecute:
@@ -137,7 +152,7 @@ class TestCommandExecute:
def callback_with_param(param): def callback_with_param(param):
return f"Hello {param}" return f"Hello {param}"
command = Command('test', 'Command description', None, callback_with_param, "world") command = Command('test', 'Command description', None, callback_with_param, args=["world"])
assert command.execute() == "Hello world" assert command.execute() == "Hello world"
def test_i_can_execute_a_command_with_open_parameter(self): def test_i_can_execute_a_command_with_open_parameter(self):
@@ -188,9 +203,9 @@ class TestCommandExecute:
class TestLambaCommand: class TestLambaCommand:
def test_i_can_create_a_command_from_lambda(self): def test_i_can_create_a_command_from_lambda(self):
command = LambdaCommand(None, lambda resp: "Hello World") command = LambdaCommand(None, lambda: "Hello World")
assert command.execute() == "Hello World" assert command.execute() == "Hello World"
def test_by_default_target_is_none(self): def test_by_default_target_is_none(self):
command = LambdaCommand(None, lambda resp: "Hello World") command = LambdaCommand(None, lambda: "Hello World")
assert command.get_htmx_params()["hx-swap"] == "none" assert command.get_htmx_params()["hx-swap"] == "none"

58
tests/core/test_utils.py Normal file
View File

@@ -0,0 +1,58 @@
import pytest
from myfasthtml.core.utils import flatten
@pytest.mark.parametrize("input_args,expected,test_description", [
# Simple list without nesting
(([1, 2, 3],), [1, 2, 3], "simple list"),
# Nested list (one level)
(([1, [2, 3], 4],), [1, 2, 3, 4], "nested list one level"),
# Nested tuple
(((1, (2, 3), 4),), [1, 2, 3, 4], "nested tuple"),
# Mixed list and tuple
(([1, (2, 3), [4, 5]],), [1, 2, 3, 4, 5], "mixed list and tuple"),
# Deeply nested structure
(([1, [2, [3, [4, 5]]]],), [1, 2, 3, 4, 5], "deeply nested structure"),
# Empty list
(([],), [], "empty list"),
# Empty nested lists
(([1, [], [2, []], 3],), [1, 2, 3], "empty nested lists"),
# Preserves order
(([[3, 1], [4, 2]],), [3, 1, 4, 2], "preserves order"),
# Strings (should not be iterated)
((["hello", ["world"]],), ["hello", "world"], "strings not iterated"),
# Mixed types
(([1, "text", [2.5, True], None],), [1, "text", 2.5, True, None], "mixed types"),
# Multiple arguments with lists
(([1, 2], [3, 4], 5), [1, 2, 3, 4, 5], "multiple arguments with lists"),
# Scalar values only
((1, 2, 3), [1, 2, 3], "scalar values only"),
# Mixed scalars and lists
((1, [2, 3], 4, [5, 6]), [1, 2, 3, 4, 5, 6], "mixed scalars and lists"),
# Multiple nested arguments
(([1, [2]], [3, [4]], 5), [1, 2, 3, 4, 5], "multiple nested arguments"),
# No arguments
((), [], "no arguments"),
# Complex real-world example
(([1, [2, 3], [[4, 5], [6, 7]], [[[8, 9]]], 10],), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "complex nesting"),
])
def test_i_can_flatten(input_args, expected, test_description):
"""Test that flatten correctly handles various nested structures and arguments."""
result = flatten(*input_args)
assert result == expected, f"Failed for test case: {test_description}"

View File

@@ -34,13 +34,13 @@ def rt(user):
class TestingCommand: class TestingCommand:
def test_i_can_trigger_a_command(self, user): def test_i_can_trigger_a_command(self, user):
command = Command('test', 'TestingCommand', None, new_value, "this is my new value") command = Command('test', 'TestingCommand', None, new_value, args=["this is my new value"])
testable = TestableElement(user, mk.button('button', command)) testable = TestableElement(user, mk.button('button', command))
testable.click() testable.click()
assert user.get_content() == "this is my new value" assert user.get_content() == "this is my new value"
def test_error_is_raised_when_command_is_not_found(self, user): def test_error_is_raised_when_command_is_not_found(self, user):
command = Command('test', 'TestingCommand', None, new_value, "this is my new value") command = Command('test', 'TestingCommand', None, new_value, args=["this is my new value"])
CommandsManager.reset() CommandsManager.reset()
testable = TestableElement(user, mk.button('button', command)) testable = TestableElement(user, mk.button('button', command))
@@ -50,7 +50,7 @@ class TestingCommand:
assert "not found." in str(exc_info.value) assert "not found." in str(exc_info.value)
def test_i_can_play_a_complex_scenario(self, user, rt): def test_i_can_play_a_complex_scenario(self, user, rt):
command = Command('test', 'TestingCommand', None, new_value, "this is my new value") command = Command('test', 'TestingCommand', None, new_value, args=["this is my new value"])
@rt('/') @rt('/')
def get(): return mk.button('button', command) def get(): return mk.button('button', command)