##// END OF EJS Templates
Some gardening on iptest result reporting
Thomas Kluyver -
Show More
@@ -1,534 +1,546 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 from multiprocessing import Process, Queue
24 24 import os
25 25 import shutil
26 26 import signal
27 27 import sys
28 28 import subprocess
29 29 import time
30 30
31 31 from .iptest import have, test_group_names as py_test_group_names, test_sections
32 32 from IPython.utils.path import compress_user
33 33 from IPython.utils.py3compat import bytes_to_str
34 34 from IPython.utils.sysinfo import get_sys_info
35 35 from IPython.utils.tempdir import TemporaryDirectory
36 36
37 37
38 38 class TestController(object):
39 39 """Run tests in a subprocess
40 40 """
41 41 #: str, IPython test suite to be executed.
42 42 section = None
43 43 #: list, command line arguments to be executed
44 44 cmd = None
45 45 #: dict, extra environment variables to set for the subprocess
46 46 env = None
47 47 #: list, TemporaryDirectory instances to clear up when the process finishes
48 48 dirs = None
49 49 #: subprocess.Popen instance
50 50 process = None
51 51 #: str, process stdout+stderr
52 52 stdout = None
53 53 #: bool, whether to capture process stdout & stderr
54 54 buffer_output = False
55 55
56 56 def __init__(self):
57 57 self.cmd = []
58 58 self.env = {}
59 59 self.dirs = []
60 60
61 61 def launch(self):
62 62 # print('*** ENV:', self.env) # dbg
63 63 # print('*** CMD:', self.cmd) # dbg
64 64 env = os.environ.copy()
65 65 env.update(self.env)
66 66 output = subprocess.PIPE if self.buffer_output else None
67 67 stdout = subprocess.STDOUT if self.buffer_output else None
68 68 self.process = subprocess.Popen(self.cmd, stdout=output,
69 69 stderr=stdout, env=env)
70 70
71 71 def wait(self):
72 72 self.stdout, _ = self.process.communicate()
73 73 return self.process.returncode
74 74
75 def dump_failure(self):
76 """Print buffered results of a test failure.
77
78 Called after tests fail while running in parallel. The base
79 implementation just prints the output from the test subprocess, but
80 subclasses can override it to add extra information.
81 """
82 print(self.stdout)
83
75 84 def cleanup_process(self):
76 85 """Cleanup on exit by killing any leftover processes."""
77 86 subp = self.process
78 87 if subp is None or (subp.poll() is not None):
79 88 return # Process doesn't exist, or is already dead.
80 89
81 90 try:
82 91 print('Cleaning up stale PID: %d' % subp.pid)
83 92 subp.kill()
84 93 except: # (OSError, WindowsError) ?
85 94 # This is just a best effort, if we fail or the process was
86 95 # really gone, ignore it.
87 96 pass
88 97 else:
89 98 for i in range(10):
90 99 if subp.poll() is None:
91 100 time.sleep(0.1)
92 101 else:
93 102 break
94 103
95 104 if subp.poll() is None:
96 105 # The process did not die...
97 106 print('... failed. Manual cleanup may be required.')
98 107
99 108 def cleanup(self):
100 109 "Kill process if it's still alive, and clean up temporary directories"
101 110 self.cleanup_process()
102 111 for td in self.dirs:
103 112 td.cleanup()
104 113
105 114 __del__ = cleanup
106 115
107 116 class PyTestController(TestController):
108 117 """Run Python tests using IPython.testing.iptest"""
109 118 #: str, Python command to execute in subprocess
110 119 pycmd = None
111 120
112 121 def __init__(self, section):
113 122 """Create new test runner."""
114 123 TestController.__init__(self)
115 124 self.section = section
116 125 # pycmd is put into cmd[2] in PyTestController.launch()
117 126 self.cmd = [sys.executable, '-c', None, section]
118 127 self.pycmd = "from IPython.testing.iptest import run_iptest; run_iptest()"
119 128 ipydir = TemporaryDirectory()
120 129 self.dirs.append(ipydir)
121 130 self.env['IPYTHONDIR'] = ipydir.name
122 131 self.workingdir = workingdir = TemporaryDirectory()
123 132 self.dirs.append(workingdir)
124 133 self.env['IPTEST_WORKING_DIR'] = workingdir.name
125 134 # This means we won't get odd effects from our own matplotlib config
126 135 self.env['MPLCONFIGDIR'] = workingdir.name
127 136
128 137 @property
129 138 def will_run(self):
130 139 try:
131 140 return test_sections[self.section].will_run
132 141 except KeyError:
133 142 return True
134 143
135 144 def add_xunit(self):
136 145 xunit_file = os.path.abspath(self.section + '.xunit.xml')
137 146 self.cmd.extend(['--with-xunit', '--xunit-file', xunit_file])
138 147
139 148 def add_coverage(self):
140 149 try:
141 150 sources = test_sections[self.section].includes
142 151 except KeyError:
143 152 sources = ['IPython']
144 153
145 154 coverage_rc = ("[run]\n"
146 155 "data_file = {data_file}\n"
147 156 "source =\n"
148 157 " {source}\n"
149 158 ).format(data_file=os.path.abspath('.coverage.'+self.section),
150 159 source="\n ".join(sources))
151 160 config_file = os.path.join(self.workingdir.name, '.coveragerc')
152 161 with open(config_file, 'w') as f:
153 162 f.write(coverage_rc)
154 163
155 164 self.env['COVERAGE_PROCESS_START'] = config_file
156 165 self.pycmd = "import coverage; coverage.process_startup(); " + self.pycmd
157 166
158 167 def launch(self):
159 168 self.cmd[2] = self.pycmd
160 169 super(PyTestController, self).launch()
161 170
162 171 js_prefix = 'js/'
163 172
164 173 def get_js_test_dir():
165 174 import IPython.html.tests as t
166 175 return os.path.join(os.path.dirname(t.__file__), '')
167 176
168 177 def all_js_groups():
169 178 import glob
170 179 test_dir = get_js_test_dir()
171 180 all_subdirs = glob.glob(test_dir + '*/')
172 181 return [js_prefix+os.path.relpath(x, test_dir) for x in all_subdirs if os.path.relpath(x, test_dir) != '__pycache__']
173 182
174 183 class JSController(TestController):
175 184 """Run CasperJS tests """
176 185 def __init__(self, section):
177 186 """Create new test runner."""
178 187 TestController.__init__(self)
179 188 self.section = section
180 189
181 190
182 191 def launch(self):
183 192 self.ipydir = TemporaryDirectory()
184 193 self.nbdir = TemporaryDirectory()
185 print("Running %s tests in directory: %r" % (self.section, self.nbdir.name))
186 194 os.makedirs(os.path.join(self.nbdir.name, os.path.join(u'sub βˆ‚ir1', u'sub βˆ‚ir 1a')))
187 195 os.makedirs(os.path.join(self.nbdir.name, os.path.join(u'sub βˆ‚ir2', u'sub βˆ‚ir 1b')))
188 196 self.dirs.append(self.ipydir)
189 197 self.dirs.append(self.nbdir)
190 198
191 199 # start the ipython notebook, so we get the port number
192 200 self._init_server()
193 201 js_test_dir = get_js_test_dir()
194 202 includes = '--includes=' + os.path.join(js_test_dir,'util.js')
195 203 test_cases = os.path.join(js_test_dir, self.section[len(js_prefix):])
196 204 port = '--port=' + str(self.server_port)
197 205 self.cmd = ['casperjs', 'test', port, includes, test_cases]
198 206 super(JSController, self).launch()
199 207
200 208 @property
201 209 def will_run(self):
202 210 return all(have[a] for a in ['zmq', 'tornado', 'jinja2', 'casperjs'])
203 211
204 212 def _init_server(self):
205 213 "Start the notebook server in a separate process"
206 214 self.queue = q = Queue()
207 215 self.server = Process(target=run_webapp, args=(q, self.ipydir.name, self.nbdir.name))
208 216 self.server.start()
209 217 self.server_port = q.get()
210 218
219 def dump_failure(self):
220 print("Ran tests with notebook directory %r" % self.nbdir)
221 super(JSController, self).dump_failure()
222
211 223 def cleanup(self):
212 224 self.server.terminate()
213 225 self.server.join()
214 226 TestController.cleanup(self)
215 227
216 228 def run_webapp(q, ipydir, nbdir, loglevel=0):
217 229 """start the IPython Notebook, and pass port back to the queue"""
218 230 import os
219 231 import IPython.html.notebookapp as nbapp
220 232 import sys
221 233 sys.stderr = open(os.devnull, 'w')
222 234 server = nbapp.NotebookApp()
223 235 args = ['--no-browser']
224 236 args.extend(['--ipython-dir', ipydir,
225 237 '--notebook-dir', nbdir,
226 238 '--log-level', str(loglevel),
227 239 ])
228 240 # ipc doesn't work on Windows, and darwin has crazy-long temp paths,
229 241 # which run afoul of ipc's maximum path length.
230 242 if sys.platform.startswith('linux'):
231 243 args.append('--KernelManager.transport=ipc')
232 244 server.initialize(args)
233 245 # communicate the port number to the parent process
234 246 q.put(server.port)
235 247 server.start()
236 248
237 249 def prepare_controllers(options):
238 250 """Returns two lists of TestController instances, those to run, and those
239 251 not to run."""
240 252 testgroups = options.testgroups
241 253
242 254 if testgroups:
243 255 py_testgroups = [g for g in testgroups if (g in py_test_group_names) \
244 256 or g.startswith('IPython.')]
245 257 if 'js' in testgroups:
246 258 js_testgroups = all_js_groups()
247 259 else:
248 260 js_testgroups = [g for g in testgroups if g not in py_testgroups]
249 261 else:
250 262 py_testgroups = py_test_group_names
251 263 js_testgroups = all_js_groups()
252 264 if not options.all:
253 265 test_sections['parallel'].enabled = False
254 266
255 267 c_js = [JSController(name) for name in js_testgroups]
256 268 c_py = [PyTestController(name) for name in py_testgroups]
257 269
258 270 configure_py_controllers(c_py, xunit=options.xunit,
259 271 coverage=options.coverage, subproc_streams=options.subproc_streams,
260 272 extra_args=options.extra_args)
261 273
262 274 controllers = c_py + c_js
263 275 to_run = [c for c in controllers if c.will_run]
264 276 not_run = [c for c in controllers if not c.will_run]
265 277 return to_run, not_run
266 278
267 279 def configure_py_controllers(controllers, xunit=False, coverage=False,
268 280 subproc_streams='capture', extra_args=()):
269 281 """Apply options for a collection of TestController objects."""
270 282 for controller in controllers:
271 283 if xunit:
272 284 controller.add_xunit()
273 285 if coverage:
274 286 controller.add_coverage()
275 287 controller.env['IPTEST_SUBPROC_STREAMS'] = subproc_streams
276 288 controller.cmd.extend(extra_args)
277 289
278 290 def do_run(controller):
279 291 try:
280 292 try:
281 293 controller.launch()
282 294 except Exception:
283 295 import traceback
284 296 traceback.print_exc()
285 297 return controller, 1 # signal failure
286 298
287 299 exitcode = controller.wait()
288 300 return controller, exitcode
289 301
290 302 except KeyboardInterrupt:
291 303 return controller, -signal.SIGINT
292 304 finally:
293 305 controller.cleanup()
294 306
295 307 def report():
296 308 """Return a string with a summary report of test-related variables."""
297 309 inf = get_sys_info()
298 310 out = []
299 311 def _add(name, value):
300 312 out.append((name, value))
301 313
302 314 _add('IPython version', inf['ipython_version'])
303 315 _add('IPython commit', "{} ({})".format(inf['commit_hash'], inf['commit_source']))
304 316 _add('IPython package', compress_user(inf['ipython_path']))
305 317 _add('Python version', inf['sys_version'].replace('\n',''))
306 318 _add('sys.executable', compress_user(inf['sys_executable']))
307 319 _add('Platform', inf['platform'])
308 320
309 321 width = max(len(n) for (n,v) in out)
310 322 out = ["{:<{width}}: {}\n".format(n, v, width=width) for (n,v) in out]
311 323
312 324 avail = []
313 325 not_avail = []
314 326
315 327 for k, is_avail in have.items():
316 328 if is_avail:
317 329 avail.append(k)
318 330 else:
319 331 not_avail.append(k)
320 332
321 333 if avail:
322 334 out.append('\nTools and libraries available at test time:\n')
323 335 avail.sort()
324 336 out.append(' ' + ' '.join(avail)+'\n')
325 337
326 338 if not_avail:
327 339 out.append('\nTools and libraries NOT available at test time:\n')
328 340 not_avail.sort()
329 341 out.append(' ' + ' '.join(not_avail)+'\n')
330 342
331 343 return ''.join(out)
332 344
333 345 def run_iptestall(options):
334 346 """Run the entire IPython test suite by calling nose and trial.
335 347
336 348 This function constructs :class:`IPTester` instances for all IPython
337 349 modules and package and then runs each of them. This causes the modules
338 350 and packages of IPython to be tested each in their own subprocess using
339 351 nose.
340 352
341 353 Parameters
342 354 ----------
343 355
344 356 All parameters are passed as attributes of the options object.
345 357
346 358 testgroups : list of str
347 359 Run only these sections of the test suite. If empty, run all the available
348 360 sections.
349 361
350 362 fast : int or None
351 363 Run the test suite in parallel, using n simultaneous processes. If None
352 364 is passed, one process is used per CPU core. Default 1 (i.e. sequential)
353 365
354 366 inc_slow : bool
355 367 Include slow tests, like IPython.parallel. By default, these tests aren't
356 368 run.
357 369
358 370 xunit : bool
359 371 Produce Xunit XML output. This is written to multiple foo.xunit.xml files.
360 372
361 373 coverage : bool or str
362 374 Measure code coverage from tests. True will store the raw coverage data,
363 375 or pass 'html' or 'xml' to get reports.
364 376
365 377 extra_args : list
366 378 Extra arguments to pass to the test subprocesses, e.g. '-v'
367 379 """
368 380 if options.fast != 1:
369 381 # If running in parallel, capture output so it doesn't get interleaved
370 382 TestController.buffer_output = True
371 383
372 384 to_run, not_run = prepare_controllers(options)
373 385
374 386 def justify(ltext, rtext, width=70, fill='-'):
375 387 ltext += ' '
376 388 rtext = (' ' + rtext).rjust(width - len(ltext), fill)
377 389 return ltext + rtext
378 390
379 391 # Run all test runners, tracking execution time
380 392 failed = []
381 393 t_start = time.time()
382 394
383 395 print()
384 396 if options.fast == 1:
385 397 # This actually means sequential, i.e. with 1 job
386 398 for controller in to_run:
387 399 print('IPython test group:', controller.section)
388 400 sys.stdout.flush() # Show in correct order when output is piped
389 401 controller, res = do_run(controller)
390 402 if res:
391 403 failed.append(controller)
392 404 if res == -signal.SIGINT:
393 405 print("Interrupted")
394 406 break
395 407 print()
396 408
397 409 else:
398 410 # Run tests concurrently
399 411 try:
400 412 pool = multiprocessing.pool.ThreadPool(options.fast)
401 413 for (controller, res) in pool.imap_unordered(do_run, to_run):
402 414 res_string = 'OK' if res == 0 else 'FAILED'
403 print(justify('IPython test group: ' + controller.section, res_string))
415 print(justify('Test group: ' + controller.section, res_string))
404 416 if res:
405 print(bytes_to_str(controller.stdout))
417 controller.dump_failure()
406 418 failed.append(controller)
407 419 if res == -signal.SIGINT:
408 420 print("Interrupted")
409 421 break
410 422 except KeyboardInterrupt:
411 423 return
412 424
413 425 for controller in not_run:
414 426 print(justify('IPython test group: ' + controller.section, 'NOT RUN'))
415 427
416 428 t_end = time.time()
417 429 t_tests = t_end - t_start
418 430 nrunners = len(to_run)
419 431 nfail = len(failed)
420 432 # summarize results
421 433 print('_'*70)
422 434 print('Test suite completed for system with the following information:')
423 435 print(report())
424 436 took = "Took %.3fs." % t_tests
425 437 print('Status: ', end='')
426 438 if not failed:
427 439 print('OK (%d test groups).' % nrunners, took)
428 440 else:
429 441 # If anything went wrong, point out what command to rerun manually to
430 442 # see the actual errors and individual summary
431 443 failed_sections = [c.section for c in failed]
432 444 print('ERROR - {} out of {} test groups failed ({}).'.format(nfail,
433 445 nrunners, ', '.join(failed_sections)), took)
434 446 print()
435 447 print('You may wish to rerun these, with:')
436 448 print(' iptest', *failed_sections)
437 449 print()
438 450
439 451 if options.coverage:
440 452 from coverage import coverage
441 453 cov = coverage(data_file='.coverage')
442 454 cov.combine()
443 455 cov.save()
444 456
445 457 # Coverage HTML report
446 458 if options.coverage == 'html':
447 459 html_dir = 'ipy_htmlcov'
448 460 shutil.rmtree(html_dir, ignore_errors=True)
449 461 print("Writing HTML coverage report to %s/ ... " % html_dir, end="")
450 462 sys.stdout.flush()
451 463
452 464 # Custom HTML reporter to clean up module names.
453 465 from coverage.html import HtmlReporter
454 466 class CustomHtmlReporter(HtmlReporter):
455 467 def find_code_units(self, morfs):
456 468 super(CustomHtmlReporter, self).find_code_units(morfs)
457 469 for cu in self.code_units:
458 470 nameparts = cu.name.split(os.sep)
459 471 if 'IPython' not in nameparts:
460 472 continue
461 473 ix = nameparts.index('IPython')
462 474 cu.name = '.'.join(nameparts[ix:])
463 475
464 476 # Reimplement the html_report method with our custom reporter
465 477 cov._harvest_data()
466 478 cov.config.from_args(omit='*{0}tests{0}*'.format(os.sep), html_dir=html_dir,
467 479 html_title='IPython test coverage',
468 480 )
469 481 reporter = CustomHtmlReporter(cov, cov.config)
470 482 reporter.report(None)
471 483 print('done.')
472 484
473 485 # Coverage XML report
474 486 elif options.coverage == 'xml':
475 487 cov.xml_report(outfile='ipy_coverage.xml')
476 488
477 489 if failed:
478 490 # Ensure that our exit code indicates failure
479 491 sys.exit(1)
480 492
481 493 argparser = argparse.ArgumentParser(description='Run IPython test suite')
482 494 argparser.add_argument('testgroups', nargs='*',
483 495 help='Run specified groups of tests. If omitted, run '
484 496 'all tests.')
485 497 argparser.add_argument('--all', action='store_true',
486 498 help='Include slow tests not run by default.')
487 499 argparser.add_argument('-j', '--fast', nargs='?', const=None, default=1, type=int,
488 500 help='Run test sections in parallel.')
489 501 argparser.add_argument('--xunit', action='store_true',
490 502 help='Produce Xunit XML results')
491 503 argparser.add_argument('--coverage', nargs='?', const=True, default=False,
492 504 help="Measure test coverage. Specify 'html' or "
493 505 "'xml' to get reports.")
494 506 argparser.add_argument('--subproc-streams', default='capture',
495 507 help="What to do with stdout/stderr from subprocesses. "
496 508 "'capture' (default), 'show' and 'discard' are the options.")
497 509
498 510 def default_options():
499 511 """Get an argparse Namespace object with the default arguments, to pass to
500 512 :func:`run_iptestall`.
501 513 """
502 514 options = argparser.parse_args([])
503 515 options.extra_args = []
504 516 return options
505 517
506 518 def main():
507 519 # iptest doesn't work correctly if the working directory is the
508 520 # root of the IPython source tree. Tell the user to avoid
509 521 # frustration.
510 522 if os.path.exists(os.path.join(os.getcwd(),
511 523 'IPython', 'testing', '__main__.py')):
512 524 print("Don't run iptest from the IPython source directory",
513 525 file=sys.stderr)
514 526 sys.exit(1)
515 527 # Arguments after -- should be passed through to nose. Argparse treats
516 528 # everything after -- as regular positional arguments, so we separate them
517 529 # first.
518 530 try:
519 531 ix = sys.argv.index('--')
520 532 except ValueError:
521 533 to_parse = sys.argv[1:]
522 534 extra_args = []
523 535 else:
524 536 to_parse = sys.argv[1:ix]
525 537 extra_args = sys.argv[ix+1:]
526 538
527 539 options = argparser.parse_args(to_parse)
528 540 options.extra_args = extra_args
529 541
530 542 run_iptestall(options)
531 543
532 544
533 545 if __name__ == '__main__':
534 546 main()
General Comments 0
You need to be logged in to leave comments. Login now