455 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
		
		
			
		
	
	
			455 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
|   | # -*- coding: utf-8 -*- | ||
|  | """yap_ipython Test Suite Runner.
 | ||
|  | 
 | ||
|  | This module provides a main entry point to a user script to test yap_ipython | ||
|  | itself from the command line. There are two ways of running this script: | ||
|  | 
 | ||
|  | 1. With the syntax `iptest all`.  This runs our entire test suite by | ||
|  |    calling this script (with different arguments) recursively.  This | ||
|  |    causes modules and package to be tested in different processes, using nose | ||
|  |    or trial where appropriate. | ||
|  | 2. With the regular nose syntax, like `iptest -vvs yap_ipython`.  In this form | ||
|  |    the script simply calls nose, but with special command line flags and | ||
|  |    plugins loaded. | ||
|  | 
 | ||
|  | """
 | ||
|  | 
 | ||
|  | # Copyright (c) yap_ipython Development Team. | ||
|  | # Distributed under the terms of the Modified BSD License. | ||
|  | 
 | ||
|  | 
 | ||
|  | import glob | ||
|  | from io import BytesIO | ||
|  | import os | ||
|  | import os.path as path | ||
|  | import sys | ||
|  | from threading import Thread, Lock, Event | ||
|  | import warnings | ||
|  | 
 | ||
|  | import nose.plugins.builtin | ||
|  | from nose.plugins.xunit import Xunit | ||
|  | from nose import SkipTest | ||
|  | from nose.core import TestProgram | ||
|  | from nose.plugins import Plugin | ||
|  | from nose.util import safe_str | ||
|  | 
 | ||
|  | from yap_ipython import version_info | ||
|  | from yap_ipython.utils.py3compat import decode | ||
|  | from yap_ipython.utils.importstring import import_item | ||
|  | from yap_ipython.testing.plugin.ipdoctest import IPythonDoctest | ||
|  | from yap_ipython.external.decorators import KnownFailure, knownfailureif | ||
|  | 
 | ||
|  | pjoin = path.join | ||
|  | 
 | ||
|  | 
 | ||
|  | # Enable printing all warnings raise by yap_ipython's modules | ||
|  | warnings.filterwarnings('ignore', message='.*Matplotlib is building the font cache.*', category=UserWarning, module='.*') | ||
|  | warnings.filterwarnings('error', message='.*', category=ResourceWarning, module='.*') | ||
|  | warnings.filterwarnings('error', message=".*{'config': True}.*", category=DeprecationWarning, module='IPy.*') | ||
|  | warnings.filterwarnings('default', message='.*', category=Warning, module='IPy.*') | ||
|  | 
 | ||
|  | warnings.filterwarnings('error', message='.*apply_wrapper.*', category=DeprecationWarning, module='.*') | ||
|  | warnings.filterwarnings('error', message='.*make_label_dec', category=DeprecationWarning, module='.*') | ||
|  | warnings.filterwarnings('error', message='.*decorated_dummy.*', category=DeprecationWarning, module='.*') | ||
|  | warnings.filterwarnings('error', message='.*skip_file_no_x11.*', category=DeprecationWarning, module='.*') | ||
|  | warnings.filterwarnings('error', message='.*onlyif_any_cmd_exists.*', category=DeprecationWarning, module='.*') | ||
|  | 
 | ||
|  | warnings.filterwarnings('error', message='.*disable_gui.*', category=DeprecationWarning, module='.*') | ||
|  | 
 | ||
|  | warnings.filterwarnings('error', message='.*ExceptionColors global is deprecated.*', category=DeprecationWarning, module='.*') | ||
|  | 
 | ||
|  | # Jedi older versions | ||
|  | warnings.filterwarnings( | ||
|  |     'error', message='.*elementwise != comparison failed and.*', category=FutureWarning, module='.*') | ||
|  | 
 | ||
|  | if version_info < (6,): | ||
|  |     # nose.tools renames all things from `camelCase` to `snake_case` which raise an | ||
|  |     # warning with the runner they also import from standard import library. (as of Dec 2015) | ||
|  |     # Ignore, let's revisit that in a couple of years for yap_ipython 6. | ||
|  |     warnings.filterwarnings( | ||
|  |         'ignore', message='.*Please use assertEqual instead', category=Warning, module='yap_ipython.*') | ||
|  | 
 | ||
|  | if version_info < (7,): | ||
|  |     warnings.filterwarnings('ignore', message='.*Completer.complete.*', | ||
|  |                             category=PendingDeprecationWarning, module='.*') | ||
|  | else: | ||
|  |     warnings.warn( | ||
|  |         'Completer.complete was pending deprecation and should be changed to Deprecated', FutureWarning) | ||
|  | 
 | ||
