X-Git-Url: https://code.grnet.gr/git/kamaki/blobdiff_plain/34c480f27c67347bb4d1f071e23d0b4d9f2e9799..099b433bc93cdbcbed6984690e00289995a7b3e0:/kamaki/cli/argument/__init__.py diff --git a/kamaki/cli/argument/__init__.py b/kamaki/cli/argument/__init__.py index 7126ff8..f2dca9b 100644 --- a/kamaki/cli/argument/__init__.py +++ b/kamaki/cli/argument/__init__.py @@ -32,25 +32,18 @@ # or implied, of GRNET S.A. from kamaki.cli.config import Config -from kamaki.cli.errors import CLISyntaxError, raiseCLIError -from kamaki.cli.utils import split_input +from kamaki.cli.errors import ( + CLISyntaxError, raiseCLIError, CLIInvalidArgument) +from kamaki.cli.utils import split_input, to_bytes from datetime import datetime as dtm from time import mktime +from sys import stderr from logging import getLogger from argparse import ArgumentParser, ArgumentError from argparse import RawDescriptionHelpFormatter - -try: - from progress.bar import ShadyBar as KamakiProgressBar -except ImportError: - try: - from progress.bar import Bar as KamakiProgressBar - except ImportError: - pass - # progress not installed - pls, pip install progress - pass +from progress.bar import ShadyBar as KamakiProgressBar log = getLogger(__name__) @@ -60,6 +53,7 @@ class Argument(object): This is the top-level Argument class. It is suggested to extent this class into more specific argument types. """ + lvalue_delimiter = '/' def __init__(self, arity, help=None, parsed_name=None, default=None): self.arity = int(arity) @@ -76,7 +70,7 @@ class Argument(object): self, name) assert name.startswith('-'), msg - self.default = default or (None if self.arity else False) + self.default = default or None @property def value(self): @@ -94,6 +88,12 @@ class Argument(object): *self.parsed_name, dest=name, action=action, default=self.default, help=self.help) + @property + def lvalue(self): + """A printable form of the left value when calling an argument e.g., + --left-value=right-value""" + return (self.lvalue_delimiter or ' ').join(self.parsed_name or []) + class ConfigArgument(Argument): """Manage a kamaki configuration (file)""" @@ -104,7 +104,7 @@ class ConfigArgument(Argument): @property def value(self): - return super(ConfigArgument, self).value + return getattr(self, '_value', None) @value.setter def value(self, config_file): @@ -135,7 +135,7 @@ class ConfigArgument(Argument): k.endswith(suffix))] def get_global(self, option): - return self.value.get_global(option) + return self.value.get('global', option) def get_cloud(self, cloud, option): return self.value.get_cloud(cloud, option) @@ -184,7 +184,7 @@ class FlagArgument(Argument): :value: true if set, false otherwise """ - def __init__(self, help='', parsed_name=None, default=False): + def __init__(self, help='', parsed_name=None, default=None): super(FlagArgument, self).__init__(0, help, parsed_name, default) @@ -223,29 +223,63 @@ class IntArgument(ValueArgument): @value.setter def value(self, newvalue): if newvalue == self.default: - self._value = self.default + self._value = newvalue return try: - self._value = int(newvalue) + if int(newvalue) == float(newvalue): + self._value = int(newvalue) + else: + raise ValueError('Raise int argument error') except ValueError: raiseCLIError(CLISyntaxError( 'IntArgument Error', details=['Value %s not an int' % newvalue])) -class DateArgument(ValueArgument): +class DataSizeArgument(ValueArgument): + """Input: a string of the form + Output: the number of bytes + Units: B, KiB, KB, MiB, MB, GiB, GB, TiB, TB """ - :value type: a string formated in an acceptable date format - :value returns: same date in first of DATE_FORMATS - """ + @property + def value(self): + return getattr(self, '_value', self.default) - DATE_FORMATS = [ - "%a %b %d %H:%M:%S %Y", - "%A, %d-%b-%y %H:%M:%S GMT", - "%a, %d %b %Y %H:%M:%S GMT"] + def _calculate_limit(self, user_input): + limit = 0 + try: + limit = int(user_input) + except ValueError: + index = 0 + digits = [str(num) for num in range(0, 10)] + ['.'] + while user_input[index] in digits: + index += 1 + limit = user_input[:index] + format = user_input[index:] + try: + return to_bytes(limit, format) + except Exception as qe: + msg = 'Failed to convert %s to bytes' % user_input, + raiseCLIError(qe, msg, details=[ + 'Syntax: containerlimit set [format] [container]', + 'e.g.,: containerlimit set 2.3GB mycontainer', + 'Valid formats:', + '(*1024): B, KiB, MiB, GiB, TiB', + '(*1000): B, KB, MB, GB, TB']) + return limit + + @value.setter + def value(self, new_value): + if new_value: + self._value = self._calculate_limit(new_value) - INPUT_FORMATS = DATE_FORMATS + ["%d-%m-%Y", "%H:%M:%S %d-%m-%Y"] + +class DateArgument(ValueArgument): + + DATE_FORMAT = '%a %b %d %H:%M:%S %Y' + + INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y'] @property def timestamp(self): @@ -255,7 +289,7 @@ class DateArgument(ValueArgument): @property def formated(self): v = getattr(self, '_value', self.default) - return v.strftime(self.DATE_FORMATS[0]) if v else None + return v.strftime(self.DATE_FORMAT) if v else None @property def value(self): @@ -263,8 +297,7 @@ class DateArgument(ValueArgument): @value.setter def value(self, newvalue): - if newvalue: - self._value = self.format_date(newvalue) + self._value = self.format_date(newvalue) if newvalue else self.default def format_date(self, datestr): for format in self.INPUT_FORMATS: @@ -272,12 +305,10 @@ class DateArgument(ValueArgument): t = dtm.strptime(datestr, format) except ValueError: continue - return t # .strftime(self.DATE_FORMATS[0]) - raiseCLIError( - None, - 'Date Argument Error', - details='%s not a valid date. correct formats:\n\t%s' % ( - datestr, self.INPUT_FORMATS)) + return t # .strftime(self.DATE_FORMAT) + raiseCLIError(None, 'Date Argument Error', details=[ + '%s not a valid date' % datestr, + 'Correct formats:\n\t%s' % self.INPUT_FORMATS]) class VersionArgument(FlagArgument): @@ -296,33 +327,77 @@ class VersionArgument(FlagArgument): print('kamaki %s' % kamaki.__version__) +class RepeatableArgument(Argument): + """A value argument that can be repeated""" + + def __init__(self, help='', parsed_name=None, default=None): + super(RepeatableArgument, self).__init__( + -1, help, parsed_name, default) + + @property + def value(self): + return getattr(self, '_value', []) + + @value.setter + def value(self, newvalue): + self._value = newvalue + + class KeyValueArgument(Argument): - """A Value Argument that can be repeated + """A Key=Value Argument that can be repeated :syntax: -- key1=value1 -- key2=value2 ... """ - def __init__(self, help='', parsed_name=None, default=[]): + def __init__(self, help='', parsed_name=None, default=None): super(KeyValueArgument, self).__init__(-1, help, parsed_name, default) @property def value(self): """ - :input: key=value - :output: {'key1':'value1', 'key2':'value2', ...} + :returns: (dict) {key1: val1, key2: val2, ...} """ - return super(KeyValueArgument, self).value + return getattr(self, '_value', {}) @value.setter def value(self, keyvalue_pairs): - self._value = {} - for pair in keyvalue_pairs: - key, sep, val = pair.partition('=') - if not sep: - raiseCLIError( - CLISyntaxError('Argument syntax error '), - details='%s is missing a "=" (usage: key1=val1 )\n' % pair) - self._value[key.strip()] = val.strip() + """ + :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...] + """ + if keyvalue_pairs: + self._value = self.value + try: + for pair in keyvalue_pairs: + key, sep, val = pair.partition('=') + assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % ( + pair) + self._value[key] = val + except Exception as e: + raiseCLIError(e, 'KeyValueArgument Syntax Error') + + +class StatusArgument(ValueArgument): + """Initialize with valid_states=['list', 'of', 'states'] + First state is the default""" + + def __init__(self, *args, **kwargs): + self.valid_states = kwargs.pop('valid_states', ['BUILD', ]) + super(StatusArgument, self).__init__(*args, **kwargs) + + @property + def value(self): + return getattr(self, '_value', None) + + @value.setter + def value(self, new_status): + if new_status: + new_status = new_status.upper() + if new_status not in self.valid_states: + raise CLIInvalidArgument( + 'Invalid argument %s' % new_status, details=[ + 'Usage: ' + '%s=[%s]' % (self.lvalue, '|'.join(self.valid_states))]) + self._value = new_status class ProgressBarArgument(FlagArgument): @@ -331,31 +406,35 @@ class ProgressBarArgument(FlagArgument): def __init__(self, help='', parsed_name='', default=True): self.suffix = '%(percent)d%%' super(ProgressBarArgument, self).__init__(help, parsed_name, default) - try: - KamakiProgressBar - except NameError: - log.warning('WARNING: no progress bar functionality') def clone(self): """Get a modifiable copy of this bar""" newarg = ProgressBarArgument( - self.help, - self.parsed_name, - self.default) + self.help, self.parsed_name, self.default) newarg._value = self._value return newarg - def get_generator(self, message, message_len=25): + def get_generator( + self, message, message_len=25, countdown=False, timeout=100): """Get a generator to handle progress of the bar (gen.next())""" if self.value: return None try: - self.bar = KamakiProgressBar() + self.bar = KamakiProgressBar( + message.ljust(message_len), max=timeout or 100) except NameError: self.value = None return self.value - self.bar.message = message.ljust(message_len) - self.bar.suffix = '%(percent)d%% - %(eta)ds' + if countdown: + bar_phases = list(self.bar.phases) + self.bar.empty_fill, bar_phases[0] = bar_phases[-1], '' + bar_phases.reverse() + self.bar.phases = bar_phases + self.bar.bar_prefix = ' ' + self.bar.bar_suffix = ' ' + self.bar.suffix = '%(remaining)ds to timeout' + else: + self.bar.suffix = '%(percent)d%% - %(eta)ds' self.bar.start() def progress_gen(n): @@ -378,8 +457,8 @@ _arguments = dict( cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')), help=Argument(0, 'Show help message', ('-h', '--help')), debug=FlagArgument('Include debug output', ('-d', '--debug')), - include=FlagArgument( - 'Include raw connection data in the output', ('-i', '--include')), + #include=FlagArgument( + # 'Include raw connection data in the output', ('-i', '--include')), silent=FlagArgument('Do not output anything', ('-s', '--silent')), verbose=FlagArgument('More info at response', ('-v', '--verbose')), version=VersionArgument('Print current version', ('-V', '--version')), @@ -394,29 +473,113 @@ _arguments = dict( class ArgumentParseManager(object): """Manage (initialize and update) an ArgumentParser object""" - parser = None - _arguments = {} - _parser_modified = False - _parsed = None - _unparsed = None - - def __init__(self, exe, arguments=None): + def __init__( + self, exe, + arguments=None, required=None, syntax=None, description=None, + check_required=True): """ :param exe: (str) the basic command (e.g. 'kamaki') :param arguments: (dict) if given, overrides the global _argument as the parsers arguments specification + :param required: (list or tuple) an iterable of argument keys, denoting + which arguments are required. A tuple denoted an AND relation, + while a list denotes an OR relation e.g., ['a', 'b'] means that + either 'a' or 'b' is required, while ('a', 'b') means that both 'a' + and 'b' ar required. + Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that + this command required either 'a', or both 'b' and 'c', or one of + 'd', 'e'. + Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'), + ['b', 'c']] means that the command required either 'a' and 'b' or + 'a' and 'c' or at least one of 'b', 'c' and could be written as + [('a', ['b', 'c']), ['b', 'c']] + :param syntax: (str) The basic syntax of the arguments. Default: + exe [ ...] + :param description: (str) The description of the commands or '' + :param check_required: (bool) Set to False inorder not to check for + required argument values while parsing """ self.parser = ArgumentParser( add_help=False, formatter_class=RawDescriptionHelpFormatter) - self.syntax = '%s [ ...] ' % exe + self._exe = exe + self.syntax = syntax or ( + '%s [ ...] ' % exe) + self.required, self.check_required = required, check_required + self.parser.description = description or '' if arguments: self.arguments = arguments else: global _arguments self.arguments = _arguments + self._parser_modified, self._parsed, self._unparsed = False, None, None self.parse() + @staticmethod + def required2list(required): + if isinstance(required, list) or isinstance(required, tuple): + terms = [] + for r in required: + terms.append(ArgumentParseManager.required2list(r)) + return list(set(terms).union()) + return required + + @staticmethod + def required2str(required, arguments, tab=''): + if isinstance(required, list): + return ' %sat least one of the following:\n%s' % (tab, ''.join( + [ArgumentParseManager.required2str( + r, arguments, tab + ' ') for r in required])) + elif isinstance(required, tuple): + return ' %sall of the following:\n%s' % (tab, ''.join( + [ArgumentParseManager.required2str( + r, arguments, tab + ' ') for r in required])) + else: + lt_pn, lt_all, arg = 23, 80, arguments[required] + tab2 = ' ' * lt_pn + ret = '%s%s' % (tab, ', '.join(arg.parsed_name)) + if arg.arity != 0: + ret += ' %s' % required.upper() + ret = ('{:<%s}' % lt_pn).format(ret) + prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' ' + cur = 0 + while arg.help[cur:]: + next = cur + lt_all - lt_pn + ret += prefix + ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next]) + cur, finish = next, '\n%s' % tab2 + return ret + '\n' + + @staticmethod + def _patch_with_required_args(arguments, required): + if isinstance(required, tuple): + return ' '.join([ArgumentParseManager._patch_with_required_args( + arguments, k) for k in required]) + elif isinstance(required, list): + return '< %s >' % ' | '.join([ + ArgumentParseManager._patch_with_required_args( + arguments, k) for k in required]) + arg = arguments[required] + return '/'.join(arg.parsed_name) + ( + ' %s [...]' % required.upper() if arg.arity < 0 else ( + ' %s' % required.upper() if arg.arity else '')) + + def print_help(self, out=stderr): + if self.required: + tmp_args = dict(self.arguments) + for term in self.required2list(self.required): + tmp_args.pop(term) + tmp_parser = ArgumentParseManager(self._exe, tmp_args) + tmp_parser.syntax = self.syntax + self._patch_with_required_args( + self.arguments, self.required) + tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % ( + self.parser.description, + self.required2str(self.required, self.arguments)) + tmp_parser.update_parser() + tmp_parser.parser.print_help() + else: + self.parser.print_help() + @property def syntax(self): """The command syntax (useful for help messages, descriptions, etc)""" @@ -428,13 +591,12 @@ class ArgumentParseManager(object): @property def arguments(self): - """(dict) arguments the parser should be aware of""" + """:returns: (dict) arguments the parser should be aware of""" return self._arguments @arguments.setter def arguments(self, new_arguments): - if new_arguments: - assert isinstance(new_arguments, dict) + assert isinstance(new_arguments, dict), 'Arguments must be in a dict' self._arguments = new_arguments self.update_parser() @@ -457,8 +619,7 @@ class ArgumentParseManager(object): :param arguments: if not given, update self.arguments instead """ - if not arguments: - arguments = self._arguments + arguments = arguments or self._arguments for name, arg in arguments.items(): try: @@ -473,15 +634,35 @@ class ArgumentParseManager(object): :param new_arguments: (dict) """ if new_arguments: - assert isinstance(new_arguments, dict) + assert isinstance(new_arguments, dict), 'Arguments not in dict !!!' self._arguments.update(new_arguments) self.update_parser() + def _parse_required_arguments(self, required, parsed_args): + if not (self.check_required and required): + return True + if isinstance(required, tuple): + for item in required: + if not self._parse_required_arguments(item, parsed_args): + return False + return True + if isinstance(required, list): + for item in required: + if self._parse_required_arguments(item, parsed_args): + return True + return False + return required in parsed_args + def parse(self, new_args=None): """Parse user input""" try: pkargs = (new_args,) if new_args else () self._parsed, unparsed = self.parser.parse_known_args(*pkargs) + parsed_args = [ + k for k, v in vars(self._parsed).items() if v not in (None, )] + if not self._parse_required_arguments(self.required, parsed_args): + self.print_help() + raise CLISyntaxError('Missing required arguments') except SystemExit: raiseCLIError(CLISyntaxError('Argument Syntax Error')) for name, arg in self.arguments.items():