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