notifier.py 11.7 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
#!/usr/bin/env python
#
# notify.py - The Notifier mixin class.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module provides the :class:`Notifier` class, intended to be used
as a mixin for providing a simple notification API.
"""

11
12

import logging
13
import inspect
14
import contextlib
15
16
import collections

Paul McCarthy's avatar
Paul McCarthy committed
17
import fsl.utils.idle        as idle
18
import fsl.utils.weakfuncref as weakfuncref
19

20

21
22
23
log = logging.getLogger(__name__)


24
25
26
27
28
DEFAULT_TOPIC = 'default'
"""Topic used when the caller does not specify one when registering,
deregistering, or notifying listeners.
"""

29
30
31
32
33
34
class Registered(Exception):
    """``Exception`` raised by :meth:`Notifier.register` when an attempt is
    made to register a listener with a name that is already registered.
    """
    pass

35

36
37
38
39
40
41
42
43
44
45
46
class _Listener(object):
    """This class is used internally by the :class:`.Notifier` class to
    store references to callback functions.
    """

    def __init__(self, name, callback, topic, runOnIdle):

        self.name = name

        # We use a WeakFunctionRef so we can refer to
        # both functions and class/instance methods
47
        self.__callback = weakfuncref.WeakFunctionRef(callback)
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
        self.topic      = topic
        self.runOnIdle  = runOnIdle
        self.enabled    = True


    @property
    def callback(self):
        """Returns the callback function, or ``None`` if it has been
        garbage-collected.
        """
        return self.__callback.function()


    def __str__(self):

        cb = self.callback
64

65
66
67
68
69
70
71
72
73
74
75
        if cb is not None: cbName = getattr(cb, '__name__', '<callable>')
        else:              cbName = '<deleted>'

        return 'Listener {} [topic: {}] [function: {}]'.format(
            self.name, self.topic, cbName)


    def __repr__(self):
        return self.__str__()


76
77
class Notifier(object):
    """The ``Notifier`` class is a mixin which provides simple notification
78
79
80
81
    capability. Listeners can be registered/deregistered to listen via the
    :meth:`register` and :meth:`deregister` methods, and notified via the
    :meth:`notify` method. Listeners can optionally listen on a specific
    *topic*, or be notified for all topics.
82
83

    .. note:: The ``Notifier`` class stores ``weakref`` references to
Paul McCarthy's avatar
Paul McCarthy committed
84
85
              registered callback functions, using the
              :class:`.WeakFunctionRef` class.
86
87
    """

88

89
90
91
92
    def __new__(cls, *args, **kwargs):
        """Initialises a dictionary of listeners on a new ``Notifier``
        instance.
        """
93

94
        new = super(Notifier, cls).__new__(cls)
95
96
97
98
99
100
101
102

        # Listeners are stored in this
        #
        # { topic : { name : _Listener } }
        #
        # dictionary, with the inner
        # dictionaries ordered by
        # insertion time.
103
        new.__listeners = collections.defaultdict(collections.OrderedDict)
104

105
106
107
108
109
110
        # Notification can be enabled on a per-
        # topic basis. This dictionary contains
        # enable states for each topic, as
        # { topic : enabled } mappings.
        new.__enabled = {}

111
112
        return new

113

114
    def register(self, name, callback, topic=None, runOnIdle=False):
115
116
        """Register a listener with this ``Notifier``.

117
        :arg name:      A unique name for the listener.
118
119
120
121
122
123

        :arg callback:  The function to call - must accept two positional
                        arguments:

                          - this ``Notifier`` instance.

124
125
126
                          - The topic, which may be ``None`` - see
                            :meth:`notify`.

127
128
129
                          - A value, which may be ``None`` - see
                            :meth:`notify`.

130
        :arg topic:     Optional topic on which to listen for notifications.
131

132
        :arg runOnIdle: If ``True``, this listener will be called on the main
Paul McCarthy's avatar
Paul McCarthy committed
133
                        thread, via the :func:`.idle.idle` function.
134
                        Otherwise this function will be called directly by the
