Merged pull request #12 from bhy/T423.
[cython.git] / runtests.py
index bea63575c41b724b1982c703a89a3c303bac29d7..e9d09f1562f12ea5d12ce986601e06812ffa9f19 100644 (file)
@@ -22,11 +22,32 @@ try:
 except ImportError:
     import pickle
 
+try:
+    from io import open as io_open
+except ImportError:
+    from codecs import open as io_open
+
 try:
     import threading
 except ImportError: # No threads, no problems
     threading = None
 
+try:
+    from collections import defaultdict
+except ImportError:
+    class defaultdict(object):
+        def __init__(self, default_factory=lambda : None):
+            self._dict = {}
+            self.default_factory = default_factory
+        def __getitem__(self, key):
+            if key not in self._dict:
+                self._dict[key] = self.default_factory()
+            return self._dict[key]
+        def __setitem__(self, key, value):
+            self._dict[key] = value
+        def __repr__(self):
+            return repr(self._dict)
+
 WITH_CYTHON = True
 CY3_DIR = None
 
@@ -47,26 +68,23 @@ if sys.platform == 'win32':
     except ValueError: pass
     distutils_distro.parse_config_files(cfgfiles)
 
-TEST_DIRS = ['compile', 'errors', 'run', 'wrappers', 'pyregr', 'build']
-TEST_RUN_DIRS = ['run', 'wrappers', 'pyregr']
-
-# Lists external modules, and a matcher matching tests
-# which should be excluded if the module is not present.
 EXT_DEP_MODULES = {
-    'numpy' : re.compile('.*\.numpy_.*').match,
-    'pstats' : re.compile('.*\.pstats_.*').match,
-    'posix' : re.compile('.*\.posix_.*').match,
+    'numpy' : 'tag:numpy',
+    'pstats' : 'tag:pstats',
+    'posix' : 'tag:posix',
 }
 
 def get_numpy_include_dirs():
     import numpy
     return [numpy.get_include()]
 
+# TODO: use tags
 EXT_DEP_INCLUDES = [
     # test name matcher , callable returning list
     (re.compile('numpy_.*').match, get_numpy_include_dirs),
 ]
 
+# TODO: use tags
 VER_DEP_MODULES = {
     # tests are excluded if 'CurrentPythonVersion OP VersionTuple', i.e.
     # (2,4) : (operator.lt, ...) excludes ... when PyVer < 2.4.x
@@ -75,11 +93,16 @@ VER_DEP_MODULES = {
                                           ]),
     (2,5) : (operator.lt, lambda x: x in ['run.any',
                                           'run.all',
+                                          'run.relativeimport_T542',
+                                          'run.relativeimport_star_T542',
                                           ]),
     (2,6) : (operator.lt, lambda x: x in ['run.print_function',
                                           'run.cython3',
+                                          'run.generators_py', # generators, with statement
                                           'run.pure_py', # decorators, with statement
                                           ]),
+    (2,7) : (operator.lt, lambda x: x in ['run.withstat_py', # multi context with statement
+                                          ]),
     # The next line should start (3,); but this is a dictionary, so
     # we can only have one (3,) key.  Since 2.7 is supposed to be the
     # last 2.x release, things would have to change drastically for this
@@ -105,6 +128,38 @@ COMPILER = None
 INCLUDE_DIRS = [ d for d in os.getenv('INCLUDE', '').split(os.pathsep) if d ]
 CFLAGS = os.getenv('CFLAGS', '').split()
 
+def memoize(f):
+    uncomputed = object()
+    f._cache = {}
+    def func(*args):
+        res = f._cache.get(args, uncomputed)
+        if res is uncomputed:
+            res = f._cache[args] = f(*args)
+        return res
+    return func
+
+def parse_tags(filepath):
+    tags = defaultdict(list)
+    f = io_open(filepath, encoding='ISO-8859-1', errors='replace')
+    try:
+        for line in f:
+            line = line.strip()
+            if not line:
+                continue
+            if line[0] != '#':
+                break
+            ix = line.find(':')
+            if ix != -1:
+                tag = line[1:ix].strip()
+                values = line[ix+1:].split(',')
+                tags[tag].extend([value.strip() for value in values])
+    finally:
+        f.close()
+    return tags
+
+parse_tags = memoize(parse_tags)
+
+
 class build_ext(_build_ext):
     def build_extension(self, ext):
         if ext.language == 'c++':
@@ -166,17 +221,15 @@ class TestBuilder(object):
 
     def build_suite(self):
         suite = unittest.TestSuite()
