summaryrefslogtreecommitdiffstats
path: root/python/mach_commands.py
diff options
context:
space:
mode:
Diffstat (limited to 'python/mach_commands.py')
-rw-r--r--python/mach_commands.py377
1 files changed, 377 insertions, 0 deletions
diff --git a/python/mach_commands.py b/python/mach_commands.py
new file mode 100644
index 0000000000..c2a0981795
--- /dev/null
+++ b/python/mach_commands.py
@@ -0,0 +1,377 @@
+# 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 absolute_import, print_function, unicode_literals
+
+import argparse
+import logging
+import os
+import sys
+import tempfile
+from multiprocessing import cpu_count
+
+import six
+
+from concurrent.futures import (
+ ThreadPoolExecutor,
+ as_completed,
+ thread,
+)
+
+import mozinfo
+from mozfile import which
+from manifestparser import TestManifest
+from manifestparser import filters as mpf
+
+from mozbuild.base import MachCommandBase
+
+from mach.decorators import (
+ CommandArgument,
+ CommandProvider,
+ Command,
+)
+from mach.util import UserError
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+@CommandProvider
+class MachCommands(MachCommandBase):
+ @Command("python", category="devenv", description="Run Python.")
+ @CommandArgument(
+ "--no-virtualenv", action="store_true", help="Do not set up a virtualenv"
+ )
+ @CommandArgument(
+ "--no-activate", action="store_true", help="Do not activate the virtualenv"
+ )
+ @CommandArgument(
+ "--exec-file", default=None, help="Execute this Python file using `exec`"
+ )
+ @CommandArgument(
+ "--ipython",
+ action="store_true",
+ default=False,
+ help="Use ipython instead of the default Python REPL.",
+ )
+ @CommandArgument(
+ "--requirements",
+ default=None,
+ help="Install this requirements file before running Python",
+ )
+ @CommandArgument("args", nargs=argparse.REMAINDER)
+ def python(
+ self, no_virtualenv, no_activate, exec_file, ipython, requirements, args
+ ):
+ # Avoid logging the command
+ self.log_manager.terminal_handler.setLevel(logging.CRITICAL)
+
+ # Note: subprocess requires native strings in os.environ on Windows.
+ append_env = {
+ "PYTHONDONTWRITEBYTECODE": str("1"),
+ }
+
+ if requirements and no_virtualenv:
+ raise UserError("Cannot pass both --requirements and --no-virtualenv.")
+
+ if no_virtualenv:
+ from mach_bootstrap import mach_sys_path
+
+ python_path = sys.executable
+ append_env["PYTHONPATH"] = os.pathsep.join(mach_sys_path(self.topsrcdir))
+ else:
+ self.virtualenv_manager.ensure()
+ if not no_activate:
+ self.virtualenv_manager.activate()
+ python_path = self.virtualenv_manager.python_path
+ if requirements:
+ self.virtualenv_manager.install_pip_requirements(
+ requirements, require_hashes=False
+ )
+
+ if exec_file:
+ exec(open(exec_file).read())
+ return 0
+
+ if ipython:
+ bindir = os.path.dirname(python_path)
+ python_path = which("ipython", path=bindir)
+ if not python_path:
+ if not no_virtualenv:
+ # Use `_run_pip` directly rather than `install_pip_package` to bypass
+ # `req.check_if_exists()` which may detect a system installed ipython.
+ self.virtualenv_manager._run_pip(["install", "ipython"])
+ python_path = which("ipython", path=bindir)
+
+ if not python_path:
+ print("error: could not detect or install ipython")
+ return 1
+
+ return self.run_process(
+ [python_path] + args,
+ pass_thru=True, # Allow user to run Python interactively.
+ ensure_exit_code=False, # Don't throw on non-zero exit code.
+ python_unbuffered=False, # Leave input buffered.
+ append_env=append_env,
+ )
+
+ @Command(
+ "python-test",
+ category="testing",
+ virtualenv_name="python-test",
+ description="Run Python unit tests with pytest.",
+ )
+ @CommandArgument(
+ "-v", "--verbose", default=False, action="store_true", help="Verbose output."
+ )
+ @CommandArgument(
+ "-j",
+ "--jobs",
+ default=None,
+ type=int,
+ help="Number of concurrent jobs to run. Default is the number of CPUs "
+ "in the system.",
+ )
+ @CommandArgument(
+ "-x",
+ "--exitfirst",
+ default=False,
+ action="store_true",
+ help="Runs all tests sequentially and breaks at the first failure.",
+ )
+ @CommandArgument(
+ "--subsuite",
+ default=None,
+ help=(
+ "Python subsuite to run. If not specified, all subsuites are run. "
+ "Use the string `default` to only run tests without a subsuite."
+ ),
+ )
+ @CommandArgument(
+ "tests",
+ nargs="*",
+ metavar="TEST",
+ help=(
+ "Tests to run. Each test can be a single file or a directory. "
+ "Default test resolution relies on PYTHON_UNITTEST_MANIFESTS."
+ ),
+ )
+ @CommandArgument(
+ "extra",
+ nargs=argparse.REMAINDER,
+ metavar="PYTEST ARGS",
+ help=(
+ "Arguments that aren't recognized by mach. These will be "
+ "passed as it is to pytest"
+ ),
+ )
+ def python_test(self, *args, **kwargs):
+ try:
+ tempdir = str(tempfile.mkdtemp(suffix="-python-test"))
+ if six.PY2:
+ os.environ[b"PYTHON_TEST_TMP"] = tempdir
+ else:
+ os.environ["PYTHON_TEST_TMP"] = tempdir
+ return self.run_python_tests(*args, **kwargs)
+ finally:
+ import mozfile
+
+ mozfile.remove(tempdir)
+
+ def run_python_tests(
+ self,
+ tests=None,
+ test_objects=None,
+ subsuite=None,
+ verbose=False,
+ jobs=None,
+ exitfirst=False,
+ extra=None,
+ **kwargs
+ ):
+
+ self.activate_virtualenv()
+ if test_objects is None:
+ from moztest.resolve import TestResolver
+
+ resolver = self._spawn(TestResolver)
+ # If we were given test paths, try to find tests matching them.
+ test_objects = resolver.resolve_tests(paths=tests, flavor="python")
+ else:
+ # We've received test_objects from |mach test|. We need to ignore
+ # the subsuite because python-tests don't use this key like other
+ # harnesses do and |mach test| doesn't realize this.
+ subsuite = None
+
+ mp = TestManifest()
+ mp.tests.extend(test_objects)
+
+ filters = []
+ if subsuite == "default":
+ filters.append(mpf.subsuite(None))
+ elif subsuite:
+ filters.append(mpf.subsuite(subsuite))
+
+ tests = mp.active_tests(
+ filters=filters,
+ disabled=False,
+ python=self.virtualenv_manager.version_info[0],
+ **mozinfo.info
+ )
+
+ if not tests:
+ submsg = "for subsuite '{}' ".format(subsuite) if subsuite else ""
+ message = (
+ "TEST-UNEXPECTED-FAIL | No tests collected "
+ + "{}(Not in PYTHON_UNITTEST_MANIFESTS?)".format(submsg)
+ )
+ self.log(logging.WARN, "python-test", {}, message)
+ return 1
+
+ parallel = []
+ sequential = []
+ os.environ.setdefault("PYTEST_ADDOPTS", "")
+
+ if extra:
+ os.environ["PYTEST_ADDOPTS"] += " " + " ".join(extra)
+
+ installed_requirements = set()
+ for test in tests:
+ if (
+ test.get("requirements")
+ and test["requirements"] not in installed_requirements
+ ):
+ self.virtualenv_manager.install_pip_requirements(
+ test["requirements"],
+ quiet=True,
+ # pylint_requirements.txt must use the legacy resolver until bug 1682959
+ # is resolved.
+ legacy_resolver=True,
+ )
+ installed_requirements.add(test["requirements"])
+
+ if exitfirst:
+ sequential = tests
+ os.environ["PYTEST_ADDOPTS"] += " -x"
+ else:
+ for test in tests:
+ if test.get("sequential"):
+ sequential.append(test)
+ else:
+ parallel.append(test)
+
+ self.jobs = jobs or cpu_count()
+ self.terminate = False
+ self.verbose = verbose
+
+ return_code = 0
+
+ def on_test_finished(result):
+ output, ret, test_path = result
+
+ for line in output:
+ self.log(logging.INFO, "python-test", {"line": line.rstrip()}, "{line}")
+
+ if ret and not return_code:
+ self.log(
+ logging.ERROR,
+ "python-test",
+ {"test_path": test_path, "ret": ret},
+ "Setting retcode to {ret} from {test_path}",
+ )
+ return return_code or ret
+
+ with ThreadPoolExecutor(max_workers=self.jobs) as executor:
+ futures = [
+ executor.submit(self._run_python_test, test) for test in parallel
+ ]
+
+ try:
+ for future in as_completed(futures):
+ return_code = on_test_finished(future.result())
+ except KeyboardInterrupt:
+ # Hack to force stop currently running threads.
+ # https://gist.github.com/clchiou/f2608cbe54403edb0b13
+ executor._threads.clear()
+ thread._threads_queues.clear()
+ raise
+
+ for test in sequential:
+ return_code = on_test_finished(self._run_python_test(test))
+ if return_code and exitfirst:
+ break
+
+ self.log(
+ logging.INFO,
+ "python-test",
+ {"return_code": return_code},
+ "Return code from mach python-test: {return_code}",
+ )
+ return return_code
+
+ def _run_python_test(self, test):
+ from mozprocess import ProcessHandler
+
+ output = []
+
+ def _log(line):
+ # Buffer messages if more than one worker to avoid interleaving
+ if self.jobs > 1:
+ output.append(line)
+ else:
+ self.log(logging.INFO, "python-test", {"line": line.rstrip()}, "{line}")
+
+ file_displayed_test = [] # used as boolean
+
+ def _line_handler(line):
+ line = six.ensure_str(line)
+ if not file_displayed_test:
+ output = (
+ "Ran" in line or "collected" in line or line.startswith("TEST-")
+ )
+ if output:
+ file_displayed_test.append(True)
+
+ # Hack to make sure treeherder highlights pytest failures
+ if "FAILED" in line.rsplit(" ", 1)[-1]:
+ line = line.replace("FAILED", "TEST-UNEXPECTED-FAIL")
+
+ _log(line)
+
+ _log(test["path"])
+ python = self.virtualenv_manager.python_path
+ cmd = [python, test["path"]]
+ env = os.environ.copy()
+ if six.PY2:
+ env[b"PYTHONDONTWRITEBYTECODE"] = b"1"
+ else:
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
+
+ # Homebrew on OS X will change Python's sys.executable to a custom value
+ # which messes with mach's virtualenv handling code. Override Homebrew's
+ # changes with the correct sys.executable value.
+ if six.PY2:
+ env[b"PYTHONEXECUTABLE"] = python.encode("utf-8")
+ else:
+ env["PYTHONEXECUTABLE"] = python
+
+ proc = ProcessHandler(
+ cmd, env=env, processOutputLine=_line_handler, storeOutput=False
+ )
+ proc.run()
+
+ return_code = proc.wait()
+
+ if not file_displayed_test:
+ _log(
+ "TEST-UNEXPECTED-FAIL | No test output (missing mozunit.main() "
+ "call?): {}".format(test["path"])
+ )
+
+ if self.verbose:
+ if return_code != 0:
+ _log("Test failed: {}".format(test["path"]))
+ else:
+ _log("Test passed: {}".format(test["path"]))
+
+ return output, return_code, test["path"]