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