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 super(ConfigArgument, self).value
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()
403 bar_phases = list(self.bar.phases)
404 self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
406 self.bar.phases = bar_phases
407 self.bar.bar_prefix = ' '
408 self.bar.bar_suffix = ' '
409 self.bar.max = timeout or 100
410 self.bar.suffix = '%(remaining)ds to timeout'
412 self.bar.suffix = '%(percent)d%% - %(eta)ds'
413 self.bar.eta = timeout or 100
414 self.bar.message = message.ljust(message_len)
418 for i in self.bar.iter(range(int(n))):
424 """Stop progress bar, return terminal cursor to user"""
427 mybar = getattr(self, 'bar', None)
434 cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
435 help=Argument(0, 'Show help message', ('-h', '--help')),
436 debug=FlagArgument('Include debug output', ('-d', '--debug')),
437 #include=FlagArgument(
438 # 'Include raw connection data in the output', ('-i', '--include')),
439 silent=FlagArgument('Do not output anything', ('-s', '--silent')),
440 verbose=FlagArgument('More info at response', ('-v', '--verbose')),
441 version=VersionArgument('Print current version', ('-V', '--version')),
442 options=RuntimeConfigArgument(
443 _config_arg, 'Override a config value', ('-o', '--options'))
447 # Initial command line interface arguments
450 class ArgumentParseManager(object):
451 """Manage (initialize and update) an ArgumentParser object"""
455 arguments=None, required=None, syntax=None, description=None):
457 :param exe: (str) the basic command (e.g. 'kamaki')
459 :param arguments: (dict) if given, overrides the global _argument as
460 the parsers arguments specification
461 :param required: (list or tuple) an iterable of argument keys, denoting
462 which arguments are required. A tuple denoted an AND relation,
463 while a list denotes an OR relation e.g., ['a', 'b'] means that
464 either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
466 Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
467 this command required either 'a', or both 'b' and 'c', or one of
469 Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
470 ['b', 'c']] means that the command required either 'a' and 'b' or
471 'a' and 'c' or at least one of 'b', 'c' and could be written as
472 [('a', ['b', 'c']), ['b', 'c']]
473 :param syntax: (str) The basic syntax of the arguments. Default:
474 exe <cmd_group> [<cmd_subbroup> ...] <cmd>
475 :param description: (str) The description of the commands or ''
477 self.parser = ArgumentParser(
478 add_help=False, formatter_class=RawDescriptionHelpFormatter)
480 self.syntax = syntax or (
481 '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
482 self.required = required
483 self.parser.description = description or ''
485 self.arguments = arguments
488 self.arguments = _arguments
489 self._parser_modified, self._parsed, self._unparsed = False, None, None
493 def required2list(required):
494 if isinstance(required, list) or isinstance(required, tuple):
497 terms.append(ArgumentParseManager.required2list(r))
498 return list(set(terms).union())
502 def required2str(required, arguments, tab=''):
503 if isinstance(required, list):
504 return ' %sat least one of the following:\n%s' % (tab, ''.join(
505 [ArgumentParseManager.required2str(
506 r, arguments, tab + ' ') for r in required]))
507 elif isinstance(required, tuple):
508 return ' %sall of the following:\n%s' % (tab, ''.join(
509 [ArgumentParseManager.required2str(
510 r, arguments, tab + ' ') for r in required]))
512 lt_pn, lt_all, arg = 23, 80, arguments[required]
514 ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
516 ret += ' %s' % required.upper()
517 ret = ('{:<%s}' % lt_pn).format(ret)
518 prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
520 while arg.help[cur:]:
521 next = cur + lt_all - lt_pn
523 ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
524 cur, finish = next, '\n%s' % tab2
528 def _patch_with_required_args(arguments, required):
529 if isinstance(required, tuple):
530 return ' '.join([ArgumentParseManager._patch_with_required_args(
531 arguments, k) for k in required])
532 elif isinstance(required, list):
533 return '< %s >' % ' | '.join([
534 ArgumentParseManager._patch_with_required_args(
535 arguments, k) for k in required])
536 arg = arguments[required]
537 return '/'.join(arg.parsed_name) + (
538 ' %s [...]' % required.upper() if arg.arity < 0 else (
539 ' %s' % required.upper() if arg.arity else ''))
541 def print_help(self, out=stderr):
543 tmp_args = dict(self.arguments)
544 for term in self.required2list(self.required):
546 tmp_parser = ArgumentParseManager(self._exe, tmp_args)
547 tmp_parser.syntax = self.syntax + self._patch_with_required_args(
548 self.arguments, self.required)
549 tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
550 self.parser.description,
551 self.required2str(self.required, self.arguments))
552 tmp_parser.update_parser()
553 tmp_parser.parser.print_help()
555 self.parser.print_help()
559 """The command syntax (useful for help messages, descriptions, etc)"""
560 return self.parser.prog
563 def syntax(self, new_syntax):
564 self.parser.prog = new_syntax
568 """:returns: (dict) arguments the parser should be aware of"""
569 return self._arguments
572 def arguments(self, new_arguments):
573 assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
574 self._arguments = new_arguments
579 """(Namespace) parser-matched terms"""
580 if self._parser_modified:
586 """(list) parser-unmatched terms"""
587 if self._parser_modified:
589 return self._unparsed
591 def update_parser(self, arguments=None):
592 """Load argument specifications to parser
594 :param arguments: if not given, update self.arguments instead
596 arguments = arguments or self._arguments
598 for name, arg in arguments.items():
600 arg.update_parser(self.parser, name)
601 self._parser_modified = True
602 except ArgumentError:
605 def update_arguments(self, new_arguments):
606 """Add to / update existing arguments
608 :param new_arguments: (dict)
611 assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
612 self._arguments.update(new_arguments)
615 def _parse_required_arguments(self, required, parsed_args):
618 if isinstance(required, tuple):
619 for item in required:
620 if not self._parse_required_arguments(item, parsed_args):
623 if isinstance(required, list):
624 for item in required:
625 if self._parse_required_arguments(item, parsed_args):
628 return required in parsed_args
630 def parse(self, new_args=None):
631 """Parse user input"""
633 pkargs = (new_args,) if new_args else ()
634 self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
636 k for k, v in vars(self._parsed).items() if v not in (None, )]
637 if not self._parse_required_arguments(self.required, parsed_args):
639 raise CLISyntaxError('Missing required arguments')
641 raiseCLIError(CLISyntaxError('Argument Syntax Error'))
642 for name, arg in self.arguments.items():
643 arg.value = getattr(self._parsed, name, arg.default)
645 for term in unparsed:
646 self._unparsed += split_input(' \'%s\' ' % term)
647 self._parser_modified = False