1 # Copyright 2012-2014 GRNET S.A. All rights reserved.
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
7 # 1. Redistributions of source code must retain the above
8 # copyright notice, this list of conditions and the following
11 # 2. Redistributions in binary form must reproduce the above
12 # copyright notice, this list of conditions and the following
13 # disclaimer in the documentation and/or other materials
14 # provided with the distribution.
16 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
17 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
18 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
19 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
20 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
21 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
22 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
23 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
24 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
25 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
26 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27 # POSSIBILITY OF SUCH DAMAGE.
29 # The views and conclusions contained in the software and
30 # documentation are those of the authors and should not be
31 # interpreted as representing official policies, either expressed
32 # or implied, of GRNET S.A.
34 from kamaki.cli.config import Config
35 from kamaki.cli.errors import (
36 CLISyntaxError, raiseCLIError, CLIInvalidArgument)
37 from kamaki.cli.utils import split_input, to_bytes
39 from datetime import datetime as dtm
40 from time import mktime
41 from sys import stderr
43 from logging import getLogger
44 from argparse import (
45 ArgumentParser, ArgumentError, RawDescriptionHelpFormatter)
46 from progress.bar import ShadyBar as KamakiProgressBar
48 log = getLogger(__name__)
51 class NoAbbrArgumentParser(ArgumentParser):
52 """This is Argument Parser with disabled argument abbreviation"""
54 def _get_option_tuples(self, option_string):
56 chars = self.prefix_chars
57 if option_string[0] in chars and option_string[1] in chars:
58 if '=' in option_string:
59 option_prefix, explicit_arg = option_string.split('=', 1)
61 option_prefix = option_string
63 for option_string in self._option_string_actions:
64 if option_string == option_prefix:
65 action = self._option_string_actions[option_string]
66 tup = action, option_string, explicit_arg
68 elif option_string[0] in chars and option_string[1] not in chars:
69 option_prefix = option_string
71 short_option_prefix = option_string[:2]
72 short_explicit_arg = option_string[2:]
74 for option_string in self._option_string_actions:
75 if option_string == short_option_prefix:
76 action = self._option_string_actions[option_string]
77 tup = action, option_string, short_explicit_arg
79 elif option_string == option_prefix:
80 action = self._option_string_actions[option_string]
81 tup = action, option_string, explicit_arg
85 NoAbbrArgumentParser, self)._get_option_tuples(option_string)
89 class Argument(object):
90 """An argument that can be parsed from command line or otherwise.
91 This is the top-level Argument class. It is suggested to extent this
92 class into more specific argument types.
94 lvalue_delimiter = '/'
96 def __init__(self, arity, help=None, parsed_name=None, default=None):
97 self.arity = int(arity)
98 self.help = '%s' % help or ''
100 assert parsed_name, 'No parsed name for argument %s' % self
101 self.parsed_name = list(parsed_name) if isinstance(
102 parsed_name, list) or isinstance(parsed_name, tuple) else (
103 '%s' % parsed_name).split()
104 for name in self.parsed_name:
105 assert name.count(' ') == 0, '%s: Invalid parse name "%s"' % (
107 msg = '%s: Invalid parse name "%s" should start with a "-"' % (
109 assert name.startswith('-'), msg
111 self.default = default or None
115 return getattr(self, '_value', self.default)
118 def value(self, newvalue):
119 self._value = newvalue
121 def update_parser(self, parser, name):
122 """Update argument parser with self info"""
123 action = 'append' if self.arity < 0 else (
124 'store' if self.arity else 'store_true')
127 dest=name, action=action, default=self.default, help=self.help)
131 """A printable form of the left value when calling an argument e.g.,
132 --left-value=right-value"""
133 return (self.lvalue_delimiter or ' ').join(self.parsed_name or [])
136 class ConfigArgument(Argument):
137 """Manage a kamaki configuration (file)"""
139 def __init__(self, help, parsed_name=('-c', '--config')):
140 super(ConfigArgument, self).__init__(1, help, parsed_name, None)
141 self.file_path = None
145 return getattr(self, '_value', None)
148 def value(self, config_file):
150 self._value = Config(config_file)
151 self.file_path = config_file
153 self._value = Config(self.file_path)
155 self._value = Config()
157 def get(self, group, term):
158 """Get a configuration setting from the Config object"""
159 return self.value.get(group, term)
165 return [term[:-slen] for term in self.value.keys('global') if (
166 term.endswith(suffix))]
172 return [(k[:-slen], v) for k, v in self.value.items('global') if (
175 def get_global(self, option):
176 return self.value.get('global', option)
178 def get_cloud(self, cloud, option):
179 return self.value.get_cloud(cloud, option)
182 _config_arg = ConfigArgument('Path to config file')
185 class RuntimeConfigArgument(Argument):
186 """Set a run-time setting option (not persistent)"""
188 def __init__(self, config_arg, help='', parsed_name=None, default=None):
189 super(self.__class__, self).__init__(1, help, parsed_name, default)
190 self._config_arg = config_arg
194 return super(RuntimeConfigArgument, self).value
197 def value(self, options):
198 if options == self.default:
200 if not isinstance(options, list):
201 options = ['%s' % options]
202 for option in options:
203 keypath, sep, val = option.partition('=')
206 CLISyntaxError('Argument Syntax Error '),
208 '%s is missing a "="',
209 ' (usage: -o section.key=val)' % option])
210 section, sep, key = keypath.partition('.')
214 self._config_arg.value.override(
220 class FlagArgument(Argument):
222 :value: true if set, false otherwise
225 def __init__(self, help='', parsed_name=None, default=None):
226 super(FlagArgument, self).__init__(0, help, parsed_name, default)
229 class ValueArgument(Argument):
232 :value returns: given value or default
235 def __init__(self, help='', parsed_name=None, default=None):
236 super(ValueArgument, self).__init__(1, help, parsed_name, default)
239 class CommaSeparatedListArgument(ValueArgument):
242 :value returns: list of the comma separated values
247 return self._value or list()
250 def value(self, newvalue):
251 self._value = newvalue.split(',') if newvalue else list()
254 class IntArgument(ValueArgument):
258 """integer (type checking)"""
259 return getattr(self, '_value', self.default)
262 def value(self, newvalue):
263 if newvalue == self.default:
264 self._value = newvalue
267 if int(newvalue) == float(newvalue):
268 self._value = int(newvalue)
270 raise ValueError('Raise int argument error')
272 raiseCLIError(CLISyntaxError(
274 details=['Value %s not an int' % newvalue]))
277 class DataSizeArgument(ValueArgument):
278 """Input: a string of the form <number><unit>
279 Output: the number of bytes
280 Units: B, KiB, KB, MiB, MB, GiB, GB, TiB, TB
285 return getattr(self, '_value', self.default)
287 def _calculate_limit(self, user_input):
290 limit = int(user_input)
293 digits = ['%s' % num for num in range(0, 10)] + ['.']
294 while user_input[index] in digits:
296 limit = user_input[:index]
297 format = user_input[index:]
299 return to_bytes(limit, format)
300 except Exception as qe:
301 msg = 'Failed to convert %s to bytes' % user_input,
302 raiseCLIError(qe, msg, details=[
303 'Syntax: containerlimit set <limit>[format] [container]',
304 'e.g.,: containerlimit set 2.3GB mycontainer',
306 '(*1024): B, KiB, MiB, GiB, TiB',
307 '(*1000): B, KB, MB, GB, TB'])
311 def value(self, new_value):
313 self._value = self._calculate_limit(new_value)
316 class UserAccountArgument(ValueArgument):
317 """A user UUID or name (if uuid does not exist)"""
319 account_client = None
323 return super(UserAccountArgument, self).value
326 def value(self, uuid_or_name):
327 if uuid_or_name and self.account_client:
328 r = self.account_client.uuids2usernames([uuid_or_name, ])
330 self._value = uuid_or_name
332 r = self.account_client.usernames2uuids([uuid_or_name])
333 self._value = r.get(uuid_or_name) if r else None
335 raise raiseCLIError('User name or UUID not found', details=[
336 '%s is not a known username or UUID' % uuid_or_name,
337 'Usage: %s <USER_UUID | USERNAME>' % self.lvalue])
340 class DateArgument(ValueArgument):
342 DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
344 INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y']
348 v = getattr(self, '_value', self.default)
349 return mktime(v.timetuple()) if v else None
353 v = getattr(self, '_value', self.default)
354 return v.strftime(self.DATE_FORMAT) if v else None
358 return self.timestamp
361 def value(self, newvalue):
362 self._value = self.format_date(newvalue) if newvalue else self.default
364 def format_date(self, datestr):
365 for format in self.INPUT_FORMATS:
367 t = dtm.strptime(datestr, format)
370 return t # .strftime(self.DATE_FORMAT)
371 raiseCLIError(None, 'Date Argument Error', details=[
372 '%s not a valid date' % datestr,
373 'Correct formats:\n\t%s' % self.INPUT_FORMATS])
376 class VersionArgument(FlagArgument):
377 """A flag argument with that prints current version"""
382 return super(self.__class__, self).value
385 def value(self, newvalue):
386 self._value = newvalue
389 print('kamaki %s' % kamaki.__version__)
392 class RepeatableArgument(Argument):
393 """A value argument that can be repeated"""
395 def __init__(self, help='', parsed_name=None, default=None):
396 super(RepeatableArgument, self).__init__(
397 -1, help, parsed_name, default)
401 return getattr(self, '_value', [])
404 def value(self, newvalue):
405 self._value = newvalue
408 class KeyValueArgument(Argument):
409 """A Key=Value Argument that can be repeated
411 :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
414 def __init__(self, help='', parsed_name=None, default=None):
415 super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
420 :returns: (dict) {key1: val1, key2: val2, ...}
422 return getattr(self, '_value', {})
425 def value(self, keyvalue_pairs):
427 :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
430 self._value = self.value
432 for pair in keyvalue_pairs:
433 key, sep, val = pair.partition('=')
434 assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (
436 self._value[key] = val
437 except Exception as e:
438 raiseCLIError(e, 'KeyValueArgument Syntax Error')
441 class StatusArgument(ValueArgument):
442 """Initialize with valid_states=['list', 'of', 'states']
443 First state is the default"""
445 def __init__(self, *args, **kwargs):
446 self.valid_states = kwargs.pop('valid_states', ['BUILD', ])
447 super(StatusArgument, self).__init__(*args, **kwargs)
451 return getattr(self, '_value', None)
454 def value(self, new_status):
456 new_status = new_status.upper()
457 if new_status not in self.valid_states:
458 raise CLIInvalidArgument(
459 'Invalid argument %s' % new_status, details=[
461 '%s=[%s]' % (self.lvalue, '|'.join(self.valid_states))])
462 self._value = new_status
465 class ProgressBarArgument(FlagArgument):
466 """Manage a progress bar"""
468 def __init__(self, help='', parsed_name='', default=True):
469 self.suffix = '%(percent)d%%'
470 super(ProgressBarArgument, self).__init__(help, parsed_name, default)
473 """Get a modifiable copy of this bar"""
474 newarg = ProgressBarArgument(
475 self.help, self.parsed_name, self.default)
476 newarg._value = self._value
480 self, message, message_len=25, countdown=False, timeout=100):
481 """Get a generator to handle progress of the bar (gen.next())"""
485 self.bar = KamakiProgressBar(
486 message.ljust(message_len), max=timeout or 100)
491 bar_phases = list(self.bar.phases)
492 self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
494 self.bar.phases = bar_phases
495 self.bar.bar_prefix = ' '
496 self.bar.bar_suffix = ' '
497 self.bar.suffix = '%(remaining)ds to timeout'
499 self.bar.suffix = '%(percent)d%% - %(eta)ds'
503 for i in self.bar.iter(range(int(n))):
509 """Stop progress bar, return terminal cursor to user"""
512 mybar = getattr(self, 'bar', None)
519 cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
520 help=Argument(0, 'Show help message', ('-h', '--help')),
521 debug=FlagArgument('Include debug output', ('-d', '--debug')),
522 #include=FlagArgument(
523 # 'Include raw connection data in the output', ('-i', '--include')),
524 silent=FlagArgument('Do not output anything', ('-s', '--silent')),
525 verbose=FlagArgument('More info at response', ('-v', '--verbose')),
526 version=VersionArgument('Print current version', ('-V', '--version')),
527 options=RuntimeConfigArgument(
528 _config_arg, 'Override a config value', ('-o', '--options'))
532 # Initial command line interface arguments
535 class ArgumentParseManager(object):
536 """Manage (initialize and update) an ArgumentParser object"""
540 arguments=None, required=None, syntax=None, description=None,
541 check_required=True):
543 :param exe: (str) the basic command (e.g. 'kamaki')
545 :param arguments: (dict) if given, overrides the global _argument as
546 the parsers arguments specification
547 :param required: (list or tuple) an iterable of argument keys, denoting
548 which arguments are required. A tuple denoted an AND relation,
549 while a list denotes an OR relation e.g., ['a', 'b'] means that
550 either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
552 Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
553 this command required either 'a', or both 'b' and 'c', or one of
555 Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
556 ['b', 'c']] means that the command required either 'a' and 'b' or
557 'a' and 'c' or at least one of 'b', 'c' and could be written as
558 [('a', ['b', 'c']), ['b', 'c']]
559 :param syntax: (str) The basic syntax of the arguments. Default:
560 exe <cmd_group> [<cmd_subbroup> ...] <cmd>
561 :param description: (str) The description of the commands or ''
562 :param check_required: (bool) Set to False inorder not to check for
563 required argument values while parsing
565 self.parser = NoAbbrArgumentParser(
566 add_help=False, formatter_class=RawDescriptionHelpFormatter)
568 self.syntax = syntax or (
569 '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
570 self.required, self.check_required = required, check_required
571 self.parser.description = description or ''
573 self.arguments = arguments
576 self.arguments = _arguments
577 self._parser_modified, self._parsed, self._unparsed = False, None, None
581 def required2list(required):
582 if isinstance(required, list) or isinstance(required, tuple):
585 terms.append(ArgumentParseManager.required2list(r))
586 return list(set(terms).union())
590 def required2str(required, arguments, tab=''):
591 if isinstance(required, list):
592 return ' %sat least one of the following:\n%s' % (tab, ''.join(
593 [ArgumentParseManager.required2str(
594 r, arguments, tab + ' ') for r in required]))
595 elif isinstance(required, tuple):
596 return ' %sall of the following:\n%s' % (tab, ''.join(
597 [ArgumentParseManager.required2str(
598 r, arguments, tab + ' ') for r in required]))
600 lt_pn, lt_all, arg = 23, 80, arguments[required]
602 ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
604 ret += ' %s' % required.upper()
605 ret = ('{:<%s}' % lt_pn).format(ret)
606 prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
608 while arg.help[cur:]:
609 next = cur + lt_all - lt_pn
611 ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
612 cur, finish = next, '\n%s' % tab2
616 def _patch_with_required_args(arguments, required):
617 if isinstance(required, tuple):
618 return ' '.join([ArgumentParseManager._patch_with_required_args(
619 arguments, k) for k in required])
620 elif isinstance(required, list):
621 return '< %s >' % ' | '.join([
622 ArgumentParseManager._patch_with_required_args(
623 arguments, k) for k in required])
624 arg = arguments[required]
625 return '/'.join(arg.parsed_name) + (
626 ' %s [...]' % required.upper() if arg.arity < 0 else (
627 ' %s' % required.upper() if arg.arity else ''))
629 def print_help(self, out=stderr):
631 tmp_args = dict(self.arguments)
632 for term in self.required2list(self.required):
634 tmp_parser = ArgumentParseManager(self._exe, tmp_args)
635 tmp_parser.syntax = self.syntax + self._patch_with_required_args(
636 self.arguments, self.required)
637 tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
638 self.parser.description,
639 self.required2str(self.required, self.arguments))
640 tmp_parser.update_parser()
641 tmp_parser.parser.print_help()
643 self.parser.print_help()
647 """The command syntax (useful for help messages, descriptions, etc)"""
648 return self.parser.prog
651 def syntax(self, new_syntax):
652 self.parser.prog = new_syntax
656 """:returns: (dict) arguments the parser should be aware of"""
657 return self._arguments
660 def arguments(self, new_arguments):
661 assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
662 self._arguments = new_arguments
667 """(Namespace) parser-matched terms"""
668 if self._parser_modified:
674 """(list) parser-unmatched terms"""
675 if self._parser_modified:
677 return self._unparsed
679 def update_parser(self, arguments=None):
680 """Load argument specifications to parser
682 :param arguments: if not given, update self.arguments instead
684 arguments = arguments or self._arguments
686 for name, arg in arguments.items():
688 arg.update_parser(self.parser, name)
689 self._parser_modified = True
690 except ArgumentError:
693 def update_arguments(self, new_arguments):
694 """Add to / update existing arguments
696 :param new_arguments: (dict)
699 assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
700 self._arguments.update(new_arguments)
703 def _parse_required_arguments(self, required, parsed_args):
704 if not (self.check_required and required):
706 if isinstance(required, tuple):
707 for item in required:
708 if not self._parse_required_arguments(item, parsed_args):
711 elif isinstance(required, list):
712 for item in required:
713 if self._parse_required_arguments(item, parsed_args):
716 return required in parsed_args
718 def parse(self, new_args=None):
719 """Parse user input"""
721 pkargs = (new_args,) if new_args else ()
722 self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
724 k for k, v in vars(self._parsed).items() if v not in (None, )]
725 if not self._parse_required_arguments(self.required, parsed_args):
727 raise CLISyntaxError('Missing required arguments')
729 raiseCLIError(CLISyntaxError('Argument Syntax Error'))
730 for name, arg in self.arguments.items():
731 arg.value = getattr(self._parsed, name, arg.default)
733 for term in unparsed:
734 self._unparsed += split_input(' \'%s\' ' % term)
735 self._parser_modified = False