Bug 856392 - Categorize mach commands; r=jhammel
authorGregory Szorc <gps@mozilla.com>
Wed, 08 May 2013 17:56:30 -0700
changeset 143106 dac8cb02fd216d408a9da4329b9d0ee1cb940b7a
parent 143105 02f1eeef26139b5d4a0f86a5fc059c4622aa405d
child 143107 56425c0947591ca8dc5720ffdc4b45e430192748
push id350
push userbbajaj@mozilla.com
push dateMon, 29 Jul 2013 23:00:49 +0000
treeherdermozilla-release@064965b37dbd [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjhammel
bugs856392
milestone23.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 856392 - Categorize mach commands; r=jhammel DONTBUILD (NPOTB)
addon-sdk/mach_commands.py
build/mach_bootstrap.py
layout/tools/reftest/mach_commands.py
python/mach/mach/base.py
python/mach/mach/commands/commandinfo.py
python/mach/mach/commands/settings.py
python/mach/mach/decorators.py
python/mach/mach/dispatcher.py
python/mach/mach/main.py
python/mach/mach/registrar.py
python/mozboot/mozboot/mach_commands.py
python/mozbuild/mozbuild/frontend/mach_commands.py
python/mozbuild/mozbuild/mach_commands.py
testing/marionette/mach_commands.py
testing/mochitest/mach_commands.py
testing/xpcshell/mach_commands.py
tools/mach_commands.py
--- a/addon-sdk/mach_commands.py
+++ b/addon-sdk/mach_commands.py
@@ -20,17 +20,18 @@ from mach.decorators import (
 
 class JetpackRunner(MozbuildObject):
     """Run jetpack tests."""
     def run_tests(self, **kwargs):
         self._run_make(target='jetpack-tests')
 
 @CommandProvider
 class MachCommands(MachCommandBase):
-    @Command('jetpack-test', help='Runs the jetpack test suite.')
+    @Command('jetpack-test', category='testing',
+        description='Runs the jetpack test suite.')
     def run_jetpack_test(self, **params):
         # We should probably have a utility function to ensure the tree is
         # ready to run tests. Until then, we just create the state dir (in
         # case the tree wasn't built with mach).
         self._ensure_state_subdir_exists('.')
 
         jetpack = self._spawn(JetpackRunner)
 
--- a/build/mach_bootstrap.py
+++ b/build/mach_bootstrap.py
@@ -46,16 +46,51 @@ MACH_MODULES = [
     'python/mozbuild/mozbuild/mach_commands.py',
     'python/mozbuild/mozbuild/frontend/mach_commands.py',
     'testing/marionette/mach_commands.py',
     'testing/mochitest/mach_commands.py',
     'testing/xpcshell/mach_commands.py',
     'tools/mach_commands.py',
 ]
 
+
+CATEGORIES = {
+    'build': {
+        'short': 'Build Commands',
+        'long': 'Interact with the build system',
+        'priority': 80,
+    },
+    'post-build': {
+        'short': 'Post-build Commands',
+        'long': 'Common actions performed after completing a build.',
+        'priority': 70,
+    },
+    'testing': {
+        'short': 'Testing',
+        'long': 'Run tests.',
+        'priority': 60,
+    },
+    'devenv': {
+        'short': 'Development Environment',
+        'long': 'Set up and configure your development environment.',
+        'priority': 50,
+    },
+    'build-dev': {
+        'short': 'Low-level Build System Interaction',
+        'long': 'Interact with specific parts of the build system.',
+        'priority': 20,
+    },
+    'misc': {
+        'short': 'Potpourri',
+        'long': 'Potent potables and assorted snacks.',
+        'priority': 10,
+    }
+}
+
+
 def bootstrap(topsrcdir, mozilla_dir=None):
     if mozilla_dir is None:
         mozilla_dir = topsrcdir
 
     # Ensure we are running Python 2.7+. We put this check here so we generate a
     # user-friendly error message rather than a cryptic stack trace on module
     # import.
     if sys.version_info[0] != 2 or sys.version_info[1] < 7:
@@ -65,11 +100,16 @@ def bootstrap(topsrcdir, mozilla_dir=Non
 
     try:
         import mach.main
     except ImportError:
         sys.path[0:0] = [os.path.join(mozilla_dir, path) for path in SEARCH_PATHS]
         import mach.main
 
     mach = mach.main.Mach(topsrcdir)
+    for category, meta in CATEGORIES.items():
+        mach.define_category(category, meta['short'], meta['long'],
+            meta['priority'])
+
     for path in MACH_MODULES:
         mach.load_commands_from_file(os.path.join(mozilla_dir, path))
+
     return mach
--- a/layout/tools/reftest/mach_commands.py
+++ b/layout/tools/reftest/mach_commands.py
@@ -122,32 +122,35 @@ def ReftestCommand(func):
              'reftest.list. If omitted, the entire test suite is executed.')
     func = path(func)
 
     return func
 
 
 @CommandProvider
 class MachCommands(MachCommandBase):
-    @Command('reftest', help='Run a reftest.')
+    @Command('reftest', category='testing', description='Run reftests.')
     @ReftestCommand
     def run_reftest(self, test_file, **kwargs):
         return self._run_reftest(test_file, suite='reftest', **kwargs)
 
-    @Command('reftest-ipc', help='Run IPC reftests.')
+    @Command('reftest-ipc', category='testing',
+        description='Run IPC reftests.')
     @ReftestCommand
     def run_ipc(self, test_file, **kwargs):
         return self._run_reftest(test_file, suite='reftest-ipc', **kwargs)
 
-    @Command('crashtest', help='Run a crashtest.')
+    @Command('crashtest', category='testing',
+        description='Run crashtests.')
     @ReftestCommand
     def run_crashtest(self, test_file, **kwargs):
         return self._run_reftest(test_file, suite='crashtest', **kwargs)
 
-    @Command('crashtest-ipc', help='Run IPC crashtests.')
+    @Command('crashtest-ipc', category='testing',
+        description='Run IPC crashtests.')
     @ReftestCommand
     def run_crashtest_ipc(self, test_file, **kwargs):
         return self._run_reftest(test_file, suite='crashtest-ipc', **kwargs)
 
     def _run_reftest(self, test_file=None, filter=None, suite=None,
             debugger=None):
         reftest = self._spawn(ReftestRunner)
         return reftest.run_reftest_test(test_file, filter=filter, suite=suite,
--- a/python/mach/mach/base.py
+++ b/python/mach/mach/base.py
@@ -6,16 +6,43 @@ from __future__ import unicode_literals
 
 from collections import namedtuple
 
 # Holds mach run-time state so it can easily be passed to command providers.
 CommandContext = namedtuple('CommandContext', ['topdir', 'cwd',
     'settings', 'log_manager', 'commands'])
 
 
+class MachError(Exception):
+    """Base class for all errors raised by mach itself."""
+
+
+class NoCommandError(MachError):
+    """No command was passed into mach."""
+
+
+class UnknownCommandError(MachError):
+    """Raised when we attempted to execute an unknown command."""
+
+    def __init__(self, command, verb):
+        MachError.__init__(self)
+
+        self.command = command
+        self.verb = verb
+
+class UnrecognizedArgumentError(MachError):
+    """Raised when an unknown argument is passed to mach."""
+
+    def __init__(self, command, arguments):
+        MachError.__init__(self)
+
+        self.command = command
+        self.arguments = arguments
+
+
 class MethodHandler(object):
     """Describes a Python method that implements a mach command.
 
     Instances of these are produced by mach when it processes classes
     defining mach commands.
     """
     __slots__ = (
         # The Python class providing the command. This is the class type not
@@ -28,28 +55,37 @@ class MethodHandler(object):
         # it instantiates classes.
         'pass_context',
 
         # The name of the method providing the command. In other words, this
         # is the str name of the attribute on the class type corresponding to
         # the name of the function.
         'method',
 
-        # The argparse subparser for this command's arguments.
-        'parser',
+        # The name of the command.
+        'name',
+
+        # String category this command belongs to.
+        'category',
 
-        # Arguments passed to add_parser() on the main mach subparser. This is
-        # a 2-tuple of positional and named arguments, respectively.
-        'parser_args',
+        # Description of the purpose of this command.
+        'description',
+
+        # Whether to allow all arguments from the parser.
+        'allow_all_arguments',
 
         # Arguments added to this command's parser. This is a 2-tuple of
         # positional and named arguments, respectively.
         'arguments',
     )
 
-    def __init__(self, cls, method, parser_args, arguments=None,
-        pass_context=False):
+    def __init__(self, cls, method, name, category=None, description=None,
+        allow_all_arguments=False, arguments=None, pass_context=False):
 
         self.cls = cls
         self.method = method
-        self.parser_args = parser_args
+        self.name = name
+        self.category = category
+        self.description = description
+        self.allow_all_arguments = allow_all_arguments
         self.arguments = arguments or []
         self.pass_context = pass_context
+
--- a/python/mach/mach/commands/commandinfo.py
+++ b/python/mach/mach/commands/commandinfo.py
@@ -10,17 +10,18 @@ from mach.decorators import (
 )
 
 
 @CommandProvider
 class BuiltinCommands(object):
     def __init__(self, context):
         self.context = context
 
-    @Command('mach-debug-commands', help='Show info about available mach commands.')
+    @Command('mach-debug-commands', category='misc',
+        description='Show info about available mach commands.')
     def commands(self):
         import inspect
 
         handlers = self.context.commands.command_handlers
         for command in sorted(handlers.keys()):
             handler = handlers[command]
             cls = handler.cls
             method = getattr(cls, getattr(handler, 'method'))
--- a/python/mach/mach/commands/settings.py
+++ b/python/mach/mach/commands/settings.py
@@ -18,26 +18,27 @@ class Settings(object):
 
     Currently, we only provide functionality to view what settings are
     available. In the future, this module will be used to modify settings, help
     people create configs via a wizard, etc.
     """
     def __init__(self, context):
         self.settings = context.settings
 
-    @Command('settings-list', help='Show available config settings.')
+    @Command('settings-list', category='devenv',
+        description='Show available config settings.')
     def list_settings(self):
         """List available settings in a concise list."""
         for section in sorted(self.settings):
             for option in sorted(self.settings[section]):
                 short, full = self.settings.option_help(section, option)
                 print('%s.%s -- %s' % (section, option, short))
 
-    @Command('settings-create',
-        help='Print a new settings file with usage info.')
+    @Command('settings-create', category='devenv',
+        description='Print a new settings file with usage info.')
     def create(self):
         """Create an empty settings file with full documentation."""
         wrapper = TextWrapper(initial_indent='# ', subsequent_indent='# ')
 
         for section in sorted(self.settings):
             print('[%s]' % section)
             print('')
 
--- a/python/mach/mach/decorators.py
+++ b/python/mach/mach/decorators.py
@@ -2,17 +2,21 @@
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 from __future__ import unicode_literals
 
 import inspect
 import types
 
-from .base import MethodHandler
+from .base import (
+    MachError,
+    MethodHandler
+)
+
 from .config import ConfigProvider
 from .registrar import Registrar
 
 
 def CommandProvider(cls):
     """Class decorator to denote that it provides subcommands for Mach.
 
     When this decorator is present, mach looks for commands being defined by
@@ -31,63 +35,78 @@ def CommandProvider(cls):
 
     if inspect.ismethod(cls.__init__):
         spec = inspect.getargspec(cls.__init__)
 
         if len(spec.args) > 2:
             msg = 'Mach @CommandProvider class %s implemented incorrectly. ' + \
                   '__init__() must take 1 or 2 arguments. From %s'
             msg = msg % (cls.__name__, inspect.getsourcefile(cls))
-            raise Exception(msg)
+            raise MachError(msg)
 
         if len(spec.args) == 2:
             pass_context = True
 
     # We scan __dict__ because we only care about the classes own attributes,
     # not inherited ones. If we did inherited attributes, we could potentially
     # define commands multiple times. We also sort keys so commands defined in
     # the same class are grouped in a sane order.
     for attr in sorted(cls.__dict__.keys()):
         value = cls.__dict__[attr]
 
         if not isinstance(value, types.FunctionType):
             continue
 
-        parser_args = getattr(value, '_mach_command', None)
-        if parser_args is None:
+        command_name, category, description, allow_all = getattr(value,
+            '_mach_command', (None, None, None, None))
+
+        if command_name is None:
             continue
 
         arguments = getattr(value, '_mach_command_args', None)
 
-        handler = MethodHandler(cls, attr, (parser_args[0], parser_args[1]),
+        handler = MethodHandler(cls, attr, command_name, category=category,
+            description=description, allow_all_arguments=allow_all,
             arguments=arguments, pass_context=pass_context)
 
         Registrar.register_command_handler(handler)
 
     return cls
 
 
 class Command(object):
     """Decorator for functions or methods that provide a mach subcommand.
 
-    The decorator accepts arguments that would be passed to add_parser() of an
-    ArgumentParser instance created via add_subparsers(). Essentially, it
-    accepts the arguments one would pass to add_argument().
+    The decorator accepts arguments that define basic attributes of the
+    command. The following arguments are recognized:
+
+         category -- The string category to which this command belongs. Mach's
+             help will group commands by category.
+
+         description -- A brief description of what the command does.
+
+         allow_all_args -- Bool indicating whether to allow unknown arguments
+             through to the command.
 
     For example:
 
-        @Command('foo', help='Run the foo action')
+        @Command('foo', category='misc', description='Run the foo action')
         def foo(self):
             pass
     """
-    def __init__(self, *args, **kwargs):
-        self._command_args = (args, kwargs)
+    def __init__(self, name, category=None, description=None,
+        allow_all_args=False):
+        self._name = name
+        self._category = category
+        self._description = description
+        self._allow_all_args = allow_all_args
 
     def __call__(self, func):
-        func._mach_command = self._command_args
+        func._mach_command = (self._name, self._category, self._description,
+            self._allow_all_args)
 
         return func
 
 
 class CommandArgument(object):
     """Decorator for additional arguments to mach subcommands.
 
     This decorator should be used to add arguments to mach commands. Arguments
@@ -108,25 +127,26 @@ class CommandArgument(object):
         command_args = getattr(func, '_mach_command_args', [])
 
         command_args.insert(0, self._command_args)
 
         func._mach_command_args = command_args
 
         return func
 
+
 def SettingsProvider(cls):
     """Class decorator to denote that this class provides Mach settings.
 
     When this decorator is encountered, the underlying class will automatically
     be registered with the Mach registrar and will (likely) be hooked up to the
     mach driver.
 
     This decorator is only allowed on mach.config.ConfigProvider classes.
     """
     if not issubclass(cls, ConfigProvider):
-        raise Exception('@SettingsProvider encountered on class that does ' +
+        raise MachError('@SettingsProvider encountered on class that does ' +
                         'not derived from mach.config.ConfigProvider.')
 
     Registrar.register_settings_provider(cls)
 
     return cls
 
new file mode 100644
--- /dev/null
+++ b/python/mach/mach/dispatcher.py
@@ -0,0 +1,176 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+from __future__ import unicode_literals
+
+import argparse
+import sys
+
+from operator import itemgetter
+
+from .base import (
+    NoCommandError,
+    UnknownCommandError,
+    UnrecognizedArgumentError,
+)
+
+
+class CommandAction(argparse.Action):
+    """An argparse action that handles mach commands.
+
+    This class is essentially a reimplementation of argparse's sub-parsers
+    feature. We first tried to use sub-parsers. However, they were missing
+    features like grouping of commands (http://bugs.python.org/issue14037).
+
+    The way this works involves light magic and a partial understanding of how
+    argparse works.
+
+    Arguments registered with an argparse.ArgumentParser have an action
+    associated with them. An action is essentially a class that when called
+    does something with the encountered argument(s). This class is one of those
+    action classes.
+
+    An instance of this class is created doing something like:
+
+        parser.add_argument('command', action=CommandAction, registrar=r)
+
+    Note that a mach.registrar.Registrar instance is passed in. The Registrar
+    holds information on all the mach commands that have been registered.
+
+    When this argument is registered with the ArgumentParser, an instance of
+    this class is instantiated. One of the subtle but important things it does
+    is tell the argument parser that it's interested in *all* of the remaining
+    program arguments. So, when the ArgumentParser calls this action, we will
+    receive the command name plus all of its arguments.
+
+    For more, read the docs in __call__.
+    """
+    def __init__(self, option_strings, dest, required=True, default=None,
+        registrar=None):
+        # A proper API would have **kwargs here. However, since we are a little
+        # hacky, we intentionally omit it as a way of detecting potentially
+        # breaking changes with argparse's implementation.
+        #
+        # In a similar vein, default is passed in but is not needed, so we drop
+        # it.
+        argparse.Action.__init__(self, option_strings, dest, required=required,
+            help=argparse.SUPPRESS, nargs=argparse.REMAINDER)
+
+        self._mach_registrar = registrar
+
+    def __call__(self, parser, namespace, values, option_string=None):
+        """This is called when the ArgumentParser has reached our arguments.
+
+        Since we always register ourselves with nargs=argparse.REMAINDER,
+        values should be a list of remaining arguments to parse. The first
+        argument should be the name of the command to invoke and all remaining
+        arguments are arguments for that command.
+
+        The gist of the flow is that we look at the command being invoked. If
+        it's *help*, we handle that specially (because argparse's default help
+        handler isn't satisfactory). Else, we create a new, independent
+        ArgumentParser instance for just the invoked command (based on the
+        information contained in the command registrar) and feed the arguments
+        into that parser. We then merge the results with the main
+        ArgumentParser.
+        """
+        if not values:
+            raise NoCommandError()
+
+        command = values[0]
+        args = values[1:]
+
+        if command == 'help':
+            if len(args):
+                self._handle_subcommand_help(parser, args[0])
+            else:
+                self._handle_main_help(parser)
+
+            sys.exit(0)
+
+        handler = self._mach_registrar.command_handlers.get(command)
+
+        # FUTURE consider looking for commands with similar names and
+        # suggest or run them.
+        if not handler:
+            raise UnknownCommandError(command, 'run')
+
+        # FUTURE
+        # If we wanted to conditionally enable commands based on whether
+        # it's possible to run them given the current state of system, here
+        # would be a good place to hook that up.
+
+        # We create a new parser, populate it with the command's arguments,
+        # then feed all remaining arguments to it, merging the results
+        # with ourselves. This is essentially what argparse subparsers
+        # do.
+
+        parser_args = {
+            'add_help': False,
+            'usage': '%(prog)s [global arguments] ' + command +
+                ' command arguments]',
+        }
+
+        if handler.allow_all_arguments:
+            parser_args['prefix_chars'] = '+'
+
+        subparser = argparse.ArgumentParser(**parser_args)
+
+        for arg in handler.arguments:
+            subparser.add_argument(*arg[0], **arg[1])
+
+        # We define the command information on the main parser result so as to
+        # not interfere with arguments passed to the command.
+        setattr(namespace, 'mach_handler', handler)
+        setattr(namespace, 'command', command)
+
+        command_namespace, extra = subparser.parse_known_args(args)
+        setattr(namespace, 'command_args', command_namespace)
+
+        if extra:
+            raise UnrecognizedArgumentError(command, extra)
+
+    def _handle_main_help(self, parser):
+        # Since we don't need full sub-parser support for the main help output,
+        # we create groups in the ArgumentParser and populate each group with
+        # arguments corresponding to command names. This has the side-effect
+        # that argparse renders it nicely.
+        r = self._mach_registrar
+
+        cats = [(k, v[2]) for k, v in r.categories.items()]
+        sorted_cats = sorted(cats, key=itemgetter(1), reverse=True)
+        for category, priority in sorted_cats:
+            title, description, _priority = r.categories[category]
+
+            group = parser.add_argument_group(title, description)
+
+            for command in sorted(r.commands_by_category[category]):
+                handler = r.command_handlers[command]
+                description = handler.description
+
+                group.add_argument(command, help=description,
+                    action='store_true')
+
+        parser.print_help()
+
+    def _handle_subcommand_help(self, parser, command):
+        handler = self._mach_registrar.command_handlers.get(command)
+
+        if not handler:
+            raise UnknownCommandError(command, 'query')
+
+        group = parser.add_argument_group('Command Arguments')
+
+        for arg in handler.arguments:
+            group.add_argument(*arg[0], **arg[1])
+
+        # This will print the description of the command below the usage.
+        description = handler.description
+        if description:
+            parser.description = description
+
+        parser.usage = '%(prog)s [global arguments] ' + command + \
+            ' [command arguments]'
+        parser.print_help()
+
--- a/python/mach/mach/main.py
+++ b/python/mach/mach/main.py
@@ -12,42 +12,64 @@ import codecs
 import imp
 import logging
 import os
 import sys
 import traceback
 import uuid
 import sys
 
-from .base import CommandContext
+from .base import (
+    CommandContext,
+    MachError,
+    NoCommandError,
+    UnknownCommandError,
+    UnrecognizedArgumentError,
+)
 
 from .decorators import (
     CommandArgument,
     CommandProvider,
     Command,
 )
 
 from .config import ConfigSettings
+from .dispatcher import CommandAction
 from .logging import LoggingManager
-
 from .registrar import Registrar
 
 
-# Settings for argument parser that don't get proxied to sub-module. i.e. these
-# are things consumed by the driver itself.
-CONSUMED_ARGUMENTS = [
-    'settings_file',
-    'verbose',
-    'logfile',
-    'log_interval',
-    'command',
-    'mach_class',
-    'mach_method',
-    'mach_pass_context',
-]
+AVATAR = '''
+                       _.-;:q=._
+                      .' j=""^k;:\.
+                     ; .F       ";`Y
+                    ,;.J_        ;'j
+                  ,-;"^7F       : .F
+                 ,-'-_<.        ;gj.
+                ;  _,._`\.     : `T"5
+                : `?8w7 `J  ,-'" -^q.
+                 \;._ _,=' ;   n58L Y.
+                   F;";  .' k_ `^'  j'
+                   J;:: ;     "y:-='
+                    L;;==      |:;   jT\\
+                    L;:;J      J:L  7:;'
+                    I;|:.L     |:k J:.'
+                    |;J:.|     ;.I F.:
+                    J;:L::     |.| |.J
+                    J:`J.`.    :.J |. L
+                     L :k:`._ ,',j J; |
+                     I :`=.:."_".'  L J
+                     |.:  `"-=-'    |.J
+                     `: :           ;:;
+                      J: :         /.;'
+                       k;.\.    _.;:Y'
+                        `Y;."-=';:='
+                          `"==="'
+'''
+
 
 MACH_ERROR = r'''
 The error occurred in mach itself. This is likely a bug in mach itself or a
 fundamental problem with a loaded module.
 
 Please consider filing a bug against mach by going to the URL:
 
     https://bugzilla.mozilla.org/enter_bug.cgi?product=Core&component=mach
@@ -70,16 +92,34 @@ command. Consider filing a bug for this 
 
 MODULE_ERROR = r'''
 The error occurred in code that was called by the mach command. This is either
 a bug in the called code itself or in the way that mach is calling it.
 
 You should consider filing a bug for this issue.
 '''.lstrip()
 
+NO_COMMAND_ERROR = r'''
+It looks like you tried to run mach without a command.
+
+Run |mach help| to show a list of commands.
+'''.lstrip()
+
+UNKNOWN_COMMAND_ERROR = r'''
+It looks like you are trying to %s an unknown mach command: %s
+
+Run |mach help| to show a list of commands.
+'''.lstrip()
+
+UNRECOGNIZED_ARGUMENT_ERROR = r'''
+It looks like you passed an unrecognized argument into mach.
+
+The %s command does not accept the arguments: %s
+'''.lstrip()
+
 
 class ArgumentParser(argparse.ArgumentParser):
     """Custom implementation argument parser to make things look pretty."""
 
     def error(self, message):
         """Custom error reporter to give more helpful text on bad commands."""
         if not message.startswith('argument command: invalid choice'):
             argparse.ArgumentParser.error(self, message)
@@ -172,16 +212,21 @@ To see more help for a specific command,
             if b'mach.commands' not in sys.modules:
                 mod = imp.new_module(b'mach.commands')
                 sys.modules[b'mach.commands'] = mod
 
             module_name = 'mach.commands.%s' % uuid.uuid1().get_hex()
 
         imp.load_source(module_name, path)
 
+    def define_category(self, name, title, description, priority=50):
+        """Provide a description for a named command category."""
+
+        Registrar.register_category(name, title, description, priority)
+
     def run(self, argv, stdin=None, stdout=None, stderr=None):
         """Runs mach with arguments provided from the command line.
 
         Returns the integer exit code that should be used. 0 means success. All
         other values indicate failure.
         """
 
         # If no encoding is defined, we default to UTF-8 because without this
@@ -243,28 +288,31 @@ To see more help for a specific command,
         if not len(argv):
             # We don't register the usage until here because if it is globally
             # registered, argparse always prints it. This is not desired when
             # running with --help.
             parser.usage = Mach.USAGE
             parser.print_usage()
             return 0
 
-        args = parser.parse_args(argv)
-
-        if args.command == 'help':
-            if args.subcommand is None:
-                parser.usage = \
-                    '%(prog)s [global arguments] command [command arguments]'
-                parser.print_help()
-                return 0
-
-            handler = Registrar.command_handlers[args.subcommand]
-            handler.parser.print_help()
-            return 0
+        try:
+            args = parser.parse_args(argv)
+        except NoCommandError:
+            print(AVATAR)
+            print(NO_COMMAND_ERROR)
+            return 1
+        except UnknownCommandError as e:
+            print(AVATAR)
+            print(UNKNOWN_COMMAND_ERROR % (e.verb, e.command))
+            return 1
+        except UnrecognizedArgumentError as e:
+            print(AVATAR)
+            print(UNRECOGNIZED_ARGUMENT_ERROR % (e.command,
+                ' '.join(e.arguments)))
+            return 1
 
         # Add JSON logging to a file if requested.
         if args.logfile:
             self.log_manager.add_json_handler(args.logfile)
 
         # Up the logging level if requested.
         log_level = logging.INFO
         if args.verbose:
@@ -274,37 +322,35 @@ To see more help for a specific command,
 
         # Always enable terminal logging. The log manager figures out if we are
         # actually in a TTY or are a pipe and does the right thing.
         self.log_manager.add_terminal_logging(level=log_level,
             write_interval=args.log_interval)
 
         self.load_settings(args)
 
-        stripped = {k: getattr(args, k) for k in vars(args) if k not in
-            CONSUMED_ARGUMENTS}
+        if not hasattr(args, 'mach_handler'):
+            raise MachError('ArgumentParser result missing mach handler info.')
 
         context = CommandContext(topdir=self.cwd, cwd=self.cwd,
             settings=self.settings, log_manager=self.log_manager,
             commands=Registrar)
 
-        if not hasattr(args, 'mach_class'):
-            raise Exception('ArgumentParser result missing mach_class.')
+        handler = getattr(args, 'mach_handler')
+        cls = handler.cls
 
-        cls = getattr(args, 'mach_class')
-
-        if getattr(args, 'mach_pass_context'):
+        if handler.pass_context:
             instance = cls(context)
         else:
             instance = cls()
 
-        fn = getattr(instance, getattr(args, 'mach_method'))
+        fn = getattr(instance, handler.method)
 
         try:
-            result = fn(**stripped)
+            result = fn(**vars(args.command_args))
 
             if not result:
                 result = 0
 
             assert isinstance(result, (int, long))
 
             return result
         except KeyboardInterrupt as ki:
@@ -414,46 +460,36 @@ To see more help for a specific command,
         self.settings.load_file(p)
 
         return os.path.exists(p)
 
     def get_argument_parser(self):
         """Returns an argument parser for the command-line interface."""
 
         parser = ArgumentParser(add_help=False,
-            usage='%(prog)s [global arguments]')
+            usage='%(prog)s [global arguments] command [command arguments]')
 
         # Order is important here as it dictates the order the auto-generated
         # help messages are printed.
-        subparser = parser.add_subparsers(dest='command', title='Commands')
-        parser.set_defaults(command='help')
-
         global_group = parser.add_argument_group('Global Arguments')
 
-        global_group.add_argument('-h', '--help', action='help',
-            help='Show this help message and exit.')
-
         #global_group.add_argument('--settings', dest='settings_file',
         #    metavar='FILENAME', help='Path to settings file.')
 
         global_group.add_argument('-v', '--verbose', dest='verbose',
             action='store_true', default=False,
             help='Print verbose output.')
         global_group.add_argument('-l', '--log-file', dest='logfile',
             metavar='FILENAME', type=argparse.FileType('ab'),
             help='Filename to write log data to.')
         global_group.add_argument('--log-interval', dest='log_interval',
             action='store_true', default=False,
             help='Prefix log line with interval from last message rather '
                 'than relative time. Note that this is NOT execution time '
                 'if there are parallel operations.')
 
-        Registrar.populate_argument_parser(subparser)
+        # We need to be last because CommandAction swallows all remaining
+        # arguments and argparse parses arguments in the order they were added.
+        parser.add_argument('command', action=CommandAction,
+            registrar=Registrar)
 
         return parser
 
-    @Command('help', help='Show mach usage info or help for a command.')
-    @CommandArgument('subcommand', default=None, nargs='?',
-        help='Command to show help info for.')
-    def _help(self, subcommand=None):
-        # The built-in handler doesn't pass the original ArgumentParser into
-        # handlers (yet). This command is currently handled by _run().
-        assert False
--- a/python/mach/mach/registrar.py
+++ b/python/mach/mach/registrar.py
@@ -1,40 +1,42 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 from __future__ import unicode_literals
 
-import collections
+from .base import MachError
 
 
 class MachRegistrar(object):
     """Container for mach command and config providers."""
 
     def __init__(self):
         self.command_handlers = {}
+        self.commands_by_category = {}
         self.settings_providers = set()
+        self.categories = {}
 
     def register_command_handler(self, handler):
-        name = handler.parser_args[0][0]
+        name = handler.name
+
+        if not handler.category:
+            raise MachError('Cannot register a mach command without a '
+                'category: %s' % name)
+
+        if handler.category not in self.categories:
+            raise MachError('Cannot register a command to an undefined '
+                'category: %s -> %s' % (name, handler.category))
 
         self.command_handlers[name] = handler
+        self.commands_by_category[handler.category].add(name)
 
     def register_settings_provider(self, cls):
         self.settings_providers.add(cls)
 
-    def populate_argument_parser(self, parser):
-        for command in sorted(self.command_handlers.keys()):
-            handler = self.command_handlers[command]
-            handler.parser = parser.add_parser(*handler.parser_args[0],
-                **handler.parser_args[1])
-
-            for arg in handler.arguments:
-                handler.parser.add_argument(*arg[0], **arg[1])
-
-            handler.parser.set_defaults(mach_class=handler.cls,
-                mach_method=handler.method,
-                mach_pass_context=handler.pass_context)
+    def register_category(self, name, title, description, priority=50):
+        self.categories[name] = (title, description, priority)
+        self.commands_by_category[name] = set()
 
 
 Registrar = MachRegistrar()
 
--- a/python/mozboot/mozboot/mach_commands.py
+++ b/python/mozboot/mozboot/mach_commands.py
@@ -10,15 +10,15 @@ from mach.decorators import (
     Command,
 )
 
 
 @CommandProvider
 class Bootstrap(object):
     """Bootstrap system and mach for optimal development experience."""
 
-    @Command('bootstrap',
-        help='Install required system packages for building.')
+    @Command('bootstrap', category='devenv',
+        description='Install required system packages for building.')
     def bootstrap(self):
         from mozboot.bootstrap import Bootstrapper
 
         bootstrapper = Bootstrapper()
         bootstrapper.bootstrap()
--- a/python/mozbuild/mozbuild/frontend/mach_commands.py
+++ b/python/mozbuild/mozbuild/frontend/mach_commands.py
@@ -38,18 +38,18 @@ def print_extra(extra):
         print('')
 
     if not len(extra):
         print('')
 
 
 @CommandProvider
 class MozbuildFileCommands(object):
-    @Command('mozbuild-reference',
-        help='View reference documentation on mozbuild files.')
+    @Command('mozbuild-reference', category='build-dev',
+        description='View reference documentation on mozbuild files.')
     @CommandArgument('symbol', default=None, nargs='*',
         help='Symbol to view help on. If not specified, all will be shown.')
     @CommandArgument('--name-only', '-n', default=False, action='store_true',
         help='Print symbol names only.')
     def reference(self, symbol, name_only=False):
         if name_only:
             for s in sorted(VARIABLES.keys()):
                 print(s)
--- a/python/mozbuild/mozbuild/mach_commands.py
+++ b/python/mozbuild/mozbuild/mach_commands.py
@@ -41,17 +41,17 @@ Preferences.
 ===================
 '''.strip()
 
 
 @CommandProvider
 class Build(MachCommandBase):
     """Interface to build the tree."""
 
-    @Command('build', help='Build the tree.')
+    @Command('build', category='build', description='Build the tree.')
     @CommandArgument('--jobs', '-j', default='0', metavar='jobs', type=int,
         help='Number of concurrent jobs to run. Default is the number of CPUs.')
     @CommandArgument('what', default=None, nargs='*', help=BUILD_WHAT_HELP)
     @CommandArgument('-X', '--disable-extra-make-dependencies',
                      default=False, action='store_true',
                      help='Do not add extra make dependencies.')
     def build(self, what=None, disable_extra_make_dependencies=None, jobs=0):
         # This code is only meant to be temporary until the more robust tree
@@ -219,33 +219,35 @@ class Build(MachCommandBase):
         # error.
         finder_percent = cpu_seconds / elapsed * 100
         if finder_percent < 25:
             return
 
         print(FINDER_SLOW_MESSAGE % finder_percent)
 
 
-    @Command('configure', help='Configure the tree (run configure and config.status')
+    @Command('configure', category='build',
+        description='Configure the tree (run configure and config.status')
     def configure(self):
         def on_line(line):
             self.log(logging.INFO, 'build_output', {'line': line}, '{line}')
 
         status = self._run_make(srcdir=True, filename='client.mk',
             target='configure', line_handler=on_line, log=False,
             print_directory=False, allow_parallel=False, ensure_exit_code=False)
 
         if not status:
             print('Configure complete!')
             print('Be sure to run |mach build| to pick up any changes');
 
         return status
 
 
-    @Command('clobber', help='Clobber the tree (delete the object directory).')
+    @Command('clobber', category='build',
+        description='Clobber the tree (delete the object directory).')
     def clobber(self):
         try:
             self.remove_objdir()
             return 0
         except WindowsError as e:
             if e.winerror in (5, 32):
                 self.log(logging.ERROR, 'file_access_error', {'error': e},
                     "Could not clobber because a file was in use. If the "
@@ -271,18 +273,18 @@ class Warnings(MachCommandBase):
 
         database = WarningsDatabase()
 
         if os.path.exists(path):
             database.load_from_file(path)
 
         return database
 
-    @Command('warnings-summary',
-        help='Show a summary of compiler warnings.')
+    @Command('warnings-summary', category='post-build',
+        description='Show a summary of compiler warnings.')
     @CommandArgument('report', default=None, nargs='?',
         help='Warnings report to display. If not defined, show the most '
             'recent report.')
     def summary(self, report=None):
         database = self.database
 
         type_counts = database.type_counts
         sorted_counts = sorted(type_counts.iteritems(),
@@ -290,17 +292,18 @@ class Warnings(MachCommandBase):
 
         total = 0
         for k, v in sorted_counts:
             print('%d\t%s' % (v, k))
             total += v
 
         print('%d\tTotal' % total)
 
-    @Command('warnings-list', help='Show a list of compiler warnings.')
+    @Command('warnings-list', category='post-build',
+        description='Show a list of compiler warnings.')
     @CommandArgument('report', default=None, nargs='?',
         help='Warnings report to display. If not defined, show the most '
             'recent report.')
     def list(self, report=None):
         database = self.database
 
         by_name = sorted(database.warnings)
 
@@ -314,17 +317,18 @@ class Warnings(MachCommandBase):
                 print('%s:%d:%d [%s] %s' % (filename, warning['line'],
                     warning['column'], warning['flag'], warning['message']))
             else:
                 print('%s:%d [%s] %s' % (filename, warning['line'],
                     warning['flag'], warning['message']))
 
 @CommandProvider
 class GTestCommands(MachCommandBase):
-    @Command('gtest', help='Run GTest unit tests.')
+    @Command('gtest', category='testing',
+        description='Run GTest unit tests.')
     @CommandArgument('gtest_filter', default=b"*", nargs='?', metavar='gtest_filter',
         help="test_filter is a ':'-separated list of wildcard patterns (called the positive patterns),"
              "optionally followed by a '-' and another ':'-separated pattern list (called the negative patterns).")
     @CommandArgument('--jobs', '-j', default='1', nargs='?', metavar='jobs', type=int,
         help='Run the tests in parallel using multiple processes.')
     @CommandArgument('--tbpl-parser', '-t', action='store_true',
         help='Output test results in a format that can be parsed by TBPL.')
     @CommandArgument('--shuffle', '-s', action='store_true',
@@ -376,17 +380,18 @@ class GTestCommands(MachCommandBase):
         # 256 into 0
         if exit_code > 255:
             exit_code = 255
 
         return exit_code
 
 @CommandProvider
 class ClangCommands(MachCommandBase):
-    @Command('clang-complete', help='Generate a .clang_complete file.')
+    @Command('clang-complete', category='devenv',
+        description='Generate a .clang_complete file.')
     def clang_complete(self):
         import shlex
 
         build_vars = {}
 
         def on_line(line):
             elements = [s.strip() for s in line.split('=', 1)]
 
@@ -430,34 +435,37 @@ class ClangCommands(MachCommandBase):
         print('-I%s/ipc/glue' % self.topsrcdir)
         print('-I%s/ipc/ipdl/_ipdlheaders' % self.topobjdir)
 
 
 @CommandProvider
 class Package(MachCommandBase):
     """Package the built product for distribution."""
 
-    @Command('package', help='Package the built product for distribution as an APK, DMG, etc.')
+    @Command('package', category='post-build',
+        description='Package the built product for distribution as an APK, DMG, etc.')
     def package(self):
         return self._run_make(directory=".", target='package', ensure_exit_code=False)
 
 @CommandProvider
 class Install(MachCommandBase):
     """Install a package."""
 
-    @Command('install', help='Install the package on the machine, or on a device.')
+    @Command('install', category='post-build',
+        description='Install the package on the machine, or on a device.')
     def install(self):
         return self._run_make(directory=".", target='install', ensure_exit_code=False)
 
 @CommandProvider
 class RunProgram(MachCommandBase):
     """Launch the compiled binary"""
 
-    @Command('run', help='Run the compiled program.', prefix_chars='+')
-    @CommandArgument('params', default=None, nargs='*',
+    @Command('run', category='post-build', allow_all_args=True,
+        description='Run the compiled program.')
+    @CommandArgument('params', default=None, nargs='...',
         help='Command-line arguments to pass to the program.')
     def run(self, params):
         try:
             args = [self.get_binary_path('app'), '-no-remote']
         except Exception as e:
             print("It looks like your program isn't built.",
                 "You can run |mach build| to build it.")
             print(e)
@@ -466,18 +474,19 @@ class RunProgram(MachCommandBase):
             args.extend(params)
         return self.run_process(args=args, ensure_exit_code=False,
             pass_thru=True)
 
 @CommandProvider
 class DebugProgram(MachCommandBase):
     """Debug the compiled binary"""
 
-    @Command('debug', help='Debug the compiled program.', prefix_chars='+')
-    @CommandArgument('params', default=None, nargs='*',
+    @Command('debug', category='post-build', allow_all_args=True,
+        description='Debug the compiled program.')
+    @CommandArgument('params', default=None, nargs='...',
         help='Command-line arguments to pass to the program.')
     def debug(self, params):
         import which
         try:
             debugger = which.which('gdb')
         except Exception as e:
             print("You don't have gdb in your PATH")
             print(e)
@@ -493,23 +502,25 @@ class DebugProgram(MachCommandBase):
             args.extend(params)
         return self.run_process(args=args, ensure_exit_code=False,
             pass_thru=True)
 
 @CommandProvider
 class Buildsymbols(MachCommandBase):
     """Produce a package of debug symbols suitable for use with Breakpad."""
 
-    @Command('buildsymbols', help='Produce a package of Breakpad-format symbols.')
+    @Command('buildsymbols', category='post-build',
+        description='Produce a package of Breakpad-format symbols.')
     def buildsymbols(self):
         return self._run_make(directory=".", target='buildsymbols', ensure_exit_code=False)
 
 @CommandProvider
 class Makefiles(MachCommandBase):
-    @Command('empty-makefiles', help='Find empty Makefile.in in the tree.')
+    @Command('empty-makefiles', category='build-dev',
+        description='Find empty Makefile.in in the tree.')
     def empty(self):
         import pymake.parser
         import pymake.parserdata
 
         IGNORE_VARIABLES = {
             'DEPTH': ('@DEPTH@',),
             'topsrcdir': ('@top_srcdir@',),
             'srcdir': ('@srcdir@',),
--- a/testing/marionette/mach_commands.py
+++ b/testing/marionette/mach_commands.py
@@ -11,17 +11,18 @@ from mach.decorators import (
     CommandArgument,
     CommandProvider,
     Command,
 )
 
 
 @CommandProvider
 class MachCommands(MachCommandBase):
-    @Command('marionette-test', help='Run a Marionette test.')
+    @Command('marionette-test', category='testing',
+        description='Run a Marionette test.')
     @CommandArgument('--homedir', dest='b2g_path',
         help='For B2G testing, the path to the B2G repo.')
     @CommandArgument('--emulator', choices=['x86', 'arm'],
         help='Run an emulator of the specified architecture.')
     @CommandArgument('--address',
         help='host:port of running Gecko instance to connect to.')
     @CommandArgument('--type', dest='testtype',
         help='Test type, usually one of: browser, b2g, b2g-qemu.')
--- a/testing/mochitest/mach_commands.py
+++ b/testing/mochitest/mach_commands.py
@@ -235,37 +235,42 @@ def MochitestCommand(func):
             'executed.')
     func = path(func)
 
     return func
 
 
 @CommandProvider
 class MachCommands(MachCommandBase):
-    @Command('mochitest-plain', help='Run a plain mochitest.')
+    @Command('mochitest-plain', category='testing',
+        description='Run a plain mochitest.')
     @MochitestCommand
     def run_mochitest_plain(self, test_file, **kwargs):
         return self.run_mochitest(test_file, 'plain', **kwargs)
 
-    @Command('mochitest-chrome', help='Run a chrome mochitest.')
+    @Command('mochitest-chrome', category='testing',
+        description='Run a chrome mochitest.')
     @MochitestCommand
     def run_mochitest_chrome(self, test_file, **kwargs):
         return self.run_mochitest(test_file, 'chrome', **kwargs)
 
-    @Command('mochitest-browser', help='Run a mochitest with browser chrome.')
+    @Command('mochitest-browser', category='testing',
+        description='Run a mochitest with browser chrome.')
     @MochitestCommand
     def run_mochitest_browser(self, test_file, **kwargs):
         return self.run_mochitest(test_file, 'browser', **kwargs)
 
-    @Command('mochitest-metro', help='Run a mochitest with metro browser chrome.')
+    @Command('mochitest-metro', category='testing',
+        description='Run a mochitest with metro browser chrome.')
     @MochitestCommand
     def run_mochitest_metro(self, test_file, **kwargs):
         return self.run_mochitest(test_file, 'metro', **kwargs)
 
-    @Command('mochitest-a11y', help='Run an a11y mochitest.')
+    @Command('mochitest-a11y', category='testing',
+        description='Run an a11y mochitest.')
     @MochitestCommand
     def run_mochitest_a11y(self, test_file, **kwargs):
         return self.run_mochitest(test_file, 'a11y', **kwargs)
 
     def run_mochitest(self, test_file, flavor, **kwargs):
         self._ensure_state_subdir_exists('.')
 
         mochitest = self._spawn(MochitestRunner)
--- a/testing/xpcshell/mach_commands.py
+++ b/testing/xpcshell/mach_commands.py
@@ -145,17 +145,18 @@ class XPCShellRunner(MozbuildObject):
 
         self.log_manager.disable_unstructured()
 
         return int(not result)
 
 
 @CommandProvider
 class MachCommands(MachCommandBase):
-    @Command('xpcshell-test', help='Run an xpcshell test.')
+    @Command('xpcshell-test', category='testing',
+        description='Run XPCOM Shell tests.')
     @CommandArgument('test_file', default='all', nargs='?', metavar='TEST',
         help='Test to run. Can be specified as a single JS file, a directory, '
              'or omitted. If omitted, the entire test suite is executed.')
     @CommandArgument('--debug', '-d', action='store_true',
         help='Run test in a debugger.')
     @CommandArgument('--interactive', '-i', action='store_true',
         help='Open an xpcshell prompt before running tests.')
     @CommandArgument('--keep-going', '-k', action='store_true',
--- a/tools/mach_commands.py
+++ b/tools/mach_commands.py
@@ -8,49 +8,54 @@ from mach.decorators import (
     CommandArgument,
     CommandProvider,
     Command,
 )
 
 
 @CommandProvider
 class SearchProvider(object):
-    @Command('mxr', help='Search for something in MXR.')
+    @Command('mxr', category='misc',
+        description='Search for something in MXR.')
     @CommandArgument('term', nargs='+', help='Term(s) to search for.')
     def mxr(self, term):
         import webbrowser
         term = ' '.join(term)
         uri = 'https://mxr.mozilla.org/mozilla-central/search?string=%s' % term
         webbrowser.open_new_tab(uri)
 
-    @Command('dxr', help='Search for something in DXR.')
+    @Command('dxr', category='misc',
+        description='Search for something in DXR.')
     @CommandArgument('term', nargs='+', help='Term(s) to search for.')
     def dxr(self, term):
         import webbrowser
         term = ' '.join(term)
         uri = 'http://dxr.mozilla.org/search?tree=mozilla-central&q=%s' % term
         webbrowser.open_new_tab(uri)
 
-    @Command('mdn', help='Search for something on MDN.')
+    @Command('mdn', category='misc',
+        description='Search for something on MDN.')
     @CommandArgument('term', nargs='+', help='Term(s) to search for.')
     def mdn(self, term):
         import webbrowser
         term = ' '.join(term)
         uri = 'https://developer.mozilla.org/search?q=%s' % term
         webbrowser.open_new_tab(uri)
 
-    @Command('google', help='Search for something on Google.')
+    @Command('google', category='misc',
+        description='Search for something on Google.')
     @CommandArgument('term', nargs='+', help='Term(s) to search for.')
     def google(self, term):
         import webbrowser
         term = ' '.join(term)
         uri = 'https://www.google.com/search?q=%s' % term
         webbrowser.open_new_tab(uri)
 
-    @Command('search', help='Search for something on the Internets. '
+    @Command('search', category='misc',
+        description='Search for something on the Internets. '
         'This will open 3 new browser tabs and search for the term on Google, '
         'MDN, and MXR.')
     @CommandArgument('term', nargs='+', help='Term(s) to search for.')
     def search(self, term):
         self.google(term)
         self.mdn(term)
         self.mxr(term)