diff --git a/testtools/deferredruntest.py b/testtools/deferredruntest.py index 8cf609d..52a6d98 100644 --- a/testtools/deferredruntest.py +++ b/testtools/deferredruntest.py @@ -1,536 +1,27 @@ -# Copyright (c) 2010-2016 testtools developers. See LICENSE for details. +# Copyright (c) 2016 testtools developers. See LICENSE for details. -"""Individual test case execution for tests that return Deferreds. - -Example:: - - class TwistedTests(testtools.TestCase): - - run_tests_with = AsynchronousDeferredRunTest - - def test_something(self): - # Wait for 5 seconds and then fire with 'Foo'. - d = Deferred() - reactor.callLater(5, lambda: d.callback('Foo')) - d.addCallback(self.assertEqual, 'Foo') - return d - -When ``test_something`` is run, ``AsynchronousDeferredRunTest`` will run the -reactor until ``d`` fires, and wait for all of its callbacks to be processed. -""" +"""Backwards compatibility for testtools.twistedsupport.""" __all__ = [ 'AsynchronousDeferredRunTest', 'AsynchronousDeferredRunTestForBrokenTwisted', 'SynchronousDeferredRunTest', 'assert_fails_with', - ] - -import warnings -import sys - -from fixtures import Fixture - -from testtools.compat import StringIO -from testtools.content import Content, text_content -from testtools.content_type import UTF8_TEXT -from testtools.runtest import RunTest, _raise_force_fail_error -from testtools._deferred import extract_result -from testtools._spinner import ( - NoResultError, - Spinner, - TimeoutError, - trap_unhandled_errors, - ) - -from twisted.internet import defer -try: - from twisted.logger import globalLogPublisher -except ImportError: - globalLogPublisher = None -from twisted.python import log -try: - from twisted.trial.unittest import _LogObserver -except ImportError: - from twisted.trial._synctest import _LogObserver - - -class _DeferredRunTest(RunTest): - """Base for tests that return Deferreds.""" - - def _got_user_failure(self, failure, tb_label='traceback'): - """We got a failure from user code.""" - return self._got_user_exception( - (failure.type, failure.value, failure.getTracebackObject()), - tb_label=tb_label) - - -class SynchronousDeferredRunTest(_DeferredRunTest): - """Runner for tests that return synchronous Deferreds. - - This runner doesn't touch the reactor at all. It assumes that tests return - Deferreds that have already fired. - """ - - def _run_user(self, function, *args): - d = defer.maybeDeferred(function, *args) - d.addErrback(self._got_user_failure) - result = extract_result(d) - return result - - -def _get_global_publisher_and_observers(): - """Return ``(log_publisher, observers)``. - - Twisted 15.2.0 changed the logging framework. This method will always - return a tuple of the global log publisher and all observers associated - with that publisher. - """ - if globalLogPublisher is not None: - # Twisted >= 15.2.0, with the new twisted.logger framework. - # log.theLogPublisher.observers will only contain legacy observers; - # we need to look at globalLogPublisher._observers, which contains - # both legacy and modern observers, and add and remove them via - # globalLogPublisher. However, we must still add and remove the - # observers we want to run with via log.theLogPublisher, because - # _LogObserver may consider old keys and require them to be mapped. - publisher = globalLogPublisher - return (publisher, list(publisher._observers)) - else: - publisher = log.theLogPublisher - return (publisher, list(publisher.observers)) - - -class _NoTwistedLogObservers(Fixture): - """Completely but temporarily remove all Twisted log observers.""" - - def _setUp(self): - publisher, real_observers = _get_global_publisher_and_observers() - for observer in reversed(real_observers): - publisher.removeObserver(observer) - self.addCleanup(publisher.addObserver, observer) - - -class _TwistedLogObservers(Fixture): - """Temporarily add Twisted log observers.""" - - def __init__(self, observers): - super(_TwistedLogObservers, self).__init__() - self._observers = observers - self._log_publisher = log.theLogPublisher - - def _setUp(self): - for observer in self._observers: - self._log_publisher.addObserver(observer) - self.addCleanup(self._log_publisher.removeObserver, observer) - - -class _ErrorObserver(Fixture): - """Capture errors logged while fixture is active.""" - - def __init__(self, error_observer): - super(_ErrorObserver, self).__init__() - self._error_observer = error_observer - - def _setUp(self): - self.useFixture(_TwistedLogObservers([self._error_observer.gotEvent])) - - def flush_logged_errors(self, *error_types): - """Clear errors of the given types from the logs. - - If no errors provided, clear all errors. - - :return: An iterable of errors removed from the logs. - """ - return self._error_observer.flushErrors(*error_types) - - -class CaptureTwistedLogs(Fixture): - """Capture all the Twisted logs and add them as a detail. - - Much of the time, you won't need to use this directly, as - :py:class:`AsynchronousDeferredRunTest` captures Twisted logs when the - ``store_twisted_logs`` is set to ``True`` (which it is by default). - - However, if you want to do custom processing of Twisted's logs, then this - class can be useful. - - For example:: - - class TwistedTests(TestCase): - run_tests_with( - partial(AsynchronousDeferredRunTest, store_twisted_logs=False)) - - def setUp(self): - super(TwistedTests, self).setUp() - twisted_logs = self.useFixture(CaptureTwistedLogs()) - # ... do something with twisted_logs ... - """ - - LOG_DETAIL_NAME = 'twisted-log' - - def _setUp(self): - logs = StringIO() - full_observer = log.FileLogObserver(logs) - self.useFixture(_TwistedLogObservers([full_observer.emit])) - self.addDetail(self.LOG_DETAIL_NAME, - Content(UTF8_TEXT, lambda: [logs.getvalue()])) - - -def run_with_log_observers(observers, function, *args, **kwargs): - """Run 'function' with the given Twisted log observers.""" - warnings.warn( - 'run_with_log_observers is deprecated since 1.8.2.', - DeprecationWarning, stacklevel=2) - with _NoTwistedLogObservers(): - with _TwistedLogObservers(observers): - return function(*args, **kwargs) - - -# Observer of the Twisted log that we install during tests. -# -# This is a global so that users can call flush_logged_errors errors in their -# test cases. -_log_observer = _LogObserver() - - -# XXX: Should really be in python-fixtures. -# See https://github.com/testing-cabal/fixtures/pull/22. -class _CompoundFixture(Fixture): - """A fixture that combines many fixtures.""" - - def __init__(self, fixtures): - super(_CompoundFixture, self).__init__() - self._fixtures = fixtures - - def _setUp(self): - for fixture in self._fixtures: - self.useFixture(fixture) - - -def flush_logged_errors(*error_types): - """Flush errors of the given types from the global Twisted log. - - Any errors logged during a test will be bubbled up to the test result, - marking the test as erroring. Use this function to declare that logged - errors were expected behavior. - - For example:: - - try: - 1/0 - except ZeroDivisionError: - log.err() - # Prevent logged ZeroDivisionError from failing the test. - flush_logged_errors(ZeroDivisionError) - - :param error_types: A variable argument list of exception types. - """ - # XXX: jml: I would like to deprecate this in favour of - # _ErrorObserver.flush_logged_errors so that I can avoid mutable global - # state. However, I don't know how to make the correct instance of - # _ErrorObserver.flush_logged_errors available to the end user. I also - # don't yet have a clear deprecation/migration path. - return _log_observer.flushErrors(*error_types) - - -class AsynchronousDeferredRunTest(_DeferredRunTest): - """Runner for tests that return Deferreds that fire asynchronously. - - Use this runner when you have tests that return Deferreds that will - only fire if the reactor is left to spin for a while. - """ - - def __init__(self, case, handlers=None, last_resort=None, reactor=None, - timeout=0.005, debug=False, suppress_twisted_logging=True, - store_twisted_logs=True): - """Construct an ``AsynchronousDeferredRunTest``. - - Please be sure to always use keyword syntax, not positional, as the - base class may add arguments in future - and for core code - compatibility with that we have to insert them before the local - parameters. - - :param TestCase case: The `TestCase` to run. - :param handlers: A list of exception handlers (ExceptionType, handler) - where 'handler' is a callable that takes a `TestCase`, a - ``testtools.TestResult`` and the exception raised. - :param last_resort: Handler to call before re-raising uncatchable - exceptions (those for which there is no handler). - :param reactor: The Twisted reactor to use. If not given, we use the - default reactor. - :param float timeout: The maximum time allowed for running a test. The - default is 0.005s. - :param debug: Whether or not to enable Twisted's debugging. Use this - to get information about unhandled Deferreds and left-over - DelayedCalls. Defaults to False. - :param bool suppress_twisted_logging: If True, then suppress Twisted's - default logging while the test is being run. Defaults to True. - :param bool store_twisted_logs: If True, then store the Twisted logs - that took place during the run as the 'twisted-log' detail. - Defaults to True. - """ - super(AsynchronousDeferredRunTest, self).__init__( - case, handlers, last_resort) - if reactor is None: - from twisted.internet import reactor - self._reactor = reactor - self._timeout = timeout - self._debug = debug - self._suppress_twisted_logging = suppress_twisted_logging - self._store_twisted_logs = store_twisted_logs - - @classmethod - def make_factory(cls, reactor=None, timeout=0.005, debug=False, - suppress_twisted_logging=True, store_twisted_logs=True): - """Make a factory that conforms to the RunTest factory interface. - - Example:: - - class SomeTests(TestCase): - # Timeout tests after two minutes. - run_tests_with = AsynchronousDeferredRunTest.make_factory( - timeout=120) - """ - # This is horrible, but it means that the return value of the method - # will be able to be assigned to a class variable *and* also be - # invoked directly. - class AsynchronousDeferredRunTestFactory: - def __call__(self, case, handlers=None, last_resort=None): - return cls( - case, handlers, last_resort, reactor, timeout, debug, - suppress_twisted_logging, store_twisted_logs, - ) - return AsynchronousDeferredRunTestFactory() - - @defer.deferredGenerator - def _run_cleanups(self): - """Run the cleanups on the test case. - - We expect that the cleanups on the test case can also return - asynchronous Deferreds. As such, we take the responsibility for - running the cleanups, rather than letting TestCase do it. - """ - while self.case._cleanups: - f, args, kwargs = self.case._cleanups.pop() - d = defer.maybeDeferred(f, *args, **kwargs) - thing = defer.waitForDeferred(d) - yield thing - try: - thing.getResult() - except Exception: - exc_info = sys.exc_info() - self.case._report_traceback(exc_info) - last_exception = exc_info[1] - yield last_exception - - def _make_spinner(self): - """Make the `Spinner` to be used to run the tests.""" - return Spinner(self._reactor, debug=self._debug) - - def _run_deferred(self): - """Run the test, assuming everything in it is Deferred-returning. - - This should return a Deferred that fires with True if the test was - successful and False if the test was not successful. It should *not* - call addSuccess on the result, because there's reactor clean up that - we needs to be done afterwards. - """ - fails = [] - - def fail_if_exception_caught(exception_caught): - if self.exception_caught == exception_caught: - fails.append(None) - - def clean_up(ignored=None): - """Run the cleanups.""" - d = self._run_cleanups() - - def clean_up_done(result): - if result is not None: - self._exceptions.append(result) - fails.append(None) - return d.addCallback(clean_up_done) - - def set_up_done(exception_caught): - """Set up is done, either clean up or run the test.""" - if self.exception_caught == exception_caught: - fails.append(None) - return clean_up() - else: - d = self._run_user(self.case._run_test_method, self.result) - d.addCallback(fail_if_exception_caught) - d.addBoth(tear_down) - return d - - def tear_down(ignored): - d = self._run_user(self.case._run_teardown, self.result) - d.addCallback(fail_if_exception_caught) - d.addBoth(clean_up) - return d - - def force_failure(ignored): - if getattr(self.case, 'force_failure', None): - d = self._run_user(_raise_force_fail_error) - d.addCallback(fails.append) - return d - - d = self._run_user(self.case._run_setup, self.result) - d.addCallback(set_up_done) - d.addBoth(force_failure) - d.addBoth(lambda ignored: len(fails) == 0) - return d - - def _log_user_exception(self, e): - """Raise 'e' and report it as a user exception.""" - try: - raise e - except e.__class__: - self._got_user_exception(sys.exc_info()) - - def _blocking_run_deferred(self, spinner): - try: - return trap_unhandled_errors( - spinner.run, self._timeout, self._run_deferred) - except NoResultError: - # We didn't get a result at all! This could be for any number of - # reasons, but most likely someone hit Ctrl-C during the test. - raise KeyboardInterrupt - except TimeoutError: - # The function took too long to run. - self._log_user_exception(TimeoutError(self.case, self._timeout)) - return False, [] - - def _get_log_fixture(self): - """Return the log fixture we're configured to use.""" - fixtures = [] - # TODO: Expose these fixtures and deprecate both of these options in - # favour of them. - if self._suppress_twisted_logging: - fixtures.append(_NoTwistedLogObservers()) - if self._store_twisted_logs: - fixtures.append(CaptureTwistedLogs()) - return _CompoundFixture(fixtures) - - def _run_core(self): - # XXX: Blatting over the namespace of the test case isn't a nice thing - # to do. Find a better way of communicating between runtest and test - # case. - self.case.reactor = self._reactor - spinner = self._make_spinner() - - # We can't just install these as fixtures on self.case, because we - # need the clean up to run even if the test times out. - # - # See https://bugs.launchpad.net/testtools/+bug/897196. - with self._get_log_fixture() as capture_logs: - for name, detail in capture_logs.getDetails().items(): - self.case.addDetail(name, detail) - with _ErrorObserver(_log_observer) as error_fixture: - successful, unhandled = self._blocking_run_deferred( - spinner) - for logged_error in error_fixture.flush_logged_errors(): - successful = False - self._got_user_failure( - logged_error, tb_label='logged-error') - - if unhandled: - successful = False - for debug_info in unhandled: - f = debug_info.failResult - info = debug_info._getDebugTracebacks() - if info: - self.case.addDetail( - 'unhandled-error-in-deferred-debug', - text_content(info)) - self._got_user_failure(f, 'unhandled-error-in-deferred') - - junk = spinner.clear_junk() - if junk: - successful = False - self._log_user_exception(UncleanReactorError(junk)) - - if successful: - self.result.addSuccess(self.case, details=self.case.getDetails()) - - def _run_user(self, function, *args): - """Run a user-supplied function. - - This just makes sure that it returns a Deferred, regardless of how the - user wrote it. - """ - d = defer.maybeDeferred(function, *args) - return d.addErrback(self._got_user_failure) - - -class AsynchronousDeferredRunTestForBrokenTwisted(AsynchronousDeferredRunTest): - """Test runner that works around Twisted brokenness re reactor junk. - - There are many APIs within Twisted itself where a Deferred fires but - leaves cleanup work scheduled for the reactor to do. Arguably, many of - these are bugs. This runner iterates the reactor event loop a number of - times after every test, in order to shake out these buggy-but-commonplace - events. - """ - - def _make_spinner(self): - spinner = super( - AsynchronousDeferredRunTestForBrokenTwisted, self)._make_spinner() - spinner._OBLIGATORY_REACTOR_ITERATIONS = 2 - return spinner - - -def assert_fails_with(d, *exc_types, **kwargs): - """Assert that ``d`` will fail with one of ``exc_types``. - - The normal way to use this is to return the result of - ``assert_fails_with`` from your unit test. - - Equivalent to Twisted's ``assertFailure``. - - :param Deferred d: A ``Deferred`` that is expected to fail. - :param exc_types: The exception types that the Deferred is expected to - fail with. - :param type failureException: An optional keyword argument. If provided, - will raise that exception instead of - ``testtools.TestCase.failureException``. - :return: A ``Deferred`` that will fail with an ``AssertionError`` if ``d`` - does not fail with one of the exception types. - """ - failureException = kwargs.pop('failureException', None) - if failureException is None: - # Avoid circular imports. - from testtools import TestCase - failureException = TestCase.failureException - expected_names = ", ".join(exc_type.__name__ for exc_type in exc_types) - - def got_success(result): - raise failureException( - "%s not raised (%r returned)" % (expected_names, result)) - - def got_failure(failure): - if failure.check(*exc_types): - return failure.value - raise failureException("%s raised instead of %s:\n %s" % ( - failure.type.__name__, expected_names, failure.getTraceback())) - return d.addCallbacks(got_success, got_failure) - - -class UncleanReactorError(Exception): - """Raised when the reactor has junk in it.""" - - def __init__(self, junk): - Exception.__init__( - self, - "The reactor still thinks it needs to do things. Close all " - "connections, kill all processes and make sure all delayed " - "calls have either fired or been cancelled:\n%s" - % ''.join(map(self._get_junk_info, junk))) - - def _get_junk_info(self, junk): - from twisted.internet.base import DelayedCall - if isinstance(junk, DelayedCall): - ret = str(junk) - else: - ret = repr(junk) - return ' %s\n' % (ret,) +] + +from .twistedsupport import ( + AsynchronousDeferredRunTest, + AsynchronousDeferredRunTestForBrokenTwisted, + SynchronousDeferredRunTest, + assert_fails_with, +) + +# Never explicitly exported but had public names: +from .twistedsupport import ( + CaptureTwistedLogs, + flush_logged_errors, +) +from .twistedsupport._runtest import ( + run_with_log_observers, + UncleanReactorError, +) diff --git a/testtools/tests/__init__.py b/testtools/tests/__init__.py index 355b6af..1a66564 100644 --- a/testtools/tests/__init__.py +++ b/testtools/tests/__init__.py @@ -10,20 +10,17 @@ import testscenarios def test_suite(): from testtools.tests import ( matchers, + twistedsupport, test_assert_that, test_compat, test_content, test_content_type, - test_deferred, - test_deferredmatchers, - test_deferredruntest, test_distutilscmd, test_fixturesupport, test_helpers, test_monkey, test_run, test_runtest, - test_spinner, test_tags, test_testcase, test_testresult, @@ -32,20 +29,17 @@ def test_suite(): ) modules = [ matchers, + twistedsupport, test_assert_that, test_compat, test_content, test_content_type, - test_deferred, - test_deferredmatchers, - test_deferredruntest, test_distutilscmd, test_fixturesupport, test_helpers, test_monkey, test_run, test_runtest, - test_spinner, test_tags, test_testcase, test_testresult, diff --git a/testtools/tests/twistedsupport/__init__.py b/testtools/tests/twistedsupport/__init__.py new file mode 100644 index 0000000..96e6ee1 --- /dev/null +++ b/testtools/tests/twistedsupport/__init__.py @@ -0,0 +1,20 @@ +# Copyright (c) testtools developers. See LICENSE for details. + +from unittest import TestSuite + + +def test_suite(): + from testtools.tests.twistedsupport import ( + test_deferred, + test_matchers, + test_runtest, + test_spinner, + ) + modules = [ + test_deferred, + test_matchers, + test_runtest, + test_spinner, + ] + suites = map(lambda x: x.test_suite(), modules) + return TestSuite(suites) diff --git a/testtools/tests/twistedsupport/_helpers.py b/testtools/tests/twistedsupport/_helpers.py new file mode 100644 index 0000000..3cf525b --- /dev/null +++ b/testtools/tests/twistedsupport/_helpers.py @@ -0,0 +1,18 @@ +# Copyright (c) 2010, 2016 testtools developers. See LICENSE for details. + +__all__ = [ + 'NeedsTwistedTestCase', +] + +from extras import try_import +from testtools import TestCase + +defer = try_import('twisted.internet.defer') + + +class NeedsTwistedTestCase(TestCase): + + def setUp(self): + super(NeedsTwistedTestCase, self).setUp() + if defer is None: + self.skipTest("Need Twisted to run") diff --git a/testtools/tests/test_deferred.py b/testtools/tests/twistedsupport/test_deferred.py similarity index 86% rename from testtools/tests/test_deferred.py rename to testtools/tests/twistedsupport/test_deferred.py index 23c7a32..308b415 100644 --- a/testtools/tests/test_deferred.py +++ b/testtools/tests/twistedsupport/test_deferred.py @@ -9,9 +9,13 @@ from testtools.matchers import ( MatchesException, Raises, ) -from testtools.tests.test_spinner import NeedsTwistedTestCase +from ._helpers import NeedsTwistedTestCase -from testtools._deferred import DeferredNotFired, extract_result + +DeferredNotFired = try_import( + 'testtools.twistedsupport._deferred.DeferredNotFired') +extract_result = try_import( + 'testtools.twistedsupport._deferred.extract_result') defer = try_import('twisted.internet.defer') Failure = try_import('twisted.python.failure.Failure') diff --git a/testtools/tests/test_deferredmatchers.py b/testtools/tests/twistedsupport/test_matchers.py similarity index 99% rename from testtools/tests/test_deferredmatchers.py rename to testtools/tests/twistedsupport/test_matchers.py index 577dca7..6ee0fda 100644 --- a/testtools/tests/test_deferredmatchers.py +++ b/testtools/tests/twistedsupport/test_matchers.py @@ -12,7 +12,7 @@ from testtools.matchers import ( Is, MatchesDict, ) -from testtools.tests.test_spinner import NeedsTwistedTestCase +from ._helpers import NeedsTwistedTestCase has_no_result = try_import('testtools.twistedsupport.has_no_result') diff --git a/testtools/tests/test_deferredruntest.py b/testtools/tests/twistedsupport/test_runtest.py similarity index 97% rename from testtools/tests/test_deferredruntest.py rename to testtools/tests/twistedsupport/test_runtest.py index 68e1291..fed5c65 100644 --- a/testtools/tests/test_deferredruntest.py +++ b/testtools/tests/twistedsupport/test_runtest.py @@ -12,7 +12,6 @@ from testtools import ( TestCase, TestResult, ) -from testtools._deferreddebug import DebugTwisted from testtools.matchers import ( AfterPreprocessing, Contains, @@ -34,22 +33,25 @@ from testtools.tests.helpers import ( AsText, MatchesEvents, ) -from testtools.tests.test_spinner import NeedsTwistedTestCase +from ._helpers import NeedsTwistedTestCase -assert_fails_with = try_import('testtools.deferredruntest.assert_fails_with') +DebugTwisted = try_import( + 'testtools.twistedsupport._deferreddebug.DebugTwisted') + +assert_fails_with = try_import('testtools.twistedsupport.assert_fails_with') AsynchronousDeferredRunTest = try_import( - 'testtools.deferredruntest.AsynchronousDeferredRunTest') + 'testtools.twistedsupport.AsynchronousDeferredRunTest') flush_logged_errors = try_import( - 'testtools.deferredruntest.flush_logged_errors') + 'testtools.twistedsupport.flush_logged_errors') SynchronousDeferredRunTest = try_import( - 'testtools.deferredruntest.SynchronousDeferredRunTest') + 'testtools.twistedsupport.SynchronousDeferredRunTest') defer = try_import('twisted.internet.defer') failure = try_import('twisted.python.failure') log = try_import('twisted.python.log') DelayedCall = try_import('twisted.internet.base.DelayedCall') _get_global_publisher_and_observers = try_import( - 'testtools.deferredruntest._get_global_publisher_and_observers') + 'testtools.twistedsupport._runtest._get_global_publisher_and_observers') class X(object): @@ -913,7 +915,7 @@ class TestNoTwistedLogObservers(NeedsTwistedTestCase): def test_nothing_logged(self): # Using _NoTwistedLogObservers means that nothing is logged to # Twisted. - from testtools.deferredruntest import _NoTwistedLogObservers + from testtools.twistedsupport._runtest import _NoTwistedLogObservers class SomeTest(TestCase): def test_something(self): @@ -925,7 +927,7 @@ class TestNoTwistedLogObservers(NeedsTwistedTestCase): def test_logging_restored(self): # _NoTwistedLogObservers restores the original log observers. - from testtools.deferredruntest import _NoTwistedLogObservers + from testtools.twistedsupport._runtest import _NoTwistedLogObservers class SomeTest(TestCase): def test_something(self): @@ -948,7 +950,7 @@ class TestTwistedLogObservers(NeedsTwistedTestCase): def test_logged_messages_go_to_observer(self): # Using _TwistedLogObservers means messages logged to Twisted go to # that observer while the fixture is active. - from testtools.deferredruntest import _TwistedLogObservers + from testtools.twistedsupport._runtest import _TwistedLogObservers messages = [] @@ -969,7 +971,7 @@ class TestErrorObserver(NeedsTwistedTestCase): def test_captures_errors(self): # _ErrorObserver stores all errors logged while it is active. - from testtools.deferredruntest import ( + from testtools.twistedsupport._runtest import ( _ErrorObserver, _LogObserver, _NoTwistedLogObservers) log_observer = _LogObserver() @@ -997,7 +999,7 @@ class TestCaptureTwistedLogs(NeedsTwistedTestCase): def test_captures_logs(self): # CaptureTwistedLogs stores all Twisted log messages as a detail. - from testtools.deferredruntest import CaptureTwistedLogs + from testtools.twistedsupport import CaptureTwistedLogs class SomeTest(TestCase): def test_something(self): diff --git a/testtools/tests/test_spinner.py b/testtools/tests/twistedsupport/test_spinner.py similarity index 97% rename from testtools/tests/test_spinner.py rename to testtools/tests/twistedsupport/test_spinner.py index a916380..62ace9f 100644 --- a/testtools/tests/test_spinner.py +++ b/testtools/tests/twistedsupport/test_spinner.py @@ -7,31 +7,22 @@ import signal from extras import try_import -from testtools import ( - skipIf, - TestCase, - ) +from testtools import skipIf from testtools.matchers import ( Equals, Is, MatchesException, Raises, ) +from ._helpers import NeedsTwistedTestCase -_spinner = try_import('testtools._spinner') + +_spinner = try_import('testtools.twistedsupport._spinner') defer = try_import('twisted.internet.defer') Failure = try_import('twisted.python.failure.Failure') -class NeedsTwistedTestCase(TestCase): - - def setUp(self): - super(NeedsTwistedTestCase, self).setUp() - if defer is None or Failure is None: - self.skipTest("Need Twisted to run") - - class TestNotReentrant(NeedsTwistedTestCase): def test_not_reentrant(self): diff --git a/testtools/twistedsupport/__init__.py b/testtools/twistedsupport/__init__.py index 98f7a5c..b80a7b7 100644 --- a/testtools/twistedsupport/__init__.py +++ b/testtools/twistedsupport/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) testtools developers. See LICENSE for details. +# Copyright (c) 2016 testtools developers. See LICENSE for details. """Support for testing code that uses Twisted.""" @@ -17,13 +17,13 @@ __all__ = [ 'flush_logged_errors', ] -from testtools._deferredmatchers import ( +from ._matchers import ( succeeded, failed, has_no_result, ) -from testtools.deferredruntest import ( +from ._runtest import ( AsynchronousDeferredRunTest, AsynchronousDeferredRunTestForBrokenTwisted, SynchronousDeferredRunTest, diff --git a/testtools/_deferred.py b/testtools/twistedsupport/_deferred.py similarity index 100% rename from testtools/_deferred.py rename to testtools/twistedsupport/_deferred.py diff --git a/testtools/_deferreddebug.py b/testtools/twistedsupport/_deferreddebug.py similarity index 100% rename from testtools/_deferreddebug.py rename to testtools/twistedsupport/_deferreddebug.py diff --git a/testtools/_deferredmatchers.py b/testtools/twistedsupport/_matchers.py similarity index 100% rename from testtools/_deferredmatchers.py rename to testtools/twistedsupport/_matchers.py diff --git a/testtools/twistedsupport/_runtest.py b/testtools/twistedsupport/_runtest.py new file mode 100644 index 0000000..251272e --- /dev/null +++ b/testtools/twistedsupport/_runtest.py @@ -0,0 +1,536 @@ +# Copyright (c) 2010-2016 testtools developers. See LICENSE for details. + +"""Individual test case execution for tests that return Deferreds. + +Example:: + + class TwistedTests(testtools.TestCase): + + run_tests_with = AsynchronousDeferredRunTest + + def test_something(self): + # Wait for 5 seconds and then fire with 'Foo'. + d = Deferred() + reactor.callLater(5, lambda: d.callback('Foo')) + d.addCallback(self.assertEqual, 'Foo') + return d + +When ``test_something`` is run, ``AsynchronousDeferredRunTest`` will run the +reactor until ``d`` fires, and wait for all of its callbacks to be processed. +""" + +__all__ = [ + 'AsynchronousDeferredRunTest', + 'AsynchronousDeferredRunTestForBrokenTwisted', + 'SynchronousDeferredRunTest', + 'assert_fails_with', + ] + +import warnings +import sys + +from fixtures import Fixture + +from testtools.compat import StringIO +from testtools.content import Content, text_content +from testtools.content_type import UTF8_TEXT +from testtools.runtest import RunTest, _raise_force_fail_error +from ._deferred import extract_result +from ._spinner import ( + NoResultError, + Spinner, + TimeoutError, + trap_unhandled_errors, + ) + +from twisted.internet import defer +try: + from twisted.logger import globalLogPublisher +except ImportError: + globalLogPublisher = None +from twisted.python import log +try: + from twisted.trial.unittest import _LogObserver +except ImportError: + from twisted.trial._synctest import _LogObserver + + +class _DeferredRunTest(RunTest): + """Base for tests that return Deferreds.""" + + def _got_user_failure(self, failure, tb_label='traceback'): + """We got a failure from user code.""" + return self._got_user_exception( + (failure.type, failure.value, failure.getTracebackObject()), + tb_label=tb_label) + + +class SynchronousDeferredRunTest(_DeferredRunTest): + """Runner for tests that return synchronous Deferreds. + + This runner doesn't touch the reactor at all. It assumes that tests return + Deferreds that have already fired. + """ + + def _run_user(self, function, *args): + d = defer.maybeDeferred(function, *args) + d.addErrback(self._got_user_failure) + result = extract_result(d) + return result + + +def _get_global_publisher_and_observers(): + """Return ``(log_publisher, observers)``. + + Twisted 15.2.0 changed the logging framework. This method will always + return a tuple of the global log publisher and all observers associated + with that publisher. + """ + if globalLogPublisher is not None: + # Twisted >= 15.2.0, with the new twisted.logger framework. + # log.theLogPublisher.observers will only contain legacy observers; + # we need to look at globalLogPublisher._observers, which contains + # both legacy and modern observers, and add and remove them via + # globalLogPublisher. However, we must still add and remove the + # observers we want to run with via log.theLogPublisher, because + # _LogObserver may consider old keys and require them to be mapped. + publisher = globalLogPublisher + return (publisher, list(publisher._observers)) + else: + publisher = log.theLogPublisher + return (publisher, list(publisher.observers)) + + +class _NoTwistedLogObservers(Fixture): + """Completely but temporarily remove all Twisted log observers.""" + + def _setUp(self): + publisher, real_observers = _get_global_publisher_and_observers() + for observer in reversed(real_observers): + publisher.removeObserver(observer) + self.addCleanup(publisher.addObserver, observer) + + +class _TwistedLogObservers(Fixture): + """Temporarily add Twisted log observers.""" + + def __init__(self, observers): + super(_TwistedLogObservers, self).__init__() + self._observers = observers + self._log_publisher = log.theLogPublisher + + def _setUp(self): + for observer in self._observers: + self._log_publisher.addObserver(observer) + self.addCleanup(self._log_publisher.removeObserver, observer) + + +class _ErrorObserver(Fixture): + """Capture errors logged while fixture is active.""" + + def __init__(self, error_observer): + super(_ErrorObserver, self).__init__() + self._error_observer = error_observer + + def _setUp(self): + self.useFixture(_TwistedLogObservers([self._error_observer.gotEvent])) + + def flush_logged_errors(self, *error_types): + """Clear errors of the given types from the logs. + + If no errors provided, clear all errors. + + :return: An iterable of errors removed from the logs. + """ + return self._error_observer.flushErrors(*error_types) + + +class CaptureTwistedLogs(Fixture): + """Capture all the Twisted logs and add them as a detail. + + Much of the time, you won't need to use this directly, as + :py:class:`AsynchronousDeferredRunTest` captures Twisted logs when the + ``store_twisted_logs`` is set to ``True`` (which it is by default). + + However, if you want to do custom processing of Twisted's logs, then this + class can be useful. + + For example:: + + class TwistedTests(TestCase): + run_tests_with( + partial(AsynchronousDeferredRunTest, store_twisted_logs=False)) + + def setUp(self): + super(TwistedTests, self).setUp() + twisted_logs = self.useFixture(CaptureTwistedLogs()) + # ... do something with twisted_logs ... + """ + + LOG_DETAIL_NAME = 'twisted-log' + + def _setUp(self): + logs = StringIO() + full_observer = log.FileLogObserver(logs) + self.useFixture(_TwistedLogObservers([full_observer.emit])) + self.addDetail(self.LOG_DETAIL_NAME, + Content(UTF8_TEXT, lambda: [logs.getvalue()])) + + +def run_with_log_observers(observers, function, *args, **kwargs): + """Run 'function' with the given Twisted log observers.""" + warnings.warn( + 'run_with_log_observers is deprecated since 1.8.2.', + DeprecationWarning, stacklevel=2) + with _NoTwistedLogObservers(): + with _TwistedLogObservers(observers): + return function(*args, **kwargs) + + +# Observer of the Twisted log that we install during tests. +# +# This is a global so that users can call flush_logged_errors errors in their +# test cases. +_log_observer = _LogObserver() + + +# XXX: Should really be in python-fixtures. +# See https://github.com/testing-cabal/fixtures/pull/22. +class _CompoundFixture(Fixture): + """A fixture that combines many fixtures.""" + + def __init__(self, fixtures): + super(_CompoundFixture, self).__init__() + self._fixtures = fixtures + + def _setUp(self): + for fixture in self._fixtures: + self.useFixture(fixture) + + +def flush_logged_errors(*error_types): + """Flush errors of the given types from the global Twisted log. + + Any errors logged during a test will be bubbled up to the test result, + marking the test as erroring. Use this function to declare that logged + errors were expected behavior. + + For example:: + + try: + 1/0 + except ZeroDivisionError: + log.err() + # Prevent logged ZeroDivisionError from failing the test. + flush_logged_errors(ZeroDivisionError) + + :param error_types: A variable argument list of exception types. + """ + # XXX: jml: I would like to deprecate this in favour of + # _ErrorObserver.flush_logged_errors so that I can avoid mutable global + # state. However, I don't know how to make the correct instance of + # _ErrorObserver.flush_logged_errors available to the end user. I also + # don't yet have a clear deprecation/migration path. + return _log_observer.flushErrors(*error_types) + + +class AsynchronousDeferredRunTest(_DeferredRunTest): + """Runner for tests that return Deferreds that fire asynchronously. + + Use this runner when you have tests that return Deferreds that will + only fire if the reactor is left to spin for a while. + """ + + def __init__(self, case, handlers=None, last_resort=None, reactor=None, + timeout=0.005, debug=False, suppress_twisted_logging=True, + store_twisted_logs=True): + """Construct an ``AsynchronousDeferredRunTest``. + + Please be sure to always use keyword syntax, not positional, as the + base class may add arguments in future - and for core code + compatibility with that we have to insert them before the local + parameters. + + :param TestCase case: The `TestCase` to run. + :param handlers: A list of exception handlers (ExceptionType, handler) + where 'handler' is a callable that takes a `TestCase`, a + ``testtools.TestResult`` and the exception raised. + :param last_resort: Handler to call before re-raising uncatchable + exceptions (those for which there is no handler). + :param reactor: The Twisted reactor to use. If not given, we use the + default reactor. + :param float timeout: The maximum time allowed for running a test. The + default is 0.005s. + :param debug: Whether or not to enable Twisted's debugging. Use this + to get information about unhandled Deferreds and left-over + DelayedCalls. Defaults to False. + :param bool suppress_twisted_logging: If True, then suppress Twisted's + default logging while the test is being run. Defaults to True. + :param bool store_twisted_logs: If True, then store the Twisted logs + that took place during the run as the 'twisted-log' detail. + Defaults to True. + """ + super(AsynchronousDeferredRunTest, self).__init__( + case, handlers, last_resort) + if reactor is None: + from twisted.internet import reactor + self._reactor = reactor + self._timeout = timeout + self._debug = debug + self._suppress_twisted_logging = suppress_twisted_logging + self._store_twisted_logs = store_twisted_logs + + @classmethod + def make_factory(cls, reactor=None, timeout=0.005, debug=False, + suppress_twisted_logging=True, store_twisted_logs=True): + """Make a factory that conforms to the RunTest factory interface. + + Example:: + + class SomeTests(TestCase): + # Timeout tests after two minutes. + run_tests_with = AsynchronousDeferredRunTest.make_factory( + timeout=120) + """ + # This is horrible, but it means that the return value of the method + # will be able to be assigned to a class variable *and* also be + # invoked directly. + class AsynchronousDeferredRunTestFactory: + def __call__(self, case, handlers=None, last_resort=None): + return cls( + case, handlers, last_resort, reactor, timeout, debug, + suppress_twisted_logging, store_twisted_logs, + ) + return AsynchronousDeferredRunTestFactory() + + @defer.deferredGenerator + def _run_cleanups(self): + """Run the cleanups on the test case. + + We expect that the cleanups on the test case can also return + asynchronous Deferreds. As such, we take the responsibility for + running the cleanups, rather than letting TestCase do it. + """ + while self.case._cleanups: + f, args, kwargs = self.case._cleanups.pop() + d = defer.maybeDeferred(f, *args, **kwargs) + thing = defer.waitForDeferred(d) + yield thing + try: + thing.getResult() + except Exception: + exc_info = sys.exc_info() + self.case._report_traceback(exc_info) + last_exception = exc_info[1] + yield last_exception + + def _make_spinner(self): + """Make the `Spinner` to be used to run the tests.""" + return Spinner(self._reactor, debug=self._debug) + + def _run_deferred(self): + """Run the test, assuming everything in it is Deferred-returning. + + This should return a Deferred that fires with True if the test was + successful and False if the test was not successful. It should *not* + call addSuccess on the result, because there's reactor clean up that + we needs to be done afterwards. + """ + fails = [] + + def fail_if_exception_caught(exception_caught): + if self.exception_caught == exception_caught: + fails.append(None) + + def clean_up(ignored=None): + """Run the cleanups.""" + d = self._run_cleanups() + + def clean_up_done(result): + if result is not None: + self._exceptions.append(result) + fails.append(None) + return d.addCallback(clean_up_done) + + def set_up_done(exception_caught): + """Set up is done, either clean up or run the test.""" + if self.exception_caught == exception_caught: + fails.append(None) + return clean_up() + else: + d = self._run_user(self.case._run_test_method, self.result) + d.addCallback(fail_if_exception_caught) + d.addBoth(tear_down) + return d + + def tear_down(ignored): + d = self._run_user(self.case._run_teardown, self.result) + d.addCallback(fail_if_exception_caught) + d.addBoth(clean_up) + return d + + def force_failure(ignored): + if getattr(self.case, 'force_failure', None): + d = self._run_user(_raise_force_fail_error) + d.addCallback(fails.append) + return d + + d = self._run_user(self.case._run_setup, self.result) + d.addCallback(set_up_done) + d.addBoth(force_failure) + d.addBoth(lambda ignored: len(fails) == 0) + return d + + def _log_user_exception(self, e): + """Raise 'e' and report it as a user exception.""" + try: + raise e + except e.__class__: + self._got_user_exception(sys.exc_info()) + + def _blocking_run_deferred(self, spinner): + try: + return trap_unhandled_errors( + spinner.run, self._timeout, self._run_deferred) + except NoResultError: + # We didn't get a result at all! This could be for any number of + # reasons, but most likely someone hit Ctrl-C during the test. + raise KeyboardInterrupt + except TimeoutError: + # The function took too long to run. + self._log_user_exception(TimeoutError(self.case, self._timeout)) + return False, [] + + def _get_log_fixture(self): + """Return the log fixture we're configured to use.""" + fixtures = [] + # TODO: Expose these fixtures and deprecate both of these options in + # favour of them. + if self._suppress_twisted_logging: + fixtures.append(_NoTwistedLogObservers()) + if self._store_twisted_logs: + fixtures.append(CaptureTwistedLogs()) + return _CompoundFixture(fixtures) + + def _run_core(self): + # XXX: Blatting over the namespace of the test case isn't a nice thing + # to do. Find a better way of communicating between runtest and test + # case. + self.case.reactor = self._reactor + spinner = self._make_spinner() + + # We can't just install these as fixtures on self.case, because we + # need the clean up to run even if the test times out. + # + # See https://bugs.launchpad.net/testtools/+bug/897196. + with self._get_log_fixture() as capture_logs: + for name, detail in capture_logs.getDetails().items(): + self.case.addDetail(name, detail) + with _ErrorObserver(_log_observer) as error_fixture: + successful, unhandled = self._blocking_run_deferred( + spinner) + for logged_error in error_fixture.flush_logged_errors(): + successful = False + self._got_user_failure( + logged_error, tb_label='logged-error') + + if unhandled: + successful = False + for debug_info in unhandled: + f = debug_info.failResult + info = debug_info._getDebugTracebacks() + if info: + self.case.addDetail( + 'unhandled-error-in-deferred-debug', + text_content(info)) + self._got_user_failure(f, 'unhandled-error-in-deferred') + + junk = spinner.clear_junk() + if junk: + successful = False + self._log_user_exception(UncleanReactorError(junk)) + + if successful: + self.result.addSuccess(self.case, details=self.case.getDetails()) + + def _run_user(self, function, *args): + """Run a user-supplied function. + + This just makes sure that it returns a Deferred, regardless of how the + user wrote it. + """ + d = defer.maybeDeferred(function, *args) + return d.addErrback(self._got_user_failure) + + +class AsynchronousDeferredRunTestForBrokenTwisted(AsynchronousDeferredRunTest): + """Test runner that works around Twisted brokenness re reactor junk. + + There are many APIs within Twisted itself where a Deferred fires but + leaves cleanup work scheduled for the reactor to do. Arguably, many of + these are bugs. This runner iterates the reactor event loop a number of + times after every test, in order to shake out these buggy-but-commonplace + events. + """ + + def _make_spinner(self): + spinner = super( + AsynchronousDeferredRunTestForBrokenTwisted, self)._make_spinner() + spinner._OBLIGATORY_REACTOR_ITERATIONS = 2 + return spinner + + +def assert_fails_with(d, *exc_types, **kwargs): + """Assert that ``d`` will fail with one of ``exc_types``. + + The normal way to use this is to return the result of + ``assert_fails_with`` from your unit test. + + Equivalent to Twisted's ``assertFailure``. + + :param Deferred d: A ``Deferred`` that is expected to fail. + :param exc_types: The exception types that the Deferred is expected to + fail with. + :param type failureException: An optional keyword argument. If provided, + will raise that exception instead of + ``testtools.TestCase.failureException``. + :return: A ``Deferred`` that will fail with an ``AssertionError`` if ``d`` + does not fail with one of the exception types. + """ + failureException = kwargs.pop('failureException', None) + if failureException is None: + # Avoid circular imports. + from testtools import TestCase + failureException = TestCase.failureException + expected_names = ", ".join(exc_type.__name__ for exc_type in exc_types) + + def got_success(result): + raise failureException( + "%s not raised (%r returned)" % (expected_names, result)) + + def got_failure(failure): + if failure.check(*exc_types): + return failure.value + raise failureException("%s raised instead of %s:\n %s" % ( + failure.type.__name__, expected_names, failure.getTraceback())) + return d.addCallbacks(got_success, got_failure) + + +class UncleanReactorError(Exception): + """Raised when the reactor has junk in it.""" + + def __init__(self, junk): + Exception.__init__( + self, + "The reactor still thinks it needs to do things. Close all " + "connections, kill all processes and make sure all delayed " + "calls have either fired or been cancelled:\n%s" + % ''.join(map(self._get_junk_info, junk))) + + def _get_junk_info(self, junk): + from twisted.internet.base import DelayedCall + if isinstance(junk, DelayedCall): + ret = str(junk) + else: + ret = repr(junk) + return ' %s\n' % (ret,) diff --git a/testtools/_spinner.py b/testtools/twistedsupport/_spinner.py similarity index 99% rename from testtools/_spinner.py rename to testtools/twistedsupport/_spinner.py index 9205a03..0bf05aa 100644 --- a/testtools/_spinner.py +++ b/testtools/twistedsupport/_spinner.py @@ -19,7 +19,7 @@ __all__ = [ from fixtures import Fixture import signal -from testtools._deferreddebug import DebugTwisted +from ._deferreddebug import DebugTwisted from twisted.internet import defer from twisted.internet.interfaces import IReactorThreads