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
12 from multiprocessing
import cpu_count
14 from concurrent
.futures
import ThreadPoolExecutor
, as_completed
, thread
18 from mozfile
import which
19 from mach
.decorators
import CommandArgument
, Command
20 from manifestparser
import TestManifest
21 from manifestparser
import filters
as mpf
24 @Command("python", category
="devenv", description
="Run Python.")
26 "--exec-file", default
=None, help="Execute this Python file using `exec`"
32 help="Use ipython instead of the default Python REPL.",
37 help="Prepare and use the virtualenv with the provided name. If not specified, "
38 "then the Mach context is used instead.",
40 @CommandArgument("args", nargs
=argparse
.REMAINDER
)
48 # Avoid logging the command
49 command_context
.log_manager
.terminal_handler
.setLevel(logging
.CRITICAL
)
51 # Note: subprocess requires native strings in os.environ on Windows.
52 append_env
= {"PYTHONDONTWRITEBYTECODE": str("1")}
55 command_context
._virtualenv
_name
= virtualenv
58 command_context
.activate_virtualenv()
59 exec(open(exec_file
).read())
64 command_context
.virtualenv_manager
.ensure()
66 "ipython", path
=command_context
.virtualenv_manager
.bin_path
70 "--ipython was specified, but the provided "
71 '--virtualenv doesn\'t have "ipython" installed.'
74 command_context
._virtualenv
_name
= "ipython"
75 command_context
.virtualenv_manager
.ensure()
77 "ipython", path
=command_context
.virtualenv_manager
.bin_path
80 command_context
.virtualenv_manager
.ensure()
81 python_path
= command_context
.virtualenv_manager
.python_path
83 return command_context
.run_process(
85 pass_thru
=True, # Allow user to run Python interactively.
86 ensure_exit_code
=False, # Don't throw on non-zero exit code.
87 python_unbuffered
=False, # Leave input buffered.
88 append_env
=append_env
,
95 virtualenv_name
="python-test",
96 description
="Run Python unit tests with pytest.",
99 "-v", "--verbose", default
=False, action
="store_true", help="Verbose output."
106 help="Number of concurrent jobs to run. Default is the number of CPUs "
114 help="Runs all tests sequentially and breaks at the first failure.",
120 "Python subsuite to run. If not specified, all subsuites are run. "
121 "Use the string `default` to only run tests without a subsuite."
129 "Tests to run. Each test can be a single file or a directory. "
130 "Default test resolution relies on PYTHON_UNITTEST_MANIFESTS."
135 nargs
=argparse
.REMAINDER
,
136 metavar
="PYTEST ARGS",
138 "Arguments that aren't recognized by mach. These will be "
139 "passed as it is to pytest"
142 def python_test(command_context
, *args
, **kwargs
):
144 tempdir
= str(tempfile
.mkdtemp(suffix
="-python-test"))
145 os
.environ
["PYTHON_TEST_TMP"] = tempdir
146 return run_python_tests(command_context
, *args
, **kwargs
)
150 mozfile
.remove(tempdir
)
153 def run_python_tests(
164 if test_objects
is None:
165 from moztest
.resolve
import TestResolver
167 resolver
= command_context
._spawn
(TestResolver
)
168 # If we were given test paths, try to find tests matching them.
169 test_objects
= resolver
.resolve_tests(paths
=tests
, flavor
="python")
171 # We've received test_objects from |mach test|. We need to ignore
172 # the subsuite because python-tests don't use this key like other
173 # harnesses do and |mach test| doesn't realize this.
177 mp
.tests
.extend(test_objects
)
180 if subsuite
== "default":
181 filters
.append(mpf
.subsuite(None))
183 filters
.append(mpf
.subsuite(subsuite
))
185 tests
= mp
.active_tests(filters
=filters
, disabled
=False, python
=3, **mozinfo
.info
)
188 submsg
= "for subsuite '{}' ".format(subsuite
) if subsuite
else ""
190 "TEST-UNEXPECTED-FAIL | No tests collected "
191 + "{}(Not in PYTHON_UNITTEST_MANIFESTS?)".format(submsg
)
193 command_context
.log(logging
.WARN
, "python-test", {}, message
)
198 os
.environ
.setdefault("PYTEST_ADDOPTS", "")
201 os
.environ
["PYTEST_ADDOPTS"] += " " + " ".join(extra
)
203 installed_requirements
= set()
206 test
.get("requirements")
207 and test
["requirements"] not in installed_requirements
209 command_context
.virtualenv_manager
.install_pip_requirements(
210 test
["requirements"], quiet
=True
212 installed_requirements
.add(test
["requirements"])
216 os
.environ
["PYTEST_ADDOPTS"] += " -x"
219 if test
.get("sequential"):
220 sequential
.append(test
)
222 parallel
.append(test
)
224 jobs
= jobs
or cpu_count()
229 def on_test_finished(result
):
230 output
, ret
, test_path
= result
233 # Log the output of failed tests at the end so it's easy to find.
234 failure_output
.extend(output
)
240 {"test_path": test_path
, "ret": ret
},
241 "Setting retcode to {ret} from {test_path}",
246 logging
.INFO
, "python-test", {"line": line
.rstrip()}, "{line}"
249 return return_code
or ret
252 total
=(len(parallel
) + len(sequential
)),
254 desc
="Tests Completed",
258 with
ThreadPoolExecutor(max_workers
=jobs
) as executor
:
261 for test
in parallel
:
265 {"line": f
"Launching thread for test {test['file_relpath']}"},
270 _run_python_test
, command_context
, test
, jobs
, verbose
275 for future
in as_completed(futures
):
277 return_code
= on_test_finished(future
.result())
278 progress_bar
.update(1)
279 except KeyboardInterrupt:
280 # Hack to force stop currently running threads.
281 # https://gist.github.com/clchiou/f2608cbe54403edb0b13
282 executor
._threads
.clear()
283 thread
._threads
_queues
.clear()
286 for test
in sequential
:
287 test_result
= _run_python_test(command_context
, test
, jobs
, verbose
)
290 return_code
= on_test_finished(test_result
)
291 if return_code
and exitfirst
:
294 progress_bar
.update(1)
297 # Now log all failures (even if there was a KeyboardInterrupt or other exception).
298 for line
in failure_output
:
300 logging
.INFO
, "python-test", {"line": line
.rstrip()}, "{line}"
306 {"return_code": return_code
},
307 "Return code from mach python-test: {return_code}",
313 def _run_python_test(command_context
, test
, jobs
, verbose
):
317 # Buffer messages if more than one worker to avoid interleaving
322 logging
.INFO
, "python-test", {"line": line
.rstrip()}, "{line}"
326 python
= command_context
.virtualenv_manager
.python_path
327 cmd
= [python
, test
["path"]]
328 env
= os
.environ
.copy()
329 env
["PYTHONDONTWRITEBYTECODE"] = "1"
331 result
= subprocess
.run(
334 stdout
=subprocess
.PIPE
,
335 stderr
=subprocess
.STDOUT
,
336 universal_newlines
=True,
340 return_code
= result
.returncode
342 file_displayed_test
= False
344 for line
in result
.stdout
.split(os
.linesep
):
345 if not file_displayed_test
:
346 test_ran
= "Ran" in line
or "collected" in line
or line
.startswith("TEST-")
348 file_displayed_test
= True
350 # Hack to make sure treeherder highlights pytest failures
351 if "FAILED" in line
.rsplit(" ", 1)[-1]:
352 line
= line
.replace("FAILED", "TEST-UNEXPECTED-FAIL")
356 if not file_displayed_test
:
359 "TEST-UNEXPECTED-FAIL | No test output (missing mozunit.main() "
360 "call?): {}".format(test
["path"])
365 _log("Test failed: {}".format(test
["path"]))
367 _log("Test passed: {}".format(test
["path"]))
369 return output
, return_code
, test
["path"]