Rename command (mixed with method "command")
[kamaki] / kamaki / cli / __init__.py
index cd63a97..910e493 100644 (file)
@@ -39,284 +39,246 @@ import gevent.monkey
 #Monkey-patch everything for gevent early on
 gevent.monkey.patch_all()
 
-import inspect
 import logging
-import sys
 
-from argparse import ArgumentParser
+from inspect import getargspec
+from argparse import ArgumentParser, ArgumentError
 from base64 import b64encode
 from os.path import abspath, basename, exists
-from sys import exit, stdout, stderr
+from sys import exit, stdout, stderr, argv
 
 try:
     from collections import OrderedDict
 except ImportError:
     from ordereddict import OrderedDict
 
-from colors import magenta, red, yellow, bold
-
-from kamaki import clients
-from .config import Config
-
-_commands = OrderedDict()
-
-GROUPS = {}
-CLI_LOCATIONS = ['kamaki.cli.commands', 'kamaki.commands', 'kamaki.cli', 'kamaki', '']
-
-class CLIError(Exception):
-    def __init__(self, message, status=0, details='', importance=0):
-        """importance is set by the raiser
-        0 is the lowest possible importance
-        Suggested values: 0, 1, 2, 3
-        """
-        super(CLIError, self).__init__(message, status, details)
-        self.message = message
-        self.status = status
-        self.details = details
-        self.importance = importance
-
-    def __unicode__(self):
-        return unicode(self.message)
-
-def command(group=None, name=None, syntax=None):
-    """Class decorator that registers a class as a CLI command."""
+#from kamaki import clients
+from .errors import CLIError, CLISyntaxError, CLICmdIncompleteError, CLICmdSpecError
+from .config import Config #TO BE REMOVED
+from .utils import bold, magenta, red, yellow, print_list, print_dict
+from .command_tree import CommandTree
+from argument import _arguments, parse_known_args
+
+cmd_spec_locations = [
+    'kamaki.cli.commands',
+    'kamaki.commands',
+    'kamaki.cli',
+    'kamaki',
+    '']
+_commands = CommandTree(name='kamaki', description='A command line tool for poking clouds')
+
+#If empty, all commands are loaded, if not empty, only commands in this list
+#e.g. [store, lele, list, lolo] is good to load store_list but not list_store
+#First arg should always refer to a group
+candidate_command_terms = []
+allow_no_commands = False
+allow_all_commands = False
+allow_subclass_signatures = False
+
+def _allow_class_in_cmd_tree(cls):
+    global allow_all_commands
+    if allow_all_commands:
+        return True
+    global allow_no_commands 
+    if allow_no_commands:
+        return False
+
+    term_list = cls.__name__.split('_')
+    global candidate_command_terms
+    index = 0
+    for term in candidate_command_terms:
+        try:
+            index += 1 if term_list[index] == term else 0
+        except IndexError: #Whole term list matched!
+            return True
+    if allow_subclass_signatures:
+        if index == len(candidate_command_terms) and len(term_list) > index:
+            try: #is subterm already in _commands?
+                _commands.get_command('_'.join(term_list[:index+1]))
+            except KeyError: #No, so it must be placed there
+                return True
+        return False
+
+    return True if index == len(term_list) else False
+
+def command():
+    """Class decorator that registers a class as a CLI command"""
 
     def decorator(cls):
-        grp, sep, cmd = cls.__name__.partition('_')
-        if not sep:
-            grp, cmd = None, cls.__name__
-
-        #cls.api = api
-        cls.group = group or grp
-        cls.name = name or cmd
-
-        short_description, sep, long_description = cls.__doc__.partition('\n')
-        cls.description = short_description
-        cls.long_description = long_description or short_description
-
-        cls.syntax = syntax
-        if cls.syntax is None:
-            # Generate a syntax string based on main's arguments
-            spec = inspect.getargspec(cls.main.im_func)
-            args = spec.args[1:]
-            n = len(args) - len(spec.defaults or ())
-            required = ' '.join('<%s>' % x.replace('____', '[:').replace('___', ':').replace('__',']').replace('_', ' ') for x in args[:n])
-            optional = ' '.join('[%s]' % x.replace('____', '[:').replace('___', ':').replace('__', ']').replace('_', ' ') for x in args[n:])
-            cls.syntax = ' '.join(x for x in [required, optional] if x)
-            if spec.varargs:
-                cls.syntax += ' <%s ...>' % spec.varargs
-
-        if cls.group not in _commands:
-            _commands[cls.group] = OrderedDict()
-        _commands[cls.group][cls.name] = cls
+        """Any class with name of the form cmd1_cmd2_cmd3_... is accepted"""
+
+        if not _allow_class_in_cmd_tree(cls):
+            return cls
+
+        cls.description, sep, cls.long_description = cls.__doc__.partition('\n')
+
+        # Generate a syntax string based on main's arguments
+        spec = getargspec(cls.main.im_func)
+        args = spec.args[1:]
+        n = len(args) - len(spec.defaults or ())
+        required = ' '.join('<%s>' % x.replace('____', '[:').replace('___', ':').replace('__',']').\
+            replace('_', ' ') for x in args[:n])
+        optional = ' '.join('[%s]' % x.replace('____', '[:').replace('___', ':').replace('__', ']').\
+            replace('_', ' ') for x in args[n:])
+        cls.syntax = ' '.join(x for x in [required, optional] if x)
+        if spec.varargs:
+            cls.syntax += ' <%s ...>' % spec.varargs
+
+        #store each term, one by one, first
+        _commands.add_command(cls.__name__, cls.description, cls)
         return cls
     return decorator
 
