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