Fully test CommandTree
[kamaki] / kamaki / cli / argument.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
41 from logging import getLogger
42 from argparse import ArgumentParser, ArgumentError
43 from argparse import RawDescriptionHelpFormatter
44
45 try:
46     from progress.bar import ShadyBar as KamakiProgressBar
47 except ImportError:
48     try:
49         from progress.bar import Bar as KamakiProgressBar
50     except ImportError:
51         pass
52     # progress not installed - pls, pip install progress
53     pass
54
55 log = getLogger(__name__)
56
57
58 class Argument(object):
59     """An argument that can be parsed from command line or otherwise.
60     This is the general Argument class. It is suggested to extent this
61     class into more specific argument types.
62     """
63
64     def __init__(self, arity, help=None, parsed_name=None, default=None):
65         self.arity = int(arity)
66
67         if help:
68             self.help = help
69         if parsed_name:
70             self.parsed_name = parsed_name
71         assert self.parsed_name, 'No parsed name for argument %s' % self
72         self.default = default
73
74     @property
75     def parsed_name(self):
76         """the string which will be recognised by the parser as an instance
77             of this argument
78         """
79         return getattr(self, '_parsed_name', None)
80
81     @parsed_name.setter
82     def parsed_name(self, newname):
83         self._parsed_name = getattr(self, '_parsed_name', [])
84         if isinstance(newname, list) or isinstance(newname, tuple):
85             self._parsed_name += list(newname)
86         else:
87             self._parsed_name.append('%s' % newname)
88
89     @property
90     def help(self):
91         """a user friendly help message"""
92         return getattr(self, '_help', None)
93
94     @help.setter
95     def help(self, newhelp):
96         self._help = '%s' % newhelp
97
98     @property
99     def arity(self):
100         """negative for repeating, 0 for flag, 1 or more for values"""
101         return getattr(self, '_arity', None)
102
103     @arity.setter
104     def arity(self, newarity):
105         newarity = int(newarity)
106         self._arity = newarity
107
108     @property
109     def default(self):
110         """the value of this argument when not set"""
111         if not hasattr(self, '_default'):
112             self._default = False if self.arity == 0 else None
113         return self._default
114
115     @default.setter
116     def default(self, newdefault):
117         self._default = newdefault
118
119     @property
120     def value(self):
121         """the value of the argument"""
122         return getattr(self, '_value', self.default)
123
124     @value.setter
125     def value(self, newvalue):
126         self._value = newvalue
127
128     def update_parser(self, parser, name):
129         """Update argument parser with self info"""
130         action = 'append' if self.arity < 0\
131             else 'store_true' if self.arity == 0\
132             else 'store'
133         parser.add_argument(
134             *self.parsed_name,
135             dest=name,
136             action=action,
137             default=self.default,
138             help=self.help)
139
140     def main(self):
141         """Overide this method to give functionality to your args"""
142         raise NotImplementedError
143
144
145 class ConfigArgument(Argument):
146     """Manage a kamaki configuration (file)"""
147
148     _config_file = None
149
150     @property
151     def value(self):
152         """A Config object"""
153         super(self.__class__, self).value
154         return super(self.__class__, self).value
155
156     @value.setter
157     def value(self, config_file):
158         if config_file:
159             self._value = Config(config_file)
160             self._config_file = config_file
161         elif self._config_file:
162             self._value = Config(self._config_file)
163         else:
164             self._value = Config()
165
166     def get(self, group, term):
167         """Get a configuration setting from the Config object"""
168         return self.value.get(group, term)
169
170     def get_groups(self):
171         suffix = '_cli'
172         slen = len(suffix)
173         return [term[:-slen] for term in self.value.keys('global') if (
174             term.endswith(suffix))]
175
176     def get_cli_specs(self):
177         suffix = '_cli'
178         slen = len(suffix)
179         return [(k[:-slen], v) for k, v in self.value.items('global') if (
180             k.endswith(suffix))]
181
182     def get_global(self, option):
183         return self.value.get_global(option)
184
185     def get_cloud(self, cloud, option):
186         return self.value.get_cloud(cloud, option)
187
188 _config_arg = ConfigArgument(
189     1, 'Path to configuration file', ('-c', '--config'))
190
191
192 class CmdLineConfigArgument(Argument):
193     """Set a run-time setting option (not persistent)"""
194
195     def __init__(self, config_arg, help='', parsed_name=None, default=None):
196         super(self.__class__, self).__init__(1, help, parsed_name, default)
197         self._config_arg = config_arg
198
199     @property
200     def value(self):
201         """A key=val option"""
202         return super(self.__class__, self).value
203
204     @value.setter
205     def value(self, options):
206         if options == self.default:
207             return
208         if not isinstance(options, list):
209             options = ['%s' % options]
210         for option in options:
211             keypath, sep, val = option.partition('=')
212             if not sep:
213                 raiseCLIError(
214                     CLISyntaxError('Argument Syntax Error '),
215                     details=[
216                         '%s is missing a "="',
217                         ' (usage: -o section.key=val)' % option])
218             section, sep, key = keypath.partition('.')
219         if not sep:
220             key = section
221             section = 'global'
222         self._config_arg.value.override(
223             section.strip(),
224             key.strip(),
225             val.strip())
226
227
228 class FlagArgument(Argument):
229     """
230     :value: true if set, false otherwise
231     """
232
233     def __init__(self, help='', parsed_name=None, default=False):
234         super(FlagArgument, self).__init__(0, help, parsed_name, default)
235
236
237 class ValueArgument(Argument):
238     """
239     :value type: string
240     :value returns: given value or default
241     """
242
243     def __init__(self, help='', parsed_name=None, default=None):
244         super(ValueArgument, self).__init__(1, help, parsed_name, default)
245
246
247 class CommaSeparatedListArgument(ValueArgument):
248     """
249     :value type: string
250     :value returns: list of the comma separated values
251     """
252
253     @property
254     def value(self):
255         return self._value or list()
256
257     @value.setter
258     def value(self, newvalue):
259         self._value = newvalue.split(',') if newvalue else list()
260
261
262 class IntArgument(ValueArgument):
263
264     @property
265     def value(self):
266         """integer (type checking)"""
267         return getattr(self, '_value', self.default)
268
269     @value.setter
270     def value(self, newvalue):
271         if newvalue == self.default:
272             self._value = self.default
273             return
274         try:
275             self._value = int(newvalue)
276         except ValueError:
277             raiseCLIError(CLISyntaxError(
278                 'IntArgument Error',
279                 details=['Value %s not an int' % newvalue]))
280
281
282 class DateArgument(ValueArgument):
283     """
284     :value type: a string formated in an acceptable date format
285
286     :value returns: same date in first of DATE_FORMATS
287     """
288
289     DATE_FORMATS = [
290         "%a %b %d %H:%M:%S %Y",
291         "%A, %d-%b-%y %H:%M:%S GMT",
292         "%a, %d %b %Y %H:%M:%S GMT"]
293
294     INPUT_FORMATS = DATE_FORMATS + ["%d-%m-%Y", "%H:%M:%S %d-%m-%Y"]
295
296     @property
297     def timestamp(self):
298         v = getattr(self, '_value', self.default)
299         return mktime(v.timetuple()) if v else None
300
301     @property
302     def formated(self):
303         v = getattr(self, '_value', self.default)
304         return v.strftime(self.DATE_FORMATS[0]) if v else None
305
306     @property
307     def value(self):
308         return self.timestamp
309
310     @value.setter
311     def value(self, newvalue):
312         if newvalue:
313             self._value = self.format_date(newvalue)
314
315     def format_date(self, datestr):
316         for format in self.INPUT_FORMATS:
317             try:
318                 t = dtm.strptime(datestr, format)
319             except ValueError:
320                 continue
321             return t  # .strftime(self.DATE_FORMATS[0])
322         raiseCLIError(
323             None,
324             'Date Argument Error',
325             details='%s not a valid date. correct formats:\n\t%s' % (
326                 datestr, self.INPUT_FORMATS))
327
328
329 class VersionArgument(FlagArgument):
330     """A flag argument with that prints current version"""
331
332     @property
333     def value(self):
334         """bool"""
335         return super(self.__class__, self).value
336
337     @value.setter
338     def value(self, newvalue):
339         self._value = newvalue
340         self.main()
341
342     def main(self):
343         """Print current version"""
344         if self.value:
345             import kamaki
346             print('kamaki %s' % kamaki.__version__)
347
348
349 class KeyValueArgument(Argument):
350     """A Value Argument that can be repeated
351
352     :syntax: --<arg> key1=value1 --<arg> key2=value2 ...
353     """
354
355     def __init__(self, help='', parsed_name=None, default=[]):
356         super(KeyValueArgument, self).__init__(-1, help, parsed_name, default)
357
358     @property
359     def value(self):
360         """
361         :input: key=value
362         :output: {'key1':'value1', 'key2':'value2', ...}
363         """
364         return super(KeyValueArgument, self).value
365
366     @value.setter
367     def value(self, keyvalue_pairs):
368         self._value = {}
369         for pair in keyvalue_pairs:
370             key, sep, val = pair.partition('=')
371             if not sep:
372                 raiseCLIError(
373                     CLISyntaxError('Argument syntax error '),
374                     details='%s is missing a "=" (usage: key1=val1 )\n' % pair)
375             self._value[key.strip()] = val.strip()
376
377
378 class ProgressBarArgument(FlagArgument):
379     """Manage a progress bar"""
380
381     def __init__(self, help='', parsed_name='', default=True):
382         self.suffix = '%(percent)d%%'
383         super(ProgressBarArgument, self).__init__(help, parsed_name, default)
384         try:
385             KamakiProgressBar
386         except NameError:
387             log.warning('WARNING: no progress bar functionality')
388
389     def clone(self):
390         """Get a modifiable copy of this bar"""
391         newarg = ProgressBarArgument(
392             self.help,
393             self.parsed_name,
394             self.default)
395         newarg._value = self._value
396         return newarg
397
398     def get_generator(self, message, message_len=25):
399         """Get a generator to handle progress of the bar (gen.next())"""
400         if self.value:
401             return None
402         try:
403             self.bar = KamakiProgressBar()
404         except NameError:
405             self.value = None
406             return self.value
407         self.bar.message = message.ljust(message_len)
408         self.bar.suffix = '%(percent)d%% - %(eta)ds'
409         self.bar.start()
410
411         def progress_gen(n):
412             for i in self.bar.iter(range(int(n))):
413                 yield
414             yield
415         return progress_gen
416
417     def finish(self):
418         """Stop progress bar, return terminal cursor to user"""
419         if self.value:
420             return
421         mybar = getattr(self, 'bar', None)
422         if mybar:
423             mybar.finish()
424
425
426 _arguments = dict(
427     config=_config_arg,
428     cloud=ValueArgument('Chose a cloud to connect to', ('--cloud')),
429     help=Argument(0, 'Show help message', ('-h', '--help')),
430     debug=FlagArgument('Include debug output', ('-d', '--debug')),
431     include=FlagArgument(
432         'Include raw connection data in the output', ('-i', '--include')),
433     silent=FlagArgument('Do not output anything', ('-s', '--silent')),
434     verbose=FlagArgument('More info at response', ('-v', '--verbose')),
435     version=VersionArgument('Print current version', ('-V', '--version')),
436     options=CmdLineConfigArgument(
437         _config_arg, 'Override a config value', ('-o', '--options'))
438 )
439
440
441 #  Initial command line interface arguments
442
443
444 class ArgumentParseManager(object):
445     """Manage (initialize and update) an ArgumentParser object"""
446
447     parser = None
448     _arguments = {}
449     _parser_modified = False
450     _parsed = None
451     _unparsed = None
452
453     def __init__(self, exe, arguments=None):
454         """
455         :param exe: (str) the basic command (e.g. 'kamaki')
456
457         :param arguments: (dict) if given, overrides the global _argument as
458             the parsers arguments specification
459         """
460         self.parser = ArgumentParser(
461             add_help=False, formatter_class=RawDescriptionHelpFormatter)
462         self.syntax = '%s <cmd_group> [<cmd_subbroup> ...] <cmd>' % exe
463         if arguments:
464             self.arguments = arguments
465         else:
466             global _arguments
467             self.arguments = _arguments
468         self.parse()
469
470     @property
471     def syntax(self):
472         """The command syntax (useful for help messages, descriptions, etc)"""
473         return self.parser.prog
474
475     @syntax.setter
476     def syntax(self, new_syntax):
477         self.parser.prog = new_syntax
478
479     @property
480     def arguments(self):
481         """(dict) arguments the parser should be aware of"""
482         return self._arguments
483
484     @arguments.setter
485     def arguments(self, new_arguments):
486         if new_arguments:
487             assert isinstance(new_arguments, dict)
488         self._arguments = new_arguments
489         self.update_parser()
490
491     @property
492     def parsed(self):
493         """(Namespace) parser-matched terms"""
494         if self._parser_modified:
495             self.parse()
496         return self._parsed
497
498     @property
499     def unparsed(self):
500         """(list) parser-unmatched terms"""
501         if self._parser_modified:
502             self.parse()
503         return self._unparsed
504
505     def update_parser(self, arguments=None):
506         """Load argument specifications to parser
507
508         :param arguments: if not given, update self.arguments instead
509         """
510         if not arguments:
511             arguments = self._arguments
512
513         for name, arg in arguments.items():
514             try:
515                 arg.update_parser(self.parser, name)
516                 self._parser_modified = True
517             except ArgumentError:
518                 pass
519
520     def update_arguments(self, new_arguments):
521         """Add to / update existing arguments
522
523         :param new_arguments: (dict)
524         """
525         if new_arguments:
526             assert isinstance(new_arguments, dict)
527             self._arguments.update(new_arguments)
528             self.update_parser()
529
530     def parse(self, new_args=None):
531         """Parse user input"""
532         try:
533             pkargs = (new_args,) if new_args else ()
534             self._parsed, unparsed = self.parser.parse_known_args(*pkargs)
535         except SystemExit:
536             raiseCLIError(CLISyntaxError('Argument Syntax Error'))
537         for name, arg in self.arguments.items():
538             arg.value = getattr(self._parsed, name, arg.default)
539         self._unparsed = []
540         for term in unparsed:
541             self._unparsed += split_input(' \'%s\' ' % term)
542         self._parser_modified = False