import argparse import inspect import logging import re import sys import app_skellington from ._bootstrap import _bootstrap_logger from . import app_container # If explicit fail is enabled, any command with at least one unknown # argument will be rejected entirely. If not enabled, unknown arguments # will be ignored. EXPLICIT_FAIL_ON_UNKNOWN_ARGS = True class CommandTree: """ Command-line interface to hold a menu of commands. You can register commands (functions or methods) in a CommandTree which will generate a corresponding argparse.ArgumentParser (and nested SubParsers) that map function/method arguments into argparse Parameters. Then, you can translate command-line arguments into invoking the function. Commands must be registered before being invoked. You create nested SubMenu(s). If function parameters have defaults, those will be available for override else they use the function defaults. Print helpful information: ./scriptname -h # View tier-0 help and usage doc ./scriptname [submenu] -h # View submenu help and usage doc ./scriptname [submenu] [command] -h # View command documentation and parameters argparse is finicky about argument placement: ./scriptname [application arguments] [submenu] [submenu arguments] [command] [command arguments] For example, ./scriptname --option="value" [submenu] [command] is different than ./scriptname [submenu] [command] --option="value" in that option is being applied to the application in the first example and applied to the refresh_datasets command (under the nhsn command group) in the second. In the same way the -h, --help options print different docs depending on where the help option was passed. """ def __init__(self): self.root_parser = argparse.ArgumentParser() self.submenu_param = None # submenu_param is the variable name # of the root submenu argument, i.e. the arg # in root_parser which selects the submenu. self.entries = {} # NOTE(MG) Implementation note: # CommandTree uses only one of these internal structures (i.e. mutually exclusive), # 'entries' is used when there is a submenu linked to multiple commands. # '_cmd_tree_is_single_command' and '_single_command' instead are used # when the CommandTree is linked to one and only one command. self._cmd_tree_is_single_command = False self._single_command = None def print_tree(self): import pprint pprint.pprint(self.entries) def add_argument(self, *args, **kwargs): """ Adds an argument to the root parser. """ _bootstrap_logger.info('adding argument to root parser: %s and %s', args, kwargs) self.root_parser.add_argument(*args, **kwargs) def init_submenu(self, param_name, is_required=False): """ Creates a root-level submenu with no entries. SubMenu node is returned which can have submenus and commands attached to it. """ # Creates an argument as a slot in the underlying argparse. subparsers = self.root_parser.add_subparsers( dest = param_name, metavar = param_name, required = is_required ) submenu = SubMenu(self, subparsers, param_name) submenu.submenu_path = '' submenu.var_name = param_name _bootstrap_logger.info('Initialized root-level submenu: Parameter = \'%s\'', param_name) self.entries[param_name] = submenu self.submenu_param = param_name return submenu def register_command( self, func, cmd_name=None, func_signature=None, docstring=None ): """ When no submenu functionality is desired, this links a single command into underlying argparse options. """ # begin copy-paste from SubMenu.register_command if inspect.isfunction(func): # print('func is function') pass elif inspect.ismethod(func): pass # print('func is method') else: raise Exception('bad value passed in for function') if not cmd_name: # safe try/except cmd_name = func.__name__ if func_signature is None: func_signature = inspect.signature(func) if docstring is None: docstring = func.__doc__ sig = func_signature params = sig.parameters # help is displayed next to the command in the submenu enumeration or # list of commands: help_text = HelpGenerator.generate_help_from_sig(docstring) # description is displayed when querying help for the specific command: description_text = HelpGenerator.generate_description_from_sig(docstring) # end copy-paste from SubMenu.register_command # begin copy-paste then editted from SubMenu.register_command # For each paramter in the function create an argparse argument in # the child ArgumentParser created for this menu entry: for key in params: if key == 'self': continue param = params[key] if '=' in str(param): if param.default is None: helptext = 'default provided' else: helptext = "default = '{}'".format(param.default) self.root_parser.add_argument( key, help=helptext, nargs='?', default=param.default) else: helptext = 'required' self.root_parser.add_argument( key, help=helptext) # # Wrapper function that instantiates an object and runs a method # # on-demand. The object is created, injected with necessary # # dependencies or services, and the method is invoked. # def func(*args, **kwargs): # obj = constructor() # return cls_method(obj, *args, **kwargs) # Build the CommandEntry structure cmd = CommandEntry() cmd.argparse_node = self.root_parser cmd.cmd_name = cmd_name cmd.func_signature = sig # cmd.func_ref = None cmd.callback = func registered_name = cmd_name _bootstrap_logger.info('registered command: %s', registered_name) # end copy-paste then editted from SubMenu.register_command self._cmd_tree_is_single_command = True self._single_command = cmd self._entries = None # def _validate(self): # pass # # TODO(MG): # # subparser can not be empty, needs to have parsers attached def parse(self, args=None): if args is None: args = sys.argv[1:] try: # on error, prints some argparse error messages: pargs, unk = self.root_parser.parse_known_args(args) # if len(unk) > 0: # _bootstrap_logger.error( # 'failed to interpret argument(s) or command-line switch from shell: %s', # unk) # if EXPLICIT_FAIL_ON_UNKNOWN_ARGS: # _bootstrap_logger.warn( # 'failed to parse arguments: explicitly failing to be safe') # return False, False if hasattr(pargs, 'usage'): pass # print('found usage in app_skellington') return pargs, unk, True # Note: SystemExit is raised when '-h' argument is supplied. except SystemExit as ex: return None, None, False def run_command(self, args=None): args, unk, success = self.parse(args) if not success: _bootstrap_logger.info('SystemExit: Perhaps user invoked --help') return if args is False and unk is False: _bootstrap_logger.error('failed parsing args') return False _bootstrap_logger.info('received args from shell: %s', args) args = vars(args) cmd = self._lookup_command(args) if cmd is None: print('cmd is None') _bootstrap_logger.error('failed to find command') return False return self._invoke_command(cmd, args) def _lookup_command(self, args): keys = list(args.keys()) # In the case there is at-most one command registered in # the CommandTree with no SubMenu (submenu will be disabled # in this case): if self._cmd_tree_is_single_command: assert self._cmd_tree_is_single_command is True, 'corrupt data structure in CommandMenu' assert self._entries is None, 'corrupt data structure in CommandMenu' assert isinstance(self._single_command, CommandEntry), 'corrupt data structure in CommandMenu' return self._single_command # There is at least one submenu we need to go down: else: assert self._single_command is None, 'corrupt data structure in CommandMenu' assert self._cmd_tree_is_single_command == False, 'corrupt data structure in CommandMenu' # Key or variable name used by argparse to store the submenu options argparse_param = self.submenu_param # e.g.: submenu_root submenu = self.entries[argparse_param] while True: if argparse_param not in keys: print('root menu parameter not found in args:', argparse_param) input('') val = args.get(argparse_param) _bootstrap_logger.debug('argparse command is \'{}\' = {}'.format(argparse_param, val)) lookup = submenu.entries.get(val) _bootstrap_logger.debug('lookup, entries[{}] = {}'.format(val, lookup)) # print(submenu.entries) # pop value del args[argparse_param] if isinstance(lookup, SubMenu): submenu = lookup argparse_param = submenu.var_name elif isinstance(lookup, CommandEntry): return lookup # return self._invoke_command(lookup, args) else: raise app_container.NoCommandSpecified('No command specified.') def _invoke_command(self, cmd, args): func = cmd.callback sig = cmd.func_signature params = sig.parameters params = [params[paramname] for paramname in params] func_args = [] for param in params: if param.name in args: func_args.append(args[param.name]) _bootstrap_logger.info('function: %s', func) _bootstrap_logger.info('function args: %s', func_args) return func(*func_args) def _get_subparser(self): return self.root_parser._subparsers._actions[1] class SubMenu: def __init__(self, parent, subparsers_obj, name): self.parent = parent # Reference to root CommandTree self.subparsers_obj = subparsers_obj self.name = name self.submenu_path = None self.entries = {} def register_command( self, func, cmd_name=None, func_signature=None, docstring=None ): """ Registers a command as an entry in this submenu. Provided function is converted into argparse arguments and made available to the user. Arguments --------- func: Callback function which will be mapped to the submenu entry. cmd_name (optional): User-facing entry name. By default will be the function name. The user will be able to use [cmd_name] [arg, ...] to invoke the callback function. func_signature: optionally, you can pass in the inspect.signature(). If None, will inspect the incoming func. Note on internals: This is used to pass the function signature of the command function while having the callback point to a function partial which executes some other code. This hook is used to inject dependencies and then execute the command function. """ if inspect.isfunction(func): # print('func is function') pass elif inspect.ismethod(func): pass # print('func is method') else: raise Exception('bad value passed in for function') if not cmd_name: # safe try/except cmd_name = func.__name__ if func_signature is None: func_signature = inspect.signature(func) if docstring is None: docstring = func.__doc__ sig = func_signature params = sig.parameters # help is displayed next to the command in the submenu enumeration or # list of commands: help_text = HelpGenerator.generate_help_from_sig(docstring) # description is displayed when querying help for the specific command: description_text = HelpGenerator.generate_description_from_sig(docstring) # Entry in local argparse._SubParsersAction # type = ArgumentParser child_node = self.subparsers_obj.add_parser( cmd_name, # Note: cmd_name here will be the VALUE # passed into the argparse arg VARIABLE NAME # created when the SubMenu/argparse.addZ_subparsers() # was created. help=help_text, description=description_text ) # For each paramter in the function create an argparse argument in # the child ArgumentParser created for this menu entry: for key in params: if key == 'self': continue param = params[key] if '=' in str(param): if param.default is None: helptext = 'default provided' else: helptext = "default = '{}'".format(param.default) child_node.add_argument( key, help=helptext, nargs='?', default=param.default) else: helptext = 'required' child_node.add_argument( key, help=helptext) # # Wrapper function that instantiates an object and runs a method # # on-demand. The object is created, injected with necessary # # dependencies or services, and the method is invoked. # def func(*args, **kwargs): # obj = constructor() # return cls_method(obj, *args, **kwargs) # Build the CommandEntry structure cmd = CommandEntry() cmd.argparse_node = child_node cmd.cmd_name = cmd_name cmd.func_signature = sig # cmd.func_ref = None cmd.callback = func registered_name = '{}.{}'.format( self.submenu_path, cmd_name) _bootstrap_logger.info('registered command: %s', registered_name) self.entries[cmd_name] = cmd def create_submenu( self, var_name, cmd_entry_name=None, is_required=False ): """ Creates a child-submenu. Arguments --------- var_name: A code-facing argparse parameter used to store the value/entry chosen by the user. cmd_entry_name: A user-facing name used to select created submenu. If not provided, the user-facing command name defaults to the same name as the code-facing argparse parameter is_required: Switches if a value must be selected in the created submenu. If not, it's an optional positional argument. """ if cmd_entry_name is None: cmd_entry_name = var_name # Create an entry in self's submenu: # type = ArgumentParser entry_node = self.subparsers_obj.add_parser( cmd_entry_name, help='sub-submenu help', description='sub-sub description') # Turn entry into a submenu of it's own: # type = _SubParsersAction subp_node = entry_node.add_subparsers( dest = var_name, metavar = var_name, required = is_required) submenu = SubMenu( self.parent, subp_node, cmd_entry_name) submenu.var_name = var_name submenu.submenu_path = '{}.{}'.format(self.submenu_path, cmd_entry_name) submenu_name = submenu.submenu_path _bootstrap_logger.info('registered submenu: %s', submenu_name) self.entries[cmd_entry_name] = submenu return submenu def __repr__(self): return 'SubMenu({})<{}>'.format( self.name, ','.join(['cmds']) ) class CommandEntry: """ Structure for a command-entry in the CLI. Stores the command-subcommand names, the function signature which contains the original parameters of the function-to-be-invoked, a reference to the original function, and a callback function wrapper which, by convention, instantiates the necessary objects (injecting dependencies, etc.) and executes the original function. The CLI module has functionality to translate the original function arguments into argparse options (creating the documentation also). Similary, it can convert from argparse options into a function call. """ def __init__(self): self.argparse_node = None self.cmd_name = None # Don't think we need. And needs to be changed # from SubMenu self.menu_path = None self.func_signature = None self.func_ref = None self.callback = None def __repr__(self): return 'CommandEntry<{}>'.format(self.cmd_name) class HelpGenerator: def __init__(self): pass @staticmethod def generate_help_from_sig(doctext): """ The 'help' text is displayed next to the command when enumerating the submenu commands. """ if doctext == None: return doctext regex = '(.*?)[.?!]' match = re.match(regex, doctext, re.MULTILINE | re.DOTALL) if match: return match.group(1) + '.' return doctext @staticmethod def generate_description_from_sig(doctext): """ The 'description' paragraph is provided when the user requests help on a specific command. """ if doctext == None: return doctext regex = '(.*?)[.?!]' match = re.match(regex, doctext, re.MULTILINE | re.DOTALL) if match: return match.group(1) + '.' return doctext