Complete container commands
[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 CLISyntaxError, raiseCLIError
36 from kamaki.cli.utils import split_input, to_bytes
37
38 from datetime import datetime as dtm
39 from time import mktime
40 from sys import stderr
41
42 from logging import getLogger
43 from argparse import ArgumentParser, ArgumentError
44 from argparse import RawDescriptionHelpFormatter
45 from progress.bar import ShadyBar as KamakiProgressBar
46
47 log = getLogger(__name__)
48
49
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.
54     """
55
56     def __init__(self, arity, help=None, parsed_name=None, default=None):
57         self.arity = int(arity)
58         self.help = '%s' % help or ''
59
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"' % (
66                 self, name)
67             msg = '%s: Invalid parse name "%s" should start with a "-"' % (
68                     self, name)
69             assert name.startswith('-'), msg
70
71         self.default = default if (default or self.arity) else False
72
73     @property
74     def value(self):
75         return getattr(self, '_value', self.default)
76
77     @value.setter
78     def value(self, newvalue):
79         self._value = newvalue
80
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')
85         parser.add_argument(
86             *self.parsed_name,
87             dest=name, action=action, default=self.default, help=self.help)
88
89
90 class ConfigArgument(Argument):
91     """Manage a kamaki configuration (file)"""
92
93     def __init__(self, help, parsed_name=('-c', '--config')):
94         super(ConfigArgument, self).__init__(1, help, parsed_name, None)
95         self.file_path = None
96
97     @property
98     def value(self):
99         return super(ConfigArgument, self).value
100
101     @value.setter
102     def value(self, config_file):
103         if config_file:
104             self._value = Config(config_file)
105             self.file_path = config_file
106         elif self.file_path:
107             self._value = Config(self.file_path)
108         else:
109             self._value = Config()
110
111     def get(self, group, term):
112         """Get a configuration setting from the Config object"""
113         return self.value.get(group, term)
114
115     @property
116     def groups(self):
117         suffix = '_cli'
118         slen = len(suffix)
119         return [term[:-slen] for term in self.value.keys('global') if (
120             term.endswith(suffix))]
121
122     @property
123     def cli_specs(self):
124         suffix = '_cli'
125         slen = len(suffix)
126         return [(k[:-slen], v) for k, v in self.value.items('global') if (
127             k.endswith(suffix))]
128
129     def get_global(self, option):
130         return self.value.get('global', option)
131
132     def get_cloud(self, cloud, option):
133         return self.value.get_cloud(cloud, option)
134
135
136 _config_arg = ConfigArgument('Path to config file')
137
138
139 class RuntimeConfigArgument(Argument):
140     """Set a run-time setting option (not persistent)"""
141
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
145
146     @property
147     def value(self):
148         return super(RuntimeConfigArgument, self).value
149
150     @value.setter
151     def value(self, options):
152         if options == self.default:
153             return
154         if not isinstance(options, list):
155             options = ['%s' % options]
156         for option in options:
157             keypath, sep, val = option.partition('=')
158             if not sep:
159                 raiseCLIError(
160                     CLISyntaxError('Argument Syntax Error '),
161                     details=[
162                         '%s is missing a "="',
163                         ' (usage: -o section.key=val)' % option])
164             section, sep, key = keypath.partition('.')
165         if not sep:
166             key = section
167             section = 'global'
168         self._config_arg.value.override(
169             section.strip(),
170             key.strip(),
171             val.strip())
172
173
174 class FlagArgument(Argument):
175     """
176     :value: true if set, false otherwise
177     """
178
179     def __init__(self, help='', parsed_name=None, default=False):
180         super(FlagArgument, self).__init__(0, help, parsed_name, default)
181
182
183 class ValueArgument(Argument):
184     """
185     :value type: string
186     :value returns: given value or default
187     """
188
189     def __init__(self, help='', parsed_name=None, default=None):
190         super(ValueArgument, self).__init__(1, help, parsed_name, default)
191
192
193 class CommaSeparatedListArgument(ValueArgument):
194     """
195     :value type: string
196     :value returns: list of the comma separated values
197     """
198
199     @property
200     def value(self):
201         return self._value or list()
202
203     @value.setter
204     def value(self, newvalue):
205         self._value = newvalue.split(',') if newvalue else list()
206
207
208 class IntArgument(ValueArgument):
209
210     @property
211     def value(self):
212         """integer (type checking)"""
213         return getattr(self, '_value', self.default)
214
215     @value.setter
216     def value(self, newvalue):
217         if newvalue == self.default:
218             self._value = newvalue
219             return
220         try:
221             if int(newvalue) == float(newvalue):
222                 self._value = int(newvalue)
223             else:
224                 raise ValueError('Raise int argument error')
225         except ValueError:
226             raiseCLIError(CLISyntaxError(
227                 'IntArgument Error',
228                 details=['Value %s not an int' % newvalue]))
229
230
231 class DataSizeArgument(ValueArgument):
232     """Input: a string of the form <number><unit>
233     Output: the number of bytes
234     Units: B, KiB, KB, MiB, MB, GiB, GB, TiB, TB
235     """
236
237     @property
238     def value(self):
239         return getattr(self, '_value', self.default)
240
241     def _calculate_limit(self, user_input):
242         limit = 0
243         try:
244             limit = int(user_input)
245         except ValueError:
246             index = 0
247             digits = [str(num) for num in range(0, 10)] + ['.']
248             while user_input[index] in digits:
249                 index += 1
250             limit = user_input[:index]
251             format = user_input[index:]
252             try:
253                 return to_bytes(limit, format)
254             except Exception as qe:
255                 msg = 'Failed to convert %s to bytes' % user_input,
256                 raiseCLIError(qe, msg, details=[
257                     'Syntax: containerlimit set <limit>[format] [container]',
258                     'e.g.,: containerlimit set 2.3GB mycontainer',
259                     'Valid formats:',
260                     '(*1024): B, KiB, MiB, GiB, TiB',
261                     '(*1000): B, KB, MB, GB, TB'])
262         return limit
263
264     @value.setter
265     def value(self, new_value):
266         if new_value:
267             self._value = self._calculate_limit(new_value)
268
269
270 class DateArgument(ValueArgument):
271
272     DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
273
274     INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y']
275
276     @property
277     def timestamp(self):
278         v = getattr(self, '_value', self.default)
279         return mktime(v.timetuple()) if v else None
280
281     @property
282     def formated(self):
283         v = getattr(self, '_value', self.default)
284         return v.strftime(self.DATE_FORMAT) if v else None
285
286     @property
287     def value(self):
288         return self.timestamp
289
290     @value.setter
291     def value(self, newvalue):
292         self._value = self.format_date(newvalue) if newvalue else self.default
293
294     def format_date(self, datestr):
295         for format in self.INPUT_FORMATS:
296             try:
297                 t = dtm.strptime(datestr, format)
298             except ValueError:
299                 continue
300             return t  # .strftime(self.DATE_FORMAT)
301         raiseCLIError(None, 'Date Argument Error', details=[
302             '%s not a valid date' % datestr,
303             'Correct formats:\n\t%s' % self.INPUT_FORMATS])
304
305
306 class VersionArgument(FlagArgument):
307     """A flag argument with that prints current version"""
308
309     @property
310     def value(self):
311         """bool"""
312         return super(self.__class__, self).value
313
314     @value.setter
315     def value(self, newvalue):
316         self._value = newvalue
317         if newvalue:
318             import kamaki
319             print('kamaki %s' % kamaki.__version__)
320
321
322 class RepeatableArgument(Argument):
323     """A value argument that can be repeated"""
324
325     def __init__(self, help='', parsed_name=None, default=[]):
326         super(RepeatableArgument, self).__init__(
327             -1, help, parsed_name, default)
328
329
330 class KeyValueArgument(Argument):
331     """A Key=Value Argument that can be repeated
332
333     :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
334     """
335
336     def __init__(self, help='', parsed_name=None, default=[]):
337         super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
338
339     @property
340     def value(self):
341         """
342         :returns: (dict) {key1: val1, key2: val2, ...}
343         """
344         return super(KeyValueArgument, self).value
345
346     @value.setter
347     def value(self, keyvalue_pairs):
348         """
349         :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
350         """
351         self._value = getattr(self, '_value', self.value) or {}
352         try:
353             for pair in keyvalue_pairs:
354                 key, sep, val = pair.partition('=')
355                 assert sep, ' %s misses a "=" (usage: key1=val1 )\n' % (pair)
356                 self._value[key] = val
357         except Exception as e:
358             raiseCLIError(e, 'KeyValueArgument Syntax Error')
359
360
361 class ProgressBarArgument(FlagArgument):
362     """Manage a progress bar"""
363
364     def __init__(self, help='', parsed_name='', default=True):
365         self.suffix = '%(percent)d%%'
366         super(ProgressBarArgument, self).__init__(help, parsed_name, default)
367
368     def clone(self):
369         """Get a modifiable copy of this bar"""
370         newarg = ProgressBarArgument(
371             self.help, self.parsed_name, self.default)
372         newarg._value = self._value
373         return newarg
374
375     def get_generator(
376             self, message, message_len=25, countdown=False, timeout=100):
377         """Get a generator to handle progress of the bar (gen.next())"""
378         if self.value:
379             return None
380         try:
381             self.bar = KamakiProgressBar()
382         except NameError:
383             self.value = None
384             return self.value
385         if countdown:
386             bar_phases = list(self.bar.phases)
387             self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
388             bar_phases.reverse()
389             self.bar.phases = bar_phases
390             self.bar.bar_prefix = ' '
391             self.bar.bar_suffix = ' '
392             self.bar.max = timeout or 100
393             self.bar.suffix = '%(remaining)ds to timeout'
394         else:
395             self.bar.suffix = '%(percent)d%% - %(eta)ds'
396         self.bar.eta = timeout or 100
397         self.bar.message = message.ljust(message_len)
398         self.bar.start()
399
400         def progress_gen(n):
401             for i in self.bar.iter(range(int(n))):
402                 yield
403             yield
404         return progress_gen
405
406     def finish(self):
407         """Stop progress bar, return terminal cursor to user"""
408         if self.value:
409             return
410         mybar = getattr(self, 'bar', None)
411         if mybar:
412             mybar.finish()
413
414
415 _arguments = dict(
416     config=_config_arg,
417     cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
418     help=Argument(0, 'Show help message', ('-h', '--help')),
419     debug=FlagArgument('Include debug output', ('-d', '--debug')),
420     #include=FlagArgument(
421     #    'Include raw connection data in the output', ('-i', '--include')),
422     silent=FlagArgument('Do not output anything', ('-s', '--silent')),
423     verbose=FlagArgument('More info at response', ('-v', '--verbose')),
424     version=VersionArgument('Print current version', ('-V', '--version')),
425     options=RuntimeConfigArgument(
426         _config_arg, 'Override a config value', ('-o', '--options'))
427 )
428
429
430 #  Initial command line interface arguments
431
432
433 class ArgumentParseManager(object):
434     """Manage (initialize and update) an ArgumentParser object"""
435
436     def __init__(
437             self, exe,
438             arguments=None, required=None, syntax=None, description=None):
439         """
440         :param exe: (str) the basic command (e.g. 'kamaki')
441
442         :param arguments: (dict) if given, overrides the global _argument as
443             the parsers arguments specification
444         :param required: (list or tuple) an iterable of argument keys, denoting
445             which arguments are required. A tuple denoted an AND relation,
446             while a list denotes an OR relation e.g., ['a', 'b'] means that
447             either 'a' or 'b' is required, while ('a', 'b') means that both 'a'
448             and 'b' ar required.
449             Nesting is allowed e.g., ['a', ('b', 'c'), ['d', 'e']] means that
450             this command required either 'a', or both 'b' and 'c', or one of
451             'd', 'e'.
452             Repeated arguments are also allowed e.g., [('a', 'b'), ('a', 'c'),
453             ['b', 'c']] means that the command required either 'a' and 'b' or
454             'a' and 'c' or at least one of 'b', 'c' and could be written as
455             [('a', ['b', 'c']), ['b', 'c']]
456         :param syntax: (str) The basic syntax of the arguments. Default:
457             exe <cmd_group> [<cmd_subbroup> ...] <cmd>
458         :param description: (str) The description of the commands or ''
459         """
460         self.parser = ArgumentParser(
461             add_help=False, formatter_class=RawDescriptionHelpFormatter)
462         self._exe = exe
463         self.syntax = syntax or (
464             '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
465         self.required = required
466         self.parser.description = description or ''
467         if arguments:
468             self.arguments = arguments
469         else:
470             global _arguments
471             self.arguments = _arguments
472         self._parser_modified, self._parsed, self._unparsed = False, None, None
473         self.parse()
474
475     @staticmethod
476     def required2list(required):
477         if isinstance(required, list) or isinstance(required, tuple):
478             terms = []
479             for r in required:
480                 terms.append(ArgumentParseManager.required2list(r))
481             return list(set(terms).union())
482         return required
483
484     @staticmethod
485     def required2str(required, arguments, tab=''):
486         if isinstance(required, list):
487             return ' %sat least one:\n%s' % (tab, ''.join(
488                 [ArgumentParseManager.required2str(
489                     r, arguments, tab + '  ') for r in required]))
490         elif isinstance(required, tuple):
491             return ' %sall:\n%s' % (tab, ''.join(
492                 [ArgumentParseManager.required2str(
493                     r, arguments, tab + '  ') for r in required]))
494         else:
495             lt_pn, lt_all, arg = 23, 80, arguments[required]
496             tab2 = ' ' * lt_pn
497             ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
498             if arg.arity != 0:
499                 ret += ' %s' % required.upper()
500             ret = ('{:<%s}' % lt_pn).format(ret)
501             prefix = ('\n%s' % tab2) if len(ret) > lt_pn else ' '
502             cur = 0
503             while arg.help[cur:]:
504                 next = cur + lt_all - lt_pn
505                 ret += prefix
506                 ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
507                 cur, finish = next, '\n%s' % tab2
508             return ret + '\n'
509
510     @staticmethod
511     def _patch_with_required_args(arguments, required):
512         if isinstance(required, tuple):
513             return ' '.join([ArgumentParseManager._patch_with_required_args(
514                 arguments, k) for k in required])
515         elif isinstance(required, list):
516             return '< %s >' % ' | '.join([
517                 ArgumentParseManager._patch_with_required_args(
518                     arguments, k) for k in required])
519         arg = arguments[required]
520         return '/'.join(arg.parsed_name) + (
521             ' %s [...]' % required.upper() if arg.arity < 0 else (
522                 ' %s' % required.upper() if arg.arity else ''))
523
524     def print_help(self, out=stderr):
525         if self.required:
526             tmp_args = dict(self.arguments)
527             for term in self.required2list(self.required):
528                 tmp_args.pop(term)
529             tmp_parser = ArgumentParseManager(self._exe, tmp_args)
530             tmp_parser.syntax = self.syntax + self._patch_with_required_args(
531                 self.arguments, self.required)
532             tmp_parser.parser.description = '%s\n\nrequired arguments:\n%s' % (
533                 self.parser.description,
534                 self.required2str(self.required, self.arguments))
535             tmp_parser.update_parser()
536             tmp_parser.parser.print_help()
537         else:
538             self.parser.print_help()
539
540     @property
541     def syntax(self):
542         """The command syntax (useful for help messages, descriptions, etc)"""
543         return self.parser.prog
544
545     @syntax.setter
546     def syntax(self, new_syntax):
547         self.parser.prog = new_syntax
548
549     @property
550     def arguments(self):
551         """:returns: (dict) arguments the parser should be aware of"""
552         return self._arguments
553
554     @arguments.setter
555     def arguments(self, new_arguments):
556         assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
557         self._arguments = new_arguments
558         self.update_parser()
559
560     @property
561     def parsed(self):
562         """(Namespace) parser-matched terms"""
563         if self._parser_modified:
564             self.parse()
565         return self._parsed
566
567     @property
568     def unparsed(self):
569         """(list) parser-unmatched terms"""
570         if self._parser_modified:
571             self.parse()
572         return self._unparsed
573
574     def update_parser(self, arguments=None):
575         """Load argument specifications to parser
576
577         :param arguments: if not given, update self.arguments instead
578         """
579         arguments = arguments or self._arguments
580
581         for name, arg in arguments.items():
582             try:
583                 arg.update_parser(self.parser, name)
584                 self._parser_modified = True
585             except ArgumentError:
586                 pass
587
588     def update_arguments(self, new_arguments):
589         """Add to / update existing arguments
590
591         :param new_arguments: (dict)
592         """
593         if new_arguments:
594             assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
595             self._arguments.update(new_arguments)
596             self.update_parser()
597
598     def _parse_required_arguments(self, required, parsed_args):
599         if not required:
600             return True
601         if isinstance(required, tuple):
602             for item in required:
603                 if not self._parse_required_arguments(item, parsed_args):
604                     return False
605             return True
606         if isinstance(required, list):
607             for item in required:
608                 if self._parse_required_arguments(item, parsed_args):
609                     return True
610             return False
611         return required in parsed_args
612
613     def parse(self, new_args=None):
614         """Parse user input"""
615         try:
616             pkargs = (new_args,) if new_args else ()
617             self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
618             parsed_args = [
619                 k for k, v in vars(self._parsed).items() if v not in (None, )]
620             if not self._parse_required_arguments(self.required, parsed_args):
621                 self.print_help()
622                 raise CLISyntaxError('Missing required arguments')
623         except SystemExit:
624             raiseCLIError(CLISyntaxError('Argument Syntax Error'))
625         for name, arg in self.arguments.items():
626             arg.value = getattr(self._parsed, name, arg.default)
627         self._unparsed = []
628         for term in unparsed:
629             self._unparsed += split_input(' \'%s\' ' % term)
630         self._parser_modified = False