wikicrawl/lib/app_skellington/cli.py

541 lines
19 KiB
Python

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('<broken>')
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