├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── PySignal.py ├── README.md ├── setup.cfg ├── setup.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | .idea 3 | dist 4 | PySignal.egg-info 5 | *.pyc -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.5" 5 | script: python tests.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Dhruv Govil 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | -------------------------------------------------------------------------------- /PySignal.py: -------------------------------------------------------------------------------- 1 | __author__ = "Dhruv Govil" 2 | __copyright__ = "Copyright 2016, Dhruv Govil" 3 | __credits__ = ["Dhruv Govil", "John Hood", "Jason Viloria", "Adric Worley", "Alex Widener"] 4 | __license__ = "MIT" 5 | __version__ = "1.1.4" 6 | __maintainer__ = "Dhruv Govil" 7 | __email__ = "dhruvagovil@gmail.com" 8 | __status__ = "Beta" 9 | 10 | import inspect 11 | import sys 12 | import weakref 13 | from functools import partial 14 | 15 | 16 | # weakref.WeakMethod backport 17 | try: 18 | from weakref import WeakMethod 19 | 20 | except ImportError: 21 | import types 22 | 23 | class WeakMethod(object): 24 | """Light WeakMethod backport compiled from various sources. Tested in 2.7""" 25 | 26 | def __init__(self, func): 27 | if inspect.ismethod(func): 28 | self._obj = weakref.ref(func.__self__) 29 | self._func = weakref.ref(func.__func__) 30 | 31 | else: 32 | self._obj = None 33 | 34 | try: 35 | self._func = weakref.ref(func.__func__) 36 | 37 | # Rather than attempting to handle this, raise the same exception 38 | # you get from WeakMethod. 39 | except AttributeError: 40 | raise TypeError("argument should be a bound method, not %s" % type(func)) 41 | 42 | def __call__(self): 43 | if self._obj is not None: 44 | obj = self._obj() 45 | func = self._func() 46 | if func is None or obj is None: 47 | return None 48 | 49 | else: 50 | return types.MethodType(func, obj, obj.__class__) 51 | 52 | elif self._func is not None: 53 | return self._func() 54 | 55 | else: 56 | return None 57 | 58 | def __eq__(self, other): 59 | try: 60 | return type(self) is type(other) and self() == other() 61 | 62 | except Exception: 63 | return False 64 | 65 | def __ne__(self, other): 66 | return not self.__eq__(other) 67 | 68 | 69 | class Signal(object): 70 | """ 71 | The Signal is the core object that handles connection and emission . 72 | """ 73 | 74 | def __init__(self): 75 | super(Signal, self).__init__() 76 | self._block = False 77 | self._sender = None 78 | self._slots = [] 79 | 80 | def __call__(self, *args, **kwargs): 81 | self.emit(*args, **kwargs) 82 | 83 | def emit(self, *args, **kwargs): 84 | """ 85 | Calls all the connected slots with the provided args and kwargs unless block is activated 86 | """ 87 | if self._block: 88 | return 89 | 90 | def _get_sender(): 91 | """Try to get the bound, class or module method calling the emit.""" 92 | prev_frame = sys._getframe(2) 93 | func_name = prev_frame.f_code.co_name 94 | 95 | # Faster to try/catch than checking for 'self' 96 | try: 97 | return getattr(prev_frame.f_locals['self'], func_name) 98 | 99 | except KeyError: 100 | return getattr(inspect.getmodule(prev_frame), func_name) 101 | 102 | # Get the sender 103 | try: 104 | self._sender = WeakMethod(_get_sender()) 105 | 106 | # Account for when func_name is at '' 107 | except AttributeError: 108 | self._sender = None 109 | 110 | # Handle unsupported module level methods for WeakMethod. 111 | # TODO: Support module level methods. 112 | except TypeError: 113 | self._sender = None 114 | 115 | for slot in self._slots: 116 | if not slot: 117 | continue 118 | elif isinstance(slot, partial): 119 | slot(*args, **kwargs) 120 | elif isinstance(slot, weakref.WeakKeyDictionary): 121 | # For class methods, get the class object and call the method accordingly. 122 | for obj, method in slot.items(): 123 | method(obj, *args, **kwargs) 124 | elif isinstance(slot, weakref.ref): 125 | # If it's a weakref, call the ref to get the instance and then call the func 126 | # Don't wrap in try/except so we don't risk masking exceptions from the actual func call 127 | tested_slot = slot() 128 | if tested_slot is not None: 129 | tested_slot(*args, **kwargs) 130 | else: 131 | # Else call it in a standard way. Should be just lambdas at this point 132 | slot(*args, **kwargs) 133 | 134 | def connect(self, slot): 135 | """ 136 | Connects the signal to any callable object 137 | """ 138 | if not callable(slot): 139 | raise ValueError("Connection to non-callable '%s' object failed" % slot.__class__.__name__) 140 | 141 | if isinstance(slot, (partial, Signal)) or '<' in slot.__name__: 142 | # If it's a partial, a Signal or a lambda. The '<' check is the only py2 and py3 compatible way I could find 143 | if slot not in self._slots: 144 | self._slots.append(slot) 145 | elif inspect.ismethod(slot): 146 | # Check if it's an instance method and store it with the instance as the key 147 | slotSelf = slot.__self__ 148 | slotDict = weakref.WeakKeyDictionary() 149 | slotDict[slotSelf] = slot.__func__ 150 | if slotDict not in self._slots: 151 | self._slots.append(slotDict) 152 | else: 153 | # If it's just a function then just store it as a weakref. 154 | newSlotRef = weakref.ref(slot) 155 | if newSlotRef not in self._slots: 156 | self._slots.append(newSlotRef) 157 | 158 | def disconnect(self, slot): 159 | """ 160 | Disconnects the slot from the signal 161 | """ 162 | if not callable(slot): 163 | return 164 | 165 | if inspect.ismethod(slot): 166 | # If it's a method, then find it by its instance 167 | slotSelf = slot.__self__ 168 | for s in self._slots: 169 | if (isinstance(s, weakref.WeakKeyDictionary) and 170 | (slotSelf in s) and 171 | (s[slotSelf] is slot.__func__)): 172 | self._slots.remove(s) 173 | break 174 | elif isinstance(slot, (partial, Signal)) or '<' in slot.__name__: 175 | # If it's a partial, a Signal or lambda, try to remove directly 176 | try: 177 | self._slots.remove(slot) 178 | except ValueError: 179 | pass 180 | else: 181 | # It's probably a function, so try to remove by weakref 182 | try: 183 | self._slots.remove(weakref.ref(slot)) 184 | except ValueError: 185 | pass 186 | 187 | def clear(self): 188 | """Clears the signal of all connected slots""" 189 | self._slots = [] 190 | 191 | def block(self, isBlocked): 192 | """Sets blocking of the signal""" 193 | self._block = bool(isBlocked) 194 | 195 | def sender(self): 196 | """Return the callable responsible for emitting the signal, if found.""" 197 | try: 198 | return self._sender() 199 | 200 | except TypeError: 201 | return None 202 | 203 | 204 | class ClassSignal(object): 205 | """ 206 | The class signal allows a signal to be set on a class rather than an instance. 207 | This emulates the behavior of a PyQt signal 208 | """ 209 | _map = {} 210 | 211 | def __get__(self, instance, owner): 212 | if instance is None: 213 | # When we access ClassSignal element on the class object without any instance, 214 | # we return the ClassSignal itself 215 | return self 216 | tmp = self._map.setdefault(self, weakref.WeakKeyDictionary()) 217 | return tmp.setdefault(instance, Signal()) 218 | 219 | def __set__(self, instance, value): 220 | raise RuntimeError("Cannot assign to a Signal object") 221 | 222 | 223 | class SignalFactory(dict): 224 | """ 225 | The Signal Factory object lets you handle signals by a string based name instead of by objects. 226 | """ 227 | 228 | def register(self, name, *slots): 229 | """ 230 | Registers a given signal 231 | :param name: the signal to register 232 | """ 233 | # setdefault initializes the object even if it exists. This is more efficient 234 | if name not in self: 235 | self[name] = Signal() 236 | 237 | for slot in slots: 238 | self[name].connect(slot) 239 | 240 | def deregister(self, name): 241 | """ 242 | Removes a given signal 243 | :param name: the signal to deregister 244 | """ 245 | self.pop(name, None) 246 | 247 | def emit(self, signalName, *args, **kwargs): 248 | """ 249 | Emits a signal by name if it exists. Any additional args or kwargs are passed to the signal 250 | :param signalName: the signal name to emit 251 | """ 252 | assert signalName in self, "%s is not a registered signal" % signalName 253 | self[signalName].emit(*args, **kwargs) 254 | 255 | def connect(self, signalName, slot): 256 | """ 257 | Connects a given signal to a given slot 258 | :param signalName: the signal name to connect to 259 | :param slot: the callable slot to register 260 | """ 261 | assert signalName in self, "%s is not a registered signal" % signalName 262 | self[signalName].connect(slot) 263 | 264 | def block(self, signals=None, isBlocked=True): 265 | """ 266 | Sets the block on any provided signals, or to all signals 267 | 268 | :param signals: defaults to all signals. Accepts either a single string or a list of strings 269 | :param isBlocked: the state to set the signal to 270 | """ 271 | if signals: 272 | try: 273 | if isinstance(signals, basestring): 274 | signals = [signals] 275 | except NameError: 276 | if isinstance(signals, str): 277 | signals = [signals] 278 | 279 | signals = signals or self.keys() 280 | 281 | for signal in signals: 282 | if signal not in self: 283 | raise RuntimeError("Could not find signal matching %s" % signal) 284 | self[signal].block(isBlocked) 285 | 286 | 287 | class ClassSignalFactory(object): 288 | """ 289 | The class signal allows a signal factory to be set on a class rather than an instance. 290 | """ 291 | _map = {} 292 | _names = set() 293 | 294 | def __get__(self, instance, owner): 295 | tmp = self._map.setdefault(self, weakref.WeakKeyDictionary()) 296 | 297 | signal = tmp.setdefault(instance, SignalFactory()) 298 | for name in self._names: 299 | signal.register(name) 300 | 301 | return signal 302 | 303 | def __set__(self, instance, value): 304 | raise RuntimeError("Cannot assign to a Signal object") 305 | 306 | def register(self, name): 307 | """ 308 | Registers a new signal with the given name 309 | :param name: The signal to register 310 | """ 311 | self._names.add(name) 312 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PySignal 2 | 3 | [![Build Status](https://travis-ci.org/dgovil/PySignal.svg?branch=master)](https://travis-ci.org/dgovil/PySignal) 4 | 5 | **Deprecated**: I realize this library is used by a few people, but due to contractual limitations, I cannot currently maintain it. I recommend forking and developing the fork if possible. I apologize for the inconvenience. 6 | 7 | For prior contributions to this repo, I made my contributions/submissions to this project solely in my personal capacity and am not conveying any rights to any intellectual property of any third parties. 8 | 9 | ----- 10 | 11 | A Qt style signal implementation that doesn't require QObjects. 12 | This supports class methods, functions, lambdas and partials. 13 | 14 | Signals can either be created on the instance or on the class, and can be handled either as objects or by string name. 15 | Unlike PyQt signals, PySignals do not enforce types by default as I believe this is more pythonic. 16 | 17 | Available under the MIT license. 18 | 19 | Check out my website too for more programming and film related content: http://dgovil.com/ 20 | 21 | ## Install 22 | 23 | You can install this using pip, though the version on Pypi is outdated 24 | 25 | ```bash 26 | pip install PySignal 27 | ``` 28 | 29 | This is compatible with Python 2.7+ and 3.x 30 | 31 | ## Usage: 32 | 33 | ```python 34 | def greet(name): 35 | print "Hello,", name 36 | 37 | class Foo(object): 38 | started = ClassSignal() 39 | classSignalFactory = ClassSignalFactory() 40 | classSignalFactory.register('Greet') 41 | 42 | 43 | def __init__(self): 44 | super(Foo, self).__init__() 45 | self.started.connect(greet) 46 | self.started.emit('Watson') 47 | 48 | self.signalFactory = SignalFactory() 49 | self.signalFactory.register('Greet') 50 | self.signalFactory['Greet'].connect(greet) 51 | self.signalFactory['Greet'].emit('Sherlock') 52 | 53 | self.classSignalFactory['Greet'].connect(greet) 54 | self.classSignalFactory['Greet'].emit('Moriarty') 55 | 56 | ended = Signal() 57 | ended.connect(greet) 58 | ended.emit('Mycroft') 59 | 60 | foo = Foo() 61 | # Hello, Watson 62 | # Hello, Sherlock 63 | # Hello, Moriarty 64 | # Hello, Mycroft 65 | ``` 66 | 67 | ## Signal Types 68 | 69 | There are 4 types of Signals included 70 | 71 | * `Signal` is the base implementation of the Signal and can be created on a per instance level. 72 | * `ClassSignal` is an object that can be created as a class variable and will act like a signal. 73 | This ensures that all instances of your class will have the signal, but can be managed individually. 74 | * `SignalFactory` allows you to have a single signal object on your instance that can generate signals by name. 75 | * `ClassSignalFactory` is the same as a signal factory but lives on the class instead of the instance. 76 | 77 | ## Why Signals? 78 | 79 | Signals allow for creating a callback interface on your object and allows for it to be extended without needing to make a new inherited class. 80 | 81 | For example I can define the following 82 | 83 | ```python 84 | class Foo(object): 85 | started = ClassSignal() 86 | ended = ClassSignal() 87 | 88 | def run(self): 89 | self.started.emit() 90 | # Do my logic here 91 | self.ended.emit() 92 | ``` 93 | 94 | This does a few things: 95 | 96 | * It guarantees that any instances of Foo or it's subclasses will always have the started and ended Signals. This allows for a guaranteed interface. 97 | * It means that when we want to add callbacks to Foo, we can do so on a case by case basis without having to subclass it to call the slots explicitly. 98 | 99 | For example: 100 | 101 | ```python 102 | foo1 = Foo() 103 | foo2 = Foo() 104 | 105 | foo1.started.connect(lambda: print("I am foo1")) 106 | foo2.started.connect(lambda: print(42)) 107 | 108 | foo1.run() # will output I am foo1 109 | foo2.run() # will output 42 110 | ``` 111 | 112 | We can also get the Signal's "sender" - the bound method responsible for emitting the signal, if available. 113 | 114 | For example: 115 | 116 | ```python 117 | bar_run1 = foo1.started.sender() # will output > 118 | bar_run2 = foo2.started.sender() # will output > 119 | 120 | print(bar_run1 == foo1.run) # will output True 121 | print(bar_run1 == foo2.run) # will output False 122 | print(bar_run1 == bar_run2) # will output False 123 | ``` 124 | 125 | Instead of having to subclass `Foo` and implement the new behavior, we can simply reuse the existing Foo class and attach on to its instances. 126 | 127 | ## What's missing? 128 | 129 | The goal of this library is to mimic Qt's callback system without requiring all either end of the signal/slot to be a QObject derivative. 130 | 131 | That said in the current state it is missing the following features: 132 | 133 | * It does not handle multiple threads. The slots are called in the same thread as the signal. 134 | This is because I am currently not aware of a way to do this in Python without implementing an equivalent to QObject which I am trying to avoid. 135 | * There is no type checking. Qt requires signals to declare the type of the data they emit. This is great for C++ but I feel it's not very pythonic and so do not implement that behavior. 136 | * You cannot query the sender. In Qt you can check what object sent a signal. Again this relies on inheriting from a QObject and Qt managing states, which are somethings I was trying to avoid. The alternative is that you can send `self` as the first parameter of the signal. e.g. `signal.emit(self, arg1, arg2)` and the slot will need to expect the first argument to be the sender. 137 | 138 | If anyone has any suggestions or solutions on how I can overcome these caveats, I'm all ears and very willing to implement it or accept pull requests from other people too 139 | 140 | ## Comparisons To Other Libraries 141 | 142 | There are a few other libraries to compare with that implement Signals. I am not completely familiar with them so please correct me if I am wrong. 143 | These may serve your purposes better depending on what you are doing. The goal of PySignal is first and foremost to be a Qt style signal slot system so the comparisons are written with that in mind. 144 | 145 | ### [Blinker](https://github.com/jek/blinker) 146 | 147 | Blinker appears to implement a very similar signal to slot mechanism. It is inspired by the django signal system. 148 | 149 | + It has a few more convenience methods like temporary connections and the ability to handle dispatch logic based on input 150 | - It does not try and keep the Qt interface naming since it prefers the django system instead 151 | - It does not appear to support partials and lambdas 152 | 153 | ### [SmokeSignal](https://github.com/shaunduncan/smokesignal/) 154 | 155 | SmokeSignal is another django inspired signal system. 156 | 157 | * It has a decorator based interface with a focus on slots rather than signals. ie slots listen for a signal rather than a signal calling a list of slots. 158 | + It has support for one time calls 159 | + It supports contexts that can fire signals on entry and exit. 160 | - It does not implement a Qt style signal slot interface 161 | - It does not appear to support partials and lambdas. 162 | 163 | ## Changelog 164 | 165 | ### 1.1.4 166 | 167 | * Final release. This includes bugfixes from various parties but includes no contributions of my own. I am no longer able to maintain this repo. Please fork. 168 | 169 | ### 1.1.3 170 | 171 | * Basic support for retrieving a Signal's "sender", with some test coverage. 172 | 173 | ### 1.1.1 174 | 175 | * Setup.py no longer imports the PySignal module and instead parses it. 176 | * Test Coverage has been expanded to 97% 177 | * Slots can no longer be attached multiple times which used to cause them firing multiple times. 178 | * Using callable to find if slot is lambda 179 | 180 | ### 1.0.1 181 | 182 | * Initial Release 183 | 184 | 185 | ## Based on these implementations 186 | 187 | http://www.jnvilo.com/cms/programming/python/programming-in-python/signal-and-slots-implementation-in-python 188 | 189 | http://www.codeheadwords.com/2015/05/05/emulating-pyqt-signals-with-descriptors 190 | 191 | ## Contributors 192 | 193 | Many thanks to: 194 | 195 | * Alex Widener for cleaning up my setup.py 196 | * Adric Worley for expanding test coverage, cleaning up the code and fixing a duplicate connection bug. 197 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import ast 4 | from setuptools import setup 5 | import PySignal 6 | 7 | with open('PySignal.py', 'rb') as f: 8 | contents = f.read().decode('utf-8') 9 | 10 | 11 | def parse(pattern): 12 | return re.search(pattern, contents).group(1).replace('"', '').strip() 13 | 14 | def read(fname): 15 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 16 | 17 | version = parse(r'__version__\s+=\s+(.*)') 18 | author = parse(r'__author__\s+=\s+(.*)') 19 | email = parse(r'__email__\s+=\s+(.*)') 20 | 21 | classifiers = [ 22 | "Development Status :: 4 - Beta", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 2", 27 | "Programming Language :: Python :: 2.6", 28 | "Programming Language :: Python :: 2.7", 29 | "Programming Language :: Python :: 3", 30 | "Programming Language :: Python :: 3.3", 31 | "Programming Language :: Python :: 3.5", 32 | "Topic :: Software Development :: Libraries :: Python Modules", 33 | "Topic :: Utilities" 34 | ] 35 | 36 | 37 | 38 | setup( 39 | name="PySignal", 40 | version=version, 41 | description="Python Signal Library to mimic the Qt Signal system for event driven connections", 42 | author=author, 43 | author_email=email, 44 | url="https://github.com/dgovil/PySignal", 45 | license="MIT", 46 | zip_safe=False, 47 | py_modules=["PySignal"], 48 | classifiers=classifiers, 49 | keywords=['signals', 'qt', 'events'] 50 | ) 51 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import PySignal 3 | from functools import partial 4 | 5 | try: 6 | import unittest2 as unittest 7 | except ImportError: 8 | import unittest 9 | 10 | 11 | def testFunc(test, value): 12 | """A test standalone function for signals to attach onto""" 13 | test.checkval = value 14 | test.func_call_count += 1 15 | 16 | 17 | def testLocalEmit(signal_instance): 18 | """A test standalone function for signals to emit at local level""" 19 | exec('signal_instance.emit()') 20 | 21 | 22 | def testModuleEmit(signal_instance): 23 | """A test standalone function for signals to emit at module level""" 24 | signal_instance.emit() 25 | 26 | 27 | class DummySignalClass(object): 28 | """A dummy class to check for instance handling of signals""" 29 | cSignal = PySignal.ClassSignal() 30 | cSignalFactory = PySignal.ClassSignalFactory() 31 | 32 | def __init__(self): 33 | self.signal = PySignal.Signal() 34 | self.signalFactory = PySignal.SignalFactory() 35 | 36 | def triggerSignal(self): 37 | self.signal.emit() 38 | 39 | def triggerClassSignal(self): 40 | self.cSignal.emit() 41 | 42 | 43 | class DummySlotClass(object): 44 | """A dummy class to check for slot handling""" 45 | checkval = None 46 | 47 | def setVal(self, val): 48 | """A method to test slot calls with""" 49 | self.checkval = val 50 | 51 | 52 | class SignalTestMixin(object): 53 | """Mixin class with common helpers for signal tests""" 54 | 55 | def __init__(self): 56 | self.checkval = None # A state check for the tests 57 | self.checkval2 = None # A state check for the tests 58 | self.setVal_call_count = 0 # A state check for the test method 59 | self.setVal2_call_count = 0 # A state check for the test method 60 | self.func_call_count = 0 # A state check for test function 61 | 62 | def reset(self): 63 | self.checkval = None 64 | self.checkval2 = None 65 | self.setVal_call_count = 0 66 | self.setVal2_call_count = 0 67 | self.func_call_count = 0 68 | 69 | # Helper methods 70 | def setVal(self, val): 71 | """A method to test instance settings with""" 72 | self.checkval = val 73 | self.setVal_call_count += 1 74 | 75 | def setVal2(self, val): 76 | """Another method to test instance settings with""" 77 | self.checkval2 = val 78 | self.setVal2_call_count += 1 79 | 80 | def throwaway(self, *args): 81 | """A method to throw redundant data into""" 82 | pass 83 | 84 | 85 | # noinspection PyProtectedMember 86 | class SignalTest(unittest.TestCase, SignalTestMixin): 87 | """Unit tests for Signal class""" 88 | 89 | def setUp(self): 90 | self.reset() 91 | 92 | def __init__(self, methodName='runTest'): 93 | unittest.TestCase.__init__(self, methodName) 94 | SignalTestMixin.__init__(self) 95 | 96 | def test_PartialConnect(self): 97 | """Tests connecting signals to partials""" 98 | partialSignal = PySignal.Signal() 99 | partialSignal.connect(partial(testFunc, self, 'Partial')) 100 | self.assertEqual(len(partialSignal._slots), 1, "Expected single connected slot") 101 | 102 | def test_PartialConnectDuplicate(self): 103 | """Tests connecting signals to partials""" 104 | partialSignal = PySignal.Signal() 105 | func = partial(testFunc, self, 'Partial') 106 | partialSignal.connect(func) 107 | partialSignal.connect(func) 108 | self.assertEqual(len(partialSignal._slots), 1, "Expected single connected slot") 109 | 110 | def test_LambdaConnect(self): 111 | """Tests connecting signals to lambdas""" 112 | lambdaSignal = PySignal.Signal() 113 | lambdaSignal.connect(lambda value: testFunc(self, value)) 114 | self.assertEqual(len(lambdaSignal._slots), 1, "Expected single connected slot") 115 | 116 | def test_LambdaConnectDuplicate(self): 117 | """Tests connecting signals to duplicate lambdas""" 118 | lambdaSignal = PySignal.Signal() 119 | func = lambda value: testFunc(self, value) 120 | lambdaSignal.connect(func) 121 | lambdaSignal.connect(func) 122 | self.assertEqual(len(lambdaSignal._slots), 1, "Expected single connected slot") 123 | 124 | def test_MethodConnect(self): 125 | """Test connecting signals to methods on class instances""" 126 | methodSignal = PySignal.Signal() 127 | methodSignal.connect(self.setVal) 128 | self.assertEqual(len(methodSignal._slots), 1, "Expected single connected slot") 129 | 130 | def test_MethodConnectDuplicate(self): 131 | """Test that each method connection is unique""" 132 | methodSignal = PySignal.Signal() 133 | methodSignal.connect(self.setVal) 134 | methodSignal.connect(self.setVal) 135 | self.assertEqual(len(methodSignal._slots), 1, "Expected single connected slot") 136 | 137 | def test_MethodConnectDifferentInstances(self): 138 | """Test connecting the same method from different instances""" 139 | methodSignal = PySignal.Signal() 140 | dummy1 = DummySlotClass() 141 | dummy2 = DummySlotClass() 142 | methodSignal.connect(dummy1.setVal) 143 | methodSignal.connect(dummy2.setVal) 144 | self.assertEqual(len(methodSignal._slots), 2, "Expected two connected slots") 145 | 146 | def test_FunctionConnect(self): 147 | """Test connecting signals to standalone functions""" 148 | funcSignal = PySignal.Signal() 149 | funcSignal.connect(testFunc) 150 | self.assertEqual(len(funcSignal._slots), 1, "Expected single connected slot") 151 | 152 | def test_FunctionConnectDuplicate(self): 153 | """Test that each function connection is unique""" 154 | funcSignal = PySignal.Signal() 155 | funcSignal.connect(testFunc) 156 | funcSignal.connect(testFunc) 157 | self.assertEqual(len(funcSignal._slots), 1, "Expected single connected slot") 158 | 159 | def test_ConnectNonCallable(self): 160 | """Test connecting non-callable object""" 161 | nonCallableSignal = PySignal.Signal() 162 | with self.assertRaises(ValueError): 163 | nonCallableSignal.connect(self.checkval) 164 | 165 | def test_EmitToPartial(self): 166 | """Test emitting signals to partial""" 167 | partialSignal = PySignal.Signal() 168 | partialSignal.connect(partial(testFunc, self, 'Partial')) 169 | partialSignal.emit() 170 | self.assertEqual(self.checkval, 'Partial') 171 | self.assertEqual(self.func_call_count, 1, "Expected function to be called once") 172 | 173 | def test_EmitToLambda(self): 174 | """Test emitting signal to lambda""" 175 | lambdaSignal = PySignal.Signal() 176 | lambdaSignal.connect(lambda value: testFunc(self, value)) 177 | lambdaSignal.emit('Lambda') 178 | self.assertEqual(self.checkval, 'Lambda') 179 | self.assertEqual(self.func_call_count, 1, "Expected function to be called once") 180 | 181 | def test_EmitToMethod(self): 182 | """Test emitting signal to method""" 183 | toSucceed = DummySignalClass() 184 | toSucceed.signal.connect(self.setVal) 185 | toSucceed.signal.emit('Method') 186 | self.assertEqual(self.checkval, 'Method') 187 | self.assertEqual(self.setVal_call_count, 1, "Expected function to be called once") 188 | 189 | def test_EmitToMethodOnDeletedInstance(self): 190 | """Test emitting signal to deleted instance method""" 191 | toDelete = DummySlotClass() 192 | toCall = PySignal.Signal() 193 | toCall.connect(toDelete.setVal) 194 | toCall.connect(self.setVal) 195 | del toDelete 196 | toCall.emit(1) 197 | self.assertEqual(self.checkval, 1) 198 | 199 | def test_EmitToFunction(self): 200 | """Test emitting signal to standalone function""" 201 | funcSignal = PySignal.Signal() 202 | funcSignal.connect(testFunc) 203 | funcSignal.emit(self, 'Function') 204 | self.assertEqual(self.checkval, 'Function') 205 | self.assertEqual(self.func_call_count, 1, "Expected function to be called once") 206 | 207 | def test_EmitToDeletedFunction(self): 208 | """Test emitting signal to deleted instance method""" 209 | def ToDelete(test, value): 210 | test.checkVal = value 211 | test.func_call_count += 1 212 | funcSignal = PySignal.Signal() 213 | funcSignal.connect(ToDelete) 214 | del ToDelete 215 | funcSignal.emit(self, 1) 216 | self.assertEqual(self.checkval, None) 217 | self.assertEqual(self.func_call_count, 0) 218 | 219 | def test_PartialDisconnect(self): 220 | """Test disconnecting partial function""" 221 | partialSignal = PySignal.Signal() 222 | part = partial(testFunc, self, 'Partial') 223 | partialSignal.connect(part) 224 | partialSignal.disconnect(part) 225 | self.assertEqual(self.checkval, None, "Slot was not removed from signal") 226 | 227 | def test_PartialDisconnectUnconnected(self): 228 | """Test disconnecting unconnected partial function""" 229 | partialSignal = PySignal.Signal() 230 | part = partial(testFunc, self, 'Partial') 231 | try: 232 | partialSignal.disconnect(part) 233 | except: 234 | self.fail("Disonnecting unconnected partial should not raise") 235 | 236 | def test_LambdaDisconnect(self): 237 | """Test disconnecting lambda function""" 238 | lambdaSignal = PySignal.Signal() 239 | func = lambda value: testFunc(self, value) 240 | lambdaSignal.connect(func) 241 | lambdaSignal.disconnect(func) 242 | self.assertEqual(len(lambdaSignal._slots), 0, "Slot was not removed from signal") 243 | 244 | def test_LambdaDisconnectUnconnected(self): 245 | """Test disconnecting unconnected lambda function""" 246 | lambdaSignal = PySignal.Signal() 247 | func = lambda value: testFunc(self, value) 248 | try: 249 | lambdaSignal.disconnect(func) 250 | except: 251 | self.fail("Disconnecting unconnected lambda should not raise") 252 | 253 | def test_MethodDisconnect(self): 254 | """Test disconnecting method""" 255 | toCall = PySignal.Signal() 256 | toCall.connect(self.setVal) 257 | toCall.connect(self.setVal2) 258 | toCall.disconnect(self.setVal2) 259 | toCall.emit(1) 260 | self.assertEqual(len(toCall._slots), 1, "Expected 1 connected after disconnect, found %d" % len(toCall._slots)) 261 | self.assertEqual(self.setVal_call_count, 1, "Expected function to be called once") 262 | self.assertEqual(self.setVal2_call_count, 0, "Expected function to not be called after disconnecting") 263 | 264 | def test_MethodDisconnectUnconnected(self): 265 | """Test disconnecting unconnected method""" 266 | toCall = PySignal.Signal() 267 | try: 268 | toCall.disconnect(self.setVal) 269 | except: 270 | self.fail("Disconnecting unconnected method should not raise") 271 | 272 | def test_FunctionDisconnect(self): 273 | """Test disconnecting function""" 274 | funcSignal = PySignal.Signal() 275 | funcSignal.connect(testFunc) 276 | funcSignal.disconnect(testFunc) 277 | self.assertEqual(len(funcSignal._slots), 0, "Slot was not removed from signal") 278 | 279 | def test_FunctionDisconnectUnconnected(self): 280 | """Test disconnecting unconnected function""" 281 | funcSignal = PySignal.Signal() 282 | try: 283 | funcSignal.disconnect(testFunc) 284 | except: 285 | self.fail("Disconnecting unconnected function should not raise") 286 | 287 | def test_DisconnectNonCallable(self): 288 | """Test disconnecting non-callable object""" 289 | signal = PySignal.Signal() 290 | try: 291 | signal.disconnect(self.checkval) 292 | except: 293 | self.fail("Disconnecting invalid object should not raise") 294 | 295 | def test_ClearSlots(self): 296 | """Test clearing all slots""" 297 | multiSignal = PySignal.Signal() 298 | func = lambda value: self.setVal(value) 299 | multiSignal.connect(func) 300 | multiSignal.connect(self.setVal) 301 | multiSignal.clear() 302 | self.assertEqual(len(multiSignal._slots), 0, "Not all slots were removed from signal") 303 | 304 | 305 | class ClassSignalTest(unittest.TestCase, SignalTestMixin): 306 | """Unit tests for ClassSignal class""" 307 | 308 | def setUp(self): 309 | self.reset() 310 | 311 | def __init__(self, methodName='runTest'): 312 | unittest.TestCase.__init__(self, methodName) 313 | SignalTestMixin.__init__(self) 314 | 315 | def test_AssignToProperty(self): 316 | """Test assigning to a ClassSignal property""" 317 | dummy = DummySignalClass() 318 | with self.assertRaises(RuntimeError): 319 | dummy.cSignal = None 320 | 321 | # noinspection PyUnresolvedReferences 322 | def test_Emit(self): 323 | """Test emitting signals from class signal and that instances of the class are unique""" 324 | toSucceed = DummySignalClass() 325 | toSucceed.cSignal.connect(self.setVal) 326 | toFail = DummySignalClass() 327 | toFail.cSignal.connect(self.throwaway) 328 | toSucceed.cSignal.emit(1) 329 | toFail.cSignal.emit(2) 330 | self.assertEqual(self.checkval, 1) 331 | 332 | def test_DeadSenderFound(self): 333 | """Test Signal sender is dead""" 334 | toFail = DummySignalClass() 335 | toFail.cSignal.connect(self.throwaway) 336 | toFail.triggerClassSignal() 337 | weak_sender = toFail.cSignal._sender 338 | del toFail 339 | self.assertEqual(None, weak_sender()) 340 | 341 | def test_FunctionSenderFound(self): 342 | """Test correct Signal sender is found (instance method)""" 343 | toSucceed = DummySignalClass() 344 | toSucceed.cSignal.connect(self.throwaway) 345 | toSucceed.triggerClassSignal() 346 | self.assertEqual(toSucceed.triggerClassSignal, toSucceed.cSignal.sender()) 347 | 348 | def test_InstanceSenderFound(self): 349 | """Test correct Signal sender is found (instance, not class method)""" 350 | toSucceed = DummySignalClass() 351 | toSucceed.cSignal.connect(self.throwaway) 352 | toSucceed.triggerClassSignal() 353 | self.assertNotEqual(DummySignalClass.triggerClassSignal, toSucceed.cSignal.sender()) 354 | self.assertEqual(toSucceed.triggerClassSignal, toSucceed.cSignal.sender()) 355 | 356 | def test_LambdaSenderFound(self): 357 | """Test correct Signal sender is found (instance method via lambda)""" 358 | toSucceed = DummySignalClass() 359 | toSucceed.cSignal.connect(self.throwaway) 360 | (lambda: toSucceed.triggerClassSignal())() 361 | self.assertEqual(toSucceed.triggerClassSignal, toSucceed.cSignal.sender()) 362 | 363 | def test_PartialSenderFound(self): 364 | """Test correct Signal sender is found (instance method via partial)""" 365 | toSucceed = DummySignalClass() 366 | toSucceed.cSignal.connect(self.throwaway) 367 | partial(toSucceed.triggerClassSignal)() 368 | self.assertEqual(toSucceed.triggerClassSignal, toSucceed.cSignal.sender()) 369 | 370 | def test_SelfSenderFound(self): 371 | """Test correct Signal sender is found (self emit)""" 372 | toSucceed = DummySignalClass() 373 | toSucceed.cSignal.connect(self.throwaway) 374 | toSucceed.cSignal.emit() 375 | self.assertEqual(self.test_SelfSenderFound, toSucceed.cSignal.sender()) 376 | 377 | def test_LocalSenderHandled(self): 378 | """Test correct Signal sender is found (module local emit)""" 379 | toFail = DummySignalClass() 380 | toFail.cSignal.connect(self.throwaway) 381 | testLocalEmit(toFail.cSignal) 382 | self.assertEqual(None, toFail.cSignal.sender()) 383 | 384 | def test_ModuleSenderHandled(self): 385 | """Test correct Signal sender is found (module local emit)""" 386 | toFail = DummySignalClass() 387 | toFail.cSignal.connect(self.throwaway) 388 | testModuleEmit(toFail.cSignal) 389 | self.assertEqual(None, toFail.cSignal.sender()) 390 | 391 | 392 | class SignalFactoryTest(unittest.TestCase, SignalTestMixin): 393 | def __init__(self, methodName='runTest'): 394 | unittest.TestCase.__init__(self, methodName) 395 | SignalTestMixin.__init__(self) 396 | 397 | def setUp(self): 398 | self.reset() 399 | 400 | # noinspection PyUnresolvedReferences 401 | def test_Emit(self): 402 | """Test emitting signals from class signal factory and that class instances are unique""" 403 | toSucceed = DummySignalClass() 404 | toSucceed.cSignalFactory.register('Spam') 405 | toSucceed.cSignalFactory['Spam'].connect(self.setVal) 406 | toFail = DummySignalClass() 407 | toFail.cSignalFactory.register('Spam') 408 | toFail.cSignalFactory['Spam'].connect(self.throwaway) 409 | toSucceed.cSignalFactory['Spam'].emit(1) 410 | toFail.cSignalFactory['Spam'].emit(2) 411 | self.assertEqual(self.checkval, 1) 412 | 413 | 414 | class ClassSignalFactoryTest(unittest.TestCase, SignalTestMixin): 415 | def __init__(self, methodName='runTest'): 416 | unittest.TestCase.__init__(self, methodName) 417 | SignalTestMixin.__init__(self) 418 | 419 | def setUp(self): 420 | self.checkval = None 421 | self.checkval2 = None 422 | self.setVal_call_count = 0 423 | self.setVal2_call_count = 0 424 | self.func_call_count = 0 425 | 426 | def test_AssignToProperty(self): 427 | """Test assigning to a ClassSignalFactory property""" 428 | dummy = DummySignalClass() 429 | with self.assertRaises(RuntimeError): 430 | dummy.cSignalFactory = None 431 | 432 | def test_Connect(self): 433 | """Test SignalFactory indirect signal connection""" 434 | dummy = DummySignalClass() 435 | dummy.signalFactory.register('Spam') 436 | dummy.signalFactory.connect('Spam', self.setVal) 437 | dummy.signalFactory.emit('Spam', 1) 438 | self.assertEqual(self.checkval, 1) 439 | self.assertEqual(self.setVal_call_count, 1) 440 | 441 | def test_ConnectInvalidChannel(self): 442 | """Test SignalFactory connecting to invalid channel""" 443 | dummy = DummySignalClass() 444 | with self.assertRaises(AssertionError): 445 | dummy.signalFactory.connect('Spam', self.setVal) 446 | 447 | def test_Emit(self): 448 | """Test emitting signals from signal factory""" 449 | toSucceed = DummySignalClass() 450 | toSucceed.signalFactory.register('Spam') 451 | toSucceed.signalFactory['Spam'].connect(self.setVal) 452 | toSucceed.signalFactory['Spam'].emit(1) 453 | self.assertEqual(self.checkval, 1) 454 | 455 | def test_BlockSingle(self): 456 | """Test blocking single channel with signal factory""" 457 | dummy = DummySignalClass() 458 | dummy.signalFactory.register('Spam', self.setVal) 459 | dummy.signalFactory.register('Eggs', self.setVal2) 460 | dummy.signalFactory.block('Spam') 461 | dummy.signalFactory.emit('Spam', 1) 462 | dummy.signalFactory.emit('Eggs', 2) 463 | self.assertEqual(self.checkval, None) 464 | self.assertEqual(self.checkval2, 2) 465 | 466 | def test_UnblockSingle(self): 467 | """Test unblocking a single channel with signal factory""" 468 | dummy = DummySignalClass() 469 | dummy.signalFactory.register('Spam', self.setVal) 470 | dummy.signalFactory.register('Eggs', self.setVal2) 471 | dummy.signalFactory.block('Spam') 472 | dummy.signalFactory.block('Spam', False) 473 | dummy.signalFactory.emit('Spam', 1) 474 | dummy.signalFactory.emit('Eggs', 2) 475 | self.assertEqual(self.checkval, 1) 476 | self.assertEqual(self.checkval2, 2) 477 | 478 | def test_BlockAll(self): 479 | """Test blocking all signals from signal factory""" 480 | dummy = DummySignalClass() 481 | dummy.signalFactory.register('Spam', self.setVal) 482 | dummy.signalFactory.register('Eggs', self.setVal2) 483 | dummy.signalFactory.block() 484 | dummy.signalFactory.emit('Spam', 1) 485 | dummy.signalFactory.emit('Eggs', 2) 486 | self.assertEqual(self.checkval, None) 487 | self.assertEqual(self.checkval2, None) 488 | 489 | def test_UnblockAll(self): 490 | """Test unblocking all signals from signal factory""" 491 | dummy = DummySignalClass() 492 | dummy.signalFactory.register('Spam', self.setVal) 493 | dummy.signalFactory.register('Eggs', self.setVal2) 494 | dummy.signalFactory.block() 495 | dummy.signalFactory.block(isBlocked=False) 496 | dummy.signalFactory.emit('Spam', 1) 497 | dummy.signalFactory.emit('Eggs', 2) 498 | self.assertEqual(self.checkval, 1) 499 | self.assertEqual(self.checkval2, 2) 500 | 501 | def test_BlockInvalidChannel(self): 502 | """Test blocking an invalid channel from signal factory""" 503 | dummy = DummySignalClass() 504 | with self.assertRaises(RuntimeError): 505 | dummy.signalFactory.block('Spam') 506 | 507 | def test_Deregister(self): 508 | """Test unregistering from SignalFactory""" 509 | dummy = DummySignalClass() 510 | dummy.signalFactory.register('Spam') 511 | dummy.signalFactory.deregister('Spam') 512 | self.assertFalse('Spam' in dummy.signalFactory, "Signal not removed") 513 | 514 | def test_DeregisterInvalidChannel(self): 515 | """Test unregistering invalid channel from SignalFactory""" 516 | dummy = DummySignalClass() 517 | try: 518 | dummy.signalFactory.deregister('Spam') 519 | except KeyError: 520 | self.fail("Deregistering invalid channel should not raise KeyError") 521 | 522 | 523 | if __name__ == '__main__': 524 | unittest.main() 525 | --------------------------------------------------------------------------------