blob: ae0705bcbb8cce7b5b80a049f493c05763fbe0e6 [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.
4
5"""Manages subcommands in a script.
6
7Each subcommand should look like this:
8 @usage('[pet name]')
9 def CMDpet(parser, args):
10 '''Prints a pet.
11
12 Many people likes pet. This command prints a pet for your pleasure.
13 '''
14 parser.add_option('--color', help='color of your pet')
15 options, args = parser.parse_args(args)
16 if len(args) != 1:
17 parser.error('A pet name is required')
18 pet = args[0]
19 if options.color:
20 print('Nice %s %d' % (options.color, pet))
21 else:
22 print('Nice %s' % pet)
23 return 0
24
25Explanation:
26 - usage decorator alters the 'usage: %prog' line in the command's help.
27 - docstring is used to both short help line and long help line.
28 - parser can be augmented with arguments.
29 - return the exit code.
30 - Every function in the specified module with a name starting with 'CMD' will
31 be a subcommand.
32 - The module's docstring will be used in the default 'help' page.
33 - If a command has no docstring, it will not be listed in the 'help' page.
34 Useful to keep compatibility commands around or aliases.
35 - If a command is an alias to another one, it won't be documented. E.g.:
36 CMDoldname = CMDnewcmd
37 will result in oldname not being documented but supported and redirecting to
38 newcmd. Make it a real function that calls the old function if you want it
39 to be documented.
maruel@chromium.org9f7fd122015-04-02 19:56:58 +000040 - CMDfoo_bar will be command 'foo-bar'.
maruel@chromium.org0633fb42013-08-16 20:06:14 +000041"""
42
43import difflib
44import sys
maruel@chromium.org39c0b222013-08-17 16:57:01 +000045import textwrap
maruel@chromium.org0633fb42013-08-16 20:06:14 +000046
47
48def usage(more):
49 """Adds a 'usage_more' property to a CMD function."""
50 def hook(fn):
51 fn.usage_more = more
52 return fn
53 return hook
54
55
maruel@chromium.org39c0b222013-08-17 16:57:01 +000056def epilog(text):
57 """Adds an 'epilog' property to a CMD function.
58
59 It will be shown in the epilog. Usually useful for examples.
60 """
61 def hook(fn):
62 fn.epilog = text
63 return fn
64 return hook
65
66
maruel@chromium.org0633fb42013-08-16 20:06:14 +000067def CMDhelp(parser, args):
68 """Prints list of commands or help for a specific command."""
Paweł Hajdan, Jr2c199e12017-05-12 16:49:45 +020069 parser.print_help()
70 return 0
maruel@chromium.org0633fb42013-08-16 20:06:14 +000071
72
maruel@chromium.org39c0b222013-08-17 16:57:01 +000073def _get_color_module():
74 """Returns the colorama module if available.
75
76 If so, assumes colors are supported and return the module handle.
77 """
78 return sys.modules.get('colorama') or sys.modules.get('third_party.colorama')
79
80
maruel@chromium.org9f7fd122015-04-02 19:56:58 +000081def _function_to_name(name):
82 """Returns the name of a CMD function."""
83 return name[3:].replace('_', '-')
84
85
maruel@chromium.org0633fb42013-08-16 20:06:14 +000086class CommandDispatcher(object):
87 def __init__(self, module):
88 """module is the name of the main python module where to look for commands.
89
90 The python builtin variable __name__ MUST be used for |module|. If the
91 script is executed in the form 'python script.py', __name__ == '__main__'
92 and sys.modules['script'] doesn't exist. On the other hand if it is unit
93 tested, __main__ will be the unit test's module so it has to reference to
94 itself with 'script'. __name__ always match the right value.
95 """
96 self.module = sys.modules[module]
97
98 def enumerate_commands(self):
99 """Returns a dict of command and their handling function.
100
101 The commands must be in the '__main__' modules. To import a command from a
102 submodule, use:
103 from mysubcommand import CMDfoo
104
105 Automatically adds 'help' if not already defined.
106
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000107 Normalizes '_' in the commands to '-'.
108
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000109 A command can be effectively disabled by defining a global variable to None,
110 e.g.:
111 CMDhelp = None
112 """
113 cmds = dict(
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000114 (_function_to_name(name), getattr(self.module, name))
115 for name in dir(self.module) if name.startswith('CMD'))
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000116 cmds.setdefault('help', CMDhelp)
117 return cmds
118
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000119 def find_nearest_command(self, name_asked):
120 """Retrieves the function to handle a command as supplied by the user.
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000121
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000122 It automatically tries to guess the _intended command_ by handling typos
123 and/or incomplete names.
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000124 """
125 commands = self.enumerate_commands()
maruel@chromium.org6dc64d32015-04-07 17:19:35 +0000126 name_to_dash = name_asked.replace('_', '-')
127 if name_to_dash in commands:
128 return commands[name_to_dash]
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000129
130 # An exact match was not found. Try to be smart and look if there's
131 # something similar.
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000132 commands_with_prefix = [c for c in commands if c.startswith(name_asked)]
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000133 if len(commands_with_prefix) == 1:
134 return commands[commands_with_prefix[0]]
135
136 # A #closeenough approximation of levenshtein distance.
137 def close_enough(a, b):
138 return difflib.SequenceMatcher(a=a, b=b).ratio()
139
140 hamming_commands = sorted(
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000141 ((close_enough(c, name_asked), c) for c in commands),
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000142 reverse=True)
143 if (hamming_commands[0][0] - hamming_commands[1][0]) < 0.3:
144 # Too ambiguous.
145 return
146
147 if hamming_commands[0][0] < 0.8:
148 # Not similar enough. Don't be a fool and run a random command.
149 return
150
151 return commands[hamming_commands[0][1]]
152
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000153 def _gen_commands_list(self):
154 """Generates the short list of supported commands."""
155 commands = self.enumerate_commands()
156 docs = sorted(
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000157 (cmd_name, self._create_command_summary(cmd_name, handler))
158 for cmd_name, handler in commands.iteritems())
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000159 # Skip commands without a docstring.
160 docs = [i for i in docs if i[1]]
161 # Then calculate maximum length for alignment:
162 length = max(len(c) for c in commands)
163
164 # Look if color is supported.
165 colors = _get_color_module()
166 green = reset = ''
167 if colors:
168 green = colors.Fore.GREEN
169 reset = colors.Fore.RESET
170 return (
171 'Commands are:\n' +
172 ''.join(
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000173 ' %s%-*s%s %s\n' % (green, length, cmd_name, reset, doc)
174 for cmd_name, doc in docs))
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000175
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000176 def _add_command_usage(self, parser, command):
177 """Modifies an OptionParser object with the function's documentation."""
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000178 cmd_name = _function_to_name(command.__name__)
179 if cmd_name == 'help':
180 cmd_name = '<command>'
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000181 # Use the module's docstring as the description for the 'help' command if
182 # available.
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000183 parser.description = (self.module.__doc__ or '').rstrip()
184 if parser.description:
185 parser.description += '\n\n'
186 parser.description += self._gen_commands_list()
187 # Do not touch epilog.
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000188 else:
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000189 # Use the command's docstring if available. For commands, unlike module
190 # docstring, realign.
191 lines = (command.__doc__ or '').rstrip().splitlines()
192 if lines[:1]:
193 rest = textwrap.dedent('\n'.join(lines[1:]))
194 parser.description = '\n'.join((lines[0], rest))
195 else:
maruel@chromium.org29404b52014-09-08 22:58:00 +0000196 parser.description = lines[0] if lines else ''
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000197 if parser.description:
198 parser.description += '\n'
199 parser.epilog = getattr(command, 'epilog', None)
200 if parser.epilog:
201 parser.epilog = '\n' + parser.epilog.strip() + '\n'
202
203 more = getattr(command, 'usage_more', '')
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000204 extra = '' if not more else ' ' + more
205 parser.set_usage('usage: %%prog %s [options]%s' % (cmd_name, extra))
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000206
207 @staticmethod
maruel@chromium.org9f7fd122015-04-02 19:56:58 +0000208 def _create_command_summary(cmd_name, command):
209 """Creates a oneliner summary from the command's docstring."""
210 if cmd_name != _function_to_name(command.__name__):
211 # Skip aliases. For example using at module level:
212 # CMDfoo = CMDbar
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000213 return ''
214 doc = command.__doc__ or ''
215 line = doc.split('\n', 1)[0].rstrip('.')
216 if not line:
217 return line
218 return (line[0].lower() + line[1:]).strip()
219
220 def execute(self, parser, args):
221 """Dispatches execution to the right command.
222
223 Fallbacks to 'help' if not disabled.
224 """
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000225 # Unconditionally disable format_description() and format_epilog().
226 # Technically, a formatter should be used but it's not worth (yet) the
227 # trouble.
228 parser.format_description = lambda _: parser.description or ''
229 parser.format_epilog = lambda _: parser.epilog or ''
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000230
231 if args:
232 if args[0] in ('-h', '--help') and len(args) > 1:
233 # Inverse the argument order so 'tool --help cmd' is rewritten to
234 # 'tool cmd --help'.
235 args = [args[1], args[0]] + args[2:]
236 command = self.find_nearest_command(args[0])
237 if command:
238 if command.__name__ == 'CMDhelp' and len(args) > 1:
239 # Inverse the arguments order so 'tool help cmd' is rewritten to
240 # 'tool cmd --help'. Do it here since we want 'tool hel cmd' to work
241 # too.
242 args = [args[1], '--help'] + args[2:]
243 command = self.find_nearest_command(args[0]) or command
244
245 # "fix" the usage and the description now that we know the subcommand.
246 self._add_command_usage(parser, command)
247 return command(parser, args[1:])
248
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000249 cmdhelp = self.enumerate_commands().get('help')
250 if cmdhelp:
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000251 # Not a known command. Default to help.
maruel@chromium.org39c0b222013-08-17 16:57:01 +0000252 self._add_command_usage(parser, cmdhelp)
Paweł Hajdan, Jr2c199e12017-05-12 16:49:45 +0200253 # Make sure we return a non-zero exit code for unknown commands.
254 rc = cmdhelp(parser, args)
255 return rc if rc != 0 else 2
maruel@chromium.org0633fb42013-08-16 20:06:14 +0000256
257 # Nothing can be done.
258 return 2