##// END OF EJS Templates
Merge pull request #2238 from takluyver/fasttest...
Bussonnier Matthias -
r8131:ecae5d1b merge
parent child Browse files
Show More
@@ -1,29 +1,29 b''
1 1 """Testing support (tools to test IPython itself).
2 2 """
3 3
4 4 #-----------------------------------------------------------------------------
5 5 # Copyright (C) 2009-2011 The IPython Development Team
6 6 #
7 7 # Distributed under the terms of the BSD License. The full license is in
8 8 # the file COPYING, distributed as part of this software.
9 9 #-----------------------------------------------------------------------------
10 10
11 11 #-----------------------------------------------------------------------------
12 12 # Functions
13 13 #-----------------------------------------------------------------------------
14 14
15 15 # User-level entry point for testing
16 def test():
16 def test(all=False):
17 17 """Run the entire IPython test suite.
18 18
19 19 For fine-grained control, you should use the :file:`iptest` script supplied
20 20 with the IPython installation."""
21 21
22 22 # Do the import internally, so that this function doesn't increase total
23 23 # import time
24 24 from iptest import run_iptestall
25 run_iptestall()
25 run_iptestall(inc_slow=all)
26 26
27 27 # So nose doesn't try to run this as a test itself and we end up with an
28 28 # infinite test loop
29 29 test.__test__ = False
@@ -1,566 +1,579 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 #-----------------------------------------------------------------------------
18 18 # Copyright (C) 2009-2011 The IPython Development Team
19 19 #
20 20 # Distributed under the terms of the BSD License. The full license is in
21 21 # the file COPYING, distributed as part of this software.
22 22 #-----------------------------------------------------------------------------
23 23
24 24 #-----------------------------------------------------------------------------
25 25 # Imports
26 26 #-----------------------------------------------------------------------------
27 27 from __future__ import print_function
28 28
29 29 # Stdlib
30 30 import glob
31 31 import os
32 32 import os.path as path
33 33 import signal
34 34 import sys
35 35 import subprocess
36 36 import tempfile
37 37 import time
38 38 import warnings
39 39
40 40 # Note: monkeypatch!
41 41 # We need to monkeypatch a small problem in nose itself first, before importing
42 42 # it for actual use. This should get into nose upstream, but its release cycle
43 43 # is slow and we need it for our parametric tests to work correctly.
44 44 from IPython.testing import nosepatch
45 45
46 46 # Monkeypatch extra assert methods into nose.tools if they're not already there.
47 47 # This can be dropped once we no longer test on Python 2.6
48 48 from IPython.testing import nose_assert_methods
49 49
50 50 # Now, proceed to import nose itself
51 51 import nose.plugins.builtin
52 52 from nose.plugins.xunit import Xunit
53 53 from nose import SkipTest
54 54 from nose.core import TestProgram
55 55
56 56 # Our own imports
57 57 from IPython.utils import py3compat
58 58 from IPython.utils.importstring import import_item
59 59 from IPython.utils.path import get_ipython_module_path, get_ipython_package_dir
60 60 from IPython.utils.process import find_cmd, pycmd2argv
61 61 from IPython.utils.sysinfo import sys_info
62 62 from IPython.utils.tempdir import TemporaryDirectory
63 63 from IPython.utils.warn import warn
64 64
65 65 from IPython.testing import globalipapp
66 66 from IPython.testing.plugin.ipdoctest import IPythonDoctest
67 67 from IPython.external.decorators import KnownFailure, knownfailureif
68 68
69 69 pjoin = path.join
70 70
71 71
72 72 #-----------------------------------------------------------------------------
73 73 # Globals
74 74 #-----------------------------------------------------------------------------
75 75
76 76
77 77 #-----------------------------------------------------------------------------
78 78 # Warnings control
79 79 #-----------------------------------------------------------------------------
80 80
81 81 # Twisted generates annoying warnings with Python 2.6, as will do other code
82 82 # that imports 'sets' as of today
83 83 warnings.filterwarnings('ignore', 'the sets module is deprecated',
84 84 DeprecationWarning )
85 85
86 86 # This one also comes from Twisted
87 87 warnings.filterwarnings('ignore', 'the sha module is deprecated',
88 88 DeprecationWarning)
89 89
90 90 # Wx on Fedora11 spits these out
91 91 warnings.filterwarnings('ignore', 'wxPython/wxWidgets release number mismatch',
92 92 UserWarning)
93 93
94 94 # ------------------------------------------------------------------------------
95 95 # Monkeypatch Xunit to count known failures as skipped.
96 96 # ------------------------------------------------------------------------------
97 97 def monkeypatch_xunit():
98 98 try:
99 99 knownfailureif(True)(lambda: None)()
100 100 except Exception as e:
101 101 KnownFailureTest = type(e)
102 102
103 103 def addError(self, test, err, capt=None):
104 104 if issubclass(err[0], KnownFailureTest):
105 105 err = (SkipTest,) + err[1:]
106 106 return self.orig_addError(test, err, capt)
107 107
108 108 Xunit.orig_addError = Xunit.addError
109 109 Xunit.addError = addError
110 110
111 111 #-----------------------------------------------------------------------------
112 112 # Logic for skipping doctests
113 113 #-----------------------------------------------------------------------------
114 114 def extract_version(mod):
115 115 return mod.__version__
116 116
117 117 def test_for(item, min_version=None, callback=extract_version):
118 118 """Test to see if item is importable, and optionally check against a minimum
119 119 version.
120 120
121 121 If min_version is given, the default behavior is to check against the
122 122 `__version__` attribute of the item, but specifying `callback` allows you to
123 123 extract the value you are interested in. e.g::
124 124
125 125 In [1]: import sys
126 126
127 127 In [2]: from IPython.testing.iptest import test_for
128 128
129 129 In [3]: test_for('sys', (2,6), callback=lambda sys: sys.version_info)
130 130 Out[3]: True
131 131
132 132 """
133 133 try:
134 134 check = import_item(item)
135 135 except (ImportError, RuntimeError):
136 136 # GTK reports Runtime error if it can't be initialized even if it's
137 137 # importable.
138 138 return False
139 139 else:
140 140 if min_version:
141 141 if callback:
142 142 # extra processing step to get version to compare
143 143 check = callback(check)
144 144
145 145 return check >= min_version
146 146 else:
147 147 return True
148 148
149 149 # Global dict where we can store information on what we have and what we don't
150 150 # have available at test run time
151 151 have = {}
152 152
153 153 have['curses'] = test_for('_curses')
154 154 have['matplotlib'] = test_for('matplotlib')
155 155 have['numpy'] = test_for('numpy')
156 156 have['pexpect'] = test_for('IPython.external.pexpect')
157 157 have['pymongo'] = test_for('pymongo')
158 158 have['pygments'] = test_for('pygments')
159 159 have['qt'] = test_for('IPython.external.qt')
160 160 have['rpy2'] = test_for('rpy2')
161 161 have['sqlite3'] = test_for('sqlite3')
162 162 have['cython'] = test_for('Cython')
163 163 have['oct2py'] = test_for('oct2py')
164 164 have['tornado'] = test_for('tornado.version_info', (2,1,0), callback=None)
165 165 have['wx'] = test_for('wx')
166 166 have['wx.aui'] = test_for('wx.aui')
167 167
168 168 if os.name == 'nt':
169 169 min_zmq = (2,1,7)
170 170 else:
171 171 min_zmq = (2,1,4)
172 172
173 173 def version_tuple(mod):
174 174 "turn '2.1.9' into (2,1,9), and '2.1dev' into (2,1,999)"
175 175 # turn 'dev' into 999, because Python3 rejects str-int comparisons
176 176 vs = mod.__version__.replace('dev', '.999')
177 177 tup = tuple([int(v) for v in vs.split('.') ])
178 178 return tup
179 179
180 180 have['zmq'] = test_for('zmq', min_zmq, version_tuple)
181 181
182 182 #-----------------------------------------------------------------------------
183 183 # Functions and classes
184 184 #-----------------------------------------------------------------------------
185 185
186 186 def report():
187 187 """Return a string with a summary report of test-related variables."""
188 188
189 189 out = [ sys_info(), '\n']
190 190
191 191 avail = []
192 192 not_avail = []
193 193
194 194 for k, is_avail in have.items():
195 195 if is_avail:
196 196 avail.append(k)
197 197 else:
198 198 not_avail.append(k)
199 199
200 200 if avail:
201 201 out.append('\nTools and libraries available at test time:\n')
202 202 avail.sort()
203 203 out.append(' ' + ' '.join(avail)+'\n')
204 204
205 205 if not_avail:
206 206 out.append('\nTools and libraries NOT available at test time:\n')
207 207 not_avail.sort()
208 208 out.append(' ' + ' '.join(not_avail)+'\n')
209 209
210 210 return ''.join(out)
211 211
212 212
213 213 def make_exclude():
214 214 """Make patterns of modules and packages to exclude from testing.
215 215
216 216 For the IPythonDoctest plugin, we need to exclude certain patterns that
217 217 cause testing problems. We should strive to minimize the number of
218 218 skipped modules, since this means untested code.
219 219
220 220 These modules and packages will NOT get scanned by nose at all for tests.
221 221 """
222 222 # Simple utility to make IPython paths more readably, we need a lot of
223 223 # these below
224 224 ipjoin = lambda *paths: pjoin('IPython', *paths)
225 225
226 226 exclusions = [ipjoin('external'),
227 227 ipjoin('quarantine'),
228 228 ipjoin('deathrow'),
229 229 # This guy is probably attic material
230 230 ipjoin('testing', 'mkdoctests'),
231 231 # Testing inputhook will need a lot of thought, to figure out
232 232 # how to have tests that don't lock up with the gui event
233 233 # loops in the picture
234 234 ipjoin('lib', 'inputhook'),
235 235 # Config files aren't really importable stand-alone
236 236 ipjoin('config', 'profile'),
237 237 # The notebook 'static' directory contains JS, css and other
238 238 # files for web serving. Occasionally projects may put a .py
239 239 # file in there (MathJax ships a conf.py), so we might as
240 240 # well play it safe and skip the whole thing.
241 241 ipjoin('frontend', 'html', 'notebook', 'static')
242 242 ]
243 243 if not have['sqlite3']:
244 244 exclusions.append(ipjoin('core', 'tests', 'test_history'))
245 245 exclusions.append(ipjoin('core', 'history'))
246 246 if not have['wx']:
247 247 exclusions.append(ipjoin('lib', 'inputhookwx'))
248 248
249 249 # FIXME: temporarily disable autoreload tests, as they can produce
250 250 # spurious failures in subsequent tests (cythonmagic).
251 251 exclusions.append(ipjoin('extensions', 'autoreload'))
252 252 exclusions.append(ipjoin('extensions', 'tests', 'test_autoreload'))
253 253
254 254 # We do this unconditionally, so that the test suite doesn't import
255 255 # gtk, changing the default encoding and masking some unicode bugs.
256 256 exclusions.append(ipjoin('lib', 'inputhookgtk'))
257 257 exclusions.append(ipjoin('zmq', 'gui', 'gtkembed'))
258 258
259 259 # These have to be skipped on win32 because the use echo, rm, cd, etc.
260 260 # See ticket https://github.com/ipython/ipython/issues/87
261 261 if sys.platform == 'win32':
262 262 exclusions.append(ipjoin('testing', 'plugin', 'test_exampleip'))
263 263 exclusions.append(ipjoin('testing', 'plugin', 'dtexample'))
264 264
265 265 if not have['pexpect']:
266 266 exclusions.extend([ipjoin('lib', 'irunner'),
267 267 ipjoin('lib', 'tests', 'test_irunner'),
268 268 ipjoin('frontend', 'terminal', 'console'),
269 269 ])
270 270
271 271 if not have['zmq']:
272 272 exclusions.append(ipjoin('zmq'))
273 273 exclusions.append(ipjoin('frontend', 'qt'))
274 274 exclusions.append(ipjoin('frontend', 'html'))
275 275 exclusions.append(ipjoin('frontend', 'consoleapp.py'))
276 276 exclusions.append(ipjoin('frontend', 'terminal', 'console'))
277 277 exclusions.append(ipjoin('parallel'))
278 278 elif not have['qt'] or not have['pygments']:
279 279 exclusions.append(ipjoin('frontend', 'qt'))
280 280
281 281 if not have['pymongo']:
282 282 exclusions.append(ipjoin('parallel', 'controller', 'mongodb'))
283 283 exclusions.append(ipjoin('parallel', 'tests', 'test_mongodb'))
284 284
285 285 if not have['matplotlib']:
286 286 exclusions.extend([ipjoin('core', 'pylabtools'),
287 287 ipjoin('core', 'tests', 'test_pylabtools'),
288 288 ipjoin('zmq', 'pylab'),
289 289 ])
290 290
291 291 if not have['cython']:
292 292 exclusions.extend([ipjoin('extensions', 'cythonmagic')])
293 293 exclusions.extend([ipjoin('extensions', 'tests', 'test_cythonmagic')])
294 294
295 295 if not have['oct2py']:
296 296 exclusions.extend([ipjoin('extensions', 'octavemagic')])
297 297 exclusions.extend([ipjoin('extensions', 'tests', 'test_octavemagic')])
298 298
299 299 if not have['tornado']:
300 300 exclusions.append(ipjoin('frontend', 'html'))
301 301
302 302 if not have['rpy2'] or not have['numpy']:
303 303 exclusions.append(ipjoin('extensions', 'rmagic'))
304 304 exclusions.append(ipjoin('extensions', 'tests', 'test_rmagic'))
305 305
306 306 # This is needed for the reg-exp to match on win32 in the ipdoctest plugin.
307 307 if sys.platform == 'win32':
308 308 exclusions = [s.replace('\\','\\\\') for s in exclusions]
309 309
310 310 # check for any exclusions that don't seem to exist:
311 311 parent, _ = os.path.split(get_ipython_package_dir())
312 312 for exclusion in exclusions:
313 313 if exclusion.endswith(('deathrow', 'quarantine')):
314 314 # ignore deathrow/quarantine, which exist in dev, but not install
315 315 continue
316 316 fullpath = pjoin(parent, exclusion)
317 317 if not os.path.exists(fullpath) and not glob.glob(fullpath + '.*'):
318 318 warn("Excluding nonexistent file: %r\n" % exclusion)
319 319
320 320 return exclusions
321 321
322 322
323 323 class IPTester(object):
324 324 """Call that calls iptest or trial in a subprocess.
325 325 """
326 326 #: string, name of test runner that will be called
327 327 runner = None
328 328 #: list, parameters for test runner
329 329 params = None
330 330 #: list, arguments of system call to be made to call test runner
331 331 call_args = None
332 332 #: list, subprocesses we start (for cleanup)
333 333 processes = None
334 334 #: str, coverage xml output file
335 335 coverage_xml = None
336 336
337 337 def __init__(self, runner='iptest', params=None):
338 338 """Create new test runner."""
339 339 p = os.path
340 340 if runner == 'iptest':
341 341 iptest_app = get_ipython_module_path('IPython.testing.iptest')
342 342 self.runner = pycmd2argv(iptest_app) + sys.argv[1:]
343 343 else:
344 344 raise Exception('Not a valid test runner: %s' % repr(runner))
345 345 if params is None:
346 346 params = []
347 347 if isinstance(params, str):
348 348 params = [params]
349 349 self.params = params
350 350
351 351 # Assemble call
352 352 self.call_args = self.runner+self.params
353 353
354 354 # Find the section we're testing (IPython.foo)
355 355 for sect in self.params:
356 356 if sect.startswith('IPython'): break
357 357 else:
358 358 raise ValueError("Section not found", self.params)
359 359
360 360 if '--with-xunit' in self.call_args:
361 361
362 362 self.call_args.append('--xunit-file')
363 363 # FIXME: when Windows uses subprocess.call, these extra quotes are unnecessary:
364 364 xunit_file = path.abspath(sect+'.xunit.xml')
365 365 if sys.platform == 'win32':
366 366 xunit_file = '"%s"' % xunit_file
367 367 self.call_args.append(xunit_file)
368 368
369 369 if '--with-xml-coverage' in self.call_args:
370 370 self.coverage_xml = path.abspath(sect+".coverage.xml")
371 371 self.call_args.remove('--with-xml-coverage')
372 372 self.call_args = ["coverage", "run", "--source="+sect] + self.call_args[1:]
373 373
374 374 # Store anything we start to clean up on deletion
375 375 self.processes = []
376 376
377 377 def _run_cmd(self):
378 378 with TemporaryDirectory() as IPYTHONDIR:
379 379 env = os.environ.copy()
380 380 env['IPYTHONDIR'] = IPYTHONDIR
381 381 # print >> sys.stderr, '*** CMD:', ' '.join(self.call_args) # dbg
382 382 subp = subprocess.Popen(self.call_args, env=env)
383 383 self.processes.append(subp)
384 384 # If this fails, the process will be left in self.processes and
385 385 # cleaned up later, but if the wait call succeeds, then we can
386 386 # clear the stored process.
387 387 retcode = subp.wait()
388 388 self.processes.pop()
389 389 return retcode
390 390
391 391 def run(self):
392 392 """Run the stored commands"""
393 393 try:
394 394 retcode = self._run_cmd()
395 395 except:
396 396 import traceback
397 397 traceback.print_exc()
398 398 return 1 # signal failure
399 399
400 400 if self.coverage_xml:
401 401 subprocess.call(["coverage", "xml", "-o", self.coverage_xml])
402 402 return retcode
403 403
404 404 def __del__(self):
405 405 """Cleanup on exit by killing any leftover processes."""
406 406 for subp in self.processes:
407 407 if subp.poll() is not None:
408 408 continue # process is already dead
409 409
410 410 try:
411 411 print('Cleaning stale PID: %d' % subp.pid)
412 412 subp.kill()
413 413 except: # (OSError, WindowsError) ?
414 414 # This is just a best effort, if we fail or the process was
415 415 # really gone, ignore it.
416 416 pass
417 417
418 418 if subp.poll() is None:
419 419 # The process did not die...
420 420 print('... failed. Manual cleanup may be required.'
421 421 % subp.pid)
422 422
423 def make_runners():
423 def make_runners(inc_slow=False):
424 424 """Define the top-level packages that need to be tested.
425 425 """
426 426
427 427 # Packages to be tested via nose, that only depend on the stdlib
428 428 nose_pkg_names = ['config', 'core', 'extensions', 'frontend', 'lib',
429 429 'testing', 'utils', 'nbformat' ]
430 430
431 431 if have['zmq']:
432 432 nose_pkg_names.append('zmq')
433 nose_pkg_names.append('parallel')
433 if inc_slow:
434 nose_pkg_names.append('parallel')
434 435
435 436 # For debugging this code, only load quick stuff
436 437 #nose_pkg_names = ['core', 'extensions'] # dbg
437 438
438 439 # Make fully qualified package names prepending 'IPython.' to our name lists
439 440 nose_packages = ['IPython.%s' % m for m in nose_pkg_names ]
440 441
441 442 # Make runners
442 443 runners = [ (v, IPTester('iptest', params=v)) for v in nose_packages ]
443 444
444 445 return runners
445 446
446 447
447 448 def run_iptest():
448 449 """Run the IPython test suite using nose.
449 450
450 451 This function is called when this script is **not** called with the form
451 452 `iptest all`. It simply calls nose with appropriate command line flags
452 453 and accepts all of the standard nose arguments.
453 454 """
454 455 # Apply our monkeypatch to Xunit
455 456 if '--with-xunit' in sys.argv and not hasattr(Xunit, 'orig_addError'):
456 457 monkeypatch_xunit()
457 458
458 459 warnings.filterwarnings('ignore',
459 460 'This will be removed soon. Use IPython.testing.util instead')
460 461
461 462 argv = sys.argv + [ '--detailed-errors', # extra info in tracebacks
462 463
463 464 '--with-ipdoctest',
464 465 '--ipdoctest-tests','--ipdoctest-extension=txt',
465 466
466 467 # We add --exe because of setuptools' imbecility (it
467 468 # blindly does chmod +x on ALL files). Nose does the
468 469 # right thing and it tries to avoid executables,
469 470 # setuptools unfortunately forces our hand here. This
470 471 # has been discussed on the distutils list and the
471 472 # setuptools devs refuse to fix this problem!
472 473 '--exe',
473 474 ]
474 475
475 476 if nose.__version__ >= '0.11':
476 477 # I don't fully understand why we need this one, but depending on what
477 478 # directory the test suite is run from, if we don't give it, 0 tests
478 479 # get run. Specifically, if the test suite is run from the source dir
479 480 # with an argument (like 'iptest.py IPython.core', 0 tests are run,
480 481 # even if the same call done in this directory works fine). It appears
481 482 # that if the requested package is in the current dir, nose bails early
482 483 # by default. Since it's otherwise harmless, leave it in by default
483 484 # for nose >= 0.11, though unfortunately nose 0.10 doesn't support it.
484 485 argv.append('--traverse-namespace')
485 486
486 487 # use our plugin for doctesting. It will remove the standard doctest plugin
487 488 # if it finds it enabled
488 489 plugins = [IPythonDoctest(make_exclude()), KnownFailure()]
489 490 # We need a global ipython running in this process
490 491 globalipapp.start_ipython()
491 492 # Now nose can run
492 493 TestProgram(argv=argv, addplugins=plugins)
493 494
494 495
495 def run_iptestall():
496 def run_iptestall(inc_slow=False):
496 497 """Run the entire IPython test suite by calling nose and trial.
497 498
498 499 This function constructs :class:`IPTester` instances for all IPython
499 500 modules and package and then runs each of them. This causes the modules
500 501 and packages of IPython to be tested each in their own subprocess using
501 502 nose.
503
504 Parameters
505 ----------
506
507 inc_slow : bool, optional
508 Include slow tests, like IPython.parallel. By default, these tests aren't
509 run.
502 510 """
503 511
504 runners = make_runners()
512 runners = make_runners(inc_slow=inc_slow)
505 513
506 514 # Run the test runners in a temporary dir so we can nuke it when finished
507 515 # to clean up any junk files left over by accident. This also makes it
508 516 # robust against being run in non-writeable directories by mistake, as the
509 517 # temp dir will always be user-writeable.
510 518 curdir = os.getcwdu()
511 519 testdir = tempfile.gettempdir()
512 520 os.chdir(testdir)
513 521
514 522 # Run all test runners, tracking execution time
515 523 failed = []
516 524 t_start = time.time()
517 525 try:
518 526 for (name, runner) in runners:
519 527 print('*'*70)
520 528 print('IPython test group:',name)
521 529 res = runner.run()
522 530 if res:
523 531 failed.append( (name, runner) )
524 532 finally:
525 533 os.chdir(curdir)
526 534 t_end = time.time()
527 535 t_tests = t_end - t_start
528 536 nrunners = len(runners)
529 537 nfail = len(failed)
530 538 # summarize results
531 539 print()
532 540 print('*'*70)
533 541 print('Test suite completed for system with the following information:')
534 542 print(report())
535 543 print('Ran %s test groups in %.3fs' % (nrunners, t_tests))
536 544 print()
537 545 print('Status:')
538 546 if not failed:
539 547 print('OK')
540 548 else:
541 549 # If anything went wrong, point out what command to rerun manually to
542 550 # see the actual errors and individual summary
543 551 print('ERROR - %s out of %s test groups failed.' % (nfail, nrunners))
544 552 for name, failed_runner in failed:
545 553 print('-'*40)
546 554 print('Runner failed:',name)
547 555 print('You may wish to rerun this one individually, with:')
548 556 failed_call_args = [py3compat.cast_unicode(x) for x in failed_runner.call_args]
549 557 print(u' '.join(failed_call_args))
550 558 print()
551 559 # Ensure that our exit code indicates failure
552 560 sys.exit(1)
553 561
554 562
555 563 def main():
556 564 for arg in sys.argv[1:]:
557 565 if arg.startswith('IPython'):
558 566 # This is in-process
559 567 run_iptest()
560 568 else:
569 if "--all" in sys.argv:
570 sys.argv.remove("--all")
571 inc_slow = True
572 else:
573 inc_slow = False
561 574 # This starts subprocesses
562 run_iptestall()
575 run_iptestall(inc_slow=inc_slow)
563 576
564 577
565 578 if __name__ == '__main__':
566 579 main()
@@ -1,282 +1,288 b''
1 1 #!/usr/bin/env python
2 2 """
3 3 This is a script for testing pull requests for IPython. It merges the pull
4 4 request with current master, installs and tests on all available versions of
5 5 Python, and posts the results to Gist if any tests fail.
6 6
7 7 Usage:
8 8 python test_pr.py 1657
9 9 """
10 10 from __future__ import print_function
11 11
12 12 import errno
13 13 from glob import glob
14 14 import io
15 15 import json
16 16 import os
17 17 import pickle
18 18 import re
19 19 import requests
20 20 import shutil
21 21 import time
22 22 from subprocess import call, check_call, check_output, PIPE, STDOUT, CalledProcessError
23 23 import sys
24 24
25 25 import gh_api
26 26 from gh_api import Obj
27 27
28 28 basedir = os.path.join(os.path.expanduser("~"), ".ipy_pr_tests")
29 29 repodir = os.path.join(basedir, "ipython")
30 30 ipy_repository = 'git://github.com/ipython/ipython.git'
31 31 ipy_http_repository = 'http://github.com/ipython/ipython.git'
32 32 gh_project="ipython/ipython"
33 33
34 34 supported_pythons = ['python2.6', 'python2.7', 'python3.2']
35 35
36 36 missing_libs_re = re.compile(r"Tools and libraries NOT available at test time:\n"
37 37 r"\s*(.*?)\n")
38 38 def get_missing_libraries(log):
39 39 m = missing_libs_re.search(log)
40 40 if m:
41 41 return m.group(1)
42 42
43 43 class TestRun(object):
44 def __init__(self, pr_num):
44 def __init__(self, pr_num, extra_args):
45 45 self.unavailable_pythons = []
46 46 self.venvs = []
47 47 self.pr_num = pr_num
48 self.extra_args = extra_args
48 49
49 50 self.pr = gh_api.get_pull_request(gh_project, pr_num)
50 51
51 52 self.setup()
52 53
53 54 self.results = []
54 55
55 56 def available_python_versions(self):
56 57 """Get the executable names of available versions of Python on the system.
57 58 """
58 59 for py in supported_pythons:
59 60 try:
60 61 check_call([py, '-c', 'import nose'], stdout=PIPE)
61 62 yield py
62 63 except (OSError, CalledProcessError):
63 64 self.unavailable_pythons.append(py)
64 65
65 66 def setup(self):
66 67 """Prepare the repository and virtualenvs."""
67 68 try:
68 69 os.mkdir(basedir)
69 70 except OSError as e:
70 71 if e.errno != errno.EEXIST:
71 72 raise
72 73 os.chdir(basedir)
73 74
74 75 # Delete virtualenvs and recreate
75 76 for venv in glob('venv-*'):
76 77 shutil.rmtree(venv)
77 78 for py in self.available_python_versions():
78 79 check_call(['virtualenv', '-p', py, '--system-site-packages', 'venv-%s' % py])
79 80 self.venvs.append((py, 'venv-%s' % py))
80 81
81 82 # Check out and update the repository
82 83 if not os.path.exists('ipython'):
83 84 try :
84 85 check_call(['git', 'clone', ipy_repository])
85 86 except CalledProcessError :
86 87 check_call(['git', 'clone', ipy_http_repository])
87 88 os.chdir(repodir)
88 89 check_call(['git', 'checkout', 'master'])
89 90 try :
90 91 check_call(['git', 'pull', 'origin', 'master'])
91 92 except CalledProcessError :
92 93 check_call(['git', 'pull', ipy_http_repository, 'master'])
93 94 os.chdir(basedir)
94 95
95 96 def get_branch(self):
96 97 repo = self.pr['head']['repo']['clone_url']
97 98 branch = self.pr['head']['ref']
98 99 owner = self.pr['head']['repo']['owner']['login']
99 100 mergeable = self.pr['mergeable']
100 101
101 102 os.chdir(repodir)
102 103 if mergeable:
103 104 merged_branch = "%s-%s" % (owner, branch)
104 105 # Delete the branch first
105 106 call(['git', 'branch', '-D', merged_branch])
106 107 check_call(['git', 'checkout', '-b', merged_branch])
107 108 check_call(['git', 'pull', '--no-ff', '--no-commit', repo, branch])
108 109 check_call(['git', 'commit', '-m', "merge %s/%s" % (repo, branch)])
109 110 else:
110 111 # Fetch the branch without merging it.
111 112 check_call(['git', 'fetch', repo, branch])
112 113 check_call(['git', 'checkout', 'FETCH_HEAD'])
113 114 os.chdir(basedir)
114 115
115 116 def markdown_format(self):
116 117 def format_result(result):
117 118 s = "* %s: " % result.py
118 119 if result.passed:
119 120 s += "OK"
120 121 else:
121 122 s += "Failed, log at %s" % result.log_url
122 123 if result.missing_libraries:
123 124 s += " (libraries not available: " + result.missing_libraries + ")"
124 125 return s
125 126
126 127 if self.pr['mergeable']:
127 128 com = self.pr['head']['sha'][:7] + " merged into master"
128 129 else:
129 130 com = self.pr['head']['sha'][:7] + " (can't merge cleanly)"
130 131 lines = ["**Test results for commit %s**" % com,
131 132 "Platform: " + sys.platform,
132 133 ""] + \
133 134 [format_result(r) for r in self.results] + \
134 ["",
135 "Not available for testing: " + ", ".join(self.unavailable_pythons)]
135 [""]
136 if self.extra_args:
137 lines.append("Extra args: %r" % self.extra_args),
138 lines.append("Not available for testing: " + ", ".join(self.unavailable_pythons))
136 139 return "\n".join(lines)
137 140
138 141 def post_results_comment(self):
139 142 body = self.markdown_format()
140 143 gh_api.post_issue_comment(gh_project, self.pr_num, body)
141 144
142 145 def print_results(self):
143 146 pr = self.pr
144 147
145 148 print("\n")
146 149 if pr['mergeable']:
147 150 print("**Test results for commit %s merged into master**" % pr['head']['sha'][:7])
148 151 else:
149 152 print("**Test results for commit %s (can't merge cleanly)**" % pr['head']['sha'][:7])
150 153 print("Platform:", sys.platform)
151 154 for result in self.results:
152 155 if result.passed:
153 156 print(result.py, ":", "OK")
154 157 else:
155 158 print(result.py, ":", "Failed")
156 159 print(" Test log:", result.get('log_url') or result.log_file)
157 160 if result.missing_libraries:
158 161 print(" Libraries not available:", result.missing_libraries)
162
163 if self.extra_args:
164 print("Extra args:", self.extra_args)
159 165 print("Not available for testing:", ", ".join(self.unavailable_pythons))
160 166
161 167 def dump_results(self):
162 168 with open(os.path.join(basedir, 'lastresults.pkl'), 'wb') as f:
163 169 pickle.dump(self, f)
164 170
165 171 @staticmethod
166 172 def load_results():
167 173 with open(os.path.join(basedir, 'lastresults.pkl'), 'rb') as f:
168 174 return pickle.load(f)
169 175
170 176 def save_logs(self):
171 177 for result in self.results:
172 178 if not result.passed:
173 179 result_locn = os.path.abspath(os.path.join('venv-%s' % result.py,
174 180 self.pr['head']['sha'][:7]+".log"))
175 181 with io.open(result_locn, 'w', encoding='utf-8') as f:
176 182 f.write(result.log)
177 183
178 184 result.log_file = result_locn
179 185
180 186 def post_logs(self):
181 187 for result in self.results:
182 188 if not result.passed:
183 189 result.log_url = gh_api.post_gist(result.log,
184 190 description='IPython test log',
185 191 filename="results.log", auth=True)
186 192
187 193 def run(self):
188 194 for py, venv in self.venvs:
189 195 tic = time.time()
190 passed, log = run_tests(venv)
196 passed, log = run_tests(venv, self.extra_args)
191 197 elapsed = int(time.time() - tic)
192 198 print("Ran tests with %s in %is" % (py, elapsed))
193 199 missing_libraries = get_missing_libraries(log)
194 200
195 201 self.results.append(Obj(py=py,
196 202 passed=passed,
197 203 log=log,
198 204 missing_libraries=missing_libraries
199 205 )
200 206 )
201 207
202 208
203 def run_tests(venv):
209 def run_tests(venv, extra_args):
204 210 py = os.path.join(basedir, venv, 'bin', 'python')
205 211 print(py)
206 212 os.chdir(repodir)
207 213 # cleanup build-dir
208 214 if os.path.exists('build'):
209 215 shutil.rmtree('build')
210 216 tic = time.time()
211 217 print ("\nInstalling IPython with %s" % py)
212 218 logfile = os.path.join(basedir, venv, 'install.log')
213 219 print ("Install log at %s" % logfile)
214 220 with open(logfile, 'wb') as f:
215 221 check_call([py, 'setup.py', 'install'], stdout=f)
216 222 toc = time.time()
217 223 print ("Installed IPython in %.1fs" % (toc-tic))
218 224 os.chdir(basedir)
219 225
220 226 # Environment variables:
221 227 orig_path = os.environ["PATH"]
222 228 os.environ["PATH"] = os.path.join(basedir, venv, 'bin') + ':' + os.environ["PATH"]
223 229 os.environ.pop("PYTHONPATH", None)
224 230
225 231 # check that the right IPython is imported
226 232 ipython_file = check_output([py, '-c', 'import IPython; print (IPython.__file__)'])
227 233 ipython_file = ipython_file.strip().decode('utf-8')
228 234 if not ipython_file.startswith(os.path.join(basedir, venv)):
229 msg = u"IPython does not appear to be in the venv: %s" % ipython_file
230 msg += u"\nDo you use setupegg.py develop?"
235 msg = "IPython does not appear to be in the venv: %s" % ipython_file
236 msg += "\nDo you use setupegg.py develop?"
231 237 print(msg, file=sys.stderr)
232 238 return False, msg
233 239
234 240 iptest = os.path.join(basedir, venv, 'bin', 'iptest')
235 241 if not os.path.exists(iptest):
236 242 iptest = os.path.join(basedir, venv, 'bin', 'iptest3')
237 243
238 244 print("\nRunning tests, this typically takes a few minutes...")
239 245 try:
240 return True, check_output([iptest], stderr=STDOUT).decode('utf-8')
246 return True, check_output([iptest] + extra_args, stderr=STDOUT).decode('utf-8')
241 247 except CalledProcessError as e:
242 248 return False, e.output.decode('utf-8')
243 249 finally:
244 250 # Restore $PATH
245 251 os.environ["PATH"] = orig_path
246 252
247 253
248 def test_pr(num, post_results=True):
254 def test_pr(num, post_results=True, extra_args=None):
249 255 # Get Github authorisation first, so that the user is prompted straight away
250 256 # if their login is needed.
251 257 if post_results:
252 258 gh_api.get_auth_token()
253 259
254 testrun = TestRun(num)
260 testrun = TestRun(num, extra_args or [])
255 261
256 262 testrun.get_branch()
257 263
258 264 testrun.run()
259 265
260 266 testrun.dump_results()
261 267
262 268 testrun.save_logs()
263 269 testrun.print_results()
264 270
265 271 if post_results:
266 272 testrun.post_logs()
267 273 testrun.post_results_comment()
268 274 print("(Posted to Github)")
269 275 else:
270 276 post_script = os.path.join(os.path.dirname(sys.argv[0]), "post_pr_test.py")
271 277 print("To post the results to Github, run", post_script)
272 278
273 279
274 280 if __name__ == '__main__':
275 281 import argparse
276 282 parser = argparse.ArgumentParser(description="Test an IPython pull request")
277 283 parser.add_argument('-p', '--publish', action='store_true',
278 284 help="Publish the results to Github")
279 285 parser.add_argument('number', type=int, help="The pull request number")
280 286
281 args = parser.parse_args()
282 test_pr(args.number, post_results=args.publish)
287 args, extra_args = parser.parse_known_args()
288 test_pr(args.number, post_results=args.publish, extra_args=extra_args)
General Comments 0
You need to be logged in to leave comments. Login now