Bug 1173633 - Print docstrings of mach command handlers in help output; r=ahal
authorGregory Szorc <gps@mozilla.com>
Thu, 11 Jun 2015 08:32:02 -0700
changeset 279110 dd39dc238ef15255e43ce2b93644f7b05d361725
parent 279109 c1dc1b68157994a9bd6bf069557a3a7093c211e0
child 279111 953806f0cc142d6884c7fefcd6dce45783c245dd
push id4932
push userjlund@mozilla.com
push dateMon, 10 Aug 2015 18:23:06 +0000
treeherdermozilla-beta@6dd5a4f5f745 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersahal
bugs1173633
milestone41.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1173633 - Print docstrings of mach command handlers in help output; r=ahal `mach help <command>` currently only displays a brief description of the command along with its arguments. Sometimes more detailed help text is needed. With this commit, the docstrings of mach command handlers will appear in the output of `mach help <command>` if they are defined. I've implemented basic docstrings for the three flavors of mach commands (normal command, main subcommand, subcommand) to demonstate things work. My hope is others will start to fill in docstrings once this feature lands so the output for `mach help` can serve as a better learning guide for new contributors.
python/mach/mach/base.py
python/mach/mach/decorators.py
python/mach/mach/dispatcher.py
python/mozbuild/mozbuild/frontend/mach_commands.py
testing/mach_commands.py
--- a/python/mach/mach/base.py
+++ b/python/mach/mach/base.py
@@ -72,16 +72,19 @@ class MethodHandler(object):
         'name',
 
         # String category this command belongs to.
         'category',
 
         # Description of the purpose of this command.
         'description',
 
+        # Docstring associated with command.
+        'docstring',
+
         # Functions used to 'skip' commands if they don't meet the conditions
         # in a given context.
         'conditions',
 
         # argparse.ArgumentParser instance to use as the basis for command
         # arguments.
         '_parser',
         'parser',
@@ -94,25 +97,26 @@ class MethodHandler(object):
         'argument_group_names',
 
         # Dict of string to MethodHandler defining sub commands for this
         # command.
         'subcommand_handlers',
     )
 
     def __init__(self, cls, method, name, category=None, description=None,
-        conditions=None, parser=None, arguments=None,
+        docstring=None, conditions=None, parser=None, arguments=None,
         argument_group_names=None, pass_context=False,
         subcommand_handlers=None):
 
         self.cls = cls
         self.method = method
         self.name = name
         self.category = category
         self.description = description
+        self.docstring = docstring
         self.conditions = conditions or []
         self.arguments = arguments or []
         self.argument_group_names = argument_group_names or []
         self.pass_context = pass_context
         self.subcommand_handlers = subcommand_handlers or {}
         self._parser = parser
 
     @property
--- a/python/mach/mach/decorators.py
+++ b/python/mach/mach/decorators.py
@@ -84,17 +84,18 @@ def CommandProvider(cls):
                 msg = msg % (command_name, type(c))
                 raise MachError(msg)
 
         arguments = getattr(value, '_mach_command_args', None)
 
         argument_group_names = getattr(value, '_mach_command_arg_group_names', None)
 
         handler = MethodHandler(cls, attr, command_name, category=category,
-            description=description, conditions=conditions, parser=parser,
+            description=description, docstring=value.__doc__,
+            conditions=conditions, parser=parser,
             arguments=arguments, argument_group_names=argument_group_names,
             pass_context=pass_context)
 
         Registrar.register_command_handler(handler)
 
     # Now do another pass to get sub-commands. We do this in two passes so
     # we can check the parent command existence without having to hold
     # state and reconcile after traversal.
@@ -116,16 +117,17 @@ def CommandProvider(cls):
 
         if command not in Registrar.command_handlers:
             continue
 
         arguments = getattr(value, '_mach_command_args', None)
         argument_group_names = getattr(value, '_mach_command_arg_group_names', None)
 
         handler = MethodHandler(cls, attr, subcommand, description=description,
+            docstring=value.__doc__,
             arguments=arguments, argument_group_names=argument_group_names,
             pass_context=pass_context)
         parent = Registrar.command_handlers[command]
 
         if parent.parser:
             raise MachError('cannot declare sub commands against a command '
                 'that has a parser installed: %s' % command)
         if subcommand in parent.subcommand_handlers:
--- a/python/mach/mach/dispatcher.py
+++ b/python/mach/mach/dispatcher.py
@@ -346,46 +346,87 @@ class CommandAction(argparse.Action):
                 handler.description = c_parser.description
                 c_parser.description = None
         else:
             c_parser = argparse.ArgumentParser(**parser_args)
             group = c_parser.add_argument_group('Command Arguments')
 
         self._populate_command_group(c_parser, handler, group)
 
-        # This will print the description of the command below the usage.
-        description = handler.description
-        if description:
-            parser.description = description
+        # Set the long help of the command to the docstring (if present) or
+        # the command decorator description argument (if present).
+        if handler.docstring:
+            parser.description = format_docstring(handler.docstring)
+        elif handler.description:
+            parser.description = handler.description
 
         parser.usage = '%(prog)s [global arguments] ' + command + \
             ' [command arguments]'
