← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:modern-zope-testrunner into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:modern-zope-testrunner into launchpad:master.

Commit message:
Upgrade to modern zope.testing and zope.testrunner

Launchpad has had a zope.testing fork since 2010, last changed in 2012,
and never merged from upstream.  In the intervening period the relevant
code has been moved to zope.testrunner.  I've landed most of our
substantial changes upstream.  There are two remaining changes, now in a
much more manageable form:

 * Report tests by id rather than str(test)
 * Make --subunit --list-tests only print test IDs

This gets us quite a few useful improvements:

 * Python 3 support in zope.testing and zope.testrunner
 * subunit v2 protocol support (although using this will require
   buildbot changes)
 * Support for unexpected successes
 * Improved support for skips
 * Enable DeprecationWarning when running tests

The warning-related changes required some rearrangements to the way we
set up our own warning filters, since that now has to be done within
zope.testrunner's warnings.catch_warnings context manager.

I also included a few preparatory changes, mainly to tidy up handling of
stdout and stderr streams.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/374387
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:modern-zope-testrunner into launchpad:master.
diff --git a/constraints.txt b/constraints.txt
index fa23f05..33861d8 100644
--- a/constraints.txt
+++ b/constraints.txt
@@ -76,34 +76,10 @@ zope.structuredtext==3.5.1
 zope.tal==4.3.0
 zope.tales==3.5.3
 #zope.testing==3.10.3
-# p1 Build of lp:~mars/zope.testing/3.9.4-p1.  Fixes bugs 570380 and 587886.
-# p2 With patch for thread leaks to make them skips, fixes windmill errors
-#    with 'new threads' in hudson/ec2 builds.
-# p3 And always tear down layers, because thats the Right Thing To Do.
-# p4 fixes --subunit --list to really just list the tests.
-# p5 Build of lp:~launchpad/zope.testing/3.9.4-p5. Fixes bug #609986.
-# p6 reinstates fix from p4.  Build of lp:~launchpad/zope.testing/3.9.4-fork
-#    revision 26.
-# p7 was unused
-# p8 redirects stdout and stderr to a black hole device when --subunit is used
-# p9 adds the redirection of __stderr__ to a black hole device
-# p10 changed the test reporting to use test.id() rather than
-#     str(test) since only the id is unique.
-# p11 reverts p9.
-# p12 reverts p11, restoring p9.
-# p13 Add a new --require-unique flag to the testrunner. When set,
-#     this will cause the testrunner to check all tests IDs to ensure they
-#     haven't been loaded before. If it encounters a duplicate, it will
-#     raise an error and quit.
-# p14 Adds test data written to stderr and stdout into the subunit output.
-# p15 Fixed internal tests.
-# p16 Adds support for skips in Python 2.7.
-# p17 Fixes skip support for Python 2.6.
-# To build (use Python 2.6) run "python bootstrap.py; ./bin/buildout".  Then to
-#    build the distribution run "bin/buildout setup . sdist"
-# Make sure you have subunit installed.
-zope.testing==3.9.4-p17
-zope.testrunner==4.0.4
+zope.testing==4.7
+#zope.testrunner==4.0.4
+# lp:~launchpad-committers/zope.testrunner:launchpad
+zope.testrunner[subunit]==5.1+lp1
 zope.traversing==3.14.0
 zope.viewlet==3.7.2
 
@@ -125,7 +101,7 @@ Pygments==2.2.0
 #python-gettext==1.0
 python-gettext==3.0
 #python-subunit==0.0.7
-python-subunit==0.0.8beta
+python-subunit==1.3.0
 #pytz==2014.10
 pytz==2017.2
 RestrictedPython==3.6.0
diff --git a/lib/lp/scripts/utilities/test.py b/lib/lp/scripts/utilities/test.py
index ee273cf..1899105 100755
--- a/lib/lp/scripts/utilities/test.py
+++ b/lib/lp/scripts/utilities/test.py
@@ -13,6 +13,7 @@
 ##############################################################################
 """Test script."""
 
+import argparse
 import doctest
 import os
 import random
@@ -23,8 +24,9 @@ import time
 import warnings
 
 import six
