summaryrefslogtreecommitdiffstats
path: root/python/mach/mach/dispatcher.py
diff options
context:
space:
mode:
Diffstat (limited to 'python/mach/mach/dispatcher.py')
-rw-r--r--python/mach/mach/dispatcher.py516
1 files changed, 516 insertions, 0 deletions
diff --git a/python/mach/mach/dispatcher.py b/python/mach/mach/dispatcher.py
new file mode 100644
index 0000000000..95287eac40
--- /dev/null
+++ b/python/mach/mach/dispatcher.py
@@ -0,0 +1,516 @@
+# 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/.
+
+import argparse
+import difflib
+import shlex
+import sys
+from operator import itemgetter
+
+from .base import NoCommandError, UnknownCommandError, UnrecognizedArgumentError
+from .decorators import SettingsProvider
+
+
+@SettingsProvider
+class DispatchSettings:
+ config_settings = [
+ (
+ "alias.*",
+ "string",
+ """
+Create a command alias of the form `<alias>=<command> <args>`.
+Aliases can also be used to set default arguments:
+<command>=<command> <args>
+""".strip(),
+ ),
+ ]
+
+
+class CommandFormatter(argparse.HelpFormatter):
+ """Custom formatter to format just a subcommand."""
+
+ def add_usage(self, *args):
+ pass
+
+
+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,
+ context=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
+ self._context = context
+
+ 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 namespace.help:
+ # -h or --help is in the global arguments.
+ self._handle_main_help(parser, namespace.verbose)
+ sys.exit(0)
+ elif values:
+ command = values[0].lower()
+ args = values[1:]
+ if command == "help":
+ if args and args[0] not in ["-h", "--help"]:
+ # Make sure args[0] is indeed a command.
+ self._handle_command_help(parser, args[0], args)
+ else:
+ self._handle_main_help(parser, namespace.verbose)
+ sys.exit(0)
+ elif "-h" in args or "--help" in args:
+ # -h or --help is in the command arguments.
+ if "--" in args:
+ # -- is in command arguments
+ if (
+ "-h" in args[: args.index("--")]
+ or "--help" in args[: args.index("--")]
+ ):
+ # Honor -h or --help only if it appears before --
+ self._handle_command_help(parser, command, args)
+ sys.exit(0)
+ else:
+ self._handle_command_help(parser, command, args)
+ sys.exit(0)
+ else:
+ raise NoCommandError(namespace)
+
+ # First see if the this is a user-defined alias
+ if command in self._context.settings.alias:
+ alias = self._context.settings.alias[command]
+ defaults = shlex.split(alias)
+ command = defaults.pop(0)
+ args = defaults + args
+
+ if command not in self._mach_registrar.command_handlers:
+ # Try to find similar commands, may raise UnknownCommandError.
+ command = self._suggest_command(command)
+
+ handler = self._mach_registrar.command_handlers.get(command)
+
+ prog = command
+ usage = "%(prog)s [global arguments] " + command + " [command arguments]"
+
+ subcommand = None
+
+ # If there are sub-commands, parse the intent out immediately.
+ if handler.subcommand_handlers and args:
+ # mach <command> help <subcommand>
+ if set(args[: args.index("--")] if "--" in args else args).intersection(
+ ("help", "--help")
+ ):
+ self._handle_subcommand_help(parser, handler, args)
+ sys.exit(0)
+ # mach <command> <subcommand> ...
+ elif args[0] in handler.subcommand_handlers:
+ subcommand = args[0]
+ handler = handler.subcommand_handlers[subcommand]
+ prog = prog + " " + subcommand
+ usage = (
+ "%(prog)s [global arguments] "
+ + command
+ + " "
+ + subcommand
+ + " [command arguments]"
+ )
+ args.pop(0)
+
+ # 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": usage,
+ }
+
+ remainder = None
+
+ if handler.parser:
+ subparser = handler.parser
+ subparser.context = self._context
+ subparser.prog = subparser.prog + " " + prog
+ for arg in subparser._actions[:]:
+ if arg.nargs == argparse.REMAINDER:
+ subparser._actions.remove(arg)
+ remainder = (
+ (arg.dest,),
+ {"default": arg.default, "nargs": arg.nargs, "help": arg.help},
+ )
+ else:
+ subparser = argparse.ArgumentParser(**parser_args)
+
+ for arg in handler.arguments:
+ # Remove our group keyword; it's not needed here.
+ group_name = arg[1].get("group")
+ if group_name:
+ del arg[1]["group"]
+
+ if arg[1].get("nargs") == argparse.REMAINDER:
+ # parse_known_args expects all argparse.REMAINDER ('...')
+ # arguments to be all stuck together. Instead, we want them to
+ # pick any extra argument, wherever they are.
+ # Assume a limited CommandArgument for those arguments.
+ assert len(arg[0]) == 1
+ assert all(k in ("default", "nargs", "help", "metavar") for k in arg[1])
+ remainder = arg
+ else:
+ 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)
+ setattr(namespace, "subcommand", subcommand)
+
+ command_namespace, extra = subparser.parse_known_args(args)
+ setattr(namespace, "command_args", command_namespace)
+ if remainder:
+ (name,), options = remainder
+ # parse_known_args usefully puts all arguments after '--' in
+ # extra, but also puts '--' there. We don't want to pass it down
+ # to the command handler. Note that if multiple '--' are on the
+ # command line, only the first one is removed, so that subsequent
+ # ones are passed down.
+ if "--" in extra:
+ extra.remove("--")
+
+ # Commands with argparse.REMAINDER arguments used to force the
+ # other arguments to be '+' prefixed. If a user now passes such
+ # an argument, if will silently end up in extra. So, check if any
+ # of the allowed arguments appear in a '+' prefixed form, and error
+ # out if that's the case.
+ for args, _ in handler.arguments:
+ for arg in args:
+ arg = arg.replace("-", "+", 1)
+ if arg in extra:
+ raise UnrecognizedArgumentError(command, [arg])
+
+ if extra:
+ setattr(command_namespace, name, extra)
+ else:
+ setattr(command_namespace, name, options.get("default", []))
+ elif extra:
+ raise UnrecognizedArgumentError(command, extra)
+
+ def _handle_main_help(self, parser, verbose):
+ # 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
+ disabled_commands = []
+
+ 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:
+ group = None
+
+ for command in sorted(r.commands_by_category[category]):
+ handler = r.command_handlers[command]
+
+ # Instantiate a handler class to see if it should be filtered
+ # out for the current context or not. Condition functions can be
+ # applied to the command's decorator.
+ if handler.conditions:
+ instance = handler.create_instance(
+ self._context, handler.virtualenv_name
+ )
+
+ is_filtered = False
+ for c in handler.conditions:
+ if not c(instance):
+ is_filtered = True
+ break
+ if is_filtered:
+ description = handler.description
+ disabled_command = {
+ "command": command,
+ "description": description,
+ }
+ disabled_commands.append(disabled_command)
+ continue
+
+ if group is None:
+ title, description, _priority = r.categories[category]
+ group = parser.add_argument_group(title, description)
+
+ description = handler.description
+ group.add_argument(command, help=description, action="store_true")
+
+ if disabled_commands and "disabled" in r.categories:
+ title, description, _priority = r.categories["disabled"]
+ group = parser.add_argument_group(title, description)
+ if verbose:
+ for c in disabled_commands:
+ group.add_argument(
+ c["command"], help=c["description"], action="store_true"
+ )
+
+ parser.print_help()
+
+ def _populate_command_group(self, parser, handler, group):
+ extra_groups = {}
+ for group_name in handler.argument_group_names:
+ group_full_name = "Command Arguments for " + group_name
+ extra_groups[group_name] = parser.add_argument_group(group_full_name)
+
+ for arg in handler.arguments:
+ # Apply our group keyword.
+ group_name = arg[1].get("group")
+ if group_name:
+ del arg[1]["group"]
+ group = extra_groups[group_name]
+ group.add_argument(*arg[0], **arg[1])
+
+ def _get_command_arguments_help(self, handler):
+ # This code is worth explaining. Because we are doing funky things with
+ # argument registration to allow the same option in both global and
+ # command arguments, we can't simply put all arguments on the same
+ # parser instance because argparse would complain. We can't register an
+ # argparse subparser here because it won't properly show help for
+ # global arguments. So, we employ a strategy similar to command
+ # execution where we construct a 2nd, independent ArgumentParser for
+ # just the command data then supplement the main help's output with
+ # this 2nd parser's. We use a custom formatter class to ignore some of
+ # the help output.
+ parser_args = {
+ "formatter_class": CommandFormatter,
+ "add_help": False,
+ }
+
+ if handler.parser:
+ c_parser = handler.parser
+ c_parser.context = self._context
+ c_parser.formatter_class = NoUsageFormatter
+ # Accessing _action_groups is a bit shady. We are highly dependent
+ # on the argparse implementation not changing. We fail fast to
+ # detect upstream changes so we can intelligently react to them.
+ group = c_parser._action_groups[1]
+
+ # By default argparse adds two groups called "positional arguments"
+ # and "optional arguments". We want to rename these to reflect standard
+ # mach terminology.
+ c_parser._action_groups[0].title = "Command Parameters"
+ c_parser._action_groups[1].title = "Command Arguments"
+
+ if not handler.description:
+ 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)
+
+ return c_parser
+
+ def _handle_command_help(self, parser, command, args):
+ handler = self._mach_registrar.command_handlers.get(command)
+
+ if not handler:
+ raise UnknownCommandError(command, "query")
+
+ if handler.subcommand_handlers:
+ self._handle_subcommand_help(parser, handler, args)
+ return
+
+ c_parser = self._get_command_arguments_help(handler)
+
+ # 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")
+
+ def by_decl_order(item):
+ return item[1].decl_order
+
+ def by_name(item):
+ return item[1].subcommand
+
+ subhandlers = handler.subcommand_handlers.items()
+ for subcommand, subhandler in sorted(
+ subhandlers,
+ key=by_decl_order if handler.order == "declaration" else by_name,
+ ):
+ group.add_argument(
+ subcommand, help=subhandler.description, action="store_true"
+ )
+
+ if handler.docstring:
+ parser.description = format_docstring(handler.docstring)
+
+ c_parser = self._get_command_arguments_help(handler)
+
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
+
+ parser.print_help()
+ print("")
+ c_parser.print_help()
+
+ def _handle_subcommand_help(self, parser, handler, args):
+ subcommand = set(args).intersection(list(handler.subcommand_handlers.keys()))
+ if not subcommand:
+ return self._handle_subcommand_main_help(parser, handler)
+
+ subcommand = subcommand.pop()
+ subhandler = handler.subcommand_handlers[subcommand]
+
+ # Initialize the parser if necessary
+ subhandler.parser
+
+ c_parser = subhandler.parser or argparse.ArgumentParser(add_help=False)
+ c_parser.formatter_class = CommandFormatter
+
+ group = c_parser.add_argument_group("Sub Command Arguments")
+ self._populate_command_group(c_parser, subhandler, group)
+
+ if subhandler.docstring:
+ parser.description = format_docstring(subhandler.docstring)
+
+ parser.formatter_class = argparse.RawDescriptionHelpFormatter
+ parser.usage = (
+ "%(prog)s [global arguments] "
+ + handler.name
+ + " "
+ + subcommand
+ + " [command arguments]"
+ )
+
+ parser.print_help()
+ print("")
+ c_parser.print_help()
+
+ def _suggest_command(self, command):
+ names = [h.name for h in self._mach_registrar.command_handlers.values()]
+ # We first try to look for a valid command that is very similar to the given command.
+ suggested_commands = difflib.get_close_matches(command, names, cutoff=0.8)
+ # If we find more than one matching command, or no command at all,
+ # we give command suggestions instead (with a lower matching threshold).
+ # All commands that start with the given command (for instance:
+ # 'mochitest-plain', 'mochitest-chrome', etc. for 'mochitest-')
+ # are also included.
+ if len(suggested_commands) != 1:
+ suggested_commands = set(
+ difflib.get_close_matches(command, names, cutoff=0.5)
+ )
+ suggested_commands |= {cmd for cmd in names if cmd.startswith(command)}
+ raise UnknownCommandError(command, "run", suggested_commands)
+ sys.stderr.write(
+ "We're assuming the '%s' command is '%s' and we're "
+ "executing it for you.\n\n" % (command, suggested_commands[0])
+ )
+ return suggested_commands[0]
+
+
+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.maxsize
+ for line in lines[1:]:
+ stripped = line.lstrip()
+ if stripped:
+ indent = min(indent, len(line) - len(stripped))
+ trimmed = [lines[0].strip()]
+ if indent < sys.maxsize:
+ 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)