##// END OF EJS Templates
Added notebook and widget test groups.
Jonathan Frederic -
Show More
@@ -1,510 +1,517
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 class JSController(TestController):
163 163 """Run CasperJS tests """
164 164 def __init__(self, section):
165 165 """Create new test runner."""
166 166 TestController.__init__(self)
167 167 self.section = section
168 168
169 169 self.ipydir = TemporaryDirectory()
170 170 self.nbdir = TemporaryDirectory()
171 171 print("Running notebook tests in directory: %r" % self.nbdir.name)
172 172 os.makedirs(os.path.join(self.nbdir.name, os.path.join('subdir1', 'subdir1a')))
173 173 os.makedirs(os.path.join(self.nbdir.name, os.path.join('subdir2', 'subdir2a')))
174 174 self.dirs.append(self.ipydir)
175 175 self.dirs.append(self.nbdir)
176 176
177 177 def launch(self):
178 178 # start the ipython notebook, so we get the port number
179 179 self._init_server()
180 180
181 181 import IPython.html.tests as t
182 182 test_dir = os.path.join(os.path.dirname(t.__file__), 'casperjs')
183 183 includes = '--includes=' + os.path.join(test_dir,'util.js')
184
185 if self.section == 'js':
184 186 test_cases = os.path.join(test_dir, 'test_cases')
187 elif self.section == 'notebook':
188 test_cases = os.path.join(test_dir, 'test_cases', 'notebook')
189 elif self.section == 'widgets':
190 test_cases = os.path.join(test_dir, 'test_cases', 'widgets')
191
185 192 port = '--port=' + str(self.server_port)
186 193 self.cmd = ['casperjs', 'test', port, includes, test_cases]
187 194
188 195 super(JSController, self).launch()
189 196
190 197 @property
191 198 def will_run(self):
192 199 return all(have[a] for a in ['zmq', 'tornado', 'jinja2', 'casperjs'])
193 200
194 201 def _init_server(self):
195 202 "Start the notebook server in a separate process"
196 203 self.queue = q = Queue()
197 204 self.server = Process(target=run_webapp, args=(q, self.ipydir.name, self.nbdir.name))
198 205 self.server.start()
199 206 self.server_port = q.get()
200 207
201 208 def cleanup(self):
202 209 self.server.terminate()
203 210 self.server.join()
204 211 TestController.cleanup(self)
205 212
206 js_test_group_names = {'js'}
213 js_test_group_names = {'js', 'notebook', 'widgets'}
207 214
208 215 def run_webapp(q, ipydir, nbdir, loglevel=0):
209 216 """start the IPython Notebook, and pass port back to the queue"""
210 217 import os
211 218 import IPython.html.notebookapp as nbapp
212 219 import sys
213 220 sys.stderr = open(os.devnull, 'w')
214 221 server = nbapp.NotebookApp()
215 222 args = ['--no-browser']
216 223 args.extend(['--ipython-dir', ipydir])
217 224 args.extend(['--notebook-dir', nbdir])
218 225 args.extend(['--log-level', str(loglevel)])
219 226 server.initialize(args)
220 227 # communicate the port number to the parent process
221 228 q.put(server.port)
222 229 server.start()
223 230
224 231 def prepare_controllers(options):
225 232 """Returns two lists of TestController instances, those to run, and those
226 233 not to run."""
227 234 testgroups = options.testgroups
228 235
229 236 if testgroups:
230 237 py_testgroups = [g for g in testgroups if (g in py_test_group_names) \
231 238 or g.startswith('IPython.')]
232 239 js_testgroups = [g for g in testgroups if g in js_test_group_names]
233 240 else:
234 241 py_testgroups = py_test_group_names
235 242 js_testgroups = js_test_group_names
236 243 if not options.all:
237 244 test_sections['parallel'].enabled = False
238 245
239 246 c_js = [JSController(name) for name in js_testgroups]
240 247 c_py = [PyTestController(name) for name in py_testgroups]
241 248
242 249 configure_py_controllers(c_py, xunit=options.xunit,
243 250 coverage=options.coverage, subproc_streams=options.subproc_streams,
244 251 extra_args=options.extra_args)
245 252
246 253 controllers = c_py + c_js
247 254 to_run = [c for c in controllers if c.will_run]
248 255 not_run = [c for c in controllers if not c.will_run]
249 256 return to_run, not_run
250 257
251 258 def configure_py_controllers(controllers, xunit=False, coverage=False,
252 259 subproc_streams='capture', extra_args=()):
253 260 """Apply options for a collection of TestController objects."""
254 261 for controller in controllers:
255 262 if xunit:
256 263 controller.add_xunit()
257 264 if coverage:
258 265 controller.add_coverage()
259 266 controller.env['IPTEST_SUBPROC_STREAMS'] = subproc_streams
260 267 controller.cmd.extend(extra_args)
261 268
262 269 def do_run(controller):
263 270 try:
264 271 try:
265 272 controller.launch()
266 273 except Exception:
267 274 import traceback
268 275 traceback.print_exc()
269 276 return controller, 1 # signal failure
270 277
271 278 exitcode = controller.wait()
272 279 return controller, exitcode
273 280
274 281 except KeyboardInterrupt:
275 282 return controller, -signal.SIGINT
276 283 finally:
277 284 controller.cleanup()
278 285
279 286 def report():
280 287 """Return a string with a summary report of test-related variables."""
281 288 inf = get_sys_info()
282 289 out = []
283 290 def _add(name, value):
284 291 out.append((name, value))
285 292
286 293 _add('IPython version', inf['ipython_version'])
287 294 _add('IPython commit', "{} ({})".format(inf['commit_hash'], inf['commit_source']))
288 295 _add('IPython package', compress_user(inf['ipython_path']))
289 296 _add('Python version', inf['sys_version'].replace('\n',''))
290 297 _add('sys.executable', compress_user(inf['sys_executable']))
291 298 _add('Platform', inf['platform'])
292 299
293 300 width = max(len(n) for (n,v) in out)
294 301 out = ["{:<{width}}: {}\n".format(n, v, width=width) for (n,v) in out]
295 302
296 303 avail = []
297 304 not_avail = []
298 305
299 306 for k, is_avail in have.items():
300 307 if is_avail:
301 308 avail.append(k)
302 309 else:
303 310 not_avail.append(k)
304 311
305 312 if avail:
306 313 out.append('\nTools and libraries available at test time:\n')
307 314 avail.sort()
308 315 out.append(' ' + ' '.join(avail)+'\n')
309 316
310 317 if not_avail:
311 318 out.append('\nTools and libraries NOT available at test time:\n')
312 319 not_avail.sort()
313 320 out.append(' ' + ' '.join(not_avail)+'\n')
314 321
315 322 return ''.join(out)
316 323
317 324 def run_iptestall(options):
318 325 """Run the entire IPython test suite by calling nose and trial.
319 326
320 327 This function constructs :class:`IPTester` instances for all IPython
321 328 modules and package and then runs each of them. This causes the modules
322 329 and packages of IPython to be tested each in their own subprocess using
323 330 nose.
324 331
325 332 Parameters
326 333 ----------
327 334
328 335 All parameters are passed as attributes of the options object.
329 336
330 337 testgroups : list of str
331 338 Run only these sections of the test suite. If empty, run all the available
332 339 sections.
333 340
334 341 fast : int or None
335 342 Run the test suite in parallel, using n simultaneous processes. If None
336 343 is passed, one process is used per CPU core. Default 1 (i.e. sequential)
337 344
338 345 inc_slow : bool
339 346 Include slow tests, like IPython.parallel. By default, these tests aren't
340 347 run.
341 348
342 349 xunit : bool
343 350 Produce Xunit XML output. This is written to multiple foo.xunit.xml files.
344 351
345 352 coverage : bool or str
346 353 Measure code coverage from tests. True will store the raw coverage data,
347 354 or pass 'html' or 'xml' to get reports.
348 355
349 356 extra_args : list
350 357 Extra arguments to pass to the test subprocesses, e.g. '-v'
351 358 """
352 359 if options.fast != 1:
353 360 # If running in parallel, capture output so it doesn't get interleaved
354 361 TestController.buffer_output = True
355 362
356 363 to_run, not_run = prepare_controllers(options)
357 364
358 365 def justify(ltext, rtext, width=70, fill='-'):
359 366 ltext += ' '
360 367 rtext = (' ' + rtext).rjust(width - len(ltext), fill)
361 368 return ltext + rtext
362 369
363 370 # Run all test runners, tracking execution time
364 371 failed = []
365 372 t_start = time.time()
366 373
367 374 print()
368 375 if options.fast == 1:
369 376 # This actually means sequential, i.e. with 1 job
370 377 for controller in to_run:
371 378 print('IPython test group:', controller.section)
372 379 sys.stdout.flush() # Show in correct order when output is piped
373 380 controller, res = do_run(controller)
374 381 if res:
375 382 failed.append(controller)
376 383 if res == -signal.SIGINT:
377 384 print("Interrupted")
378 385 break
379 386 print()
380 387
381 388 else:
382 389 # Run tests concurrently
383 390 try:
384 391 pool = multiprocessing.pool.ThreadPool(options.fast)
385 392 for (controller, res) in pool.imap_unordered(do_run, to_run):
386 393 res_string = 'OK' if res == 0 else 'FAILED'
387 394 print(justify('IPython test group: ' + controller.section, res_string))
388 395 if res:
389 396 print(bytes_to_str(controller.stdout))
390 397 failed.append(controller)
391 398 if res == -signal.SIGINT:
392 399 print("Interrupted")
393 400 break
394 401 except KeyboardInterrupt:
395 402 return
396 403
397 404 for controller in not_run:
398 405 print(justify('IPython test group: ' + controller.section, 'NOT RUN'))
399 406
400 407 t_end = time.time()
401 408 t_tests = t_end - t_start
402 409 nrunners = len(to_run)
403 410 nfail = len(failed)
404 411 # summarize results
405 412 print('_'*70)
406 413 print('Test suite completed for system with the following information:')
407 414 print(report())
408 415 took = "Took %.3fs." % t_tests
409 416 print('Status: ', end='')
410 417 if not failed:
411 418 print('OK (%d test groups).' % nrunners, took)
412 419 else:
413 420 # If anything went wrong, point out what command to rerun manually to
414 421 # see the actual errors and individual summary
415 422 failed_sections = [c.section for c in failed]
416 423 print('ERROR - {} out of {} test groups failed ({}).'.format(nfail,
417 424 nrunners, ', '.join(failed_sections)), took)
418 425 print()
419 426 print('You may wish to rerun these, with:')
420 427 print(' iptest', *failed_sections)
421 428 print()
422 429
423 430 if options.coverage:
424 431 from coverage import coverage
425 432 cov = coverage(data_file='.coverage')
426 433 cov.combine()
427 434 cov.save()
428 435
429 436 # Coverage HTML report
430 437 if options.coverage == 'html':
431 438 html_dir = 'ipy_htmlcov'
432 439 shutil.rmtree(html_dir, ignore_errors=True)
433 440 print("Writing HTML coverage report to %s/ ... " % html_dir, end="")
434 441 sys.stdout.flush()
435 442
436 443 # Custom HTML reporter to clean up module names.
437 444 from coverage.html import HtmlReporter
438 445 class CustomHtmlReporter(HtmlReporter):
439 446 def find_code_units(self, morfs):
440 447 super(CustomHtmlReporter, self).find_code_units(morfs)
441 448 for cu in self.code_units:
442 449 nameparts = cu.name.split(os.sep)
443 450 if 'IPython' not in nameparts:
444 451 continue
445 452 ix = nameparts.index('IPython')
446 453 cu.name = '.'.join(nameparts[ix:])
447 454
448 455 # Reimplement the html_report method with our custom reporter
449 456 cov._harvest_data()
450 457 cov.config.from_args(omit='*%stests' % os.sep, html_dir=html_dir,
451 458 html_title='IPython test coverage',
452 459 )
453 460 reporter = CustomHtmlReporter(cov, cov.config)
454 461 reporter.report(None)
455 462 print('done.')
456 463
457 464 # Coverage XML report
458 465 elif options.coverage == 'xml':
459 466 cov.xml_report(outfile='ipy_coverage.xml')
460 467
461 468 if failed:
462 469 # Ensure that our exit code indicates failure
463 470 sys.exit(1)
464 471
465 472 argparser = argparse.ArgumentParser(description='Run IPython test suite')
466 473 argparser.add_argument('testgroups', nargs='*',
467 474 help='Run specified groups of tests. If omitted, run '
468 475 'all tests.')
469 476 argparser.add_argument('--all', action='store_true',
470 477 help='Include slow tests not run by default.')
471 478 argparser.add_argument('-j', '--fast', nargs='?', const=None, default=1, type=int,
472 479 help='Run test sections in parallel.')
473 480 argparser.add_argument('--xunit', action='store_true',
474 481 help='Produce Xunit XML results')
475 482 argparser.add_argument('--coverage', nargs='?', const=True, default=False,
476 483 help="Measure test coverage. Specify 'html' or "
477 484 "'xml' to get reports.")
478 485 argparser.add_argument('--subproc-streams', default='capture',
479 486 help="What to do with stdout/stderr from subprocesses. "
480 487 "'capture' (default), 'show' and 'discard' are the options.")
481 488
482 489 def default_options():
483 490 """Get an argparse Namespace object with the default arguments, to pass to
484 491 :func:`run_iptestall`.
485 492 """
486 493 options = argparser.parse_args([])
487 494 options.extra_args = []
488 495 return options
489 496
490 497 def main():
491 498 # Arguments after -- should be passed through to nose. Argparse treats
492 499 # everything after -- as regular positional arguments, so we separate them
493 500 # first.
494 501 try:
495 502 ix = sys.argv.index('--')
496 503 except ValueError:
497 504 to_parse = sys.argv[1:]
498 505 extra_args = []
499 506 else:
500 507 to_parse = sys.argv[1:ix]
501 508 extra_args = sys.argv[ix+1:]
502 509
503 510 options = argparser.parse_args(to_parse)
504 511 options.extra_args = extra_args
505 512
506 513 run_iptestall(options)
507 514
508 515
509 516 if __name__ == '__main__':
510 517 main()
General Comments 0
You need to be logged in to leave comments. Login now