-def set_api_description(api, description):
-    """Method to be called by api CLIs
-    Each CLI can set more than one api descriptions"""
-    GROUPS[api] = description
-
-def main():
-
-    def print_groups():
-        print('\nGroups:')
-        for group in _commands:
-            description = GROUPS.get(group, '')
-            print(' ', group.ljust(12), description)
-
-    def print_commands(group):
-        description = GROUPS.get(group, '')
-        if description:
-            print('\n' + description)
-
-        print('\nCommands:')
-        for name, cls in _commands[group].items():
-            print(' ', name.ljust(14), cls.description)
-
-    def manage_logging_handlers(args):
-        """This is mostly to handle logging for clients package"""
-
-        def add_handler(name, level, prefix=''):
-            h = logging.StreamHandler()
-            fmt = logging.Formatter(prefix + '%(message)s')
-            h.setFormatter(fmt)
-            logger = logging.getLogger(name)
-            logger.addHandler(h)
-            logger.setLevel(level)
-
-        if args.silent:
-            add_handler('', logging.CRITICAL)
-        elif args.debug:
-            add_handler('requests', logging.INFO, prefix='* ')
-            add_handler('clients.send', logging.DEBUG, prefix='> ')
-            add_handler('clients.recv', logging.DEBUG, prefix='< ')
-        elif args.verbose:
-            add_handler('requests', logging.INFO, prefix='* ')
-            add_handler('clients.send', logging.INFO, prefix='> ')
-            add_handler('clients.recv', logging.INFO, prefix='< ')
-        elif args.include:
-            add_handler('clients.recv', logging.INFO)
+def _update_parser(parser, arguments):
+    for name, argument in arguments.items():
+        try:
+            argument.update_parser(parser, name)
+        except ArgumentError:
+            pass
+
+def _init_parser(exe):
+    parser = ArgumentParser(add_help=False)
+    parser.prog='%s <cmd_group> [<cmd_subbroup> ...] <cmd>'%exe
+    _update_parser(parser, _arguments)
+    return parser
+
+def _print_error_message(cli_err):
+    errmsg = unicode(cli_err) + (' (%s)'%cli_err.status if cli_err.status else ' ')
+    if cli_err.importance == 1:
+        errmsg = magenta(errmsg)
+    elif cli_err.importance == 2:
+        errmsg = yellow(errmsg)
+    elif cli_err.importance > 2:
+        errmsg = red(errmsg)
+    stdout.write(errmsg)
+    if cli_err.details is not None and len(cli_err.details) > 0:
+        print(': %s'%cli_err.details)
+    else:
+        print()
+
+def get_command_group(unparsed):
+    groups = _arguments['config'].get_groups()
+    for grp_candidate in unparsed:
+        if grp_candidate in groups:
+            unparsed.remove(grp_candidate)
+            return grp_candidate
+    return None
+
+def load_command(group, unparsed, reload_package=False):
+    global candidate_command_terms
+    candidate_command_terms = [group] + unparsed
+    pkg = load_group_package(group, reload_package)
+
+    #From all possible parsed commands, chose the first match in user string
+    final_cmd = _commands.get_command(group)
+    for term in unparsed:
+        cmd = final_cmd.get_subcmd(term)
+        if cmd is not None:
+            final_cmd = cmd
+            unparsed.remove(cmd.name)
+    return final_cmd
+
+def shallow_load():
+    """Load only group names and descriptions"""
+    global allow_no_commands 
+    allow_no_commands = True#load only descriptions
+    for grp in _arguments['config'].get_groups():
+        load_group_package(grp)
+    allow_no_commands = False
+
+def load_group_package(group, reload_package=False):
+    spec_pkg = _arguments['config'].value.get(group, 'cli')
+    for location in cmd_spec_locations:
+        location += spec_pkg if location == '' else ('.'+spec_pkg)
+        try:
+            package = __import__(location, fromlist=['API_DESCRIPTION'])
+        except ImportError:
+            continue
+        if reload_package:
+            reload(package)
+        for grp, descr in package.API_DESCRIPTION.items():
+            _commands.add_command(grp, descr)
+        return package
+    raise CLICmdSpecError(details='Cmd Spec Package %s load failed'%spec_pkg)
+
+def print_commands(prefix=None, full_depth=False):
+    cmd_list = _commands.get_groups() if prefix is None else _commands.get_subcommands(prefix)
+    cmds = {}
+    for subcmd in cmd_list:
+        if subcmd.sublen() > 0:
+            sublen_str = '( %s more terms ... )'%subcmd.sublen()
+            cmds[subcmd.name] = [subcmd.help, sublen_str] if subcmd.has_description else subcmd_str
         else:
