X-Git-Url: https://code.grnet.gr/git/kamaki/blobdiff_plain/b46307af8f1d4f2871220e22aa886f973a28f915..df0045d8e5c4a697bbdb8d324a7ced41ab24bd6b:/kamaki/cli/__init__.py diff --git a/kamaki/cli/__init__.py b/kamaki/cli/__init__.py index 568f70a..b9ef171 100644 --- a/kamaki/cli/__init__.py +++ b/kamaki/cli/__init__.py @@ -1,6 +1,4 @@ -#!/usr/bin/env python - -# Copyright 2011-2012 GRNET S.A. All rights reserved. +# Copyright 2012-2013 GRNET S.A. All rights reserved. # # Redistribution and use in source and binary forms, with or # without modification, are permitted provided that the following @@ -31,119 +29,366 @@ # The views and conclusions contained in the software and # documentation are those of the authors and should not be # interpreted as representing official policies, either expressed -# or implied, of GRNET S.A. - -from __future__ import print_function - -import gevent.monkey -#Monkey-patch everything for gevent early on -gevent.monkey.patch_all() +# or implied, of GRNET S.A.command import logging - +from sys import argv, exit, stdout +from os.path import basename, exists from inspect import getargspec -from argparse import ArgumentParser, ArgumentError -from os.path import basename -from sys import exit, stdout, stderr, argv - -try: - from collections import OrderedDict -except ImportError: - from ordereddict import OrderedDict - -#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 -from .history import History -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 +from kamaki.cli.argument import ArgumentParseManager +from kamaki.cli.history import History +from kamaki.cli.utils import print_dict, red, magenta, yellow +from kamaki.cli.errors import CLIError +from kamaki.cli import logger - return True if index == len(term_list) else False +_help = False +_debug = False +_include = False +_verbose = False +_colors = False +kloger = None +filelog = None -def command(): - """Class decorator that registers a class as a CLI command""" +# command auxiliary methods - def decorator(cls): - """Any class with name of the form cmd1_cmd2_cmd3_... is accepted""" +_best_match = [] - if not _allow_class_in_cmd_tree(cls): - return cls - cls.description, sep, cls.long_description = cls.__doc__.partition('\n') +def _arg2syntax(arg): + return arg.replace( + '____', '[:').replace( + '___', ':').replace( + '__', ']').replace( + '_', ' ') + - # Generate a syntax string based on main's arguments +def _construct_command_syntax(cls): 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:]) + required = ' '.join(['<%s>' % _arg2syntax(x) for x in args[:n]]) + optional = ' '.join(['[%s]' % _arg2syntax(x) 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) + +def _num_of_matching_terms(basic_list, attack_list): + if not attack_list: + return len(basic_list) + + matching_terms = 0 + for i, term in enumerate(basic_list): + try: + if term != attack_list[i]: + break + except IndexError: + break + matching_terms += 1 + return matching_terms + + +def _update_best_match(name_terms, prefix=[]): + if prefix: + pref_list = prefix if isinstance(prefix, list) else prefix.split('_') + else: + pref_list = [] + + num_of_matching_terms = _num_of_matching_terms(name_terms, pref_list) + global _best_match + if not prefix: + _best_match = [] + + if num_of_matching_terms and len(_best_match) <= num_of_matching_terms: + if len(_best_match) < num_of_matching_terms: + _best_match = name_terms[:num_of_matching_terms] + return True + return False + + +def command(cmd_tree, prefix='', descedants_depth=1): + """Load a class as a command + e.g. spec_cmd0_cmd1 will be command spec cmd0 + + :param cmd_tree: is initialized in cmd_spec file and is the structure + where commands are loaded. Var name should be _commands + :param prefix: if given, load only commands prefixed with prefix, + :param descedants_depth: is the depth of the tree descedants of the + prefix command. It is used ONLY if prefix and if prefix is not + a terminal command + + :returns: the specified class object + """ + + def wrap(cls): + global kloger + cls_name = cls.__name__ + + if not cmd_tree: + if _debug: + kloger.warning('command %s found but not loaded' % cls_name) + return cls + + name_terms = cls_name.split('_') + if not _update_best_match(name_terms, prefix): + if _debug: + kloger.warning('%s failed to update_best_match' % cls_name) + return None + + global _best_match + max_len = len(_best_match) + descedants_depth + if len(name_terms) > max_len: + partial = '_'.join(name_terms[:max_len]) + if not cmd_tree.has_command(partial): # add partial path + cmd_tree.add_command(partial) + if _debug: + kloger.warning('%s failed max_len test' % cls_name) + return None + + ( + cls.description, sep, cls.long_description + ) = cls.__doc__.partition('\n') + _construct_command_syntax(cls) + + cmd_tree.add_command(cls_name, cls.description, cls) return cls - return decorator + return wrap + + +cmd_spec_locations = [ + 'kamaki.cli.commands', + 'kamaki.commands', + 'kamaki.cli', + 'kamaki', + ''] + + +# Generic init auxiliary functions + + +def _setup_logging(silent=False, debug=False, verbose=False, include=False): + """handle logging for clients package""" + + if silent: + logger.add_stream_logger(__name__, logging.CRITICAL) + return + + sfmt, rfmt = '> %(message)s', '< %(message)s' + if debug: + print('Logging location: %s' % logger.get_log_filename()) + logger.add_stream_logger('kamaki.clients.send', logging.DEBUG, sfmt) + logger.add_stream_logger('kamaki.clients.recv', logging.DEBUG, rfmt) + logger.add_stream_logger(__name__, logging.DEBUG) + elif verbose: + logger.add_stream_logger('kamaki.clients.send', logging.INFO, sfmt) + logger.add_stream_logger('kamaki.clients.recv', logging.INFO, rfmt) + logger.add_stream_logger(__name__, logging.INFO) + if include: + logger.add_stream_logger('kamaki.clients.send', logging.INFO, sfmt) + logger.add_stream_logger('kamaki.clients.recv', logging.INFO, rfmt) + logger.add_stream_logger(__name__, logging.WARNING) + global kloger + kloger = logger.get_logger(__name__) + + +def _check_config_version(cnf): + guess = cnf.guess_version() + if exists(cnf.path) and guess < 0.9: + print('Config file format version >= 9.0 is required') + print('Configuration file: %s' % cnf.path) + print('but kamaki can fix this:') + print('Calculating changes while preserving information') + lost_terms = cnf.rescue_old_file() + print('... DONE') + if lost_terms: + print 'The following information will NOT be preserved:' + print '\t', '\n\t'.join(lost_terms) + print('Kamaki is ready to convert the config file to version 3.0') + stdout.write('Create (overwrite) file %s ? [y/N] ' % cnf.path) + from sys import stdin + reply = stdin.readline() + if reply in ('Y\n', 'y\n'): + cnf.write() + print('... DONE') + else: + print('... ABORTING') + raise CLIError( + 'Invalid format for config file %s' % cnf.path, + importance=3, details=[ + 'Please, update config file to v3.0', + 'For automatic conversion, rerun and say Y']) + + +def _init_session(arguments, is_non_API=False): + """ + :returns: (AuthCachedClient, str) authenticator and cloud name + """ + global _help + _help = arguments['help'].value + global _debug + _debug = arguments['debug'].value + global _include + _include = arguments['include'].value + global _verbose + _verbose = arguments['verbose'].value + _cnf = arguments['config'] + + if _help or is_non_API: + return None, None + + _check_config_version(_cnf.value) + + global _colors + _colors = _cnf.value.get_global('colors') + if not (stdout.isatty() and _colors == 'on'): + from kamaki.cli.utils import remove_colors + remove_colors() + _silent = arguments['silent'].value + _setup_logging(_silent, _debug, _verbose, _include) + + cloud = arguments['cloud'].value or _cnf.value.get( + 'global', 'default_cloud') + if not cloud: + num_of_clouds = len(_cnf.value.keys('cloud')) + if num_of_clouds == 1: + cloud = _cnf.value.keys('cloud')[0] + elif num_of_clouds > 1: + raise CLIError( + 'Found %s clouds but none of them is set as default' % ( + num_of_clouds), + importance=2, details=[ + 'Please, choose one of the following cloud names:', + ', '.join(_cnf.value.keys('cloud')), + 'To see all cloud settings:', + ' kamaki config get cloud.', + 'To set a default cloud:', + ' kamaki config set default_cloud ', + 'To pick a cloud for the current session, use --cloud:', + ' kamaki --cloud= ...']) + if not cloud in _cnf.value.keys('cloud'): + raise CLIError( + 'No cloud%s is configured' % ((' "%s"' % cloud) if cloud else ''), + importance=3, details=[ + 'To configure a new cloud "%s", find and set the' % ( + cloud or ''), + 'single authentication URL and token:', + ' kamaki config set cloud.%s.url ' % ( + cloud or ''), + ' kamaki config set cloud.%s.token ' % ( + cloud or '')]) + auth_args = dict() + for term in ('url', 'token'): + try: + auth_args[term] = _cnf.get_cloud(cloud, term) + except KeyError: + auth_args[term] = '' + if not auth_args[term]: + raise CLIError( + 'No authentication %s provided for cloud "%s"' % (term, cloud), + importance=3, details=[ + 'Set a %s for cloud %s:' % (term, cloud), + ' kamaki config set cloud.%s.%s <%s>' % ( + cloud, term, term)]) + + from kamaki.clients.astakos import AstakosClient as AuthCachedClient + try: + return AuthCachedClient(auth_args['url'], auth_args['token']), cloud + except AssertionError as ae: + kloger.warning('WARNING: Failed to load authenticator [%s]' % ae) + return None, cloud -def _update_parser(parser, arguments): - for name, argument in arguments.items(): + +def _load_spec_module(spec, arguments, module): + if not spec: + return None + pkg = None + for location in cmd_spec_locations: + location += spec if location == '' else '.%s' % spec try: - argument.update_parser(parser, name) - except ArgumentError: - pass - -def _init_parser(exe): - parser = ArgumentParser(add_help=False) - parser.prog='%s [ ...] '%exe - _update_parser(parser, _arguments) - return parser - -def _print_error_message(cli_err, verbose=False): - errmsg = unicode(cli_err) + (' (%s)'%cli_err.status if cli_err.status else ' ') + pkg = __import__(location, fromlist=[module]) + return pkg + except ImportError as ie: + continue + if not pkg: + kloger.debug('Loading cmd grp %s failed: %s' % (spec, ie)) + return pkg + + +def _groups_help(arguments): + global _debug + global kloger + descriptions = {} + for cmd_group, spec in arguments['config'].get_cli_specs(): + pkg = _load_spec_module(spec, arguments, '_commands') + if pkg: + cmds = getattr(pkg, '_commands') + try: + for cmd in cmds: + descriptions[cmd.name] = cmd.description + except TypeError: + if _debug: + kloger.warning( + 'No cmd description for module %s' % cmd_group) + elif _debug: + kloger.warning('Loading of %s cmd spec failed' % cmd_group) + print('\nOptions:\n - - - -') + print_dict(descriptions) + + +def _load_all_commands(cmd_tree, arguments): + _cnf = arguments['config'] + for cmd_group, spec in _cnf.get_cli_specs(): + try: + spec_module = _load_spec_module(spec, arguments, '_commands') + spec_commands = getattr(spec_module, '_commands') + except AttributeError: + if _debug: + global kloger + kloger.warning('No valid description for %s' % cmd_group) + continue + for spec_tree in spec_commands: + if spec_tree.name == cmd_group: + cmd_tree.add_tree(spec_tree) + break + + +# Methods to be used by CLI implementations + + +def print_subcommands_help(cmd): + printout = {} + for subcmd in cmd.get_subcommands(): + spec, sep, print_path = subcmd.path.partition('_') + printout[print_path.replace('_', ' ')] = subcmd.description + if printout: + print('\nOptions:\n - - - -') + print_dict(printout) + + +def update_parser_help(parser, cmd): + global _best_match + parser.syntax = parser.syntax.split('<')[0] + parser.syntax += ' '.join(_best_match) + + description = '' + if cmd.is_command: + cls = cmd.get_class() + parser.syntax += ' ' + cls.syntax + parser.update_arguments(cls().arguments) + description = getattr(cls, 'long_description', '') + description = description.strip() + else: + parser.syntax += ' <...>' + if cmd.has_description: + parser.parser.description = cmd.help + ( + ('\n%s' % description) if description else '') + else: + parser.parser.description = description + + +def print_error_message(cli_err): + errmsg = '%s' % cli_err if cli_err.importance == 1: errmsg = magenta(errmsg) elif cli_err.importance == 2: @@ -151,167 +396,113 @@ def _print_error_message(cli_err, verbose=False): elif cli_err.importance > 2: errmsg = red(errmsg) stdout.write(errmsg) - if verbose and 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 + for errmsg in cli_err.details: + print('| %s' % errmsg) -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) +def exec_cmd(instance, cmd_args, help_method): + try: + return instance.main(*cmd_args) + except TypeError as err: + if err.args and err.args[0].startswith('main()'): + print(magenta('Syntax error')) + if _debug: + raise err + if _verbose: + print(unicode(err)) + help_method() + else: + raise + return 1 + + +def get_command_group(unparsed, arguments): + groups = arguments['config'].get_groups() 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') - if spec_pkg is None: - return None - for location in cmd_spec_locations: - location += spec_pkg if location == '' else ('.'+spec_pkg) - try: - package = __import__(location, fromlist=['API_DESCRIPTION']) - except ImportError: + if term.startswith('-'): 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: - cmds[subcmd.name] = subcmd.help - if len(cmds) > 0: - print('\nOptions:') - print_dict(cmds, ident=12) - if full_depth: - _commands.pretty_print() - -def setup_logging(silent=False, debug=False, verbose=False, include=False): - """handle logging for clients package""" + if term in groups: + unparsed.remove(term) + return term + return None + return None - 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 silent: - add_handler('', logging.CRITICAL) - elif debug: - add_handler('requests', logging.INFO, prefix='* ') - add_handler('clients.send', logging.DEBUG, prefix='> ') - add_handler('clients.recv', logging.DEBUG, prefix='< ') - elif verbose: - add_handler('requests', logging.INFO, prefix='* ') - add_handler('clients.send', logging.INFO, prefix='> ') - add_handler('clients.recv', logging.INFO, prefix='< ') - elif include: - add_handler('clients.recv', logging.INFO) - else: - add_handler('', logging.WARNING) +def set_command_params(parameters): + """Add a parameters list to a command + + :param paramters: (list of str) a list of parameters + """ + global command + def_params = list(command.func_defaults) + def_params[0] = parameters + command.func_defaults = tuple(def_params) + + +# CLI Choice: + +def run_one_cmd(exe_string, parser, auth_base, cloud): + global _history + _history = History( + parser.arguments['config'].get_global('history_file')) + _history.add(' '.join([exe_string] + argv[1:])) + from kamaki.cli import one_command + one_command.run(auth_base, cloud, parser, _help) + -def one_command(): - _debug = False - _help = False - _verbose = False +def run_shell(exe_string, parser, auth_base, cloud): + from command_shell import _init_shell + shell = _init_shell(exe_string, parser) + _load_all_commands(shell.cmd_tree, parser.arguments) + shell.run(auth_base, cloud, parser) + + +def is_non_API(parser): + nonAPIs = ('history', 'config') + for term in parser.unparsed: + if not term.startswith('-'): + if term in nonAPIs: + return True + return False + return False + + +def main(): try: exe = basename(argv[0]) - parser = _init_parser(exe) - parsed, unparsed = parse_known_args(parser) - _history = History(_arguments['config'].get('history', 'file')) - _history.add(' '.join([exe]+argv[1:])) - _debug = _arguments['debug'].value - _help = _arguments['help'].value - _verbose = _arguments['verbose'].value - if _arguments['version'].value: - exit(0) + parser = ArgumentParseManager(exe) - group = get_command_group(unparsed) - if group is None: - parser.print_help() - shallow_load() - print_commands(full_depth=_debug) + if parser.arguments['version'].value: 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=_debug) - exit(0) - - setup_logging(silent=_arguments['silent'].value, debug=_debug, verbose=_verbose, - include=_arguments['include'].value) - cli = cmd.get_class() - executable = cli(_arguments) - _update_parser(parser, executable.arguments) - parser.prog = '%s %s %s'%(exe, cmd.path.replace('_', ' '), cli.syntax) - parsed, new_unparsed = parse_known_args(parser) - unparsed = [term for term in unparsed if term in new_unparsed] - 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 + log_file = parser.arguments['config'].get_global('log_file') + if log_file: + logger.set_log_filename(log_file) + global filelog + filelog = logger.add_file_logger(__name__.split('.')[0]) + filelog.info('* Initial Call *\n%s\n- - -' % ' '.join(argv)) + + auth_base, cloud = _init_session(parser.arguments, is_non_API(parser)) + + from kamaki.cli.utils import suggest_missing + global _colors + exclude = ['ansicolors'] if not _colors == 'on' else [] + suggest_missing(exclude=exclude) + + if parser.unparsed: + run_one_cmd(exe, parser, auth_base, cloud) + elif _help: + parser.parser.print_help() + _groups_help(parser.arguments) + else: + run_shell(exe, parser, auth_base, cloud) except CLIError as err: + print_error_message(err) + if _debug: + raise err + exit(1) + except Exception as er: + print('Unknown Error: %s' % er) if _debug: raise - _print_error_message(err, verbose=_verbose) exit(1)