-        test_dirs = TEST_DIRS
         filenames = os.listdir(self.rootdir)
         filenames.sort()
         for filename in filenames:
-            if not WITH_CYTHON and filename == "errors":
-                # we won't get any errors without running Cython
-                continue
             path = os.path.join(self.rootdir, filename)
-            if os.path.isdir(path) and filename in test_dirs:
+            if os.path.isdir(path):
                 if filename == 'pyregr' and not self.with_pyregr:
                     continue
+                if filename == 'broken' and not self.test_bugs:
+                    continue
                 suite.addTest(
                     self.handle_directory(path, filename))
         if sys.platform not in ['win32']:
@@ -191,67 +244,81 @@ class TestBuilder(object):
         if not os.path.exists(workdir):
             os.makedirs(workdir)
 
-        expect_errors = (context == 'errors')
         suite = unittest.TestSuite()
         filenames = os.listdir(path)
         filenames.sort()
         for filename in filenames:
-            if filename.endswith(".srctree"):
-                if not [ 1 for match in self.selectors if match(filename) ]:
-                    continue
-                if self.exclude_selectors:
-                    if [1 for match in self.exclude_selectors if match(filename)]:
-                        continue
-                suite.addTest(EndToEndTest(os.path.join(path, filename), workdir, self.cleanup_workdir))
-                continue
-            if not (filename.endswith(".pyx") or filename.endswith(".py")):
+            filepath = os.path.join(path, filename)
+            module, ext = os.path.splitext(filename)
+            if ext not in ('.py', '.pyx', '.srctree'):
                 continue
-            if filename.startswith('.'): continue # certain emacs backup files
-            if context == 'pyregr' and not filename.startswith('test_'):
-                continue
-            module = os.path.splitext(filename)[0]
+            if filename.startswith('.'):
+                continue # certain emacs backup files
+            tags = parse_tags(filepath)
             fqmodule = "%s.%s" % (context, module)
             if not [ 1 for match in self.selectors
-                     if match(fqmodule) ]:
+                     if match(fqmodule, tags) ]:
                 continue
             if self.exclude_selectors:
-                if [1 for match in self.exclude_selectors if match(fqmodule)]:
+                if [1 for match in self.exclude_selectors 
+                        if match(fqmodule, tags)]:
+                    continue
+
+            mode = 'run' # default
+            if tags['mode']:
+                mode = tags['mode'][0]
+            elif context == 'pyregr':
+                mode = 'pyregr'
+
+            if ext == '.srctree':
+                suite.addTest(EndToEndTest(filepath, workdir, self.cleanup_workdir))
+                continue
+
+            # Choose the test suite.
+            if mode == 'pyregr':
+                if not filename.startswith('test_'):
                     continue
-            if context == 'pyregr':
                 test_class = CythonPyregrTestCase
-            elif context in TEST_RUN_DIRS:
+            elif mode == 'run':
                 if module.startswith("test_"):
                     test_class = CythonUnitTestCase
                 else:
                     test_class = CythonRunTestCase
             else:
                 test_class = CythonCompileTestCase
+
             for test in self.build_tests(test_class, path, workdir,
-                                         module, expect_errors):
+                                         module, mode == 'error', tags):
                 suite.addTest(test)
-            if context == 'run' and filename.endswith('.py'):
+            if mode == 'run' and ext == '.py':
                 # additionally test file in real Python
                 suite.addTest(PureDoctestTestCase(module, os.path.join(path, filename)))
+                
         return suite
 
-    def build_tests(self, test_class, path, workdir, module, expect_errors):
+    def build_tests(self, test_class, path, workdir, module, expect_errors, tags):
+        if 'werror' in tags['tags']:
+            warning_errors = True
+        else:
+            warning_errors = False
+
         if expect_errors:
-            if 'cpp' in module and 'cpp' in self.languages:
+            if 'cpp' in tags['tag'] and 'cpp' in self.languages:
                 languages = ['cpp']
             else:
                 languages = self.languages[:1]
         else:
             languages = self.languages
-        if 'cpp' in module and 'c' in languages:
+        if 'cpp' in tags['tag'] and 'c' in languages:
             languages = list(languages)
             languages.remove('c')
         tests = [ self.build_test(test_class, path, workdir, module,
-                                  language, expect_errors)
+                                  language, expect_errors, warning_errors)
                   for language in languages ]
         return tests
 
     def build_test(self, test_class, path, workdir, module,
-                   language, expect_errors):
+                   language, expect_errors, warning_errors):
         workdir = os.path.join(workdir, language)
         if not os.path.exists(workdir):
             os.makedirs(workdir)
