[email protected] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 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 |
[email protected] | 39c0b22 | 2013-08-17 16:57:01 | [diff] [blame] | 44 | import textwrap |
[email protected] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 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 | |
[email protected] | 39c0b22 | 2013-08-17 16:57:01 | [diff] [blame] | 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 | |
[email protected] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 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 | |
[email protected] | 39c0b22 | 2013-08-17 16:57:01 | [diff] [blame] | 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 | |
[email protected] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 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 | """ |
[email protected] | 89f91d4 | 2013-08-22 18:37:13 | [diff] [blame] | 122 | # 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] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 125 | 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] | 39c0b22 | 2013-08-17 16:57:01 | [diff] [blame] | 152 | 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] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 175 | def _add_command_usage(self, parser, command): |
| 176 | """Modifies an OptionParser object with the function's documentation.""" |
| 177 | name = command.__name__[3:] |
[email protected] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 178 | if name == 'help': |
| 179 | name = '<command>' |
| 180 | # Use the module's docstring as the description for the 'help' command if |
| 181 | # available. |
[email protected] | 39c0b22 | 2013-08-17 16:57:01 | [diff] [blame] | 182 | 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] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 187 | else: |
[email protected] | 39c0b22 | 2013-08-17 16:57:01 | [diff] [blame] | 188 | # 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] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 203 | 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] | 39c0b22 | 2013-08-17 16:57:01 | [diff] [blame] | 223 | # 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] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 228 | |
| 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] | 39c0b22 | 2013-08-17 16:57:01 | [diff] [blame] | 247 | cmdhelp = self.enumerate_commands().get('help') |
| 248 | if cmdhelp: |
[email protected] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 249 | # Not a known command. Default to help. |
[email protected] | 39c0b22 | 2013-08-17 16:57:01 | [diff] [blame] | 250 | self._add_command_usage(parser, cmdhelp) |
| 251 | return cmdhelp(parser, args) |
[email protected] | 0633fb4 | 2013-08-16 20:06:14 | [diff] [blame] | 252 | |
| 253 | # Nothing can be done. |
| 254 | return 2 |