+
+        # This is needed to preserve line endings in the description field,
+        # which may be populated from a docstring.
+        parser.formatter_class = argparse.RawDescriptionHelpFormatter
         parser.print_help()
         print('')
         c_parser.print_help()
 
     def _handle_subcommand_main_help(self, parser, handler):
         parser.usage = '%(prog)s [global arguments] ' + handler.name + \
             ' subcommand [subcommand arguments]'
         group = parser.add_argument_group('Sub Commands')
 
         for subcommand, subhandler in sorted(handler.subcommand_handlers.iteritems()):
             group.add_argument(subcommand, help=subhandler.description,
                 action='store_true')
 
+        if handler.docstring:
+            parser.description = format_docstring(handler.docstring)
+
+        parser.formatter_class = argparse.RawDescriptionHelpFormatter
+
         parser.print_help()
 
     def _handle_subcommand_help(self, parser, command, subcommand, handler):
         parser.usage = '%(prog)s [global arguments] ' + command + \
             ' ' + subcommand + ' [command arguments]'
 
         c_parser = argparse.ArgumentParser(add_help=False,
             formatter_class=CommandFormatter)
         group = c_parser.add_argument_group('Sub Command Arguments')
         self._populate_command_group(c_parser, handler, group)
 
+        if handler.docstring:
+            parser.description = format_docstring(handler.docstring)
+
+        parser.formatter_class = argparse.RawDescriptionHelpFormatter
+
         parser.print_help()
         print('')
         c_parser.print_help()
 
+
 class NoUsageFormatter(argparse.HelpFormatter):
     def _format_usage(self, *args, **kwargs):
         return ""
+
+
+def format_docstring(docstring):
+    """Format a raw docstring into something suitable for presentation.
+
+    This function is based on the example function in PEP-0257.
+    """
+    if not docstring:
+        return ''
+    lines = docstring.expandtabs().splitlines()
+    indent = sys.maxint
+    for line in lines[1:]:
+        stripped = line.lstrip()
+        if stripped:
+            indent = min(indent, len(line) - len(stripped))
+    trimmed = [lines[0].strip()]
+    if indent < sys.maxint:
+        for line in lines[1:]:
+            trimmed.append(line[indent:].rstrip())
+    while trimmed and not trimmed[-1]:
+        trimmed.pop()
+    while trimmed and not trimmed[0]:
+        trimmed.pop(0)
+    return '\n'.join(trimmed)
--- a/python/mozbuild/mozbuild/frontend/mach_commands.py
+++ b/python/mozbuild/mozbuild/frontend/mach_commands.py
@@ -80,25 +80,34 @@ class MozbuildFileCommands(MachCommandBa
         for line in format_module(m):
             print(line)
 
         return 0
 
     @Command('file-info', category='build-dev',
              description='Query for metadata about files.')
     def file_info(self):
-        pass
+        """Show files metadata derived from moz.build files.
+
+        moz.build files contain "Files" sub-contexts for declaring metadata
+        against file patterns. This command suite is used to query that data.
+        """
 
     @SubCommand('file-info', 'bugzilla-component',
                 'Show Bugzilla component info for files listed.')
     @CommandArgument('-r', '--rev',
                      help='Version control revision to look up info from')
     @CommandArgument('paths', nargs='+',
                      help='Paths whose data to query')
     def file_info_bugzilla(self, paths, rev=None):
+        """Show Bugzilla component for a set of files.
+
+        Given a requested set of files (which can be specified using
+        wildcards), print the Bugzilla component for each file.
+        """
         components = defaultdict(set)
         try:
             for p, m in self._get_files_info(paths, rev=rev).items():
                 components[m.get('BUG_COMPONENT')].add(p)
         except InvalidPathException as e:
             print(e.message)
             return 1
 
--- a/testing/mach_commands.py
+++ b/testing/mach_commands.py
@@ -179,16 +179,34 @@ The following test suites and aliases ar
 TEST_HELP = TEST_HELP.strip()
 
 
 @CommandProvider
 class Test(MachCommandBase):
     @Command('test', category='testing', description='Run tests (detects the kind of test and runs it).')
     @CommandArgument('what', default=None, nargs='*', help=TEST_HELP)
     def test(self, what):
+        """Run tests from names or paths.
+
+        mach test accepts arguments specifying which tests to run. Each argument
+        can be:
+
+        * The path to a test file
+        * A directory containing tests
+        * A test suite name
+        * An alias to a test suite name (codes used on TreeHerder)
+
+        When paths or directories are given, they are first resolved to test
+        files known to the build system.
+
+        If resolved tests belong to more than one test type/flavor/harness,
+        the harness for each relevant type/flavor will be invoked. e.g. if
+        you specify a directory with xpcshell and browser chrome mochitests,
+        both harnesses will be invoked.
+        """
         from mozbuild.testing import TestResolver
 
         # Parse arguments and assemble a test "plan."
         run_suites = set()
         run_tests = []
         resolver = self._spawn(TestResolver)
 
         for entry in what: