From 1347f1261839b850b4e646e1091425a779171f09 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Fri, 19 Dec 2025 21:12:55 +0100 Subject: [PATCH] Improved Command class management. --- CLAUDE.md | 2 +- src/myfasthtml/controls/Boundaries.py | 1 - src/myfasthtml/controls/CommandsDebugger.py | 36 +++- src/myfasthtml/controls/DataGridsManager.py | 17 +- src/myfasthtml/controls/Dropdown.py | 1 + src/myfasthtml/controls/InstancesDebugger.py | 2 +- src/myfasthtml/controls/Keyboard.py | 4 +- src/myfasthtml/controls/Layout.py | 5 +- src/myfasthtml/controls/Mouse.py | 4 +- src/myfasthtml/controls/Panel.py | 4 +- src/myfasthtml/controls/TabsManager.py | 14 +- src/myfasthtml/controls/TreeView.py | 37 ++-- src/myfasthtml/controls/helpers.py | 17 +- src/myfasthtml/core/commands.py | 193 ++++++++++--------- src/myfasthtml/core/instances.py | 13 +- src/myfasthtml/core/network_utils.py | 4 +- src/myfasthtml/core/utils.py | 22 +++ src/myfasthtml/test/matcher.py | 4 +- tests/controls/test_helpers.py | 2 +- tests/controls/test_treeview.py | 45 +++-- tests/core/test_commands.py | 27 ++- tests/core/test_utils.py | 58 ++++++ tests/test_integration.py | 6 +- 23 files changed, 349 insertions(+), 169 deletions(-) create mode 100644 tests/core/test_utils.py diff --git a/CLAUDE.md b/CLAUDE.md index ad54e2c..947bdb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,7 +173,7 @@ pip install -e . Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`. **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 - `LambdaCommand`: Inline command for simple operations - `CommandsManager`: Global registry for command execution diff --git a/src/myfasthtml/controls/Boundaries.py b/src/myfasthtml/controls/Boundaries.py index 9c99353..f82ac89 100644 --- a/src/myfasthtml/controls/Boundaries.py +++ b/src/myfasthtml/controls/Boundaries.py @@ -1,7 +1,6 @@ from fasthtml.xtend import Script from myfasthtml.controls.BaseCommands import BaseCommands -from myfasthtml.controls.helpers import Ids from myfasthtml.core.commands import Command from myfasthtml.core.instances import SingleInstance diff --git a/src/myfasthtml/controls/CommandsDebugger.py b/src/myfasthtml/controls/CommandsDebugger.py index 8168ff9..9596b3c 100644 --- a/src/myfasthtml/controls/CommandsDebugger.py +++ b/src/myfasthtml/controls/CommandsDebugger.py @@ -1,6 +1,6 @@ from myfasthtml.controls.VisNetwork import VisNetwork 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 @@ -9,22 +9,18 @@ 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) def render(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(x)) - ) + nodes, edges = self._get_nodes_and_edges() vis_network = VisNetwork(self, nodes=nodes, edges=edges) return vis_network @staticmethod - def get_command_parent(command): + def get_command_parent_from_ft(command): if (ft := command.get_ft()) is None: return None if hasattr(ft, "get_id") and callable(ft.get_id): @@ -36,6 +32,30 @@ class CommandsDebugger(SingleInstance): 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): return list(CommandsManager.commands.values()) diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 2f065e2..bdb9ef1 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -10,7 +10,7 @@ from myfasthtml.controls.Panel import Panel from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.TreeView import TreeView, TreeNode 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.instances import MultipleInstance, InstancesManager from myfasthtml.icons.fluent_p1 import table_add20_regular @@ -51,22 +51,29 @@ class Commands(BaseCommands): "Open from Excel", self._owner, self._owner.open_from_excel, - tab_id, - file_upload).htmx(target=f"#{self._owner._tree.get_id()}") + args=[tab_id, + file_upload]).htmx(target=f"#{self._owner._tree.get_id()}") def clear_tree(self): return Command("ClearTree", "Clear tree", self._owner, 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): 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) self.commands = Commands(self) self._state = DataGridsState(self) self._tree = self._mk_tree() + self._tree.bind_command("SelectNode", self.commands.show_document()) self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager) def upload_from_source(self): @@ -86,7 +93,7 @@ class DataGridsManager(MultipleInstance): name=file_upload.get_sheet_name(), type="excel", tab_id=tab_id, - datagrid_id=None + datagrid_id=dg.get_id() ) self._state.elements = self._state.elements + [document] parent_id = self._tree.ensure_path(document.namespace) @@ -110,7 +117,7 @@ class DataGridsManager(MultipleInstance): tree = TreeView(self, _id="-treeview") for element in self._state.elements: 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 def render(self): diff --git a/src/myfasthtml/controls/Dropdown.py b/src/myfasthtml/controls/Dropdown.py index ee303fc..68fdccf 100644 --- a/src/myfasthtml/controls/Dropdown.py +++ b/src/myfasthtml/controls/Dropdown.py @@ -34,6 +34,7 @@ class Dropdown(MultipleInstance): 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 diff --git a/src/myfasthtml/controls/InstancesDebugger.py b/src/myfasthtml/controls/InstancesDebugger.py index da2e531..3c9c3b0 100644 --- a/src/myfasthtml/controls/InstancesDebugger.py +++ b/src/myfasthtml/controls/InstancesDebugger.py @@ -37,7 +37,7 @@ class InstancesDebugger(SingleInstance): instances, id_getter=lambda x: x.get_full_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: edge["color"] = "green" diff --git a/src/myfasthtml/controls/Keyboard.py b/src/myfasthtml/controls/Keyboard.py index ff7e4d8..854b071 100644 --- a/src/myfasthtml/controls/Keyboard.py +++ b/src/myfasthtml/controls/Keyboard.py @@ -2,7 +2,7 @@ import json from fasthtml.xtend import Script -from myfasthtml.core.commands import BaseCommand +from myfasthtml.core.commands import Command from myfasthtml.core.instances import MultipleInstance @@ -17,7 +17,7 @@ class Keyboard(MultipleInstance): super().__init__(parent, _id=_id) self.combinations = combinations or {} - def add(self, sequence: str, command: BaseCommand): + def add(self, sequence: str, command: Command): self.combinations[sequence] = command return self diff --git a/src/myfasthtml/controls/Layout.py b/src/myfasthtml/controls/Layout.py index 0be079f..23b4091 100644 --- a/src/myfasthtml/controls/Layout.py +++ b/src/myfasthtml/controls/Layout.py @@ -40,7 +40,8 @@ class Commands(BaseCommands): return Command("ToggleDrawer", f"Toggle {side} layout drawer", 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): """ @@ -57,7 +58,7 @@ class Commands(BaseCommands): f"Update {side} drawer width", self._owner, self._owner.update_drawer_width, - side) + args=[side]) class Layout(SingleInstance): diff --git a/src/myfasthtml/controls/Mouse.py b/src/myfasthtml/controls/Mouse.py index 8f97124..26a3625 100644 --- a/src/myfasthtml/controls/Mouse.py +++ b/src/myfasthtml/controls/Mouse.py @@ -2,7 +2,7 @@ import json from fasthtml.xtend import Script -from myfasthtml.core.commands import BaseCommand +from myfasthtml.core.commands import Command from myfasthtml.core.instances import MultipleInstance @@ -17,7 +17,7 @@ class Mouse(MultipleInstance): super().__init__(parent, _id=_id) self.combinations = combinations or {} - def add(self, sequence: str, command: BaseCommand): + def add(self, sequence: str, command: Command): self.combinations[sequence] = command return self diff --git a/src/myfasthtml/controls/Panel.py b/src/myfasthtml/controls/Panel.py index 36fedcc..30172a9 100644 --- a/src/myfasthtml/controls/Panel.py +++ b/src/myfasthtml/controls/Panel.py @@ -21,7 +21,7 @@ class Commands(BaseCommands): f"Toggle {side} side panel", self._owner, self._owner.toggle_side, - side) + args=[side]) def update_side_width(self, side: Literal["left", "right"]): """ @@ -37,7 +37,7 @@ class Commands(BaseCommands): f"Update {side} side panel width", self._owner, self._owner.update_side_width, - side) + args=[side]) class Panel(MultipleInstance): diff --git a/src/myfasthtml/controls/TabsManager.py b/src/myfasthtml/controls/TabsManager.py index 07a12f0..9935fff 100644 --- a/src/myfasthtml/controls/TabsManager.py +++ b/src/myfasthtml/controls/TabsManager.py @@ -62,25 +62,25 @@ class Commands(BaseCommands): "Activate or show a specific tab", self._owner, self._owner.show_tab, - tab_id, - True, - False).htmx(target=f"#{self._id}-controller", swap="outerHTML") + args=[tab_id, + True, + False]).htmx(target=f"#{self._id}-controller", swap="outerHTML") def close_tab(self, tab_id): return Command(f"{self._prefix}CloseTab", "Close a specific tab", self._owner, 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): return Command(f"{self._prefix}AddTab", "Add a new tab", self._owner, self._owner.on_new_tab, - label, - component, - auto_increment).htmx(target=f"#{self._id}-controller", swap="outerHTML") + args=[label, + component, + auto_increment]).htmx(target=f"#{self._id}-controller", swap="outerHTML") class TabsManager(MultipleInstance): diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py index 30f8926..b5dfea3 100644 --- a/src/myfasthtml/controls/TreeView.py +++ b/src/myfasthtml/controls/TreeView.py @@ -13,7 +13,7 @@ from fasthtml.components import Div, Input, Span from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.Keyboard import Keyboard 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.instances import MultipleInstance from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular @@ -37,7 +37,7 @@ class TreeNode: type: str = "default" parent: Optional[str] = None 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): @@ -71,7 +71,9 @@ class Commands(BaseCommands): f"Toggle node {node_id}", self._owner, 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): """Create command to add a child node.""" @@ -79,7 +81,9 @@ class Commands(BaseCommands): f"Add child to {parent_id}", self._owner, 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): """Create command to add a sibling node.""" @@ -87,7 +91,8 @@ class Commands(BaseCommands): f"Add sibling to {node_id}", self._owner, 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()}") def start_rename(self, node_id: str): @@ -96,7 +101,9 @@ class Commands(BaseCommands): f"Start renaming {node_id}", self._owner, 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): """Create command to save renamed node.""" @@ -104,14 +111,18 @@ class Commands(BaseCommands): f"Save rename for {node_id}", self._owner, 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): """Create command to cancel renaming.""" return Command("CancelRename", "Cancel rename", 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): """Create command to delete a node.""" @@ -119,7 +130,9 @@ class Commands(BaseCommands): f"Delete node {node_id}", self._owner, 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): """Create command to select a node.""" @@ -127,7 +140,9 @@ class Commands(BaseCommands): f"Select node {node_id}", self._owner, 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): @@ -394,7 +409,7 @@ class TreeView(MultipleInstance): name="node_label", value=node.label, 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: label_element = mk.mk( Span(node.label, cls="mf-treenode-label text-sm"), diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index 6e4aaa4..e3c982b 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -1,7 +1,7 @@ from fasthtml.components import * 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 @@ -14,7 +14,7 @@ class Ids: class mk: @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. @@ -33,7 +33,7 @@ class mk: @staticmethod def dialog_buttons(ok_title: str = "OK", cancel_title: str = "Cancel", - on_ok: Command = None, + on_ok: Command | CommandTemplate = None, on_cancel: Command = None, cls=None): return Div( @@ -52,7 +52,7 @@ class mk: can_hover=False, tooltip=None, cls='', - command: Command = None, + command: Command | CommandTemplate = None, binding: Binding = None, **kwargs): """ @@ -92,7 +92,7 @@ class mk: icon=None, size: str = "sm", cls='', - command: Command = None, + command: Command | CommandTemplate = None, binding: Binding = None, **kwargs): merged_cls = merge_classes("flex", cls, kwargs) @@ -109,7 +109,10 @@ class mk: replace("xl", "32")) @staticmethod - def manage_command(ft, command: Command): + def manage_command(ft, command: Command | CommandTemplate): + if isinstance(command, CommandTemplate): + command = command.command + if command: ft = command.bind_ft(ft) @@ -130,7 +133,7 @@ class mk: return ft @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_binding(ft, binding, init_binding=init_binding) if binding else ft return ft diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 5a1e1b0..c24eab5 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -3,12 +3,13 @@ import json import uuid 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.utils import flatten -class BaseCommand: +class Command: """ Represents the base command class for defining executable actions. @@ -25,28 +26,80 @@ class BaseCommand: :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.name = name self.description = description self.owner = owner + self.callback = callback + self.default_args = args or [] + self.default_kwargs = kwargs or {} self._htmx_extra = {} self._bindings = [] self._ft = None + self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {} + self._key = key # register the command 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): - return { + res = { "hx-post": f"{ROUTE_ROOT}{Routes.Commands}", "hx-swap": "outerHTML", "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): - 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): # 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}" def ajax_htmx_options(self): - return { + res = { "url": self.url, "target": self._htmx_extra.get("hx-target", "this"), "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): 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): - if key in self.callback_parameters: - param = self.callback_parameters[key] + if key in self._callback_parameters: + param = self._callback_parameters[key] if param.annotation == bool: return value == "true" elif param.annotation == int: @@ -156,72 +182,59 @@ class Command(BaseCommand): 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 + def _create_kwargs(self, *args): + """ + Try to recreate the requested kwargs from the client response and the default kwargs. + :param args: + :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 - def execute(self, client_response: dict = None): - ret_from_bindings = [] - - 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 + def __str__(self): + return f"Command({self.name})" class LambdaCommand(Command): def __init__(self, owner, delegate, name="LambdaCommand", description="Lambda Command"): super().__init__(name, description, owner, delegate) 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: - commands = {} + commands = {} # by_id + commands_by_key = {} @staticmethod - def register(command: BaseCommand): + def register(command: Command): CommandsManager.commands[str(command.id)] = command + if (key := command.get_key()) is not None: + CommandsManager.commands_by_key[key] = command @staticmethod - def get_command(command_id: str) -> Optional[BaseCommand]: + def get_command(command_id: str) -> Optional[Command]: return CommandsManager.commands.get(command_id) + @staticmethod + def get_command_by_key(key): + return CommandsManager.commands_by_key.get(key, None) + @staticmethod def reset(): - return CommandsManager.commands.clear() + CommandsManager.commands.clear() + CommandsManager.commands_by_key.clear() diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index 7325a0d..05fd3a1 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -67,6 +67,7 @@ class BaseInstance: self._session = session or (parent.get_session() if parent else None) self._id = self.compute_id(_id, parent) self._prefix = self._id if isinstance(self, (UniqueInstance, SingleInstance)) else self.compute_prefix() + self._bound_commands = {} if auto_register: InstancesManager.register(self._session, self) @@ -80,16 +81,26 @@ class BaseInstance: def get_parent(self) -> Optional['BaseInstance']: 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: return self._prefix def get_full_id(self) -> str: 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() 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 def compute_prefix(cls): return f"mf-{pascal_to_snake(cls.__name__)}" diff --git a/src/myfasthtml/core/network_utils.py b/src/myfasthtml/core/network_utils.py index 5a9c077..6073ec1 100644 --- a/src/myfasthtml/core/network_utils.py +++ b/src/myfasthtml/core/network_utils.py @@ -150,6 +150,7 @@ def from_parent_child_list( id_getter: Callable = None, label_getter: Callable = None, parent_getter: Callable = None, + ghost_label_getter: Callable = lambda node: str(node), ghost_color: str = GHOST_COLOR, root_color: str | None = ROOT_COLOR ) -> tuple[list, list]: @@ -161,6 +162,7 @@ def from_parent_child_list( id_getter: callback to extract node ID label_getter: callback to extract node label 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) root_color: color for root nodes (nodes without parent) @@ -225,7 +227,7 @@ def from_parent_child_list( ghost_nodes.add(parent_id) nodes.append({ "id": parent_id, - "label": str(parent_id), + "label": ghost_label_getter(parent_id), "color": ghost_color }) diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index b3e774c..addfea4 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -262,6 +262,28 @@ def snake_to_pascal(name: str) -> str: 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) def post(session, c_id: str, client_response: dict = None): """ diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py index f1079f5..a143d5e 100644 --- a/src/myfasthtml/test/matcher.py +++ b/src/myfasthtml/test/matcher.py @@ -5,7 +5,7 @@ from typing import Optional, Any from fastcore.basics import NotStr 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.test.testclient import MyFT @@ -160,7 +160,7 @@ class AttributeForbidden(ChildrenPredicate): class HasHtmx(ChildrenPredicate): - def __init__(self, command: BaseCommand = None, **htmx_params): + def __init__(self, command: Command = None, **htmx_params): super().__init__(None) self.command = command if command: diff --git a/tests/controls/test_helpers.py b/tests/controls/test_helpers.py index 3f66293..f8d1903 100644 --- a/tests/controls/test_helpers.py +++ b/tests/controls/test_helpers.py @@ -45,7 +45,7 @@ def test_i_can_mk_button_with_attrs(): def test_i_can_mk_button_with_command(user, rt): 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('/') def get(): return mk.button('button', command) diff --git a/tests/controls/test_treeview.py b/tests/controls/test_treeview.py index ffaa82f..9c7569e 100644 --- a/tests/controls/test_treeview.py +++ b/tests/controls/test_treeview.py @@ -464,20 +464,20 @@ class TestTreeviewBehaviour: def test_i_cannot_ensure_path_with_none(self, root_instance): """Test that ensure_path raises ValueError when path is None.""" tree_view = TreeView(root_instance) - + with pytest.raises(ValueError, match="Invalid path.*None"): tree_view.ensure_path(None) - + def test_i_cannot_ensure_path_with_empty_string(self, root_instance): """Test that ensure_path raises ValueError for empty strings after stripping.""" tree_view = TreeView(root_instance) - + with pytest.raises(ValueError, match="Invalid path.*empty"): tree_view.ensure_path(" ") - + with pytest.raises(ValueError, match="Invalid path.*empty"): tree_view.ensure_path("") - + with pytest.raises(ValueError, match="Invalid path.*empty"): tree_view.ensure_path("...") @@ -531,44 +531,57 @@ class TestTreeviewBehaviour: 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.""" tree_view = TreeView(root_instance) - + with pytest.raises(ValueError, match="Invalid path"): tree_view.ensure_path("folder1. .folder2") - + 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.""" tree_view = TreeView(root_instance) - + # Create a path and get the returned ID returned_id = tree_view.ensure_path("folder1.folder2.folder3") - + # Verify the returned ID is not None assert returned_id is not None - + # Verify the returned ID corresponds to folder3 assert returned_id in tree_view._state.items assert tree_view._state.items[returned_id].label == "folder3" - + # Verify we can use this ID to add a child leaf = TreeNode(label="file.txt", type="file") tree_view.add_node(leaf, parent_id=returned_id) - + assert leaf.parent == returned_id assert leaf.id in tree_view._state.items[returned_id].children - + def test_ensure_path_returns_existing_node_id(self, root_instance): """Test that ensure_path returns ID even when path already exists.""" tree_view = TreeView(root_instance) - + # Create initial path first_id = tree_view.ensure_path("folder1.folder2") - + # Ensure same path again second_id = tree_view.ensure_path("folder1.folder2") - + # Should return the same ID assert first_id == second_id 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: diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py index 842c718..2a9c719 100644 --- a/tests/core/test_commands.py +++ b/tests/core/test_commands.py @@ -33,9 +33,15 @@ class TestCommandDefault: assert command.description == 'Command description' assert command.execute() == "Hello World" - def test_command_are_registered(self): + def test_commands_are_registered(self): command = Command('test', 'Command description', None, callback) 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: @@ -74,7 +80,7 @@ class TestCommandBind: 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): data = Data("hello") @@ -92,7 +98,7 @@ class TestCommandBind: 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): command = Command('test', 'Command description', None, callback) @@ -120,6 +126,15 @@ class TestCommandBind: assert "hx_swap_oob" not in res[0].attrs assert res[1].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: @@ -137,7 +152,7 @@ class TestCommandExecute: def callback_with_param(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" def test_i_can_execute_a_command_with_open_parameter(self): @@ -188,9 +203,9 @@ class TestCommandExecute: class TestLambaCommand: 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" 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" diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py new file mode 100644 index 0000000..af2d6ef --- /dev/null +++ b/tests/core/test_utils.py @@ -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}" diff --git a/tests/test_integration.py b/tests/test_integration.py index f8e7900..beab801 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -34,13 +34,13 @@ def rt(user): class TestingCommand: 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.click() assert user.get_content() == "this is my new value" def test_error_is_raised_when_command_is_not_found(self, user): - command = Command('test', 'TestingCommand', None, new_value, "this is my new value") + command = Command('test', 'TestingCommand', None, new_value, args=["this is my new value"]) CommandsManager.reset() testable = TestableElement(user, mk.button('button', command)) @@ -50,7 +50,7 @@ class TestingCommand: assert "not found." in str(exc_info.value) 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('/') def get(): return mk.button('button', command)