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 CLISyntaxError, raiseCLIError
36 from kamaki.cli.utils import split_input, to_bytes
38 from datetime import datetime as dtm
39 from time import mktime
40 from sys import stderr
42 from logging import getLogger
43 from argparse import ArgumentParser, ArgumentError
44 from argparse import RawDescriptionHelpFormatter
45 from progress.bar import ShadyBar as KamakiProgressBar
47 log = getLogger(__name__)
50 class Argument(object):
51 """An argument that can be parsed from command line or otherwise.
52 This is the top-level Argument class. It is suggested to extent this
53 class into more specific argument types.
55 lvalue_delimiter = '/'
57 def __init__(self, arity, help=None, parsed_name=None, default=None):
58 self.arity = int(arity)
59 self.help = '%s' % help or ''
61 assert parsed_name, 'No parsed name for argument %s' % self
62 self.parsed_name = list(parsed_name) if isinstance(
63 parsed_name, list) or isinstance(parsed_name, tuple) else (
64 '%s' % parsed_name).split()
65 for name in self.parsed_name:
66 assert name.count(' ') == 0, '%s: Invalid parse name "%s"' % (
68 msg = '%s: Invalid parse name "%s" should start with a "-"' % (
70 assert name.startswith('-'), msg
72 self.default = default or None
76 return getattr(self, '_value', self.default)
79 def value(self, newvalue):
80 self._value = newvalue
82 def update_parser(self, parser, name):
83 """Update argument parser with self info"""
84 action = 'append' if self.arity < 0 else (
85 'store' if self.arity else 'store_true')
88 dest=name, action=action, default=self.default, help=self.help)
92 """A printable form of the left value when calling an argument e.g.,
93 --left-value=right-value"""
94 return (self.lvalue_delimiter or ' ').join(self.parsed_name or [])
97 class ConfigArgument(Argument):
98 """Manage a kamaki configuration (file)"""
100 def __init__(self, help, parsed_name=('-c', '--config')):
101 super(ConfigArgument, self).__init__(1, help, parsed_name, None)
102 self.file_path = None
106 return getattr(self, '_value', None)
109 def value(self, config_file):
111 self._value = Config(config_file)
112 self.file_path = config_file
114 self._value = Config(self.file_path)
116 self._value = Config()
118 def get(self, group, term):
119 """Get a configuration setting from the Config object"""
120 return self.value.get(group, term)
126 return [term[:-slen] for term in self.value.keys('global') if (
127 term.endswith(suffix))]
133 return [(k[:-slen], v) for k, v in self.value.items('global') if (
136 def get_global(self, option):
137 return self.value.get('global', option)
139 def get_cloud(self, cloud, option):
140 return self.value.get_cloud(cloud, option)
143 _config_arg = ConfigArgument('Path to config file')
146 class RuntimeConfigArgument(Argument):
147 """Set a run-time setting option (not persistent)"""
149 def __init__(self, config_arg, help='', parsed_name=None, default=None):
150 super(self.__class__, self).__init__(1, help, parsed_name, default)
151 self._config_arg = config_arg
155 return super(RuntimeConfigArgument, self).value
158 def value(self, options):
159 if options == self.default:
161 if not isinstance(options, list):
162 options = ['%s' % options]
163 for option in options:
164 keypath, sep, val = option.partition('=')
167 CLISyntaxError('Argument Syntax Error '),
169 '%s is missing a "="',
170 ' (usage: -o section.key=val)' % option])
171 section, sep, key = keypath.partition('.')
175 self._config_arg.value.override(
181 class FlagArgument(Argument):
183 :value: true if set, false otherwise
186 def __init__(self, help='', parsed_name=None, default=None):
187 super(FlagArgument, self).__init__(0, help, parsed_name, default)
190 class ValueArgument(Argument):
193 :value returns: given value or default
196 def __init__(self, help='', parsed_name=None, default=None):
197 super(ValueArgument, self).__init__(1, help, parsed_name, default)
200 class CommaSeparatedListArgument(ValueArgument):
203 :value returns: list of the comma separated values
208 return self._value or list()
211 def value(self, newvalue):
212 self._value = newvalue.split(',') if newvalue else list()
215 class IntArgument(ValueArgument):
219 """integer (type checking)"""
220 return getattr(self, '_value', self.default)
223 def value(self, newvalue):
224 if newvalue == self.default:
225 self._value = newvalue
228 if int(newvalue) == float(newvalue):
229 self._value = int(newvalue)
231 raise ValueError('Raise int argument error')
233 raiseCLIError(CLISyntaxError(
235 details=['Value %s not an int' % newvalue]))
238 class DataSizeArgument(ValueArgument):
239 """Input: a string of the form <number><unit>
240 Output: the number of bytes
241 Units: B, KiB, KB, MiB, MB, GiB, GB, TiB, TB
246 return getattr(self, '_value', self.default)
248 def _calculate_limit(self, user_input):
251 limit = int(user_input)
254 digits = [str(num) for num in range(0, 10)] + ['.']
255 while user_input[index] in digits:
257 limit = user_input[:index]
258 format = user_input[index:]
260 return to_bytes(limit, format)
261 except Exception as qe:
262 msg = 'Failed to convert %s to bytes' % user_input,
263 raiseCLIError(qe, msg, details=[
264 'Syntax: containerlimit set <limit>[format] [container]',
265 'e.g.,: containerlimit set 2.3GB mycontainer',
267 '(*1024): B, KiB, MiB, GiB, TiB',
268 '(*1000): B, KB, MB, GB, TB'])
272 def value(self, new_value):
274 self._value = self._calculate_limit(new_value)
277 class DateArgument(ValueArgument):
279 DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
281 INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y']
285 v = getattr(self, '_value', self.default)
286 return mktime(v.timetuple()) if v else None
290 v = getattr(self, '_value', self.default)
291 return v.strftime(self.DATE_FORMAT) if v else None
295 return self.timestamp
298 def value(self, newvalue):
299 self._value = self.format_date(newvalue) if newvalue else self.default
301 def format_date(self, datestr):
302 for format in self.INPUT_FORMATS:
304 t = dtm.strptime(datestr, format)
307 return t # .strftime(self.DATE_FORMAT)
308 raiseCLIError(None, 'Date Argument Error', details=[
309 '%s not a valid date' % datestr,
310 'Correct formats:\n\t%s' % self.INPUT_FORMATS])
313 class VersionArgument(FlagArgument):
314 """A flag argument with that prints current version"""
319 return super(self.__class__, self).value
322 def value(self, newvalue):
323 self._value = newvalue
326 print('kamaki %s' % kamaki.__version__)
329 class RepeatableArgument(Argument):
330 """A value argument that can be repeated"""
332 def __init__(self, help='', parsed_name=None, default=None):
333 super(RepeatableArgument, self).__init__(
334 -1, help, parsed_name, default)
338 return getattr(self, '_value', [])
341 def value(self, newvalue):
342 self._value = newvalue
345 class KeyValueArgument(Argument):
346 """A Key=Value Argument that can be repeated
348 :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
351 def __init__(self, help='', parsed_name=None, default=None):
352 super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
357 :returns: (dict) {key1: val1, key2: val2, ...}
359 return getattr(self, '_value', {})
362 def value(self, keyvalue_pairs):
364 :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
367 self._value = self.value
369 for pair in keyvalue_pairs:
370 key, sep, val = pair.partition('=')
371 assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (
373 self._value[key] = val
374 except Exception as e:
375 raiseCLIError(e, 'KeyValueArgument Syntax Error')
378 class ProgressBarArgument(FlagArgument):
379 """Manage a progress bar"""
381 def __init__(self, help='', parsed_name='', default=True):
382 self.suffix = '%(percent)d%%'
383 super(ProgressBarArgument, self).__init__(help, parsed_name, default)
386 """Get a modifiable copy of this bar"""
387 newarg = ProgressBarArgument(
388 self.help, self.parsed_name, self.default)
389 newarg._value = self._value
393 self, message, message_len=25, countdown=False, timeout=100):
394 """Get a generator to handle progress of the bar (gen.next())"""
398 self.bar = KamakiProgressBar(
399 message.ljust(message_len), max=timeout or 100)
404 bar_phases = list(self.bar.phases)
405 self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
407 self.bar.phases = bar_phases
408 self.bar.bar_prefix = ' '
409 self.bar.bar_suffix = ' '
410 self.bar.suffix = '%(remaining)ds to timeout'
412 self.bar.suffix = '%(percent)d%% - %(eta)ds'
416 for i in self.bar.iter(range(int(n))):
422 """Stop progress bar, return terminal cursor to user"""
425 mybar = getattr(self, 'bar', None)
432 cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
433 help=Argument(0, 'Show help message', ('-h', '--help')),
434 debug=FlagArgument('Include debug output', ('-d', '--debug')),
435 #include=FlagArgument(
436 # 'Include raw connection data in the output', ('-i', '--include')),
437 silent=FlagArgument('Do not output anything', ('-s', '--silent')),
438 verbose=FlagArgument('More info at response', ('-v', '--verbose')),
439 version=VersionArgument('Print current version', ('-V', '--version')),
440 options=RuntimeConfigArgument(
441 _config_arg, 'Override a config value', ('-o', '--options'))
445 # Initial command line interface arguments
448 class ArgumentParseManager(object):
449 """Manage (initialize and update) an ArgumentParser object"""
453 arguments=None, required=None, syntax=None, description=None,
454 check_required=True):
456 :param exe: (str) the basic command (e.g. 'kamaki')
458 :param arguments: (dict) if given, overrides the global _argument as
459 the parsers arguments specification
460 :param required: (list or tuple) an iterable of argument keys, denoting
461 which arguments are required. A tuple denoted an AND relation,
462 while a list denotes an OR relation e.g., ['a', 'b'] means that
463 either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
465 Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
466 this command required either 'a', or both 'b' and 'c', or one of
468 Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
469 ['b', 'c']] means that the command required either 'a' and 'b' or
470 'a' and 'c' or at least one of 'b', 'c' and could be written as
471 [('a', ['b', 'c']), ['b', 'c']]
472 :param syntax: (str) The basic syntax of the arguments. Default:
473 exe <cmd_group> [<cmd_subbroup> ...] <cmd>
474 :param description: (str) The description of the commands or ''
475 :param check_required: (bool) Set to False inorder not to check for
476 required argument values while parsing
478 self.parser = ArgumentParser(
479 add_help=False, formatter_class=RawDescriptionHelpFormatter)
481 self.syntax = syntax or (
482 '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
483 self.required, self.check_required = required, check_required
484 self.parser.description = description or ''
486 self.arguments = arguments
489 self.arguments = _arguments
490 self._parser_modified, self._parsed, self._unparsed = False, None, None
494 def required2list(required):
495 if isinstance(required, list) or isinstance(required, tuple):
498 terms.append(ArgumentParseManager.required2list(r))
499 return list(set(terms).union())
503 def required2str(required, arguments, tab=''):
504 if isinstance(required, list):
505 return ' %sat least one of the following:\n%s' % (tab, ''.join(
506 [ArgumentParseManager.required2str(
507 r, arguments, tab + ' ') for r in required]))
508 elif isinstance(required, tuple):
509 return ' %sall of the following:\n%s' % (tab, ''.join(
510 [ArgumentParseManager.required2str(
511 r, arguments, tab + ' ') for r in required]))
513 lt_pn, lt_all, arg = 23, 80, arguments[required]
515 ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
517 ret += ' %s' % required.upper()
518 ret = ('{:<%s}' % lt_pn).format(ret)
519 prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
521 while arg.help[cur:]:
522 next = cur + lt_all - lt_pn
524 ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
525 cur, finish = next, '\n%s' % tab2
529 def _patch_with_required_args(arguments, required):
530 if isinstance(required, tuple):
531 return ' '.join([ArgumentParseManager._patch_with_required_args(
532 arguments, k) for k in required])
533 elif isinstance(required, list):
534 return '< %s >' % ' | '.join([
535 ArgumentParseManager._patch_with_required_args(
536 arguments, k) for k in required])
537 arg = arguments[required]
538 return '/'.join(arg.parsed_name) + (
539 ' %s [...]' % required.upper() if arg.arity < 0 else (
540 ' %s' % required.upper() if arg.arity else ''))
542 def print_help(self, out=stderr):
544 tmp_args = dict(self.arguments)
545 for term in self.required2list(self.required):
547 tmp_parser = ArgumentParseManager(self._exe, tmp_args)
548 tmp_parser.syntax = self.syntax + self._patch_with_required_args(
549 self.arguments, self.required)
550 tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
551 self.parser.description,
552 self.required2str(self.required, self.arguments))
553 tmp_parser.update_parser()
554 tmp_parser.parser.print_help()
556 self.parser.print_help()
560 """The command syntax (useful for help messages, descriptions, etc)"""
561 return self.parser.prog
564 def syntax(self, new_syntax):
565 self.parser.prog = new_syntax
569 """:returns: (dict) arguments the parser should be aware of"""
570 return self._arguments
573 def arguments(self, new_arguments):
574 assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
575 self._arguments = new_arguments
580 """(Namespace) parser-matched terms"""
581 if self._parser_modified:
587 """(list) parser-unmatched terms"""
588 if self._parser_modified:
590 return self._unparsed
592 def update_parser(self, arguments=None):
593 """Load argument specifications to parser
595 :param arguments: if not given, update self.arguments instead
597 arguments = arguments or self._arguments
599 for name, arg in arguments.items():
601 arg.update_parser(self.parser, name)
602 self._parser_modified = True
603 except ArgumentError:
606 def update_arguments(self, new_arguments):
607 """Add to / update existing arguments
609 :param new_arguments: (dict)
612 assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
613 self._arguments.update(new_arguments)
616 def _parse_required_arguments(self, required, parsed_args):
617 if not (self.check_required and required):
619 if isinstance(required, tuple):
620 for item in required:
621 if not self._parse_required_arguments(item, parsed_args):
624 if isinstance(required, list):
625 for item in required:
626 if self._parse_required_arguments(item, parsed_args):
629 return required in parsed_args
631 def parse(self, new_args=None):
632 """Parse user input"""
634 pkargs = (new_args,) if new_args else ()
635 self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
637 k for k, v in vars(self._parsed).items() if v not in (None, )]
638 if not self._parse_required_arguments(self.required, parsed_args):
640 raise CLISyntaxError('Missing required arguments')
642 raiseCLIError(CLISyntaxError('Argument Syntax Error'))
643 for name, arg in self.arguments.items():
644 arg.value = getattr(self._parsed, name, arg.default)
646 for term in unparsed:
647 self._unparsed += split_input(' \'%s\' ' % term)
648 self._parser_modified = False