@@ -263,13 +330,14 @@ class TestBuilder(object):
                           cleanup_sharedlibs=self.cleanup_sharedlibs,
                           cython_only=self.cython_only,
                           fork=self.fork,
-                          language_level=self.language_level)
+                          language_level=self.language_level,
+                          warning_errors=warning_errors)
 
 class CythonCompileTestCase(unittest.TestCase):
     def __init__(self, test_directory, workdir, module, language='c',
                  expect_errors=False, annotate=False, cleanup_workdir=True,
                  cleanup_sharedlibs=True, cython_only=False, fork=True,
-                 language_level=2):
+                 language_level=2, warning_errors=False):
         self.test_directory = test_directory
         self.workdir = workdir
         self.module = module
@@ -281,16 +349,26 @@ class CythonCompileTestCase(unittest.TestCase):
         self.cython_only = cython_only
         self.fork = fork
         self.language_level = language_level
+        self.warning_errors = warning_errors
         unittest.TestCase.__init__(self)
 
     def shortDescription(self):
         return "compiling (%s) %s" % (self.language, self.module)
 
     def setUp(self):
+        from Cython.Compiler import Options
+        self._saved_options = [ (name, getattr(Options, name))
+                                for name in ('warning_errors', 'error_on_unknown_names') ]
+        Options.warning_errors = self.warning_errors
+
         if self.workdir not in sys.path:
             sys.path.insert(0, self.workdir)
 
     def tearDown(self):
+        from Cython.Compiler import Options
+        for name, value in self._saved_options:
+            setattr(Options, name, value)
+
         try:
             sys.path.remove(self.workdir)
         except ValueError:
@@ -352,10 +430,10 @@ class CythonCompileTestCase(unittest.TestCase):
 
     def split_source_and_output(self, test_directory, module, workdir):
         source_file = self.find_module_source_file(os.path.join(test_directory, module) + '.pyx')
-        source_and_output = codecs.open(source_file, 'rU', 'ISO-8859-1')
+        source_and_output = io_open(source_file, 'rU', encoding='ISO-8859-1')
         try:
-            out = codecs.open(os.path.join(workdir, module + os.path.splitext(source_file)[1]),
-                              'w', 'ISO-8859-1')
+            out = io_open(os.path.join(workdir, module + os.path.splitext(source_file)[1]),
+                              'w', encoding='ISO-8859-1')
             for line in source_and_output:
                 last_line = line
                 if line.startswith("_ERRORS"):
@@ -512,64 +590,70 @@ class CythonRunTestCase(CythonCompileTestCase):
             self.run_doctests(self.module, result)
 
     def run_doctests(self, module_name, result):
-        if sys.version_info[0] >= 3 or not hasattr(os, 'fork') or not self.fork:
-            doctest.DocTestSuite(module_name).run(result)
-            gc.collect()
-            return
-
-        # fork to make sure we do not keep the tested module loaded
-        result_handle, result_file = tempfile.mkstemp()
-        os.close(result_handle)
-        child_id = os.fork()
-        if not child_id:
-            result_code = 0
-            try:
-                try:
-                    tests = None
-                    try:
-                        partial_result = PartialTestResult(result)
-                        tests = doctest.DocTestSuite(module_name)
-                        tests.run(partial_result)
-                        gc.collect()
-                    except Exception:
-                        if tests is None:
-                            # importing failed, try to fake a test class
-                            tests = _FakeClass(
-                                failureException=sys.exc_info()[1],
-                                _shortDescription=self.shortDescription(),
-                                module_name=None)
-                        partial_result.addError(tests, sys.exc_info())
-                        result_code = 1
-                    output = open(result_file, 'wb')
-                    pickle.dump(partial_result.data(), output)
-                except:
-                    traceback.print_exc()
-            finally:
-                try: output.close()
-                except: pass
-                os._exit(result_code)
+        def run_test(result):
+            tests = doctest.DocTestSuite(module_name)
+            tests.run(result)
+        run_forked_test(result, run_test, self.shortDescription(), self.fork)
+
 
+def run_forked_test(result, run_func, test_name, fork=True):
+    if not fork or sys.version_info[0] >= 3 or not hasattr(os, 'fork'):
+        run_func(result)
+        gc.collect()
+        return
+
+    # fork to make sure we do not keep the tested module loaded
+    result_handle, result_file = tempfile.mkstemp()
+    os.close(result_handle)
+    child_id = os.fork()
+    if not child_id:
+        result_code = 0
         try:
