]> git.saurik.com Git - wxWidgets.git/blobdiff - wxPython/wx/lib/pubsub.py
reduce the number of objects for a quicker startup time
[wxWidgets.git] / wxPython / wx / lib / pubsub.py
index 6efa12c378d2bde0c47a48ebe7ef162e228b04ae..6973df1e0814e1603fa2358201e29ff6b0a13d0f 100644 (file)
-#---------------------------------------------------------------------------
-# Name:        wxPython.lib.pubsub
-# Purpose:     The Publish/Subscribe framework used by evtmgr.EventManager
-#
-# Author:      Robb Shecter and Robin Dunn
-#
-# Created:     12-December-2002
-# RCS-ID:      $Id$
-# Copyright:   (c) 2002 by db-X Corporation
-# Licence:     wxWindows license
+
 #---------------------------------------------------------------------------
 """
-This module has classes for implementing the Publish/Subscribe design
-pattern.
+This module provides a publish-subscribe component that allows
+listeners to subcribe to messages of a given topic. Contrary to the
+original wxPython.lib.pubsub module (which it is based on), it uses 
+weak referencing to the subscribers so the lifetime of subscribers 
+is not affected by Publisher. Also, callable objects can be used in 
+addition to functions and bound methods. See Publisher class docs for 
+more details. 
+
+Thanks to Robb Shecter and Robin Dunn for having provided 
+the basis for this module (which now shares most of the concepts but
+very little design or implementation with the original 
+wxPython.lib.pubsub).
+
+The publisher is a singleton instance of the PublisherClass class. You 
+access the instance via the Publisher object available from the module::
+
+    from wx.lib.pubsub import Publisher
+    Publisher().subscribe(...)
+    Publisher().sendMessage(...)
+    ...
+
+:Author:      Oliver Schoenborn
+:Since:       Apr 2004
+:Version:     $Id$
+:Copyright:   \(c) 2004 Oliver Schoenborn
+:License:     wxWidgets
+"""
 
-It's a very flexible PS implementation: The message topics are tuples
-of any length, containing any objects (that can be used as hash keys).
-A subscriber's topic matches any message topic for which it's a
-sublist.
+_implNotes = """
+Implementation notes
+--------------------
+
+In class Publisher, I represent the topics-listener set as a tree
+where each node is a topic, and contains a list of listeners of that
+topic, and a dictionary of subtopics of that topic. When the Publisher
+is told to send a message for a given topic, it traverses the tree
+down to the topic for which a message is being generated, all
+listeners on the way get sent the message.
+
+Publisher currently uses a weak listener topic tree to store the
+topics for each listener, and if a listener dies before being
+unsubscribed, the tree is notified, and the tree eliminates the
+listener from itself.
+
+Ideally, _TopicTreeNode would be a generic _TreeNode with named
+subnodes, and _TopicTreeRoot would be a generic _Tree with named
+nodes, and Publisher would store listeners in each node and a topic
+tuple would be converted to a path in the tree.  This would lead to a
+much cleaner separation of concerns. But time is over, time to move on.
+"""
+#---------------------------------------------------------------------------
 
-It also has many optimizations to favor time efficiency (ie., run-time
-speed).  I did this because I use it to support extreme uses.  For
-example, piping every wxWindows mouse event through to multiple
-listeners, and expecting the app to have no noticeable slowdown.  This
-has made the code somewhat obfuscated, but I've done my best to
-document it.
+# for function and method parameter counting:
+from types   import InstanceType
+from inspect import getargspec, ismethod, isfunction
+# for weakly bound methods:
+from new     import instancemethod as InstanceMethod
+from weakref import ref as WeakRef
 
-The Server and Message classes are the two that clients interact
-with..
+# -----------------------------------------------------------------------------
 
-This module is compatible with Python 2.1.
+def _isbound(method):
+    """Return true if method is a bound method, false otherwise"""
+    assert ismethod(method)
+    return method.im_self is not None
 
