This is a longer post than usual, but it’s pulling several different topics together.
While working on a personal project, I felt the need to mock up PyQt signals. If you’re not familiar with signals, they are an implementation of the observer pattern used to pass events in the Qt system. In the C++ implementation, they are very powerful and expressive, as the Qt MOC does a lot of work to make them easy to use and overcome a lot of work you’d need to do to get flexible callbacks in pure C++. In Python, they are not as cool, only because Python’s dynamic binding and duck-typing make implementing arbitrary callbacks a snap.
To use a signal in PyQt, you instantiate a QObject with a signal and connect it to a slot on another QObject. All of the Qt widgets come with signals, and often you will use them or implement your own. A sample custom signal/slot system is seen below:
from PySide import QtCore class MySource(QtCore.QObject): mySignal = QtCore.Signal(object, int) def __init__(self, parent=None): QtCore.QObject.__init__(self, parent) class MyDest(QtCore.QObject): def __init__(self, parent=None): QtCore.QObject.__init__(self, parent) def mySlot(self, obj, i): print('Object: %s, Int: %s' % (obj.__class__.__name__, i)) if __name__ == '__main__': a = MySource() b = MyDest() a.mySignal.connect(b.mySlot) a.mySignal.emit(a, 12)
Exec’ing this file yields:
Object: MySource, Int: 12
I was working with PySide and getting some errors, so I decided I would make classes that would function like PyQt objects with signals, get the code working, then swap out the underlying objects with their Qt equivalents. I’ve always liked Qt’s signals, and ended up using the Python implementation a fair amount in my project, and thought it would be a good exercise to create them from scratch.
To emulate the calling style of a PyQt signal, the Python descriptor is a natural choice. Descriptors normally get or set a value. In this case, we want the __get__ to return an object that has a connect method that will create the signal connection. The connection is an entry in a WeakKeyDictionary. The weak key dict will cleanup the signals when an object is deleted by the Python garbage collector – this prevents signal connections from keeping an object from being deleted.
The _Sig object is responsible for mapping the class instance to the target instances. It stores the args of the signal, which would be the types that are going to be sent when the signal’s emit function is called. WeakKeyDictionary is used throughout to prevent circular references from keeping objects alive – this is strictly an observer pattern.
It would be nice to keep a reference only to the methods, but I was unsuccessful storing those in a WeakSet, so the object itself is needed. The _Sig class will only work with class instances, although it should be possible to adapt to trigger static functions.
import weakref,inspect class _Sig(object): """Maps objects to create Signal connection.""" def __init__(self, pr, args): """ Args: pr (object): An instance of a class. args (list or tuple): A sequence of arg types. Attributes: _ob (object): Instance of the class that would be the sender. _args (list or tuple): The sequence of arg types to use for the Signal. _conn (WeakKeyDictionary): Maps the target item to the target method. """ self._ob = pr self._args = args self._conn = weakref.WeakKeyDictionary() def emit(self, *args): """Emits the signal Args: The values to emit - the types should match _args. Note: If _ob has a method called 'signalsBlocked', it will be called. If the method returns True, the signal is not emitted. Raises: TypeError: If one of the emitted args is not an instance of _args, TypeError is raised. """ if hasattr(self._ob, 'signalsBlocked') and self._ob.signalsBlocked(): return idx = 0 for ik,k in zip(self._args, args): if not isinstance(k, ik): msg = 'Pos %s: Expected %s, got %s' % (idx, ik, k) raise TypeError(msg) idx += 1 for val in self._conn.values(): for ck in val: ck(*args) def connect(self, method): """Create connection to method. Args: method (instanceMethod): The 'slot' method to be called when Signal is emitted. Raises: TypeError: Method must be a method on a class instance. """ if not inspect.ismethod(method): raise TypeError('Expected method, got %s' % method.__class__) self._conn.setdefault(method.__self__, []).append(method) def disconnect(self, method): """Disconnect a method from the signal. Args: method (instanceMethod): The 'slot' method to be disconnected. """ current = self._conn.get(method.__self__, []) current = [x for x in current if x.im_func is not method.im_func] assert(method not in current) if current: self._conn[method.__self__] = current else: self._conn.pop(method.__self__, None) def getOutputs(self): """Get a list of output methods. Returns: A list of methods that will be triggered when this signal is emitted. """ result = [] for val in self._conn.values(): result.extend(val) return result
By itself, the _Sig does nothing – it needs a descriptor to access it. The Signal descriptor uses a __set__ method that raises a RuntimeError, which keeps the user from assigning a value to the attribute and thus overwriting it.
class Signal(object): """The actual Signal object. This descriptor does the work of providing access to the underlying _Sig object. Attributes: _map (dict): Map of Signal instances to WeakKeyDictionaries. """ # Map this signal instance to some data. _map = {} def __init__(self, *args): """Initialize the Signal Args: List of types - these types are used to match the types emitted by the Signal. """ self._args = args def __get__(self, obj, typ=None): """Return a _Sig object mapped to obj. We use the signal as the key so that other signals do not collide. Each Signal instance is unique and located in a class definition. A weak key dict maps the actual instance to the _Sig object. Args: obj (object): Instance of for the descriptor. typ (class): The owner. Returns: A _Sig object initialized to obj. """ tmp = self._map.setdefault(self, weakref.WeakKeyDictionary()) return tmp.setdefault(obj, _Sig(obj, self._args)) def __set__(self, obj, val): """Raise an exception. This prevents a user from setting an attribute on the instance that would replace the signal. Args: obj (object): Instance of class. val (object): Value to set to. Raises: RuntimeError: Always raise this. """ raise RuntimeError('Cannot assign to Signal')
To use the signal, put it in a class definition and connect it from an instance of the class. Notice that Python class instances should be new-style classes derived from object.
class Source(object): test = Signal(int, float, object) class Target(object): def slot(self, i, f, o): print('slot(%s, %s, %s)' % (i, f, o)) src = Source() trg = Target() src.test.connect(trg.slot) src.test.emit(12, 33.4, src.__class__.__name__)
Executing the code above yields:
slot(12, 33.4, Source)
This implementation of the Signal checks the types of the args sent, which was it’s goal since it is emulating a Qt style signal. Different implementations could omit the type-checking, although I find it handy in larger systems. There is no concept of the sender() method – this is a method on QObjects that interfaces with the Signals. It would be possible to add the sender as an argument when the signal is emitted. For Python, one could also add optional keyword arguments. That will be left as an exercise for the reader.
There is also no checking for cycles – one could create an endless cycle of signals to slots. I may address this in a future post if it comes up.