diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index e83237c6..8857c052 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -14,7 +14,8 @@ import sys, struct from .python2_3 import asUnicode, basestring from .Qt import QtGui, QtCore, USE_PYSIDE from . import getConfigOption, setConfigOptions -from . import debug +from . import debug, reload +from .reload import getPreviousVersion from .metaarray import MetaArray @@ -2428,3 +2429,45 @@ def toposort(deps, nodes=None, seen=None, stack=None, depth=0): sorted.extend( toposort(deps, deps[n], seen, stack+[n], depth=depth+1)) sorted.append(n) return sorted + + +def disconnect(signal, slot): + """Disconnect a Qt signal from a slot. + + This method augments Qt's Signal.disconnect(): + + * Return bool indicating whether disconnection was successful, rather than + raising an exception + * Attempt to disconnect prior versions of the slot when using pg.reload + """ + while True: + try: + signal.disconnect(slot) + return True + except (TypeError, RuntimeError): + slot = reload.getPreviousVersion(slot) + if slot is None: + return False + + +class SignalBlock(object): + """Class used to temporarily block a Qt signal connection:: + + with SignalBlock(signal, slot): + # do something that emits a signal; it will + # not be delivered to slot + """ + def __init__(self, signal, slot): + self.signal = signal + self.slot = slot + + def __enter__(self): + self.reconnect = disconnect(self.signal, self.slot) + return self + + def __exit__(self, *args): + if self.reconnect: + self.signal.connect(self.slot) + + + diff --git a/pyqtgraph/reload.py b/pyqtgraph/reload.py index ccf83913..766ec9d0 100644 --- a/pyqtgraph/reload.py +++ b/pyqtgraph/reload.py @@ -21,13 +21,17 @@ Does NOT: print module.someObject """ - -import inspect, os, sys, gc, traceback -try: - import __builtin__ as builtins -except ImportError: - import builtins +from __future__ import print_function +import inspect, os, sys, gc, traceback, types from .debug import printExc +try: + from importlib import reload as orig_reload +except ImportError: + orig_reload = reload + + +py3 = sys.version_info >= (3,) + def reloadAll(prefix=None, debug=False): """Automatically reload everything whose __file__ begins with prefix. @@ -79,7 +83,7 @@ def reload(module, debug=False, lists=False, dicts=False): ## make a copy of the old module dictionary, reload, then grab the new module dictionary for comparison oldDict = module.__dict__.copy() - builtins.reload(module) + orig_reload(module) newDict = module.__dict__ ## Allow modules access to the old dictionary after they reload @@ -97,7 +101,9 @@ def reload(module, debug=False, lists=False, dicts=False): if debug: print(" Updating class %s.%s (0x%x -> 0x%x)" % (module.__name__, k, id(old), id(new))) updateClass(old, new, debug) - + # don't put this inside updateClass because it is reentrant. + new.__previous_reload_version__ = old + elif inspect.isfunction(old): depth = updateFunction(old, new, debug) if debug: @@ -127,6 +133,9 @@ def updateFunction(old, new, debug, depth=0, visited=None): old.__code__ = new.__code__ old.__defaults__ = new.__defaults__ + if hasattr(old, '__kwdefaults'): + old.__kwdefaults__ = new.__kwdefaults__ + old.__doc__ = new.__doc__ if visited is None: visited = [] @@ -151,8 +160,9 @@ def updateFunction(old, new, debug, depth=0, visited=None): ## For classes: ## 1) find all instances of the old class and set instance.__class__ to the new class ## 2) update all old class methods to use code from the new class methods -def updateClass(old, new, debug): + +def updateClass(old, new, debug): ## Track town all instances and subclasses of old refs = gc.get_referrers(old) for ref in refs: @@ -174,13 +184,20 @@ def updateClass(old, new, debug): ## This seems to work. Is there any reason not to? ## Note that every time we reload, the class hierarchy becomes more complex. ## (and I presume this may slow things down?) - ref.__bases__ = ref.__bases__[:ind] + (new,old) + ref.__bases__[ind+1:] + newBases = ref.__bases__[:ind] + (new,old) + ref.__bases__[ind+1:] + try: + ref.__bases__ = newBases + except TypeError: + print(" Error setting bases for class %s" % ref) + print(" old bases: %s" % repr(ref.__bases__)) + print(" new bases: %s" % repr(newBases)) + raise if debug: print(" Changed superclass for %s" % safeStr(ref)) #else: #if debug: #print " Ignoring reference", type(ref) - except: + except Exception: print("Error updating reference (%s) for class change (%s -> %s)" % (safeStr(ref), safeStr(old), safeStr(new))) raise @@ -189,7 +206,8 @@ def updateClass(old, new, debug): ## but it fixes a few specific cases (pyqt signals, for one) for attr in dir(old): oa = getattr(old, attr) - if inspect.ismethod(oa): + if (py3 and inspect.isfunction(oa)) or inspect.ismethod(oa): + # note python2 has unbound methods, whereas python3 just uses plain functions try: na = getattr(new, attr) except AttributeError: @@ -197,9 +215,14 @@ def updateClass(old, new, debug): print(" Skipping method update for %s; new class does not have this attribute" % attr) continue - if hasattr(oa, 'im_func') and hasattr(na, 'im_func') and oa.__func__ is not na.__func__: - depth = updateFunction(oa.__func__, na.__func__, debug) - #oa.im_class = new ## bind old method to new class ## not allowed + ofunc = getattr(oa, '__func__', oa) # in py2 we have to get the __func__ from unbound method, + nfunc = getattr(na, '__func__', na) # in py3 the attribute IS the function + + if ofunc is not nfunc: + depth = updateFunction(ofunc, nfunc, debug) + if not hasattr(nfunc, '__previous_reload_method__'): + nfunc.__previous_reload_method__ = oa # important for managing signal connection + #oa.__class__ = new ## bind old method to new class ## not allowed if debug: extra = "" if depth > 0: @@ -208,6 +231,8 @@ def updateClass(old, new, debug): ## And copy in new functions that didn't exist previously for attr in dir(new): + if attr == '__previous_reload_version__': + continue if not hasattr(old, attr): if debug: print(" Adding missing attribute %s" % attr) @@ -223,14 +248,37 @@ def updateClass(old, new, debug): def safeStr(obj): try: s = str(obj) - except: + except Exception: try: s = repr(obj) - except: + except Exception: s = "" % (safeStr(type(obj)), id(obj)) return s +def getPreviousVersion(obj): + """Return the previous version of *obj*, or None if this object has not + been reloaded. + """ + if isinstance(obj, type) or inspect.isfunction(obj): + return getattr(obj, '__previous_reload_version__', None) + elif inspect.ismethod(obj): + if obj.__self__ is None: + # unbound method + return getattr(obj.__func__, '__previous_reload_method__', None) + else: + oldmethod = getattr(obj.__func__, '__previous_reload_method__', None) + if oldmethod is None: + return None + self = obj.__self__ + oldfunc = getattr(oldmethod, '__func__', oldmethod) + if hasattr(oldmethod, 'im_class'): + # python 2 + cls = oldmethod.im_class + return types.MethodType(oldfunc, self, cls) + else: + # python 3 + return types.MethodType(oldfunc, self) diff --git a/pyqtgraph/tests/test_reload.py b/pyqtgraph/tests/test_reload.py new file mode 100644 index 00000000..6adbeeb6 --- /dev/null +++ b/pyqtgraph/tests/test_reload.py @@ -0,0 +1,116 @@ +import tempfile, os, sys, shutil +import pyqtgraph as pg +import pyqtgraph.reload + + +pgpath = os.path.join(os.path.dirname(pg.__file__), '..') + +# make temporary directory to write module code +path = None + +def setup_module(): + # make temporary directory to write module code + global path + path = tempfile.mkdtemp() + sys.path.insert(0, path) + +def teardown_module(): + global path + shutil.rmtree(path) + sys.path.remove(path) + + +code = """ +import sys +sys.path.append('{path}') + +import pyqtgraph as pg + +class C(pg.QtCore.QObject): + sig = pg.QtCore.Signal() + def fn(self): + print("{msg}") + +""" + +def remove_cache(mod): + if os.path.isfile(mod+'c'): + os.remove(mod+'c') + cachedir = os.path.join(os.path.dirname(mod), '__pycache__') + if os.path.isdir(cachedir): + shutil.rmtree(cachedir) + + +def test_reload(): + py3 = sys.version_info >= (3,) + + # write a module + mod = os.path.join(path, 'reload_test_mod.py') + print("\nRELOAD FILE:", mod) + open(mod, 'w').write(code.format(path=pgpath, msg="C.fn() Version1")) + + # import the new module + import reload_test_mod + print("RELOAD MOD:", reload_test_mod.__file__) + + c = reload_test_mod.C() + c.sig.connect(c.fn) + if py3: + v1 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, c.sig, c.fn, c.fn.__func__) + else: + v1 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, reload_test_mod.C.fn.__func__, c.sig, c.fn, c.fn.__func__) + + + + # write again and reload + open(mod, 'w').write(code.format(path=pgpath, msg="C.fn() Version2")) + remove_cache(mod) + pg.reload.reloadAll(path, debug=True) + if py3: + v2 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, c.sig, c.fn, c.fn.__func__) + else: + v2 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, reload_test_mod.C.fn.__func__, c.sig, c.fn, c.fn.__func__) + + if not py3: + assert c.fn.im_class is v2[0] + oldcfn = pg.reload.getPreviousVersion(c.fn) + if oldcfn is None: + # Function did not reload; are we using pytest's assertion rewriting? + raise Exception("Function did not reload. (This can happen when using py.test" + " with assertion rewriting; use --assert=plain for this test.)") + if py3: + assert oldcfn.__func__ is v1[2] + else: + assert oldcfn.im_class is v1[0] + assert oldcfn.__func__ is v1[2].__func__ + assert oldcfn.__self__ is c + + + # write again and reload + open(mod, 'w').write(code.format(path=pgpath, msg="C.fn() Version2")) + remove_cache(mod) + pg.reload.reloadAll(path, debug=True) + if py3: + v3 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, c.sig, c.fn, c.fn.__func__) + else: + v3 = (reload_test_mod.C, reload_test_mod.C.sig, reload_test_mod.C.fn, reload_test_mod.C.fn.__func__, c.sig, c.fn, c.fn.__func__) + + #for i in range(len(old)): + #print id(old[i]), id(new1[i]), id(new2[i]), old[i], new1[i] + + cfn1 = pg.reload.getPreviousVersion(c.fn) + cfn2 = pg.reload.getPreviousVersion(cfn1) + + if py3: + assert cfn1.__func__ is v2[2] + assert cfn2.__func__ is v1[2] + else: + assert cfn1.__func__ is v2[2].__func__ + assert cfn2.__func__ is v1[2].__func__ + assert cfn1.im_class is v2[0] + assert cfn2.im_class is v1[0] + assert cfn1.__self__ is c + assert cfn2.__self__ is c + + pg.functions.disconnect(c.sig, c.fn) +