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 command_context
.activate_virtualenv()
165 if test_objects
is None:
166 from moztest
.resolve
import TestResolver
168 resolver
= command_context
._spawn
(TestResolver
)
169 # If we were given test paths, try to find tests matching them.
170 test_objects
= resolver
.resolve_tests(paths
=tests
, flavor
="python")
172 # We've received test_objects from |mach test|. We need to ignore
173 # the subsuite because python-tests don't use this key like other
174 # harnesses do and |mach test| doesn't realize this.
178 mp
.tests
.extend(test_objects
)
181 if subsuite
== "default":
182 filters
.append(mpf
.subsuite(None))
184 filters
.append(mpf
.subsuite(subsuite
))
186 tests
= mp
.active_tests(filters
=filters
, disabled
=False, python
=3, **mozinfo
.info
)
189 submsg
= "for subsuite '{}' ".format(subsuite
) if subsuite
else ""
191 "TEST-UNEXPECTED-FAIL | No tests collected "
192 + "{}(Not in PYTHON_UNITTEST_MANIFESTS?)".format(submsg
)
194 command_context
.log(logging
.WARN
, "python-test", {}, message
)
199 os
.environ
.setdefault("PYTEST_ADDOPTS", "")
202 os
.environ
["PYTEST_ADDOPTS"] += " " + " ".join(extra
)
204 installed_requirements
= set()
207 test
.get("requirements")
208 and test
["requirements"] not in installed_requirements
210 command_context
.virtualenv_manager
.install_pip_requirements(
211 test
["requirements"], quiet
=True
213 installed_requirements
.add(test
["requirements"])
217 os
.environ
["PYTEST_ADDOPTS"] += " -x"
220 if test
.get("sequential"):
221 sequential
.append(test
)
223 parallel
.append(test
)
225 jobs
= jobs
or cpu_count()
230 def on_test_finished(result
):
231 output
, ret
, test_path
= result
234 # Log the output of failed tests at the end so it's easy to find.
235 failure_output
.extend(output
)
241 {"test_path": test_path
, "ret": ret
},
242 "Setting retcode to {ret} from {test_path}",
247 logging
.INFO
, "python-test", {"line": line
.rstrip()}, "{line}"
250 return return_code
or ret
253 total
=(len(parallel
) + len(sequential
)),
255 desc
="Tests Completed",
259 with
ThreadPoolExecutor(max_workers
=jobs
) as executor
:
262 for test
in parallel
:
266 {"line": f
"Launching thread for test {test['file_relpath']}"},
271 _run_python_test
, command_context
, test
, jobs
, verbose
276 for future
in as_completed(futures
):
278 return_code
= on_test_finished(future
.result())
279 progress_bar
.update(1)
280 except KeyboardInterrupt:
281 # Hack to force stop currently running threads.
282 # https://gist.github.com/clchiou/f2608cbe54403edb0b13
283 executor
._threads
.clear()
284 thread
._threads
_queues
.clear()
287 for test
in sequential
:
288 test_result
= _run_python_test(command_context
, test
, jobs
, verbose
)
291 return_code
= on_test_finished(test_result
)
292 if return_code
and exitfirst
:
295 progress_bar
.update(1)
298 # Now log all failures (even if there was a KeyboardInterrupt or other exception).
299 for line
in failure_output
:
301 logging
.INFO
, "python-test", {"line": line
.rstrip()}, "{line}"
307 {"return_code": return_code
},
308 "Return code from mach python-test: {return_code}",
314 def _run_python_test(command_context
, test
, jobs
, verbose
):
318 # Buffer messages if more than one worker to avoid interleaving
323 logging
.INFO
, "python-test", {"line": line
.rstrip()}, "{line}"
327 python
= command_context
.virtualenv_manager
.python_path
328 cmd
= [python
, test
["path"]]
329 env
= os
.environ
.copy()
330 env
["PYTHONDONTWRITEBYTECODE"] = "1"
332 result
= subprocess
.run(
335 stdout
=subprocess
.PIPE
,
336 stderr
=subprocess
.STDOUT
,
337 universal_newlines
=True,
341 return_code
= result
.returncode
343 file_displayed_test
= False
345 for line
in result
.stdout
.split(os
.linesep
):
346 if not file_displayed_test
:
347 test_ran
= "Ran" in line
or "collected" in line
or line
.startswith("TEST-")
349 file_displayed_test
= True
351 # Hack to make sure treeherder highlights pytest failures
352 if "FAILED" in line
.rsplit(" ", 1)[-1]:
353 line
= line
.replace("FAILED", "TEST-UNEXPECTED-FAIL")
357 if not file_displayed_test
:
360 "TEST-UNEXPECTED-FAIL | No test output (missing mozunit.main() "
361 "call?): {}".format(test
["path"])
366 _log("Test failed: {}".format(test
["path"]))
368 _log("Test passed: {}".format(test
["path"]))
370 return output
, return_code
, test
["path"]