135
                        :meth:`notify` method. Defaults to ``False``.
136
137
138

        :raises: A :exc:`Registered` error if a listener with the given
                 ``name`` is already registered on the given ``topic``.
139
        """
140

141
142
143
        if topic is None:
            topic = DEFAULT_TOPIC

144
        listener = _Listener(name, callback, topic, runOnIdle)
145
146
147

        if name in self.__listeners[topic]:
            raise Registered('Listener {} is already registered'.format(name))
148

149
        self.__listeners[topic][name] = listener
150
        self.__enabled[  topic]       = self.__enabled.get(topic, True)
151

152
        log.debug('{}: Registered {}'.format(type(self).__name__, listener))
153

154

155
    def deregister(self, name, topic=None):
156
157
158
        """De-register a listener that has been previously registered with
        this ``Notifier``.

159
160
        :arg name:  Name of the listener to de-register.
        :arg topic: Topic on which the listener was registered.
161
        """
162

163
164
165
166
167
168
169
170
171
        if topic is None:
            topic = DEFAULT_TOPIC

        listeners = self.__listeners.get(topic, None)

        # Silently absorb invalid topics
        if listeners is None:
            return

172
        listener = listeners.pop(name, None)
173
174
175
176
177

        # Silently absorb invalid names - the
        # notify function may have removed gc'd
        # listeners, so they will no longer exist
        # in the dictionary.
178
        if listener is None:
179
            return
180
181
182
183

        # No more listeners for this topic
        if len(listeners) == 0:
            self.__listeners.pop(topic)
184
            self.__enabled  .pop(topic)
185

186
187
        log.debug('{}: De-registered listener {}'.format(
            type(self).__name__, listener))
188
189


190
    def enable(self, name, topic=None, enable=True):
191
192
193
194
        """Enables the specified listener. """
        if topic is None:
            topic = DEFAULT_TOPIC

195
        self.__listeners[topic][name].enabled = enable
196
197
198
199


    def disable(self, name, topic=None):
        """Disables the specified listener. """
200
        self.enable(name, topic, False)
201
202
203
204
205
206
207
208
209


    def isEnabled(self, name, topic=None):
        """Returns ``True`` if the specified listener is enabled, ``False``
        otherwise.
        """
        if topic is None:
            topic = DEFAULT_TOPIC

210
211
        try:             return self.__listeners[topic][name].enabled
        except KeyError: return False
212
213


214
215
216
217
    def enableAll(self, topic=None, state=True):
        """Enable/disable all listeners for the specified topic.

        :arg topic: Topic to enable/disable listeners on. If ``None``,
218
                    all listeners are enabled/disabled.
219
220
221
222

        :arg state: State to set listeners to.
        """

223
224
        if topic is not None: topics = [topic]
        else:                 topics = list(self.__enabled.keys())
225

226
227
228
        for topic in topics:
            if topic in self.__enabled:
                self.__enabled[topic] = state
229

230

231
232
233
234
    def disableAll(self, topic=None):
        """Disable all listeners for the specified topic (or ``None``
        to disable all listeners).
        """
235
        self.enableAll(topic, False)
236
237
238
239
240


    def isAllEnabled(self, topic=None):
        """Returns ``True`` if all listeners for the specified topic (or all
        listeners if ``topic=None``) are enabled, ``False`` otherwise.
241
        """
242
243
        if topic is None:
            topic = DEFAULT_TOPIC
244

245
        return self.__enabled.get(topic, False)
246

247

248
249
250
    @contextlib.contextmanager
    def skipAll(self, topic=None):
        """Context manager which disables all listeners for the
251
252
253
254
        specified topic, and restores their state before returning.

        :arg topic: Topic to skip listeners on. If ``None``, notification
                    is disabled for all topics.
