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

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

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