Repository: pyqtgraph/pyqtgraph

Issue:
TypeError in SignalProxy __init__
<!-- In the following, please describe your issue in detail! -->
<!-- If some of the sections do not apply, just remove them. -->

### Short description
Found a small bug in `SignalProxy.__init__()` that occurs when creating an instance of `SignalProxy` without directly handing over a slot to connect to.
The default value for the optional init parameter `slot` is `None` and so you can not create a `weakref` from that.
The culprit is line 38 of _SignalProxy.py_ https://github.com/pyqtgraph/pyqtgraph/blob/develop/pyqtgraph/SignalProxy.py#L38

My proposal would be to get rid of the instance attribute `self.slot` entirely since you only use it for disconnection in `SignalProxy.disconnect`. This is not needed as you could simply call `self.sigDelayed.disconnect()` without any arguments. 
This would be even better since it would disconnect ALL slots (even the ones "manually" connected) from `sigDelayed` as the user would expect from calling `disconnect` on any `QObject` (see also https://doc.qt.io/qt-5/qobject.html#disconnect).

### Code to reproduce
<!-- Please provide a minimal working example that reproduces the issue in the code block below.
     Ideally, this should be a full example someone else could run without additional setup. -->
```python
# -*- coding: utf-8 -*-
import pyqtgraph as pg
import numpy as np

class Dummy(pg.QtCore.QObject):
    sigTest = pg.QtCore.Signal()
    
    def my_slot():
        pass


if __name__ == '__main__':
    app = pg.QtCore.QCoreApplication([])
    dummy_obj = Dummy()
    proxy = pg.SignalProxy(dummy_obj.sigTest)  # raises TypeError
    proxy.sigDelayed.connect(dummy_obj.my_slot)
    # This would work instead:
    # proxy = pg.SignalProxy(dummy_obj.sigTest, slot=dummy_obj.my_slot)
```

### Expected behavior
One should be able to create instances of `SignalProxy` by just passing the sender signal.
It seems like this was intended behaviour, as it should be, since it can be beneficial to create a proxy and connect the receiving slot later.

### Real behavior
```
Traceback (most recent call last):
  File ".\pg_signal_proxy_test.py", line 15, in <module>
    proxy = pg.SignalProxy(dummy_obj.sigTest)  # raises TypeError
  File "C:\Program Files\Python37\lib\site-packages\pyqtgraph\SignalProxy.py", line 38, in __init__
    self.slot = weakref.ref(slot)
TypeError: cannot create weak reference to 'NoneType' object
```

### Tested environment(s)

 * PyQtGraph version: 0.10.0.4
 * Qt Python binding: PyQt5 5.9.2 Qt 5.9.3
 * Python version: 3.7.7
 * NumPy version: 1.18.3
 * Operating system: Debian 10 (stable) & Win10 Enterprise (10.0.18363)
 * Installation method: pip



Discussion:
hi @Neverhorst 

Thanks for the bug report, I can see that this is still an issue in 0.11rc0.

Resolving patch:
diff --git a/pyqtgraph/SignalProxy.py b/pyqtgraph/SignalProxy.py
--- a/pyqtgraph/SignalProxy.py
+++ b/pyqtgraph/SignalProxy.py
@@ -1,23 +1,26 @@
 # -*- coding: utf-8 -*-
+import weakref
+
 from .Qt import QtCore
 from .ptime import time
 from . import ThreadsafeTimer
-import weakref
 from .functions import SignalBlock
 
 __all__ = ['SignalProxy']
 
+
 class SignalProxy(QtCore.QObject):
     """Object which collects rapid-fire signals and condenses them
     into a single signal or a rate-limited stream of signals. 
     Used, for example, to prevent a SpinBox from generating multiple 
     signals when the mouse wheel is rolled over it.
     
-    Emits sigDelayed after input signals have stopped for a certain period of time.
+    Emits sigDelayed after input signals have stopped for a certain period of
+    time.
     """
-    
+
     sigDelayed = QtCore.Signal(object)
-    
+
     def __init__(self, signal, delay=0.3, rateLimit=0, slot=None):
         """Initialization arguments:
         signal - a bound Signal or pyqtSignal instance
@@ -26,32 +29,36 @@ def __init__(self, signal, delay=0.3, rateLimit=0, slot=None):
         rateLimit - (signals/sec) if greater than 0, this allows signals to stream out at a 
                     steady rate while they are being received.
         """
-        
+
         QtCore.QObject.__init__(self)
-        signal.connect(self.signalReceived)
-        self.signal = signal
         self.delay = delay
         self.rateLimit = rateLimit
         self.args = None
         self.timer = ThreadsafeTimer.ThreadsafeTimer()
         self.timer.timeout.connect(self.flush)
-        self.blockSignal = False
-        self.slot = weakref.ref(slot)
         self.lastFlushTime = None
+        self.signal = signal
+        self.signal.connect(self.signalReceived)
         if slot is not None:
+            self.blockSignal = False
             self.sigDelayed.connect(slot)
-        
+            self.slot = weakref.ref(slot)
+        else:
+            self.blockSignal = True
+            self.slot = None
+
     def setDelay(self, delay):
         self.delay = delay
-        
+
     def signalReceived(self, *args):
-        """Received signal. Cancel previous timer and store args to be forwarded later."""
+        """Received signal. Cancel previous timer and store args to be
+        forwarded later."""
         if self.blockSignal:
             return
         self.args = args
         if self.rateLimit == 0:
             self.timer.stop()
-            self.timer.start((self.delay*1000)+1)
+            self.timer.start(int(self.delay * 1000) + 1)
         else:
             now = time()
             if self.lastFlushTime is None:
@@ -59,10 +66,10 @@ def signalReceived(self, *args):
             else:
                 lastFlush = self.lastFlushTime
                 leakTime = max(0, (lastFlush + (1.0 / self.rateLimit)) - now)
-                
+
             self.timer.stop()
-            self.timer.start((min(leakTime, self.delay)*1000)+1)
-        
+            self.timer.start(int(min(leakTime, self.delay) * 1000) + 1)
+
     def flush(self):
         """If there is a signal queued up, send it now."""
         if self.args is None or self.blockSignal:
@@ -70,10 +77,9 @@ def flush(self):
         args, self.args = self.args, None
         self.timer.stop()
         self.lastFlushTime = time()
-        #self.emit(self.signal, *self.args)
         self.sigDelayed.emit(args)
         return True
-        
+
     def disconnect(self):
         self.blockSignal = True
         try:
@@ -81,31 +87,23 @@ def disconnect(self):
         except:
             pass
         try:
+            # XXX: This is a weakref, however segfaulting on PySide and
+            # Python 2. We come back later
             self.sigDelayed.disconnect(self.slot)
         except:
             pass
-   
+        finally:
+            self.slot = None
+
+    def connectSlot(self, slot):
+        """Connect the `SignalProxy` to an external slot"""
+        assert self.slot is None, "Slot was already connected!"
+        self.slot = weakref.ref(slot)
+        self.sigDelayed.connect(slot)
+        self.blockSignal = False
+
     def block(self):
-        """Return a SignalBlocker that temporarily blocks input signals to this proxy.
+        """Return a SignalBlocker that temporarily blocks input signals to
+        this proxy.
         """
         return SignalBlock(self.signal, self.signalReceived)
-    
-    
-if __name__ == '__main__':
-    from .Qt import QtGui
-    app = QtGui.QApplication([])
-    win = QtGui.QMainWindow()
-    spin = QtGui.QSpinBox()
-    win.setCentralWidget(spin)
-    win.show()
-    
-    def fn(*args):
-        print("Raw signal:", args)
-    def fn2(*args):
-        print("Delayed signal:", args)
-    
-    
-    spin.valueChanged.connect(fn)
-    #proxy = proxyConnect(spin, QtCore.SIGNAL('valueChanged(int)'), fn)
-    proxy = SignalProxy(spin.valueChanged, delay=0.5, slot=fn2)
-        
\ No newline at end of file


Test patch:
diff --git a/pyqtgraph/tests/test_signalproxy.py b/pyqtgraph/tests/test_signalproxy.py
new file mode 100644
--- /dev/null
+++ b/pyqtgraph/tests/test_signalproxy.py
@@ -0,0 +1,153 @@
+import sys
+import pytest
+
+from ..Qt import QtCore
+from ..Qt import QtGui
+from ..Qt import QT_LIB, PYSIDE
+
+from ..SignalProxy import SignalProxy
+
+
+class Sender(QtCore.QObject):
+    signalSend = QtCore.Signal()
+
+    def __init__(self, parent=None):
+        super(Sender, self).__init__(parent)
+
+
+class Receiver(QtCore.QObject):
+
+    def __init__(self, parent=None):
+        super(Receiver, self).__init__(parent)
+        self.counter = 0
+
+    def slotReceive(self):
+        self.counter += 1
+
+
+@pytest.fixture
+def qapp():
+    app = QtGui.QApplication.instance()
+    if app is None:
+        app = QtGui.QApplication(sys.argv)
+    yield app
+
+    app.processEvents(QtCore.QEventLoop.AllEvents, 100)
+    app.deleteLater()
+
+
+def test_signal_proxy_slot(qapp):
+    """Test the normal work mode of SignalProxy with `signal` and `slot`"""
+    sender = Sender(parent=qapp)
+    receiver = Receiver(parent=qapp)
+    proxy = SignalProxy(sender.signalSend, delay=0.0, rateLimit=0.6,
+                        slot=receiver.slotReceive)
+
+    assert proxy.blockSignal is False
+    assert proxy is not None
+    assert sender is not None
+    assert receiver is not None
+
+    sender.signalSend.emit()
+    proxy.flush()
+    qapp.processEvents(QtCore.QEventLoop.AllEvents, 10)
+
+    assert receiver.counter > 0
+    del proxy
+    del sender
+    del receiver
+
+
+@pytest.mark.skipif(QT_LIB == PYSIDE and sys.version_info < (2, 8),
+                    reason="Crashing on PySide and Python 2.7")
+def test_signal_proxy_disconnect_slot(qapp):
+    """Test the disconnect of SignalProxy with `signal` and `slot`"""
+    sender = Sender(parent=qapp)
+    receiver = Receiver(parent=qapp)
+    proxy = SignalProxy(sender.signalSend, delay=0.0, rateLimit=0.6,
+                        slot=receiver.slotReceive)
+
+    assert proxy.blockSignal is False
+    assert proxy is not None
+    assert sender is not None
+    assert receiver is not None
+    assert proxy.slot is not None
+
+    proxy.disconnect()
+    assert proxy.slot is None
+    assert proxy.blockSignal is True
+
+    sender.signalSend.emit()
+    proxy.flush()
+    qapp.processEvents(QtCore.QEventLoop.AllEvents, 10)
+
+    assert receiver.counter == 0
+
+    del proxy
+    del sender
+    del receiver
+
+
+def test_signal_proxy_no_slot_start(qapp):
+    """Test the connect mode of SignalProxy without slot at start`"""
+    sender = Sender(parent=qapp)
+    receiver = Receiver(parent=qapp)
+    proxy = SignalProxy(sender.signalSend, delay=0.0, rateLimit=0.6)
+
+    assert proxy.blockSignal is True
+    assert proxy is not None
+    assert sender is not None
+    assert receiver is not None
+
+    sender.signalSend.emit()
+    proxy.flush()
+    qapp.processEvents(QtCore.QEventLoop.AllEvents, 10)
+    assert receiver.counter == 0
+
+    # Start a connect
+    proxy.connectSlot(receiver.slotReceive)
+    assert proxy.blockSignal is False
+    sender.signalSend.emit()
+    proxy.flush()
+    qapp.processEvents(QtCore.QEventLoop.AllEvents, 10)
+    assert receiver.counter > 0
+
+    # An additional connect should raise an AssertionError
+    with pytest.raises(AssertionError):
+        proxy.connectSlot(receiver.slotReceive)
+
+    del proxy
+    del sender
+    del receiver
+
+
+def test_signal_proxy_slot_block(qapp):
+    """Test the block mode of SignalProxy with `signal` and `slot`"""
+    sender = Sender(parent=qapp)
+    receiver = Receiver(parent=qapp)
+    proxy = SignalProxy(sender.signalSend, delay=0.0, rateLimit=0.6,
+                        slot=receiver.slotReceive)
+
+    assert proxy.blockSignal is False
+    assert proxy is not None
+    assert sender is not None
+    assert receiver is not None
+
+    with proxy.block():
+        sender.signalSend.emit()
+        sender.signalSend.emit()
+        sender.signalSend.emit()
+        proxy.flush()
+        qapp.processEvents(QtCore.QEventLoop.AllEvents, 10)
+
+        assert receiver.counter == 0
+
+    sender.signalSend.emit()
+    proxy.flush()
+    qapp.processEvents(QtCore.QEventLoop.AllEvents, 10)
+
+    assert receiver.counter > 0
+
+    del proxy
+    del sender
+    del receiver