Commit 0633fb4f authored by maruel@chromium.org's avatar maruel@chromium.org

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
parent eec3ea35
......@@ -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('<patch url or issue id>')
@subcommand.usage('<patch url or issue id>')
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 = '<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__':
......
# 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 = '<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
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment