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