255
        """
256

257
258
259
260
261
262
263
        if topic is not None: topics = [topic]
        else:                 topics = list(self.__enabled.keys())

        states = [self.isAllEnabled(t) for t in topics]

        for t in topics:
            self.disableAll(t)
264
265
266

        try:
            yield
267

268
        finally:
269
270
            for t, s in zip(topics, states):
                self.enableAll(t, s)
271
272


273
274
275
276
    @contextlib.contextmanager
    def skip(self, name, topic=None):
        """Context manager which disables the speciifed listener, and
        restores its state before returning.
277
278
279
280
281
282
283
284
285
286
287
288
289
290

        You can use this method if you have some code which triggers a
        notification, but you do not your own listener to be notified.
        For example::

            def __myListener(*a):
                pass

            notifier.register('myListener', __myListener)

            with notifier.skip('myListener'):
                # if a notification is triggered
                # by the code here, the __myListener
                # function will not be called.
291
292
293
294

        :arg name:  Name of the listener to skip

        :arg topic: Topic or topics that the listener is registered on.
295
296
        """

Paul McCarthy's avatar
Paul McCarthy committed
297
        if topic is None or isinstance(topic, str):
298
299
300
301
302
303
304
            topic = [topic]

        topics = topic
        states = [self.isEnabled(name, t) for t in topics]

        for topic in topics:
            self.disable(name, topic)
305
306
307
308
309

        try:
            yield

        finally:
310
311
            for topic, state in zip(topics, states):
                self.enable(name, topic, state)
312

313

314
315
    def notify(self, *args, **kwargs):
        """Notify all registered listeners of this ``Notifier``.
316

317
318
        The documented arguments must be passed as keyword arguments.

319
320
321
322
323
324
325
        :arg topic: The topic on which to notify. Default
                    listeners are always notified, regardless
                    of the specified topic.

        :arg value: A value passed through to the registered listener
                    functions. If not provided, listeners will be passed
                    a value of ``None``.
326

327
        All other arguments passed to this method are ignored.
328
329

        .. note:: Listeners registered with ``runOnIdle=True`` are called
Paul McCarthy's avatar
Paul McCarthy committed
330
                  via :func:`idle.idle`. Other listeners are called directly.
331
                  See :meth:`register`.
332
        """
333

334
335
336
        topic     = kwargs.get('topic', None)
        value     = kwargs.get('value', None)
        listeners = self.__getListeners(topic)
337

338
        if len(listeners) == 0:
339
            return
340

341
342
343
        if log.getEffectiveLevel() <= logging.DEBUG:
            stack   = inspect.stack()
            frame   = stack[1]
344
            srcMod  = '...{}'.format(frame[1][-20:])
345
            srcLine = frame[2]
346

347
            log.debug('{}: Notifying {} listeners (topic: {}) [{}:{}]'.format(
348
                type(self).__name__,
349
                len(listeners),
350
                topic,
351
352
                srcMod,
                srcLine))
353

354
        for listener in listeners:
355

356
357
358
359
360
361
362
363
364
365
366
367
368
369
            callback = listener.callback
            name     = listener.name

            # The callback, or the owner of the
            # callback function may have been
            # gc'd - remove it if this is the case.
            if callback is None:
                log.debug('Listener {} has been gc\'d - '
                          'removing from list'.format(name))
                self.__listeners[listener.topic].pop(name)

            elif not listener.enabled:
                continue

Paul McCarthy's avatar
Paul McCarthy committed
370
371
            elif listener.runOnIdle: idle.idle(callback, self, topic, value)
            else:                    callback(           self, topic, value)
372
373
374
375
376
377
378


    def __getListeners(self, topic):
        """Called by :meth:`notify`. Returns all listeners which should be
        notified for the specified ``topic``.
        """

379
        listeners = []
380

381
382
383
384
        # Default listeners are called on all topics
        # (unless the default topic is disabled)
        if self.__enabled.get(DEFAULT_TOPIC, False):
            listeners.extend(self.__listeners.get(DEFAULT_TOPIC, {}).values())
385

386
387
        if topic is None or topic == DEFAULT_TOPIC:
            return listeners
388

389
390
        if self.__enabled.get(topic, False):
            listeners.extend(self.__listeners.get(topic, {}).values())
391
392

        return listeners