From 0633fb4ff243842ea16cf1165f76b7c38e2aaecd Mon Sep 17 00:00:00 2001 From: "maruel@chromium.org" Date: Fri, 16 Aug 2013 20:06:14 +0000 Subject: [PATCH] Split generic subcommand code off its own module. Use the code in git_cl.py, since it was the more evolved. Add documentation and clean up the structure along the way. This makes it possible to easily reuse the generic subcommand handling code. As a first step, only git_cl.py is using it. Eventually, gclient and gcl could be switch over. R=iannucci@chromium.org BUG= Review URL: https://chromiumcodereview.appspot.com/23250002 git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@218072 0039d316-1c4b-4281-b951-d872f2087c98 --- git_cl.py | 133 +++++---------------------------- subcommand.py | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 113 deletions(-) create mode 100644 subcommand.py diff --git a/git_cl.py b/git_cl.py index d833650322..eb6594dc48 100755 --- a/git_cl.py +++ b/git_cl.py @@ -7,7 +7,6 @@ """A git-command for integrating reviews on Rietveld.""" -import difflib from distutils.version import LooseVersion import json import logging @@ -36,9 +35,11 @@ import gclient_utils import presubmit_support import rietveld import scm +import subcommand import subprocess2 import watchlists +__version__ = '1.0' DEFAULT_SERVER = 'https://codereview.appspot.com' POSTUPSTREAM_HOOK_PATTERN = '.git/hooks/post-cl-%s' @@ -98,13 +99,6 @@ def IsGitVersionAtLeast(min_version): LooseVersion(version[len(prefix):]) >= LooseVersion(min_version)) -def usage(more): - def hook(fn): - fn.usage_more = more - return fn - return hook - - def ask_for_data(prompt): try: return raw_input(prompt) @@ -1031,7 +1025,7 @@ def DownloadHooks(force): DieWithError('\nFailed to download hooks from %s' % src) -@usage('[repo root containing codereview.settings]') +@subcommand.usage('[repo root containing codereview.settings]') def CMDconfig(parser, args): """Edits configuration for this tree.""" @@ -1189,7 +1183,7 @@ def CMDstatus(parser, args): return 0 -@usage('[issue_number]') +@subcommand.usage('[issue_number]') def CMDissue(parser, args): """Sets or displays the current code review issue number. @@ -1448,7 +1442,7 @@ def cleanup_list(l): return sorted(filter(None, stripped_items)) -@usage('[args to "git diff"]') +@subcommand.usage('[args to "git diff"]') def CMDupload(parser, args): """Uploads the current changelist to codereview.""" parser.add_option('--bypass-hooks', action='store_true', dest='bypass_hooks', @@ -1772,7 +1766,7 @@ def SendUpstream(parser, args, cmd): return 0 -@usage('[upstream branch to apply against]') +@subcommand.usage('[upstream branch to apply against]') def CMDdcommit(parser, args): """Commits the current changelist via git-svn.""" if not settings.GetIsGitSvn(): @@ -1788,7 +1782,7 @@ will instead be silently ignored.""" return SendUpstream(parser, args, 'dcommit') -@usage('[upstream branch to apply against]') +@subcommand.usage('[upstream branch to apply against]') def CMDpush(parser, args): """Commits the current changelist via git.""" if settings.GetIsGitSvn(): @@ -1798,7 +1792,7 @@ def CMDpush(parser, args): return SendUpstream(parser, args, 'push') -@usage('') +@subcommand.usage('') def CMDpatch(parser, args): """Patchs in a code review.""" parser.add_option('-b', dest='newbranch', @@ -2045,7 +2039,7 @@ def CMDtry(parser, args): return 0 -@usage('[new upstream branch]') +@subcommand.usage('[new upstream branch]') def CMDupstream(parser, args): """Prints or sets the name of the upstream branch, if any.""" _, args = parser.parse_args(args) @@ -2147,72 +2141,11 @@ def CMDformat(parser, args): return 0 -### Glue code for subcommand handling. - - -def Commands(): - """Returns a dict of command and their handling function.""" - module = sys.modules[__name__] - cmds = (fn[3:] for fn in dir(module) if fn.startswith('CMD')) - return dict((cmd, getattr(module, 'CMD' + cmd)) for cmd in cmds) - - -def Command(name): - """Retrieves the function to handle a command.""" - commands = Commands() - if name in commands: - return commands[name] - - # 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.""" - _, args = parser.parse_args(args) - if len(args) == 1: - return main(args + ['--help']) - parser.print_help() - return 0 - - -def GenUsage(parser, command): - """Modify an OptParse object with the function's documentation.""" - obj = Command(command) - # Get back the real command name in case Command() guess the actual command - # name. - command = obj.__name__[3:] - more = getattr(obj, 'usage_more', '') - if command == 'help': - command = '' - else: - parser.description = obj.__doc__ - parser.set_usage('usage: %%prog %s [options] %s' % (command, more)) - - class OptionParser(optparse.OptionParser): """Creates the option parse and add --verbose support.""" def __init__(self, *args, **kwargs): - optparse.OptionParser.__init__(self, *args, **kwargs) + optparse.OptionParser.__init__( + self, *args, prog='git cl', version=__version__, **kwargs) self.add_option( '-v', '--verbose', action='count', default=0, help='Use 2 times for more debugging info') @@ -2232,8 +2165,6 @@ class OptionParser(optparse.OptionParser): def main(argv): - """Doesn't parse the arguments here, just find the right subcommand to - execute.""" if sys.hexversion < 0x02060000: print >> sys.stderr, ( '\nYour python version %s is unsupported, please upgrade.\n' % @@ -2244,39 +2175,15 @@ def main(argv): global settings settings = Settings() - # Do it late so all commands are listed. - commands = Commands() - length = max(len(c) for c in commands) - - def gen_summary(x): - """Creates a oneline summary from the docstring.""" - line = x.split('\n', 1)[0].rstrip('.') - return line[0].lower() + line[1:] - - docs = sorted( - (name, gen_summary(handler.__doc__).strip()) - for name, handler in commands.iteritems()) - CMDhelp.usage_more = ('\n\nCommands are:\n' + '\n'.join( - ' %-*s %s' % (length, name, doc) for name, doc in docs)) - - parser = OptionParser() - if argv: - command = Command(argv[0]) - if command: - # "fix" the usage and the description now that we know the subcommand. - GenUsage(parser, argv[0]) - try: - return command(parser, argv[1:]) - except urllib2.HTTPError, e: - if e.code != 500: - raise - DieWithError( - ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith ' - 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e))) - - # Not a known command. Default to help. - GenUsage(parser, 'help') - return CMDhelp(parser, argv) + dispatcher = subcommand.CommandDispatcher(__name__) + try: + return dispatcher.execute(OptionParser(), argv) + except urllib2.HTTPError, e: + if e.code != 500: + raise + DieWithError( + ('AppEngine is misbehaving and returned HTTP %d, again. Keep faith ' + 'and retry or visit go/isgaeup.\n%s') % (e.code, str(e))) if __name__ == '__main__': diff --git a/subcommand.py b/subcommand.py new file mode 100644 index 0000000000..2f58a209ee --- /dev/null +++ b/subcommand.py @@ -0,0 +1,201 @@ +# Copyright 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. + """ + 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 = '' + # 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