-
Notifications
You must be signed in to change notification settings - Fork 85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add notifier for wrapping user handler for the observer framework #1000
Changes from 61 commits
db6428e
fa4e141
febe308
94c62b3
117902e
600602d
52ccbca
f7ae36f
5fce1ea
4c6c041
35bd119
af89c24
ca66b7e
e5d1756
53ef3d3
be5b33e
5c88767
cf8507e
2590c71
9b7e007
81afc1a
6de85bd
bd70167
d41db28
45cdf11
266f42a
6651da6
fdc4d65
2485c5b
167da0e
a9f01db
c06ef76
18552b0
84759dc
61b377c
c405c4f
fde6684
4a59e04
08ca08a
03b8e7c
563ea1a
13b4afc
fda97b7
fe52eb0
2f73866
513f278
eebddb3
8bf7696
0e57e6a
787c6ce
a0b9ad9
74cacff
3c8ef5e
f4e225d
c17a849
961e0a3
5eb6053
13d92a1
40ec482
9614866
d65e756
fe7e4ab
367ca3d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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): | ||
kitchoi marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" 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") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,218 @@ | ||
# (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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The following comment is still valid but GitHub marked it outdated because I moved the docstring after making the comment: #1000 (comment) :
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with this sentiment, but also that this is sufficiently deep internals that we can change it later without major issues. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's right this is not going to affect the API users will interact with, so I'd be happy to do the refactoring in a separate PR, I will open an issue for that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Opened #1063 |
||
|
||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Previously (in the PoC) there was a callback in both weakref for removing this notifier from the observable when either the handler or the target is deleted. That requires keeping a reference to the list of notifiers in the observable. If we don't do the cleanup, we may have some silenced notifiers lying around if the code that puts up the notifiers don't do a good job of taking them down (e.g. traitsui). This may be a bit inefficient, but I am not sure how common this scenario is. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 19 days after making this comment: I am now more in favour of not having a weakref callback for removing the notifier. Without the weakref callback, there is only one way to remove the notifier: |
||
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?" | ||
) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. FTR, I was able to run into some kind of race conditions sometimes with the following test, but this particular exception has not yet occurred.
The last two assertions fail sometimes, the "Notifier not found." exception appears sometimes, and the "Sharing notifiers across observables is unexpected." error in |
||
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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another discussion point: I think currently if one calls
The second call is ignored because "dispatch" is not considered for differentiating notifiers. Here we could differentiate them, and I am tempted to think that we should. In other words, calling the following will result in two notifiers, one to be dispatched on the same thread, and the second one to be dispatched on the GUI event loop:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am in favour of this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 I will add this one line for comparing the dispatch callable (and its test). |
||
) | ||
|
||
|
||
def _return(value): | ||
return value |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking ahead... we will need to expose this publicly, but they will need to be wrapped under some compatible layer to reconcile the difference between these functions and the ones in
traits.trait_notifiers
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A compatibility layer may not be needed if the API is good here: pushing and popping exception handlers is rare (mainly used in the test-support code, I think).