##// END OF EJS Templates
Allow passing extra arguments to iptest through for nose
Thomas Kluyver -
Show More
@@ -1,404 +1,422 b''
1 1 # -*- coding: utf-8 -*-
2 2 """IPython Test Process Controller
3 3
4 4 This module runs one or more subprocesses which will actually run the IPython
5 5 test suite.
6 6
7 7 """
8 8
9 9 #-----------------------------------------------------------------------------
10 10 # Copyright (C) 2009-2011 The IPython Development Team
11 11 #
12 12 # Distributed under the terms of the BSD License. The full license is in
13 13 # the file COPYING, distributed as part of this software.
14 14 #-----------------------------------------------------------------------------
15 15
16 16 #-----------------------------------------------------------------------------
17 17 # Imports
18 18 #-----------------------------------------------------------------------------
19 19 from __future__ import print_function
20 20
21 21 import argparse
22 22 import multiprocessing.pool
23 23 import os
24 24 import shutil
25 25 import signal
26 26 import sys
27 27 import subprocess
28 28 import time
29 29
30 30 from .iptest import have, test_group_names, test_sections
31 31 from IPython.utils.py3compat import bytes_to_str
32 32 from IPython.utils.sysinfo import sys_info
33 33 from IPython.utils.tempdir import TemporaryDirectory
34 34
35 35
36 36 class TestController(object):
37 37 """Run tests in a subprocess
38 38 """
39 39 #: str, IPython test suite to be executed.
40 40 section = None
41 41 #: list, command line arguments to be executed
42 42 cmd = None
43 43 #: dict, extra environment variables to set for the subprocess
44 44 env = None
45 45 #: list, TemporaryDirectory instances to clear up when the process finishes
46 46 dirs = None
47 47 #: subprocess.Popen instance
48 48 process = None
49 49 #: str, process stdout+stderr
50 50 stdout = None
51 51 #: bool, whether to capture process stdout & stderr
52 52 buffer_output = False
53 53
54 54 def __init__(self):
55 55 self.cmd = []
56 56 self.env = {}
57 57 self.dirs = []
58 58
59 59 @property
60 60 def will_run(self):
61 61 """Override in subclasses to check for dependencies."""
62 62 return False
63 63
64 64 def launch(self):
65 65 # print('*** ENV:', self.env) # dbg
66 66 # print('*** CMD:', self.cmd) # dbg
67 67 env = os.environ.copy()
68 68 env.update(self.env)
69 69 output = subprocess.PIPE if self.buffer_output else None
70 70 stdout = subprocess.STDOUT if self.buffer_output else None
71 71 self.process = subprocess.Popen(self.cmd, stdout=output,
72 72 stderr=stdout, env=env)
73 73
74 74 def wait(self):
75 75 self.stdout, _ = self.process.communicate()
76 76 return self.process.returncode
77 77
78 78 def cleanup_process(self):
79 79 """Cleanup on exit by killing any leftover processes."""
80 80 subp = self.process
81 81 if subp is None or (subp.poll() is not None):
82 82 return # Process doesn't exist, or is already dead.
83 83
84 84 try:
85 85 print('Cleaning up stale PID: %d' % subp.pid)
86 86 subp.kill()
87 87 except: # (OSError, WindowsError) ?
88 88 # This is just a best effort, if we fail or the process was
89 89 # really gone, ignore it.
90 90 pass
91 91 else:
92 92 for i in range(10):
93 93 if subp.poll() is None:
94 94 time.sleep(0.1)
95 95 else:
96 96 break
97 97
98 98 if subp.poll() is None:
99 99 # The process did not die...
100 100 print('... failed. Manual cleanup may be required.')
101 101
102 102 def cleanup(self):
103 103 "Kill process if it's still alive, and clean up temporary directories"
104 104 self.cleanup_process()
105 105 for td in self.dirs:
106 106 td.cleanup()
107 107
108 108 __del__ = cleanup
109 109
110 110 class PyTestController(TestController):
111 111 """Run Python tests using IPython.testing.iptest"""
112 112 #: str, Python command to execute in subprocess
113 113 pycmd = None
114 114
115 115 def __init__(self, section):
116 116 """Create new test runner."""
117 117 TestController.__init__(self)
118 118 self.section = section
119 119 # pycmd is put into cmd[2] in PyTestController.launch()
120 120 self.cmd = [sys.executable, '-c', None, section]
121 121 self.pycmd = "from IPython.testing.iptest import run_iptest; run_iptest()"
122 122 ipydir = TemporaryDirectory()
123 123 self.dirs.append(ipydir)
124 124 self.env['IPYTHONDIR'] = ipydir.name
125 125 self.workingdir = workingdir = TemporaryDirectory()
126 126 self.dirs.append(workingdir)
127 127 self.env['IPTEST_WORKING_DIR'] = workingdir.name
128 128 # This means we won't get odd effects from our own matplotlib config
129 129 self.env['MPLCONFIGDIR'] = workingdir.name
130 130
131 131 @property
132 132 def will_run(self):
133 133 try:
134 134 return test_sections[self.section].will_run
135 135 except KeyError:
136 136 return True
137 137
138 138 def add_xunit(self):
139 139 xunit_file = os.path.abspath(self.section + '.xunit.xml')
140 140 self.cmd.extend(['--with-xunit', '--xunit-file', xunit_file])
141 141
142 142 def add_coverage(self):
143 143 try:
144 144 sources = test_sections[self.section].includes
145 145 except KeyError:
146 146 sources = ['IPython']
147 147
148 148 coverage_rc = ("[run]\n"
149 149 "data_file = {data_file}\n"
150 150 "source =\n"
151 151 " {source}\n"
152 152 ).format(data_file=os.path.abspath('.coverage.'+self.section),
153 153 source="\n ".join(sources))
154 154 config_file = os.path.join(self.workingdir.name, '.coveragerc')
155 155 with open(config_file, 'w') as f:
156 156 f.write(coverage_rc)
157 157
158 158 self.env['COVERAGE_PROCESS_START'] = config_file
159 159 self.pycmd = "import coverage; coverage.process_startup(); " + self.pycmd
160 160
161 161 def launch(self):
162 162 self.cmd[2] = self.pycmd
163 163 super(PyTestController, self).launch()
164 164
165 165
166 166 def prepare_py_test_controllers(inc_slow=False):
167 167 """Returns an ordered list of PyTestController instances to be run."""
168 168 to_run, not_run = [], []
169 169 if not inc_slow:
170 170 test_sections['parallel'].enabled = False
171 171
172 172 for name in test_group_names:
173 173 controller = PyTestController(name)
174 174 if controller.will_run:
175 175 to_run.append(controller)
176 176 else:
177 177 not_run.append(controller)
178 178 return to_run, not_run
179 179
180 def configure_controllers(controllers, xunit=False, coverage=False):
180 def configure_controllers(controllers, xunit=False, coverage=False, extra_args=()):
181 181 """Apply options for a collection of TestController objects."""
182 182 for controller in controllers:
183 183 if xunit:
184 184 controller.add_xunit()
185 185 if coverage:
186 186 controller.add_coverage()
187 controller.cmd.extend(extra_args)
187 188
188 189 def do_run(controller):
189 190 try:
190 191 try:
191 192 controller.launch()
192 193 except Exception:
193 194 import traceback
194 195 traceback.print_exc()
195 196 return controller, 1 # signal failure
196 197
197 198 exitcode = controller.wait()
198 199 return controller, exitcode
199 200
200 201 except KeyboardInterrupt:
201 202 return controller, -signal.SIGINT
202 203 finally:
203 204 controller.cleanup()
204 205
205 206 def report():
206 207 """Return a string with a summary report of test-related variables."""
207 208
208 209 out = [ sys_info(), '\n']
209 210
210 211 avail = []
211 212 not_avail = []
212 213
213 214 for k, is_avail in have.items():
214 215 if is_avail:
215 216 avail.append(k)
216 217 else:
217 218 not_avail.append(k)
218 219
219 220 if avail:
220 221 out.append('\nTools and libraries available at test time:\n')
221 222 avail.sort()
222 223 out.append(' ' + ' '.join(avail)+'\n')
223 224
224 225 if not_avail:
225 226 out.append('\nTools and libraries NOT available at test time:\n')
226 227 not_avail.sort()
227 228 out.append(' ' + ' '.join(not_avail)+'\n')
228 229
229 230 return ''.join(out)
230 231
231 232 def run_iptestall(options):
232 233 """Run the entire IPython test suite by calling nose and trial.
233 234
234 235 This function constructs :class:`IPTester` instances for all IPython
235 236 modules and package and then runs each of them. This causes the modules
236 237 and packages of IPython to be tested each in their own subprocess using
237 238 nose.
238 239
239 240 Parameters
240 241 ----------
241 242
242 243 All parameters are passed as attributes of the options object.
243 244
244 245 testgroups : list of str
245 246 Run only these sections of the test suite. If empty, run all the available
246 247 sections.
247 248
248 249 fast : int or None
249 250 Run the test suite in parallel, using n simultaneous processes. If None
250 251 is passed, one process is used per CPU core. Default 1 (i.e. sequential)
251 252
252 253 inc_slow : bool
253 254 Include slow tests, like IPython.parallel. By default, these tests aren't
254 255 run.
255 256
256 257 xunit : bool
257 258 Produce Xunit XML output. This is written to multiple foo.xunit.xml files.
258 259
259 260 coverage : bool or str
260 261 Measure code coverage from tests. True will store the raw coverage data,
261 262 or pass 'html' or 'xml' to get reports.
263
264 extra_args : list
265 Extra arguments to pass to the test subprocesses, e.g. '-v'
262 266 """
263 267 if options.fast != 1:
264 268 # If running in parallel, capture output so it doesn't get interleaved
265 269 TestController.buffer_output = True
266 270
267 271 if options.testgroups:
268 272 to_run = [PyTestController(name) for name in options.testgroups]
269 273 not_run = []
270 274 else:
271 275 to_run, not_run = prepare_py_test_controllers(inc_slow=options.all)
272 276
273 configure_controllers(to_run, xunit=options.xunit, coverage=options.coverage)
277 configure_controllers(to_run, xunit=options.xunit, coverage=options.coverage,
278 extra_args=options.extra_args)
274 279
275 280 def justify(ltext, rtext, width=70, fill='-'):
276 281 ltext += ' '
277 282 rtext = (' ' + rtext).rjust(width - len(ltext), fill)
278 283 return ltext + rtext
279 284
280 285 # Run all test runners, tracking execution time
281 286 failed = []
282 287 t_start = time.time()
283 288
284 289 print()
285 290 if options.fast == 1:
286 291 # This actually means sequential, i.e. with 1 job
287 292 for controller in to_run:
288 293 print('IPython test group:', controller.section)
289 294 controller, res = do_run(controller)
290 295 if res:
291 296 failed.append(controller)
292 297 if res == -signal.SIGINT:
293 298 print("Interrupted")
294 299 break
295 300 print()
296 301
297 302 else:
298 303 # Run tests concurrently
299 304 try:
300 305 pool = multiprocessing.pool.ThreadPool(options.fast)
301 306 for (controller, res) in pool.imap_unordered(do_run, to_run):
302 307 res_string = 'OK' if res == 0 else 'FAILED'
303 308 print(justify('IPython test group: ' + controller.section, res_string))
304 309 if res:
305 310 print(bytes_to_str(controller.stdout))
306 311 failed.append(controller)
307 312 if res == -signal.SIGINT:
308 313 print("Interrupted")
309 314 break
310 315 except KeyboardInterrupt:
311 316 return
312 317
313 318 for controller in not_run:
314 319 print(justify('IPython test group: ' + controller.section, 'NOT RUN'))
315 320
316 321 t_end = time.time()
317 322 t_tests = t_end - t_start
318 323 nrunners = len(to_run)
319 324 nfail = len(failed)
320 325 # summarize results
321 326 print('_'*70)
322 327 print('Test suite completed for system with the following information:')
323 328 print(report())
324 329 print('Ran %s test groups in %.3fs' % (nrunners, t_tests))
325 330 print()
326 331 print('Status: ', end='')
327 332 if not failed:
328 333 print('OK')
329 334 else:
330 335 # If anything went wrong, point out what command to rerun manually to
331 336 # see the actual errors and individual summary
332 337 failed_sections = [c.section for c in failed]
333 338 print('ERROR - {} out of {} test groups failed ({}).'.format(nfail,
334 339 nrunners, ', '.join(failed_sections)))
335 340 print()
336 341 print('You may wish to rerun these, with:')
337 342 print(' iptest', *failed_sections)
338 343 print()
339 344
340 345 if options.coverage:
341 346 from coverage import coverage
342 347 cov = coverage(data_file='.coverage')
343 348 cov.combine()
344 349 cov.save()
345 350
346 351 # Coverage HTML report
347 352 if options.coverage == 'html':
348 353 html_dir = 'ipy_htmlcov'
349 354 shutil.rmtree(html_dir, ignore_errors=True)
350 355 print("Writing HTML coverage report to %s/ ... " % html_dir, end="")
351 356 sys.stdout.flush()
352 357
353 358 # Custom HTML reporter to clean up module names.
354 359 from coverage.html import HtmlReporter
355 360 class CustomHtmlReporter(HtmlReporter):
356 361 def find_code_units(self, morfs):
357 362 super(CustomHtmlReporter, self).find_code_units(morfs)
358 363 for cu in self.code_units:
359 364 nameparts = cu.name.split(os.sep)
360 365 if 'IPython' not in nameparts:
361 366 continue
362 367 ix = nameparts.index('IPython')
363 368 cu.name = '.'.join(nameparts[ix:])
364 369
365 370 # Reimplement the html_report method with our custom reporter
366 371 cov._harvest_data()
367 372 cov.config.from_args(omit='*%stests' % os.sep, html_dir=html_dir,
368 373 html_title='IPython test coverage',
369 374 )
370 375 reporter = CustomHtmlReporter(cov, cov.config)
371 376 reporter.report(None)
372 377 print('done.')
373 378
374 379 # Coverage XML report
375 380 elif options.coverage == 'xml':
376 381 cov.xml_report(outfile='ipy_coverage.xml')
377 382
378 383 if failed:
379 384 # Ensure that our exit code indicates failure
380 385 sys.exit(1)
381 386
382 387
383 388 def main():
389 # Arguments after -- should be passed through to nose. Argparse treats
390 # everything after -- as regular positional arguments, so we separate them
391 # first.
392 try:
393 ix = sys.argv.index('--')
394 except ValueError:
395 to_parse = sys.argv[1:]
396 extra_args = []
397 else:
398 to_parse = sys.argv[1:ix]
399 extra_args = sys.argv[ix+1:]
400
384 401 parser = argparse.ArgumentParser(description='Run IPython test suite')
385 402 parser.add_argument('testgroups', nargs='*',
386 403 help='Run specified groups of tests. If omitted, run '
387 404 'all tests.')
388 405 parser.add_argument('--all', action='store_true',
389 406 help='Include slow tests not run by default.')
390 407 parser.add_argument('-j', '--fast', nargs='?', const=None, default=1, type=int,
391 408 help='Run test sections in parallel.')
392 409 parser.add_argument('--xunit', action='store_true',
393 410 help='Produce Xunit XML results')
394 411 parser.add_argument('--coverage', nargs='?', const=True, default=False,
395 412 help="Measure test coverage. Specify 'html' or "
396 413 "'xml' to get reports.")
397 414
398 options = parser.parse_args()
415 options = parser.parse_args(to_parse)
416 options.extra_args = extra_args
399 417
400 418 run_iptestall(options)
401 419
402 420
403 421 if __name__ == '__main__':
404 422 main()
General Comments 0
You need to be logged in to leave comments. Login now