Improved Command class management.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__)}"
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
58
tests/core/test_utils.py
Normal 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}"
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user