summaryrefslogtreecommitdiffstats
path: root/python/mach/mach/main.py
diff options
context:
space:
mode:
Diffstat (limited to 'python/mach/mach/main.py')
-rw-r--r--python/mach/mach/main.py735
1 files changed, 735 insertions, 0 deletions
diff --git a/python/mach/mach/main.py b/python/mach/mach/main.py
new file mode 100644
index 0000000000..9ab880341d
--- /dev/null
+++ b/python/mach/mach/main.py
@@ -0,0 +1,735 @@
+# 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/.
+
+# This module provides functionality for the command-line build tool
+# (mach). It is packaged as a module because everything is a library.
+
+import argparse
+import codecs
+import errno
+import imp
+import logging
+import os
+import sys
+import traceback
+import uuid
+from collections.abc import Iterable
+from pathlib import Path
+from typing import Dict, List, Union
+
+from .base import (
+ CommandContext,
+ FailedCommandError,
+ MachError,
+ MissingFileError,
+ NoCommandError,
+ UnknownCommandError,
+ UnrecognizedArgumentError,
+)
+from .config import ConfigSettings
+from .dispatcher import CommandAction
+from .logging import LoggingManager
+from .registrar import Registrar
+from .sentry import NoopErrorReporter, register_sentry
+from .telemetry import create_telemetry_from_environment, report_invocation_metrics
+from .util import UserError, setenv
+
+SUGGEST_MACH_BUSTED_TEMPLATE = r"""
+You can invoke ``./mach busted`` to check if this issue is already on file. If it
+isn't, please use ``./mach busted file %s`` to report it. If ``./mach busted`` is
+misbehaving, you can also inspect the dependencies of bug 1543241.
+""".lstrip()
+
+MACH_ERROR_TEMPLATE = (
+ r"""
+The error occurred in mach itself. This is likely a bug in mach itself or a
+fundamental problem with a loaded module.
+
+""".lstrip()
+ + SUGGEST_MACH_BUSTED_TEMPLATE
+)
+
+ERROR_FOOTER = r"""
+If filing a bug, please include the full output of mach, including this error
+message.
+
+The details of the failure are as follows:
+""".lstrip()
+
+USER_ERROR = r"""
+This is a user error and does not appear to be a bug in mach.
+""".lstrip()
+
+COMMAND_ERROR_TEMPLATE = (
+ r"""
+The error occurred in the implementation of the invoked mach command.
+
+This should never occur and is likely a bug in the implementation of that
+command.
+""".lstrip()
+ + SUGGEST_MACH_BUSTED_TEMPLATE
+)
+
+MODULE_ERROR_TEMPLATE = (
+ 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.
+""".lstrip()
+ + SUGGEST_MACH_BUSTED_TEMPLATE
+)
+
+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
+%s
+Run ``mach help`` to show a list of commands.
+""".lstrip()
+
+SUGGESTED_COMMANDS_MESSAGE = r"""
+Did you want to %s any of these commands instead: %s?
+"""
+
+UNRECOGNIZED_ARGUMENT_ERROR = r"""
+It looks like you passed an unrecognized argument into mach.
+
+The %s command does not accept the arguments: %s
+""".lstrip()
+
+INVALID_ENTRY_POINT = r"""
+Entry points should return a list of command providers or directories
+containing command providers. The following entry point is invalid:
+
+ %s
+
+You are seeing this because there is an error in an external module attempting
+to implement a mach command. Please fix the error, or uninstall the module from
+your system.
+""".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)
+ assert False
+
+ print("Invalid command specified. The list of commands is below.\n")
+ self.print_help()
+ sys.exit(1)
+
+ def format_help(self):
+ text = argparse.ArgumentParser.format_help(self)
+
+ # Strip out the silly command list that would preceed the pretty list.
+ #
+ # Commands:
+ # {foo,bar}
+ # foo Do foo.
+ # bar Do bar.
+ search = "Commands:\n {"
+ start = text.find(search)
+
+ if start != -1:
+ end = text.find("}\n", start)
+ assert end != -1
+
+ real_start = start + len("Commands:\n")
+ real_end = end + len("}\n")
+
+ text = text[0:real_start] + text[real_end:]
+
+ return text
+
+
+class ContextWrapper(object):
+ def __init__(self, context, handler):
+ object.__setattr__(self, "_context", context)
+ object.__setattr__(self, "_handler", handler)
+
+ def __getattribute__(self, key):
+ try:
+ return getattr(object.__getattribute__(self, "_context"), key)
+ except AttributeError as e:
+ try:
+ ret = object.__getattribute__(self, "_handler")(key)
+ except (AttributeError, TypeError):
+ # TypeError is in case the handler comes from old code not
+ # taking a key argument.
+ raise e
+ setattr(self, key, ret)
+ return ret
+
+ def __setattr__(self, key, value):
+ setattr(object.__getattribute__(self, "_context"), key, value)
+
+
+class MachCommandReference:
+ """A reference to a mach command.
+
+ Holds the metadata for a mach command.
+ """
+
+ module: Path
+
+ def __init__(self, module: Union[str, Path]):
+ self.module = Path(module)
+
+
+class Mach(object):
+ """Main mach driver type.
+
+ This type is responsible for holding global mach state and dispatching
+ a command from arguments.
+
+ The following attributes may be assigned to the instance to influence
+ behavior:
+
+ populate_context_handler -- If defined, it must be a callable. The
+ callable signature is the following:
+ populate_context_handler(key=None)
+ It acts as a fallback getter for the mach.base.CommandContext
+ instance.
+ This allows to augment the context instance with arbitrary data
+ for use in command handlers.
+
+ require_conditions -- If True, commands that do not have any condition
+ functions applied will be skipped. Defaults to False.
+
+ settings_paths -- A list of files or directories in which to search
+ for settings files to load.
+
+ """
+
+ USAGE = """%(prog)s [global arguments] command [command arguments]
+
+mach (German for "do") is the main interface to the Mozilla build system and
+common developer tasks.
+
+You tell mach the command you want to perform and it does it for you.
+
+Some common commands are:
+
+ %(prog)s build Build/compile the source tree.
+ %(prog)s help Show full help, including the list of all commands.
+
+To see more help for a specific command, run:
+
+ %(prog)s help <command>
+"""
+
+ def __init__(self, cwd: str):
+ assert Path(cwd).is_dir()
+
+ self.cwd = cwd
+ self.log_manager = LoggingManager()
+ self.logger = logging.getLogger(__name__)
+ self.settings = ConfigSettings()
+ self.settings_paths = []
+
+ if "MACHRC" in os.environ:
+ self.settings_paths.append(os.environ["MACHRC"])
+
+ self.log_manager.register_structured_logger(self.logger)
+ self.populate_context_handler = None
+
+ def load_commands_from_directory(self, path: Path):
+ """Scan for mach commands from modules in a directory.
+
+ This takes a path to a directory, loads the .py files in it, and
+ registers and found mach command providers with this mach instance.
+ """
+ for f in sorted(path.iterdir()):
+ if not f.suffix == ".py" or f.name == "__init__.py":
+ continue
+
+ full_path = path / f
+ module_name = f"mach.commands.{str(f)[0:-3]}"
+
+ self.load_commands_from_file(full_path, module_name=module_name)
+
+ def load_commands_from_file(self, path: Union[str, Path], module_name=None):
+ """Scan for mach commands from a file.
+
+ This takes a path to a file and loads it as a Python module under the
+ module name specified. If no name is specified, a random one will be
+ chosen.
+ """
+ if module_name is None:
+ # Ensure parent module is present otherwise we'll (likely) get
+ # an error due to unknown parent.
+ if "mach.commands" not in sys.modules:
+ mod = imp.new_module("mach.commands")
+ sys.modules["mach.commands"] = mod
+
+ module_name = f"mach.commands.{uuid.uuid4().hex}"
+
+ try:
+ imp.load_source(module_name, str(path))
+ except IOError as e:
+ if e.errno != errno.ENOENT:
+ raise
+
+ raise MissingFileError(f"{path} does not exist")
+
+ def load_commands_from_spec(
+ self, spec: Dict[str, MachCommandReference], topsrcdir: str, missing_ok=False
+ ):
+ """Load mach commands based on the given spec.
+
+ Takes a dictionary mapping command names to their metadata.
+ """
+ modules = set(spec[command].module for command in spec)
+
+ for path in modules:
+ try:
+ self.load_commands_from_file(topsrcdir / path)
+ except MissingFileError:
+ if not missing_ok:
+ raise
+
+ def load_commands_from_entry_point(self, group="mach.providers"):
+ """Scan installed packages for mach command provider entry points. An
+ entry point is a function that returns a list of paths to files or
+ directories containing command providers.
+
+ This takes an optional group argument which specifies the entry point
+ group to use. If not specified, it defaults to 'mach.providers'.
+ """
+ try:
+ import pkg_resources
+ except ImportError:
+ print(
+ "Could not find setuptools, ignoring command entry points",
+ file=sys.stderr,
+ )
+ return
+
+ for entry in pkg_resources.iter_entry_points(group=group, name=None):
+ paths = entry.load()()
+ if not isinstance(paths, Iterable):
+ print(INVALID_ENTRY_POINT % entry)
+ sys.exit(1)
+
+ for path in paths:
+ path = Path(path)
+ if path.is_file():
+ self.load_commands_from_file(path)
+ elif path.is_dir():
+ self.load_commands_from_directory(path)
+ else:
+ print(f"command provider '{path}' does not exist")
+
+ def define_category(self, name, title, description, priority=50):
+ """Provide a description for a named command category."""
+
+ Registrar.register_category(name, title, description, priority)
+
+ @property
+ def require_conditions(self):
+ return Registrar.require_conditions
+
+ @require_conditions.setter
+ def require_conditions(self, value):
+ Registrar.require_conditions = value
+
+ 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.
+ """
+ sentry = NoopErrorReporter()
+
+ # If no encoding is defined, we default to UTF-8 because without this
+ # Python 2.7 will assume the default encoding of ASCII. This will blow
+ # up with UnicodeEncodeError as soon as it encounters a non-ASCII
+ # character in a unicode instance. We simply install a wrapper around
+ # the streams and restore once we have finished.
+ stdin = sys.stdin if stdin is None else stdin
+ stdout = sys.stdout if stdout is None else stdout
+ stderr = sys.stderr if stderr is None else stderr
+
+ orig_stdin = sys.stdin
+ orig_stdout = sys.stdout
+ orig_stderr = sys.stderr
+
+ sys.stdin = stdin
+ sys.stdout = stdout
+ sys.stderr = stderr
+
+ orig_env = dict(os.environ)
+
+ try:
+ # Load settings as early as possible so things in dispatcher.py
+ # can use them.
+ for provider in Registrar.settings_providers:
+ self.settings.register_provider(provider)
+
+ setting_paths_to_pass = [Path(path) for path in self.settings_paths]
+ self.load_settings(setting_paths_to_pass)
+
+ if sys.version_info < (3, 0):
+ if stdin.encoding is None:
+ sys.stdin = codecs.getreader("utf-8")(stdin)
+
+ if stdout.encoding is None:
+ sys.stdout = codecs.getwriter("utf-8")(stdout)
+
+ if stderr.encoding is None:
+ sys.stderr = codecs.getwriter("utf-8")(stderr)
+
+ # Allow invoked processes (which may not have a handle on the
+ # original stdout file descriptor) to know if the original stdout
+ # is a TTY. This provides a mechanism to allow said processes to
+ # enable emitting code codes, for example.
+ if os.isatty(orig_stdout.fileno()):
+ setenv("MACH_STDOUT_ISATTY", "1")
+
+ return self._run(argv)
+ except KeyboardInterrupt:
+ print("mach interrupted by signal or user action. Stopping.")
+ return 1
+
+ except Exception:
+ # _run swallows exceptions in invoked handlers and converts them to
+ # a proper exit code. So, the only scenario where we should get an
+ # exception here is if _run itself raises. If _run raises, that's a
+ # bug in mach (or a loaded command module being silly) and thus
+ # should be reported differently.
+ self._print_error_header(argv, sys.stdout)
+ print(MACH_ERROR_TEMPLATE % "general")
+
+ exc_type, exc_value, exc_tb = sys.exc_info()
+ stack = traceback.extract_tb(exc_tb)
+
+ sentry_event_id = sentry.report_exception(exc_value)
+ self._print_exception(
+ sys.stdout, exc_type, exc_value, stack, sentry_event_id=sentry_event_id
+ )
+
+ return 1
+
+ finally:
+ os.environ.clear()
+ os.environ.update(orig_env)
+
+ sys.stdin = orig_stdin
+ sys.stdout = orig_stdout
+ sys.stderr = orig_stderr
+
+ def _run(self, argv):
+ if self.populate_context_handler:
+ topsrcdir = Path(self.populate_context_handler("topdir"))
+ sentry = register_sentry(argv, self.settings, topsrcdir)
+ else:
+ sentry = NoopErrorReporter()
+
+ context = CommandContext(
+ cwd=self.cwd,
+ settings=self.settings,
+ log_manager=self.log_manager,
+ commands=Registrar,
+ )
+
+ if self.populate_context_handler:
+ context = ContextWrapper(context, self.populate_context_handler)
+
+ parser = self.get_argument_parser(context)
+ context.global_parser = parser
+
+ 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
+
+ try:
+ args = parser.parse_args(argv)
+ except NoCommandError:
+ print(NO_COMMAND_ERROR)
+ return 1
+ except UnknownCommandError as e:
+ suggestion_message = (
+ SUGGESTED_COMMANDS_MESSAGE % (e.verb, ", ".join(e.suggested_commands))
+ if e.suggested_commands
+ else ""
+ )
+ print(UNKNOWN_COMMAND_ERROR % (e.verb, e.command, suggestion_message))
+ return 1
+ except UnrecognizedArgumentError as e:
+ print(UNRECOGNIZED_ARGUMENT_ERROR % (e.command, " ".join(e.arguments)))
+ return 1
+
+ if not hasattr(args, "mach_handler"):
+ raise MachError("ArgumentParser result missing mach handler info.")
+
+ context.is_interactive = (
+ args.is_interactive
+ and sys.__stdout__.isatty()
+ and sys.__stderr__.isatty()
+ and not os.environ.get("MOZ_AUTOMATION", None)
+ )
+ context.telemetry = create_telemetry_from_environment(self.settings)
+
+ handler = getattr(args, "mach_handler")
+ report_invocation_metrics(context.telemetry, handler.name)
+
+ # 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:
+ log_level = logging.DEBUG
+
+ self.log_manager.register_structured_logger(logging.getLogger("mach"))
+
+ write_times = True
+ if (
+ args.log_no_times
+ or "MACH_NO_WRITE_TIMES" in os.environ
+ or "MOZ_AUTOMATION" in os.environ
+ ):
+ write_times = False
+
+ # 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, write_times=write_times
+ )
+
+ if args.settings_file:
+ # Argument parsing has already happened, so settings that apply
+ # to command line handling (e.g alias, defaults) will be ignored.
+ self.load_settings([Path(args.settings_file)])
+
+ try:
+ return Registrar._run_command_handler(
+ handler,
+ context,
+ debug_command=args.debug_command,
+ profile_command=args.profile_command,
+ **vars(args.command_args),
+ )
+ except KeyboardInterrupt as ki:
+ raise ki
+ except FailedCommandError as e:
+ print(e)
+ return e.exit_code
+ except UserError:
+ # We explicitly don't report UserErrors to Sentry.
+ exc_type, exc_value, exc_tb = sys.exc_info()
+ # The first two frames are us and are never used.
+ stack = traceback.extract_tb(exc_tb)[2:]
+ self._print_error_header(argv, sys.stdout)
+ print(USER_ERROR)
+ self._print_exception(sys.stdout, exc_type, exc_value, stack)
+ return 1
+ except Exception:
+ exc_type, exc_value, exc_tb = sys.exc_info()
+ sentry_event_id = sentry.report_exception(exc_value)
+
+ # The first two frames are us and are never used.
+ stack = traceback.extract_tb(exc_tb)[2:]
+
+ # If we have nothing on the stack, the exception was raised as part
+ # of calling the @Command method itself. This likely means a
+ # mismatch between @CommandArgument and arguments to the method.
+ # e.g. there exists a @CommandArgument without the corresponding
+ # argument on the method. We handle that here until the module
+ # loader grows the ability to validate better.
+ if not len(stack):
+ print(COMMAND_ERROR_TEMPLATE % handler.name)
+ self._print_exception(
+ sys.stdout,
+ exc_type,
+ exc_value,
+ traceback.extract_tb(exc_tb),
+ sentry_event_id=sentry_event_id,
+ )
+ return 1
+
+ # Split the frames into those from the module containing the
+ # command and everything else.
+ command_frames = []
+ other_frames = []
+
+ initial_file = stack[0][0]
+
+ for frame in stack:
+ if frame[0] == initial_file:
+ command_frames.append(frame)
+ else:
+ other_frames.append(frame)
+
+ # If the exception was in the module providing the command, it's
+ # likely the bug is in the mach command module, not something else.
+ # If there are other frames, the bug is likely not the mach
+ # command's fault.
+ self._print_error_header(argv, sys.stdout)
+
+ if len(other_frames):
+ print(MODULE_ERROR_TEMPLATE % handler.name)
+ else:
+ print(COMMAND_ERROR_TEMPLATE % handler.name)
+
+ self._print_exception(
+ sys.stdout, exc_type, exc_value, stack, sentry_event_id=sentry_event_id
+ )
+
+ return 1
+
+ def log(self, level, action, params, format_str):
+ """Helper method to record a structured log event."""
+ self.logger.log(level, format_str, extra={"action": action, "params": params})
+
+ def _print_error_header(self, argv, fh):
+ fh.write("Error running mach:\n\n")
+ fh.write(" ")
+ fh.write(repr(argv))
+ fh.write("\n\n")
+
+ def _print_exception(self, fh, exc_type, exc_value, stack, sentry_event_id=None):
+ fh.write(ERROR_FOOTER)
+ fh.write("\n")
+
+ for l in traceback.format_exception_only(exc_type, exc_value):
+ fh.write(l)
+
+ fh.write("\n")
+ for l in traceback.format_list(stack):
+ fh.write(l)
+
+ if not sentry_event_id:
+ return
+
+ fh.write("\nSentry event ID: {}\n".format(sentry_event_id))
+
+ def load_settings(self, paths: List[Path]):
+ """Load the specified settings files.
+
+ If a directory is specified, the following basenames will be
+ searched for in this order:
+
+ machrc, .machrc
+ """
+ valid_names = ("machrc", ".machrc")
+
+ def find_in_dir(base: Path):
+ if base.is_file():
+ return base
+
+ for name in valid_names:
+ path = base / name
+ if path.is_file():
+ return path
+
+ files = map(find_in_dir, paths)
+ files = filter(bool, files)
+
+ self.settings.load_files(list(files))
+
+ def get_argument_parser(self, context):
+ """Returns an argument parser for the command-line interface."""
+
+ parser = ArgumentParser(
+ add_help=False,
+ usage="%(prog)s [global arguments] " "command [command arguments]",
+ )
+
+ # WARNING!!! If you add a global argument here, also add it to the
+ # global argument handling in the top-level `mach` script.
+ # Order is important here as it dictates the order the auto-generated
+ # help messages are printed.
+ global_group = parser.add_argument_group("Global Arguments")
+
+ 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("a"),
+ 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.",
+ )
+ global_group.add_argument(
+ "--no-interactive",
+ dest="is_interactive",
+ action="store_false",
+ help="Automatically selects the default option on any "
+ "interactive prompts. If the output is not a terminal, "
+ "then --no-interactive is assumed.",
+ )
+ suppress_log_by_default = False
+ if "INSIDE_EMACS" in os.environ:
+ suppress_log_by_default = True
+ global_group.add_argument(
+ "--log-no-times",
+ dest="log_no_times",
+ action="store_true",
+ default=suppress_log_by_default,
+ help="Do not prefix log lines with times. By default, "
+ "mach will prefix each output line with the time since "
+ "command start.",
+ )
+ global_group.add_argument(
+ "-h",
+ "--help",
+ dest="help",
+ action="store_true",
+ default=False,
+ help="Show this help message.",
+ )
+ global_group.add_argument(
+ "--debug-command",
+ action="store_true",
+ help="Start a Python debugger when command is dispatched.",
+ )
+ global_group.add_argument(
+ "--profile-command",
+ action="store_true",
+ help="Capture a Python profile of the mach process as command is dispatched.",
+ )
+ global_group.add_argument(
+ "--settings",
+ dest="settings_file",
+ metavar="FILENAME",
+ default=None,
+ help="Path to settings file.",
+ )
+
+ # 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, context=context
+ )
+
+ return parser