Update wait to use --status
[kamaki] / kamaki / cli / argument / __init__.py
1 # Copyright 2012-2013 GRNET S.A. All rights reserved.
2 #
3 # Redistribution and use in source and binary forms, with or
4 # without modification, are permitted provided that the following
5 # conditions are met:
6 #
7 #   1. Redistributions of source code must retain the above
8 #     copyright notice, this list of conditions and the following
9 #     disclaimer.
10 #
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.
15 #
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.
28 #
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.
33
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
38
39 from datetime import datetime as dtm
40 from time import mktime
41 from sys import stderr
42
43 from logging import getLogger
44 from argparse import ArgumentParser, ArgumentError
45 from argparse import RawDescriptionHelpFormatter
46 from progress.bar import ShadyBar as KamakiProgressBar
47
48 log = getLogger(__name__)
49
50
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.
55     """
56     lvalue_delimiter = '/'
57
58     def __init__(self, arity, help=None, parsed_name=None, default=None):
59         self.arity = int(arity)
60         self.help = '%s' % help or ''
61
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"' % (
68                 self, name)
69             msg = '%s: Invalid parse name "%s" should start with a "-"' % (
70                     self, name)
71             assert name.startswith('-'), msg
72
73         self.default = default or None
74
75     @property
76     def value(self):
77         return getattr(self, '_value', self.default)
78
79     @value.setter
80     def value(self, newvalue):
81         self._value = newvalue
82
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')
87         parser.add_argument(
88             *self.parsed_name,
89             dest=name, action=action, default=self.default, help=self.help)
90
91     @property
92     def lvalue(self):
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 [])
96
97
98 class ConfigArgument(Argument):
99     """Manage a kamaki configuration (file)"""
100
101     def __init__(self, help, parsed_name=('-c', '--config')):
102         super(ConfigArgument, self).__init__(1, help, parsed_name, None)
103         self.file_path = None
104
105     @property
106     def value(self):
107         return getattr(self, '_value', None)
108
109     @value.setter
110     def value(self, config_file):
111         if config_file:
112             self._value = Config(config_file)
113             self.file_path = config_file
114         elif self.file_path:
115             self._value = Config(self.file_path)
116         else:
117             self._value = Config()
118
119     def get(self, group, term):
120         """Get a configuration setting from the Config object"""
121         return self.value.get(group, term)
122
123     @property
124     def groups(self):
125         suffix = '_cli'
126         slen = len(suffix)
127         return [term[:-slen] for term in self.value.keys('global') if (
128             term.endswith(suffix))]
129
130     @property
131     def cli_specs(self):
132         suffix = '_cli'
133         slen = len(suffix)
134         return [(k[:-slen], v) for k, v in self.value.items('global') if (
135             k.endswith(suffix))]
136
137     def get_global(self, option):
138         return self.value.get('global', option)
139
140     def get_cloud(self, cloud, option):
141         return self.value.get_cloud(cloud, option)
142
143
144 _config_arg = ConfigArgument('Path to config file')
145
146
147 class RuntimeConfigArgument(Argument):
148     """Set a run-time setting option (not persistent)"""
149
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
153
154     @property
155     def value(self):
156         return super(RuntimeConfigArgument, self).value
157
158     @value.setter
159     def value(self, options):
160         if options == self.default:
161             return
162         if not isinstance(options, list):
163             options = ['%s' % options]
164         for option in options:
165             keypath, sep, val = option.partition('=')
166             if not sep:
167                 raiseCLIError(
168                     CLISyntaxError('Argument Syntax Error '),
169                     details=[
170                         '%s is missing a "="',
171                         ' (usage: -o section.key=val)' % option])
172             section, sep, key = keypath.partition('.')
173         if not sep:
174             key = section
175             section = 'global'
176         self._config_arg.value.override(
177             section.strip(),
178             key.strip(),
179             val.strip())
180
181
182 class FlagArgument(Argument):
183     """
184     :value: true if set, false otherwise
185     """
186
187     def __init__(self, help='', parsed_name=None, default=None):
188         super(FlagArgument, self).__init__(0, help, parsed_name, default)
189
190
191 class ValueArgument(Argument):
192     """
193     :value type: string
194     :value returns: given value or default
195     """
196
197     def __init__(self, help='', parsed_name=None, default=None):
198         super(ValueArgument, self).__init__(1, help, parsed_name, default)
199
200
201 class CommaSeparatedListArgument(ValueArgument):
202     """
203     :value type: string
204     :value returns: list of the comma separated values
205     """
206
207     @property
208     def value(self):
209         return self._value or list()
210
211     @value.setter
212     def value(self, newvalue):
213         self._value = newvalue.split(',') if newvalue else list()
214
215
216 class IntArgument(ValueArgument):
217
218     @property
219     def value(self):
220         """integer (type checking)"""
221         return getattr(self, '_value', self.default)
222
223     @value.setter
224     def value(self, newvalue):
225         if newvalue == self.default:
226             self._value = newvalue
227             return
228         try:
229             if int(newvalue) == float(newvalue):
230                 self._value = int(newvalue)
231             else:
232                 raise ValueError('Raise int argument error')
233         except ValueError:
234             raiseCLIError(CLISyntaxError(
235                 'IntArgument Error',
236                 details=['Value %s not an int' % newvalue]))
237
238
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
243     """
244
245     @property
246     def value(self):
247         return getattr(self, '_value', self.default)
248
249     def _calculate_limit(self, user_input):
250         limit = 0
251         try:
252             limit = int(user_input)
253         except ValueError:
254             index = 0
255             digits = [str(num) for num in range(0, 10)] + ['.']
256             while user_input[index] in digits:
257                 index += 1
258             limit = user_input[:index]
259             format = user_input[index:]
260             try:
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',
267                     'Valid formats:',
268                     '(*1024): B, KiB, MiB, GiB, TiB',
269                     '(*1000): B, KB, MB, GB, TB'])
270         return limit
271
272     @value.setter
273     def value(self, new_value):
274         if new_value:
275             self._value = self._calculate_limit(new_value)
276
277
278 class DateArgument(ValueArgument):
279
280     DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
281
282     INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y']
283
284     @property
285     def timestamp(self):
286         v = getattr(self, '_value', self.default)
287         return mktime(v.timetuple()) if v else None
288
289     @property
290     def formated(self):
291         v = getattr(self, '_value', self.default)
292         return v.strftime(self.DATE_FORMAT) if v else None
293
294     @property
295     def value(self):
296         return self.timestamp
297
298     @value.setter
299     def value(self, newvalue):
300         self._value = self.format_date(newvalue) if newvalue else self.default
301
302     def format_date(self, datestr):
303         for format in self.INPUT_FORMATS:
304             try:
305                 t = dtm.strptime(datestr, format)
306             except ValueError:
307                 continue
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])
312
313
314 class VersionArgument(FlagArgument):
315     """A flag argument with that prints current version"""
316
317     @property
318     def value(self):
319         """bool"""
320         return super(self.__class__, self).value
321
322     @value.setter
323     def value(self, newvalue):
324         self._value = newvalue
325         if newvalue:
326             import kamaki
327             print('kamaki %s' % kamaki.__version__)
328
329
330 class RepeatableArgument(Argument):
331     """A value argument that can be repeated"""
332
333     def __init__(self, help='', parsed_name=None, default=None):
334         super(RepeatableArgument, self).__init__(
335             -1, help, parsed_name, default)
336
337     @property
338     def value(self):
339         return getattr(self, '_value', [])
340
341     @value.setter
342     def value(self, newvalue):
343         self._value = newvalue
344
345
346 class KeyValueArgument(Argument):
347     """A Key=Value Argument that can be repeated
348
349     :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
350     """
351
352     def __init__(self, help='', parsed_name=None, default=None):
353         super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
354
355     @property
356     def value(self):
357         """
358         :returns: (dict) {key1: val1, key2: val2, ...}
359         """
360         return getattr(self, '_value', {})
361
362     @value.setter
363     def value(self, keyvalue_pairs):
364         """
365         :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
366         """
367         if keyvalue_pairs:
368             self._value = self.value
369             try:
370                 for pair in keyvalue_pairs:
371                     key, sep, val = pair.partition('=')
372                     assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (
373                         pair)
374                     self._value[key] = val
375             except Exception as e:
376                 raiseCLIError(e, 'KeyValueArgument Syntax Error')
377
378
379 class StatusArgument(ValueArgument):
380     """Initialize with valid_states=['list', 'of', 'states']
381     First state is the default"""
382
383     def __init__(self, *args, **kwargs):
384         self.valid_states = kwargs.pop('valid_states', ['BUILD', ])
385         super(StatusArgument, self).__init__(*args, **kwargs)
386
387     @property
388     def value(self):
389         return getattr(self, '_value', None)
390
391     @value.setter
392     def value(self, new_status):
393         if 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=[
398                     'Usage: '
399                     '%s=[%s]' % (self.lvalue, '|'.join(self.valid_states))])
400             self._value = new_status
401
402
403 class ProgressBarArgument(FlagArgument):
404     """Manage a progress bar"""
405
406     def __init__(self, help='', parsed_name='', default=True):
407         self.suffix = '%(percent)d%%'
408         super(ProgressBarArgument, self).__init__(help, parsed_name, default)
409
410     def clone(self):
411         """Get a modifiable copy of this bar"""
412         newarg = ProgressBarArgument(
413             self.help, self.parsed_name, self.default)
414         newarg._value = self._value
415         return newarg
416
417     def get_generator(
418             self, message, message_len=25, countdown=False, timeout=100):
419         """Get a generator to handle progress of the bar (gen.next())"""
420         if self.value:
421             return None
422         try:
423             self.bar = KamakiProgressBar(
424                 message.ljust(message_len), max=timeout or 100)
425         except NameError:
426             self.value = None
427             return self.value
428         if countdown:
429             bar_phases = list(self.bar.phases)
430             self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
431             bar_phases.reverse()
432             self.bar.phases = bar_phases
433             self.bar.bar_prefix = ' '
434             self.bar.bar_suffix = ' '
435             self.bar.suffix = '%(remaining)ds to timeout'
436         else:
437             self.bar.suffix = '%(percent)d%% - %(eta)ds'
438         self.bar.start()
439
440         def progress_gen(n):
441             for i in self.bar.iter(range(int(n))):
442                 yield
443             yield
444         return progress_gen
445
446     def finish(self):
447         """Stop progress bar, return terminal cursor to user"""
448         if self.value:
449             return
450         mybar = getattr(self, 'bar', None)
451         if mybar:
452             mybar.finish()
453
454
455 _arguments = dict(
456     config=_config_arg,
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'))
467 )
468
469
470 #  Initial command line interface arguments
471
472
473 class ArgumentParseManager(object):
474     """Manage (initialize and update) an ArgumentParser object"""
475
476     def __init__(
477             self, exe,
478             arguments=None, required=None, syntax=None, description=None,
479             check_required=True):
480         """
481         :param exe: (str) the basic command (e.g. 'kamaki')
482
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'
489             and 'b' ar required.
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
492             'd', 'e'.
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
502         """
503         self.parser = ArgumentParser(
504             add_help=False, formatter_class=RawDescriptionHelpFormatter)
505         self._exe = exe
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 ''
510         if arguments:
511             self.arguments = arguments
512         else:
513             global _arguments
514             self.arguments = _arguments
515         self._parser_modified, self._parsed, self._unparsed = False, None, None
516         self.parse()
517
518     @staticmethod
519     def required2list(required):
520         if isinstance(required, list) or isinstance(required, tuple):
521             terms = []
522             for r in required:
523                 terms.append(ArgumentParseManager.required2list(r))
524             return list(set(terms).union())
525         return required
526
527     @staticmethod
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]))
537         else:
538             lt_pn, lt_all, arg = 23, 80, arguments[required]
539             tab2 = ' ' * lt_pn
540             ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
541             if arg.arity != 0:
542                 ret += ' %s' % required.upper()
543             ret = ('{:<%s}' % lt_pn).format(ret)
544             prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
545             cur = 0
546             while arg.help[cur:]:
547                 next = cur + lt_all - lt_pn
548                 ret += prefix
549                 ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
550                 cur, finish = next, '\n%s' % tab2
551             return ret + '\n'
552
553     @staticmethod
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 ''))
566
567     def print_help(self, out=stderr):
568         if self.required:
569             tmp_args = dict(self.arguments)
570             for term in self.required2list(self.required):
571                 tmp_args.pop(term)
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()
580         else:
581             self.parser.print_help()
582
583     @property
584     def syntax(self):
585         """The command syntax (useful for help messages, descriptions, etc)"""
586         return self.parser.prog
587
588     @syntax.setter
589     def syntax(self, new_syntax):
590         self.parser.prog = new_syntax
591
592     @property
593     def arguments(self):
594         """:returns: (dict) arguments the parser should be aware of"""
595         return self._arguments
596
597     @arguments.setter
598     def arguments(self, new_arguments):
599         assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
600         self._arguments = new_arguments
601         self.update_parser()
602
603     @property
604     def parsed(self):
605         """(Namespace) parser-matched terms"""
606         if self._parser_modified:
607             self.parse()
608         return self._parsed
609
610     @property
611     def unparsed(self):
612         """(list) parser-unmatched terms"""
613         if self._parser_modified:
614             self.parse()
615         return self._unparsed
616
617     def update_parser(self, arguments=None):
618         """Load argument specifications to parser
619
620         :param arguments: if not given, update self.arguments instead
621         """
622         arguments = arguments or self._arguments
623
624         for name, arg in arguments.items():
625             try:
626                 arg.update_parser(self.parser, name)
627                 self._parser_modified = True
628             except ArgumentError:
629                 pass
630
631     def update_arguments(self, new_arguments):
632         """Add to / update existing arguments
633
634         :param new_arguments: (dict)
635         """
636         if new_arguments:
637             assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
638             self._arguments.update(new_arguments)
639             self.update_parser()
640
641     def _parse_required_arguments(self, required, parsed_args):
642         if not (self.check_required and required):
643             return True
644         if isinstance(required, tuple):
645             for item in required:
646                 if not self._parse_required_arguments(item, parsed_args):
647                     return False
648             return True
649         if isinstance(required, list):
650             for item in required:
651                 if self._parse_required_arguments(item, parsed_args):
652                     return True
653             return False
654         return required in parsed_args
655
656     def parse(self, new_args=None):
657         """Parse user input"""
658         try:
659             pkargs = (new_args,) if new_args else ()
660             self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
661             parsed_args = [
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):
664                 self.print_help()
665                 raise CLISyntaxError('Missing required arguments')
666         except SystemExit:
667             raiseCLIError(CLISyntaxError('Argument Syntax Error'))
668         for name, arg in self.arguments.items():
669             arg.value = getattr(self._parsed, name, arg.default)
670         self._unparsed = []
671         for term in unparsed:
672             self._unparsed += split_input(' \'%s\' ' % term)
673         self._parser_modified = False