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