Source code

Revision control

Other Tools

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/.
4
5
from __future__ import absolute_import, print_function, unicode_literals
6
7
import argparse
8
import logging
9
import os
10
import sys
11
import tempfile
12
from multiprocessing import cpu_count
13
14
from concurrent.futures import (
15
ThreadPoolExecutor,
16
as_completed,
17
thread,
18
)
19
20
import mozinfo
21
from manifestparser import TestManifest
22
from manifestparser import filters as mpf
23
24
from mozbuild.base import (
25
MachCommandBase,
26
)
27
28
from mach.decorators import (
29
CommandArgument,
30
CommandProvider,
31
Command,
32
)
33
34
here = os.path.abspath(os.path.dirname(__file__))
35
36
37
@CommandProvider
38
class MachCommands(MachCommandBase):
39
@Command('python', category='devenv',
40
description='Run Python.')
41
@CommandArgument('--no-virtualenv', action='store_true',
42
help='Do not set up a virtualenv')
43
@CommandArgument('--exec-file',
44
default=None,
45
help='Execute this Python file using `execfile`')
46
@CommandArgument('args', nargs=argparse.REMAINDER)
47
def python(self, no_virtualenv, exec_file, args):
48
# Avoid logging the command
49
self.log_manager.terminal_handler.setLevel(logging.CRITICAL)
50
51
# Note: subprocess requires native strings in os.environ on Windows.
52
append_env = {
53
b'PYTHONDONTWRITEBYTECODE': str('1'),
54
}
55
56
if no_virtualenv:
57
python_path = sys.executable
58
append_env[b'PYTHONPATH'] = os.pathsep.join(sys.path)
59
else:
60
self._activate_virtualenv()
61
python_path = self.virtualenv_manager.python_path
62
63
if exec_file:
64
exec(open(exec_file).read())
65
return 0
66
67
return self.run_process([python_path] + args,
68
pass_thru=True, # Allow user to run Python interactively.
69
ensure_exit_code=False, # Don't throw on non-zero exit code.
70
append_env=append_env)
71
72
@Command('python-test', category='testing',
73
description='Run Python unit tests with an appropriate test runner.')
74
@CommandArgument('-v', '--verbose',
75
default=False,
76
action='store_true',
77
help='Verbose output.')
78
@CommandArgument('--python',
79
default='2.7',
80
help='Version of Python for Pipenv to use. When given a '
81
'Python version, Pipenv will automatically scan your '
82
'system for a Python that matches that given version.')
83
@CommandArgument('-j', '--jobs',
84
default=None,
85
type=int,
86
help='Number of concurrent jobs to run. Default is the number of CPUs '
87
'in the system.')
88
@CommandArgument('-x', '--exitfirst',
89
default=False,
90
action='store_true',
91
help='Runs all tests sequentially and breaks at the first failure.')
92
@CommandArgument('--subsuite',
93
default=None,
94
help=('Python subsuite to run. If not specified, all subsuites are run. '
95
'Use the string `default` to only run tests without a subsuite.'))
96
@CommandArgument('tests', nargs='*',
97
metavar='TEST',
98
help=('Tests to run. Each test can be a single file or a directory. '
99
'Default test resolution relies on PYTHON_UNITTEST_MANIFESTS.'))
100
@CommandArgument('extra', nargs=argparse.REMAINDER,
101
metavar='PYTEST ARGS',
102
help=('Arguments that aren\'t recognized by mach. These will be '
103
'passed as it is to pytest'))
104
def python_test(self, *args, **kwargs):
105
try:
106
tempdir = os.environ[b'PYTHON_TEST_TMP'] = str(tempfile.mkdtemp(suffix='-python-test'))
107
return self.run_python_tests(*args, **kwargs)
108
finally:
109
import mozfile
110
mozfile.remove(tempdir)
111
112
def run_python_tests(self,
113
tests=None,
114
test_objects=None,
115
subsuite=None,
116
verbose=False,
117
jobs=None,
118
python=None,
119
exitfirst=False,
120
extra=None,
121
**kwargs):
122
python = python or self.virtualenv_manager.python_path
123
self.activate_pipenv(pipfile=None, populate=True, python=python)
124
125
if test_objects is None:
126
from moztest.resolve import TestResolver
127
resolver = self._spawn(TestResolver)
128
# If we were given test paths, try to find tests matching them.
129
test_objects = resolver.resolve_tests(paths=tests, flavor='python')
130
else:
131
# We've received test_objects from |mach test|. We need to ignore
132
# the subsuite because python-tests don't use this key like other
133
# harnesses do and |mach test| doesn't realize this.
134
subsuite = None
135
136
mp = TestManifest()
137
mp.tests.extend(test_objects)
138
139
filters = []
140
if subsuite == 'default':
141
filters.append(mpf.subsuite(None))
142
elif subsuite:
143
filters.append(mpf.subsuite(subsuite))
144
145
tests = mp.active_tests(
146
filters=filters,
147
disabled=False,
148
python=self.virtualenv_manager.version_info[0],
149
**mozinfo.info)
150
151
if not tests:
152
submsg = "for subsuite '{}' ".format(subsuite) if subsuite else ""
153
message = "TEST-UNEXPECTED-FAIL | No tests collected " + \
154
"{}(Not in PYTHON_UNITTEST_MANIFESTS?)".format(submsg)
155
self.log(logging.WARN, 'python-test', {}, message)
156
return 1
157
158
parallel = []
159
sequential = []
160
os.environ.setdefault('PYTEST_ADDOPTS', '')
161
162
if extra:
163
os.environ['PYTEST_ADDOPTS'] += " " + " ".join(extra)
164
165
if exitfirst:
166
sequential = tests
167
os.environ['PYTEST_ADDOPTS'] += " -x"
168
else:
169
for test in tests:
170
if test.get('sequential'):
171
sequential.append(test)
172
else:
173
parallel.append(test)
174
175
self.jobs = jobs or cpu_count()
176
self.terminate = False
177
self.verbose = verbose
178
179
return_code = 0
180
181
def on_test_finished(result):
182
output, ret, test_path = result
183
184
for line in output:
185
self.log(logging.INFO, 'python-test', {'line': line.rstrip()}, '{line}')
186
187
if ret and not return_code:
188
self.log(logging.ERROR, 'python-test', {'test_path': test_path, 'ret': ret},
189
'Setting retcode to {ret} from {test_path}')
190
return return_code or ret
191
192
with ThreadPoolExecutor(max_workers=self.jobs) as executor:
193
futures = [executor.submit(self._run_python_test, test)
194
for test in parallel]
195
196
try:
197
for future in as_completed(futures):
198
return_code = on_test_finished(future.result())
199
except KeyboardInterrupt:
200
# Hack to force stop currently running threads.
202
executor._threads.clear()
203
thread._threads_queues.clear()
204
raise
205
206
for test in sequential:
207
return_code = on_test_finished(self._run_python_test(test))
208
if return_code and exitfirst:
209
break
210
211
self.log(logging.INFO, 'python-test', {'return_code': return_code},
212
'Return code from mach python-test: {return_code}')
213
return return_code
214
215
def _run_python_test(self, test):
216
from mozprocess import ProcessHandler
217
218
if test.get('requirements'):
219
self.virtualenv_manager.install_pip_requirements(test['requirements'], quiet=True)
220
221
output = []
222
223
def _log(line):
224
# Buffer messages if more than one worker to avoid interleaving
225
if self.jobs > 1:
226
output.append(line)
227
else:
228
self.log(logging.INFO, 'python-test', {'line': line.rstrip()}, '{line}')
229
230
file_displayed_test = [] # used as boolean
231
232
def _line_handler(line):
233
if not file_displayed_test:
234
output = ('Ran' in line or 'collected' in line or
235
line.startswith('TEST-'))
236
if output:
237
file_displayed_test.append(True)
238
239
# Hack to make sure treeherder highlights pytest failures
240
if b'FAILED' in line.rsplit(b' ', 1)[-1]:
241
line = line.replace(b'FAILED', b'TEST-UNEXPECTED-FAIL')
242
243
_log(line)
244
245
_log(test['path'])
246
cmd = [self.virtualenv_manager.python_path, test['path']]
247
env = os.environ.copy()
248
env[b'PYTHONDONTWRITEBYTECODE'] = b'1'
249
250
proc = ProcessHandler(cmd, env=env, processOutputLine=_line_handler, storeOutput=False)
251
proc.run()
252
253
return_code = proc.wait()
254
255
if not file_displayed_test:
256
_log('TEST-UNEXPECTED-FAIL | No test output (missing mozunit.main() '
257
'call?): {}'.format(test['path']))
258
259
if self.verbose:
260
if return_code != 0:
261
_log('Test failed: {}'.format(test['path']))
262
else:
263
_log('Test passed: {}'.format(test['path']))
264
265
return output, return_code, test['path']