From cc2c7448d10c8f9fb83dc506ce04aaaab8e38269 Mon Sep 17 00:00:00 2001 From: Robert Collins Date: Tue, 28 Aug 2012 23:58:16 +1200 Subject: * Factor out new ``CallMany`` class to isolate the cleanup logic. (Robert Collins) --- NEWS | 3 ++ lib/fixtures/callmany.py | 100 ++++++++++++++++++++++++++++++++++++ lib/fixtures/fixture.py | 39 ++++++-------- lib/fixtures/tests/__init__.py | 1 + lib/fixtures/tests/test_callmany.py | 68 ++++++++++++++++++++++++ lib/fixtures/tests/test_fixture.py | 2 + 6 files changed, 189 insertions(+), 24 deletions(-) create mode 100644 lib/fixtures/callmany.py create mode 100644 lib/fixtures/tests/test_callmany.py diff --git a/NEWS b/NEWS index a994487..e132091 100644 --- a/NEWS +++ b/NEWS @@ -11,6 +11,9 @@ CHANGES: * Add ``join`` method to ``TempDir`` to more readily get paths relative to a temporary directory. (Jonathan Lange) +* Factor out new ``CallMany`` class to isolate the cleanup logic. + (Robert Collins) + 0.3.9 ~~~~~ diff --git a/lib/fixtures/callmany.py b/lib/fixtures/callmany.py new file mode 100644 index 0000000..23580cb --- /dev/null +++ b/lib/fixtures/callmany.py @@ -0,0 +1,100 @@ +# fixtures: Fixtures with cleanups for testing and convenience. +# +# Copyright (c) 2010, Robert Collins +# +# Licensed under either the Apache License, Version 2.0 or the BSD 3-clause +# license at the users choice. A copy of both licenses are available in the +# project source as Apache-2.0 and BSD. You may not use this file except in +# compliance with one of these two licences. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under these licenses is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# license you chose for the specific language governing permissions and +# limitations under that license. + +__all__ = [ + 'CallMany', + ] + +import sys + +from testtools.compat import ( + reraise, + ) +from testtools.helpers import try_import + + +class MultipleExceptions(Exception): + """Report multiple exc_info tuples in self.args.""" + +MultipleExceptions = try_import( + "testtools.MultipleExceptions", MultipleExceptions) + + +class CallMany(object): + """A stack of functions which will all be called on __call__. + + CallMany also acts as a context manager for convenience. + + Functions are called in last pushed first executed order. + + This is used by Fixture to manage its addCleanup feature. + """ + + def __init__(self): + self._cleanups = [] + + def push(self, cleanup, *args, **kwargs): + """Add a function to be called from __call__. + + On __call__ all functions are called - see __call__ for details on how + multiple exceptions are handled. + + :param cleanup: A callable to call during cleanUp. + :param *args: Positional args for cleanup. + :param kwargs: Keyword args for cleanup. + :return: None + """ + self._cleanups.append((cleanup, args, kwargs)) + + def __call__(self, raise_errors=True): + """Run all the registered functions. + + :param raise_errors: Deprecated parameter from before testtools gained + MultipleExceptions. raise_errors defaults to True. When True + if exception(s) are raised while running functions, they are + re-raised after all the functions have run. If multiple exceptions + are raised, they are all wrapped into a MultipleExceptions object, + and that is raised. + Thus, to cach a specific exception from a function run by __call__, + you need to catch both the exception and MultipleExceptions, and + then check within a MultipleExceptions instance for an occurance of + the type you wish to catch. + :return: Either None or a list of the exc_info() for each exception + that occured if raise_errors was False. + """ + cleanups = reversed(self._cleanups) + self._cleanups = [] + result = [] + for cleanup, args, kwargs in cleanups: + try: + cleanup(*args, **kwargs) + except Exception: + result.append(sys.exc_info()) + if result and raise_errors: + if 1 == len(result): + error = result[0] + reraise(error[0], error[1], error[2]) + else: + raise MultipleExceptions(*result) + if not raise_errors: + return result + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self() + return False # propogate exceptions from the with body. + diff --git a/lib/fixtures/fixture.py b/lib/fixtures/fixture.py index 3573683..6c3422f 100644 --- a/lib/fixtures/fixture.py +++ b/lib/fixtures/fixture.py @@ -29,11 +29,11 @@ from testtools.compat import ( ) from testtools.helpers import try_import -class MultipleExceptions(Exception): - """Report multiple exc_info tuples in self.args.""" - -MultipleExceptions = try_import( - "testtools.MultipleExceptions", MultipleExceptions) +from callmany import ( + CallMany, + # Deprecated, imported for compatibility. + MultipleExceptions, + ) gather_details = try_import("testtools.testcase.gather_details") @@ -74,7 +74,7 @@ class Fixture(object): :param kwargs: Keyword args for cleanup. :return: None """ - self._cleanups.append((cleanup, args, kwargs)) + self._cleanups.push(cleanup, *args, **kwargs) def addDetail(self, name, content_object): """Add a detail to the Fixture. @@ -108,22 +108,10 @@ class Fixture(object): :return: A list of the exc_info() for each exception that occured if raise_first was False """ - cleanups = reversed(self._cleanups) - self._clear_cleanups() - result = [] - for cleanup, args, kwargs in cleanups: - try: - cleanup(*args, **kwargs) - except Exception: - result.append(sys.exc_info()) - if result and raise_first: - if 1 == len(result): - error = result[0] - reraise(error[0], error[1], error[2]) - else: - raise MultipleExceptions(*result) - if not raise_first: - return result + try: + return self._cleanups(raise_errors=raise_first) + finally: + self._clear_cleanups() def _clear_cleanups(self): """Clean the cleanup queue without running them. @@ -134,7 +122,7 @@ class Fixture(object): This also clears the details dict. """ - self._cleanups = [] + self._cleanups = CallMany() self._details = {} self._detail_sources = [] @@ -143,7 +131,10 @@ class Fixture(object): return self def __exit__(self, exc_type, exc_val, exc_tb): - self.cleanUp() + try: + self._cleanups() + finally: + self._clear_cleanups() return False # propogate exceptions from the with body. def getDetails(self): diff --git a/lib/fixtures/tests/__init__.py b/lib/fixtures/tests/__init__.py index 1274e07..7ce9e67 100644 --- a/lib/fixtures/tests/__init__.py +++ b/lib/fixtures/tests/__init__.py @@ -28,6 +28,7 @@ def test_suite(): def load_tests(loader, standard_tests, pattern): test_modules = [ + 'callmany', 'fixture', 'testcase', ] diff --git a/lib/fixtures/tests/test_callmany.py b/lib/fixtures/tests/test_callmany.py new file mode 100644 index 0000000..2bf28da --- /dev/null +++ b/lib/fixtures/tests/test_callmany.py @@ -0,0 +1,68 @@ +# fixtures: Fixtures with cleanups for testing and convenience. +# +# Copyright (c) 2010, Robert Collins +# +# Licensed under either the Apache License, Version 2.0 or the BSD 3-clause +# license at the users choice. A copy of both licenses are available in the +# project source as Apache-2.0 and BSD. You may not use this file except in +# compliance with one of these two licences. +# +# Unless required by applicable law or agreed to in writing, software +# distributed under these licenses is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# license you chose for the specific language governing permissions and +# limitations under that license. + +import types + +import testtools + +from fixtures.callmany import CallMany + + +class TestCallMany(testtools.TestCase): + + def test__call__raise_errors_false_callsall_returns_exceptions(self): + calls = [] + def raise_exception1(): + calls.append('1') + raise Exception('woo') + def raise_exception2(): + calls.append('2') + raise Exception('woo') + call = CallMany() + call.push(raise_exception2) + call.push(raise_exception1) + exceptions = call(raise_errors=False) + self.assertEqual(['1', '2'], calls) + # There should be two exceptions + self.assertEqual(2, len(exceptions)) + # They should be a sys.exc_info tuple. + self.assertEqual(3, len(exceptions[0])) + type, value, tb = exceptions[0] + self.assertEqual(Exception, type) + self.assertIsInstance(value, Exception) + self.assertEqual(('woo',), value.args) + self.assertIsInstance(tb, types.TracebackType) + + def test_exit_propogates_exceptions(self): + call = CallMany() + call.__enter__() + self.assertEqual(False, call.__exit__(None, None, None)) + + def test_exit_runs_all_raises_first_exception(self): + calls = [] + def raise_exception1(): + calls.append('1') + raise Exception('woo') + def raise_exception2(): + calls.append('2') + raise Exception('hoo') + call = CallMany() + call.push(raise_exception2) + call.push(raise_exception1) + call.__enter__() + exc = self.assertRaises(Exception, call.__exit__, None, None, None) + self.assertEqual(('woo',), exc.args[0][1].args) + self.assertEqual(('hoo',), exc.args[1][1].args) + self.assertEqual(['1', '2'], calls) diff --git a/lib/fixtures/tests/test_fixture.py b/lib/fixtures/tests/test_fixture.py index d1343e3..74e6ad0 100644 --- a/lib/fixtures/tests/test_fixture.py +++ b/lib/fixtures/tests/test_fixture.py @@ -28,6 +28,8 @@ require_gather_details = skipIf(gather_details is None, "gather_details() is not available.") +# Note: the cleanup related tests are strictly speaking redundant, IFF they are +# replaced with contract tests for correct use of CallMany. class TestFixture(testtools.TestCase): def test_resetCallsSetUpCleanUp(self): -- cgit v1.2.1