-            cid, result_code = os.waitpid(child_id, 0)
-            # os.waitpid returns the child's result code in the
-            # upper byte of result_code, and the signal it was
-            # killed by in the lower byte
-            if result_code & 255:
-                raise Exception("Tests in module '%s' were unexpectedly killed by signal %d"%
-                                (module_name, result_code & 255))
-            result_code = result_code >> 8
-            if result_code in (0,1):
-                input = open(result_file, 'rb')
+            try:
+                tests = None
                 try:
-                    PartialTestResult.join_results(result, pickle.load(input))
-                finally:
-                    input.close()
-            if result_code:
-                raise Exception("Tests in module '%s' exited with status %d" %
-                                (module_name, result_code))
+                    partial_result = PartialTestResult(result)
+                    run_func(partial_result)
+                    gc.collect()
+                except Exception:
+                    if tests is None:
+                        # importing failed, try to fake a test class
+                        tests = _FakeClass(
+                            failureException=sys.exc_info()[1],
+                            _shortDescription=test_name,
+                            module_name=None)
+                    partial_result.addError(tests, sys.exc_info())
+                    result_code = 1
+                output = open(result_file, 'wb')
+                pickle.dump(partial_result.data(), output)
+            except:
+                traceback.print_exc()
         finally:
-            try: os.unlink(result_file)
+            try: output.close()
             except: pass
+            os._exit(result_code)
+
+    try:
+        cid, result_code = os.waitpid(child_id, 0)
+        # os.waitpid returns the child's result code in the
+        # upper byte of result_code, and the signal it was
+        # killed by in the lower byte
+        if result_code & 255:
+            raise Exception("Tests in module '%s' were unexpectedly killed by signal %d"%
+                            (module_name, result_code & 255))
+        result_code = result_code >> 8
+        if result_code in (0,1):
+            input = open(result_file, 'rb')
+            try:
+                PartialTestResult.join_results(result, pickle.load(input))
+            finally:
+                input.close()
+        if result_code:
+            raise Exception("Tests in module '%s' exited with status %d" %
+                            (module_name, result_code))
+    finally:
+        try: os.unlink(result_file)
+        except: pass
 
 class PureDoctestTestCase(unittest.TestCase):
     def __init__(self, module_name, module_path):
@@ -667,6 +751,11 @@ class CythonUnitTestCase(CythonRunTestCase):
 
 
 class CythonPyregrTestCase(CythonRunTestCase):
+    def setUp(self):
+        CythonRunTestCase.setUp(self)
+        from Cython.Compiler import Options
+        Options.error_on_unknown_names = False
+
     def _run_unittest(self, result, *classes):
         """Run tests from unittest.TestCase-derived classes."""
         valid_types = (unittest.TestSuite, unittest.TestCase)
@@ -692,20 +781,23 @@ class CythonPyregrTestCase(CythonRunTestCase):
         except ImportError: # Py3k
             from test import support
 
-        def run_unittest(*classes):
-            return self._run_unittest(result, *classes)
-        def run_doctest(module, verbosity=None):
-            return self._run_doctest(result, module)
+        def run_test(result):
+            def run_unittest(*classes):
+                return self._run_unittest(result, *classes)
+            def run_doctest(module, verbosity=None):
+                return self._run_doctest(result, module)
 
-        support.run_unittest = run_unittest
-        support.run_doctest = run_doctest
+            support.run_unittest = run_unittest
+            support.run_doctest = run_doctest
 
-        try:
-            module = __import__(self.module)
-            if hasattr(module, 'test_main'):
-                module.test_main()
-        except (unittest.SkipTest, support.ResourceDenied):
-            result.addSkip(self, 'ok')
+            try:
+                module = __import__(self.module)
+                if hasattr(module, 'test_main'):
+                    module.test_main()
+            except (unittest.SkipTest, support.ResourceDenied):
+                result.addSkip(self, 'ok')
+
+        run_forked_test(result, run_test, self.shortDescription(), self.fork)
 
 include_debugger = sys.version_info[:2] > (2, 5)
 
@@ -914,11 +1006,11 @@ class MissingDependencyExcluder:
             try:
                 __import__(mod)
             except ImportError:
-                self.exclude_matchers.append(matcher)
+                self.exclude_matchers.append(string_selector(matcher))
         self.tests_missing_deps = []