-Author: Robb Shecter
-"""
 
-#---------------------------------------------------------------------------
+def _paramMinCountFunc(function):
+    """Given a function, return pair (min,d) where min is minimum # of
+    args required, and d is number of default arguments."""
+    assert isfunction(function)
+    (args, va, kwa, dflt) = getargspec(function)
+    lenDef = len(dflt or ())
+    lenArgs = len(args or ())
+    lenVA = int(va is not None)
+    return (lenArgs - lenDef + lenVA, lenDef)
+
 
-class Publisher:
+def _paramMinCount(callableObject):
+    """
+    Given a callable object (function, method or callable instance),
+    return pair (min,d) where min is minimum # of args required, and d
+    is number of default arguments. The 'self' parameter, in the case
+    of methods, is not counted.
+    """
+    if type(callableObject) is InstanceType:
+        min, d = _paramMinCountFunc(callableObject.__call__.im_func)
+        return min-1, d
+    elif ismethod(callableObject):
+        min, d = _paramMinCountFunc(callableObject.im_func)
+        return min-1, d
+    elif isfunction(callableObject):
+        return _paramMinCountFunc(callableObject)
+    else:
+        raise 'Cannot determine type of callable: '+repr(callableObject)
+
+
+def _tupleize(items):
+    """Convert items to tuple if not already one, 
+    so items must be a list, tuple or non-sequence"""
+    if isinstance(items, list):
+        raise TypeError, 'Not allowed to tuple-ize a list'
+    elif isinstance(items, (str, unicode)) and items.find('.') != -1:
+        items = tuple(items.split('.'))
+    elif not isinstance(items, tuple):
+        items = (items,)
+    return items
+
+
+def _getCallableName(callable):
+    """Get name for a callable, ie function, bound 
+    method or callable instance"""
+    if ismethod(callable):
+        return '%s.%s ' % (callable.im_self, callable.im_func.func_name)
+    elif isfunction(callable):
+        return '%s ' % callable.__name__
+    else:
+        return '%s ' % callable
+    
+    
+def _removeItem(item, fromList):
+    """Attempt to remove item from fromList, return true 
+    if successful, false otherwise."""
+    try: 
+        fromList.remove(item)
+        return True
+    except ValueError:
+        return False
+        
+        
+# -----------------------------------------------------------------------------
+
+class _WeakMethod:
+    """Represent a weak bound method, i.e. a method doesn't keep alive the 
+    object that it is bound to. It uses WeakRef which, used on its own, 
+    produces weak methods that are dead on creation, not very useful. 
+    Typically, you will use the getRef() function instead of using
+    this class directly. """
+    
+    def __init__(self, method, notifyDead = None):
+        """The method must be bound. notifyDead will be called when 
+        object that method is bound to dies. """
+        assert ismethod(method)
+        if method.im_self is None:
+            raise ValueError, "We need a bound method!"
+        if notifyDead is None:
+            self.objRef = WeakRef(method.im_self)
+        else:
+            self.objRef = WeakRef(method.im_self, notifyDead)
+        self.fun = method.im_func
+        self.cls = method.im_class
+        
+    def __call__(self):
+        """Returns a new.instancemethod if object for method still alive. 
+        Otherwise return None. Note that instancemethod causes a 
+        strong reference to object to be created, so shouldn't save 
+        the return value of this call. Note also that this __call__
+        is required only for compatibility with WeakRef.ref(), otherwise
+        there would be more efficient ways of providing this functionality."""
+        if self.objRef() is None:
+            return None
+        else:
+            return InstanceMethod(self.fun, self.objRef(), self.cls)
+        
+    def __eq__(self, method2):
+        """Two WeakMethod objects compare equal if they refer to the same method
+        of the same instance. Thanks to Josiah Carlson for patch and clarifications
+        on how dict uses eq/cmp and hashing. """
+        if not isinstance(method2, _WeakMethod):
+            return False 
+        return      self.fun      is method2.fun \
+                and self.objRef() is method2.objRef() \
+                and self.objRef() is not None
+    
+    def __hash__(self):
+        """Hash is an optimization for dict searches, it need not 
+        return different numbers for every different object. Some objects
+        are not hashable (eg objects of classes derived from dict) so no
+        hash(objRef()) in there, and hash(self.cls) would only be useful
+        in the rare case where instance method was rebound. """
+        return hash(self.fun)
+    
+    def __repr__(self):
+        dead = ''
+        if self.objRef() is None: 
+            dead = '; DEAD'
+        obj = '<%s at %s%s>' % (self.__class__, id(self), dead)
+        return obj
+        
+    def refs(self, weakRef):
+        """Return true if we are storing same object referred to by weakRef."""
+        return self.objRef == weakRef
+
+
+def _getWeakRef(obj, notifyDead=None):
+    """Get a weak reference to obj. If obj is a bound method, a _WeakMethod
+    object, that behaves like a WeakRef, is returned, if it is
+    anything else a WeakRef is returned. If obj is an unbound method,
+    a ValueError will be raised."""
+    if ismethod(obj):
+        createRef = _WeakMethod
+    else:
+        createRef = WeakRef
+        
+    if notifyDead is None:
+        return createRef(obj)
+    else:
+        return createRef(obj, notifyDead)
+    
+    
+# -----------------------------------------------------------------------------
+
+def getStrAllTopics():
+    """Function to call if, for whatever reason, you need to know 
+    explicitely what is the string to use to indicate 'all topics'."""
+    return ''
+
+
+# alias, easier to see where used
+ALL_TOPICS = getStrAllTopics()
+
+# -----------------------------------------------------------------------------
+
+
+class _NodeCallback:
+    """Encapsulate a weak reference to a method of a TopicTreeNode
+    in such a way that the method can be called, if the node is 
+    still alive, but the callback does not *keep* the node alive.
+    Also, define two methods, preNotify() and noNotify(), which can 
+    be redefined to something else, very useful for testing. 
+    """
+    
+    def __init__(self, obj):
+        self.objRef = _getWeakRef(obj)
+        
+    def __call__(self, weakCB):
+        notify = self.objRef()
+        if notify is not None: 
+            self.preNotify(weakCB)
+            notify(weakCB)
+        else: 
+            self.noNotify()
+            
+    def preNotify(self, dead):
+        """'Gets called just before our callback (self.objRef) is called"""
+        pass
+        
+    def noNotify(self):
+        """Gets called if the TopicTreeNode for this callback is dead"""
+        pass
+
+
+class _TopicTreeNode:
+    """A node in the topic tree. This contains a list of callables
+    that are interested in the topic that this node is associated
+    with, and contains a dictionary of subtopics, whose associated
+    values are other _TopicTreeNodes. The topic of a node is not stored
+    in the node, so that the tree can be implemented as a dictionary
+    rather than a list, for ease of use (and, likely, performance).
+    
+    Note that it uses _NodeCallback to encapsulate a callback for 
+    when a registered listener dies, possible thanks to WeakRef.
+    Whenever this callback is called, the onDeadListener() function, 
+    passed in at construction time, is called (unless it is None).
+    """
+    
+    def __init__(self, topicPath, onDeadListenerWeakCB):
+        self.__subtopics = {}
+        self.__callables = []
+        self.__topicPath = topicPath
+        self.__onDeadListenerWeakCB = onDeadListenerWeakCB
+        
+    def getPathname(self): 
+        """The complete node path to us, ie., the topic tuple that would lead to us"""
+        return self.__topicPath
+    
+    def createSubtopic(self, subtopic, topicPath):
+        """Create a child node for subtopic"""
+        return self.__subtopics.setdefault(subtopic,
+                    _TopicTreeNode(topicPath, self.__onDeadListenerWeakCB))
+    
+    def hasSubtopic(self, subtopic):
+        """Return true only if topic string is one of subtopics of this node"""
+        return self.__subtopics.has_key(subtopic)
+    
+    def getNode(self, subtopic):
+        """Return ref to node associated with subtopic"""
+        return self.__subtopics[subtopic]
+    
+    def addCallable(self, callable):
+        """Add a callable to list of callables for this topic node"""
+        try:
+            id = self.__callables.index(_getWeakRef(callable))
+            return self.__callables[id]
+        except ValueError:
+            wrCall = _getWeakRef(callable, _NodeCallback(self.__notifyDead))
+            self.__callables.append(wrCall)
+            return wrCall
+            
+    def getCallables(self):
+        """Get callables associated with this topic node"""
+        return [cb() for cb in self.__callables if cb() is not None]
+    
+    def hasCallable(self, callable):
+        """Return true if callable in this node"""
+        try: 
+            self.__callables.index(_getWeakRef(callable))
+            return True
+        except ValueError:
+            return False
+    
+    def sendMessage(self, message):
+        """Send a message to our callables"""
+        deliveryCount = 0
+        for cb in self.__callables:
+            listener = cb()
+            if listener is not None:
+                listener(message)
+                deliveryCount += 1
+        return deliveryCount
+    
+    def removeCallable(self, callable):
+        """Remove weak callable from our node (and return True). 
+        Does nothing if not here (and returns False)."""
+        try: 
+            self.__callables.remove(_getWeakRef(callable))
+            return True
+        except ValueError:
+            return False
+        
+    def clearCallables(self):
+        """Abandon list of callables to caller. We no longer have 
+        any callables after this method is called."""
+        tmpList = [cb for cb in self.__callables if cb() is not None]
+        self.__callables = []
+        return tmpList
+        
+    def __notifyDead(self, dead):
+        """Gets called when a listener dies, thanks to WeakRef"""
+        #print 'TreeNODE', `self`, 'received death certificate for ', dead
+        self.__cleanupDead()
+        if self.__onDeadListenerWeakCB is not None:
+            cb = self.__onDeadListenerWeakCB()
+            if cb is not None: 
+                cb(dead)
+        
+    def __cleanupDead(self):
+        """Remove all dead objects from list of callables"""
+        self.__callables = [cb for cb in self.__callables if cb() is not None]
+        
+    def __str__(self):
+        """Print us in a not-so-friendly, but readable way, good for debugging."""
+        strVal = []
+        for callable in self.getCallables():
+            strVal.append(_getCallableName(callable))
+        for topic, node in self.__subtopics.iteritems():
+            strVal.append(' (%s: %s)' %(topic, node))
+        return ''.join(strVal)
+      
+      
+class _TopicTreeRoot(_TopicTreeNode):
     """
-    The publish/subscribe server.  This class is a Singleton.
+    The root of the tree knows how to access other node of the 
+    tree and is the gateway of the tree user to the tree nodes. 
+    It can create topics, and and remove callbacks, etc. 
+    
+    For efficiency, it stores a dictionary of listener-topics, 
+    so that unsubscribing a listener just requires finding the 
+    topics associated to a listener, and finding the corresponding
+    nodes of the tree. Without it, unsubscribing would require 
+    that we search the whole tree for all nodes that contain 
+    given listener. Since Publisher is a singleton, it will 
+    contain all topics in the system so it is likely to be a large
+    tree. However, it is possible that in some runs, unsubscribe()
+    is called very little by the user, in which case most unsubscriptions
+    are automatic, ie caused by the listeners dying. In this case, 
+    a flag is set to indicate that the dictionary should be cleaned up
+    at the next opportunity. This is not necessary, it is just an 
+    optimization.
     """