|  | 
 | ||
|  | 
 | ||
|  | # ------------------------------------------------------------------------------ | ||
|  | # Monkeypatch Xunit to count known failures as skipped. | ||
|  | # ------------------------------------------------------------------------------ | ||
|  | def monkeypatch_xunit(): | ||
|  |     try: | ||
|  |         knownfailureif(True)(lambda: None)() | ||
|  |     except Exception as e: | ||
|  |         KnownFailureTest = type(e) | ||
|  | 
 | ||
|  |     def addError(self, test, err, capt=None): | ||
|  |         if issubclass(err[0], KnownFailureTest): | ||
|  |             err = (SkipTest,) + err[1:] | ||
|  |         return self.orig_addError(test, err, capt) | ||
|  | 
 | ||
|  |     Xunit.orig_addError = Xunit.addError | ||
|  |     Xunit.addError = addError | ||
|  | 
 | ||
|  | #----------------------------------------------------------------------------- | ||
|  | # Check which dependencies are installed and greater than minimum version. | ||
|  | #----------------------------------------------------------------------------- | ||
|  | def extract_version(mod): | ||
|  |     return mod.__version__ | ||
|  | 
 | ||
|  | def test_for(item, min_version=None, callback=extract_version): | ||
|  |     """Test to see if item is importable, and optionally check against a minimum
 | ||
|  |     version. | ||
|  | 
 | ||
|  |     If min_version is given, the default behavior is to check against the | ||
|  |     `__version__` attribute of the item, but specifying `callback` allows you to | ||
|  |     extract the value you are interested in. e.g:: | ||
|  | 
 | ||
|  |         In [1]: import sys | ||
|  | 
 | ||
|  |         In [2]: from yap_ipython.testing.iptest import test_for | ||
|  | 
 | ||
|  |         In [3]: test_for('sys', (2,6), callback=lambda sys: sys.version_info) | ||
|  |         Out[3]: True | ||
|  | 
 | ||
|  |     """
 | ||
|  |     try: | ||
|  |         check = import_item(item) | ||
|  |     except (ImportError, RuntimeError): | ||
|  |         # GTK reports Runtime error if it can't be initialized even if it's | ||
|  |         # importable. | ||
|  |         return False | ||
|  |     else: | ||
|  |         if min_version: | ||
|  |             if callback: | ||
|  |                 # extra processing step to get version to compare | ||
|  |                 check = callback(check) | ||
|  | 
 | ||
|  |             return check >= min_version | ||
|  |         else: | ||
|  |             return True | ||
|  | 
 | ||
|  | # Global dict where we can store information on what we have and what we don't | ||
|  | # have available at test run time | ||
|  | have = {'matplotlib': test_for('matplotlib'), | ||
|  |         'pygments': test_for('pygments'), | ||
|  |         'sqlite3': test_for('sqlite3')} | ||
|  | 
 | ||
|  | #----------------------------------------------------------------------------- | ||
|  | # Test suite definitions | ||
|  | #----------------------------------------------------------------------------- | ||
|  | 
 | ||
|  | test_group_names = ['core', | ||
|  |                     'extensions', 'lib', 'terminal', 'testing', 'utils', | ||
|  |                    ] | ||
|  | 
 | ||
|  | class TestSection(object): | ||
|  |     def __init__(self, name, includes): | ||
|  |         self.name = name | ||
|  |         self.includes = includes | ||
|  |         self.excludes = [] | ||
|  |         self.dependencies = [] | ||
|  |         self.enabled = True | ||
|  |      | ||
|  |     def exclude(self, module): | ||
|  |         if not module.startswith('yap_ipython'): | ||
|  |             module = self.includes[0] + "." + module | ||
|  |         self.excludes.append(module.replace('.', os.sep)) | ||
|  |      | ||
|  |     def requires(self, *packages): | ||
|  |         self.dependencies.extend(packages) | ||
|  |      | ||
|  |     @property | ||
|  |     def will_run(self): | ||
|  |         return self.enabled and all(have[p] for p in self.dependencies) | ||
|  | 
 | ||
|  | # Name -> (include, exclude, dependencies_met) | ||
|  | test_sections = {n:TestSection(n, ['yap_ipython.%s' % n]) for n in test_group_names} | ||
|  | 
 | ||
|  | 
 | ||
|  | # Exclusions and dependencies | ||
|  | # --------------------------- | ||
|  | 
 | ||
