Bug 1694101 [wpt PR 17571] - HTML: <tr height=0> is a thing, a=testonly
[gecko.git] / python / mach_commands.py
blob7e1ce9bf43bc9349632f3c9a3291473b7dd7b9f0
1 # This Source Code Form is subject to the terms of the Mozilla Public
2 # License, v. 2.0. If a copy of the MPL was not distributed with this
3 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
5 from __future__ import absolute_import, print_function, unicode_literals
7 import argparse
8 import logging
9 import os
10 import sys
11 import tempfile
12 from multiprocessing import cpu_count
14 import six
16 from concurrent.futures import (
17 ThreadPoolExecutor,
18 as_completed,
19 thread,
22 import mozinfo
23 from mozfile import which
24 from manifestparser import TestManifest
25 from manifestparser import filters as mpf
27 from mozbuild.base import MachCommandBase
29 from mach.decorators import (
30 CommandArgument,
31 CommandProvider,
32 Command,
34 from mach.util import UserError
36 here = os.path.abspath(os.path.dirname(__file__))
39 @CommandProvider
40 class MachCommands(MachCommandBase):
41 @Command("python", category="devenv", description="Run Python.")
42 @CommandArgument(
43 "--no-virtualenv", action="store_true", help="Do not set up a virtualenv"
45 @CommandArgument(
46 "--no-activate", action="store_true", help="Do not activate the virtualenv"
48 @CommandArgument(
49 "--exec-file", default=None, help="Execute this Python file using `exec`"
51 @CommandArgument(
52 "--ipython",
53 action="store_true",
54 default=False,
55 help="Use ipython instead of the default Python REPL.",
57 @CommandArgument(
58 "--requirements",
59 default=None,
60 help="Install this requirements file before running Python",
62 @CommandArgument("args", nargs=argparse.REMAINDER)
63 def python(
64 self, no_virtualenv, no_activate, exec_file, ipython, requirements, args
66 # Avoid logging the command
67 self.log_manager.terminal_handler.setLevel(logging.CRITICAL)
69 # Note: subprocess requires native strings in os.environ on Windows.
70 append_env = {
71 "PYTHONDONTWRITEBYTECODE": str("1"),
74 if requirements and no_virtualenv:
75 raise UserError("Cannot pass both --requirements and --no-virtualenv.")
77 if no_virtualenv:
78 from mach_bootstrap import mach_sys_path
80 python_path = sys.executable
81 append_env["PYTHONPATH"] = os.pathsep.join(mach_sys_path(self.topsrcdir))
82 else:
83 self.virtualenv_manager.ensure()
84 if not no_activate:
85 self.virtualenv_manager.activate()
86 python_path = self.virtualenv_manager.python_path
87 if requirements:
88 self.virtualenv_manager.install_pip_requirements(
89 requirements, require_hashes=False
92 if exec_file:
93 exec(open(exec_file).read())
94 return 0
96 if ipython:
97 bindir = os.path.dirname(python_path)
98 python_path = which("ipython", path=bindir)
99 if not python_path:
100 if not no_virtualenv:
101 # Use `_run_pip` directly rather than `install_pip_package` to bypass
102 # `req.check_if_exists()` which may detect a system installed ipython.
103 self.virtualenv_manager._run_pip(["install", "ipython"])
104 python_path = which("ipython", path=bindir)
106 if not python_path:
107 print("error: could not detect or install ipython")
108 return 1
110 return self.run_process(
111 [python_path] + args,
112 pass_thru=True, # Allow user to run Python interactively.
113 ensure_exit_code=False, # Don't throw on non-zero exit code.
114 python_unbuffered=False, # Leave input buffered.
115 append_env=append_env,
118 @Command(
119 "python-test",
120 category="testing",
121 virtualenv_name="python-test",
122 description="Run Python unit tests with pytest.",
124 @CommandArgument(
125 "-v", "--verbose", default=False, action="store_true", help="Verbose output."
127 @CommandArgument(
128 "-j",
129 "--jobs",
130 default=None,
131 type=int,
132 help="Number of concurrent jobs to run. Default is the number of CPUs "
133 "in the system.",
135 @CommandArgument(
136 "-x",
137 "--exitfirst",
138 default=False,
139 action="store_true",
140 help="Runs all tests sequentially and breaks at the first failure.",
142 @CommandArgument(
143 "--subsuite",
144 default=None,
145 help=(
146 "Python subsuite to run. If not specified, all subsuites are run. "
147 "Use the string `default` to only run tests without a subsuite."
150 @CommandArgument(
151 "tests",
152 nargs="*",
153 metavar="TEST",
154 help=(
155 "Tests to run. Each test can be a single file or a directory. "
156 "Default test resolution relies on PYTHON_UNITTEST_MANIFESTS."
159 @CommandArgument(
160 "extra",
161 nargs=argparse.REMAINDER,
162 metavar="PYTEST ARGS",
163 help=(
164 "Arguments that aren't recognized by mach. These will be "
165 "passed as it is to pytest"
168 def python_test(self, *args, **kwargs):
169 try:
170 tempdir = str(tempfile.mkdtemp(suffix="-python-test"))
171 if six.PY2:
172 os.environ[b"PYTHON_TEST_TMP"] = tempdir
173 else:
174 os.environ["PYTHON_TEST_TMP"] = tempdir
175 return self.run_python_tests(*args, **kwargs)
176 finally:
177 import mozfile
179 mozfile.remove(tempdir)
181 def run_python_tests(
182 self,
183 tests=None,
184 test_objects=None,
185 subsuite=None,
186 verbose=False,
187 jobs=None,
188 exitfirst=False,
189 extra=None,
190 **kwargs
193 self.activate_virtualenv()
194 if test_objects is None:
195 from moztest.resolve import TestResolver
197 resolver = self._spawn(TestResolver)
198 # If we were given test paths, try to find tests matching them.
199 test_objects = resolver.resolve_tests(paths=tests, flavor="python")
200 else:
201 # We've received test_objects from |mach test|. We need to ignore
202 # the subsuite because python-tests don't use this key like other
203 # harnesses do and |mach test| doesn't realize this.
204 subsuite = None
206 mp = TestManifest()
207 mp.tests.extend(test_objects)
209 filters = []
210 if subsuite == "default":
211 filters.append(mpf.subsuite(None))
212 elif subsuite:
213 filters.append(mpf.subsuite(subsuite))
215 tests = mp.active_tests(
216 filters=filters,
217 disabled=False,
218 python=self.virtualenv_manager.version_info[0],
219 **mozinfo.info
222 if not tests:
223 submsg = "for subsuite '{}' ".format(subsuite) if subsuite else ""
224 message = (
225 "TEST-UNEXPECTED-FAIL | No tests collected "
226 + "{}(Not in PYTHON_UNITTEST_MANIFESTS?)".format(submsg)
228 self.log(logging.WARN, "python-test", {}, message)
229 return 1
231 parallel = []
232 sequential = []
233 os.environ.setdefault("PYTEST_ADDOPTS", "")
235 if extra:
236 os.environ["PYTEST_ADDOPTS"] += " " + " ".join(extra)
238 installed_requirements = set()
239 for test in tests:
240 if (
241 test.get("requirements")
242 and test["requirements"] not in installed_requirements
244 self.virtualenv_manager.install_pip_requirements(
245 test["requirements"],
246 quiet=True,
248 installed_requirements.add(test["requirements"])
250 if exitfirst:
251 sequential = tests
252 os.environ["PYTEST_ADDOPTS"] += " -x"
253 else:
254 for test in tests:
255 if test.get("sequential"):
256 sequential.append(test)
257 else:
258 parallel.append(test)
260 self.jobs = jobs or cpu_count()
261 self.terminate = False
262 self.verbose = verbose
264 return_code = 0
266 def on_test_finished(result):
267 output, ret, test_path = result
269 for line in output:
270 self.log(logging.INFO, "python-test", {"line": line.rstrip()}, "{line}")
272 if ret and not return_code:
273 self.log(
274 logging.ERROR,
275 "python-test",
276 {"test_path": test_path, "ret": ret},
277 "Setting retcode to {ret} from {test_path}",
279 return return_code or ret
281 with ThreadPoolExecutor(max_workers=self.jobs) as executor:
282 futures = [
283 executor.submit(self._run_python_test, test) for test in parallel
286 try:
287 for future in as_completed(futures):
288 return_code = on_test_finished(future.result())
289 except KeyboardInterrupt:
290 # Hack to force stop currently running threads.
291 # https://gist.github.com/clchiou/f2608cbe54403edb0b13
292 executor._threads.clear()
293 thread._threads_queues.clear()
294 raise
296 for test in sequential:
297 return_code = on_test_finished(self._run_python_test(test))
298 if return_code and exitfirst:
299 break
301 self.log(
302 logging.INFO,
303 "python-test",
304 {"return_code": return_code},
305 "Return code from mach python-test: {return_code}",
307 return return_code
309 def _run_python_test(self, test):
310 from mozprocess import ProcessHandler
312 output = []
314 def _log(line):
315 # Buffer messages if more than one worker to avoid interleaving
316 if self.jobs > 1:
317 output.append(line)
318 else:
319 self.log(logging.INFO, "python-test", {"line": line.rstrip()}, "{line}")
321 file_displayed_test = [] # used as boolean
323 def _line_handler(line):
324 line = six.ensure_str(line)
325 if not file_displayed_test:
326 output = (
327 "Ran" in line or "collected" in line or line.startswith("TEST-")
329 if output:
330 file_displayed_test.append(True)
332 # Hack to make sure treeherder highlights pytest failures
333 if "FAILED" in line.rsplit(" ", 1)[-1]:
334 line = line.replace("FAILED", "TEST-UNEXPECTED-FAIL")
336 _log(line)
338 _log(test["path"])
339 python = self.virtualenv_manager.python_path
340 cmd = [python, test["path"]]
341 env = os.environ.copy()
342 if six.PY2:
343 env[b"PYTHONDONTWRITEBYTECODE"] = b"1"
344 else:
345 env["PYTHONDONTWRITEBYTECODE"] = "1"
347 # Homebrew on OS X will change Python's sys.executable to a custom value
348 # which messes with mach's virtualenv handling code. Override Homebrew's
349 # changes with the correct sys.executable value.
350 if six.PY2:
351 env[b"PYTHONEXECUTABLE"] = python.encode("utf-8")
352 else:
353 env["PYTHONEXECUTABLE"] = python
355 proc = ProcessHandler(
356 cmd, env=env, processOutputLine=_line_handler, storeOutput=False
358 proc.run()
360 return_code = proc.wait()
362 if not file_displayed_test:
363 _log(
364 "TEST-UNEXPECTED-FAIL | No test output (missing mozunit.main() "
365 "call?): {}".format(test["path"])
368 if self.verbose:
369 if return_code != 0:
370 _log("Test failed: {}".format(test["path"]))
371 else:
372 _log("Test passed: {}".format(test["path"]))
374 return output, return_code, test["path"]