+    
     def __init__(self):
-        self.topicDict         = {}
-        self.functionDict      = {}
-        self.subscribeAllList  = []
-        self.messageCount      = 0
-        self.deliveryCount     = 0
+        self.__callbackDict  = {}
+        self.__callbackDictCleanup = 0
+        # all child nodes will call our __rootNotifyDead method
+        # when one of their registered listeners dies 
+        _TopicTreeNode.__init__(self, (ALL_TOPICS,), 
+                                _getWeakRef(self.__rootNotifyDead))
+        
+    def addTopic(self, topic, listener):
+        """Add topic to tree if doesnt exist, and add listener to topic node"""
+        assert isinstance(topic, tuple)
+        topicNode = self.__getTreeNode(topic, make=True)
+        weakCB = topicNode.addCallable(listener)
+        assert topicNode.hasCallable(listener)
+
+        theList = self.__callbackDict.setdefault(weakCB, [])
+        assert self.__callbackDict.has_key(weakCB)
+        # add it only if we don't already have it
+        try:
+            weakTopicNode = WeakRef(topicNode)
+            theList.index(weakTopicNode)
+        except ValueError:
+            theList.append(weakTopicNode)
+        assert self.__callbackDict[weakCB].index(weakTopicNode) >= 0
+        
+    def getTopics(self, listener):
+        """Return the list of topics for given listener"""
+        weakNodes = self.__callbackDict.get(_getWeakRef(listener), [])
+        return [weakNode().getPathname() for weakNode in weakNodes 
+                if weakNode() is not None]
+
+    def isSubscribed(self, listener, topic=None):
+        """Return true if listener is registered for topic specified. 
+        If no topic specified, return true if subscribed to something.
+        Use topic=getStrAllTopics() to determine if a listener will receive 
+        messages for all topics."""
+        weakCB = _getWeakRef(listener)
+        if topic is None: 
+            return self.__callbackDict.has_key(weakCB)
+        else:
+            topicPath = _tupleize(topic)
+            for weakNode in self.__callbackDict[weakCB]:
+                if topicPath == weakNode().getPathname():
+                    return True
+            return False
+            
+    def unsubscribe(self, listener, topicList):
+        """Remove listener from given list of topics. If topicList
+        doesn't have any topics for which listener has subscribed,
+        nothing happens."""
+        weakCB = _getWeakRef(listener)
+        if not self.__callbackDict.has_key(weakCB):
+            return
+        
+        cbNodes = self.__callbackDict[weakCB] 
+        if topicList is None:
+            for weakNode in cbNodes:
+                weakNode().removeCallable(listener)
+            del self.__callbackDict[weakCB] 
+            return
+
+        for weakNode in cbNodes:
+            node = weakNode()
+            if node is not None and node.getPathname() in topicList:
+                success = node.removeCallable(listener)
+                assert success == True
+                cbNodes.remove(weakNode)
+                assert not self.isSubscribed(listener, node.getPathname())
+
+    def unsubAll(self, topicList, onNoSuchTopic):
+        """Unsubscribe all listeners registered for any topic in 
+        topicList. If a topic in the list does not exist, and 
+        onNoSuchTopic is not None, a call
+        to onNoSuchTopic(topic) is done for that topic."""
+        for topic in topicList:
+            node = self.__getTreeNode(topic)
+            if node is not None:
+                weakCallables = node.clearCallables()
+                for callable in weakCallables:
+                    weakNodes = self.__callbackDict[callable]
+                    success = _removeItem(WeakRef(node), weakNodes)
+                    assert success == True
+                    if weakNodes == []:
+                        del self.__callbackDict[callable]
+            elif onNoSuchTopic is not None: 
+                onNoSuchTopic(topic)
+            
+    def sendMessage(self, topic, message, onTopicNeverCreated):
+        """Send a message for given topic to all registered listeners. If 
+        topic doesn't exist, call onTopicNeverCreated(topic)."""
+        # send to the all-toipcs listeners
+        deliveryCount = _TopicTreeNode.sendMessage(self, message)
+        # send to those who listen to given topic or any of its supertopics
+        node = self
+        for topicItem in topic:
+            assert topicItem != ''
+            if node.hasSubtopic(topicItem):
+                node = node.getNode(topicItem)
+                deliveryCount += node.sendMessage(message)
+            else: # topic never created, don't bother continuing
+                if onTopicNeverCreated is not None:
+                    onTopicNeverCreated(topic)
+                break
+        return deliveryCount
+
+    def numListeners(self):
+        """Return a pair (live, dead) with count of live and dead listeners in tree"""
+        dead, live = 0, 0
+        for cb in self.__callbackDict:
+            if cb() is None: 
+                dead += 1
+            else:
+                live += 1
+        return live, dead
+    
+    # clean up the callback dictionary after how many dead listeners
+    callbackDeadLimit = 10
+
+    def __rootNotifyDead(self, dead):
+        #print 'TreeROOT received death certificate for ', dead
+        self.__callbackDictCleanup += 1
+        if self.__callbackDictCleanup > _TopicTreeRoot.callbackDeadLimit:
+            self.__callbackDictCleanup = 0
+            oldDict = self.__callbackDict
+            self.__callbackDict = {}
+            for weakCB, weakNodes in oldDict.iteritems():
+                if weakCB() is not None:
+                    self.__callbackDict[weakCB] = weakNodes
+        
+    def __getTreeNode(self, topic, make=False):
+        """Return the tree node for 'topic' from the topic tree. If it 
+        doesnt exist and make=True, create it first."""
+        # if the all-topics, give root; 
+        if topic == (ALL_TOPICS,):
+            return self
+            
+        # not root, so traverse tree
+        node = self
+        path = ()
+        for topicItem in topic:
+            path += (topicItem,)
+            if topicItem == ALL_TOPICS:
+                raise ValueError, 'Topic tuple must not contain ""'
+            if make: 
+                node = node.createSubtopic(topicItem, path)
+            elif node.hasSubtopic(topicItem):
+                node = node.getNode(topicItem)
+            else:
+                return None
+        # done
+        return node
+        
+    def printCallbacks(self):
+        strVal = ['Callbacks:\n']
+        for listener, weakTopicNodes in self.__callbackDict.iteritems():
+            topics = [topic() for topic in weakTopicNodes if topic() is not None]
+            strVal.append('  %s: %s\n' % (_getCallableName(listener()), topics))
+        return ''.join(strVal)
+        
+    def __str__(self):
+        return 'all: %s' % _TopicTreeNode.__str__(self)
+    
+    
+# -----------------------------------------------------------------------------
 