|  | # core: | ||
|  | sec = test_sections['core'] | ||
|  | if not have['sqlite3']: | ||
|  |     sec.exclude('tests.test_history') | ||
|  |     sec.exclude('history') | ||
|  | if not have['matplotlib']: | ||
|  |     sec.exclude('pylabtools'), | ||
|  |     sec.exclude('tests.test_pylabtools') | ||
|  | 
 | ||
|  | # lib: | ||
|  | sec = test_sections['lib'] | ||
|  | sec.exclude('kernel') | ||
|  | if not have['pygments']: | ||
|  |     sec.exclude('tests.test_lexers') | ||
|  | # We do this unconditionally, so that the test suite doesn't import | ||
|  | # gtk, changing the default encoding and masking some unicode bugs. | ||
|  | sec.exclude('inputhookgtk') | ||
|  | # We also do this unconditionally, because wx can interfere with Unix signals. | ||
|  | # There are currently no tests for it anyway. | ||
|  | sec.exclude('inputhookwx') | ||
|  | # Testing inputhook will need a lot of thought, to figure out | ||
|  | # how to have tests that don't lock up with the gui event | ||
|  | # loops in the picture | ||
|  | sec.exclude('inputhook') | ||
|  | 
 | ||
|  | # testing: | ||
|  | sec = test_sections['testing'] | ||
|  | # These have to be skipped on win32 because they use echo, rm, cd, etc. | ||
|  | # See ticket https://github.com/ipython/ipython/issues/87 | ||
|  | if sys.platform == 'win32': | ||
|  |     sec.exclude('plugin.test_exampleip') | ||
|  |     sec.exclude('plugin.dtexample') | ||
|  | 
 | ||
|  | # don't run jupyter_console tests found via shim | ||
|  | test_sections['terminal'].exclude('console') | ||
|  | 
 | ||
|  | # extensions: | ||
|  | sec = test_sections['extensions'] | ||
|  | # This is deprecated in favour of rpy2 | ||
|  | sec.exclude('rmagic') | ||
|  | # autoreload does some strange stuff, so move it to its own test section | ||
|  | sec.exclude('autoreload') | ||
|  | sec.exclude('tests.test_autoreload') | ||
|  | test_sections['autoreload'] = TestSection('autoreload', | ||
|  |         ['yap_ipython.extensions.autoreload', 'yap_ipython.extensions.tests.test_autoreload']) | ||
|  | test_group_names.append('autoreload') | ||
|  | 
 | ||
|  | 
 | ||
|  | #----------------------------------------------------------------------------- | ||
|  | # Functions and classes | ||
|  | #----------------------------------------------------------------------------- | ||
|  | 
 | ||
|  | def check_exclusions_exist(): | ||
|  |     from yap_ipython.paths import get_ipython_package_dir | ||
|  |     from warnings import warn | ||
|  |     parent = os.path.dirname(get_ipython_package_dir()) | ||
|  |     for sec in test_sections: | ||
|  |         for pattern in sec.exclusions: | ||
|  |             fullpath = pjoin(parent, pattern) | ||
|  |             if not os.path.exists(fullpath) and not glob.glob(fullpath + '.*'): | ||
|  |                 warn("Excluding nonexistent file: %r" % pattern) | ||
|  | 
 | ||
|  | 
 | ||
|  | class ExclusionPlugin(Plugin): | ||
|  |     """A nose plugin to effect our exclusions of files and directories.
 | ||
|  |     """
 | ||
|  |     name = 'exclusions' | ||
|  |     score = 3000  # Should come before any other plugins | ||
|  |      | ||
|  |     def __init__(self, exclude_patterns=None): | ||
|  |         """
 | ||
|  |         Parameters | ||
|  |         ---------- | ||
|  | 
 | ||
|  |         exclude_patterns : sequence of strings, optional | ||
|  |           Filenames containing these patterns (as raw strings, not as regular | ||
|  |           expressions) are excluded from the tests. | ||
|  |         """
 | ||
|  |         self.exclude_patterns = exclude_patterns or [] | ||
|  |         super(ExclusionPlugin, self).__init__() | ||
|  | 
 | ||
|  |     def options(self, parser, env=os.environ): | ||
|  |         Plugin.options(self, parser, env) | ||
|  |      | ||
|  |     def configure(self, options, config): | ||
|  |         Plugin.configure(self, options, config) | ||
|  |         # Override nose trying to disable plugin. | ||
|  |         self.enabled = True | ||
|  |          | ||
|  |     def wantFile(self, filename): | ||
|  |         """Return whether the given filename should be scanned for tests.
 | ||
|  |         """
 | ||
|  |         if any(pat in filename for pat in self.exclude_patterns): | ||
|  |             return False | ||
|  |         return None | ||
|  | 
 | ||
