1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273
|
# 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.
- CMDfoo_bar will be command 'foo-bar'.
"""
import difflib
import sys
import textwrap
import optparse
from collections.abc import Callable
from typing import NoReturn
CommandFunction = Callable[[optparse.OptionParser, list[str]], int]
def usage(more: str) -> Callable[[CommandFunction], CommandFunction]:
"""Adds a 'usage_more' property to a CMD function."""
def hook(fn):
fn.usage_more = more
return fn
return hook
def epilog(text: str) -> Callable[[CommandFunction], CommandFunction]:
"""Adds an 'epilog' property to a CMD function.
It will be shown in the epilog. Usually useful for examples.
"""
def hook(fn):
fn.epilog = text
return fn
return hook
def CMDhelp(parser: optparse.OptionParser, args: list[str]) -> NoReturn:
"""Prints list of commands or help for a specific command."""
# This is the default help implementation. It can be disabled or overridden
# if wanted.
if not any(i in ('-h', '--help') for i in args):
args = args + ['--help']
parser.parse_args(args)
# Never gets there.
assert False
def _get_color_module():
"""Returns the colorama module if available.
If so, assumes colors are supported and return the module handle.
"""
return sys.modules.get('colorama') or sys.modules.get(
'third_party.colorama')
def _function_to_name(name: str) -> str:
"""Returns the name of a CMD function."""
return name[3:].replace('_', '-')
class CommandDispatcher(object):
def __init__(self, module: str):
"""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) -> dict[str, CommandFunction]:
"""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.
Normalizes '_' in the commands to '-'.
A command can be effectively disabled by defining a global variable to
None, e.g.:
CMDhelp = None
"""
cmds = dict((_function_to_name(name), getattr(self.module, name))
for name in dir(self.module) if name.startswith('CMD'))
cmds.setdefault('help', CMDhelp)
return cmds
def find_nearest_command(self, name_asked: str) -> CommandFunction | None:
"""Retrieves the function to handle a command as supplied by the user.
It automatically tries to guess the _intended command_ by handling typos
and/or incomplete names.
"""
commands = self.enumerate_commands()
name_to_dash = name_asked.replace('_', '-')
if name_to_dash in commands:
return commands[name_to_dash]
# 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_asked)]
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_asked), c) for c in commands), reverse=True)
if (hamming_commands[0][0] - hamming_commands[1][0]) < 0.3:
# Too ambiguous.
return None
if hamming_commands[0][0] < 0.8:
# Not similar enough. Don't be a fool and run a random command.
return None
return commands[hamming_commands[0][1]]
def _gen_commands_list(self) -> str:
"""Generates the short list of supported commands."""
commands = self.enumerate_commands()
docs = sorted(
(cmd_name, self._create_command_summary(cmd_name, handler))
for cmd_name, handler in commands.items())
# Skip commands without a docstring.
docs = [i for i in docs if i[1]]
# Then calculate maximum length for alignment:
length = max(len(c) for c in commands)
# Look if color is supported.
colors = _get_color_module()
green = reset = ''
if colors:
green = colors.Fore.GREEN
reset = colors.Fore.RESET
return ('Commands are:\n' +
''.join(' %s%-*s%s %s\n' %
(green, length, cmd_name, reset, doc)
for cmd_name, doc in docs))
def _add_command_usage(self, parser: optparse.OptionParser,
command: CommandFunction) -> None:
"""Modifies an OptionParser object with the function's documentation."""
cmd_name = _function_to_name(command.__name__)
if cmd_name == 'help':
cmd_name = '<command>'
# Use the module's docstring as the description for the 'help'
# command if available.
parser.description = (self.module.__doc__ or '').rstrip()
if parser.description:
parser.description += '\n\n'
parser.description += self._gen_commands_list()
# Do not touch epilog.
else:
# Use the command's docstring if available. For commands, unlike
# module docstring, realign.
lines = (command.__doc__ or '').rstrip().splitlines()
if lines[:1]:
rest = textwrap.dedent('\n'.join(lines[1:]))
parser.description = '\n'.join((lines[0], rest))
else:
parser.description = lines[0] if lines else ''
if parser.description:
parser.description += '\n'
parser.epilog = getattr(command, 'epilog', None)
if parser.epilog:
parser.epilog = '\n' + parser.epilog.strip() + '\n'
more = getattr(command, 'usage_more', '')
extra = '' if not more else ' ' + more
parser.set_usage('usage: %%prog %s [options]%s' % (cmd_name, extra))
@staticmethod
def _create_command_summary(cmd_name: str, command: CommandFunction) -> str:
"""Creates a oneliner summary from the command's docstring."""
if cmd_name != _function_to_name(command.__name__):
# Skip aliases. For example using at module level:
# CMDfoo = CMDbar
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: optparse.OptionParser, args: list[str]) -> int:
"""Dispatches execution to the right command.
Fallbacks to 'help' if not disabled.
"""
# Unconditionally disable format_description() and format_epilog().
# Technically, a formatter should be used but it's not worth (yet) the
# trouble.
parser.format_description = lambda formatter: parser.description or ''
parser.format_epilog = lambda formatter: parser.epilog or ''
if args:
if args[0] in ('-h', '--help') and len(args) > 1:
# Reverse 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:
# Reverse the argument order so 'tool help cmd' is rewritten
# to 'tool cmd --help'. Do it here since we want 'tool help
# 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:])
cmdhelp = self.enumerate_commands().get('help')
if cmdhelp:
# Not a known command. Default to help.
self._add_command_usage(parser, cmdhelp)
# Don't pass list of arguments as those may not be supported by
# cmdhelp. See: https://crbug.com/1352093
return cmdhelp(parser, [])
# Nothing can be done.
return 2
|