+class _SingletonKey: pass
+
+class PublisherClass:
+    """
+    The publish/subscribe manager.  It keeps track of which listeners
+    are interested in which topics (see subscribe()), and sends a
+    Message for a given topic to listeners that have subscribed to
+    that topic, with optional user data (see sendMessage()).
+    
+    The three important concepts for Publisher are:
+        
+    - listener: a function, bound method or
+      callable object that can be called with one parameter
+      (not counting 'self' in the case of methods). The parameter
+      will be a reference to a Message object. E.g., these listeners
+      are ok::
+          
+          class Foo:
+              def __call__(self, a, b=1): pass # can be called with only one arg
+              def meth(self,  a):         pass # takes only one arg
+              def meth2(self, a=2, b=''): pass # can be called with one arg
+        
+          def func(a, b=''): pass
+          
+          Foo foo
+          Publisher().subscribe(foo)           # functor
+          Publisher().subscribe(foo.meth)      # bound method
+          Publisher().subscribe(foo.meth2)     # bound method
+          Publisher().subscribe(func)          # function
+          
+      The three types of callables all have arguments that allow a call 
+      with only one argument. In every case, the parameter 'a' will contain
+      the message. 
+
+    - topic: a single word, a tuple of words, or a string containing a
+      set of words separated by dots, for example: 'sports.baseball'.
+      A tuple or a dotted notation string denotes a hierarchy of
+      topics from most general to least. For example, a listener of
+      this topic::
+
+          ('sports','baseball')
+
+      would receive messages for these topics::
+
+          ('sports', 'baseball')                 # because same
+          ('sports', 'baseball', 'highscores')   # because more specific
+
+      but not these::
+
+           'sports'      # because more general
+          ('sports',)    # because more general
+          () or ('')     # because only for those listening to 'all' topics
+          ('news')       # because different topic
+          
+    - message: this is an instance of Message, containing the topic for 
+      which the message was sent, and any data the sender specified. 
+      
+    :note: This class is visible to importers of pubsub only as a
+           Singleton. I.e., every time you execute 'Publisher()', it's
+           actually the same instance of PublisherClass that is
+           returned. So to use, just do'Publisher().method()'.
+        
+    """
+    
+    __ALL_TOPICS_TPL = (ALL_TOPICS, )
+    
+    def __init__(self, singletonKey):
+        """Construct a Publisher. This can only be done by the pubsub 
+        module. You just use pubsub.Publisher()."""
+        if not isinstance(singletonKey, _SingletonKey):
+            raise invalid_argument("Use Publisher() to get access to singleton")
+        self.__messageCount  = 0
+        self.__deliveryCount = 0
+        self.__topicTree     = _TopicTreeRoot()
 
     #
     # Public API
     #
 
-    def subscribe(self, topic, listener):
+    def getDeliveryCount(self):
+        """How many listeners have received a message since beginning of run"""
+        return self.__deliveryCount
+    
+    def getMessageCount(self):
+        """How many times sendMessage() was called since beginning of run"""
+        return self.__messageCount
+    
+    def subscribe(self, listener, topic = ALL_TOPICS):
         """
-        Add the given subscription to the list.  This will
-        add an entry recording the fact that the listener wants
-        to get messages for (at least) the given topic.  This
-        method may be called multiple times for one listener,
-        registering it with many topics.  It can also be invoked
-        many times for a particular topic, each time with a
-        different listener.
-
-        listener: expected to be either a method or function that
-        takes zero or one parameters.  (Not counting 'self' in the
-        case of methods. If it accepts a parameter, it will be given
-        a reference to a Message object.
-
-        topic: will  be converted to a tuple if it isn't one.
-        It's a pattern matches any topic that it's a sublist
-        of.  For example, this pattern:
-
-          ('sports',)
-
-        would match these:
-
-          ('sports',)
-          ('sports', 'baseball')
-          ('sports', 'baseball', 'highscores')
-
-        but not these:
-
-          ()
-          ('news')
-          (12345)
+        Subscribe listener for given topic. If topic is not specified,
+        listener will be subscribed for all topics (that listener will 
+        receive a Message for any topic for which a message is generated). 
+        
+        This method may be called multiple times for one listener,
+        registering it with many topics.  It can also be invoked many
+        times for a particular topic, each time with a different
+        listener.  See the class doc for requirements on listener and
+        topic.
+
+        :note: The listener is held by Publisher() only by *weak*
+               reference.  This means you must ensure you have at
+               least one strong reference to listener, otherwise it
+               will be DOA ("dead on arrival"). This is particularly
+               easy to forget when wrapping a listener method in a
+               proxy object (e.g. to bind some of its parameters),
+               e.g.::
+        
+                  class Foo: 
+                      def listener(self, event): pass
+                  class Wrapper:
+                      def __init__(self, fun): self.fun = fun
+                      def __call__(self, *args): self.fun(*args)
+                  foo = Foo()
+                  Publisher().subscribe( Wrapper(foo.listener) ) # whoops: DOA!
+                  wrapper = Wrapper(foo.listener)
+                  Publisher().subscribe(wrapper) # good!
+        
+        :note: Calling this method for the same listener, with two
+               topics in the same branch of the topic hierarchy, will
+               cause the listener to be notified twice when a message
+               for the deepest topic is sent. E.g.
+               subscribe(listener, 't1') and then subscribe(listener,
+               ('t1','t2')) means that when calling sendMessage('t1'),
+               listener gets one message, but when calling
+               sendMessage(('t1','t2')), listener gets message twice.
+        
         """
+        self.validate(listener)
+
+        if topic is None: 
+            raise TypeError, 'Topic must be either a word, tuple of '\
+                             'words, or getStrAllTopics()'
+            
+        self.__topicTree.addTopic(_tupleize(topic), listener)
+
+    def isSubscribed(self, listener, topic=None):
+        """Return true if listener has subscribed to topic specified. 
+        If no topic specified, return true if subscribed to something.
+        Use topic=getStrAllTopics() to determine if a listener will receive 
+        messages for all topics."""
+        return self.__topicTree.isSubscribed(listener, topic)
+            
+    def validate(self, listener):
+        """Similar to isValid(), but raises a TypeError exception if not valid"""
+        # check callable
         if not callable(listener):
