1 # Copyright 2012-2013 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 ArgumentParser, ArgumentError
45 from argparse import RawDescriptionHelpFormatter
46 from progress.bar import ShadyBar as KamakiProgressBar
48 log = getLogger(__name__)
51 class Argument(object):
52 """An argument that can be parsed from command line or otherwise.
53 This is the top-level Argument class. It is suggested to extent this
54 class into more specific argument types.
56 lvalue_delimiter = '/'
58 def __init__(self, arity, help=None, parsed_name=None, default=None):
59 self.arity = int(arity)
60 self.help = '%s' % help or ''
62 assert parsed_name, 'No parsed name for argument %s' % self
63 self.parsed_name = list(parsed_name) if isinstance(
64 parsed_name, list) or isinstance(parsed_name, tuple) else (
65 '%s' % parsed_name).split()
66 for name in self.parsed_name:
67 assert name.count(' ') == 0, '%s: Invalid parse name "%s"' % (
69 msg = '%s: Invalid parse name "%s" should start with a "-"' % (
71 assert name.startswith('-'), msg
73 self.default = default or None
77 return getattr(self, '_value', self.default)
80 def value(self, newvalue):
81 self._value = newvalue
83 def update_parser(self, parser, name):
84 """Update argument parser with self info"""
85 action = 'append' if self.arity < 0 else (
86 'store' if self.arity else 'store_true')
89 dest=name, action=action, default=self.default, help=self.help)
93 """A printable form of the left value when calling an argument e.g.,
94 --left-value=right-value"""
95 return (self.lvalue_delimiter or ' ').join(self.parsed_name or [])
98 class ConfigArgument(Argument):
99 """Manage a kamaki configuration (file)"""
101 def __init__(self, help, parsed_name=('-c', '--config')):
102 super(ConfigArgument, self).__init__(1, help, parsed_name, None)
103 self.file_path = None
107 return getattr(self, '_value', None)
110 def value(self, config_file):
112 self._value = Config(config_file)
113 self.file_path = config_file
115 self._value = Config(self.file_path)
117 self._value = Config()
119 def get(self, group, term):
120 """Get a configuration setting from the Config object"""
121 return self.value.get(group, term)
127 return [term[:-slen] for term in self.value.keys('global') if (
128 term.endswith(suffix))]
134 return [(k[:-slen], v) for k, v in self.value.items('global') if (
137 def get_global(self, option):
138 return self.value.get('global', option)
140 def get_cloud(self, cloud, option):
141 return self.value.get_cloud(cloud, option)
144 _config_arg = ConfigArgument('Path to config file')
147 class RuntimeConfigArgument(Argument):
148 """Set a run-time setting option (not persistent)"""
150 def __init__(self, config_arg, help='', parsed_name=None, default=None):
151 super(self.__class__, self).__init__(1, help, parsed_name, default)
152 self._config_arg = config_arg
156 return super(RuntimeConfigArgument, self).value
159 def value(self, options):
160 if options == self.default:
162 if not isinstance(options, list):
163 options = ['%s' % options]
164 for option in options:
165 keypath, sep, val = option.partition('=')
168 CLISyntaxError('Argument Syntax Error '),
170 '%s is missing a "="',
171 ' (usage: -o section.key=val)' % option])
172 section, sep, key = keypath.partition('.')
176 self._config_arg.value.override(
182 class FlagArgument(Argument):
184 :value: true if set, false otherwise
187 def __init__(self, help='', parsed_name=None, default=None):
188 super(FlagArgument, self).__init__(0, help, parsed_name, default)
191 class ValueArgument(Argument):
194 :value returns: given value or default
197 def __init__(self, help='', parsed_name=None, default=None):
198 super(ValueArgument, self).__init__(1, help, parsed_name, default)
201 class CommaSeparatedListArgument(ValueArgument):
204 :value returns: list of the comma separated values
209 return self._value or list()
212 def value(self, newvalue):
213 self._value = newvalue.split(',') if newvalue else list()
216 class IntArgument(ValueArgument):
220 """integer (type checking)"""
221 return getattr(self, '_value', self.default)
224 def value(self, newvalue):
225 if newvalue == self.default:
226 self._value = newvalue
229 if int(newvalue) == float(newvalue):
230 self._value = int(newvalue)
232 raise ValueError('Raise int argument error')
234 raiseCLIError(CLISyntaxError(
236 details=['Value %s not an int' % newvalue]))
239 class DataSizeArgument(ValueArgument):
240 """Input: a string of the form <number><unit>
241 Output: the number of bytes
242 Units: B, KiB, KB, MiB, MB, GiB, GB, TiB, TB
247 return getattr(self, '_value', self.default)
249 def _calculate_limit(self, user_input):
252 limit = int(user_input)
255 digits = [str(num) for num in range(0, 10)] + ['.']
256 while user_input[index] in digits:
258 limit = user_input[:index]
259 format = user_input[index:]
261 return to_bytes(limit, format)
262 except Exception as qe:
263 msg = 'Failed to convert %s to bytes' % user_input,
264 raiseCLIError(qe, msg, details=[
265 'Syntax: containerlimit set <limit>[format] [container]',
266 'e.g.,: containerlimit set 2.3GB mycontainer',
268 '(*1024): B, KiB, MiB, GiB, TiB',
269 '(*1000): B, KB, MB, GB, TB'])
273 def value(self, new_value):
275 self._value = self._calculate_limit(new_value)
278 class DateArgument(ValueArgument):
280 DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
282 INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y']
286 v = getattr(self, '_value', self.default)
287 return mktime(v.timetuple()) if v else None
291 v = getattr(self, '_value', self.default)
292 return v.strftime(self.DATE_FORMAT) if v else None
296 return self.timestamp
299 def value(self, newvalue):
300 self._value = self.format_date(newvalue) if newvalue else self.default
302 def format_date(self, datestr):
303 for format in self.INPUT_FORMATS:
305 t = dtm.strptime(datestr, format)
308 return t # .strftime(self.DATE_FORMAT)
309 raiseCLIError(None, 'Date Argument Error', details=[
310 '%s not a valid date' % datestr,
311 'Correct formats:\n\t%s' % self.INPUT_FORMATS])
314 class VersionArgument(FlagArgument):
315 """A flag argument with that prints current version"""
320 return super(self.__class__, self).value
323 def value(self, newvalue):
324 self._value = newvalue
327 print('kamaki %s' % kamaki.__version__)
330 class RepeatableArgument(Argument):
331 """A value argument that can be repeated"""
333 def __init__(self, help='', parsed_name=None, default=None):
334 super(RepeatableArgument, self).__init__(
335 -1, help, parsed_name, default)
339 return getattr(self, '_value', [])
342 def value(self, newvalue):
343 self._value = newvalue
346 class KeyValueArgument(Argument):
347 """A Key=Value Argument that can be repeated
349 :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
352 def __init__(self, help='', parsed_name=None, default=None):
353 super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
358 :returns: (dict) {key1: val1, key2: val2, ...}
360 return getattr(self, '_value', {})
363 def value(self, keyvalue_pairs):
365 :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
368 self._value = self.value
370 for pair in keyvalue_pairs:
371 key, sep, val = pair.partition('=')
372 assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (
374 self._value[key] = val
375 except Exception as e:
376 raiseCLIError(e, 'KeyValueArgument Syntax Error')
379 class StatusArgument(ValueArgument):
380 """Initialize with valid_states=['list', 'of', 'states']
381 First state is the default"""
383 def __init__(self, *args, **kwargs):
384 self.valid_states = kwargs.pop('valid_states', ['BUILD', ])
385 super(StatusArgument, self).__init__(*args, **kwargs)
389 return getattr(self, '_value', None)
392 def value(self, new_status):
394 new_status = new_status.upper()
395 if new_status not in self.valid_states:
396 raise CLIInvalidArgument(
397 'Invalid argument %s' % new_status, details=[
399 '%s=[%s]' % (self.lvalue, '|'.join(self.valid_states))])
400 self._value = new_status
403 class ProgressBarArgument(FlagArgument):
404 """Manage a progress bar"""
406 def __init__(self, help='', parsed_name='', default=True):
407 self.suffix = '%(percent)d%%'
408 super(ProgressBarArgument, self).__init__(help, parsed_name, default)
411 """Get a modifiable copy of this bar"""
412 newarg = ProgressBarArgument(
413 self.help, self.parsed_name, self.default)
414 newarg._value = self._value
418 self, message, message_len=25, countdown=False, timeout=100):
419 """Get a generator to handle progress of the bar (gen.next())"""
423 self.bar = KamakiProgressBar(
424 message.ljust(message_len), max=timeout or 100)
429 bar_phases = list(self.bar.phases)
430 self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
432 self.bar.phases = bar_phases
433 self.bar.bar_prefix = ' '
434 self.bar.bar_suffix = ' '
435 self.bar.suffix = '%(remaining)ds to timeout'
437 self.bar.suffix = '%(percent)d%% - %(eta)ds'
441 for i in self.bar.iter(range(int(n))):
447 """Stop progress bar, return terminal cursor to user"""
450 mybar = getattr(self, 'bar', None)
457 cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
458 help=Argument(0, 'Show help message', ('-h', '--help')),
459 debug=FlagArgument('Include debug output', ('-d', '--debug')),
460 #include=FlagArgument(
461 # 'Include raw connection data in the output', ('-i', '--include')),
462 silent=FlagArgument('Do not output anything', ('-s', '--silent')),
463 verbose=FlagArgument('More info at response', ('-v', '--verbose')),
464 version=VersionArgument('Print current version', ('-V', '--version')),
465 options=RuntimeConfigArgument(
466 _config_arg, 'Override a config value', ('-o', '--options'))
470 # Initial command line interface arguments
473 class ArgumentParseManager(object):
474 """Manage (initialize and update) an ArgumentParser object"""
478 arguments=None, required=None, syntax=None, description=None,
479 check_required=True):
481 :param exe: (str) the basic command (e.g. 'kamaki')
483 :param arguments: (dict) if given, overrides the global _argument as
484 the parsers arguments specification
485 :param required: (list or tuple) an iterable of argument keys, denoting
486 which arguments are required. A tuple denoted an AND relation,
487 while a list denotes an OR relation e.g., ['a', 'b'] means that
488 either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
490 Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
491 this command required either 'a', or both 'b' and 'c', or one of
493 Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
494 ['b', 'c']] means that the command required either 'a' and 'b' or
495 'a' and 'c' or at least one of 'b', 'c' and could be written as
496 [('a', ['b', 'c']), ['b', 'c']]
497 :param syntax: (str) The basic syntax of the arguments. Default:
498 exe <cmd_group> [<cmd_subbroup> ...] <cmd>
499 :param description: (str) The description of the commands or ''
500 :param check_required: (bool) Set to False inorder not to check for
501 required argument values while parsing
503 self.parser = ArgumentParser(
504 add_help=False, formatter_class=RawDescriptionHelpFormatter)
506 self.syntax = syntax or (
507 '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
508 self.required, self.check_required = required, check_required
509 self.parser.description = description or ''
511 self.arguments = arguments
514 self.arguments = _arguments
515 self._parser_modified, self._parsed, self._unparsed = False, None, None
519 def required2list(required):
520 if isinstance(required, list) or isinstance(required, tuple):
523 terms.append(ArgumentParseManager.required2list(r))
524 return list(set(terms).union())
528 def required2str(required, arguments, tab=''):
529 if isinstance(required, list):
530 return ' %sat least one of the following:\n%s' % (tab, ''.join(
531 [ArgumentParseManager.required2str(
532 r, arguments, tab + ' ') for r in required]))
533 elif isinstance(required, tuple):
534 return ' %sall of the following:\n%s' % (tab, ''.join(
535 [ArgumentParseManager.required2str(
536 r, arguments, tab + ' ') for r in required]))
538 lt_pn, lt_all, arg = 23, 80, arguments[required]
540 ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
542 ret += ' %s' % required.upper()
543 ret = ('{:<%s}' % lt_pn).format(ret)
544 prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
546 while arg.help[cur:]:
547 next = cur + lt_all - lt_pn
549 ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
550 cur, finish = next, '\n%s' % tab2
554 def _patch_with_required_args(arguments, required):
555 if isinstance(required, tuple):
556 return ' '.join([ArgumentParseManager._patch_with_required_args(
557 arguments, k) for k in required])
558 elif isinstance(required, list):
559 return '< %s >' % ' | '.join([
560 ArgumentParseManager._patch_with_required_args(
561 arguments, k) for k in required])
562 arg = arguments[required]
563 return '/'.join(arg.parsed_name) + (
564 ' %s [...]' % required.upper() if arg.arity < 0 else (
565 ' %s' % required.upper() if arg.arity else ''))
567 def print_help(self, out=stderr):
569 tmp_args = dict(self.arguments)
570 for term in self.required2list(self.required):
572 tmp_parser = ArgumentParseManager(self._exe, tmp_args)
573 tmp_parser.syntax = self.syntax + self._patch_with_required_args(
574 self.arguments, self.required)
575 tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
576 self.parser.description,
577 self.required2str(self.required, self.arguments))
578 tmp_parser.update_parser()
579 tmp_parser.parser.print_help()
581 self.parser.print_help()
585 """The command syntax (useful for help messages, descriptions, etc)"""
586 return self.parser.prog
589 def syntax(self, new_syntax):
590 self.parser.prog = new_syntax
594 """:returns: (dict) arguments the parser should be aware of"""
595 return self._arguments
598 def arguments(self, new_arguments):
599 assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
600 self._arguments = new_arguments
605 """(Namespace) parser-matched terms"""
606 if self._parser_modified:
612 """(list) parser-unmatched terms"""
613 if self._parser_modified:
615 return self._unparsed
617 def update_parser(self, arguments=None):
618 """Load argument specifications to parser
620 :param arguments: if not given, update self.arguments instead
622 arguments = arguments or self._arguments
624 for name, arg in arguments.items():
626 arg.update_parser(self.parser, name)
627 self._parser_modified = True
628 except ArgumentError:
631 def update_arguments(self, new_arguments):
632 """Add to / update existing arguments
634 :param new_arguments: (dict)
637 assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
638 self._arguments.update(new_arguments)
641 def _parse_required_arguments(self, required, parsed_args):
642 if not (self.check_required and required):
644 if isinstance(required, tuple):
645 for item in required:
646 if not self._parse_required_arguments(item, parsed_args):
649 if isinstance(required, list):
650 for item in required:
651 if self._parse_required_arguments(item, parsed_args):
654 return required in parsed_args
656 def parse(self, new_args=None):
657 """Parse user input"""
659 pkargs = (new_args,) if new_args else ()
660 self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
662 k for k, v in vars(self._parsed).items() if v not in (None, )]
663 if not self._parse_required_arguments(self.required, parsed_args):
665 raise CLISyntaxError('Missing required arguments')
667 raiseCLIError(CLISyntaxError('Argument Syntax Error'))
668 for name, arg in self.arguments.items():
669 arg.value = getattr(self._parsed, name, arg.default)
671 for term in unparsed:
672 self._unparsed += split_input(' \'%s\' ' % term)
673 self._parser_modified = False