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
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.
56 def __init__(self, arity, help=None, parsed_name=None, default=None):
57 self.arity = int(arity)
58 self.help = '%s' % help or ''
60 assert parsed_name, 'No parsed name for argument %s' % self
61 self.parsed_name = list(parsed_name) if isinstance(
62 parsed_name, list) or isinstance(parsed_name, tuple) else (
63 '%s' % parsed_name).split()
64 for name in self.parsed_name:
65 assert name.count(' ') == 0, '%s: Invalid parse name "%s"' % (
67 msg = '%s: Invalid parse name "%s" should start with a "-"' % (
69 assert name.startswith('-'), msg
71 self.default = default if (default or self.arity) else False
75 return getattr(self, '_value', self.default)
78 def value(self, newvalue):
79 self._value = newvalue
81 def update_parser(self, parser, name):
82 """Update argument parser with self info"""
83 action = 'append' if self.arity < 0 else (
84 'store' if self.arity else 'store_true')
87 dest=name, action=action, default=self.default, help=self.help)
90 class ConfigArgument(Argument):
91 """Manage a kamaki configuration (file)"""
93 def __init__(self, help, parsed_name=('-c', '--config')):
94 super(ConfigArgument, self).__init__(1, help, parsed_name, None)
99 return super(ConfigArgument, self).value
102 def value(self, config_file):
104 self._value = Config(config_file)
105 self.file_path = config_file
107 self._value = Config(self.file_path)
109 self._value = Config()
111 def get(self, group, term):
112 """Get a configuration setting from the Config object"""
113 return self.value.get(group, term)
119 return [term[:-slen] for term in self.value.keys('global') if (
120 term.endswith(suffix))]
126 return [(k[:-slen], v) for k, v in self.value.items('global') if (
129 def get_global(self, option):
130 return self.value.get('global', option)
132 def get_cloud(self, cloud, option):
133 return self.value.get_cloud(cloud, option)
136 _config_arg = ConfigArgument('Path to config file')
139 class RuntimeConfigArgument(Argument):
140 """Set a run-time setting option (not persistent)"""
142 def __init__(self, config_arg, help='', parsed_name=None, default=None):
143 super(self.__class__, self).__init__(1, help, parsed_name, default)
144 self._config_arg = config_arg
148 return super(RuntimeConfigArgument, self).value
151 def value(self, options):
152 if options == self.default:
154 if not isinstance(options, list):
155 options = ['%s' % options]
156 for option in options:
157 keypath, sep, val = option.partition('=')
160 CLISyntaxError('Argument Syntax Error '),
162 '%s is missing a "="',
163 ' (usage: -o section.key=val)' % option])
164 section, sep, key = keypath.partition('.')
168 self._config_arg.value.override(
174 class FlagArgument(Argument):
176 :value: true if set, false otherwise
179 def __init__(self, help='', parsed_name=None, default=False):
180 super(FlagArgument, self).__init__(0, help, parsed_name, default)
183 class ValueArgument(Argument):
186 :value returns: given value or default
189 def __init__(self, help='', parsed_name=None, default=None):
190 super(ValueArgument, self).__init__(1, help, parsed_name, default)
193 class CommaSeparatedListArgument(ValueArgument):
196 :value returns: list of the comma separated values
201 return self._value or list()
204 def value(self, newvalue):
205 self._value = newvalue.split(',') if newvalue else list()
208 class IntArgument(ValueArgument):
212 """integer (type checking)"""
213 return getattr(self, '_value', self.default)
216 def value(self, newvalue):
217 if newvalue == self.default:
218 self._value = newvalue
221 if int(newvalue) == float(newvalue):
222 self._value = int(newvalue)
224 raise ValueError('Raise int argument error')
226 raiseCLIError(CLISyntaxError(
228 details=['Value %s not an int' % newvalue]))
231 class DateArgument(ValueArgument):
233 DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
235 INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y']
239 v = getattr(self, '_value', self.default)
240 return mktime(v.timetuple()) if v else None
244 v = getattr(self, '_value', self.default)
245 return v.strftime(self.DATE_FORMAT) if v else None
249 return self.timestamp
252 def value(self, newvalue):
253 self._value = self.format_date(newvalue) if newvalue else self.default
255 def format_date(self, datestr):
256 for format in self.INPUT_FORMATS:
258 t = dtm.strptime(datestr, format)
261 return t # .strftime(self.DATE_FORMAT)
262 raiseCLIError(None, 'Date Argument Error', details=[
263 '%s not a valid date' % datestr,
264 'Correct formats:\n\t%s' % self.INPUT_FORMATS])
267 class VersionArgument(FlagArgument):
268 """A flag argument with that prints current version"""
273 return super(self.__class__, self).value
276 def value(self, newvalue):
277 self._value = newvalue
280 print('kamaki %s' % kamaki.__version__)
283 class RepeatableArgument(Argument):
284 """A value argument that can be repeated"""
286 def __init__(self, help='', parsed_name=None, default=[]):
287 super(RepeatableArgument, self).__init__(
288 -1, help, parsed_name, default)
291 class KeyValueArgument(Argument):
292 """A Key=Value Argument that can be repeated
294 :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
297 def __init__(self, help='', parsed_name=None, default=[]):
298 super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
303 :returns: (dict) {key1: val1, key2: val2, ...}
305 return super(KeyValueArgument, self).value
308 def value(self, keyvalue_pairs):
310 :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
312 self._value = getattr(self, '_value', self.value) or {}
314 for pair in keyvalue_pairs:
315 key, sep, val = pair.partition('=')
316 assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (pair)
317 self._value[key] = val
318 except Exception as e:
319 raiseCLIError(e, 'KeyValueArgument Syntax Error')
322 class ProgressBarArgument(FlagArgument):
323 """Manage a progress bar"""
325 def __init__(self, help='', parsed_name='', default=True):
326 self.suffix = '%(percent)d%%'
327 super(ProgressBarArgument, self).__init__(help, parsed_name, default)
330 """Get a modifiable copy of this bar"""
331 newarg = ProgressBarArgument(
332 self.help, self.parsed_name, self.default)
333 newarg._value = self._value
337 self, message, message_len=25, countdown=False, timeout=100):
338 """Get a generator to handle progress of the bar (gen.next())"""
342 self.bar = KamakiProgressBar()
347 bar_phases = list(self.bar.phases)
348 self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
350 self.bar.phases = bar_phases
351 self.bar.bar_prefix = ' '
352 self.bar.bar_suffix = ' '
353 self.bar.max = timeout or 100
354 self.bar.suffix = '%(remaining)ds to timeout'
356 self.bar.suffix = '%(percent)d%% - %(eta)ds'
357 self.bar.eta = timeout or 100
358 self.bar.message = message.ljust(message_len)
362 for i in self.bar.iter(range(int(n))):
368 """Stop progress bar, return terminal cursor to user"""
371 mybar = getattr(self, 'bar', None)
378 cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
379 help=Argument(0, 'Show help message', ('-h', '--help')),
380 debug=FlagArgument('Include debug output', ('-d', '--debug')),
381 #include=FlagArgument(
382 # 'Include raw connection data in the output', ('-i', '--include')),
383 silent=FlagArgument('Do not output anything', ('-s', '--silent')),
384 verbose=FlagArgument('More info at response', ('-v', '--verbose')),
385 version=VersionArgument('Print current version', ('-V', '--version')),
386 options=RuntimeConfigArgument(
387 _config_arg, 'Override a config value', ('-o', '--options'))
391 # Initial command line interface arguments
394 class ArgumentParseManager(object):
395 """Manage (initialize and update) an ArgumentParser object"""
399 arguments=None, required=None, syntax=None, description=None):
401 :param exe: (str) the basic command (e.g. 'kamaki')
403 :param arguments: (dict) if given, overrides the global _argument as
404 the parsers arguments specification
405 :param required: (list or tuple) an iterable of argument keys, denoting
406 which arguments are required. A tuple denoted an AND relation,
407 while a list denotes an OR relation e.g., ['a', 'b'] means that
408 either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
410 Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
411 this command required either 'a', or both 'b' and 'c', or one of
413 Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
414 ['b', 'c']] means that the command required either 'a' and 'b' or
415 'a' and 'c' or at least one of 'b', 'c' and could be written as
416 [('a', ['b', 'c']), ['b', 'c']]
417 :param syntax: (str) The basic syntax of the arguments. Default:
418 exe <cmd_group> [<cmd_subbroup> ...] <cmd>
419 :param description: (str) The description of the commands or ''
421 self.parser = ArgumentParser(
422 add_help=False, formatter_class=RawDescriptionHelpFormatter)
424 self.syntax = syntax or (
425 '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
426 self.required = required
427 self.parser.description = description or ''
429 self.arguments = arguments
432 self.arguments = _arguments
433 self._parser_modified, self._parsed, self._unparsed = False, None, None
437 def required2list(required):
438 if isinstance(required, list) or isinstance(required, tuple):
441 terms.append(ArgumentParseManager.required2list(r))
442 return list(set(terms).union())
446 def required2str(required, arguments, tab=''):
447 if isinstance(required, list):
448 return ' %sat least one:\n%s' % (tab, ''.join(
449 [ArgumentParseManager.required2str(
450 r, arguments, tab + ' ') for r in required]))
451 elif isinstance(required, tuple):
452 return ' %sall:\n%s' % (tab, ''.join(
453 [ArgumentParseManager.required2str(
454 r, arguments, tab + ' ') for r in required]))
456 lt_pn, lt_all, arg = 23, 80, arguments[required]
458 ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
460 ret += ' %s' % required.upper()
461 ret = ('{:<%s}' % lt_pn).format(ret)
462 prefix = ('\n%s' % tab2) if len(ret) < lt_pn else ' '
463 step, cur = (len(arg.help) / (lt_all - lt_pn)) or len(arg.help), 0
464 while arg.help[cur:]:
467 ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
468 cur, finish = next, '\n%s' % tab2
472 def _patch_with_required_args(arguments, required):
473 if isinstance(required, tuple):
474 return ' '.join([ArgumentParseManager._patch_with_required_args(
475 arguments, k) for k in required])
476 elif isinstance(required, list):
477 return '< %s >' % ' | '.join([
478 ArgumentParseManager._patch_with_required_args(
479 arguments, k) for k in required])
480 arg = arguments[required]
481 return '/'.join(arg.parsed_name) + (
482 ' %s [...]' % required.upper() if arg.arity < 0 else (
483 ' %s' % required.upper() if arg.arity else ''))
485 def print_help(self, out=stderr):
487 tmp_args = dict(self.arguments)
488 for term in self.required2list(self.required):
490 tmp_parser = ArgumentParseManager(self._exe, tmp_args)
491 tmp_parser.syntax = self.syntax + self._patch_with_required_args(
492 self.arguments, self.required)
493 tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
494 self.parser.description,
495 self.required2str(self.required, self.arguments))
496 tmp_parser.update_parser()
497 tmp_parser.parser.print_help()
499 self.parser.print_help()
503 """The command syntax (useful for help messages, descriptions, etc)"""
504 return self.parser.prog
507 def syntax(self, new_syntax):
508 self.parser.prog = new_syntax
512 """:returns: (dict) arguments the parser should be aware of"""
513 return self._arguments
516 def arguments(self, new_arguments):
517 assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
518 self._arguments = new_arguments
523 """(Namespace) parser-matched terms"""
524 if self._parser_modified:
530 """(list) parser-unmatched terms"""
531 if self._parser_modified:
533 return self._unparsed
535 def update_parser(self, arguments=None):
536 """Load argument specifications to parser
538 :param arguments: if not given, update self.arguments instead
540 arguments = arguments or self._arguments
542 for name, arg in arguments.items():
544 arg.update_parser(self.parser, name)
545 self._parser_modified = True
546 except ArgumentError:
549 def update_arguments(self, new_arguments):
550 """Add to / update existing arguments
552 :param new_arguments: (dict)
555 assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
556 self._arguments.update(new_arguments)
559 def parse(self, new_args=None):
560 """Parse user input"""
562 pkargs = (new_args,) if new_args else ()
563 self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
564 pdict = vars(self._parsed)
565 diff = set(self.required or []).difference(
566 [k for k in pdict if pdict[k] not in (None, )])
569 miss = ['/'.join(self.arguments[k].parsed_name) for k in diff]
570 raise CLISyntaxError(
571 'Missing required arguments (%s)' % ', '.join(miss))
573 raiseCLIError(CLISyntaxError('Argument Syntax Error'))
574 for name, arg in self.arguments.items():
575 arg.value = getattr(self._parsed, name, arg.default)
577 for term in unparsed:
578 self._unparsed += split_input(' \'%s\' ' % term)
579 self._parser_modified = False