-            raise TypeError('The P/S listener, '+`listener`+', is not callable.')
-        aTopic = Topic(topic)
-
-        # Determine now (at registration time) how many parameters
-        # the listener expects, and get a reference to a function which
-        # calls it correctly at message-send time.
-        callableVersion = self.__makeCallable(listener)
-
-        # Add this tuple to a list which is in a dict keyed by
-        # the topic's first element.
-        self.__addTopicToCorrectList(aTopic, listener, callableVersion)
-
-        # Add to a dict in order to speed-up unsubscribing.
-        self.__addFunctionLookup(listener, aTopic)
-
-
-    def unsubscribe(self, listener):
+            raise TypeError, 'Listener '+`listener`+' must be a '\
+                             'function, bound method or instance.'
+        # ok, callable, but if method, is it bound:
+        elif ismethod(listener) and not _isbound(listener):
+            raise TypeError, 'Listener '+`listener`+\
+                             ' is a method but it is unbound!'
+                             
+        # check that it takes the right number of parameters
+        min, d = _paramMinCount(listener)
+        if min > 1:
+            raise TypeError, 'Listener '+`listener`+" can't"\
+                             ' require more than one parameter!'
+        if min <= 0 and d == 0:
+            raise TypeError, 'Listener '+`listener`+' lacking arguments!'
+                             
+        assert (min == 0 and d>0) or (min == 1)
+
+    def isValid(self, listener):
+        """Return true only if listener will be able to subscribe to 
+        Publisher."""
+        try: 
+            self.validate(listener)
+            return True
+        except TypeError:
+            return False
+
+    def unsubAll(self, topics=None, onNoSuchTopic=None):
+        """Unsubscribe all listeners subscribed for topics. Topics can 
+        be a single topic (string or tuple) or a list of topics (ie 
+        list containing strings and/or tuples). If topics is not 
+        specified, all listeners for all topics will be unsubscribed, 
+        ie. the Publisher singleton will have no topics and no listeners
+        left. If onNoSuchTopic is given, it will be called as 
+        onNoSuchTopic(topic) for each topic that is unknown.
         """
-        Remove the given listener from the registry,
-        for all topics that it's associated with.
+        if topics is None: 
+            del self.__topicTree
+            self.__topicTree = _TopicTreeRoot()
+            return
+        
+        # make sure every topics are in tuple form
+        if isinstance(topics, list):
+            topicList = [_tupleize(x) for x in topics]
+        else:
+            topicList = [_tupleize(topics)]
+            
+        # unsub every listener of topics
+        self.__topicTree.unsubAll(topicList, onNoSuchTopic)
+            
+    def unsubscribe(self, listener, topics=None):
+        """Unsubscribe listener. If topics not specified, listener is
+        completely unsubscribed. Otherwise, it is unsubscribed only 
+        for the topic (the usual tuple) or list of topics (ie a list
+        of tuples) specified. Nothing happens if listener is not actually
+        subscribed to any of the topics.
+        
+        Note that if listener subscribed for two topics (a,b) and (a,c), 
+        then unsubscribing for topic (a) will do nothing. You must 
+        use getAssociatedTopics(listener) and give unsubscribe() the returned 
+        list (or a subset thereof).
         """
-        if not callable(listener):
-            raise TypeError('The P/S listener, '+`listener`+', is not callable.')
-        topicList = self.getAssociatedTopics(listener)
-        for aTopic in topicList:
-            subscriberList = self.__getTopicList(aTopic)
-            listToKeep = []
-            for subscriber in subscriberList:
-                if subscriber[0] != listener:
-                    listToKeep.append(subscriber)
-            self.__setTopicList(aTopic, listToKeep)
-        self.__delFunctionLookup(listener)
-
-
+        self.validate(listener)
+        topicList = None
+        if topics is not None:
+            if isinstance(topics, list):
+                topicList = [_tupleize(x) for x in topics]
+            else:
+                topicList = [_tupleize(topics)]
+            
+        self.__topicTree.unsubscribe(listener, topicList)
+        
     def getAssociatedTopics(self, listener):
+        """Return a list of topics the given listener is registered with. 
+        Returns [] if listener never subscribed.
+        
+        :attention: when using the return of this method to compare to
+                expected list of topics, remember that topics that are
+                not in the form of a tuple appear as a one-tuple in
+                the return. E.g. if you have subscribed a listener to
+                'topic1' and ('topic2','subtopic2'), this method
+                returns::
+            
+                associatedTopics = [('topic1',), ('topic2','subtopic2')]
         """
-        Return a list of topics the given listener is
-        registered with.
-        """
-        return self.functionDict.get(listener, [])
-
-
-    def sendMessage(self, topic, data=None):
-        """
-        Relay a message to registered listeners.
-        """
-        aTopic    = Topic(topic)
-        message   = Message(aTopic.items, data)
-        topicList = self.__getTopicList(aTopic)
-
-        # Send to the matching topics
-        for subscriber in topicList:
-            if subscriber[1].matches(aTopic):
-                subscriber[2](message)
-
-        # Send to any listeners registered for ALL
-        for subscriber in self.subscribeAllList:
-            subscriber[2](message)
-
-
+        return self.__topicTree.getTopics(listener)
+    
+    def sendMessage(self, topic=ALL_TOPICS, data=None, onTopicNeverCreated=None):
+        """Send a message for given topic, with optional data, to
+        subscribed listeners. If topic is not specified, only the
+        listeners that are interested in all topics will receive message. 
+        The onTopicNeverCreated is an optional callback of your choice that 
+        will be called if the topic given was never created (i.e. it, or 
+        one of its subtopics, was never subscribed to by any listener). 
+        It will be called as onTopicNeverCreated(topic)."""
+        aTopic  = _tupleize(topic)
+        message = Message(aTopic, data)
+        self.__messageCount += 1
+        
+        # send to those who listen to all topics
+        self.__deliveryCount += \
+            self.__topicTree.sendMessage(aTopic, message, onTopicNeverCreated)
+        
     #
     # Private methods
     #
 
-    def __makeCallable(self, function):
-        """
-        Return a function that is what the server
-        will actually call.
-
-        This is a time optimization: this removes a test
-        for the number of parameters from the inner loop
-        of sendMessage().
-        """
-        parameters = self.__parameterCount(function)
-        if parameters == 0:
-            # Return a function that calls the listener
-            # with no arguments.
-            return lambda m, f=function: f()
-        elif parameters == 1:
-            # Return a function that calls the listener
-            # with one argument (which will be the message).
-            return lambda m, f=function: f(m)
-        else:
-            raise TypeError('The publish/subscribe listener, '+`function`+', has wrong parameter count')
-
-
-    def __parameterCount(self, callableObject):
-        """
-        Return the effective number of parameters required
-        by the callable object.  In other words, the 'self'
-        parameter of methods is not counted.
-        """
-        try:
-            # Try to handle this like a method
-            return callableObject.im_func.func_code.co_argcount - 1
-        except AttributeError:
-            pass
-
-        try:
-            # Try to handle this like a function
-            return callableObject.func_code.co_argcount
-        except AttributeError:
-            raise 'Cannot determine if this is a method or function: '+str(callableObject)
-
-    def __addFunctionLookup(self, aFunction, aTopic):
-        try:
-            aList = self.functionDict[aFunction]
-        except KeyError:
-            aList = []
-            self.functionDict[aFunction] = aList
-        aList.append(aTopic)
-
-
-    def __delFunctionLookup(self, aFunction):
-        try:
-            del self.functionDict[aFunction]
-        except KeyError:
-            print 'Warning: listener not found. Logic error in PublishSubscribe?', aFunction
-
-
-    def __addTopicToCorrectList(self, topic, listener, callableVersion):
-        if len(topic.items) == 0:
-            self.subscribeAllList.append((listener, topic, callableVersion))
-        else:
-            self.__getTopicList(topic).append((listener, topic, callableVersion))
-
-
-    def __getTopicList(self, aTopic):
-        """
-        Return the correct sublist of subscribers based on the
-        given topic.
-        """
-        try:
-            elementZero = aTopic.items[0]
-        except IndexError:
-            return self.subscribeAllList
-
-        try:
-            subList = self.topicDict[elementZero]
-        except KeyError:
-            subList = []
-            self.topicDict[elementZero] = subList
-        return subList
-
-
-    def __setTopicList(self, aTopic, aSubscriberList):
-        try:
-            self.topicDict[aTopic.items[0]] = aSubscriberList
-        except IndexError:
-            self.subscribeAllList = aSubscriberList
-
-
     def __call__(self):
+        """Allows for singleton"""
         return self
+    
+    def __str__(self):
+        return str(self.__topicTree)
 
-
-# Create an instance with the same name as the class, effectivly
-# hiding the class object so it can't be instantiated any more.  From
+# Create the Publisher singleton. We prevent users from (inadvertently)
+# instantiating more than one object, by requiring a key that is 
+# accessible only to module.  From
 # this point forward any calls to Publisher() will invoke the __call__
 # of this instance which just returns itself.
 #
@@ -250,15 +816,18 @@ class Publisher:
 # class from Publisher without jumping through hoops.  If this ever
 # becomes an issue then a new Singleton implementaion will need to be
 # employed.
-Publisher = Publisher()
+_key = _SingletonKey()
+Publisher = PublisherClass(_key)
 
 
 #---------------------------------------------------------------------------
 
 class Message:
     """
