blob: 0d5a8aec76e7701acf1a2cd215e32a9c581d7822 [file] [log] [blame]
maruel@chromium.org0633fb42013-08-16 20:06:14 +00001# Copyright 2013 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
maruel@chromium.org0633fb42013-08-16 20:06:14 +00004"""Manages subcommands in a script.
5
6Each subcommand should look like this:
7 @usage('[pet name]')
8 def CMDpet(parser, args):
9 '''Prints a pet.
10
11 Many people likes pet. This command prints a pet for your pleasure.
12 '''
13 parser.add_option('--color', help='color of your pet')
14 options, args = parser.parse_args(args)
15 if len(args) != 1:
16 parser.error('A pet name is required')
17 pet = args[0]
18 if options.color:
19 print('Nice %s %d' % (options.color, pet))
20 else:
21 print('Nice %s' % pet)
22 return 0
23
24Explanation:
25 - usage decorator alters the 'usage: %prog' line in the command's help.
26 - docstring is used to both short help line and long help line.
27 - parser can be augmented with arguments.
28 - return the exit code.
29 - Every function in the specified module with a name starting with 'CMD' will
30 be a subcommand.
31 - The module's docstring will be used in the default 'help' page.
32 - If a command has no docstring, it will not be listed in the 'help' page.
33 Useful to keep compatibility commands around or aliases.
34 - If a command is an alias to another one, it won't be documented. E.g.:
35 CMDoldname = CMDnewcmd
36 will result in oldname not being documented but supported and redirecting to
37 newcmd. Make it a real function that calls the old function if you want it
38 to be documented.
maruel@chromium.org9f7fd122015-04-02 19:56:58 +000039 - CMDfoo_bar will be command 'foo-bar'.
maruel@chromium.org0633fb42013-08-16 20:06:14 +000040"""
41
42import difflib
43import sys
maruel@chromium.org39c0b222013-08-17 16:57:01 +000044import textwrap
maruel@chromium.org0633fb42013-08-16 20:06:14 +000045
46
47def usage(more):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000048 """Adds a 'usage_more' property to a CMD function."""
49 def hook(fn):
50 fn.usage_more = more
51 return fn
52
53 return hook
maruel@chromium.org0633fb42013-08-16 20:06:14 +000054
55
maruel@chromium.org39c0b222013-08-17 16:57:01 +000056def epilog(text):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000057 """Adds an 'epilog' property to a CMD function.
maruel@chromium.org39c0b222013-08-17 16:57:01 +000058
59 It will be shown in the epilog. Usually useful for examples.
60 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000061 def hook(fn):
62 fn.epilog = text
63 return fn
64
65 return hook
maruel@chromium.org39c0b222013-08-17 16:57:01 +000066
67
maruel@chromium.org0633fb42013-08-16 20:06:14 +000068def CMDhelp(parser, args):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000069 """Prints list of commands or help for a specific command."""
70 # This is the default help implementation. It can be disabled or overridden
71 # if wanted.
72 if not any(i in ('-h', '--help') for i in args):
73 args = args + ['--help']
74 parser.parse_args(args)
75 # Never gets there.
76 assert False
maruel@chromium.org0633fb42013-08-16 20:06:14 +000077
78
maruel@chromium.org39c0b222013-08-17 16:57:01 +000079def _get_color_module():
Mike Frysinger124bb8e2023-09-06 05:48:55 +000080 """Returns the colorama module if available.
maruel@chromium.org39c0b222013-08-17 16:57:01 +000081
82 If so, assumes colors are supported and return the module handle.
83 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000084 return sys.modules.get('colorama') or sys.modules.get(
85 'third_party.colorama')
maruel@chromium.org39c0b222013-08-17 16:57:01 +000086
87
maruel@chromium.org9f7fd122015-04-02 19:56:58 +000088def _function_to_name(name):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000089 """Returns the name of a CMD function."""
90 return name[3:].replace('_', '-')
maruel@chromium.org9f7fd122015-04-02 19:56:58 +000091
92
maruel@chromium.org0633fb42013-08-16 20:06:14 +000093class CommandDispatcher(object):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000094 def __init__(self, module):
95 """module is the name of the main python module where to look for
96 commands.
maruel@chromium.org0633fb42013-08-16 20:06:14 +000097
98 The python builtin variable __name__ MUST be used for |module|. If the
99 script is executed in the form 'python script.py', __name__ == '__main__'
100 and sys.modules['script'] doesn't exist. On the other hand if it is unit
101 tested, __main__ will be the unit test's module so it has to reference to
102 itself with 'script'. __name__ always match the right value.
103 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000104 self.module = sys.modules[module]
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000105
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000106 def enumerate_commands(self):
107 """Returns a dict of command and their handling function.
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000108
109 The commands must be in the '__main__' modules. To import a command from a
110 submodule, use:
111 from mysubcommand import CMDfoo
112
113 Automatically adds 'help' if not already defined.
114
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000115 Normalizes '_' in the commands to '-'.
116
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000117 A command can be effectively disabled by defining a global variable to None,
118 e.g.:
119 CMDhelp = None
120 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000121 cmds = dict((_function_to_name(name), getattr(self.module, name))
122 for name in dir(self.module) if name.startswith('CMD'))
123 cmds.setdefault('help', CMDhelp)
124 return cmds
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000125
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000126 def find_nearest_command(self, name_asked):
127 """Retrieves the function to handle a command as supplied by the user.
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000128
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000129 It automatically tries to guess the _intended command_ by handling typos
130 and/or incomplete names.
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000131 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000132 commands = self.enumerate_commands()
133 name_to_dash = name_asked.replace('_', '-')
134 if name_to_dash in commands:
135 return commands[name_to_dash]
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000136
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000137 # An exact match was not found. Try to be smart and look if there's
138 # something similar.
139 commands_with_prefix = [c for c in commands if c.startswith(name_asked)]
140 if len(commands_with_prefix) == 1:
141 return commands[commands_with_prefix[0]]
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000142
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000143 # A #closeenough approximation of levenshtein distance.
144 def close_enough(a, b):
145 return difflib.SequenceMatcher(a=a, b=b).ratio()
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000146
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000147 hamming_commands = sorted(
148 ((close_enough(c, name_asked), c) for c in commands), reverse=True)
149 if (hamming_commands[0][0] - hamming_commands[1][0]) < 0.3:
150 # Too ambiguous.
151 return None
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000152
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000153 if hamming_commands[0][0] < 0.8:
154 # Not similar enough. Don't be a fool and run a random command.
155 return None
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000156
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000157 return commands[hamming_commands[0][1]]
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000158
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000159 def _gen_commands_list(self):
160 """Generates the short list of supported commands."""
161 commands = self.enumerate_commands()
162 docs = sorted(
163 (cmd_name, self._create_command_summary(cmd_name, handler))
164 for cmd_name, handler in commands.items())
165 # Skip commands without a docstring.
166 docs = [i for i in docs if i[1]]
167 # Then calculate maximum length for alignment:
168 length = max(len(c) for c in commands)
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000169
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000170 # Look if color is supported.
171 colors = _get_color_module()
172 green = reset = ''
173 if colors:
174 green = colors.Fore.GREEN
175 reset = colors.Fore.RESET
176 return ('Commands are:\n' +
177 ''.join(' %s%-*s%s %s\n' %
178 (green, length, cmd_name, reset, doc)
179 for cmd_name, doc in docs))
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000180
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000181 def _add_command_usage(self, parser, command):
182 """Modifies an OptionParser object with the function's documentation."""
183 cmd_name = _function_to_name(command.__name__)
184 if cmd_name == 'help':
185 cmd_name = '<command>'
186 # Use the module's docstring as the description for the 'help'
187 # command if available.
188 parser.description = (self.module.__doc__ or '').rstrip()
189 if parser.description:
190 parser.description += '\n\n'
191 parser.description += self._gen_commands_list()
192 # Do not touch epilog.
193 else:
194 # Use the command's docstring if available. For commands, unlike
195 # module docstring, realign.
196 lines = (command.__doc__ or '').rstrip().splitlines()
197 if lines[:1]:
198 rest = textwrap.dedent('\n'.join(lines[1:]))
199 parser.description = '\n'.join((lines[0], rest))
200 else:
201 parser.description = lines[0] if lines else ''
202 if parser.description:
203 parser.description += '\n'
204 parser.epilog = getattr(command, 'epilog', None)
205 if parser.epilog:
206 parser.epilog = '\n' + parser.epilog.strip() + '\n'
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000207
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000208 more = getattr(command, 'usage_more', '')
209 extra = '' if not more else ' ' + more
210 parser.set_usage('usage: %%prog %s [options]%s' % (cmd_name, extra))
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000211
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000212 @staticmethod
213 def _create_command_summary(cmd_name, command):
214 """Creates a oneliner summary from the command's docstring."""
215 if cmd_name != _function_to_name(command.__name__):
216 # Skip aliases. For example using at module level:
217 # CMDfoo = CMDbar
218 return ''
219 doc = command.__doc__ or ''
220 line = doc.split('\n', 1)[0].rstrip('.')
221 if not line:
222 return line
223 return (line[0].lower() + line[1:]).strip()
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000224
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000225 def execute(self, parser, args):
226 """Dispatches execution to the right command.
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000227
228 Fallbacks to 'help' if not disabled.
229 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000230 # Unconditionally disable format_description() and format_epilog().
231 # Technically, a formatter should be used but it's not worth (yet) the
232 # trouble.
233 parser.format_description = lambda _: parser.description or ''
234 parser.format_epilog = lambda _: parser.epilog or ''
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000235
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000236 if args:
237 if args[0] in ('-h', '--help') and len(args) > 1:
238 # Reverse the argument order so 'tool --help cmd' is rewritten
239 # to 'tool cmd --help'.
240 args = [args[1], args[0]] + args[2:]
241 command = self.find_nearest_command(args[0])
242 if command:
243 if command.__name__ == 'CMDhelp' and len(args) > 1:
244 # Reverse the argument order so 'tool help cmd' is rewritten
245 # to 'tool cmd --help'. Do it here since we want 'tool help
246 # cmd' to work too.
247 args = [args[1], '--help'] + args[2:]
248 command = self.find_nearest_command(args[0]) or command
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000249
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000250 # "fix" the usage and the description now that we know the
251 # subcommand.
252 self._add_command_usage(parser, command)
253 return command(parser, args[1:])
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000254
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000255 cmdhelp = self.enumerate_commands().get('help')
256 if cmdhelp:
257 # Not a known command. Default to help.
258 self._add_command_usage(parser, cmdhelp)
259 # Don't pass list of arguments as those may not be supported by
260 # cmdhelp. See: https://crbug.com/1352093
261 return cmdhelp(parser, [])
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000262
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000263 # Nothing can be done.
264 return 2