History as command
[kamaki] / kamaki / cli / __init__.py
1 #!/usr/bin/env python
2
3 # Copyright 2011-2012 GRNET S.A. All rights reserved.
4 #
5 # Redistribution and use in source and binary forms, with or
6 # without modification, are permitted provided that the following
7 # conditions are met:
8 #
9 #   1. Redistributions of source code must retain the above
10 #      copyright notice, this list of conditions and the following
11 #      disclaimer.
12 #
13 #   2. Redistributions in binary form must reproduce the above
14 #      copyright notice, this list of conditions and the following
15 #      disclaimer in the documentation and/or other materials
16 #      provided with the distribution.
17 #
18 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
19 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
21 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
22 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
25 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
26 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29 # POSSIBILITY OF SUCH DAMAGE.
30 #
31 # The views and conclusions contained in the software and
32 # documentation are those of the authors and should not be
33 # interpreted as representing official policies, either expressed
34 # or implied, of GRNET S.A.
35
36 from __future__ import print_function
37
38 import gevent.monkey
39 #Monkey-patch everything for gevent early on
40 gevent.monkey.patch_all()
41
42 import logging
43
44 from inspect import getargspec
45 from argparse import ArgumentParser, ArgumentError
46 from os.path import basename
47 from sys import exit, stdout, stderr, argv
48
49 try:
50     from collections import OrderedDict
51 except ImportError:
52     from ordereddict import OrderedDict
53
54 #from kamaki import clients
55 from .errors import CLIError, CLISyntaxError, CLICmdIncompleteError, CLICmdSpecError
56 from .config import Config #TO BE REMOVED
57 from .utils import bold, magenta, red, yellow, print_list, print_dict
58 from .command_tree import CommandTree
59 from argument import _arguments, parse_known_args
60 from .history import History
61
62 cmd_spec_locations = [
63     'kamaki.cli.commands',
64     'kamaki.commands',
65     'kamaki.cli',
66     'kamaki',
67     '']
68 _commands = CommandTree(name='kamaki', description='A command line tool for poking clouds')
69
70 #If empty, all commands are loaded, if not empty, only commands in this list
71 #e.g. [store, lele, list, lolo] is good to load store_list but not list_store
72 #First arg should always refer to a group
73 candidate_command_terms = []
74 allow_no_commands = False
75 allow_all_commands = False
76 allow_subclass_signatures = False
77
78 def _allow_class_in_cmd_tree(cls):
79     global allow_all_commands
80     if allow_all_commands:
81         return True
82     global allow_no_commands 
83     if allow_no_commands:
84         return False
85
86     term_list = cls.__name__.split('_')
87     global candidate_command_terms
88     index = 0
89     for term in candidate_command_terms:
90         try:
91             index += 1 if term_list[index] == term else 0
92         except IndexError: #Whole term list matched!
93             return True
94     if allow_subclass_signatures:
95         if index == len(candidate_command_terms) and len(term_list) > index:
96             try: #is subterm already in _commands?
97                 _commands.get_command('_'.join(term_list[:index+1]))
98             except KeyError: #No, so it must be placed there
99                 return True
100         return False
101
102     return True if index == len(term_list) else False
103
104 def command():
105     """Class decorator that registers a class as a CLI command"""
106
107     def decorator(cls):
108         """Any class with name of the form cmd1_cmd2_cmd3_... is accepted"""
109
110         if not _allow_class_in_cmd_tree(cls):
111             return cls
112
113         cls.description, sep, cls.long_description = cls.__doc__.partition('\n')
114
115         # Generate a syntax string based on main's arguments
116         spec = getargspec(cls.main.im_func)
117         args = spec.args[1:]
118         n = len(args) - len(spec.defaults or ())
119         required = ' '.join('<%s>' % x.replace('____', '[:').replace('___', ':').replace('__',']').\
120             replace('_', ' ') for x in args[:n])
121         optional = ' '.join('[%s]' % x.replace('____', '[:').replace('___', ':').replace('__', ']').\
122             replace('_', ' ') for x in args[n:])
123         cls.syntax = ' '.join(x for x in [required, optional] if x)
124         if spec.varargs:
125             cls.syntax += ' <%s ...>' % spec.varargs
126
127         #store each term, one by one, first
128         _commands.add_command(cls.__name__, cls.description, cls)
129         return cls
130     return decorator
131
132 def _update_parser(parser, arguments):
133     for name, argument in arguments.items():
134         try:
135             argument.update_parser(parser, name)
136         except ArgumentError:
137             pass
138
139 def _init_parser(exe):
140     parser = ArgumentParser(add_help=False)
141     parser.prog='%s <cmd_group> [<cmd_subbroup> ...] <cmd>'%exe
142     _update_parser(parser, _arguments)
143     return parser
144
145 def _print_error_message(cli_err, verbose=False):
146     errmsg = unicode(cli_err) + (' (%s)'%cli_err.status if cli_err.status else ' ')
147     if cli_err.importance == 1:
148         errmsg = magenta(errmsg)
149     elif cli_err.importance == 2:
150         errmsg = yellow(errmsg)
151     elif cli_err.importance > 2:
152         errmsg = red(errmsg)
153     stdout.write(errmsg)
154     if verbose and cli_err.details is not None and len(cli_err.details) > 0:
155         print(': %s'%cli_err.details)
156     else:
157         print()
158
159 def get_command_group(unparsed):
160     groups = _arguments['config'].get_groups()
161     for grp_candidate in unparsed:
162         if grp_candidate in groups:
163             unparsed.remove(grp_candidate)
164             return grp_candidate
165     return None
166
167 def load_command(group, unparsed, reload_package=False):
168     global candidate_command_terms
169     candidate_command_terms = [group] + unparsed
170     pkg = load_group_package(group, reload_package)
171
172     #From all possible parsed commands, chose the first match in user string
173     final_cmd = _commands.get_command(group)
174     for term in unparsed:
175         cmd = final_cmd.get_subcmd(term)
176         if cmd is not None:
177             final_cmd = cmd
178             unparsed.remove(cmd.name)
179     return final_cmd
180
181 def shallow_load():
182     """Load only group names and descriptions"""
183     global allow_no_commands 
184     allow_no_commands = True#load only descriptions
185     for grp in _arguments['config'].get_groups():
186         load_group_package(grp)
187     allow_no_commands = False
188
189 def load_group_package(group, reload_package=False):
190     spec_pkg = _arguments['config'].value.get(group, 'cli')
191     if spec_pkg is None:
192         return None
193     for location in cmd_spec_locations:
194         location += spec_pkg if location == '' else ('.'+spec_pkg)
195         try:
196             package = __import__(location, fromlist=['API_DESCRIPTION'])
197         except ImportError:
198             continue
199         if reload_package:
200             reload(package)
201         for grp, descr in package.API_DESCRIPTION.items():
202             _commands.add_command(grp, descr)
203         return package
204     raise CLICmdSpecError(details='Cmd Spec Package %s load failed'%spec_pkg)
205
206 def print_commands(prefix=None, full_depth=False):
207     cmd_list = _commands.get_groups() if prefix is None else _commands.get_subcommands(prefix)
208     cmds = {}
209     for subcmd in cmd_list:
210         if subcmd.sublen() > 0:
211             sublen_str = '( %s more terms ... )'%subcmd.sublen()
212             cmds[subcmd.name] = [subcmd.help, sublen_str] if subcmd.has_description else subcmd_str
213         else:
214             cmds[subcmd.name] = subcmd.help
215     if len(cmds) > 0:
216         print('\nOptions:')
217         print_dict(cmds, ident=12)
218     if full_depth:
219         _commands.pretty_print()
220
221 def setup_logging(silent=False, debug=False, verbose=False, include=False):
222     """handle logging for clients package"""
223
224     def add_handler(name, level, prefix=''):
225         h = logging.StreamHandler()
226         fmt = logging.Formatter(prefix + '%(message)s')
227         h.setFormatter(fmt)
228         logger = logging.getLogger(name)
229         logger.addHandler(h)
230         logger.setLevel(level)
231
232     if silent:
233         add_handler('', logging.CRITICAL)
234     elif debug:
235         add_handler('requests', logging.INFO, prefix='* ')
236         add_handler('clients.send', logging.DEBUG, prefix='> ')
237         add_handler('clients.recv', logging.DEBUG, prefix='< ')
238     elif verbose:
239         add_handler('requests', logging.INFO, prefix='* ')
240         add_handler('clients.send', logging.INFO, prefix='> ')
241         add_handler('clients.recv', logging.INFO, prefix='< ')
242     elif include:
243         add_handler('clients.recv', logging.INFO)
244     else:
245         add_handler('', logging.WARNING)
246
247 def one_command():
248     _debug = False
249     _help = False
250     _verbose = False
251     try:
252         exe = basename(argv[0])
253         parser = _init_parser(exe)
254         parsed, unparsed = parse_known_args(parser)
255         _history = History(_arguments['config'].get('history', 'file'))
256         _history.add(' '.join([exe]+argv[1:]))
257         _debug = _arguments['debug'].value
258         _help = _arguments['help'].value
259         _verbose = _arguments['verbose'].value
260         if _arguments['version'].value:
261             exit(0)
262
263         group = get_command_group(unparsed)
264         if group is None:
265             parser.print_help()
266             shallow_load()
267             print_commands(full_depth=_debug)
268             exit(0)
269
270         cmd = load_command(group, unparsed)
271         if _help or not cmd.is_command:
272             if cmd.has_description:
273                 parser.description = cmd.help 
274             else:
275                 try:
276                     parser.description = _commands.get_closest_ancestor_command(cmd.path).help
277                 except KeyError:
278                     parser.description = ' '
279             parser.prog = '%s %s '%(exe, cmd.path.replace('_', ' '))
280             if cmd.is_command:
281                 cli = cmd.get_class()
282                 parser.prog += cli.syntax
283                 _update_parser(parser, cli().arguments)
284             else:
285                 parser.prog += '[...]'
286             parser.print_help()
287
288             #Shuuuut, we now have to load one more level just to see what is missing
289             global allow_subclass_signatures 
290             allow_subclass_signatures = True
291             load_command(group, cmd.path.split('_')[1:], reload_package=True)
292
293             print_commands(cmd.path, full_depth=_debug)
294             exit(0)
295
296         setup_logging(silent=_arguments['silent'].value, debug=_debug, verbose=_verbose,
297             include=_arguments['include'].value)
298         cli = cmd.get_class()
299         executable = cli(_arguments)
300         _update_parser(parser, executable.arguments)
301         parser.prog = '%s %s %s'%(exe, cmd.path.replace('_', ' '), cli.syntax)
302         parsed, new_unparsed = parse_known_args(parser)
303         unparsed = [term for term in unparsed if term in new_unparsed]
304         try:
305             ret = executable.main(*unparsed)
306             exit(ret)
307         except TypeError as e:
308             if e.args and e.args[0].startswith('main()'):
309                 parser.print_help()
310                 exit(1)
311             else:
312                 raise
313     except CLIError as err:
314         if _debug:
315             raise
316         _print_error_message(err, verbose=_verbose)
317         exit(1)