-    A simple container object for the two components of
-    a message; the topic and the data.
+    A simple container object for the two components of a message: the 
+    topic and the user data. An instance of Message is given to your 
+    listener when called by Publisher().sendMessage(topic) (if your
+    listener callback was registered for that topic).
     """
     def __init__(self, topic, data):
         self.topic = topic
@@ -268,115 +837,407 @@ class Message:
         return '[Topic: '+`self.topic`+',  Data: '+`self.data`+']'
 
 
-#---------------------------------------------------------------------------
-
-class Topic:
-    """
-    A class that represents a publish/subscribe topic.
-    Currently, it's only used internally in the framework; the
-    API expects and returns plain old tuples.
-
-    It currently exists mostly as a place to keep the matches()
-    function.  This function, though, could also correctly be
-    seen as an attribute of the P/S server.  Getting rid of this
-    class would also mean one fewer object instantiation per
-    message send.
-    """
-
-    listType   = type([])
-    tupleType  = type(())
-
-    def __init__(self, items):
-        # Make sure we have a tuple.
-        if type(items) == self.__class__.listType:
-            items = tuple(items)
-        elif type(items) != self.__class__.tupleType:
-            items = (items,)
-        self.items  = items
-        self.length = len(items)
-
-
-    def matches(self, aTopic):
-        """
-        Consider myself to be a topic pattern,
-        and return True if I match the given specific
-        topic.  For example,
-        a = ('sports')
-        b = ('sports','baseball')
-        a.matches(b) --> 1
-        b.matches(a) --> 0
-        """
-        # The question this method answers is equivalent to;
-        # is my list a sublist of aTopic's?  So, my algorithm
-        # is: 1) make a copy of the aTopic list which is
-        # truncated to the pattern's length. 2) Test for
-        # equality.
-        #
-        # This algorithm may be somewhat memory-intensive,
-        # because it creates a temporary list on each
-        # call to match.  A possible to-do would be to
-        # re-write this with a hand-coded loop.
-        return (self.items == aTopic.items[:self.length])
-
-
-    def __repr__(self):
-        import string
-        return '<Topic>' + string.join(map(repr, self.items), ', ') + '</Topic>'
-
-
-    def __eq__(self, aTopic):
-        """
-        Return True if I equal the given topic.  We're considered
-        equal if our tuples are equal.
-        """
-        if type(self) != type(aTopic):
-            return 0
-        else:
-            return self.items == aTopic.items
-
-
-    def __ne__(self, aTopic):
-        """
-        Return False if I equal the given topic.
-        """
-        return not self == aTopic
-
-
 #---------------------------------------------------------------------------
 
 
 #
 # Code for a simple command-line test
 #
-if __name__ == '__main__':
-
+def test():
+    def done(funcName):
+        print '----------- Done %s -----------' % funcName
+        
+    def testParam():
+        def testFunc00(): pass
+        def testFunc21(a,b,c=1): pass
+        def testFuncA(*args): pass
+        def testFuncAK(*args,**kwds): pass
+        def testFuncK(**kwds): pass
+        
+        class Foo:
+            def testMeth(self,a,b): pass
+            def __call__(self, a): pass
+        class Foo2:
+            def __call__(self, *args): pass
+            
+        assert _paramMinCount(testFunc00)==(0,0)
+        assert _paramMinCount(testFunc21)==(2,1)
+        assert _paramMinCount(testFuncA) ==(1,0)
+        assert _paramMinCount(testFuncAK)==(1,0)
+        assert _paramMinCount(testFuncK) ==(0,0)
+        foo = Foo()
+        assert _paramMinCount(Foo.testMeth)==(2,0)
+        assert _paramMinCount(foo.testMeth)==(2,0)
+        assert _paramMinCount(foo)==(1,0)
+        assert _paramMinCount(Foo2())==(1,0)
+    
+        done('testParam')
+
+    testParam()
+    #------------------------
+
+    _NodeCallback.notified = 0
+    def testPreNotifyNode(self, dead):
+        _NodeCallback.notified += 1
+        print 'testPreNotifyNODE heard notification of', `dead`
+    _NodeCallback.preNotify = testPreNotifyNode
+    
+    def testTreeNode():
+
+        class WS:
+            def __init__(self, s):
+                self.s = s
+            def __call__(self, msg):
+                print 'WS#', self.s, ' received msg ', msg
+            def __str__(self):
+                return self.s
+            
+        def testPreNotifyRoot(dead):
+            print 'testPreNotifyROOT heard notification of', `dead`
+    
+        node = _TopicTreeNode((ALL_TOPICS,), WeakRef(testPreNotifyRoot))
+        boo, baz, bid = WS('boo'), WS('baz'), WS('bid')
+        node.addCallable(boo)
+        node.addCallable(baz)
+        node.addCallable(boo)
+        assert node.getCallables() == [boo,baz]
+        assert node.hasCallable(boo)
+        
+        node.removeCallable(bid) # no-op
+        assert node.hasCallable(baz)
+        assert node.getCallables() == [boo,baz]
+        
+        node.removeCallable(boo)
+        assert node.getCallables() == [baz]
+        assert node.hasCallable(baz)
+        assert not node.hasCallable(boo)
+        
+        node.removeCallable(baz)
+        assert node.getCallables() == []
+        assert not node.hasCallable(baz)
+
+        node2 = node.createSubtopic('st1', ('st1',))
+        node3 = node.createSubtopic('st2', ('st2',))
+        cb1, cb2, cb = WS('st1_cb1'), WS('st1_cb2'), WS('st2_cb')
+        node2.addCallable(cb1)
+        node2.addCallable(cb2)
+        node3.addCallable(cb)
+        node2.createSubtopic('st3', ('st1','st3'))
+        node2.createSubtopic('st4', ('st1','st4'))
+       
+        print str(node)
+        assert str(node) == ' (st1: st1_cb1 st1_cb2  (st4: ) (st3: )) (st2: st2_cb )'
+    
+        # verify send message, and that a dead listener does not get sent one
+        delivered = node2.sendMessage('hello')
+        assert delivered == 2
+        del cb1
+        delivered = node2.sendMessage('hello')
+        assert delivered == 1
+        assert _NodeCallback.notified == 1
+        
+        done('testTreeNode')
+        
+    testTreeNode()
+    #------------------------
+    
+    def testValidate():
+        class Foo:
+            def __call__(self, a):   pass
+            def fun(self, b):        pass
+            def fun2(self, b=1):     pass
+            def fun3(self, a, b=2):  pass
+            def badFun(self):        pass
+            def badFun2():           pass
+            def badFun3(self, a, b): pass
+            
+        server = Publisher()
+        foo = Foo()
+        server.validate(foo)
+        server.validate(foo.fun)
+        server.validate(foo.fun2)
+        server.validate(foo.fun3)
+        assert not server.isValid(foo.badFun)
+        assert not server.isValid(foo.badFun2)
+        assert not server.isValid(foo.badFun3)
+    
+        done('testValidate')
+
+    testValidate()
+    #------------------------
+    
     class SimpleListener:
         def __init__(self, number):
             self.number = number
+        def __call__(self, message = ''): 
+            print 'Callable #%s got the message "%s"' %(self.number, message)
         def notify(self, message):
-            print '#'+str(self.number)+' got the message:', message
-
-    # Build a list of ten listeners.
-    lList = []
-    for x in range(10):
-        lList.append(SimpleListener(x))
-
-    server = Publisher()
-
-    # Everyone's interested in politics...
-    for x in lList:
-        Publisher().subscribe(topic='politics', listener=x.notify)  # also tests singleton
-
-    # But only the first four are interested in trivia.
-    for x in lList[:4]:
-        server.subscribe(topic='trivia',   listener=x.notify)
-
-    # This one subscribes to everything.
-    everythingListener = SimpleListener(999)
-    server.subscribe(topic=(), listener=everythingListener.notify)
-
-    # Now send out two messages, testing topic matching.
-    server.sendMessage(topic='trivia',               data='What is the capitol of Oregon?')
-    server.sendMessage(topic=('politics','germany'), data='The Greens have picked up another seat in the Bundestag.')
-
+            print '%s.notify() got the message "%s"' %(self.number, message)
+        def __str__(self):
+            return "SimpleListener_%s" % self.number
+
+    def testSubscribe():
+        publisher = Publisher()
+        
+        topic1 = 'politics'
+        topic2 = ('history','middle age')
+        topic3 = ('politics','UN')
+        topic4 = ('politics','NATO')
+        topic5 = ('politics','NATO','US')
+        
+        lisnr1 = SimpleListener(1)
+        lisnr2 = SimpleListener(2)
+        def func(message, a=1): 
+            print 'Func received message "%s"' % message
+        lisnr3 = func
+        lisnr4 = lambda x: 'Lambda received message "%s"' % x
+
+        assert not publisher.isSubscribed(lisnr1)
+        assert not publisher.isSubscribed(lisnr2)
+        assert not publisher.isSubscribed(lisnr3)
+        assert not publisher.isSubscribed(lisnr4)
+        
+        publisher.subscribe(lisnr1, topic1)
+        assert publisher.getAssociatedTopics(lisnr1) == [(topic1,)]
+        publisher.subscribe(lisnr1, topic2)
+        publisher.subscribe(lisnr1, topic1) # do it again, should be no-op
+        assert publisher.getAssociatedTopics(lisnr1) == [(topic1,),topic2]
+        publisher.subscribe(lisnr2.notify, topic3)
+        assert publisher.getAssociatedTopics(lisnr2.notify) == [topic3]
+        assert publisher.getAssociatedTopics(lisnr1) == [(topic1,),topic2]
+        publisher.subscribe(lisnr3, topic5)
+        assert publisher.getAssociatedTopics(lisnr3) == [topic5]
+        assert publisher.getAssociatedTopics(lisnr2.notify) == [topic3]
+        assert publisher.getAssociatedTopics(lisnr1) == [(topic1,),topic2]
+        publisher.subscribe(lisnr4)
+        
+        print "Publisher tree: ", publisher
+        assert publisher.isSubscribed(lisnr1)
+        assert publisher.isSubscribed(lisnr1, topic1)
+        assert publisher.isSubscribed(lisnr1, topic2)
+        assert publisher.isSubscribed(lisnr2.notify)
+        assert publisher.isSubscribed(lisnr3, topic5)
+        assert publisher.isSubscribed(lisnr4, ALL_TOPICS)
+        expectTopicTree = 'all: <lambda>  (politics: SimpleListener_1  (UN: SimpleListener_2.notify ) (NATO:  (US: func ))) (history:  (middle age: SimpleListener_1 ))'
+        print "Publisher tree: ", publisher
+        assert str(publisher) == expectTopicTree
+        
+        publisher.unsubscribe(lisnr1, 'booboo') # should do nothing
+        assert publisher.getAssociatedTopics(lisnr1) == [(topic1,),topic2]
+        assert publisher.getAssociatedTopics(lisnr2.notify) == [topic3]
+        assert publisher.getAssociatedTopics(lisnr3) == [topic5]
+        publisher.unsubscribe(lisnr1, topic1)
+        assert publisher.getAssociatedTopics(lisnr1) == [topic2]
+        assert publisher.getAssociatedTopics(lisnr2.notify) == [topic3]
+        assert publisher.getAssociatedTopics(lisnr3) == [topic5]
+        publisher.unsubscribe(lisnr1, topic2)
+        publisher.unsubscribe(lisnr1, topic2)
+        publisher.unsubscribe(lisnr2.notify, topic3)
+        publisher.unsubscribe(lisnr3, topic5)
+        assert publisher.getAssociatedTopics(lisnr1) == []
+        assert publisher.getAssociatedTopics(lisnr2.notify) == []
+        assert publisher.getAssociatedTopics(lisnr3) == []
+        publisher.unsubscribe(lisnr4)
+        
+        expectTopicTree = 'all:  (politics:  (UN: ) (NATO:  (US: ))) (history:  (middle age: ))'
+        print "Publisher tree: ", publisher
+        assert str(publisher) == expectTopicTree
+        assert publisher.getDeliveryCount() == 0
+        assert publisher.getMessageCount() == 0
+        
+        publisher.unsubAll()
+        assert str(publisher) == 'all: '
+        
+        done('testSubscribe')
+    
+    testSubscribe()
+    #------------------------
+    
+    def testUnsubAll():
+        publisher = Publisher()
+        
+        topic1 = 'politics'
+        topic2 = ('history','middle age')
+        topic3 = ('politics','UN')
+        topic4 = ('politics','NATO')
+        topic5 = ('politics','NATO','US')
+        
+        lisnr1 = SimpleListener(1)
+        lisnr2 = SimpleListener(2)
+        def func(message, a=1): 
+            print 'Func received message "%s"' % message
+        lisnr3 = func
+        lisnr4 = lambda x: 'Lambda received message "%s"' % x
+
+        publisher.subscribe(lisnr1, topic1)
+        publisher.subscribe(lisnr1, topic2)
+        publisher.subscribe(lisnr2.notify, topic3)
+        publisher.subscribe(lisnr3, topic2)
+        publisher.subscribe(lisnr3, topic5)
+        publisher.subscribe(lisnr4)
+        
+        expectTopicTree = 'all: <lambda>  (politics: SimpleListener_1  (UN: SimpleListener_2.notify ) (NATO:  (US: func ))) (history:  (middle age: SimpleListener_1 func ))'
+        print "Publisher tree: ", publisher
+        assert str(publisher) == expectTopicTree
+    
+        publisher.unsubAll(topic1)
+        assert publisher.getAssociatedTopics(lisnr1) == [topic2]
+        assert not publisher.isSubscribed(lisnr1, topic1)
+        
+        publisher.unsubAll(topic2)
+        print publisher
+        assert publisher.getAssociatedTopics(lisnr1) == []
+        assert publisher.getAssociatedTopics(lisnr3) == [topic5]
+        assert not publisher.isSubscribed(lisnr1)
+        assert publisher.isSubscribed(lisnr3, topic5)
+        
+        #print "Publisher tree: ", publisher
+        expectTopicTree = 'all: <lambda>  (politics:  (UN: SimpleListener_2.notify ) (NATO:  (US: func ))) (history:  (middle age: ))'
+        assert str(publisher) == expectTopicTree
+        publisher.unsubAll(ALL_TOPICS)
+        #print "Publisher tree: ", publisher
+        expectTopicTree = 'all:  (politics:  (UN: SimpleListener_2.notify ) (NATO:  (US: func ))) (history:  (middle age: ))'
+        assert str(publisher) == expectTopicTree
+        
+        publisher.unsubAll()
+        done('testUnsubAll')
+    
+    testUnsubAll()
+    #------------------------
+    
+    def testSend():
+        publisher = Publisher()
+        called = []
+        
+        class TestListener:
+            def __init__(self, num):
+                self.number = num
+            def __call__(self, b): 
+                called.append( 'TL%scb' % self.number )
+            def notify(self, b):
+                called.append( 'TL%sm' % self.number )
+        def funcListener(b):
+            called.append('func')
+            
+        lisnr1 = TestListener(1)
+        lisnr2 = TestListener(2)
+        lisnr3 = funcListener
+        lisnr4 = lambda x: called.append('lambda')
+
+        topic1 = 'politics'
+        topic2 = 'history'
+        topic3 = ('politics','UN')
+        topic4 = ('politics','NATO','US')
+        topic5 = ('politics','NATO')
+        
+        publisher.subscribe(lisnr1, topic1)
+        publisher.subscribe(lisnr2, topic2)
+        publisher.subscribe(lisnr2.notify, topic2)
+        publisher.subscribe(lisnr3, topic4)
+        publisher.subscribe(lisnr4)
+        
+        print publisher
+        
+        # setup ok, now test send/receipt
+        publisher.sendMessage(topic1)
+        assert called == ['lambda','TL1cb']
+        called = []
+        publisher.sendMessage(topic2)
+        assert called == ['lambda','TL2cb','TL2m']
+        called = []
+        publisher.sendMessage(topic3)
+        assert called == ['lambda','TL1cb']
+        called = []
+        publisher.sendMessage(topic4)
+        assert called == ['lambda','TL1cb','func']
+        called = []
+        publisher.sendMessage(topic5)
+        assert called == ['lambda','TL1cb']
+        assert publisher.getDeliveryCount() == 12
+        assert publisher.getMessageCount() == 5
+    
+        # test weak referencing works:
+        _NodeCallback.notified = 0
+        del lisnr2
+        called = []
+        publisher.sendMessage(topic2)
+        assert called == ['lambda']
+        assert _NodeCallback.notified == 2
+        
+        done('testSend')
+        
+    testSend()
+    assert _NodeCallback.notified == 5
+    
+    def testDead():
+        # verify if weak references work as expected
+        print '------ Starting testDead ----------'
+        node = _TopicTreeNode('t1', None)
+        lisnr1 = SimpleListener(1)
+        lisnr2 = SimpleListener(2)
+        lisnr3 = SimpleListener(3)
+        lisnr4 = SimpleListener(4)
+
+        node.addCallable(lisnr1)
+        node.addCallable(lisnr2)
+        node.addCallable(lisnr3)
+        node.addCallable(lisnr4)
+        
+        print 'Deleting listeners first'
+        _NodeCallback.notified = 0
+        del lisnr1
+        del lisnr2
+        assert _NodeCallback.notified == 2
+        
+        print 'Deleting node first'
+        _NodeCallback.notified = 0
+        del node
+        del lisnr3
+        del lisnr4
+        assert _NodeCallback.notified == 0
+        
+        lisnr1 = SimpleListener(1)
+        lisnr2 = SimpleListener(2)
+        lisnr3 = SimpleListener(3)
+        lisnr4 = SimpleListener(4)
+        
+        # try same with root of tree
+        node = _TopicTreeRoot()
+        node.addTopic(('',), lisnr1)
+        node.addTopic(('',), lisnr2)
+        node.addTopic(('',), lisnr3)
+        node.addTopic(('',), lisnr4)
+        # add objects that will die immediately to see if cleanup occurs
+        # this must be done visually as it is a low-level detail
+        _NodeCallback.notified = 0
+        _TopicTreeRoot.callbackDeadLimit = 3
+        node.addTopic(('',), SimpleListener(5))
+        node.addTopic(('',), SimpleListener(6))
+        node.addTopic(('',), SimpleListener(7))
+        print node.numListeners()
+        assert node.numListeners() == (4, 3)
+        node.addTopic(('',), SimpleListener(8))
+        assert node.numListeners() == (4, 0)
+        assert _NodeCallback.notified == 4
+        
+        print 'Deleting listeners first'
+        _NodeCallback.notified = 0
+        del lisnr1
+        del lisnr2
+        assert _NodeCallback.notified == 2
+        print 'Deleting node first'
+        _NodeCallback.notified = 0
+        del node
+        del lisnr3
+        del lisnr4
+        assert _NodeCallback.notified == 0
+        
+        done('testDead')
+    
+    testDead()
+    
+    print 'Exiting tests'
 #---------------------------------------------------------------------------
+
+if __name__ == '__main__':
+    test()