Make shell syntax help as verbose as one-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
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 DateArgument(ValueArgument):
232
233     DATE_FORMAT = '%a %b %d %H:%M:%S %Y'
234
235     INPUT_FORMATS = [DATE_FORMAT, '%d-%m-%Y', '%H:%M:%S %d-%m-%Y']
236
237     @property
238     def timestamp(self):
239         v = getattr(self, '_value', self.default)
240         return mktime(v.timetuple()) if v else None
241
242     @property
243     def formated(self):
244         v = getattr(self, '_value', self.default)
245         return v.strftime(self.DATE_FORMAT) if v else None
246
247     @property
248     def value(self):
249         return self.timestamp
250
251     @value.setter
252     def value(self, newvalue):
253         self._value = self.format_date(newvalue) if newvalue else self.default
254
255     def format_date(self, datestr):
256         for format in self.INPUT_FORMATS:
257             try:
258                 t = dtm.strptime(datestr, format)
259             except ValueError:
260                 continue
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])
265
266
267 class VersionArgument(FlagArgument):
268     """A flag argument with that prints current version"""
269
270     @property
271     def value(self):
272         """bool"""
273         return super(self.__class__, self).value
274
275     @value.setter
276     def value(self, newvalue):
277         self._value = newvalue
278         if newvalue:
279             import kamaki
280             print('kamaki %s' % kamaki.__version__)
281
282
283 class RepeatableArgument(Argument):
284     """A value argument that can be repeated"""
285
286     def __init__(self, help='', parsed_name=None, default=[]):
287         super(RepeatableArgument, self).__init__(
288             -1, help, parsed_name, default)
289
290
291 class KeyValueArgument(Argument):
292     """A Key=Value Argument that can be repeated
293
294     :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
295     """
296
297     def __init__(self, help='', parsed_name=None, default=[]):
298         super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
299
300     @property
301     def value(self):
302         """
303         :returns: (dict) {key1: val1, key2: val2, ...}
304         """
305         return super(KeyValueArgument, self).value
306
307     @value.setter
308     def value(self, keyvalue_pairs):
309         """
310         :param keyvalue_pairs: (str) ['key1=val1', 'key2=val2', ...]
311         """
312         self._value = getattr(self, '_value', self.value) or {}
313         try:
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')
320
321
322 class ProgressBarArgument(FlagArgument):
323     """Manage a progress bar"""
324
325     def __init__(self, help='', parsed_name='', default=True):
326         self.suffix = '%(percent)d%%'
327         super(ProgressBarArgument, self).__init__(help, parsed_name, default)
328
329     def clone(self):
330         """Get a modifiable copy of this bar"""
331         newarg = ProgressBarArgument(
332             self.help, self.parsed_name, self.default)
333         newarg._value = self._value
334         return newarg
335
336     def get_generator(
337             self, message, message_len=25, countdown=False, timeout=100):
338         """Get a generator to handle progress of the bar (gen.next())"""
339         if self.value:
340             return None
341         try:
342             self.bar = KamakiProgressBar()
343         except NameError:
344             self.value = None
345             return self.value
346         if countdown:
347             bar_phases = list(self.bar.phases)
348             self.bar.empty_fill, bar_phases[0] = bar_phases[-1], ''
349             bar_phases.reverse()
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'
355         else:
356             self.bar.suffix = '%(percent)d%% - %(eta)ds'
357         self.bar.eta = timeout or 100
358         self.bar.message = message.ljust(message_len)
359         self.bar.start()
360
361         def progress_gen(n):
362             for i in self.bar.iter(range(int(n))):
363                 yield
364             yield
365         return progress_gen
366
367     def finish(self):
368         """Stop progress bar, return terminal cursor to user"""
369         if self.value:
370             return
371         mybar = getattr(self, 'bar', None)
372         if mybar:
373             mybar.finish()
374
375
376 _arguments = dict(
377     config=_config_arg,
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'))
388 )
389
390
391 #  Initial command line interface arguments
392
393
394 class ArgumentParseManager(object):
395     """Manage (initialize and update) an ArgumentParser object"""
396
397     def __init__(
398             self, exe,
399             arguments=None, required=None, syntax=None, description=None):
400         """
401         :param exe: (str) the basic command (e.g. 'kamaki')
402
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'
409             and 'b' ar required.
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
412             'd', 'e'.
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 ''
420         """
421         self.parser = ArgumentParser(
422             add_help=False, formatter_class=RawDescriptionHelpFormatter)
423         self._exe = exe
424         self.syntax = syntax or (
425             '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe)
426         self.required = required
427         self.parser.description = description or ''
428         if arguments:
429             self.arguments = arguments
430         else:
431             global _arguments
432             self.arguments = _arguments
433         self._parser_modified, self._parsed, self._unparsed = False, None, None
434         self.parse()
435
436     @staticmethod
437     def required2list(required):
438         if isinstance(required, list) or isinstance(required, tuple):
439             terms = []
440             for r in required:
441                 terms.append(ArgumentParseManager.required2list(r))
442             return list(set(terms).union())
443         return required
444
445     @staticmethod
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]))
455         else:
456             lt_pn, lt_all, arg = 23, 80, arguments[required]
457             tab2 = ' ' * lt_pn
458             ret = '%s%s' % (tab, ', '.join(arg.parsed_name))
459             if arg.arity != 0:
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:]:
465                 next = cur + step
466                 ret += prefix
467                 ret += ('{:<%s}' % (lt_all - lt_pn)).format(arg.help[cur:next])
468                 cur, finish = next, '\n%s' % tab2
469             return ret + '\n'
470
471     @staticmethod
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 ''))
484
485     def print_help(self, out=stderr):
486         if self.required:
487             tmp_args = dict(self.arguments)
488             for term in self.required2list(self.required):
489                 tmp_args.pop(term)
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()
498         else:
499             self.parser.print_help()
500
501     @property
502     def syntax(self):
503         """The command syntax (useful for help messages, descriptions, etc)"""
504         return self.parser.prog
505
506     @syntax.setter
507     def syntax(self, new_syntax):
508         self.parser.prog = new_syntax
509
510     @property
511     def arguments(self):
512         """:returns: (dict) arguments the parser should be aware of"""
513         return self._arguments
514
515     @arguments.setter
516     def arguments(self, new_arguments):
517         assert isinstance(new_arguments, dict), 'Arguments must be in a dict'
518         self._arguments = new_arguments
519         self.update_parser()
520
521     @property
522     def parsed(self):
523         """(Namespace) parser-matched terms"""
524         if self._parser_modified:
525             self.parse()
526         return self._parsed
527
528     @property
529     def unparsed(self):
530         """(list) parser-unmatched terms"""
531         if self._parser_modified:
532             self.parse()
533         return self._unparsed
534
535     def update_parser(self, arguments=None):
536         """Load argument specifications to parser
537
538         :param arguments: if not given, update self.arguments instead
539         """
540         arguments = arguments or self._arguments
541
542         for name, arg in arguments.items():
543             try:
544                 arg.update_parser(self.parser, name)
545                 self._parser_modified = True
546             except ArgumentError:
547                 pass
548
549     def update_arguments(self, new_arguments):
550         """Add to / update existing arguments
551
552         :param new_arguments: (dict)
553         """
554         if new_arguments:
555             assert isinstance(new_arguments, dict), 'Arguments not in dict !!!'
556             self._arguments.update(new_arguments)
557             self.update_parser()
558
559     def parse(self, new_args=None):
560         """Parse user input"""
561         try:
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, )])
567             if diff:
568                 self.print_help()
569                 miss = ['/'.join(self.arguments[k].parsed_name) for k in diff]
570                 raise CLISyntaxError(
571                     'Missing required arguments (%s)' % ', '.join(miss))
572         except SystemExit:
573             raiseCLIError(CLISyntaxError('Argument Syntax Error'))
574         for name, arg in self.arguments.items():
575             arg.value = getattr(self._parsed, name, arg.default)
576         self._unparsed = []
577         for term in unparsed:
578             self._unparsed += split_input(' \'%s\' ' % term)
579         self._parser_modified = False