Chromium Code Reviews| Index: subcommand.py |
| diff --git a/subcommand.py b/subcommand.py |
| new file mode 100644 |
| index 0000000000000000000000000000000000000000..be3032e3f3908132fe9de06fc0b8179a9fca6982 |
| --- /dev/null |
| +++ b/subcommand.py |
| @@ -0,0 +1,178 @@ |
| +# 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 alter the usage: 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 main module with a name starting with 'CMD' will be a |
| + subcommand. |
| + - The module's docstring will be used in the default help page. |
| +""" |
| + |
| +import difflib |
| +import sys |
| + |
| +# This is the main module where to search for commands. It should never be |
| +# modified except for unit tests, where the main module is the unit test so this |
| +# value needs to be updated manually. |
| +MAIN_MODULE = sys.modules['__main__'] |
|
iannucci
2013/08/15 20:46:04
Hm.. I don't like this (esp. modifying it in the t
M-A Ruel
2013/08/15 20:56:55
I'll prototype this and will reply.
|
| + |
| + |
| +def usage(more): |
| + """Adds a 'usage_more' property to a CMD function.""" |
| + def hook(fn): |
| + fn.usage_more = more |
| + return fn |
| + return hook |
|
iannucci
2013/08/15 20:46:04
I've actually always wondered why this doesn't jus
M-A Ruel
2013/08/15 20:56:55
wraps() is not applicable because it's not a hook,
iannucci
2013/08/15 21:23:22
Ah right.
|
| + |
| + |
| +def get_commands(): |
| + """Returns a dict of command and their handling function. |
| + |
| + The commands must be in the '__main__' modules. To import a command from a |
|
iannucci
2013/08/15 20:46:04
I think some of these docstrings need to be update
|
| + 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(MAIN_MODULE, fn)) |
| + for fn in dir(MAIN_MODULE) if fn.startswith('CMD')) |
| + cmds.setdefault('help', CMDhelp) |
| + return cmds |
| + |
| + |
| +def get_command(name): |
|
iannucci
2013/08/15 20:46:04
Hm... I would expect this to be equivalent to get_
M-A Ruel
2013/08/15 20:56:55
That was ripped directly from git_cl.py The docstr
iannucci
2013/08/15 21:23:22
maybe find_closest_command?
M-A Ruel
2013/08/16 13:47:20
Done.
|
| + """Retrieves the function to handle a command. |
| + |
| + It automatically tries to guess the intended command by handling typos or |
| + incomplete names. |
| + """ |
| + commands = get_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 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 |
| + |
| + |
| +def add_command_usage(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 __main__ module docstring as the help. |
| + parser.description = sys.modules['__main__'].__doc__.strip() + '\n' |
|
iannucci
2013/08/15 20:46:04
This shouldn't be sys.modules['__main__'], I don't
M-A Ruel
2013/08/15 20:56:55
Exact, fixing.
|
| + else: |
| + parser.description = command.__doc__.strip() or '' |
| + if parser.description: |
| + parser.description += '\n' |
| + parser.set_usage('usage: %%prog %s [options] %s' % (name, more)) |
| + |
| + |
| +def dispatch_command(parser, args): |
| + """Dispatches execution to the right command. |
| + |
| + Fallbacks to 'help'. |
| + """ |
| + commands = get_commands() |
| + length = max(len(c) for c in commands) |
| + |
| + def gen_summary(x): |
| + """Creates a oneline summary from the docstring.""" |
| + line = x.__doc__.split('\n', 1)[0].rstrip('.') |
| + return (line[0].lower() + line[1:]).strip() |
| + |
| + docs = sorted( |
| + (name, gen_summary(handler)) for name, handler in commands.iteritems()) |
| + |
| + # Lists all the commands in 'help'. |
| + if commands['help']: |
| + commands['help'].usage_more = ( |
| + '\n\nCommands are:\n' + '\n'.join( |
| + ' %-*s %s' % (length, name, doc) for name, doc in docs)) |
| + |
| + 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 = get_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 = get_command(args[0]) |
| + |
| + # "fix" the usage and the description now that we know the subcommand. |
| + add_command_usage(parser, command) |
| + return command(parser, args[1:]) |
| + |
| + if commands['help']: |
| + # Not a known command. Default to help. |
| + add_command_usage(parser, commands['help']) |
| + return commands['help'](parser, args) |
| + |
| + # Nothing can be done. |
| + return 2 |