import html
import inspect
import json
import logging
import uuid
from dataclasses import dataclass
from typing import Optional, Literal
from myutils.observable import NotObservableError, ObservableResultCollector
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.utils import flatten
logger = logging.getLogger("Commands")
AUTO_SWAP_OOB = "__auto_swap_oob__"
@dataclass
class BoundCommand:
"""
Represents a command bound to another command.
Attributes:
command: The command to execute
when: When to execute the bound command ("before" or "after" the main command)
"""
command: 'Command'
when: Literal["before", "after"] = "after"
class Command:
"""
Represents the base command class for defining executable actions.
This class serves as a foundation for commands that can be registered,
executed, and utilized within a system. Each command has a unique identifier,
a name, and a description. Commands should override the `execute` method
to provide specific functionality.
:ivar id: A unique identifier for the command.
:type id: uuid.UUID
:ivar name: The name of the command.
:type name: str
:ivar description: A brief description of the command's functionality.
:type description: str
"""
@staticmethod
def process_key(key, name, owner, args, kwargs):
def _compute_from_args():
res = []
for arg in args:
if hasattr(arg, "get_full_id"):
res.append(arg.get_full_id())
else:
res.append(str(arg))
return "-".join(res)
# special management when kwargs are provided
# In this situation,
# either there is no parameter (so one single instance of the command is enough)
# or the parameter is a kwargs (so the parameters are provided when the command is called)
if key is None:
key_parts = []
if owner is not None:
key_parts.append(f"{owner.get_full_id()}")
key_parts.append(name)
if args:
key_parts.append(_compute_from_args())
key = "-".join(key_parts)
else:
key = key.replace("#{args}", _compute_from_args())
if owner is not None:
key = key.replace("#{id}", owner.get_full_id())
key = key.replace("#{id-name-args}", f"{owner.get_full_id()}-{name}-{_compute_from_args()}")
return key
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 = {AUTO_SWAP_OOB: True}
self._bindings = []
self._ft = None
self._callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
self._key = self.process_key(key, self.name, self.owner, self.default_args, self.default_kwargs)
# register the command
if auto_register:
if self._key is not None:
if self._key in CommandsManager.commands_by_key:
#logger.debug(f"Command {self.name} with key={self._key} will not be registered.")
self.id = CommandsManager.commands_by_key[self._key].id
else:
#logger.debug(f"Command {self.name} with key={self._key} will be registered.")
CommandsManager.register(self)
else:
logger.warning(f"Command {self.name} has no key, it will not be registered.")
def get_key(self):
return self._key
def get_htmx_params(self, escaped=False, values_encode=None):
res = {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-swap": "outerHTML",
"hx-vals": {"c_id": f"{self.id}"},
}
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
if escaped:
res["hx-vals"] = html.escape(json.dumps(res["hx-vals"]))
if values_encode == "json":
res["hx-vals"] = json.dumps(res["hx-vals"])
return res
def execute(self, client_response: dict = None):
logger.debug(f"Executing command {self.name} with arguments {client_response=}")
with ObservableResultCollector(self._bindings) as collector:
# Execute "before" bound commands
ret_from_before_commands = []
if self.owner:
before_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "before"]
for bound_cmd in before_commands:
logger.debug(f" will execute bound command {bound_cmd.command.name} BEFORE...")
r = bound_cmd.command.execute(client_response)
ret_from_before_commands.append(r)
# Execute main callback
kwargs = self._create_kwargs(self.default_kwargs,
client_response,
{"client_response": client_response or {}})
ret = self.callback(*self.default_args, **kwargs)
# Execute "after" bound commands
ret_from_after_commands = []
if self.owner:
after_commands = [bc for bc in self.owner.get_bound_commands(self.name) if bc.when == "after"]
for bound_cmd in after_commands:
logger.debug(f" will execute bound command {bound_cmd.command.name} AFTER...")
r = bound_cmd.command.execute(client_response)
ret_from_after_commands.append(r)
all_ret = flatten(ret, ret_from_before_commands, ret_from_after_commands, collector.results)
# Set the hx-swap-oob attribute on all elements returned by the callback
if self._htmx_extra[AUTO_SWAP_OOB]:
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, auto_swap_oob=True):
self._htmx_extra[AUTO_SWAP_OOB] = auto_swap_oob
# Note that the default value is the same than in get_htmx_params()
if target is None:
self._htmx_extra["hx-swap"] = "none"
elif target != "this":
self._htmx_extra["hx-target"] = target
if swap is None:
self._htmx_extra["hx-swap"] = "none"
elif swap != "outerHTML":
self._htmx_extra["hx-swap"] = swap
if trigger is not None:
self._htmx_extra["hx-trigger"] = trigger
return self
def bind_ft(self, ft):
"""
Update the FT with the command's HTMX parameters.
:param ft:
:return:
"""
self._ft = ft
htmx = self.get_htmx_params()
ft.attrs |= htmx
return ft
def bind(self, data):
"""
Attach a binding to the command.
When done, if a binding is triggered during the execution of the command,
the results of the binding will be passed to the command's execute() method.
:param data:
:return:
"""
if not hasattr(data, '_listeners'):
raise NotObservableError(
f"Object must be made observable with make_observable() before binding"
)
self._bindings.append(data)
# by default, remove the swap on the attached element when binding is used
self._htmx_extra["hx-swap"] = "none"
return self
@property
def url(self):
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
def ajax_htmx_options(self):
res = {
"url": self.url,
"target": self._htmx_extra.get("hx-target", "this"),
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
"values": self.default_kwargs
}
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
return res
def get_ft(self):
return self._ft
def _cast_parameter(self, key, value):
if key in self._callback_parameters:
param = self._callback_parameters[key]
if param.annotation == bool:
return value == "true"
elif param.annotation == int:
return int(value)
elif param.annotation == float:
return float(value)
elif param.annotation == list:
return value.split(",")
elif param.annotation == dict:
return json.loads(value)
return value
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 __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)
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 = {} # by_id
commands_by_key = {}
@staticmethod
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[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():
CommandsManager.commands.clear()
CommandsManager.commands_by_key.clear()