From c8abba316546d88064974bce0364ae9fed5e43e7 Mon Sep 17 00:00:00 2001 From: TheOnePath Date: Wed, 2 Aug 2023 14:20:20 +0100 Subject: [PATCH] Updated commands.py - Added global variable for logging. - Removed `class@RegisterCommand` metaclass. - Replaced with decorator function. - Added `class@CommandHelpStruct`. - Basic struct to define rigid structure of the help message of a command. Uses `__slots__` over instance dictionary. - Changed type annotations for removal of metaclass. - Added logging. - Created decorator function `register_command()`. - Used to register a new command class (replaces metaclass) - Guarantees a name and doc dunder attribute is defined. - Registry now ensures that a command isn't already defined with the same registry name. - Must be invoked with parens --- soypak/cli/command.py | 87 +++++++++++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 23 deletions(-) diff --git a/soypak/cli/command.py b/soypak/cli/command.py index c2a4de7..f8068fa 100644 --- a/soypak/cli/command.py +++ b/soypak/cli/command.py @@ -1,36 +1,26 @@ +from collections.abc import Collection import sys import clog import soypak.cli.parser as parser -class RegisterCommand(type): - """Register a new command to soypak and add help information to the parser. +# partial load the logger +log = lambda: clog.Logger.get("runtime_logger") - This class is to be used as metaclass for commands. - """ - def __init__(cls, name, bases, _dict): - super().__init__(name, bases, _dict) - # get the name of the command - name = getattr(cls, "name", None) - if name is None: - raise Exception("Command has no registered name.") - # check if the command already exists (means we have duplicate registered name) - if name in Command.registered_cmds: - raise Exception("Duplicate command, already exists.") - - # add the command to the registered list, with `name` and class object - Command.registered_cmds[name] = cls - if not cls.__doc__: - raise Exception("Command does not provide a __doc__ attribute for usage.") - # add the command to the argparse._action_groups list (will appear as a positional in help message) - parser.SoypakParser.add_action_command(cmd=name, help=cls.__doc__.split('\n')[0]) +class CommandHelpStruct: + __slots__ = ('synopsis', 'usage', 'summary') + def __init__(self, *, synopsis: str, usage: str, summary: Collection = '') -> None: + self.synopsis = synopsis + self.usage = usage + self.summary: tuple = tuple(summary) if summary is tuple else (summary,) class Command: """Class for creating a new type of command. Keep record of the registered commands for soypak.""" # store all registered commands. Those are commands which soypak implements - registered_cmds: dict[str, RegisterCommand] = {} + registered_cmds: dict[str, type] = {} + registry_names: list[str] = [] def __init__(self) -> None: # get/create the parser instance @@ -38,14 +28,14 @@ class Command: @staticmethod - def get_command(name, *, error: bool = False, args: list[str] | None = None) -> RegisterCommand | None: + def get_command(name, *, error: bool = False, args: list[str] | None = None) -> type | None: """Fetch and initialise a registered command, or return `None` is command cannot be found.""" if name in Command.registered_cmds: return Command.registered_cmds[name](data=parser.SoypakParser().namespace, args=args) if error: # Just get the logger for this one statement (:@Note: if more logging occurs, global variable for logger) - clog.Logger.get("runtime_logger").error("Unrecognised command: %s" % name).withConsole() + log().error("Unrecognised command: %s" % name).withConsole() Command.die() return None @@ -60,3 +50,54 @@ class Command: def run(self): """Virtual method which must be overridden by a new implementation in the child class.""" raise RuntimeError("Virtual method must be overridden by child class.") + + + +# This template is inspired by how Cpython does `@dataclass`s +def register_command(_cls=None, *, name: str, alias=None, help: CommandHelpStruct): + """ Register a command to soypak. Used as a decorator `@register_command` on a command class. """ + def init(cls): + # check if the command has already been given a registry name (only one should exist) + if hasattr(cls, 'name'): + msg = f"Command {cls.__name__!r} has already been registered with the given name: {getattr(cls, 'name')!r}" + log().error(msg) + raise Exception(msg) + + # check if that registry name is already taken + if name in Command.registry_names: + msg = f"Command {cls.__name__!r} cannot use registry name {name!r} because it is in use." + log().error(msg) + raise Exception(msg) + + # set the name of the command as given + setattr(cls, 'name', name) + + # This is deliberate to make aware that `alias` isn't being used so pointless to pass now. + if alias is not None: + log().warn(f"'alias' argument was passed as something other than None. This is intended for future use.") + raise Warning("The 'alias' argument is not intended for use. Please exclude for now.") + + # ensure that the `help` given is of the correct type + if not isinstance(help, CommandHelpStruct): + msg = f"The argument 'help' must be of type CommandHelpStruct, not {type(help)!r}" + log().error(msg) + raise TypeError(msg) + + # construct the help message + doc = "{0}\n\nUsage: {1}\n\n{2}".format(help.synopsis, help.usage, "".join(help.summary)) + setattr(cls, '__doc__', doc) + + # add the command to the registered list, with `name` and class object + Command.registered_cmds[name] = cls + Command.registry_names.insert(-1, name) + # add the command to the argparse._action_groups list (will appear as a positional in help message) + parser.SoypakParser.add_action_command(cmd=name, help=help.synopsis) + + return cls + + # if somehow this is ever not None, raise RuntimeError + if _cls is not None: + raise RuntimeError("Decorator must be called with at least 1 position argument.") + + + return init