|  |     def wantDirectory(self, directory): | ||
|  |         """Return whether the given directory should be scanned for tests.
 | ||
|  |         """
 | ||
|  |         if any(pat in directory for pat in self.exclude_patterns): | ||
|  |             return False | ||
|  |         return None | ||
|  | 
 | ||
|  | 
 | ||
|  | class StreamCapturer(Thread): | ||
|  |     daemon = True  # Don't hang if main thread crashes | ||
|  |     started = False | ||
|  |     def __init__(self, echo=False): | ||
|  |         super(StreamCapturer, self).__init__() | ||
|  |         self.echo = echo | ||
|  |         self.streams = [] | ||
|  |         self.buffer = BytesIO() | ||
|  |         self.readfd, self.writefd = os.pipe() | ||
|  |         self.buffer_lock = Lock() | ||
|  |         self.stop = Event() | ||
|  | 
 | ||
|  |     def run(self): | ||
|  |         self.started = True | ||
|  | 
 | ||
|  |         while not self.stop.is_set(): | ||
|  |             chunk = os.read(self.readfd, 1024) | ||
|  | 
 | ||
|  |             with self.buffer_lock: | ||
|  |                 self.buffer.write(chunk) | ||
|  |             if self.echo: | ||
|  |                 sys.stdout.write(decode(chunk)) | ||
|  | 
 | ||
|  |         os.close(self.readfd) | ||
|  |         os.close(self.writefd) | ||
|  | 
 | ||
|  |     def reset_buffer(self): | ||
|  |         with self.buffer_lock: | ||
|  |             self.buffer.truncate(0) | ||
|  |             self.buffer.seek(0) | ||
|  | 
 | ||
|  |     def get_buffer(self): | ||
|  |         with self.buffer_lock: | ||
|  |             return self.buffer.getvalue() | ||
|  | 
 | ||
|  |     def ensure_started(self): | ||
|  |         if not self.started: | ||
|  |             self.start() | ||
|  | 
 | ||
|  |     def halt(self): | ||
|  |         """Safely stop the thread.""" | ||
|  |         if not self.started: | ||
|  |             return | ||
|  | 
 | ||
|  |         self.stop.set() | ||
|  |         os.write(self.writefd, b'\0')  # Ensure we're not locked in a read() | ||
|  |         self.join() | ||
|  | 
 | ||
|  | class SubprocessStreamCapturePlugin(Plugin): | ||
|  |     name='subprocstreams' | ||
|  |     def __init__(self): | ||
|  |         Plugin.__init__(self) | ||
|  |         self.stream_capturer = StreamCapturer() | ||
|  |         self.destination = os.environ.get('IPTEST_SUBPROC_STREAMS', 'capture') | ||
|  |         # This is ugly, but distant parts of the test machinery need to be able | ||
|  |         # to redirect streams, so we make the object globally accessible. | ||
|  |         nose.iptest_stdstreams_fileno = self.get_write_fileno | ||
|  | 
 | ||
|  |     def get_write_fileno(self): | ||
|  |         if self.destination == 'capture': | ||
|  |             self.stream_capturer.ensure_started() | ||
|  |             return self.stream_capturer.writefd | ||
|  |         elif self.destination == 'discard': | ||
|  |             return os.open(os.devnull, os.O_WRONLY) | ||
|  |         else: | ||
|  |             return sys.__stdout__.fileno() | ||
|  |      | ||
|  |     def configure(self, options, config): | ||
|  |         Plugin.configure(self, options, config) | ||
|  |         # Override nose trying to disable plugin. | ||
|  |         if self.destination == 'capture': | ||
|  |             self.enabled = True | ||
|  |      | ||
|  |     def startTest(self, test): | ||
|  |         # Reset log capture | ||
|  |         self.stream_capturer.reset_buffer() | ||
|  |      | ||
|  |     def formatFailure(self, test, err): | ||
|  |         # Show output | ||
|  |         ec, ev, tb = err | ||
|  |         captured = self.stream_capturer.get_buffer().decode('utf-8', 'replace') | ||
|  |         if captured.strip(): | ||
|  |             ev = safe_str(ev) | ||
|  |             out = [ev, '>> begin captured subprocess output <<', | ||
|  |                     captured, | ||
|  |                     '>> end captured subprocess output <<'] | ||
|  |             return ec, '\n'.join(out), tb | ||
|  | 
 | ||
|  |         return err | ||
|  |      | ||
|  |     formatError = formatFailure | ||
|  |      | ||
|  |     def finalize(self, result): | ||
|  |         self.stream_capturer.halt() | ||
|  | 
 | ||
