blob: 0b0fa6e30206d9495c004420aede869b8922f685 [file] [log] [blame]
[email protected]0633fb42013-08-16 20:06:141# Copyright 2013 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Manages subcommands in a script.
6
7Each subcommand should look like this:
8 @usage('[pet name]')
9 def CMDpet(parser, args):
10 '''Prints a pet.
11
12 Many people likes pet. This command prints a pet for your pleasure.
13 '''
14 parser.add_option('--color', help='color of your pet')
15 options, args = parser.parse_args(args)
16 if len(args) != 1:
17 parser.error('A pet name is required')
18 pet = args[0]
19 if options.color:
20 print('Nice %s %d' % (options.color, pet))
21 else:
22 print('Nice %s' % pet)
23 return 0
24
25Explanation:
26 - usage decorator alters the 'usage: %prog' line in the command's help.
27 - docstring is used to both short help line and long help line.
28 - parser can be augmented with arguments.
29 - return the exit code.
30 - Every function in the specified module with a name starting with 'CMD' will
31 be a subcommand.
32 - The module's docstring will be used in the default 'help' page.
33 - If a command has no docstring, it will not be listed in the 'help' page.
34 Useful to keep compatibility commands around or aliases.
35 - If a command is an alias to another one, it won't be documented. E.g.:
36 CMDoldname = CMDnewcmd
37 will result in oldname not being documented but supported and redirecting to
38 newcmd. Make it a real function that calls the old function if you want it
39 to be documented.
40"""
41
42import difflib
43import sys
[email protected]39c0b222013-08-17 16:57:0144import textwrap
[email protected]0633fb42013-08-16 20:06:1445
46
47def usage(more):
48 """Adds a 'usage_more' property to a CMD function."""
49 def hook(fn):
50 fn.usage_more = more
51 return fn
52 return hook
53
54
[email protected]39c0b222013-08-17 16:57:0155def epilog(text):
56 """Adds an 'epilog' property to a CMD function.
57
58 It will be shown in the epilog. Usually useful for examples.
59 """
60 def hook(fn):
61 fn.epilog = text
62 return fn
63 return hook
64
65
[email protected]0633fb42013-08-16 20:06:1466def CMDhelp(parser, args):
67 """Prints list of commands or help for a specific command."""
68 # This is the default help implementation. It can be disabled or overriden if
69 # wanted.
70 if not any(i in ('-h', '--help') for i in args):
71 args = args + ['--help']
72 _, args = parser.parse_args(args)
73 # Never gets there.
74 assert False
75
76
[email protected]39c0b222013-08-17 16:57:0177def _get_color_module():
78 """Returns the colorama module if available.
79
80 If so, assumes colors are supported and return the module handle.
81 """
82 return sys.modules.get('colorama') or sys.modules.get('third_party.colorama')
83
84
[email protected]0633fb42013-08-16 20:06:1485class CommandDispatcher(object):
86 def __init__(self, module):
87 """module is the name of the main python module where to look for commands.
88
89 The python builtin variable __name__ MUST be used for |module|. If the
90 script is executed in the form 'python script.py', __name__ == '__main__'
91 and sys.modules['script'] doesn't exist. On the other hand if it is unit
92 tested, __main__ will be the unit test's module so it has to reference to
93 itself with 'script'. __name__ always match the right value.
94 """
95 self.module = sys.modules[module]
96
97 def enumerate_commands(self):
98 """Returns a dict of command and their handling function.
99
100 The commands must be in the '__main__' modules. To import a command from a
101 submodule, use:
102 from mysubcommand import CMDfoo
103
104 Automatically adds 'help' if not already defined.
105
106 A command can be effectively disabled by defining a global variable to None,
107 e.g.:
108 CMDhelp = None
109 """
110 cmds = dict(
111 (fn[3:], getattr(self.module, fn))
112 for fn in dir(self.module) if fn.startswith('CMD'))
113 cmds.setdefault('help', CMDhelp)
114 return cmds
115
116 def find_nearest_command(self, name):
117 """Retrieves the function to handle a command.
118
119 It automatically tries to guess the intended command by handling typos or
120 incomplete names.
121 """
[email protected]89f91d42013-08-22 18:37:13122 # Implicitly replace foo-bar to foo_bar since foo-bar is not a valid python
123 # symbol but it's faster to type.
124 name = name.replace('-', '_')
[email protected]0633fb42013-08-16 20:06:14125 commands = self.enumerate_commands()
126 if name in commands:
127 return commands[name]
128
129 # An exact match was not found. Try to be smart and look if there's
130 # something similar.
131 commands_with_prefix = [c for c in commands if c.startswith(name)]
132 if len(commands_with_prefix) == 1:
133 return commands[commands_with_prefix[0]]
134
135 # A #closeenough approximation of levenshtein distance.
136 def close_enough(a, b):
137 return difflib.SequenceMatcher(a=a, b=b).ratio()
138
139 hamming_commands = sorted(
140 ((close_enough(c, name), c) for c in commands),
141 reverse=True)
142 if (hamming_commands[0][0] - hamming_commands[1][0]) < 0.3:
143 # Too ambiguous.
144 return
145
146 if hamming_commands[0][0] < 0.8:
147 # Not similar enough. Don't be a fool and run a random command.
148 return
149
150 return commands[hamming_commands[0][1]]
151
[email protected]39c0b222013-08-17 16:57:01152 def _gen_commands_list(self):
153 """Generates the short list of supported commands."""
154 commands = self.enumerate_commands()
155 docs = sorted(
156 (name, self._create_command_summary(name, handler))
157 for name, handler in commands.iteritems())
158 # Skip commands without a docstring.
159 docs = [i for i in docs if i[1]]
160 # Then calculate maximum length for alignment:
161 length = max(len(c) for c in commands)
162
163 # Look if color is supported.
164 colors = _get_color_module()
165 green = reset = ''
166 if colors:
167 green = colors.Fore.GREEN
168 reset = colors.Fore.RESET
169 return (
170 'Commands are:\n' +
171 ''.join(
172 ' %s%-*s%s %s\n' % (green, length, name, reset, doc)
173 for name, doc in docs))
174
[email protected]0633fb42013-08-16 20:06:14175 def _add_command_usage(self, parser, command):
176 """Modifies an OptionParser object with the function's documentation."""
177 name = command.__name__[3:]
[email protected]0633fb42013-08-16 20:06:14178 if name == 'help':
179 name = '<command>'
180 # Use the module's docstring as the description for the 'help' command if
181 # available.
[email protected]39c0b222013-08-17 16:57:01182 parser.description = (self.module.__doc__ or '').rstrip()
183 if parser.description:
184 parser.description += '\n\n'
185 parser.description += self._gen_commands_list()
186 # Do not touch epilog.
[email protected]0633fb42013-08-16 20:06:14187 else:
[email protected]39c0b222013-08-17 16:57:01188 # Use the command's docstring if available. For commands, unlike module
189 # docstring, realign.
190 lines = (command.__doc__ or '').rstrip().splitlines()
191 if lines[:1]:
192 rest = textwrap.dedent('\n'.join(lines[1:]))
193 parser.description = '\n'.join((lines[0], rest))
194 else:
195 parser.description = lines[0]
196 if parser.description:
197 parser.description += '\n'
198 parser.epilog = getattr(command, 'epilog', None)
199 if parser.epilog:
200 parser.epilog = '\n' + parser.epilog.strip() + '\n'
201
202 more = getattr(command, 'usage_more', '')
[email protected]0633fb42013-08-16 20:06:14203 parser.set_usage(
204 'usage: %%prog %s [options]%s' % (name, '' if not more else ' ' + more))
205
206 @staticmethod
207 def _create_command_summary(name, command):
208 """Creates a oneline summary from the command's docstring."""
209 if name != command.__name__[3:]:
210 # Skip aliases.
211 return ''
212 doc = command.__doc__ or ''
213 line = doc.split('\n', 1)[0].rstrip('.')
214 if not line:
215 return line
216 return (line[0].lower() + line[1:]).strip()
217
218 def execute(self, parser, args):
219 """Dispatches execution to the right command.
220
221 Fallbacks to 'help' if not disabled.
222 """
[email protected]39c0b222013-08-17 16:57:01223 # Unconditionally disable format_description() and format_epilog().
224 # Technically, a formatter should be used but it's not worth (yet) the
225 # trouble.
226 parser.format_description = lambda _: parser.description or ''
227 parser.format_epilog = lambda _: parser.epilog or ''
[email protected]0633fb42013-08-16 20:06:14228
229 if args:
230 if args[0] in ('-h', '--help') and len(args) > 1:
231 # Inverse the argument order so 'tool --help cmd' is rewritten to
232 # 'tool cmd --help'.
233 args = [args[1], args[0]] + args[2:]
234 command = self.find_nearest_command(args[0])
235 if command:
236 if command.__name__ == 'CMDhelp' and len(args) > 1:
237 # Inverse the arguments order so 'tool help cmd' is rewritten to
238 # 'tool cmd --help'. Do it here since we want 'tool hel cmd' to work
239 # too.
240 args = [args[1], '--help'] + args[2:]
241 command = self.find_nearest_command(args[0]) or command
242
243 # "fix" the usage and the description now that we know the subcommand.
244 self._add_command_usage(parser, command)
245 return command(parser, args[1:])
246
[email protected]39c0b222013-08-17 16:57:01247 cmdhelp = self.enumerate_commands().get('help')
248 if cmdhelp:
[email protected]0633fb42013-08-16 20:06:14249 # Not a known command. Default to help.
[email protected]39c0b222013-08-17 16:57:01250 self._add_command_usage(parser, cmdhelp)
251 return cmdhelp(parser, args)
[email protected]0633fb42013-08-16 20:06:14252
253 # Nothing can be done.
254 return 2