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