# =====IMPORTS===== # Future imports from __future__ import annotations # Built-in imports import re import asyncio import shlex # Typing imports from typing import TYPE_CHECKING if TYPE_CHECKING: # Built-in imports from typing import Union, List, Tuple class Simple: """ Acts as a base class for all other types; behaves like the given function """ def __init__(self, func: callable): """ Args: func: function to mimic """ self.func = func def __call__(self, *args, **kwargs): """ All this call does is call the wrapped function. Because we overwrote __get__, we can pass self to the function, making it behave as a class method of the instance calling it. """ return self.func.__call__(self._obj, *args, **kwargs) def __get__(self, instance, owner) -> Simple: """ We use __get__ to get the class calling the function. This allows us to pass 'self' to the wrapped function, effectively making this class fully behave as a class method. Args: instance: instance calling the function owner: type of the function """ self._cls = owner self._obj = instance return self @property def client(self): """ Returns the Frank client instance. """ return self._obj._client class SimpleCommand(Simple): """ Base class for the various command types. """ def __init__(self, func: callable, cmd: str, help_str: str = None): """ Args: func: function to wrap cmd: keyword used to call this function help_str: short description of the command """ super().__init__(func) self.cmd = cmd self.help_str = help_str def match(self, message: str) -> Tuple[bool, List[str]]: """ Returns wether the command matches the given message. If the arguments can't be parsed (e.g. unmatched quotes), it will return False as well. Args: message: message to check """ return self._match_full(message.split(" ")) def _match_full(self, parts: List[str]) -> Tuple[bool, List[str]]: """ Returns wether the message matches the full command. Args: parts: parts of the message """ # Can't match without 3 or more parts if len(parts) < 3: return False, None # Return False if it doesn't match if not all( ( parts[0] == self.client.PREFIX, parts[1] in self._obj.PREFIX, parts[2] == self.cmd, ) ): return False, None # Parse the output, and return True with the parsed items if it works, # otherwise return False try: parsed = shlex.split(" ".join(parts[3:])) return True, parsed except ValueError: return False, None class RegularCommand(SimpleCommand): """ Defines a regular command; Handles command aliases as well. """ def __init__( self, func: callable, cmd: str, help_str: str = None, alias: Union[str, List[str]] = None, requires_prefix: bool = False, ): super().__init__(self, func, cmd, help_str) self.alias = alias # This only matters for aliases self.requires_prefix = requires_prefix # TODO: make this return the right value def match(self, message: str) -> Tuple[bool, List[str]]: """ Returns wether the message matches the current command. """ parts = message.split(" ") # If the alias doesn't match, return the full match, otherwise return # alias matches, parts = self._match_alias(parts) if matches: return matches, parts return self._match_full(parts) # TODO: make this return the right value def _match_alias(self, parts: List[str]) -> Tuple[bool, List[str]]: """ Returns wether the message matches an alias. """ # Return False if there's only one part but a prefix is required if self.requires_prefix and len(parts) == 1: return False # Match with prefix if self.requires_prefix: return parts[0] == self.client.PREFIX and parts[1] in self.alias # Match without prefix return parts[0] in self.alias class RegexCommand(SimpleCommand): """ A subclass of Command that can use a regex pattern instead of a fixed prefix. """ def match(self, message: str) -> Tuple[str, List[str]]: """ Returns wether the regex pattern matches the given prefix. Args: prefix: string to match pattern against; Pattern must match entire prefix """ parts = message.split(" ") matches = bool(re.fullmatch(self.cmd, parts[0])) # If it doesn't match, just return False, don't parse the rest if not matches: return False, None try: parsed = shlex.split(" ".join(parts)) return True, parsed except ValueError: return False, None class Daemon(Simple): """ Represents a daemon a.k.a. a background process. """ def __init__(self, func: callable, interval: Union[int, float] = 0): """ Args func: function to wrap interval: time between calls of the function; if <= 0, the function is assumed to contain its own infinite loop, allowing for fine-grained control of the daemon, if desired """ super().__init__(func) self.interval = interval # If an interval > 0 is given, it wraps the function inside an infinite # loop with the desired delay if interval > 0: async def loop(self, *args, **kwargs): while True: # TODO: does this make func and sleep run at the same time? await func(self, *args, **kwargs) await asyncio.sleep(interval) self.func = loop class Default(Simple): """ Represents a default command a.k.a. when the module is called without a command. """ def __init__(self, func: callable, help_str: str = None): """ Args: func: function to wrap help_str: short description of the default command """ super().__init__(func) self.help_str = help_str