-    def __call__(self, testname):
+    def __call__(self, testname, tags=None):
         for matcher in self.exclude_matchers:
-            if matcher(testname):
+            if matcher(testname, tags):
                 self.tests_missing_deps.append(testname)
                 return True
         return False
@@ -932,7 +1024,7 @@ class VersionDependencyExcluder:
             if compare(version_info, ver):
                 self.exclude_matchers.append(matcher)
         self.tests_missing_deps = []
-    def __call__(self, testname):
+    def __call__(self, testname, tags=None):
         for matcher in self.exclude_matchers:
             if matcher(testname):
                 self.tests_missing_deps.append(testname)
@@ -952,9 +1044,37 @@ class FileListExcluder:
         finally:
             f.close()
 
-    def __call__(self, testname):
+    def __call__(self, testname, tags=None):
         return testname in self.excludes or testname.split('.')[-1] in self.excludes
 
+class TagsSelector:
+
+    def __init__(self, tag, value):
+        self.tag = tag
+        self.value = value
+    
+    def __call__(self, testname, tags=None):
+        if tags is None:
+            return False
+        else:
+            return self.value in tags[self.tag]
+
+class RegExSelector:
+    
+    def __init__(self, pattern_string):
+        self.pattern = re.compile(pattern_string, re.I|re.U)
+
+    def __call__(self, testname, tags=None):
+        return self.pattern.search(testname)
+
+def string_selector(s):
+    ix = s.find(':')
+    if ix == -1:
+        return RegExSelector(s)
+    else:
+        return TagsSelector(s[:ix], s[ix+1:])
+        
+
 def refactor_for_py3(distdir, cy3_dir):
     # need to convert Cython sources first
     import lib2to3.refactor
@@ -1187,16 +1307,16 @@ def main():
     if options.tickets:
         for ticket_number in options.tickets:
             test_bugs = True
-            cmd_args.append('.*T%s$' % ticket_number)
+            cmd_args.append('ticket:%s' % ticket_number)
     if not test_bugs:
         for selector in cmd_args:
             if selector.startswith('bugs'):
                 test_bugs = True
 
     import re
-    selectors = [ re.compile(r, re.I|re.U).search for r in cmd_args ]
+    selectors = [ string_selector(r) for r in cmd_args ]
     if not selectors:
-        selectors = [ lambda x:True ]
+        selectors = [ lambda x, tags=None: True ]
 
     # Chech which external modules are not present and exclude tests
     # which depends on them (by prefix)
@@ -1206,7 +1326,7 @@ def main():
     exclude_selectors = [missing_dep_excluder, version_dep_excluder] # want to pring msg at exit
 
     if options.exclude:
-        exclude_selectors += [ re.compile(r, re.I|re.U).search for r in options.exclude ]
+        exclude_selectors += [ string_selector(r) for r in options.exclude ]
 
     if not test_bugs:
         exclude_selectors += [ FileListExcluder(os.path.join(ROOTDIR, "bugs.txt")) ]
@@ -1240,15 +1360,15 @@ def main():
         test_suite.addTest(filetests.build_suite())
 
     if options.system_pyregr and languages:
-        filetests = TestBuilder(ROOTDIR, WORKDIR, selectors, exclude_selectors,
-                                options.annotate_source, options.cleanup_workdir,
-                                options.cleanup_sharedlibs, True,
-                                options.cython_only, languages, test_bugs,
-                                options.fork, options.language_level)
-        test_suite.addTest(
-            filetests.handle_directory(
-                os.path.join(sys.prefix, 'lib', 'python'+sys.version[:3], 'test'),
-                'pyregr'))
+        sys_pyregr_dir = os.path.join(sys.prefix, 'lib', 'python'+sys.version[:3], 'test')
+        if os.path.isdir(sys_pyregr_dir):
+            filetests = TestBuilder(ROOTDIR, WORKDIR, selectors, exclude_selectors,
+                                    options.annotate_source, options.cleanup_workdir,
+                                    options.cleanup_sharedlibs, True,
+                                    options.cython_only, languages, test_bugs,
+                                    options.fork, sys.version_info[0])
+            sys.stderr.write("Including CPython regression tests in %s\n" % sys_pyregr_dir)
+            test_suite.addTest(filetests.handle_directory(sys_pyregr_dir, 'pyregr'))
 
     if options.xml_output_dir:
         from Cython.Tests.xmlrunner import XMLTestRunner