Merge branch 'feature/1812-traverse-actions-dict' into feature/1947-implement-missing-node-actions

This commit is contained in:
Czar.Echavez
2023-10-10 08:58:58 +01:00
58 changed files with 2111 additions and 950 deletions

View File

@@ -11,9 +11,9 @@ from primaite import getLogger
_LOGGER = getLogger(__name__)
class ActionPermissionValidator(BaseModel):
class RequestPermissionValidator(BaseModel):
"""
Base class for action validators.
Base class for request validators.
The permissions manager is designed to be generic. So, although in the first instance the permissions
are evaluated purely on membership to AccountGroup, this class can support validating permissions based on any
@@ -22,130 +22,127 @@ class ActionPermissionValidator(BaseModel):
@abstractmethod
def __call__(self, request: List[str], context: Dict) -> bool:
"""Use the request and context paramters to decide whether the action should be permitted."""
"""Use the request and context paramters to decide whether the request should be permitted."""
pass
class AllowAllValidator(ActionPermissionValidator):
"""Always allows the action."""
class AllowAllValidator(RequestPermissionValidator):
"""Always allows the request."""
def __call__(self, request: List[str], context: Dict) -> bool:
"""Always allow the action."""
"""Always allow the request."""
return True
class Action(BaseModel):
class RequestType(BaseModel):
"""
This object stores data related to a single action.
This object stores data related to a single request type.
This includes the callable that can execute the action request, and the validator that will decide whether
the action can be performed or not.
This includes the callable that can execute the request, and the validator that will decide whether
the request can be performed or not.
"""
func: Callable[[List[str], Dict], None]
"""
``func`` is a function that accepts a request and a context dict. Typically this would be a lambda function
that invokes a class method of your SimComponent. For example if the component is a node and the action is for
that invokes a class method of your SimComponent. For example if the component is a node and the request type is for
turning it off, then the SimComponent should have a turn_off(self) method that does not need to accept any args.
Then, this Action will be given something like ``func = lambda request, context: self.turn_off()``.
Then, this request will be given something like ``func = lambda request, context: self.turn_off()``.
``func`` can also be another action manager, since ActionManager is a callable with a signature that matches what is
``func`` can also be another request manager, since RequestManager is a callable with a signature that matches what is
expected by ``func``.
"""
validator: ActionPermissionValidator = AllowAllValidator()
validator: RequestPermissionValidator = AllowAllValidator()
"""
``validator`` is an instance of `ActionPermissionValidator`. This is essentially a callable that
``validator`` is an instance of ``RequestPermissionValidator``. This is essentially a callable that
accepts `request` and `context` and returns a boolean to represent whether the permission is granted to perform
the action. The default validator will allow
the request. The default validator will allow
"""
# TODO: maybe this can be renamed to something like action selector?
# Because there are two ways it's used, to select from a list of action verbs, or to select a child object to which to
# forward the request.
class ActionManager(BaseModel):
class RequestManager(BaseModel):
"""
ActionManager is used by `SimComponent` instances to keep track of actions.
RequestManager is used by `SimComponent` instances to keep track of requests.
Its main purpose is to be a lookup from action name to action function and corresponding validation function. This
class is responsible for providing a consistent API for processing actions as well as helpful error messages.
Its main purpose is to be a lookup from request name to request function and corresponding validation function. This
class is responsible for providing a consistent API for processing requests as well as helpful error messages.
"""
actions: Dict[str, Action] = {}
"""maps action verb to an action object."""
request_types: Dict[str, RequestType] = {}
"""maps request name to an RequestType object."""
def __call__(self, request: Callable[[List[str], Dict], None], context: Dict) -> None:
"""
Process an action request.
Process an request request.
:param request: A list of strings which specify what action to take. The first string must be one of the allowed
actions, i.e. it must be a key of self.actions. The subsequent strings in the list are passed as parameters
to the action function.
:param request: A list of strings describing the request. The first string must be one of the allowed
request names, i.e. it must be a key of self.request_types. The subsequent strings in the list are passed as
parameters to the request function.
:type request: List[str]
:param context: Dictionary of additional information necessary to process or validate the request.
:type context: Dict
:raises RuntimeError: If the request parameter does not have a valid action identifier as the first item.
:raises RuntimeError: If the request parameter does not have a valid request name as the first item.
"""
action_key = request[0]
request_key = request[0]
if action_key not in self.actions:
if request_key not in self.request_types:
msg = (
f"Action request {request} could not be processed because {action_key} is not a valid action",
"within this ActionManager",
f"Request {request} could not be processed because {request_key} is not a valid request name",
"within this RequestManager",
)
_LOGGER.error(msg)
raise RuntimeError(msg)
action = self.actions[action_key]
action_options = request[1:]
request_type = self.request_types[request_key]
request_options = request[1:]
if not action.validator(action_options, context):
_LOGGER.debug(f"Action request {request} was denied due to insufficient permissions")
if not request_type.validator(request_options, context):
_LOGGER.debug(f"Request {request} was denied due to insufficient permissions")
return
action.func(action_options, context)
request_type.func(request_options, context)
def add_action(self, name: str, action: Action) -> None:
def add_request(self, name: str, request_type: RequestType) -> None:
"""
Add an action to this action manager.
Add a request type to this request manager.
:param name: The string associated to this action.
:param name: The string associated to this request.
:type name: str
:param action: Action object.
:type action: Action
:param request_type: Request type object which contains information about how to resolve request.
:type request_type: RequestType
"""
if name in self.actions:
msg = f"Attempted to register an action but the action name {name} is already taken."
if name in self.request_types:
msg = f"Attempted to register a request but the request name {name} is already taken."
_LOGGER.error(msg)
raise RuntimeError(msg)
self.actions[name] = action
self.request_types[name] = request_type
def remove_action(self, name: str) -> None:
def remove_request(self, name: str) -> None:
"""
Remove an action from this manager.
Remove a request from this manager.
:param name: name identifier of the action
:param name: name identifier of the request
:type name: str
"""
if name not in self.actions:
msg = f"Attempted to remove action {name} from action manager, but it was not registered."
if name not in self.request_types:
msg = f"Attempted to remove request {name} from request manager, but it was not registered."
_LOGGER.error(msg)
raise RuntimeError(msg)
self.actions.pop(name)
self.request_types.pop(name)
def get_action_tree(self) -> List[List[str]]:
"""Recursively generate action tree for this component."""
actions = []
for act_name, act in self.actions.items():
if isinstance(act.func, ActionManager):
sub_actions = act.func.get_action_tree()
sub_actions = [[act_name] + a for a in sub_actions]
actions.extend(sub_actions)
def get_request_types_recursively(self) -> List[List[str]]:
"""Recursively generate request tree for this component."""
requests = []
for req_name, req in self.request_types.items():
if isinstance(req.func, RequestManager):
sub_requests = req.func.get_request_types_recursively()
sub_requests = [[req_name] + a for a in sub_requests]
requests.extend(sub_requests)
else:
actions.append([act_name])
return actions
requests.append([req_name])
return requests
class SimComponent(BaseModel):
@@ -161,30 +158,30 @@ class SimComponent(BaseModel):
if not kwargs.get("uuid"):
kwargs["uuid"] = str(uuid4())
super().__init__(**kwargs)
self._action_manager: ActionManager = self._init_action_manager()
self._request_manager: RequestManager = self._init_request_manager()
self._parent: Optional["SimComponent"] = None
def _init_action_manager(self) -> ActionManager:
def _init_request_manager(self) -> RequestManager:
"""
Initialise the action manager for this component.
Initialise the request manager for this component.
When using a hierarchy of components, the child classes should call the parent class's _init_action_manager and
add additional actions on top of the existing generic ones.
When using a hierarchy of components, the child classes should call the parent class's _init_request_manager and
add additional requests on top of the existing generic ones.
Example usage for inherited classes:
..code::python
class WebBrowser(Application):
def _init_action_manager(self) -> ActionManager:
am = super()._init_action_manager() # all actions generic to any Application get initialised
am.add_action(...) # initialise any actions specific to the web browser
def _init_request_manager(self) -> RequestManager:
am = super()._init_request_manager() # all requests generic to any Application get initialised
am.add_request(...) # initialise any requests specific to the web browser
return am
:return: Actiona manager object belonging to this sim component.
:rtype: ActionManager
:return: Request manager object belonging to this sim component.
:rtype: RequestManager
"""
return ActionManager()
return RequestManager()
@abstractmethod
def describe_state(self) -> Dict:
@@ -204,27 +201,27 @@ class SimComponent(BaseModel):
"""Update the visible statuses of the SimComponent."""
pass
def apply_action(self, action: List[str], context: Dict = {}) -> None:
def apply_request(self, request: List[str], context: Dict = {}) -> None:
"""
Apply an action to a simulation component. Action data is passed in as a 'namespaced' list of strings.
Apply a request to a simulation component. Request data is passed in as a 'namespaced' list of strings.
If the list only has one element, the action is intended to be applied directly to this object. If the list has
multiple entries, the action is passed to the child of this object specified by the first one or two entries.
If the list only has one element, the request is intended to be applied directly to this object. If the list has
multiple entries, the request is passed to the child of this object specified by the first one or two entries.
This is essentially a namespace.
For example, ["turn_on",] is meant to apply an action of 'turn on' to this component.
For example, ["turn_on",] is meant to apply a request of 'turn on' to this component.
However, ["services", "email_client", "turn_on"] is meant to 'turn on' this component's email client service.
:param action: List describing the action to apply to this object.
:type action: List[str]
:param request: List describing the request to apply to this object.
:type request: List[str]
:param: context: Dict containing context for actions
:param: context: Dict containing context for requests
:type context: Dict
"""
if self._action_manager is None:
if self._request_manager is None:
return
self._action_manager(action, context)
self._request_manager(request, context)
def apply_timestep(self, timestep: int) -> None:
"""