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