-            add_handler('', logging.WARNING)
-
-    def load_groups(config):
-        """load groups and import CLIs and Modules"""
-        loaded_modules = {}
-        for api in config.apis():
-            api_cli = config.get(api, 'cli')
-            if None == api_cli or len(api_cli)==0:
-                print('Warnig: No Command Line Interface "%s" given for API "%s"'%(api_cli, api))
-                print('\t(cli option in config file)')
-                continue
-            if not loaded_modules.has_key(api_cli):
-                loaded_modules[api_cli] = False
-                for location in CLI_LOCATIONS:
-                    location += api_cli if location == '' else '.%s'%api_cli
-                    try:
-                        __import__(location)
-                        loaded_modules[api_cli] = True
-                        break
-                    except ImportError:
-                        pass
-                if not loaded_modules[api_cli]:
-                    print('Warning: failed to load Command Line Interface "%s" for API "%s"'%(api_cli, api))
-                    print('\t(No suitable cli in known paths)')
-                    continue
-            if not GROUPS.has_key(api):
-                GROUPS[api] = 'No description (interface: %s)'%api_cli
-
-    def init_parser(exe):
-        parser = ArgumentParser(add_help=False)
-        parser.prog = '%s <group> <command>' % exe
-        parser.add_argument('-h', '--help', dest='help', action='store_true',
-                          default=False,
-                          help="Show this help message and exit")
-        parser.add_argument('--config', dest='config', metavar='PATH',
-                          help="Specify the path to the configuration file")
-        parser.add_argument('-d', '--debug', dest='debug', action='store_true',
-                          default=False,
-                          help="Include debug output")
-        parser.add_argument('-i', '--include', dest='include', action='store_true',
-                          default=False,
-                          help="Include protocol headers in the output")
-        parser.add_argument('-s', '--silent', dest='silent', action='store_true',
-                          default=False,
-                          help="Silent mode, don't output anything")
-        parser.add_argument('-v', '--verbose', dest='verbose', action='store_true',
-                          default=False,
-                          help="Make the operation more talkative")
-        parser.add_argument('-V', '--version', dest='version', action='store_true',
-                          default=False,
-                          help="Show version number and quit")
-        parser.add_argument('-o', dest='options', action='append',
-                          default=[], metavar="KEY=VAL",
-                          help="Override a config value")
-        return parser
-
-    def find_term_in_args(arg_list, term_list):
-        """find an arg_list term in term_list. All other terms up to found
-        term are rearanged at the end of arg_list, preserving relative order
-        """
-        arg_tail = []
-        while len(arg_list) > 0:
-            group = arg_list.pop(0)
-            if group not in term_list:
-                arg_tail.append(group)
-            else:
-                arg_list += arg_tail
-                return group
-        return None
-
-    """Main Code"""
-    exe = basename(sys.argv[0])
-    parser = init_parser(exe)
-    args, argv = parser.parse_known_args()
-
-    #print version
-    if args.version:
-        import kamaki
-        print("kamaki %s" % kamaki.__version__)
-        exit(0)
-
-    config = Config(args.config) if args.config else Config()
-
-    #load config options from command line
-    for option in args.options:
-        keypath, sep, val = option.partition('=')
-        if not sep:
-            print("Invalid option '%s'" % option)
-            exit(1)
-        section, sep, key = keypath.partition('.')
-        if not sep:
-            print("Invalid option '%s'" % option)
-            exit(1)
-        config.override(section.strip(), key.strip(), val.strip())
-
-    load_groups(config)
-    group = find_term_in_args(argv, _commands)
-    if not group:
-        parser.print_help()
-        print_groups()
-        exit(0)
-
-    parser.prog = '%s %s <command>' % (exe, group)
-    command = find_term_in_args(argv, _commands[group])
-
-    if not command:
-        parser.print_help()
-        print_commands(group)
-        exit(0)
-
-    cmd = _commands[group][command]()
-
-    parser.prog = '%s %s %s' % (exe, group, command)
-    if cmd.syntax:
-        parser.prog += '  %s' % cmd.syntax
-    parser.description = cmd.description
-    parser.epilog = ''
-    if hasattr(cmd, 'update_parser'):
-        cmd.update_parser(parser)
-
-    #check other args
-    args, argv = parser.parse_known_args()
-    if group != argv[0]:
-        errmsg = red('Invalid command group '+argv[0])
-        print(errmsg, file=stderr)
-        exit(1)
-    if command != argv[1]:
-        errmsg = red('Invalid command "%s" in group "%s"'%(argv[1], argv[0]))
-        print(errmsg, file=stderr)
-        exit(1)
-
-    if args.help:
-        parser.print_help()
-        exit(0)
-
-    manage_logging_handlers(args)
-    cmd.args = args
-    cmd.config = config
+            cmds[subcmd.name] = subcmd.help
+    if len(cmds) > 0:
+        print('\nOptions:')
+        print_dict(cmds, ident=12)
+    if full_depth:
+        _commands.pretty_print()
+
+def one_command():
+    _debug = False
+    _help = False
+    _verbose = False
     try:
-        ret = cmd.main(*argv[2:])
-        exit(ret)
-    except TypeError as e:
-        if e.args and e.args[0].startswith('main()'):
+        exe = basename(argv[0])
+        parser = _init_parser(exe)
+        parsed, unparsed = parse_known_args(parser)
+        _debug = _arguments['debug'].value
+        _help = _arguments['help'].value
+        _verbose = _arguments['verbose'].value
+        if _arguments['version'].value:
+            exit(0)
+
+        group = get_command_group(unparsed)
+        if group is None:
             parser.print_help()
-            exit(1)
-        else:
-            raise
+            shallow_load()
+            print_commands(full_depth=_verbose)
+            exit(0)
+
+        cmd = load_command(group, unparsed)
+        if _help or not cmd.is_command:
+            if cmd.has_description:
+                parser.description = cmd.help 
+            else:
+                try:
+                    parser.description = _commands.get_closest_ancestor_command(cmd.path).help
+                except KeyError:
+                    parser.description = ' '
+            parser.prog = '%s %s '%(exe, cmd.path.replace('_', ' '))
+            if cmd.is_command:
+                cli = cmd.get_class()
+                parser.prog += cli.syntax
+                _update_parser(parser, cli().arguments)
+            else:
+                parser.prog += '[...]'
+            parser.print_help()
+
+            #Shuuuut, we now have to load one more level just to see what is missing
+            global allow_subclass_signatures 
+            allow_subclass_signatures = True
+            load_command(group, cmd.path.split('_')[1:], reload_package=True)
+
+            print_commands(cmd.path, full_depth=_verbose)
+            exit(0)
+
+        cli = cmd.get_class()
+        executable = cli(_arguments)
+        _update_parser(parser, executable.arguments)
+        parser.prog = '%s %s %s'%(exe, cmd.path.replace('_', ' '), cli.syntax)
+        parse_known_args(parser)
+        try:
+            ret = executable.main(*unparsed)
+            exit(ret)
+        except TypeError as e:
+            if e.args and e.args[0].startswith('main()'):
+                parser.print_help()
+                exit(1)
+            else:
+                raise
     except CLIError as err:
-        errmsg = 'CLI Error '
-        errmsg += '(%s): '%err.status if err.status else ': '
-        errmsg += err.message if err.message else ''
-        if err.importance == 1:
-            errmsg = yellow(errmsg)
-        elif err.importance == 2:
-            errmsg = magenta(errmsg)
-        elif err.importance > 2:
-            errmsg = red(errmsg)
-        print(errmsg, file=stderr)
+        if _debug:
+            raise
+        _print_error_message(err)
         exit(1)
-
-if __name__ == '__main__':
-    main()