-from zope.testing import testrunner
-from zope.testing.testrunner import options
+from zope.testrunner import options
+from zope.testrunner.feature import Feature
+from zope.testrunner.runner import Runner
 
 from lp.scripts.utilities import (
     importpedant,
@@ -63,9 +65,6 @@ def configure_environment():
     # Install the import pedant import hook and atexit handler.
     importpedant.install_import_pedant()
 
-    # Install the warning handler hook and atexit handler.
-    warninghandler.install_warning_handler()
-
     # Ensure that atexit handlers are executed on TERM.
     def exit_with_atexit_handlers(*ignored):
         sys.exit(-1 * signal.SIGTERM)
@@ -103,9 +102,6 @@ def filter_warnings():
         'ignore', 'twisted.python.plugin', DeprecationWarning,
         )
     warnings.filterwarnings(
-        'ignore', 'zope.testing.doctest', DeprecationWarning,
-        )
-    warnings.filterwarnings(
         'ignore', 'bzrlib.*was deprecated', DeprecationWarning,
         )
     # The next one is caused by an infelicity in python-openid.  The
@@ -126,6 +122,15 @@ def filter_warnings():
          r'Use \"message.getOpenIDNamespace\(\)\" instead'),
         DeprecationWarning
     )
+    # XXX cjwatson 2019-10-18: This can be dropped once the port to Breezy
+    # is complete.
+    warnings.filterwarnings(
+        'ignore',
+        re.escape(
+            'RefsContainer._follow is deprecated. '
+            'Use RefsContainer.follow instead.'),
+        DeprecationWarning, r'dulwich\.refs',
+        )
     # This warning will be triggered if the beforeTraversal hook fails. We
     # want to ensure it is not raised as an error, as this will mask the
     # real problem.
@@ -141,6 +146,19 @@ def filter_warnings():
     warnings.filterwarnings('error', r'shortlist\(\)')
 
 
+class LaunchpadWarnings(Feature):
+    """Install Launchpad's warning handler and filters."""
+
+    active = True
+
+    def global_setup(self):
+        warnings.showwarning = warninghandler.launchpad_showwarning
+        filter_warnings()
+
+    def global_teardown(self):
+        warninghandler.report_warnings()
+
+
 def install_fake_pgsql_connect():
     from lp.testing import pgsql
     # If this is removed, make sure lp.testing.pgsql is updated
@@ -177,6 +195,13 @@ defaults = {
     }
 
 
+class LaunchpadRunner(Runner):
+
+    def configure(self):
+        super(LaunchpadRunner, self).configure()
+        self.features.insert(0, LaunchpadWarnings(self))
+
+
 def main():
     # The working directory change is just so that the test script
     # can be invoked from places other than the root of the source
@@ -187,7 +212,6 @@ def main():
 
     fix_doctest_output()
     configure_environment()
-    filter_warnings()
     install_fake_pgsql_connect()
     randomise_listdir()
 
@@ -223,43 +247,46 @@ def main():
         from lp.services.testing.parallel import main
         sys.exit(main(sys.argv))
 
