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`.
**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

View File

@@ -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

View File

@@ -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())

View File

@@ -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,8 +51,8 @@ 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",
@@ -60,13 +60,20 @@ class Commands(BaseCommands):
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):

View File

@@ -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

View File

@@ -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"

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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"),

View File

@@ -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

View File

@@ -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,50 +182,24 @@ 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):
@@ -207,21 +207,34 @@ class LambdaCommand(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()

View File

@@ -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__)}"

View File

@@ -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
})

View File

@@ -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):
"""

View File

@@ -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:

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 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)

View File

@@ -570,6 +570,19 @@ class TestTreeviewBehaviour:
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:
"""Tests for TreeView HTML rendering."""

View File

@@ -33,10 +33,16 @@ 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)
@@ -121,6 +127,15 @@ class TestCommandBind:
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"

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:
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)