OLD | NEW |
| (Empty) |
1 # 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 | |
7 Each 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 | |
25 Explanation: | |
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 | |
42 import difflib | |
43 import sys | |
44 import textwrap | |
45 | |
46 | |
47 def 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 | |
55 def 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 | |
66 def 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 | |
77 def _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 | |
85 class 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 """ | |
122 commands = self.enumerate_commands() | |
123 if name in commands: | |
124 return commands[name] | |
125 | |
126 # An exact match was not found. Try to be smart and look if there's | |
127 # something similar. | |
128 commands_with_prefix = [c for c in commands if c.startswith(name)] | |
129 if len(commands_with_prefix) == 1: | |
130 return commands[commands_with_prefix[0]] | |
131 | |
132 # A #closeenough approximation of levenshtein distance. | |
133 def close_enough(a, b): | |
134 return difflib.SequenceMatcher(a=a, b=b).ratio() | |
135 | |
136 hamming_commands = sorted( | |
137 ((close_enough(c, name), c) for c in commands), | |
138 reverse=True) | |
139 if (hamming_commands[0][0] - hamming_commands[1][0]) < 0.3: | |
140 # Too ambiguous. | |
141 return | |
142 | |
143 if hamming_commands[0][0] < 0.8: | |
144 # Not similar enough. Don't be a fool and run a random command. | |
145 return | |
146 | |
147 return commands[hamming_commands[0][1]] | |
148 | |
149 def _gen_commands_list(self): | |
150 """Generates the short list of supported commands.""" | |
151 commands = self.enumerate_commands() | |
152 docs = sorted( | |
153 (name, self._create_command_summary(name, handler)) | |
154 for name, handler in commands.iteritems()) | |
155 # Skip commands without a docstring. | |
156 docs = [i for i in docs if i[1]] | |
157 # Then calculate maximum length for alignment: | |
158 length = max(len(c) for c in commands) | |
159 | |
160 # Look if color is supported. | |
161 colors = _get_color_module() | |
162 green = reset = '' | |
163 if colors: | |
164 green = colors.Fore.GREEN | |
165 reset = colors.Fore.RESET | |
166 return ( | |
167 'Commands are:\n' + | |
168 ''.join( | |
169 ' %s%-*s%s %s\n' % (green, length, name, reset, doc) | |
170 for name, doc in docs)) | |
171 | |
172 def _add_command_usage(self, parser, command): | |
173 """Modifies an OptionParser object with the function's documentation.""" | |
174 name = command.__name__[3:] | |
175 if name == 'help': | |
176 name = '<command>' | |
177 # Use the module's docstring as the description for the 'help' command if | |
178 # available. | |
179 parser.description = (self.module.__doc__ or '').rstrip() | |
180 if parser.description: | |
181 parser.description += '\n\n' | |
182 parser.description += self._gen_commands_list() | |
183 # Do not touch epilog. | |
184 else: | |
185 # Use the command's docstring if available. For commands, unlike module | |
186 # docstring, realign. | |
187 lines = (command.__doc__ or '').rstrip().splitlines() | |
188 if lines[:1]: | |
189 rest = textwrap.dedent('\n'.join(lines[1:])) | |
190 parser.description = '\n'.join((lines[0], rest)) | |
191 else: | |
192 parser.description = lines[0] | |
193 if parser.description: | |
194 parser.description += '\n' | |
195 parser.epilog = getattr(command, 'epilog', None) | |
196 if parser.epilog: | |
197 parser.epilog = '\n' + parser.epilog.strip() + '\n' | |
198 | |
199 more = getattr(command, 'usage_more', '') | |
200 parser.set_usage( | |
201 'usage: %%prog %s [options]%s' % (name, '' if not more else ' ' + more)) | |
202 | |
203 @staticmethod | |
204 def _create_command_summary(name, command): | |
205 """Creates a oneline summary from the command's docstring.""" | |
206 if name != command.__name__[3:]: | |
207 # Skip aliases. | |
208 return '' | |
209 doc = command.__doc__ or '' | |
210 line = doc.split('\n', 1)[0].rstrip('.') | |
211 if not line: | |
212 return line | |
213 return (line[0].lower() + line[1:]).strip() | |
214 | |
215 def execute(self, parser, args): | |
216 """Dispatches execution to the right command. | |
217 | |
218 Fallbacks to 'help' if not disabled. | |
219 """ | |
220 # Unconditionally disable format_description() and format_epilog(). | |
221 # Technically, a formatter should be used but it's not worth (yet) the | |
222 # trouble. | |
223 parser.format_description = lambda _: parser.description or '' | |
224 parser.format_epilog = lambda _: parser.epilog or '' | |
225 | |
226 if args: | |
227 if args[0] in ('-h', '--help') and len(args) > 1: | |
228 # Inverse the argument order so 'tool --help cmd' is rewritten to | |
229 # 'tool cmd --help'. | |
230 args = [args[1], args[0]] + args[2:] | |
231 command = self.find_nearest_command(args[0]) | |
232 if command: | |
233 if command.__name__ == 'CMDhelp' and len(args) > 1: | |
234 # Inverse the arguments order so 'tool help cmd' is rewritten to | |
235 # 'tool cmd --help'. Do it here since we want 'tool hel cmd' to work | |
236 # too. | |
237 args = [args[1], '--help'] + args[2:] | |
238 command = self.find_nearest_command(args[0]) or command | |
239 | |
240 # "fix" the usage and the description now that we know the subcommand. | |
241 self._add_command_usage(parser, command) | |
242 return command(parser, args[1:]) | |
243 | |
244 cmdhelp = self.enumerate_commands().get('help') | |
245 if cmdhelp: | |
246 # Not a known command. Default to help. | |
247 self._add_command_usage(parser, cmdhelp) | |
248 return cmdhelp(parser, args) | |
249 | |
250 # Nothing can be done. | |
251 return 2 | |
OLD | NEW |