-    def load_list(option, opt_str, list_name, parser):
-        patch_find_tests(filter_tests(list_name, '--shuffle' in sys.argv))
-    options.parser.add_option(
-        '--load-list', type=str, action='callback', callback=load_list)
-    options.parser.add_option(
+    class LoadListAction(argparse.Action):
+        def __call__(self, parser, namespace, values, option_string=None):
+            patch_find_tests(filter_tests(values, '--shuffle' in sys.argv))
+
+    options.parser.add_argument(
+        '--load-list', type=str, action=LoadListAction)
+    options.parser.add_argument(
         '--parallel', action='store_true',
         help='Run tests in parallel processes. '
             'Poorly isolated tests will break.')
 
     # tests_pattern is a regexp, so the parsed value is hard to compare
     # with the default value in the loop below.
-    options.parser.defaults['tests_pattern'] = defaults['tests_pattern']
+    options.parser.set_defaults(tests_pattern=defaults['tests_pattern'])
     local_options = options.get_options(args=args)
     # Set our default options, if the options aren't specified.
     for name, value in defaults.items():
         parsed_option = getattr(local_options, name)
-        if ((parsed_option == []) or
-            (parsed_option == options.parser.defaults.get(name))):
+        if (parsed_option == [] or
+                parsed_option == options.parser.get_default(name)):
             # The option probably wasn't specified on the command line,
             # let's replace it with our default value. It could be that
-            # the real default (as specified in
-            # zope.testing.testrunner.options) was specified, and we
-            # shouldn't replace it with our default, but it's such and
-            # edge case, so we don't have to care about it.
-            options.parser.defaults[name] = value
+            # the real default (as specified in zope.testrunner.options)
+            # was specified, and we shouldn't replace it with our
+            # default, but it's such an edge case, so we don't have to
+            # care about it.
+            options.parser.set_defaults(**{name: value})
 
     # Turn on Layer profiling if requested.
     if local_options.verbose >= 3 and main_process:
         profiled.setup_profiling()
 
     try:
-        try:
-            testrunner.run([])
-        except SystemExit:
-            # Print Layer profiling report if requested.
-            if main_process and local_options.verbose >= 3:
-                profiled.report_profile_stats()
-            raise
+        script_parts = [os.path.abspath(sys.argv[0])]
+        runner = LaunchpadRunner(
+            [], script_parts=script_parts, cwd=os.getcwd())
+        runner.run()
+        # Print Layer profiling report if requested.
+        if main_process and local_options.verbose >= 3:
+            profiled.report_profile_stats()
+        return int(runner.failed)
     finally:
         os.chdir(there)
diff --git a/lib/lp/scripts/utilities/warninghandler.py b/lib/lp/scripts/utilities/warninghandler.py
index addc5f3..4a489d7 100644
--- a/lib/lp/scripts/utilities/warninghandler.py
+++ b/lib/lp/scripts/utilities/warninghandler.py
@@ -1,11 +1,12 @@
-# Copyright 2009-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Handlers for warnings, to be installed when testing."""
 
+from __future__ import print_function
+
 __metaclass__ = type
 
-import atexit
 import inspect
 import StringIO
 import sys
@@ -188,39 +189,34 @@ def launchpad_showwarning(message, category, filename, lineno, file=None,
 def report_need_page_titles():
     global need_page_titles
     if need_page_titles:
-        print
-        print "The following pages need titles."
+        print(file=sys.stderr)
+        print("The following pages need titles.", file=sys.stderr)
         for message in need_page_titles:
-            print "   ", message
+            print("   ", message, file=sys.stderr)
 
 
 def report_no_order_by():
     global no_order_by
     if no_order_by:
-        print
-        print ("The following code has issues with"
-               " ambiguous select results ordering.")
+        print(file=sys.stderr)
+        print("The following code has issues with"
+              " ambiguous select results ordering.", file=sys.stderr)
         for report in no_order_by:
-            print
-            print report
+            print(file=sys.stderr)
+            print(report, file=sys.stderr)
 
 
 def report_other_warnings():
     global other_warnings
     if other_warnings:
-        print
-        print "General warnings."
+        print(file=sys.stderr)
+        print("General warnings.", file=sys.stderr)
         for warninginfo in other_warnings.itervalues():
-            print
-            print warninginfo
+            print(file=sys.stderr)
+            print(warninginfo, file=sys.stderr)
 
 
 def report_warnings():
     report_need_page_titles()
     report_no_order_by()
     report_other_warnings()
-
-
-def install_warning_handler():
-    warnings.showwarning = launchpad_showwarning
-    atexit.register(report_warnings)
diff --git a/lib/lp/services/mail/tests/test_stub.py b/lib/lp/services/mail/tests/test_stub.py
index 095af90..8945153 100644
--- a/lib/lp/services/mail/tests/test_stub.py
+++ b/lib/lp/services/mail/tests/test_stub.py
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -6,7 +6,7 @@ __metaclass__ = type
 from doctest import DocTestSuite
 import re
 
-from zope.testing.renormalizing import RENormalizing
+from zope.testing.renormalizing import OutputChecker
 
 from lp.testing.layers import LaunchpadFunctionalLayer
 
@@ -112,7 +112,7 @@ def test_simple_sendmail():
 
 
 def test_suite():
-    suite = DocTestSuite(checker=RENormalizing([
+    suite = DocTestSuite(checker=OutputChecker([
         (re.compile(r"'revision', '[0-9a-f]+'"),
          "'revision', '%s'" % ('0' * 40))]))
     suite.layer = LaunchpadFunctionalLayer
diff --git a/lib/lp/services/testing/customresult.py b/lib/lp/services/testing/customresult.py
index 5f1bbc4..2164f3d 100644
--- a/lib/lp/services/testing/customresult.py
+++ b/lib/lp/services/testing/customresult.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2012 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Support code for using a custom test result in test.py."""
@@ -11,11 +11,11 @@ __all__ = [
 
 from unittest import TestSuite
 
-from zope.testing.testrunner import find
+from zope.testrunner import find
 
 
 def patch_find_tests(hook):
-    """Add a post-processing hook to zope.testing.testrunner.find_tests.
+    """Add a post-processing hook to zope.testrunner.find_tests.
 
     This is useful for things like filtering tests or listing tests.
 
diff --git a/lib/lp/services/testing/tests/test_parallel.py b/lib/lp/services/testing/tests/test_parallel.py
index 9e65111..2dec513 100644
--- a/lib/lp/services/testing/tests/test_parallel.py
+++ b/lib/lp/services/testing/tests/test_parallel.py
@@ -1,4 +1,4 @@
-# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# Copyright 2010-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Parallel test glue."""
@@ -102,7 +102,7 @@ class TestUtilities(TestCase, TestWithFixtures):
             self.assertEqual(
                 ['bin/test', '-vt', 'filter', '--list-tests', '--subunit'],
                 args['args'])
-            return {'stdin': StringIO(), 'stdout': StringIO(u"""\
+            return {'stdin': StringIO(), 'stdout': StringIO("""\
 test: quux
 successful: quux
 test: glom
diff --git a/lib/lp/services/tests/test_stacktrace.py b/lib/lp/services/tests/test_stacktrace.py
index 0f1c605..dedbf77 100644
--- a/lib/lp/services/tests/test_stacktrace.py
+++ b/lib/lp/services/tests/test_stacktrace.py
@@ -1,20 +1,22 @@
-# Copyright 2011 Canonical Ltd.  This software is licensed under the
+# Copyright 2011-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test the stacktrace module."""
 
 __metaclass__ = type
 
-import StringIO
 import sys
 
+from fixtures import MonkeyPatch
+import six
+
 from lp.services import stacktrace
 from lp.testing import TestCase
 from lp.testing.layers import BaseLayer
 
 # This constant must always be equal to the line number on which it lives for
 # the tests to pass.
-MY_LINE_NUMBER = 17
+MY_LINE_NUMBER = 19
 
 MY_FILE_NAME = __file__[:__file__.rindex('.py')] + '.py'
 
@@ -148,17 +150,16 @@ class TestStacktrace(TestCase):
     def test_get_frame_data_supplement_bad_getInfo_with_traceback(self):
         def boo_hiss():
             raise ValueError()
-        original_stderr = sys.__stderr__
-        stderr = sys.stderr = StringIO.StringIO()
+        stderr = six.StringIO()
         self.assertFalse(stacktrace.DEBUG_EXCEPTION_FORMATTER)
         stacktrace.DEBUG_EXCEPTION_FORMATTER = True
         try:
-            filename, lineno, name, line, modname, supplement, info = (
-                stacktrace._get_frame_data(
-                    get_frame(supplement=dict(getInfo=boo_hiss)),
-                    MY_LINE_NUMBER))
+            with MonkeyPatch('sys.stderr', stderr):
+                filename, lineno, name, line, modname, supplement, info = (
+                    stacktrace._get_frame_data(
+                        get_frame(supplement=dict(getInfo=boo_hiss)),
+                        MY_LINE_NUMBER))
         finally:
-            sys.stderr = original_stderr
             stacktrace.DEBUG_EXCEPTION_FORMATTER = False
         self.assertEqual(
             dict(source_url=None, line=None, column=None, expression=None,
@@ -276,50 +277,43 @@ class TestStacktrace(TestCase):
     def test_format_list_extra_errors(self):
         extracted = stacktrace.extract_stack(get_frame(supplement=dict()))
         extracted[-1][-2]['warnings'] = object()  # This should never happen.
-        original_stderr = sys.__stderr__
-        stderr = sys.stderr = StringIO.StringIO()
+        stderr = six.StringIO()
         self.assertFalse(stacktrace.DEBUG_EXCEPTION_FORMATTER)
         stacktrace.DEBUG_EXCEPTION_FORMATTER = True
         try:
-            formatted = stacktrace.format_list(extracted)
+            with MonkeyPatch('sys.stderr', stderr):
+                formatted = stacktrace.format_list(extracted)
         finally:
-            sys.stderr = original_stderr
             stacktrace.DEBUG_EXCEPTION_FORMATTER = False
         self.assertStartsWith(stderr.getvalue(), 'Traceback (most recent')
         self.assertEndsWith(formatted[-1], '    return sys._getframe()\n')
 
     def test_print_list_default(self):
         extracted = stacktrace.extract_stack(get_frame())
-        original_stderr = sys.__stderr__
-        stderr = sys.stderr = StringIO.StringIO()
-        try:
+        stderr = six.StringIO()
+        with MonkeyPatch('sys.stderr', stderr):
             stacktrace.print_list(extracted)
-        finally:
-            sys.stderr = original_stderr
         self.assertEndsWith(stderr.getvalue(), 'return sys._getframe()\n')
 
     def test_print_list_file(self):
         extracted = stacktrace.extract_stack(get_frame())
-        f = StringIO.StringIO()
+        f = six.StringIO()
         stacktrace.print_list(extracted, file=f)
         self.assertEndsWith(f.getvalue(), 'return sys._getframe()\n')
 
     def test_print_stack_default(self):
-        original_stderr = sys.__stderr__
-        stderr = sys.stderr = StringIO.StringIO()
-        try:
+        stderr = six.StringIO()
+        with MonkeyPatch('sys.stderr', stderr):
             stacktrace.print_stack()
-        finally:
-            sys.stderr = original_stderr
         self.assertEndsWith(stderr.getvalue(), 'stacktrace.print_stack()\n')
 
     def test_print_stack_options(self):
-        f = StringIO.StringIO()
+        f = six.StringIO()
         frame = get_frame()
         stacktrace.print_stack(f=frame, limit=100, file=f)
         self.assertEndsWith(f.getvalue(), 'return sys._getframe()\n')
         self.assertTrue(len(f.getvalue().split('\n')) > 4)
-        f = StringIO.StringIO()
+        f = six.StringIO()
         stacktrace.print_stack(f=frame, limit=2, file=f)
         self.assertEqual(4, len(f.getvalue().strip().split('\n')))
         self.assertEndsWith(f.getvalue(), 'return sys._getframe()\n')
diff --git a/lib/lp/testing/__init__.py b/lib/lp/testing/__init__.py
index 5f91c86..1aa11ea 100644
--- a/lib/lp/testing/__init__.py
+++ b/lib/lp/testing/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2009-2018 Canonical Ltd.  This software is licensed under the
+# Copyright 2009-2019 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 from __future__ import absolute_import, print_function
@@ -124,7 +124,7 @@ from zope.security.proxy import (
     isinstance as zope_isinstance,
     removeSecurityProxy,
     )
-from zope.testing.testrunner.runner import TestResult as ZopeTestResult
+from zope.testrunner.runner import TestResult as ZopeTestResult
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.app.interfaces.security import IAuthorization
@@ -243,8 +243,8 @@ def reset_logging():
     logging._handlers.clear()
 
     # Reset the setup
-    from zope.testing.testrunner.runner import Runner
-    from zope.testing.testrunner.logsupport import Logging
+    from zope.testrunner.runner import Runner
+    from zope.testrunner.logsupport import Logging
     Logging(Runner()).global_setup()
     lp_sitecustomize.customize_logger()
 
diff --git a/setup.py b/setup.py
index 6859381..a2141cf 100644
--- a/setup.py
+++ b/setup.py
@@ -272,6 +272,7 @@ setup(
         'zope.tales',
         'zope.testbrowser',
         'zope.testing',
+        'zope.testrunner[subunit]',
         'zope.traversing',
         'zope.viewlet',  # only fixing a broken dependency
         'zope.vocabularyregistry',