Backed out changeset b71c8c052463 (bug 1943846) for causing mass failures. CLOSED...
[gecko.git] / python / mach_commands.py
blob34bfcf65068f5d3a1880af1f2d25c51451767b52
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 import argparse
6 import logging
7 import os
8 import subprocess
9 import tempfile
10 from concurrent.futures import ThreadPoolExecutor, as_completed, thread
12 import mozinfo
13 from mach.decorators import Command, CommandArgument
14 from manifestparser import TestManifest
15 from manifestparser import filters as mpf
16 from mozbuild.util import cpu_count
17 from mozfile import which
18 from tqdm import tqdm
21 @Command("python", category="devenv", description="Run Python.")
22 @CommandArgument(
23 "--exec-file", default=None, help="Execute this Python file using `exec`"
25 @CommandArgument(
26 "--ipython",
27 action="store_true",
28 default=False,
29 help="Use ipython instead of the default Python REPL.",
31 @CommandArgument(
32 "--virtualenv",
33 default=None,
34 help="Prepare and use the virtualenv with the provided name. If not specified, "
35 "then the Mach context is used instead.",
37 @CommandArgument("args", nargs=argparse.REMAINDER)
38 def python(
39 command_context,
40 exec_file,
41 ipython,
42 virtualenv,
43 args,
45 # Avoid logging the command
46 command_context.log_manager.terminal_handler.setLevel(logging.CRITICAL)
48 # Note: subprocess requires native strings in os.environ on Windows.
49 append_env = {"PYTHONDONTWRITEBYTECODE": str("1")}
51 if virtualenv:
52 command_context._virtualenv_name = virtualenv
54 if exec_file:
55 command_context.activate_virtualenv()
56 exec(open(exec_file).read())
57 return 0
59 if ipython:
60 if virtualenv:
61 command_context.virtualenv_manager.ensure()
62 python_path = which(
63 "ipython", path=command_context.virtualenv_manager.bin_path
65 if not python_path:
66 raise Exception(
67 "--ipython was specified, but the provided "
68 '--virtualenv doesn\'t have "ipython" installed.'
70 else:
71 command_context._virtualenv_name = "ipython"
72 command_context.virtualenv_manager.ensure()
73 python_path = which(
74 "ipython", path=command_context.virtualenv_manager.bin_path
76 else:
77 command_context.virtualenv_manager.ensure()
78 python_path = command_context.virtualenv_manager.python_path
80 return command_context.run_process(
81 [python_path] + args,
82 pass_thru=True, # Allow user to run Python interactively.
83 ensure_exit_code=False, # Don't throw on non-zero exit code.
84 python_unbuffered=False, # Leave input buffered.
85 append_env=append_env,
89 @Command(
90 "python-test",
91 category="testing",
92 virtualenv_name="python-test",
93 description="Run Python unit tests with pytest.",
95 @CommandArgument(
96 "-v", "--verbose", default=False, action="store_true", help="Verbose output."
98 @CommandArgument(
99 "-j",
100 "--jobs",
101 default=None,
102 type=int,
103 help="Number of concurrent jobs to run. Default is the number of CPUs "
104 "in the system.",
106 @CommandArgument(
107 "-x",
108 "--exitfirst",
109 default=False,
110 action="store_true",
111 help="Runs all tests sequentially and breaks at the first failure.",
113 @CommandArgument(
114 "--subsuite",
115 default=None,
116 help=(
117 "Python subsuite to run. If not specified, all subsuites are run. "
118 "Use the string `default` to only run tests without a subsuite."
121 @CommandArgument(
122 "tests",
123 nargs="*",
124 metavar="TEST",
125 help=(
126 "Tests to run. Each test can be a single file or a directory. "
127 "Default test resolution relies on PYTHON_UNITTEST_MANIFESTS."
130 @CommandArgument(
131 "extra",
132 nargs=argparse.REMAINDER,
133 metavar="PYTEST ARGS",
134 help=(
135 "Arguments that aren't recognized by mach. These will be "
136 "passed as it is to pytest"
139 def python_test(command_context, *args, **kwargs):
140 try:
141 tempdir = str(tempfile.mkdtemp(suffix="-python-test"))
142 os.environ["PYTHON_TEST_TMP"] = tempdir
143 return run_python_tests(command_context, *args, **kwargs)
144 finally:
145 import mozfile
147 mozfile.remove(tempdir)
150 def run_python_tests(
151 command_context,
152 tests=None,
153 test_objects=None,
154 subsuite=None,
155 verbose=False,
156 jobs=None,
157 exitfirst=False,
158 extra=None,
159 **kwargs,
161 if test_objects is None:
162 from moztest.resolve import TestResolver
164 resolver = command_context._spawn(TestResolver)
165 # If we were given test paths, try to find tests matching them.
166 test_objects = resolver.resolve_tests(paths=tests, flavor="python")
167 else:
168 # We've received test_objects from |mach test|. We need to ignore
169 # the subsuite because python-tests don't use this key like other
170 # harnesses do and |mach test| doesn't realize this.
171 subsuite = None
173 mp = TestManifest()
174 mp.tests.extend(test_objects)
176 filters = []
177 if subsuite == "default":
178 filters.append(mpf.subsuite(None))
179 elif subsuite:
180 filters.append(mpf.subsuite(subsuite))
182 tests = mp.active_tests(filters=filters, disabled=False, python=3, **mozinfo.info)
184 if not tests:
185 submsg = "for subsuite '{}' ".format(subsuite) if subsuite else ""
186 message = (
187 "TEST-UNEXPECTED-FAIL | No tests collected "
188 + "{}(Not in PYTHON_UNITTEST_MANIFESTS?)".format(submsg)
190 command_context.log(logging.WARN, "python-test", {}, message)
191 return 1
193 parallel = []
194 sequential = []
195 os.environ.setdefault("PYTEST_ADDOPTS", "")
197 if extra:
198 os.environ["PYTEST_ADDOPTS"] += " " + " ".join(extra)
200 installed_requirements = set()
201 for test in tests:
202 if (
203 test.get("requirements")
204 and test["requirements"] not in installed_requirements
206 command_context.virtualenv_manager.install_pip_requirements(
207 test["requirements"], quiet=True
209 installed_requirements.add(test["requirements"])
211 if exitfirst:
212 sequential = tests
213 os.environ["PYTEST_ADDOPTS"] += " -x"
214 else:
215 for test in tests:
216 if test.get("sequential"):
217 sequential.append(test)
218 else:
219 parallel.append(test)
221 jobs = jobs or cpu_count()
223 return_code = 0
224 failure_output = []
226 def on_test_finished(result):
227 output, ret, test_path = result
229 if ret:
230 # Log the output of failed tests at the end so it's easy to find.
231 failure_output.extend(output)
233 if not return_code:
234 command_context.log(
235 logging.ERROR,
236 "python-test",
237 {"test_path": test_path, "ret": ret},
238 "Setting retcode to {ret} from {test_path}",
240 else:
241 for line in output:
242 command_context.log(
243 logging.INFO, "python-test", {"line": line.rstrip()}, "{line}"
246 return return_code or ret
248 with tqdm(
249 total=(len(parallel) + len(sequential)),
250 unit="Test",
251 desc="Tests Completed",
252 initial=0,
253 ) as progress_bar:
254 try:
255 with ThreadPoolExecutor(max_workers=jobs) as executor:
256 futures = []
258 for test in parallel:
259 command_context.log(
260 logging.DEBUG,
261 "python-test",
262 {"line": f"Launching thread for test {test['file_relpath']}"},
263 "{line}",
265 futures.append(
266 executor.submit(
267 _run_python_test, command_context, test, jobs, verbose
271 try:
272 for future in as_completed(futures):
273 progress_bar.clear()
274 return_code = on_test_finished(future.result())
275 progress_bar.update(1)
276 except KeyboardInterrupt:
277 # Hack to force stop currently running threads.
278 # https://gist.github.com/clchiou/f2608cbe54403edb0b13
279 executor._threads.clear()
280 thread._threads_queues.clear()
281 raise
283 for test in sequential:
284 test_result = _run_python_test(command_context, test, jobs, verbose)
286 progress_bar.clear()
287 return_code = on_test_finished(test_result)
288 if return_code and exitfirst:
289 break
291 progress_bar.update(1)
292 finally:
293 progress_bar.clear()
294 # Now log all failures (even if there was a KeyboardInterrupt or other exception).
295 for line in failure_output:
296 command_context.log(
297 logging.INFO, "python-test", {"line": line.rstrip()}, "{line}"
300 command_context.log(
301 logging.INFO,
302 "python-test",
303 {"return_code": return_code},
304 "Return code from mach python-test: {return_code}",
307 return return_code
310 def _run_python_test(command_context, test, jobs, verbose):
311 output = []
313 def _log(line):
314 # Buffer messages if more than one worker to avoid interleaving
315 if jobs > 1:
316 output.append(line)
317 else:
318 command_context.log(
319 logging.INFO, "python-test", {"line": line.rstrip()}, "{line}"
322 _log(test["path"])
323 python = command_context.virtualenv_manager.python_path
324 cmd = [python, test["path"]]
325 env = os.environ.copy()
326 env["PYTHONDONTWRITEBYTECODE"] = "1"
328 result = subprocess.run(
329 cmd,
330 env=env,
331 stdout=subprocess.PIPE,
332 stderr=subprocess.STDOUT,
333 universal_newlines=True,
334 encoding="UTF-8",
337 return_code = result.returncode
339 file_displayed_test = False
341 for line in result.stdout.split(os.linesep):
342 if not file_displayed_test:
343 test_ran = "Ran" in line or "collected" in line or line.startswith("TEST-")
344 if test_ran:
345 file_displayed_test = True
347 # Hack to make sure treeherder highlights pytest failures
348 if "FAILED" in line.rsplit(" ", 1)[-1]:
349 line = line.replace("FAILED", "TEST-UNEXPECTED-FAIL")
351 _log(line)
353 if not file_displayed_test:
354 return_code = 1
355 _log(
356 "TEST-UNEXPECTED-FAIL | No test output (missing mozunit.main() "
357 "call?): {}".format(test["path"])
360 if verbose:
361 if return_code != 0:
362 _log("Test failed: {}".format(test["path"]))
363 else:
364 _log("Test passed: {}".format(test["path"]))
366 return output, return_code, test["path"]