Chromium Code Reviews| Index: subcommand.py |
| diff --git a/subcommand.py b/subcommand.py |
| new file mode 100644 |
| index 0000000000000000000000000000000000000000..170b05320204960984192b53e268558e46a3c0e2 |
| --- /dev/null |
| +++ b/subcommand.py |
| @@ -0,0 +1,201 @@ |
| +# Copyright (c) 2013 The Chromium Authors. All rights reserved. |
| +# Use of this source code is governed by a BSD-style license that can be |
| +# found in the LICENSE file. |
| + |
| +"""Manages subcommands in a script. |
| + |
| +Each subcommand should look like this: |
| + @usage('[pet name]') |
| + def CMDpet(parser, args): |
| + '''Prints a pet. |
| + |
| + Many people likes pet. This command prints a pet for your pleasure. |
| + ''' |
| + parser.add_option('--color', help='color of your pet') |
| + options, args = parser.parse_args(args) |
| + if len(args) != 1: |
| + parser.error('A pet name is required') |
| + pet = args[0] |
| + if options.color: |
| + print('Nice %s %d' % (options.color, pet)) |
| + else: |
| + print('Nice %s' % pet) |
| + return 0 |
| + |
| +Explanation: |
| + - usage decorator alters the 'usage: %prog' line in the command's help. |
| + - docstring is used to both short help line and long help line. |
| + - parser can be augmented with arguments. |
| + - return the exit code. |
| + - Every function in the specified module with a name starting with 'CMD' will |
| + be a subcommand. |
| + - The module's docstring will be used in the default 'help' page. |
| + - If a command has no docstring, it will not be listed in the 'help' page. |
| + Useful to keep compatibility commands around or aliases. |
| + - If a command is an alias to another one, it won't be documented. E.g.: |
| + CMDoldname = CMDnewcmd |
| + will result in oldname not being documented but supported and redirecting to |
| + newcmd. Make it a real function that calls the old function if you want it |
| + to be documented. |
| +""" |
| + |
| +import difflib |
| +import sys |
| + |
| + |
| +def usage(more): |
| + """Adds a 'usage_more' property to a CMD function.""" |
| + def hook(fn): |
| + fn.usage_more = more |
| + return fn |
| + return hook |
| + |
| + |
| +def CMDhelp(parser, args): |
| + """Prints list of commands or help for a specific command.""" |
| + # This is the default help implementation. It can be disabled or overriden if |
| + # wanted. |
| + if not any(i in ('-h', '--help') for i in args): |
| + args = args + ['--help'] |
| + _, args = parser.parse_args(args) |
| + # Never gets there. |
| + assert False |
| + |
| + |
| +class CommandDispatcher(object): |
| + def __init__(self, module): |
| + """module is the name of the main python module where to look for commands. |
| + |
| + The python builtin variable __name__ MUST be used for |module|. If the |
| + script is executed in the form 'python script.py', __name__ == '__main__' |
| + and sys.modules['script'] doesn't exist. On the other hand if it is unit |
| + tested, __main__ will be the unit test's module so it has to reference to |
| + itself with 'script'. __name__ always match the right value. |
|
iannucci
2013/08/16 19:53:06
Ah, good call. This works :)
|
| + """ |
| + self.module = sys.modules[module] |
| + |
| + def enumerate_commands(self): |
| + """Returns a dict of command and their handling function. |
| + |
| + The commands must be in the '__main__' modules. To import a command from a |
| + submodule, use: |
| + from mysubcommand import CMDfoo |
| + |
| + Automatically adds 'help' if not already defined. |
| + |
| + A command can be effectively disabled by defining a global variable to None, |
| + e.g.: |
| + CMDhelp = None |
| + """ |
| + cmds = dict( |
| + (fn[3:], getattr(self.module, fn)) |
| + for fn in dir(self.module) if fn.startswith('CMD')) |
| + cmds.setdefault('help', CMDhelp) |
| + return cmds |
| + |
| + def find_nearest_command(self, name): |
| + """Retrieves the function to handle a command. |
| + |
| + It automatically tries to guess the intended command by handling typos or |
| + incomplete names. |
| + """ |
| + commands = self.enumerate_commands() |
| + if name in commands: |
| + return commands[name] |
| + |
| + # An exact match was not found. Try to be smart and look if there's |
| + # something similar. |
| + commands_with_prefix = [c for c in commands if c.startswith(name)] |
| + if len(commands_with_prefix) == 1: |
| + return commands[commands_with_prefix[0]] |
| + |
| + # A #closeenough approximation of levenshtein distance. |
| + def close_enough(a, b): |
| + return difflib.SequenceMatcher(a=a, b=b).ratio() |
| + |
| + hamming_commands = sorted( |
| + ((close_enough(c, name), c) for c in commands), |
| + reverse=True) |
| + if (hamming_commands[0][0] - hamming_commands[1][0]) < 0.3: |
| + # Too ambiguous. |
| + return |
| + |
| + if hamming_commands[0][0] < 0.8: |
| + # Not similar enough. Don't be a fool and run a random command. |
| + return |
| + |
| + return commands[hamming_commands[0][1]] |
| + |
| + def _add_command_usage(self, parser, command): |
| + """Modifies an OptionParser object with the function's documentation.""" |
| + name = command.__name__[3:] |
| + more = getattr(command, 'usage_more', '') |
| + if name == 'help': |
| + name = '<command>' |
| + # Use the module's docstring as the description for the 'help' command if |
| + # available. |
| + parser.description = self.module.__doc__ |
| + else: |
| + # Use the command's docstring if available. |
| + parser.description = command.__doc__ |
| + parser.description = (parser.description or '').strip() |
| + if parser.description: |
| + parser.description += '\n' |
| + parser.set_usage( |
| + 'usage: %%prog %s [options]%s' % (name, '' if not more else ' ' + more)) |
| + |
| + @staticmethod |
| + def _create_command_summary(name, command): |
| + """Creates a oneline summary from the command's docstring.""" |
| + if name != command.__name__[3:]: |
| + # Skip aliases. |
| + return '' |
| + doc = command.__doc__ or '' |
| + line = doc.split('\n', 1)[0].rstrip('.') |
| + if not line: |
| + return line |
| + return (line[0].lower() + line[1:]).strip() |
| + |
| + def execute(self, parser, args): |
| + """Dispatches execution to the right command. |
| + |
| + Fallbacks to 'help' if not disabled. |
| + """ |
| + commands = self.enumerate_commands() |
| + length = max(len(c) for c in commands) |
| + |
| + # Lists all the commands in 'help'. |
| + if commands['help']: |
| + docs = sorted( |
| + (name, self._create_command_summary(name, handler)) |
| + for name, handler in commands.iteritems()) |
| + # Skip commands without a docstring. |
| + commands['help'].usage_more = ( |
| + '\n\nCommands are:\n' + '\n'.join( |
| + ' %-*s %s' % (length, name, doc) for name, doc in docs if doc)) |
| + |
| + if args: |
| + if args[0] in ('-h', '--help') and len(args) > 1: |
| + # Inverse the argument order so 'tool --help cmd' is rewritten to |
| + # 'tool cmd --help'. |
| + args = [args[1], args[0]] + args[2:] |
| + command = self.find_nearest_command(args[0]) |
| + if command: |
| + if command.__name__ == 'CMDhelp' and len(args) > 1: |
| + # Inverse the arguments order so 'tool help cmd' is rewritten to |
| + # 'tool cmd --help'. Do it here since we want 'tool hel cmd' to work |
| + # too. |
| + args = [args[1], '--help'] + args[2:] |
| + command = self.find_nearest_command(args[0]) or command |
| + |
| + # "fix" the usage and the description now that we know the subcommand. |
| + self._add_command_usage(parser, command) |
| + return command(parser, args[1:]) |
| + |
| + if commands['help']: |
| + # Not a known command. Default to help. |
| + self._add_command_usage(parser, commands['help']) |
| + return commands['help'](parser, args) |
| + |
| + # Nothing can be done. |
| + return 2 |