subcommand.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. # 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. """Manages subcommands in a script.
  5. Each subcommand should look like this:
  6. @usage('[pet name]')
  7. def CMDpet(parser, args):
  8. '''Prints a pet.
  9. Many people likes pet. This command prints a pet for your pleasure.
  10. '''
  11. parser.add_option('--color', help='color of your pet')
  12. options, args = parser.parse_args(args)
  13. if len(args) != 1:
  14. parser.error('A pet name is required')
  15. pet = args[0]
  16. if options.color:
  17. print('Nice %s %d' % (options.color, pet))
  18. else:
  19. print('Nice %s' % pet)
  20. return 0
  21. Explanation:
  22. - usage decorator alters the 'usage: %prog' line in the command's help.
  23. - docstring is used to both short help line and long help line.
  24. - parser can be augmented with arguments.
  25. - return the exit code.
  26. - Every function in the specified module with a name starting with 'CMD' will
  27. be a subcommand.
  28. - The module's docstring will be used in the default 'help' page.
  29. - If a command has no docstring, it will not be listed in the 'help' page.
  30. Useful to keep compatibility commands around or aliases.
  31. - If a command is an alias to another one, it won't be documented. E.g.:
  32. CMDoldname = CMDnewcmd
  33. will result in oldname not being documented but supported and redirecting to
  34. newcmd. Make it a real function that calls the old function if you want it
  35. to be documented.
  36. - CMDfoo_bar will be command 'foo-bar'.
  37. """
  38. import difflib
  39. import sys
  40. import textwrap
  41. def usage(more):
  42. """Adds a 'usage_more' property to a CMD function."""
  43. def hook(fn):
  44. fn.usage_more = more
  45. return fn
  46. return hook
  47. def epilog(text):
  48. """Adds an 'epilog' property to a CMD function.
  49. It will be shown in the epilog. Usually useful for examples.
  50. """
  51. def hook(fn):
  52. fn.epilog = text
  53. return fn
  54. return hook
  55. def CMDhelp(parser, args):
  56. """Prints list of commands or help for a specific command."""
  57. # This is the default help implementation. It can be disabled or overridden
  58. # if wanted.
  59. if not any(i in ('-h', '--help') for i in args):
  60. args = args + ['--help']
  61. parser.parse_args(args)
  62. # Never gets there.
  63. assert False
  64. def _get_color_module():
  65. """Returns the colorama module if available.
  66. If so, assumes colors are supported and return the module handle.
  67. """
  68. return sys.modules.get('colorama') or sys.modules.get(
  69. 'third_party.colorama')
  70. def _function_to_name(name):
  71. """Returns the name of a CMD function."""
  72. return name[3:].replace('_', '-')
  73. class CommandDispatcher(object):
  74. def __init__(self, module):
  75. """module is the name of the main python module where to look for
  76. commands.
  77. The python builtin variable __name__ MUST be used for |module|. If the
  78. script is executed in the form 'python script.py', __name__ == '__main__'
  79. and sys.modules['script'] doesn't exist. On the other hand if it is unit
  80. tested, __main__ will be the unit test's module so it has to reference to
  81. itself with 'script'. __name__ always match the right value.
  82. """
  83. self.module = sys.modules[module]
  84. def enumerate_commands(self):
  85. """Returns a dict of command and their handling function.
  86. The commands must be in the '__main__' modules. To import a command from a
  87. submodule, use:
  88. from mysubcommand import CMDfoo
  89. Automatically adds 'help' if not already defined.
  90. Normalizes '_' in the commands to '-'.
  91. A command can be effectively disabled by defining a global variable to None,
  92. e.g.:
  93. CMDhelp = None
  94. """
  95. cmds = dict((_function_to_name(name), getattr(self.module, name))
  96. for name in dir(self.module) if name.startswith('CMD'))
  97. cmds.setdefault('help', CMDhelp)
  98. return cmds
  99. def find_nearest_command(self, name_asked):
  100. """Retrieves the function to handle a command as supplied by the user.
  101. It automatically tries to guess the _intended command_ by handling typos
  102. and/or incomplete names.
  103. """
  104. commands = self.enumerate_commands()
  105. name_to_dash = name_asked.replace('_', '-')
  106. if name_to_dash in commands:
  107. return commands[name_to_dash]
  108. # An exact match was not found. Try to be smart and look if there's
  109. # something similar.
  110. commands_with_prefix = [c for c in commands if c.startswith(name_asked)]
  111. if len(commands_with_prefix) == 1:
  112. return commands[commands_with_prefix[0]]
  113. # A #closeenough approximation of levenshtein distance.
  114. def close_enough(a, b):
  115. return difflib.SequenceMatcher(a=a, b=b).ratio()
  116. hamming_commands = sorted(
  117. ((close_enough(c, name_asked), c) for c in commands), reverse=True)
  118. if (hamming_commands[0][0] - hamming_commands[1][0]) < 0.3:
  119. # Too ambiguous.
  120. return None
  121. if hamming_commands[0][0] < 0.8:
  122. # Not similar enough. Don't be a fool and run a random command.
  123. return None
  124. return commands[hamming_commands[0][1]]
  125. def _gen_commands_list(self):
  126. """Generates the short list of supported commands."""
  127. commands = self.enumerate_commands()
  128. docs = sorted(
  129. (cmd_name, self._create_command_summary(cmd_name, handler))
  130. for cmd_name, handler in commands.items())
  131. # Skip commands without a docstring.
  132. docs = [i for i in docs if i[1]]
  133. # Then calculate maximum length for alignment:
  134. length = max(len(c) for c in commands)
  135. # Look if color is supported.
  136. colors = _get_color_module()
  137. green = reset = ''
  138. if colors:
  139. green = colors.Fore.GREEN
  140. reset = colors.Fore.RESET
  141. return ('Commands are:\n' +
  142. ''.join(' %s%-*s%s %s\n' %
  143. (green, length, cmd_name, reset, doc)
  144. for cmd_name, doc in docs))
  145. def _add_command_usage(self, parser, command):
  146. """Modifies an OptionParser object with the function's documentation."""
  147. cmd_name = _function_to_name(command.__name__)
  148. if cmd_name == 'help':
  149. cmd_name = '<command>'
  150. # Use the module's docstring as the description for the 'help'
  151. # command if available.
  152. parser.description = (self.module.__doc__ or '').rstrip()
  153. if parser.description:
  154. parser.description += '\n\n'
  155. parser.description += self._gen_commands_list()
  156. # Do not touch epilog.
  157. else:
  158. # Use the command's docstring if available. For commands, unlike
  159. # module docstring, realign.
  160. lines = (command.__doc__ or '').rstrip().splitlines()
  161. if lines[:1]:
  162. rest = textwrap.dedent('\n'.join(lines[1:]))
  163. parser.description = '\n'.join((lines[0], rest))
  164. else:
  165. parser.description = lines[0] if lines else ''
  166. if parser.description:
  167. parser.description += '\n'
  168. parser.epilog = getattr(command, 'epilog', None)
  169. if parser.epilog:
  170. parser.epilog = '\n' + parser.epilog.strip() + '\n'
  171. more = getattr(command, 'usage_more', '')
  172. extra = '' if not more else ' ' + more
  173. parser.set_usage('usage: %%prog %s [options]%s' % (cmd_name, extra))
  174. @staticmethod
  175. def _create_command_summary(cmd_name, command):
  176. """Creates a oneliner summary from the command's docstring."""
  177. if cmd_name != _function_to_name(command.__name__):
  178. # Skip aliases. For example using at module level:
  179. # CMDfoo = CMDbar
  180. return ''
  181. doc = command.__doc__ or ''
  182. line = doc.split('\n', 1)[0].rstrip('.')
  183. if not line:
  184. return line
  185. return (line[0].lower() + line[1:]).strip()
  186. def execute(self, parser, args):
  187. """Dispatches execution to the right command.
  188. Fallbacks to 'help' if not disabled.
  189. """
  190. # Unconditionally disable format_description() and format_epilog().
  191. # Technically, a formatter should be used but it's not worth (yet) the
  192. # trouble.
  193. parser.format_description = lambda _: parser.description or ''
  194. parser.format_epilog = lambda _: parser.epilog or ''
  195. if args:
  196. if args[0] in ('-h', '--help') and len(args) > 1:
  197. # Reverse the argument order so 'tool --help cmd' is rewritten
  198. # to 'tool cmd --help'.
  199. args = [args[1], args[0]] + args[2:]
  200. command = self.find_nearest_command(args[0])
  201. if command:
  202. if command.__name__ == 'CMDhelp' and len(args) > 1:
  203. # Reverse the argument order so 'tool help cmd' is rewritten
  204. # to 'tool cmd --help'. Do it here since we want 'tool help
  205. # cmd' to work too.
  206. args = [args[1], '--help'] + args[2:]
  207. command = self.find_nearest_command(args[0]) or command
  208. # "fix" the usage and the description now that we know the
  209. # subcommand.
  210. self._add_command_usage(parser, command)
  211. return command(parser, args[1:])
  212. cmdhelp = self.enumerate_commands().get('help')
  213. if cmdhelp:
  214. # Not a known command. Default to help.
  215. self._add_command_usage(parser, cmdhelp)
  216. # Don't pass list of arguments as those may not be supported by
  217. # cmdhelp. See: https://crbug.com/1352093
  218. return cmdhelp(parser, [])
  219. # Nothing can be done.
  220. return 2