diff --git a/traits/observers/_exception_handling.py b/traits/observers/_exception_handling.py new file mode 100644 index 000000000..b8b0012ac --- /dev/null +++ b/traits/observers/_exception_handling.py @@ -0,0 +1,123 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +# This module provides the push_exception_handler and pop_exception_handler +# for the observers. + +import logging +import sys + + +class ObserverExceptionHandler: + """ State for an exception handler. + + Parameters + ---------- + handler : callable(event) or None + A callable to handle an event, in the context of + an exception. If None, the exceptions will be logged. + reraise_exceptions : boolean + Whether to reraise the exception. + """ + + def __init__(self, handler, reraise_exceptions): + self.handler = handler if handler is not None else self._log_exception + self.reraise_exceptions = reraise_exceptions + self.logger = None + + def _log_exception(self, event): + """ A handler that logs the exception with the given event. + + Parameters + ---------- + event : object + An event object emitted by the notification. + """ + if self.logger is None: + self.logger = logging.getLogger("traits") + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(message)s")) + self.logger.addHandler(handler) + self.logger.setLevel(logging.ERROR) + + self.logger.exception( + "Exception occurred in traits notification handler " + "for event object: %r", + event, + ) + + +class ObserverExceptionHandlerStack: + """ A stack of exception handlers. + + Parameters + ---------- + handlers : list of ObserverExceptionHandler + The last item is the current handler. + """ + + def __init__(self): + self.handlers = [] + + def push_exception_handler( + self, handler=None, reraise_exceptions=False): + """ Push a new exception handler into the stack. Making it the + current exception handler. + + Parameters + ---------- + handler : callable(event) or None + A callable to handle an event, in the context of + an exception. If None, the exceptions will be logged. + reraise_exceptions : boolean + Whether to reraise the exception. + """ + self.handlers.append( + ObserverExceptionHandler( + handler=handler, reraise_exceptions=reraise_exceptions, + ) + ) + + def pop_exception_handler(self): + """ Pop the current exception handler from the stack. + + Raises + ------ + IndexError + If there are no handlers to pop. + """ + return self.handlers.pop() + + def handle_exception(self, event): + """ Handles a traits notification exception using the handler last pushed. + + Parameters + ---------- + event : object + An event object emitted by the notification. + """ + _, excp, _ = sys.exc_info() + try: + handler_state = self.handlers[-1] + except IndexError: + handler_state = ObserverExceptionHandler( + handler=None, + reraise_exceptions=False, + ) + + handler_state.handler(event) + if handler_state.reraise_exceptions: + raise excp + + +_exception_handler_stack = ObserverExceptionHandlerStack() +push_exception_handler = _exception_handler_stack.push_exception_handler +pop_exception_handler = _exception_handler_stack.pop_exception_handler +handle_exception = _exception_handler_stack.handle_exception diff --git a/traits/observers/_exceptions.py b/traits/observers/_exceptions.py new file mode 100644 index 000000000..1770f5685 --- /dev/null +++ b/traits/observers/_exceptions.py @@ -0,0 +1,14 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + + +class NotifierNotFound(Exception): + """ Raised when a notifier cannot be found.""" + pass diff --git a/traits/observers/_i_observable.py b/traits/observers/_i_observable.py new file mode 100644 index 000000000..ca83cb40c --- /dev/null +++ b/traits/observers/_i_observable.py @@ -0,0 +1,29 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + + +class IObservable: + """ Interface for objects that can emit notifications for the observer + system. + """ + + def _notifiers(self, force_create): + """ Return a list of callables where each callable is a notifier. + The list is expected to be mutated for contributing or removing + notifiers from the object. + + Parameters + ---------- + force_create: boolean + It is added for compatibility with CTrait. + It should not be used otherwise. + """ + raise NotImplementedError( + "Observable object must implement _notifiers") diff --git a/traits/observers/_trait_event_notifier.py b/traits/observers/_trait_event_notifier.py new file mode 100644 index 000000000..e660cd0b3 --- /dev/null +++ b/traits/observers/_trait_event_notifier.py @@ -0,0 +1,219 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +from functools import partial +import types +import weakref + +from traits.observers._exception_handling import handle_exception +from traits.observers._exceptions import NotifierNotFound + + +class TraitEventNotifier: + """ Wrapper for invoking user's handler for a trait change event. + + An instance of ``TraitEventNotifier`` is a callable to be contributed + to an instance of ``IObserverable``, e.g. ``CTrait``, ``TraitList`` etc., + such that it will be called when an observerable emits notificaitons for + changes. The call signature is defined by the observable object + and may vary. It is the responsibility of the ``event_factory`` to adapt + the varying call signatures and create an event object to be given + to the user's handler. + + A ``TraitEventNotifier`` keeps a reference count in order to address + situations where a same object is repeated inside a container + and one would not want to fire the same change handler multiple times + (see enthought/traits#237). For that purpose, a notifier keeps track of + the ``HasTraits`` instance (called ``target``) on which the user applies + the observers, keeps a reference count internally, and it also needs to + determine whether another notifier refers to the same change handler and + ``HasTraits`` instance. + + Since there is only one reference count associated with a notifier, + each notifier is expected to be added to only one observable. + + Parameters + ---------- + handler : callable(event) + The user's handler to receive the change event. + The event object is returned by the ``event_factory``. + If the handler is an instance method, then a weak reference is + created for the method. If the instance is garbage collected, + the notifier will be muted. + target : object + An object for defining the context of the notifier. + A weak reference is created for the target. + If the target is garbage collected, the notifier will be muted. + This target is typically an instance of ``HasTraits`` and will be + seen by the user as the "owner" of the change handler. + This is also used for distinguishing one notifier from another + notifier wrapping the same handler. + event_factory : callable(*args, **kwargs) -> object + A factory function for creating the event object to be sent to + the handler. The call signature must be compatible with the + call signature expected by the observable this notifier is used + with. e.g. for CTrait, the call signature will be + ``(object, name, old, new)``. + prevent_event : callable(event) -> boolean + A callable for controlling whether the user handler should be + invoked. It receives the event created by the event factory and + returns true if the event should be prevented, false if the event + should be fired. + dispatcher : callable(handler, event) + A callable for dispatching the handler, e.g. on a different + thread or on a GUI event loop. ``event`` is the object + created by the event factory. + + Raises + ------ + ValueError + If the handler given is not a callable. + """ + + def __init__( + self, *, handler, target, + event_factory, prevent_event, dispatcher): + + if not callable(handler): + raise ValueError( + "handler must be a callable, got {!r}".format(handler)) + + # This is such that the notifier does not prevent + # the target from being garbage collected. + self.target = weakref.ref(target) + + if isinstance(handler, types.MethodType): + self.handler = weakref.WeakMethod(handler) + else: + self.handler = partial(_return, value=handler) + self.dispatcher = dispatcher + self.event_factory = event_factory + self.prevent_event = prevent_event + # Reference count to avoid adding multiple equivalent notifiers + # to the same observable. + self._ref_count = 0 + + def __call__(self, *args, **kwargs): + """ Called by observables. The call signature will vary and will be + handled by the event factory. + """ + if self.target() is None: + # target is deleted. The notifier is disabled. + return + + # Hold onto the reference while invoking the handler + handler = self.handler() + + if handler is None: + # The instance method is deleted. The notifier is disabled. + return + + event = self.event_factory(*args, **kwargs) + if self.prevent_event(event): + return + try: + self.dispatcher(handler, event=event) + except Exception: + handle_exception(event) + + def add_to(self, observable): + """ Add this notifier to an observable object. + + If an equivalent notifier exists, the existing notifier's reference + count is bumped. Hence this method is not idempotent. + N number of calls to this ``add_to`` must be matched by N calls to the + ``remove_from`` method in order to completely remove a notifier from + an observable. + + Parameters + ---------- + observable : IObservable + An object for adding this notifier to. + + Raises + ------ + RuntimeError + If the internal reference count is not zero and an equivalent + notifier is not found in the observable. + """ + notifiers = observable._notifiers(True) + for other in notifiers: + if self.equals(other): + other._ref_count += 1 + break + else: + # It is not a current use case to share a notifier with multiple + # observables. Using a single reference count will tie the lifetime + # of the notifier to multiple objects. + if self._ref_count != 0: + raise RuntimeError( + "Sharing notifiers across observables is unexpected." + ) + notifiers.append(self) + self._ref_count += 1 + + def remove_from(self, observable): + """ Remove this notifier from an observable object. + + If an equivalent notifier exists, the existing notifier's reference + count is decremented and the notifier is only removed if + the count is reduced to zero. + + Parameters + ---------- + observable : IObservable + An object for removing this notifier from. + + Raises + ------ + RuntimeError + If the reference count becomes negative unexpectedly. + NotifierNotFound + If the notifier is not found. + """ + notifiers = observable._notifiers(True) + for other in notifiers[:]: + if self.equals(other): + if other._ref_count == 1: + notifiers.remove(other) + other._ref_count -= 1 + if other._ref_count < 0: + raise RuntimeError( + "Reference count is negative. " + "Race condition?" + ) + break + else: + raise NotifierNotFound("Notifier not found.") + + def equals(self, other): + """ Return true if the other notifier is equivalent to this one. + + Parameters + ---------- + other : any + + Returns + ------- + boolean + """ + if other is self: + return True + if type(other) is not type(self): + return False + return ( + self.handler() == other.handler() + and self.target() is other.target() + and self.dispatcher == other.dispatcher + ) + + +def _return(value): + return value diff --git a/traits/observers/tests/test_exception_handling.py b/traits/observers/tests/test_exception_handling.py new file mode 100644 index 000000000..f09b68246 --- /dev/null +++ b/traits/observers/tests/test_exception_handling.py @@ -0,0 +1,89 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! +""" +Test the push_exception_handler and pop_exception_handler for the observers +""" +import io +import unittest +from unittest import mock + +from traits.observers._exception_handling import ( + ObserverExceptionHandlerStack, +) + + +class TestExceptionHandling(unittest.TestCase): + + def test_default_logging(self): + stack = ObserverExceptionHandlerStack() + + with mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: + try: + raise ZeroDivisionError() + except Exception: + stack.handle_exception("Event") + + content = stderr.getvalue() + self.assertIn( + "Exception occurred in traits notification handler for " + "event object: {!r}".format("Event"), + content, + ) + + def test_push_exception_handler(self): + # Test pushing an exception handler + # with the default logging handler and reraise_exceptions set to True. + + stack = ObserverExceptionHandlerStack() + + stack.push_exception_handler(reraise_exceptions=True) + + with mock.patch("sys.stderr", new_callable=io.StringIO) as stderr, \ + self.assertRaises(ZeroDivisionError): + + try: + raise ZeroDivisionError() + except Exception: + stack.handle_exception("Event") + + content = stderr.getvalue() + self.assertIn("ZeroDivisionError", content) + + def test_push_exception_handler_collect_events(self): + + events = [] + + def handler(event): + events.append(event) + + stack = ObserverExceptionHandlerStack() + stack.push_exception_handler(handler=handler) + + try: + raise ZeroDivisionError() + except Exception: + stack.handle_exception("Event") + + self.assertEqual(events, ["Event"]) + + def test_pop_exception_handler(self): + + stack = ObserverExceptionHandlerStack() + + stack.push_exception_handler(reraise_exceptions=True) + stack.pop_exception_handler() + + # This should not raise as we fall back to the default + + with mock.patch("sys.stderr"): + try: + raise ZeroDivisionError() + except Exception: + stack.handle_exception("Event") diff --git a/traits/observers/tests/test_trait_event_notifier.py b/traits/observers/tests/test_trait_event_notifier.py new file mode 100644 index 000000000..b531bad04 --- /dev/null +++ b/traits/observers/tests/test_trait_event_notifier.py @@ -0,0 +1,543 @@ +# (C) Copyright 2005-2020 Enthought, Inc., Austin, TX +# All rights reserved. +# +# This software is provided without warranty under the terms of the BSD +# license included in LICENSE.txt and may be redistributed only under +# the conditions described in the aforementioned license. The license +# is also available online at http://www.enthought.com/licenses/BSD.txt +# +# Thanks for using Enthought open source! + +import io +import unittest +from unittest import mock +import weakref + +from traits.observers._exception_handling import ( + pop_exception_handler, + push_exception_handler, +) +from traits.observers._exceptions import NotifierNotFound +from traits.observers._trait_event_notifier import TraitEventNotifier + + +def dispatch_here(function, event): + """ Dispatcher that let the function call through.""" + function(event) + + +def not_prevent_event(event): + """ An implementation of prevent_event that does not prevent + any event from being propagated. + """ + return False + + +class DummyObservable: + """ Dummy implementation of IObservable for testing purposes. + """ + + def __init__(self): + self.notifiers = [] + + def _notifiers(self, force_create): + return self.notifiers + + def handler(self, event): + pass + + +# Dummy target object that is not garbage collected while the tests are run. +_DUMMY_TARGET = mock.Mock() + + +def create_notifier(**kwargs): + """ Convenient function for creating an instance of TraitEventNotifier + for testing purposes. + """ + values = dict( + handler=mock.Mock(), + target=_DUMMY_TARGET, + event_factory=mock.Mock(), + prevent_event=not_prevent_event, + dispatcher=dispatch_here, + ) + values.update(kwargs) + return TraitEventNotifier(**values) + + +class TestTraitEventNotifierCall(unittest.TestCase): + """ Test calling an instance of TraitEventNotifier. """ + + def setUp(self): + push_exception_handler(reraise_exceptions=True) + self.addCleanup(pop_exception_handler) + + def tearDown(self): + pass + + def test_init_and_call(self): + + handler = mock.Mock() + + def event_factory(*args, **kwargs): + return "Event" + + notifier = create_notifier( + handler=handler, + event_factory=event_factory, + ) + + # when + notifier(a=1, b=2) + + # then + self.assertEqual(handler.call_count, 1) + (args, _), = handler.call_args_list + self.assertEqual(args, ("Event", )) + + def test_alternative_dispatcher(self): + # Test the dispatch is used + events = [] + + def dispatcher(handler, event): + events.append(event) + + def event_factory(*args, **kwargs): + return "Event" + + notifier = create_notifier( + dispatcher=dispatcher, + event_factory=event_factory, + ) + + # when + notifier(a=1, b=2) + + # then + self.assertEqual(events, ["Event"]) + + def test_prevent_event_is_used(self): + # Test prevent_event can stop an event from being dispatched. + + def prevent_event(event): + return True + + handler = mock.Mock() + notifier = create_notifier( + handler=handler, + prevent_event=prevent_event, + ) + + # when + notifier(a=1, b=2) + + # then + handler.assert_not_called() + + def test_init_check_handler_is_callable_early(self): + # Early sanity check to capture misuse + + not_a_callable = None + with self.assertRaises(ValueError) as exception_cm: + create_notifier(handler=not_a_callable) + + self.assertEqual( + str(exception_cm.exception), + "handler must be a callable, got {!r}".format(not_a_callable) + ) + + +class TestTraitEventNotifierException(unittest.TestCase): + """ Test the default exception handling without pushing and + popping exception handlers. + """ + + def test_capture_exception(self): + # Any exception from the handler will be captured and + # logged. This is such that failure in one handler + # does not prevent other notifiers to be called. + + # sanity check + # there are no exception handlers + with self.assertRaises(IndexError): + pop_exception_handler() + + def misbehaving_handler(event): + raise ZeroDivisionError("lalalala") + + notifier = create_notifier(handler=misbehaving_handler) + + # when + with mock.patch("sys.stderr", new_callable=io.StringIO) as stderr: + notifier(a=1, b=2) + + # then + content = stderr.getvalue() + self.assertIn( + "Exception occurred in traits notification handler", + content, + ) + # The tracback should be included + self.assertIn("ZeroDivisionError", content) + + +class TestTraitEventNotifierEqual(unittest.TestCase): + """ Test comparing two instances of TraitEventNotifier. """ + + def setUp(self): + push_exception_handler(reraise_exceptions=True) + self.addCleanup(pop_exception_handler) + + def tearDown(self): + pass + + def test_equals_use_handler_and_target(self): + # Check the notifier can identify an equivalence + # using the handler and the target + handler1 = mock.Mock() + handler2 = mock.Mock() + target1 = mock.Mock() + target2 = mock.Mock() + dispatcher = dispatch_here + notifier1 = create_notifier( + handler=handler1, target=target1, dispatcher=dispatcher) + notifier2 = create_notifier( + handler=handler1, target=target1, dispatcher=dispatcher) + notifier3 = create_notifier( + handler=handler1, target=target2, dispatcher=dispatcher) + notifier4 = create_notifier( + handler=handler2, target=target1, dispatcher=dispatcher) + + # then + self.assertTrue( + notifier1.equals(notifier2), + "The two notifiers should consider each other as equal." + ) + self.assertTrue( + notifier2.equals(notifier1), + "The two notifiers should consider each other as equal." + ) + self.assertFalse( + notifier3.equals(notifier1), + "Expected the notifiers to be different because targets are " + "not identical." + ) + self.assertFalse( + notifier4.equals(notifier1), + "Expected the notifiers to be different because the handlers " + "do not compare equally." + ) + + def test_equality_check_with_instance_methods(self): + # Methods are descriptors and need to be compared with care. + instance = DummyObservable() + target = mock.Mock() + + notifier1 = create_notifier(handler=instance.handler, target=target) + notifier2 = create_notifier(handler=instance.handler, target=target) + + self.assertTrue(notifier1.equals(notifier2)) + self.assertTrue(notifier2.equals(notifier1)) + + def test_equals_compared_to_different_type(self): + notifier = create_notifier() + self.assertFalse(notifier.equals(float)) + + def test_not_equal_if_dispatcher_different(self): + handler = mock.Mock() + target = mock.Mock() + dispatcher1 = mock.Mock() + dispatcher2 = mock.Mock() + notifier1 = create_notifier( + handler=handler, target=target, dispatcher=dispatcher1) + notifier2 = create_notifier( + handler=handler, target=target, dispatcher=dispatcher2) + + # then + self.assertFalse( + notifier1.equals(notifier2), + "Expected the notifiers to be different because the dispatchers " + "do not compare equally." + ) + self.assertFalse( + notifier2.equals(notifier1), + "Expected the notifiers to be different because the dispatchers " + "do not compare equally." + ) + + +class TestTraitEventNotifierAddRemove(unittest.TestCase): + """ Test TraitEventNotifier capability of adding/removing + itself to/from an observable. + """ + + def setUp(self): + push_exception_handler(reraise_exceptions=True) + self.addCleanup(pop_exception_handler) + + def tearDown(self): + pass + + def test_add_to_observable(self): + # It is typical that the observable also has other + # "notifiers" unknown to the TraitEventNotifier + dummy = DummyObservable() + dummy.notifiers = [str, float] + + notifier = create_notifier() + + # when + notifier.add_to(dummy) + + # then + self.assertEqual(dummy.notifiers, [str, float, notifier]) + + def test_add_to_observable_twice_increase_count(self): + # Test trying to add the "same" notifier results in + # the existing notifier bumping its own reference + # count. + dummy = DummyObservable() + + def handler(event): + pass + + notifier1 = create_notifier(handler=handler, target=_DUMMY_TARGET) + notifier2 = create_notifier(handler=handler, target=_DUMMY_TARGET) + + # when + notifier1.add_to(dummy) + notifier2.add_to(dummy) + + # then + self.assertEqual(dummy.notifiers, [notifier1]) + self.assertEqual(notifier1._ref_count, 2) + + def test_add_to_observable_different_notifier(self): + dummy = DummyObservable() + + def handler(event): + pass + + notifier1 = create_notifier(handler=handler, target=_DUMMY_TARGET) + # The target is different! + notifier2 = create_notifier(handler=handler, target=dummy) + + # when + notifier1.add_to(dummy) + notifier2.add_to(dummy) + + # then + self.assertEqual(dummy.notifiers, [notifier1, notifier2]) + + def test_remove_from_observable(self): + # Test creating two equivalent notifiers. + # The second notifier is able to remove the first one + # from the observable as if the first one was itself. + dummy = DummyObservable() + + def handler(event): + pass + + notifier1 = create_notifier(handler=handler, target=_DUMMY_TARGET) + notifier2 = create_notifier(handler=handler, target=_DUMMY_TARGET) + + # when + notifier1.add_to(dummy) + self.assertEqual(dummy.notifiers, [notifier1]) + notifier2.remove_from(dummy) + + # then + self.assertEqual(dummy.notifiers, []) + + def test_remove_from_observable_with_ref_count(self): + # Test reference counting logic in remove_from + dummy = DummyObservable() + + def handler(event): + pass + + notifier1 = create_notifier(handler=handler, target=_DUMMY_TARGET) + notifier2 = create_notifier(handler=handler, target=_DUMMY_TARGET) + + # when + # add_to is called twice. + notifier1.add_to(dummy) + notifier1.add_to(dummy) + self.assertEqual(dummy.notifiers, [notifier1]) + + # when + # removing it once + notifier2.remove_from(dummy) + + # then + self.assertEqual(dummy.notifiers, [notifier1]) + + # when + # removing it the second time + notifier2.remove_from(dummy) + + # then + # will remove the callable. + self.assertEqual(dummy.notifiers, []) + + def test_remove_from_error_if_not_found(self): + dummy = DummyObservable() + notifier1 = create_notifier() + + with self.assertRaises(NotifierNotFound) as e: + notifier1.remove_from(dummy) + + self.assertEqual(str(e.exception), "Notifier not found.") + + def test_remove_from_differentiate_not_equal_notifier(self): + dummy = DummyObservable() + notifier1 = create_notifier(handler=mock.Mock()) + + # The handler is different + notifier2 = create_notifier(handler=mock.Mock()) + + # when + notifier1.add_to(dummy) + notifier2.add_to(dummy) + notifier2.remove_from(dummy) + + # then + self.assertEqual(dummy.notifiers, [notifier1]) + + def test_add_to_multiple_observables(self): + # This is a use case we don't have but we want to guard + # against for now. + dummy1 = DummyObservable() + dummy2 = DummyObservable() + + notifier = create_notifier() + + # when + notifier.add_to(dummy1) + + # then + with self.assertRaises(RuntimeError) as exception_context: + notifier.add_to(dummy2) + + self.assertEqual( + str(exception_context.exception), + "Sharing notifiers across observables is unexpected." + ) + + +class TestTraitEventNotifierWeakrefTarget(unittest.TestCase): + """ Test weakref handling for target in TraitEventNotifier.""" + + def setUp(self): + push_exception_handler(reraise_exceptions=True) + self.addCleanup(pop_exception_handler) + + def tearDown(self): + pass + + def test_notifier_does_not_prevent_object_deletion(self): + # Typical use case: target is an instance of HasTraits + # and the notifier is attached to an internal object + # inside the target. + # The reverse reference to target should not prevent + # the target from being garbage collected when not in use. + target = DummyObservable() + target.internal_object = DummyObservable() + target_ref = weakref.ref(target) + + notifier = create_notifier(target=target) + notifier.add_to(target.internal_object) + + # when + del target + + # then + self.assertIsNone(target_ref()) + + def test_callable_disabled_if_target_removed(self): + target = mock.Mock() + handler = mock.Mock() + notifier = create_notifier(handler=handler, target=target) + + # sanity check + notifier(a=1, b=2) + self.assertEqual(handler.call_count, 1) + handler.reset_mock() + + # when + del target + + # then + notifier(a=1, b=2) + handler.assert_not_called() + + +class TestTraitEventNotifierWeakrefHandler(unittest.TestCase): + """ Test weakref handling for handler in TraitEventNotifier.""" + + def setUp(self): + push_exception_handler(reraise_exceptions=True) + self.addCleanup(pop_exception_handler) + + def tearDown(self): + pass + + def test_method_as_handler_does_not_prevent_garbage_collect(self): + # It is a typical use case that the handler is a method + # of an object. + # The reference to such a handler should not prevent the + # object from being garbage collected. + + dummy = DummyObservable() + dummy.internal_object = DummyObservable() + dummy_ref = weakref.ref(dummy) + + notifier = create_notifier(handler=dummy.handler) + notifier.add_to(dummy.internal_object) + + # when + del dummy + + # then + self.assertIsNone(dummy_ref()) + + def test_callable_disabled_if_handler_deleted(self): + + dummy = DummyObservable() + dummy.internal_object = DummyObservable() + + event_factory = mock.Mock() + + notifier = create_notifier( + handler=dummy.handler, event_factory=event_factory) + notifier.add_to(dummy.internal_object) + + # sanity check + notifier(a=1, b=2) + self.assertEqual(event_factory.call_count, 1) + event_factory.reset_mock() + + # when + del dummy + + # then + notifier(a=1, b=2) + event_factory.assert_not_called() + + def test_reference_held_when_dispatching(self): + # Test when the notifier proceeds to fire, it holds a + # strong reference to the handler + dummy = DummyObservable() + + def event_factory(*args, **kwargs): + nonlocal dummy + del dummy + + notifier = create_notifier( + handler=dummy.handler, + event_factory=event_factory, + ) + notifier.add_to(dummy) + + notifier(a=1, b=2)