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