|  | 
 | ||
|  | def run_iptest(): | ||
|  |     """Run the yap_ipython test suite using nose.
 | ||
|  | 
 | ||
|  |     This function is called when this script is **not** called with the form | ||
|  |     `iptest all`.  It simply calls nose with appropriate command line flags | ||
|  |     and accepts all of the standard nose arguments. | ||
|  |     """
 | ||
|  |     # Apply our monkeypatch to Xunit | ||
|  |     if '--with-xunit' in sys.argv and not hasattr(Xunit, 'orig_addError'): | ||
|  |         monkeypatch_xunit() | ||
|  | 
 | ||
|  |     arg1 = sys.argv[1] | ||
|  |     if arg1 in test_sections: | ||
|  |         section = test_sections[arg1] | ||
|  |         sys.argv[1:2] = section.includes | ||
|  |     elif arg1.startswith('yap_ipython.') and arg1[8:] in test_sections: | ||
|  |         section = test_sections[arg1[8:]] | ||
|  |         sys.argv[1:2] = section.includes | ||
|  |     else: | ||
|  |         section = TestSection(arg1, includes=[arg1]) | ||
|  |          | ||
|  | 
 | ||
|  |     argv = sys.argv + [ '--detailed-errors',  # extra info in tracebacks | ||
|  |                         # We add --exe because of setuptools' imbecility (it | ||
|  |                         # blindly does chmod +x on ALL files).  Nose does the | ||
|  |                         # right thing and it tries to avoid executables, | ||
|  |                         # setuptools unfortunately forces our hand here.  This | ||
|  |                         # has been discussed on the distutils list and the | ||
|  |                         # setuptools devs refuse to fix this problem! | ||
|  |                         '--exe', | ||
|  |                         ] | ||
|  |     if '-a' not in argv and '-A' not in argv: | ||
|  |         argv = argv + ['-a', '!crash'] | ||
|  | 
 | ||
|  |     if nose.__version__ >= '0.11': | ||
|  |         # I don't fully understand why we need this one, but depending on what | ||
|  |         # directory the test suite is run from, if we don't give it, 0 tests | ||
|  |         # get run.  Specifically, if the test suite is run from the source dir | ||
|  |         # with an argument (like 'iptest.py yap_ipython.core', 0 tests are run, | ||
|  |         # even if the same call done in this directory works fine).  It appears | ||
|  |         # that if the requested package is in the current dir, nose bails early | ||
|  |         # by default.  Since it's otherwise harmless, leave it in by default | ||
|  |         # for nose >= 0.11, though unfortunately nose 0.10 doesn't support it. | ||
|  |         argv.append('--traverse-namespace') | ||
|  | 
 | ||
|  |     plugins = [ ExclusionPlugin(section.excludes), KnownFailure(), | ||
|  |                SubprocessStreamCapturePlugin() ] | ||
|  |      | ||
|  |     # we still have some vestigial doctests in core | ||
|  |     if (section.name.startswith(('core', 'yap_ipython.core', 'yap_ipython.utils'))): | ||
|  |         plugins.append(IPythonDoctest()) | ||
|  |         argv.extend([ | ||
|  |             '--with-ipdoctest', | ||
|  |             '--ipdoctest-tests', | ||
|  |             '--ipdoctest-extension=txt', | ||
|  |         ]) | ||
|  | 
 | ||
|  |      | ||
|  |     # Use working directory set by parent process (see iptestcontroller) | ||
|  |     if 'IPTEST_WORKING_DIR' in os.environ: | ||
|  |         os.chdir(os.environ['IPTEST_WORKING_DIR']) | ||
|  |      | ||
|  |     # We need a global ipython running in this process, but the special | ||
|  |     # in-process group spawns its own yap_ipython kernels, so for *that* group we | ||
|  |     # must avoid also opening the global one (otherwise there's a conflict of | ||
|  |     # singletons).  Ultimately the solution to this problem is to refactor our | ||
|  |     # assumptions about what needs to be a singleton and what doesn't (app | ||
|  |     # objects should, individual shells shouldn't).  But for now, this | ||
|  |     # workaround allows the test suite for the inprocess module to complete. | ||
|  |     if 'kernel.inprocess' not in section.name: | ||
|  |         from yap_ipython.testing import globalipapp | ||
|  |         globalipapp.start_ipython() | ||
|  | 
 | ||
|  |     # Now nose can run | ||
|  |     TestProgram(argv=argv, addplugins=plugins) | ||
|  | 
 | ||
|  | if __name__ == '__main__': | ||
|  |     run_iptest() |