##// END OF EJS Templates
distinguish capture_output from buffer_output...
Min RK -
Show More
@@ -1,515 +1,519 b''
1 1 # -*- coding: utf-8 -*-
2 2 """IPython Test Suite Runner.
3 3
4 4 This module provides a main entry point to a user script to test IPython
5 5 itself from the command line. There are two ways of running this script:
6 6
7 7 1. With the syntax `iptest all`. This runs our entire test suite by
8 8 calling this script (with different arguments) recursively. This
9 9 causes modules and package to be tested in different processes, using nose
10 10 or trial where appropriate.
11 11 2. With the regular nose syntax, like `iptest -vvs IPython`. In this form
12 12 the script simply calls nose, but with special command line flags and
13 13 plugins loaded.
14 14
15 15 """
16 16
17 17 # Copyright (c) IPython Development Team.
18 18 # Distributed under the terms of the Modified BSD License.
19 19
20 20 from __future__ import print_function
21 21
22 22 import glob
23 23 from io import BytesIO
24 24 import os
25 25 import os.path as path
26 26 import sys
27 27 from threading import Thread, Lock, Event
28 28 import warnings
29 29
30 30 import nose.plugins.builtin
31 31 from nose.plugins.xunit import Xunit
32 32 from nose import SkipTest
33 33 from nose.core import TestProgram
34 34 from nose.plugins import Plugin
35 35 from nose.util import safe_str
36 36
37 37 from IPython.utils.process import is_cmd_found
38 from IPython.utils.py3compat import bytes_to_str
38 39 from IPython.utils.importstring import import_item
39 40 from IPython.testing.plugin.ipdoctest import IPythonDoctest
40 41 from IPython.external.decorators import KnownFailure, knownfailureif
41 42
42 43 pjoin = path.join
43 44
44 45
45 46 #-----------------------------------------------------------------------------
46 47 # Globals
47 48 #-----------------------------------------------------------------------------
48 49
49 50
50 51 #-----------------------------------------------------------------------------
51 52 # Warnings control
52 53 #-----------------------------------------------------------------------------
53 54
54 55 # Twisted generates annoying warnings with Python 2.6, as will do other code
55 56 # that imports 'sets' as of today
56 57 warnings.filterwarnings('ignore', 'the sets module is deprecated',
57 58 DeprecationWarning )
58 59
59 60 # This one also comes from Twisted
60 61 warnings.filterwarnings('ignore', 'the sha module is deprecated',
61 62 DeprecationWarning)
62 63
63 64 # Wx on Fedora11 spits these out
64 65 warnings.filterwarnings('ignore', 'wxPython/wxWidgets release number mismatch',
65 66 UserWarning)
66 67
67 68 # ------------------------------------------------------------------------------
68 69 # Monkeypatch Xunit to count known failures as skipped.
69 70 # ------------------------------------------------------------------------------
70 71 def monkeypatch_xunit():
71 72 try:
72 73 knownfailureif(True)(lambda: None)()
73 74 except Exception as e:
74 75 KnownFailureTest = type(e)
75 76
76 77 def addError(self, test, err, capt=None):
77 78 if issubclass(err[0], KnownFailureTest):
78 79 err = (SkipTest,) + err[1:]
79 80 return self.orig_addError(test, err, capt)
80 81
81 82 Xunit.orig_addError = Xunit.addError
82 83 Xunit.addError = addError
83 84
84 85 #-----------------------------------------------------------------------------
85 86 # Check which dependencies are installed and greater than minimum version.
86 87 #-----------------------------------------------------------------------------
87 88 def extract_version(mod):
88 89 return mod.__version__
89 90
90 91 def test_for(item, min_version=None, callback=extract_version):
91 92 """Test to see if item is importable, and optionally check against a minimum
92 93 version.
93 94
94 95 If min_version is given, the default behavior is to check against the
95 96 `__version__` attribute of the item, but specifying `callback` allows you to
96 97 extract the value you are interested in. e.g::
97 98
98 99 In [1]: import sys
99 100
100 101 In [2]: from IPython.testing.iptest import test_for
101 102
102 103 In [3]: test_for('sys', (2,6), callback=lambda sys: sys.version_info)
103 104 Out[3]: True
104 105
105 106 """
106 107 try:
107 108 check = import_item(item)
108 109 except (ImportError, RuntimeError):
109 110 # GTK reports Runtime error if it can't be initialized even if it's
110 111 # importable.
111 112 return False
112 113 else:
113 114 if min_version:
114 115 if callback:
115 116 # extra processing step to get version to compare
116 117 check = callback(check)
117 118
118 119 return check >= min_version
119 120 else:
120 121 return True
121 122
122 123 # Global dict where we can store information on what we have and what we don't
123 124 # have available at test run time
124 125 have = {}
125 126
126 127 have['curses'] = test_for('_curses')
127 128 have['matplotlib'] = test_for('matplotlib')
128 129 have['numpy'] = test_for('numpy')
129 130 have['pexpect'] = test_for('IPython.external.pexpect')
130 131 have['pymongo'] = test_for('pymongo')
131 132 have['pygments'] = test_for('pygments')
132 133 have['qt'] = test_for('IPython.external.qt')
133 134 have['sqlite3'] = test_for('sqlite3')
134 135 have['tornado'] = test_for('tornado.version_info', (3,1,0), callback=None)
135 136 have['jinja2'] = test_for('jinja2')
136 137 have['mistune'] = test_for('mistune')
137 138 have['requests'] = test_for('requests')
138 139 have['sphinx'] = test_for('sphinx')
139 140 have['jsonschema'] = test_for('jsonschema')
140 141 have['terminado'] = test_for('terminado')
141 142 have['casperjs'] = is_cmd_found('casperjs')
142 143 have['phantomjs'] = is_cmd_found('phantomjs')
143 144 have['slimerjs'] = is_cmd_found('slimerjs')
144 145
145 146 min_zmq = (2,1,11)
146 147
147 148 have['zmq'] = test_for('zmq.pyzmq_version_info', min_zmq, callback=lambda x: x())
148 149
149 150 #-----------------------------------------------------------------------------
150 151 # Test suite definitions
151 152 #-----------------------------------------------------------------------------
152 153
153 154 test_group_names = ['parallel', 'kernel', 'kernel.inprocess', 'config', 'core',
154 155 'extensions', 'lib', 'terminal', 'testing', 'utils',
155 156 'nbformat', 'qt', 'html', 'nbconvert'
156 157 ]
157 158
158 159 class TestSection(object):
159 160 def __init__(self, name, includes):
160 161 self.name = name
161 162 self.includes = includes
162 163 self.excludes = []
163 164 self.dependencies = []
164 165 self.enabled = True
165 166
166 167 def exclude(self, module):
167 168 if not module.startswith('IPython'):
168 169 module = self.includes[0] + "." + module
169 170 self.excludes.append(module.replace('.', os.sep))
170 171
171 172 def requires(self, *packages):
172 173 self.dependencies.extend(packages)
173 174
174 175 @property
175 176 def will_run(self):
176 177 return self.enabled and all(have[p] for p in self.dependencies)
177 178
178 179 # Name -> (include, exclude, dependencies_met)
179 180 test_sections = {n:TestSection(n, ['IPython.%s' % n]) for n in test_group_names}
180 181
181 182 # Exclusions and dependencies
182 183 # ---------------------------
183 184
184 185 # core:
185 186 sec = test_sections['core']
186 187 if not have['sqlite3']:
187 188 sec.exclude('tests.test_history')
188 189 sec.exclude('history')
189 190 if not have['matplotlib']:
190 191 sec.exclude('pylabtools'),
191 192 sec.exclude('tests.test_pylabtools')
192 193
193 194 # lib:
194 195 sec = test_sections['lib']
195 196 if not have['zmq']:
196 197 sec.exclude('kernel')
197 198 # We do this unconditionally, so that the test suite doesn't import
198 199 # gtk, changing the default encoding and masking some unicode bugs.
199 200 sec.exclude('inputhookgtk')
200 201 # We also do this unconditionally, because wx can interfere with Unix signals.
201 202 # There are currently no tests for it anyway.
202 203 sec.exclude('inputhookwx')
203 204 # Testing inputhook will need a lot of thought, to figure out
204 205 # how to have tests that don't lock up with the gui event
205 206 # loops in the picture
206 207 sec.exclude('inputhook')
207 208
208 209 # testing:
209 210 sec = test_sections['testing']
210 211 # These have to be skipped on win32 because they use echo, rm, cd, etc.
211 212 # See ticket https://github.com/ipython/ipython/issues/87
212 213 if sys.platform == 'win32':
213 214 sec.exclude('plugin.test_exampleip')
214 215 sec.exclude('plugin.dtexample')
215 216
216 217 # terminal:
217 218 if (not have['pexpect']) or (not have['zmq']):
218 219 test_sections['terminal'].exclude('console')
219 220
220 221 # parallel
221 222 sec = test_sections['parallel']
222 223 sec.requires('zmq')
223 224 if not have['pymongo']:
224 225 sec.exclude('controller.mongodb')
225 226 sec.exclude('tests.test_mongodb')
226 227
227 228 # kernel:
228 229 sec = test_sections['kernel']
229 230 sec.requires('zmq')
230 231 # The in-process kernel tests are done in a separate section
231 232 sec.exclude('inprocess')
232 233 # importing gtk sets the default encoding, which we want to avoid
233 234 sec.exclude('zmq.gui.gtkembed')
234 235 sec.exclude('zmq.gui.gtk3embed')
235 236 if not have['matplotlib']:
236 237 sec.exclude('zmq.pylab')
237 238
238 239 # kernel.inprocess:
239 240 test_sections['kernel.inprocess'].requires('zmq')
240 241
241 242 # extensions:
242 243 sec = test_sections['extensions']
243 244 # This is deprecated in favour of rpy2
244 245 sec.exclude('rmagic')
245 246 # autoreload does some strange stuff, so move it to its own test section
246 247 sec.exclude('autoreload')
247 248 sec.exclude('tests.test_autoreload')
248 249 test_sections['autoreload'] = TestSection('autoreload',
249 250 ['IPython.extensions.autoreload', 'IPython.extensions.tests.test_autoreload'])
250 251 test_group_names.append('autoreload')
251 252
252 253 # qt:
253 254 test_sections['qt'].requires('zmq', 'qt', 'pygments')
254 255
255 256 # html:
256 257 sec = test_sections['html']
257 258 sec.requires('zmq', 'tornado', 'requests', 'sqlite3', 'jsonschema')
258 259 # The notebook 'static' directory contains JS, css and other
259 260 # files for web serving. Occasionally projects may put a .py
260 261 # file in there (MathJax ships a conf.py), so we might as
261 262 # well play it safe and skip the whole thing.
262 263 sec.exclude('static')
263 264 sec.exclude('tasks')
264 265 if not have['jinja2']:
265 266 sec.exclude('notebookapp')
266 267 if not have['pygments'] or not have['jinja2']:
267 268 sec.exclude('nbconvert')
268 269 if not have['terminado']:
269 270 sec.exclude('terminal')
270 271
271 272 # config:
272 273 # Config files aren't really importable stand-alone
273 274 test_sections['config'].exclude('profile')
274 275
275 276 # nbconvert:
276 277 sec = test_sections['nbconvert']
277 278 sec.requires('pygments', 'jinja2', 'jsonschema', 'mistune')
278 279 # Exclude nbconvert directories containing config files used to test.
279 280 # Executing the config files with iptest would cause an exception.
280 281 sec.exclude('tests.files')
281 282 sec.exclude('exporters.tests.files')
282 283 if not have['tornado']:
283 284 sec.exclude('nbconvert.post_processors.serve')
284 285 sec.exclude('nbconvert.post_processors.tests.test_serve')
285 286
286 287 # nbformat:
287 288 test_sections['nbformat'].requires('jsonschema')
288 289
289 290 #-----------------------------------------------------------------------------
290 291 # Functions and classes
291 292 #-----------------------------------------------------------------------------
292 293
293 294 def check_exclusions_exist():
294 295 from IPython.utils.path import get_ipython_package_dir
295 296 from IPython.utils.warn import warn
296 297 parent = os.path.dirname(get_ipython_package_dir())
297 298 for sec in test_sections:
298 299 for pattern in sec.exclusions:
299 300 fullpath = pjoin(parent, pattern)
300 301 if not os.path.exists(fullpath) and not glob.glob(fullpath + '.*'):
301 302 warn("Excluding nonexistent file: %r" % pattern)
302 303
303 304
304 305 class ExclusionPlugin(Plugin):
305 306 """A nose plugin to effect our exclusions of files and directories.
306 307 """
307 308 name = 'exclusions'
308 309 score = 3000 # Should come before any other plugins
309 310
310 311 def __init__(self, exclude_patterns=None):
311 312 """
312 313 Parameters
313 314 ----------
314 315
315 316 exclude_patterns : sequence of strings, optional
316 317 Filenames containing these patterns (as raw strings, not as regular
317 318 expressions) are excluded from the tests.
318 319 """
319 320 self.exclude_patterns = exclude_patterns or []
320 321 super(ExclusionPlugin, self).__init__()
321 322
322 323 def options(self, parser, env=os.environ):
323 324 Plugin.options(self, parser, env)
324 325
325 326 def configure(self, options, config):
326 327 Plugin.configure(self, options, config)
327 328 # Override nose trying to disable plugin.
328 329 self.enabled = True
329 330
330 331 def wantFile(self, filename):
331 332 """Return whether the given filename should be scanned for tests.
332 333 """
333 334 if any(pat in filename for pat in self.exclude_patterns):
334 335 return False
335 336 return None
336 337
337 338 def wantDirectory(self, directory):
338 339 """Return whether the given directory should be scanned for tests.
339 340 """
340 341 if any(pat in directory for pat in self.exclude_patterns):
341 342 return False
342 343 return None
343 344
344 345
345 346 class StreamCapturer(Thread):
346 347 daemon = True # Don't hang if main thread crashes
347 348 started = False
348 def __init__(self):
349 def __init__(self, echo=False):
349 350 super(StreamCapturer, self).__init__()
351 self.echo = echo
350 352 self.streams = []
351 353 self.buffer = BytesIO()
352 354 self.readfd, self.writefd = os.pipe()
353 355 self.buffer_lock = Lock()
354 356 self.stop = Event()
355 357
356 358 def run(self):
357 359 self.started = True
358 360
359 361 while not self.stop.is_set():
360 362 chunk = os.read(self.readfd, 1024)
361 363
362 364 with self.buffer_lock:
363 365 self.buffer.write(chunk)
366 if self.echo:
367 sys.stdout.write(bytes_to_str(chunk))
364 368
365 369 os.close(self.readfd)
366 370 os.close(self.writefd)
367 371
368 372 def reset_buffer(self):
369 373 with self.buffer_lock:
370 374 self.buffer.truncate(0)
371 375 self.buffer.seek(0)
372 376
373 377 def get_buffer(self):
374 378 with self.buffer_lock:
375 379 return self.buffer.getvalue()
376 380
377 381 def ensure_started(self):
378 382 if not self.started:
379 383 self.start()
380 384
381 385 def halt(self):
382 386 """Safely stop the thread."""
383 387 if not self.started:
384 388 return
385 389
386 390 self.stop.set()
387 391 os.write(self.writefd, b'wake up') # Ensure we're not locked in a read()
388 392 self.join()
389 393
390 394 class SubprocessStreamCapturePlugin(Plugin):
391 395 name='subprocstreams'
392 396 def __init__(self):
393 397 Plugin.__init__(self)
394 398 self.stream_capturer = StreamCapturer()
395 399 self.destination = os.environ.get('IPTEST_SUBPROC_STREAMS', 'capture')
396 400 # This is ugly, but distant parts of the test machinery need to be able
397 401 # to redirect streams, so we make the object globally accessible.
398 402 nose.iptest_stdstreams_fileno = self.get_write_fileno
399 403
400 404 def get_write_fileno(self):
401 405 if self.destination == 'capture':
402 406 self.stream_capturer.ensure_started()
403 407 return self.stream_capturer.writefd
404 408 elif self.destination == 'discard':
405 409 return os.open(os.devnull, os.O_WRONLY)
406 410 else:
407 411 return sys.__stdout__.fileno()
408 412
409 413 def configure(self, options, config):
410 414 Plugin.configure(self, options, config)
411 415 # Override nose trying to disable plugin.
412 416 if self.destination == 'capture':
413 417 self.enabled = True
414 418
415 419 def startTest(self, test):
416 420 # Reset log capture
417 421 self.stream_capturer.reset_buffer()
418 422
419 423 def formatFailure(self, test, err):
420 424 # Show output
421 425 ec, ev, tb = err
422 426 captured = self.stream_capturer.get_buffer().decode('utf-8', 'replace')
423 427 if captured.strip():
424 428 ev = safe_str(ev)
425 429 out = [ev, '>> begin captured subprocess output <<',
426 430 captured,
427 431 '>> end captured subprocess output <<']
428 432 return ec, '\n'.join(out), tb
429 433
430 434 return err
431 435
432 436 formatError = formatFailure
433 437
434 438 def finalize(self, result):
435 439 self.stream_capturer.halt()
436 440
437 441
438 442 def run_iptest():
439 443 """Run the IPython test suite using nose.
440 444
441 445 This function is called when this script is **not** called with the form
442 446 `iptest all`. It simply calls nose with appropriate command line flags
443 447 and accepts all of the standard nose arguments.
444 448 """
445 449 # Apply our monkeypatch to Xunit
446 450 if '--with-xunit' in sys.argv and not hasattr(Xunit, 'orig_addError'):
447 451 monkeypatch_xunit()
448 452
449 453 warnings.filterwarnings('ignore',
450 454 'This will be removed soon. Use IPython.testing.util instead')
451 455
452 456 arg1 = sys.argv[1]
453 457 if arg1 in test_sections:
454 458 section = test_sections[arg1]
455 459 sys.argv[1:2] = section.includes
456 460 elif arg1.startswith('IPython.') and arg1[8:] in test_sections:
457 461 section = test_sections[arg1[8:]]
458 462 sys.argv[1:2] = section.includes
459 463 else:
460 464 section = TestSection(arg1, includes=[arg1])
461 465
462 466
463 467 argv = sys.argv + [ '--detailed-errors', # extra info in tracebacks
464 468
465 469 '--with-ipdoctest',
466 470 '--ipdoctest-tests','--ipdoctest-extension=txt',
467 471
468 472 # We add --exe because of setuptools' imbecility (it
469 473 # blindly does chmod +x on ALL files). Nose does the
470 474 # right thing and it tries to avoid executables,
471 475 # setuptools unfortunately forces our hand here. This
472 476 # has been discussed on the distutils list and the
473 477 # setuptools devs refuse to fix this problem!
474 478 '--exe',
475 479 ]
476 480 if '-a' not in argv and '-A' not in argv:
477 481 argv = argv + ['-a', '!crash']
478 482
479 483 if nose.__version__ >= '0.11':
480 484 # I don't fully understand why we need this one, but depending on what
481 485 # directory the test suite is run from, if we don't give it, 0 tests
482 486 # get run. Specifically, if the test suite is run from the source dir
483 487 # with an argument (like 'iptest.py IPython.core', 0 tests are run,
484 488 # even if the same call done in this directory works fine). It appears
485 489 # that if the requested package is in the current dir, nose bails early
486 490 # by default. Since it's otherwise harmless, leave it in by default
487 491 # for nose >= 0.11, though unfortunately nose 0.10 doesn't support it.
488 492 argv.append('--traverse-namespace')
489 493
490 494 # use our plugin for doctesting. It will remove the standard doctest plugin
491 495 # if it finds it enabled
492 496 plugins = [ExclusionPlugin(section.excludes), IPythonDoctest(), KnownFailure(),
493 497 SubprocessStreamCapturePlugin() ]
494 498
495 499 # Use working directory set by parent process (see iptestcontroller)
496 500 if 'IPTEST_WORKING_DIR' in os.environ:
497 501 os.chdir(os.environ['IPTEST_WORKING_DIR'])
498 502
499 503 # We need a global ipython running in this process, but the special
500 504 # in-process group spawns its own IPython kernels, so for *that* group we
501 505 # must avoid also opening the global one (otherwise there's a conflict of
502 506 # singletons). Ultimately the solution to this problem is to refactor our
503 507 # assumptions about what needs to be a singleton and what doesn't (app
504 508 # objects should, individual shells shouldn't). But for now, this
505 509 # workaround allows the test suite for the inprocess module to complete.
506 510 if 'kernel.inprocess' not in section.name:
507 511 from IPython.testing import globalipapp
508 512 globalipapp.start_ipython()
509 513
510 514 # Now nose can run
511 515 TestProgram(argv=argv, addplugins=plugins)
512 516
513 517 if __name__ == '__main__':
514 518 run_iptest()
515 519
@@ -1,707 +1,709 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 # 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 re
19 19 import requests
20 20 import shutil
21 21 import signal
22 22 import sys
23 23 import subprocess
24 24 import time
25 25
26 26 from .iptest import (
27 27 have, test_group_names as py_test_group_names, test_sections, StreamCapturer,
28 28 test_for,
29 29 )
30 30 from IPython.utils.path import compress_user
31 31 from IPython.utils.py3compat import bytes_to_str
32 32 from IPython.utils.sysinfo import get_sys_info
33 33 from IPython.utils.tempdir import TemporaryDirectory
34 34 from IPython.utils.text import strip_ansi
35 35
36 36 try:
37 37 # Python >= 3.3
38 38 from subprocess import TimeoutExpired
39 39 def popen_wait(p, timeout):
40 40 return p.wait(timeout)
41 41 except ImportError:
42 42 class TimeoutExpired(Exception):
43 43 pass
44 44 def popen_wait(p, timeout):
45 45 """backport of Popen.wait from Python 3"""
46 46 for i in range(int(10 * timeout)):
47 47 if p.poll() is not None:
48 48 return
49 49 time.sleep(0.1)
50 50 if p.poll() is None:
51 51 raise TimeoutExpired
52 52
53 53 NOTEBOOK_SHUTDOWN_TIMEOUT = 10
54 54
55 55 class TestController(object):
56 56 """Run tests in a subprocess
57 57 """
58 58 #: str, IPython test suite to be executed.
59 59 section = None
60 60 #: list, command line arguments to be executed
61 61 cmd = None
62 62 #: dict, extra environment variables to set for the subprocess
63 63 env = None
64 64 #: list, TemporaryDirectory instances to clear up when the process finishes
65 65 dirs = None
66 66 #: subprocess.Popen instance
67 67 process = None
68 68 #: str, process stdout+stderr
69 69 stdout = None
70 70
71 71 def __init__(self):
72 72 self.cmd = []
73 73 self.env = {}
74 74 self.dirs = []
75 75
76 76 def setup(self):
77 77 """Create temporary directories etc.
78 78
79 79 This is only called when we know the test group will be run. Things
80 80 created here may be cleaned up by self.cleanup().
81 81 """
82 82 pass
83 83
84 def launch(self, buffer_output=False):
84 def launch(self, buffer_output=False, capture_output=False):
85 85 # print('*** ENV:', self.env) # dbg
86 86 # print('*** CMD:', self.cmd) # dbg
87 87 env = os.environ.copy()
88 88 env.update(self.env)
89 output = subprocess.PIPE if buffer_output else None
90 stdout = subprocess.STDOUT if buffer_output else None
91 self.process = subprocess.Popen(self.cmd, stdout=output,
92 stderr=stdout, env=env)
89 if buffer_output:
90 capture_output = True
91 self.stdout_capturer = c = StreamCapturer(echo=not buffer_output)
92 c.start()
93 stdout = c.writefd if capture_output else None
94 stderr = subprocess.STDOUT if capture_output else None
95 self.process = subprocess.Popen(self.cmd, stdout=stdout,
96 stderr=stderr, env=env)
93 97
94 98 def wait(self):
95 self.stdout, _ = self.process.communicate()
99 self.process.wait()
100 self.stdout_capturer.halt()
101 self.stdout = self.stdout_capturer.get_buffer()
96 102 return self.process.returncode
97 103
98 104 def print_extra_info(self):
99 105 """Print extra information about this test run.
100 106
101 107 If we're running in parallel and showing the concise view, this is only
102 108 called if the test group fails. Otherwise, it's called before the test
103 109 group is started.
104 110
105 111 The base implementation does nothing, but it can be overridden by
106 112 subclasses.
107 113 """
108 114 return
109 115
110 116 def cleanup_process(self):
111 117 """Cleanup on exit by killing any leftover processes."""
112 118 subp = self.process
113 119 if subp is None or (subp.poll() is not None):
114 120 return # Process doesn't exist, or is already dead.
115 121
116 122 try:
117 123 print('Cleaning up stale PID: %d' % subp.pid)
118 124 subp.kill()
119 125 except: # (OSError, WindowsError) ?
120 126 # This is just a best effort, if we fail or the process was
121 127 # really gone, ignore it.
122 128 pass
123 129 else:
124 130 for i in range(10):
125 131 if subp.poll() is None:
126 132 time.sleep(0.1)
127 133 else:
128 134 break
129 135
130 136 if subp.poll() is None:
131 137 # The process did not die...
132 138 print('... failed. Manual cleanup may be required.')
133 139
134 140 def cleanup(self):
135 141 "Kill process if it's still alive, and clean up temporary directories"
136 142 self.cleanup_process()
137 143 for td in self.dirs:
138 144 td.cleanup()
139 145
140 146 __del__ = cleanup
141 147
142 148
143 149 class PyTestController(TestController):
144 150 """Run Python tests using IPython.testing.iptest"""
145 151 #: str, Python command to execute in subprocess
146 152 pycmd = None
147 153
148 154 def __init__(self, section, options):
149 155 """Create new test runner."""
150 156 TestController.__init__(self)
151 157 self.section = section
152 158 # pycmd is put into cmd[2] in PyTestController.launch()
153 159 self.cmd = [sys.executable, '-c', None, section]
154 160 self.pycmd = "from IPython.testing.iptest import run_iptest; run_iptest()"
155 161 self.options = options
156 162
157 163 def setup(self):
158 164 ipydir = TemporaryDirectory()
159 165 self.dirs.append(ipydir)
160 166 self.env['IPYTHONDIR'] = ipydir.name
161 167 self.workingdir = workingdir = TemporaryDirectory()
162 168 self.dirs.append(workingdir)
163 169 self.env['IPTEST_WORKING_DIR'] = workingdir.name
164 170 # This means we won't get odd effects from our own matplotlib config
165 171 self.env['MPLCONFIGDIR'] = workingdir.name
166 172
167 173 # From options:
168 174 if self.options.xunit:
169 175 self.add_xunit()
170 176 if self.options.coverage:
171 177 self.add_coverage()
172 178 self.env['IPTEST_SUBPROC_STREAMS'] = self.options.subproc_streams
173 179 self.cmd.extend(self.options.extra_args)
174 180
175 181 @property
176 182 def will_run(self):
177 183 try:
178 184 return test_sections[self.section].will_run
179 185 except KeyError:
180 186 return True
181 187
182 188 def add_xunit(self):
183 189 xunit_file = os.path.abspath(self.section + '.xunit.xml')
184 190 self.cmd.extend(['--with-xunit', '--xunit-file', xunit_file])
185 191
186 192 def add_coverage(self):
187 193 try:
188 194 sources = test_sections[self.section].includes
189 195 except KeyError:
190 196 sources = ['IPython']
191 197
192 198 coverage_rc = ("[run]\n"
193 199 "data_file = {data_file}\n"
194 200 "source =\n"
195 201 " {source}\n"
196 202 ).format(data_file=os.path.abspath('.coverage.'+self.section),
197 203 source="\n ".join(sources))
198 204 config_file = os.path.join(self.workingdir.name, '.coveragerc')
199 205 with open(config_file, 'w') as f:
200 206 f.write(coverage_rc)
201 207
202 208 self.env['COVERAGE_PROCESS_START'] = config_file
203 209 self.pycmd = "import coverage; coverage.process_startup(); " + self.pycmd
204 210
205 211 def launch(self, buffer_output=False):
206 212 self.cmd[2] = self.pycmd
207 213 super(PyTestController, self).launch(buffer_output=buffer_output)
208 214
209 215
210 216 js_prefix = 'js/'
211 217
212 218 def get_js_test_dir():
213 219 import IPython.html.tests as t
214 220 return os.path.join(os.path.dirname(t.__file__), '')
215 221
216 222 def all_js_groups():
217 223 import glob
218 224 test_dir = get_js_test_dir()
219 225 all_subdirs = glob.glob(test_dir + '[!_]*/')
220 226 return [js_prefix+os.path.relpath(x, test_dir) for x in all_subdirs]
221 227
222 228 class JSController(TestController):
223 229 """Run CasperJS tests """
224 230
225 231 requirements = ['zmq', 'tornado', 'jinja2', 'casperjs', 'sqlite3',
226 232 'jsonschema']
227 display_slimer_output = False
228 233
229 234 def __init__(self, section, xunit=True, engine='phantomjs', url=None):
230 235 """Create new test runner."""
231 236 TestController.__init__(self)
232 237 self.engine = engine
233 238 self.section = section
234 239 self.xunit = xunit
235 240 self.url = url
236 241 self.slimer_failure = re.compile('^FAIL.*', flags=re.MULTILINE)
237 242 js_test_dir = get_js_test_dir()
238 243 includes = '--includes=' + os.path.join(js_test_dir,'util.js')
239 244 test_cases = os.path.join(js_test_dir, self.section[len(js_prefix):])
240 245 self.cmd = ['casperjs', 'test', includes, test_cases, '--engine=%s' % self.engine]
241 246
242 247 def setup(self):
243 248 self.ipydir = TemporaryDirectory()
244 249 self.nbdir = TemporaryDirectory()
245 250 self.dirs.append(self.ipydir)
246 251 self.dirs.append(self.nbdir)
247 252 os.makedirs(os.path.join(self.nbdir.name, os.path.join(u'sub βˆ‚ir1', u'sub βˆ‚ir 1a')))
248 253 os.makedirs(os.path.join(self.nbdir.name, os.path.join(u'sub βˆ‚ir2', u'sub βˆ‚ir 1b')))
249 254
250 255 if self.xunit:
251 256 self.add_xunit()
252 257
253 258 # If a url was specified, use that for the testing.
254 259 if self.url:
255 260 try:
256 261 alive = requests.get(self.url).status_code == 200
257 262 except:
258 263 alive = False
259 264
260 265 if alive:
261 266 self.cmd.append("--url=%s" % self.url)
262 267 else:
263 268 raise Exception('Could not reach "%s".' % self.url)
264 269 else:
265 270 # start the ipython notebook, so we get the port number
266 271 self.server_port = 0
267 272 self._init_server()
268 273 if self.server_port:
269 274 self.cmd.append("--port=%i" % self.server_port)
270 275 else:
271 276 # don't launch tests if the server didn't start
272 277 self.cmd = [sys.executable, '-c', 'raise SystemExit(1)']
273 278
274 279 def add_xunit(self):
275 280 xunit_file = os.path.abspath(self.section.replace('/','.') + '.xunit.xml')
276 281 self.cmd.append('--xunit=%s' % xunit_file)
277 282
278 283 def launch(self, buffer_output):
279 284 # If the engine is SlimerJS, we need to buffer the output because
280 285 # SlimerJS does not support exit codes, so CasperJS always returns 0.
281 286 if self.engine == 'slimerjs' and not buffer_output:
282 self.display_slimer_output = True
283 return super(JSController, self).launch(buffer_output=True)
287 return super(JSController, self).launch(capture_output=True)
284 288
285 289 else:
286 290 return super(JSController, self).launch(buffer_output=buffer_output)
287 291
288 292 def wait(self, *pargs, **kwargs):
289 293 """Wait for the JSController to finish"""
290 294 ret = super(JSController, self).wait(*pargs, **kwargs)
291 295 # If this is a SlimerJS controller, check the captured stdout for
292 296 # errors. Otherwise, just return the return code.
293 297 if self.engine == 'slimerjs':
294 298 stdout = bytes_to_str(self.stdout)
295 if self.display_slimer_output:
296 print(stdout)
297 299 if ret != 0:
298 300 # This could still happen e.g. if it's stopped by SIGINT
299 301 return ret
300 302 return bool(self.slimer_failure.search(strip_ansi(stdout)))
301 303 else:
302 304 return ret
303 305
304 306 def print_extra_info(self):
305 307 print("Running tests with notebook directory %r" % self.nbdir.name)
306 308
307 309 @property
308 310 def will_run(self):
309 311 should_run = all(have[a] for a in self.requirements + [self.engine])
310 312 tornado4 = test_for('tornado.version_info', (4,0,0), callback=None)
311 313 if should_run and self.engine == 'phantomjs' and tornado4:
312 314 print("phantomjs cannot connect websockets to tornado 4", file=sys.stderr)
313 315 return False
314 316 return should_run
315 317
316 318 def _init_server(self):
317 319 "Start the notebook server in a separate process"
318 320 self.server_command = command = [sys.executable,
319 321 '-m', 'IPython.html',
320 322 '--no-browser',
321 323 '--ipython-dir', self.ipydir.name,
322 324 '--notebook-dir', self.nbdir.name,
323 325 ]
324 326 # ipc doesn't work on Windows, and darwin has crazy-long temp paths,
325 327 # which run afoul of ipc's maximum path length.
326 328 if sys.platform.startswith('linux'):
327 329 command.append('--KernelManager.transport=ipc')
328 330 self.stream_capturer = c = StreamCapturer()
329 331 c.start()
330 332 self.server = subprocess.Popen(command, stdout=c.writefd, stderr=subprocess.STDOUT, cwd=self.nbdir.name)
331 333 self.server_info_file = os.path.join(self.ipydir.name,
332 334 'profile_default', 'security', 'nbserver-%i.json' % self.server.pid
333 335 )
334 336 self._wait_for_server()
335 337
336 338 def _wait_for_server(self):
337 339 """Wait 30 seconds for the notebook server to start"""
338 340 for i in range(300):
339 341 if self.server.poll() is not None:
340 342 return self._failed_to_start()
341 343 if os.path.exists(self.server_info_file):
342 344 try:
343 345 self._load_server_info()
344 346 except ValueError:
345 347 # If the server is halfway through writing the file, we may
346 348 # get invalid JSON; it should be ready next iteration.
347 349 pass
348 350 else:
349 351 return
350 352 time.sleep(0.1)
351 353 print("Notebook server-info file never arrived: %s" % self.server_info_file,
352 354 file=sys.stderr
353 355 )
354 356
355 357 def _failed_to_start(self):
356 358 """Notebook server exited prematurely"""
357 359 captured = self.stream_capturer.get_buffer().decode('utf-8', 'replace')
358 360 print("Notebook failed to start: ", file=sys.stderr)
359 361 print(self.server_command)
360 362 print(captured, file=sys.stderr)
361 363
362 364 def _load_server_info(self):
363 365 """Notebook server started, load connection info from JSON"""
364 366 with open(self.server_info_file) as f:
365 367 info = json.load(f)
366 368 self.server_port = info['port']
367 369
368 370 def cleanup(self):
369 371 try:
370 372 self.server.terminate()
371 373 except OSError:
372 374 # already dead
373 375 pass
374 376 # wait 10s for the server to shutdown
375 377 try:
376 378 popen_wait(self.server, NOTEBOOK_SHUTDOWN_TIMEOUT)
377 379 except TimeoutExpired:
378 380 # server didn't terminate, kill it
379 381 try:
380 382 print("Failed to terminate notebook server, killing it.",
381 383 file=sys.stderr
382 384 )
383 385 self.server.kill()
384 386 except OSError:
385 387 # already dead
386 388 pass
387 389 # wait another 10s
388 390 try:
389 391 popen_wait(self.server, NOTEBOOK_SHUTDOWN_TIMEOUT)
390 392 except TimeoutExpired:
391 393 print("Notebook server still running (%s)" % self.server_info_file,
392 394 file=sys.stderr
393 395 )
394 396
395 397 self.stream_capturer.halt()
396 398 TestController.cleanup(self)
397 399
398 400
399 401 def prepare_controllers(options):
400 402 """Returns two lists of TestController instances, those to run, and those
401 403 not to run."""
402 404 testgroups = options.testgroups
403 405 if testgroups:
404 406 if 'js' in testgroups:
405 407 js_testgroups = all_js_groups()
406 408 else:
407 409 js_testgroups = [g for g in testgroups if g.startswith(js_prefix)]
408 410
409 411 py_testgroups = [g for g in testgroups if g not in ['js'] + js_testgroups]
410 412 else:
411 413 py_testgroups = py_test_group_names
412 414 if not options.all:
413 415 js_testgroups = []
414 416 test_sections['parallel'].enabled = False
415 417 else:
416 418 js_testgroups = all_js_groups()
417 419
418 420 engine = 'slimerjs' if options.slimerjs else 'phantomjs'
419 421 c_js = [JSController(name, xunit=options.xunit, engine=engine, url=options.url) for name in js_testgroups]
420 422 c_py = [PyTestController(name, options) for name in py_testgroups]
421 423
422 424 controllers = c_py + c_js
423 425 to_run = [c for c in controllers if c.will_run]
424 426 not_run = [c for c in controllers if not c.will_run]
425 427 return to_run, not_run
426 428
427 429 def do_run(controller, buffer_output=True):
428 430 """Setup and run a test controller.
429 431
430 432 If buffer_output is True, no output is displayed, to avoid it appearing
431 433 interleaved. In this case, the caller is responsible for displaying test
432 434 output on failure.
433 435
434 436 Returns
435 437 -------
436 438 controller : TestController
437 439 The same controller as passed in, as a convenience for using map() type
438 440 APIs.
439 441 exitcode : int
440 442 The exit code of the test subprocess. Non-zero indicates failure.
441 443 """
442 444 try:
443 445 try:
444 446 controller.setup()
445 447 if not buffer_output:
446 448 controller.print_extra_info()
447 449 controller.launch(buffer_output=buffer_output)
448 450 except Exception:
449 451 import traceback
450 452 traceback.print_exc()
451 453 return controller, 1 # signal failure
452 454
453 455 exitcode = controller.wait()
454 456 return controller, exitcode
455 457
456 458 except KeyboardInterrupt:
457 459 return controller, -signal.SIGINT
458 460 finally:
459 461 controller.cleanup()
460 462
461 463 def report():
462 464 """Return a string with a summary report of test-related variables."""
463 465 inf = get_sys_info()
464 466 out = []
465 467 def _add(name, value):
466 468 out.append((name, value))
467 469
468 470 _add('IPython version', inf['ipython_version'])
469 471 _add('IPython commit', "{} ({})".format(inf['commit_hash'], inf['commit_source']))
470 472 _add('IPython package', compress_user(inf['ipython_path']))
471 473 _add('Python version', inf['sys_version'].replace('\n',''))
472 474 _add('sys.executable', compress_user(inf['sys_executable']))
473 475 _add('Platform', inf['platform'])
474 476
475 477 width = max(len(n) for (n,v) in out)
476 478 out = ["{:<{width}}: {}\n".format(n, v, width=width) for (n,v) in out]
477 479
478 480 avail = []
479 481 not_avail = []
480 482
481 483 for k, is_avail in have.items():
482 484 if is_avail:
483 485 avail.append(k)
484 486 else:
485 487 not_avail.append(k)
486 488
487 489 if avail:
488 490 out.append('\nTools and libraries available at test time:\n')
489 491 avail.sort()
490 492 out.append(' ' + ' '.join(avail)+'\n')
491 493
492 494 if not_avail:
493 495 out.append('\nTools and libraries NOT available at test time:\n')
494 496 not_avail.sort()
495 497 out.append(' ' + ' '.join(not_avail)+'\n')
496 498
497 499 return ''.join(out)
498 500
499 501 def run_iptestall(options):
500 502 """Run the entire IPython test suite by calling nose and trial.
501 503
502 504 This function constructs :class:`IPTester` instances for all IPython
503 505 modules and package and then runs each of them. This causes the modules
504 506 and packages of IPython to be tested each in their own subprocess using
505 507 nose.
506 508
507 509 Parameters
508 510 ----------
509 511
510 512 All parameters are passed as attributes of the options object.
511 513
512 514 testgroups : list of str
513 515 Run only these sections of the test suite. If empty, run all the available
514 516 sections.
515 517
516 518 fast : int or None
517 519 Run the test suite in parallel, using n simultaneous processes. If None
518 520 is passed, one process is used per CPU core. Default 1 (i.e. sequential)
519 521
520 522 inc_slow : bool
521 523 Include slow tests, like IPython.parallel. By default, these tests aren't
522 524 run.
523 525
524 526 slimerjs : bool
525 527 Use slimerjs if it's installed instead of phantomjs for casperjs tests.
526 528
527 529 url : unicode
528 530 Address:port to use when running the JS tests.
529 531
530 532 xunit : bool
531 533 Produce Xunit XML output. This is written to multiple foo.xunit.xml files.
532 534
533 535 coverage : bool or str
534 536 Measure code coverage from tests. True will store the raw coverage data,
535 537 or pass 'html' or 'xml' to get reports.
536 538
537 539 extra_args : list
538 540 Extra arguments to pass to the test subprocesses, e.g. '-v'
539 541 """
540 542 to_run, not_run = prepare_controllers(options)
541 543
542 544 def justify(ltext, rtext, width=70, fill='-'):
543 545 ltext += ' '
544 546 rtext = (' ' + rtext).rjust(width - len(ltext), fill)
545 547 return ltext + rtext
546 548
547 549 # Run all test runners, tracking execution time
548 550 failed = []
549 551 t_start = time.time()
550 552
551 553 print()
552 554 if options.fast == 1:
553 555 # This actually means sequential, i.e. with 1 job
554 556 for controller in to_run:
555 557 print('Test group:', controller.section)
556 558 sys.stdout.flush() # Show in correct order when output is piped
557 559 controller, res = do_run(controller, buffer_output=False)
558 560 if res:
559 561 failed.append(controller)
560 562 if res == -signal.SIGINT:
561 563 print("Interrupted")
562 564 break
563 565 print()
564 566
565 567 else:
566 568 # Run tests concurrently
567 569 try:
568 570 pool = multiprocessing.pool.ThreadPool(options.fast)
569 571 for (controller, res) in pool.imap_unordered(do_run, to_run):
570 572 res_string = 'OK' if res == 0 else 'FAILED'
571 573 print(justify('Test group: ' + controller.section, res_string))
572 574 if res:
573 575 controller.print_extra_info()
574 576 print(bytes_to_str(controller.stdout))
575 577 failed.append(controller)
576 578 if res == -signal.SIGINT:
577 579 print("Interrupted")
578 580 break
579 581 except KeyboardInterrupt:
580 582 return
581 583
582 584 for controller in not_run:
583 585 print(justify('Test group: ' + controller.section, 'NOT RUN'))
584 586
585 587 t_end = time.time()
586 588 t_tests = t_end - t_start
587 589 nrunners = len(to_run)
588 590 nfail = len(failed)
589 591 # summarize results
590 592 print('_'*70)
591 593 print('Test suite completed for system with the following information:')
592 594 print(report())
593 595 took = "Took %.3fs." % t_tests
594 596 print('Status: ', end='')
595 597 if not failed:
596 598 print('OK (%d test groups).' % nrunners, took)
597 599 else:
598 600 # If anything went wrong, point out what command to rerun manually to
599 601 # see the actual errors and individual summary
600 602 failed_sections = [c.section for c in failed]
601 603 print('ERROR - {} out of {} test groups failed ({}).'.format(nfail,
602 604 nrunners, ', '.join(failed_sections)), took)
603 605 print()
604 606 print('You may wish to rerun these, with:')
605 607 print(' iptest', *failed_sections)
606 608 print()
607 609
608 610 if options.coverage:
609 611 from coverage import coverage
610 612 cov = coverage(data_file='.coverage')
611 613 cov.combine()
612 614 cov.save()
613 615
614 616 # Coverage HTML report
615 617 if options.coverage == 'html':
616 618 html_dir = 'ipy_htmlcov'
617 619 shutil.rmtree(html_dir, ignore_errors=True)
618 620 print("Writing HTML coverage report to %s/ ... " % html_dir, end="")
619 621 sys.stdout.flush()
620 622
621 623 # Custom HTML reporter to clean up module names.
622 624 from coverage.html import HtmlReporter
623 625 class CustomHtmlReporter(HtmlReporter):
624 626 def find_code_units(self, morfs):
625 627 super(CustomHtmlReporter, self).find_code_units(morfs)
626 628 for cu in self.code_units:
627 629 nameparts = cu.name.split(os.sep)
628 630 if 'IPython' not in nameparts:
629 631 continue
630 632 ix = nameparts.index('IPython')
631 633 cu.name = '.'.join(nameparts[ix:])
632 634
633 635 # Reimplement the html_report method with our custom reporter
634 636 cov._harvest_data()
635 637 cov.config.from_args(omit='*{0}tests{0}*'.format(os.sep), html_dir=html_dir,
636 638 html_title='IPython test coverage',
637 639 )
638 640 reporter = CustomHtmlReporter(cov, cov.config)
639 641 reporter.report(None)
640 642 print('done.')
641 643
642 644 # Coverage XML report
643 645 elif options.coverage == 'xml':
644 646 cov.xml_report(outfile='ipy_coverage.xml')
645 647
646 648 if failed:
647 649 # Ensure that our exit code indicates failure
648 650 sys.exit(1)
649 651
650 652 argparser = argparse.ArgumentParser(description='Run IPython test suite')
651 653 argparser.add_argument('testgroups', nargs='*',
652 654 help='Run specified groups of tests. If omitted, run '
653 655 'all tests.')
654 656 argparser.add_argument('--all', action='store_true',
655 657 help='Include slow tests not run by default.')
656 658 argparser.add_argument('--slimerjs', action='store_true',
657 659 help="Use slimerjs if it's installed instead of phantomjs for casperjs tests.")
658 660 argparser.add_argument('--url', help="URL to use for the JS tests.")
659 661 argparser.add_argument('-j', '--fast', nargs='?', const=None, default=1, type=int,
660 662 help='Run test sections in parallel. This starts as many '
661 663 'processes as you have cores, or you can specify a number.')
662 664 argparser.add_argument('--xunit', action='store_true',
663 665 help='Produce Xunit XML results')
664 666 argparser.add_argument('--coverage', nargs='?', const=True, default=False,
665 667 help="Measure test coverage. Specify 'html' or "
666 668 "'xml' to get reports.")
667 669 argparser.add_argument('--subproc-streams', default='capture',
668 670 help="What to do with stdout/stderr from subprocesses. "
669 671 "'capture' (default), 'show' and 'discard' are the options.")
670 672
671 673 def default_options():
672 674 """Get an argparse Namespace object with the default arguments, to pass to
673 675 :func:`run_iptestall`.
674 676 """
675 677 options = argparser.parse_args([])
676 678 options.extra_args = []
677 679 return options
678 680
679 681 def main():
680 682 # iptest doesn't work correctly if the working directory is the
681 683 # root of the IPython source tree. Tell the user to avoid
682 684 # frustration.
683 685 if os.path.exists(os.path.join(os.getcwd(),
684 686 'IPython', 'testing', '__main__.py')):
685 687 print("Don't run iptest from the IPython source directory",
686 688 file=sys.stderr)
687 689 sys.exit(1)
688 690 # Arguments after -- should be passed through to nose. Argparse treats
689 691 # everything after -- as regular positional arguments, so we separate them
690 692 # first.
691 693 try:
692 694 ix = sys.argv.index('--')
693 695 except ValueError:
694 696 to_parse = sys.argv[1:]
695 697 extra_args = []
696 698 else:
697 699 to_parse = sys.argv[1:ix]
698 700 extra_args = sys.argv[ix+1:]
699 701
700 702 options = argparser.parse_args(to_parse)
701 703 options.extra_args = extra_args
702 704
703 705 run_iptestall(options)
704 706
705 707
706 708 if __name__ == '__main__':
707 709 main()
General Comments 0
You need to be logged in to leave comments. Login now