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/.
10 from concurrent
.futures
import ThreadPoolExecutor
, as_completed
, thread
11 from multiprocessing
import cpu_count
14 from mach
.decorators
import Command
, CommandArgument
15 from manifestparser
import TestManifest
16 from manifestparser
import filters
as mpf
17 from mozfile
import which
21 @Command("python", category
="devenv", description
="Run Python.")
23 "--exec-file", default
=None, help="Execute this Python file using `exec`"
29 help="Use ipython instead of the default Python REPL.",
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
)
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")}
52 command_context
._virtualenv
_name
= virtualenv
55 command_context
.activate_virtualenv()
56 exec(open(exec_file
).read())
61 command_context
.virtualenv_manager
.ensure()
63 "ipython", path
=command_context
.virtualenv_manager
.bin_path
67 "--ipython was specified, but the provided "
68 '--virtualenv doesn\'t have "ipython" installed.'
71 command_context
._virtualenv
_name
= "ipython"
72 command_context
.virtualenv_manager
.ensure()
74 "ipython", path
=command_context
.virtualenv_manager
.bin_path
77 command_context
.virtualenv_manager
.ensure()
78 python_path
= command_context
.virtualenv_manager
.python_path
80 return command_context
.run_process(
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
,
92 virtualenv_name
="python-test",
93 description
="Run Python unit tests with pytest.",
96 "-v", "--verbose", default
=False, action
="store_true", help="Verbose output."
103 help="Number of concurrent jobs to run. Default is the number of CPUs "
111 help="Runs all tests sequentially and breaks at the first failure.",
117 "Python subsuite to run. If not specified, all subsuites are run. "
118 "Use the string `default` to only run tests without a subsuite."
126 "Tests to run. Each test can be a single file or a directory. "
127 "Default test resolution relies on PYTHON_UNITTEST_MANIFESTS."
132 nargs
=argparse
.REMAINDER
,
133 metavar
="PYTEST ARGS",
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
):
141 tempdir
= str(tempfile
.mkdtemp(suffix
="-python-test"))
142 os
.environ
["PYTHON_TEST_TMP"] = tempdir
143 return run_python_tests(command_context
, *args
, **kwargs
)
147 mozfile
.remove(tempdir
)
150 def run_python_tests(
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")
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.
174 mp
.tests
.extend(test_objects
)
177 if subsuite
== "default":
178 filters
.append(mpf
.subsuite(None))
180 filters
.append(mpf
.subsuite(subsuite
))
182 tests
= mp
.active_tests(filters
=filters
, disabled
=False, python
=3, **mozinfo
.info
)
185 submsg
= "for subsuite '{}' ".format(subsuite
) if subsuite
else ""
187 "TEST-UNEXPECTED-FAIL | No tests collected "
188 + "{}(Not in PYTHON_UNITTEST_MANIFESTS?)".format(submsg
)
190 command_context
.log(logging
.WARN
, "python-test", {}, message
)
195 os
.environ
.setdefault("PYTEST_ADDOPTS", "")
198 os
.environ
["PYTEST_ADDOPTS"] += " " + " ".join(extra
)
200 installed_requirements
= set()
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"])
213 os
.environ
["PYTEST_ADDOPTS"] += " -x"
216 if test
.get("sequential"):
217 sequential
.append(test
)
219 parallel
.append(test
)
221 jobs
= jobs
or cpu_count()
226 def on_test_finished(result
):
227 output
, ret
, test_path
= result
230 # Log the output of failed tests at the end so it's easy to find.
231 failure_output
.extend(output
)
237 {"test_path": test_path
, "ret": ret
},
238 "Setting retcode to {ret} from {test_path}",
243 logging
.INFO
, "python-test", {"line": line
.rstrip()}, "{line}"
246 return return_code
or ret
249 total
=(len(parallel
) + len(sequential
)),
251 desc
="Tests Completed",
255 with
ThreadPoolExecutor(max_workers
=jobs
) as executor
:
258 for test
in parallel
:
262 {"line": f
"Launching thread for test {test['file_relpath']}"},
267 _run_python_test
, command_context
, test
, jobs
, verbose
272 for future
in as_completed(futures
):
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()
283 for test
in sequential
:
284 test_result
= _run_python_test(command_context
, test
, jobs
, verbose
)
287 return_code
= on_test_finished(test_result
)
288 if return_code
and exitfirst
:
291 progress_bar
.update(1)
294 # Now log all failures (even if there was a KeyboardInterrupt or other exception).
295 for line
in failure_output
:
297 logging
.INFO
, "python-test", {"line": line
.rstrip()}, "{line}"
303 {"return_code": return_code
},
304 "Return code from mach python-test: {return_code}",
310 def _run_python_test(command_context
, test
, jobs
, verbose
):
314 # Buffer messages if more than one worker to avoid interleaving
319 logging
.INFO
, "python-test", {"line": line
.rstrip()}, "{line}"
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(
331 stdout
=subprocess
.PIPE
,
332 stderr
=subprocess
.STDOUT
,
333 universal_newlines
=True,
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-")
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")
353 if not file_displayed_test
:
356 "TEST-UNEXPECTED-FAIL | No test output (missing mozunit.main() "
357 "call?): {}".format(test
["path"])
362 _log("Test failed: {}".format(test
["path"]))
364 _log("Test passed: {}".format(test
["path"]))
366 return output
, return_code
, test
["path"]