[pygobject] [API add] Add Signal class for adding and connecting custom signals.



commit a7c524219987fbf37e455a91e4c78d2b9b4db12d
Author: Simon Feltman <s feltman gmail com>
Date:   Tue Mar 20 04:33:50 2012 -0700

    [API add] Add Signal class for adding and connecting custom signals.
    
    The Signal class provides easy creation of signals and removes the
    need for __gsignals__ in client code. The Signal class can also be
    used as a decorator for wrapping up the custom closure. As well as
    providing a "BoundSignal" when accessed on an instance for making
    connections without specifying a signal name string.
    Python3 annotations can also be used to supply closure argument and
    return types when Signal is used as a decorator. For example:
    
    class Eggs(GObject.GObject):
        @GObject.Signal
        def spam(self, count:int):
            pass
    
    https://bugzilla.gnome.org/show_bug.cgi?id=434924

 examples/signal.py          |   34 ++++--
 gi/_gobject/Makefile.am     |    3 +-
 gi/_gobject/__init__.py     |    5 +
 gi/_gobject/signalhelper.py |  251 +++++++++++++++++++++++++++++++++++++++++++
 tests/test_signal.py        |  208 ++++++++++++++++++++++++++++++++++--
 5 files changed, 482 insertions(+), 19 deletions(-)
---
diff --git a/examples/signal.py b/examples/signal.py
index 9a9d00c..69c1d62 100644
--- a/examples/signal.py
+++ b/examples/signal.py
@@ -1,28 +1,42 @@
+from __future__ import print_function
+
 from gi.repository import GObject
 
 
 class C(GObject.GObject):
-    __gsignals__ = {
-        'my_signal': (GObject.SIGNAL_RUN_FIRST, GObject.TYPE_NONE,
-                      (GObject.TYPE_INT,))
-    }
+    @GObject.Signal(arg_types=(int,))
+    def my_signal(self, arg):
+        """Decorator style signal which uses the method name as signal name and
+        the method as the closure.
 
-    def do_my_signal(self, arg):
-        print "C: class closure for `my_signal' called with argument", arg
+        Note that with python3 annotations can be used for argument types as follows:
+            @GObject.Signal
+            def my_signal(self, arg:int):
+                pass
+        """
+        print("C: class closure for `my_signal' called with argument", arg)
+
+    @GObject.Signal
+    def noarg_signal(self):
+        """Decoration of a signal using all defaults and no arguments."""
+        print("C: class closure for `noarg_signal' called")
 
 
 class D(C):
     def do_my_signal(self, arg):
-        print "D: class closure for `my_signal' called.  Chaining up to C"
-        C.do_my_signal(self, arg)
+        print("D: class closure for `my_signal' called.  Chaining up to C")
+        C.my_signal(self, arg)
+
 
+def my_signal_handler(obj, arg, *extra):
+    print("handler for `my_signal' called with argument", arg, "and extra args", extra)
 
-def my_signal_handler(object, arg, *extra):
-    print "handler for `my_signal' called with argument", arg, "and extra args", extra
 
 inst = C()
 inst2 = D()
 
 inst.connect("my_signal", my_signal_handler, 1, 2, 3)
+inst.connect("noarg_signal", my_signal_handler, 1, 2, 3)
 inst.emit("my_signal", 42)
+inst.emit("noarg_signal")
 inst2.emit("my_signal", 42)
diff --git a/gi/_gobject/Makefile.am b/gi/_gobject/Makefile.am
index 2537f99..61762fc 100644
--- a/gi/_gobject/Makefile.am
+++ b/gi/_gobject/Makefile.am
@@ -29,7 +29,8 @@ pygobjectdir = $(pyexecdir)/gi/_gobject
 pygobject_PYTHON = \
 	__init__.py \
 	constants.py \
-	propertyhelper.py
+	propertyhelper.py \
+	signalhelper.py
 
 pygobject_LTLIBRARIES = _gobject.la
 
diff --git a/gi/_gobject/__init__.py b/gi/_gobject/__init__.py
index ba267c8..7b6ab36 100644
--- a/gi/_gobject/__init__.py
+++ b/gi/_gobject/__init__.py
@@ -31,6 +31,7 @@ from .. import _glib
 from . import _gobject
 from . import constants
 from .propertyhelper import Property
+from . import signalhelper
 
 GBoxed = _gobject.GBoxed
 GEnum = _gobject.GEnum
@@ -210,6 +211,9 @@ G_MAXSSIZE = constants.G_MAXSSIZE
 G_MINOFFSET = constants.G_MINOFFSET
 G_MAXOFFSET = constants.G_MAXOFFSET
 
+Signal = signalhelper.Signal
+SignalOverride = signalhelper.SignalOverride
+
 from .._glib import option
 sys.modules['gi._gobject.option'] = option
 
@@ -219,6 +223,7 @@ class GObjectMeta(type):
     def __init__(cls, name, bases, dict_):
         type.__init__(cls, name, bases, dict_)
         cls._install_properties()
+        signalhelper.install_signals(cls)
         cls._type_register(cls.__dict__)
 
     def _install_properties(cls):
diff --git a/gi/_gobject/signalhelper.py b/gi/_gobject/signalhelper.py
new file mode 100644
index 0000000..0bd631d
--- /dev/null
+++ b/gi/_gobject/signalhelper.py
@@ -0,0 +1,251 @@
+# -*- Mode: Python; py-indent-offset: 4 -*-
+# pygobject - Python bindings for the GObject library
+# Copyright (C) 2012 Simon Feltman
+#
+#   gobject/signalhelper.py: GObject signal binding decorator object
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
+# USA
+
+import sys
+import inspect
+
+from . import _gobject
+
+# Callable went away in python 3.0 and came back in 3.2.
+# Use versioning to figure out when to define it, otherwise we have to deal with
+# the complexity of using __builtin__ or builtin between python versions to
+# check if callable exists which PyFlakes will also complain about.
+if (3, 0) <= sys.version_info < (3, 2):
+    def callable(fn):
+        return hasattr(fn, '__call__')
+
+
+class Signal(str):
+    """
+    Object which gives a nice API for creating and binding signals.
+
+    Example:
+    class Spam(GObject.GObject):
+        velocity = 0
+
+        @GObject.Signal
+        def pushed(self):
+            self.velocity += 1
+
+        @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST)
+        def pulled(self):
+            self.velocity -= 1
+
+        stomped = GObject.Signal('stomped', arg_types=(int,))
+
+        @GObject.Signal
+        def annotated_signal(self, a:int, b:str):
+            "Python3 annotation support for parameter types.
+
+    def on_pushed(obj):
+        print(obj)
+
+    spam = Spam()
+    spam.pushed.connect(on_pushed)
+    spam.pushed.emit()
+    """
+    class BoundSignal(str):
+        """
+        Temporary binding object which can be used for connecting signals
+        without specifying the signal name string to connect.
+        """
+        def __new__(cls, name, *args, **kargs):
+            return str.__new__(cls, name)
+
+        def __init__(self, signal, gobj):
+            str.__init__(self)
+            self.signal = signal
+            self.gobj = gobj
+
+        def __repr__(self):
+            return 'BoundSignal("%s")' % self
+
+        def __call__(self, *args, **kargs):
+            """Call the signals closure."""
+            return self.signal.func(self.gobj, *args, **kargs)
+
+        def connect(self, callback, *args, **kargs):
+            """Same as GObject.GObject.connect except there is no need to specify
+            the signal name."""
+            return self.gobj.connect(self, callback, *args, **kargs)
+
+        def connect_detailed(self, callback, detail, *args, **kargs):
+            """Same as GObject.GObject.connect except there is no need to specify
+            the signal name. In addition concats "::<detail>" to the signal name
+            when connecting; for use with notifications like "notify" when a property
+            changes.
+            """
+            return self.gobj.connect(self + '::' + detail, callback, *args, **kargs)
+
+        def disconnect(self, handler_id):
+            """Same as GObject.GObject.disconnect."""
+            self.instance.disconnect(handler_id)
+
+        def emit(self, *args, **kargs):
+            """Same as GObject.GObject.emit except there is no need to specify
+            the signal name."""
+            self.gobj.emit(str(self), *args, **kargs)
+
+    def __new__(cls, name='', *args, **kargs):
+        if callable(name):
+            name = name.__name__
+        return str.__new__(cls, name)
+
+    def __init__(self, name='', func=None, flags=_gobject.SIGNAL_RUN_FIRST,
+                 return_type=None, arg_types=None, doc=''):
+        """
+        @param  name: name of signal or closure method when used as direct decorator.
+        @type   name: string or callable
+        @param  func: closure method.
+        @type   func: callable
+        @param  flags: flags specifying when to run closure
+        @type   flags: GObject.SignalFlags
+        @param  return_type: return type
+        @type   return_type: type
+        @param  arg_types: list of argument types specifying the signals function signature
+        @type   arg_types: None
+        @param  doc: documentation of signal object
+        @type   doc: string
+        """
+        if func and not name:
+            name = func.__name__
+        elif callable(name):
+            func = name
+            name = func.__name__
+        if func and not doc:
+            doc = func.__doc__
+
+        str.__init__(self)
+
+        if func and not (return_type or arg_types):
+            return_type, arg_types = get_signal_annotations(func)
+        if arg_types is None:
+            arg_types = tuple()
+
+        self.func = func
+        self.flags = flags
+        self.return_type = return_type
+        self.arg_types = arg_types
+        self.__doc__ = doc
+
+    def __get__(self, instance, owner=None):
+        """Returns a BoundSignal when accessed on an object instance."""
+        if instance is None:
+            return self
+        return self.BoundSignal(self, instance)
+
+    def __call__(self, obj, *args, **kargs):
+        """Allows for instantiated Signals to be used as a decorator or calling
+        of the underlying signal method."""
+
+        # If obj is a GObject, than we call this signal as a closure otherwise
+        # it is used as a re-application of a decorator.
+        if isinstance(obj, _gobject.GObject):
+            self.func(obj, *args, **kargs)
+        else:
+            # If self is already an allocated name, use it otherwise create a new named
+            # signal using the closure name as the name.
+            if str(self):
+                name = str(self)
+            else:
+                name = obj.__name__
+            # Return a new value of this type since it is based on an immutable string.
+            return type(self)(name=name, func=obj, flags=self.flags,
+                              return_type=self.return_type, arg_types=self.arg_types,
+                              doc=self.__doc__)
+
+    def copy(self, newName=None):
+        """Returns a renamed copy of the Signal."""
+        if newName is None:
+            newName = self.name
+        return type(self)(name=newName, func=self.func, flags=self.flags,
+                          return_type=self.return_type, arg_types=self.arg_types,
+                          doc=self.__doc__)
+
+    def get_signal_args(self):
+        """Returns a tuple of: (flags, return_type, arg_types)"""
+        return (self.flags, self.return_type, self.arg_types)
+
+
+class SignalOverride(Signal):
+    """Specialized sub-class of signal which can be used as a decorator for overriding
+    existing signals on GObjects.
+
+    Example:
+    class MyWidget(Gtk.Widget):
+        @GObject.SignalOverride
+        def configure_event(self):
+            pass
+    """
+    def get_signal_args(self):
+        """Returns the string 'override'."""
+        return 'override'
+
+
+def get_signal_annotations(func):
+    """Attempt pulling python 3 function annotations off of 'func' for
+    use as a signals type information. Returns an ordered nested tuple
+    of (return_type, (arg_type1, arg_type2, ...)). If the given function
+    does not have annotations then (None, tuple()) is returned.
+    """
+    arg_types = tuple()
+    return_type = None
+
+    if hasattr(func, '__annotations__'):
+        spec = inspect.getfullargspec(func)
+        arg_types = tuple(spec.annotations[arg] for arg in spec.args
+                          if arg in spec.annotations)
+        if 'return' in spec.annotations:
+            return_type = spec.annotations['return']
+
+    return return_type, arg_types
+
+
+def install_signals(cls):
+    """Adds Signal instances on a GObject derived class into the '__gsignals__'
+    dictionary to be picked up and registered as real GObject signals.
+    """
+    gsignals = getattr(cls, '__gsignals__', {})
+    newsignals = {}
+    for name, signal in cls.__dict__.items():
+        if isinstance(signal, Signal):
+            signalName = str(signal)
+            # Fixup a signal which is unnamed by using the class variable name.
+            # Since Signal is based on string which immutable,
+            # we must copy and replace the class variable.
+            if not signalName:
+                signalName = name
+                signal = signal.copy(name)
+                setattr(cls, name, signal)
+            if signalName in gsignals:
+                raise ValueError('Signal "%s" has already been registered.' % name)
+            newsignals[signalName] = signal
+            gsignals[signalName] = signal.get_signal_args()
+
+    cls.__gsignals__ = gsignals
+
+    # Setup signal closures by adding the specially named
+    # method to the class in the form of "do_<signal_name>".
+    for name, signal in newsignals.items():
+        if signal.func is not None:
+            funcName = 'do_' + name.replace('-', '_')
+            if not hasattr(cls, funcName):
+                setattr(cls, funcName, signal.func)
diff --git a/tests/test_signal.py b/tests/test_signal.py
index 02e3b96..bff823b 100644
--- a/tests/test_signal.py
+++ b/tests/test_signal.py
@@ -5,6 +5,7 @@ import unittest
 import sys
 
 from gi.repository import GObject
+from gi._gobject import signalhelper
 import testhelper
 from compathelper import _long
 
@@ -342,20 +343,20 @@ class TestSigProp(unittest.TestCase):
 
 f = GObject.SignalFlags.RUN_FIRST
 l = GObject.SignalFlags.RUN_LAST
-float = GObject.TYPE_FLOAT
-double = GObject.TYPE_DOUBLE
-uint = GObject.TYPE_UINT
-ulong = GObject.TYPE_ULONG
+gfloat = GObject.TYPE_FLOAT
+gdouble = GObject.TYPE_DOUBLE
+guint = GObject.TYPE_UINT
+gulong = GObject.TYPE_ULONG
 
 
 class CM(GObject.GObject):
     __gsignals__ = dict(
         test1=(f, None, ()),
         test2=(l, None, (str,)),
-        test3=(l, int, (double,)),
-        test4=(f, None, (bool, _long, float, double, int, uint, ulong)),
-        test_float=(l, float, (float,)),
-        test_double=(l, double, (double, )),
+        test3=(l, int, (gdouble,)),
+        test4=(f, None, (bool, _long, gfloat, gdouble, int, guint, gulong)),
+        test_float=(l, gfloat, (gfloat,)),
+        test_double=(l, gdouble, (gdouble, )),
         test_string=(l, str, (str, )),
         test_object=(l, object, (object, )),
     )
@@ -419,5 +420,196 @@ class TestPyGValue(unittest.TestCase):
         obj.emit('my-boxed-signal')
         assert not sys.last_type
 
+
+class TestSignalDecorator(unittest.TestCase):
+    class Decorated(GObject.GObject):
+        value = 0
+
+        @GObject.Signal
+        def pushed(self):
+            """this will push"""
+            self.value += 1
+
+        @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST)
+        def pulled(self):
+            self.value -= 1
+
+        stomped = GObject.Signal('stomped', arg_types=(int,), doc='this will stomp')
+        unnamed = GObject.Signal()
+
+    class DecoratedOverride(GObject.GObject):
+        overridden_closure_called = False
+        notify_called = False
+        value = GObject.Property(type=int, default=0)
+
+        @GObject.SignalOverride
+        def notify(self, *args, **kargs):
+            self.overridden_closure_called = True
+            #GObject.GObject.notify(self, *args, **kargs)
+
+        def on_notify(self, obj, prop):
+            self.notify_called = True
+
+    def setUp(self):
+        self.unnamedCalled = False
+
+    def onUnnamed(self, obj):
+        self.unnamedCalled = True
+
+    def testGetSignalArgs(self):
+        self.assertEqual(self.Decorated.pushed.get_signal_args(),
+                         (GObject.SignalFlags.RUN_FIRST, None, tuple()))
+        self.assertEqual(self.Decorated.pulled.get_signal_args(),
+                         (GObject.SignalFlags.RUN_LAST, None, tuple()))
+        self.assertEqual(self.Decorated.stomped.get_signal_args(),
+                         (GObject.SignalFlags.RUN_FIRST, None, (int,)))
+
+    def testClosuresCalled(self):
+        decorated = self.Decorated()
+        self.assertEqual(decorated.value, 0)
+        decorated.pushed.emit()
+        self.assertEqual(decorated.value, 1)
+        decorated.pulled.emit()
+        self.assertEqual(decorated.value, 0)
+
+    def testSignalCopy(self):
+        blah = self.Decorated.stomped.copy('blah')
+        self.assertEqual(str(blah), blah)
+        self.assertEqual(blah.func, self.Decorated.stomped.func)
+        self.assertEqual(blah.flags, self.Decorated.stomped.flags)
+        self.assertEqual(blah.return_type, self.Decorated.stomped.return_type)
+        self.assertEqual(blah.arg_types, self.Decorated.stomped.arg_types)
+        self.assertEqual(blah.__doc__, self.Decorated.stomped.__doc__)
+
+    def testDocString(self):
+        # Test the two techniques for setting doc strings on the signals
+        # class variables, through the "doc" keyword or as the getter doc string.
+        self.assertEqual(self.Decorated.stomped.__doc__, 'this will stomp')
+        self.assertEqual(self.Decorated.pushed.__doc__, 'this will push')
+
+    def testUnnamedSignalGetsNamed(self):
+        self.assertEqual(str(self.Decorated.unnamed), 'unnamed')
+
+    def testUnnamedSignalGetsCalled(self):
+        obj = self.Decorated()
+        obj.connect('unnamed', self.onUnnamed)
+        self.assertEqual(self.unnamedCalled, False)
+        obj.emit('unnamed')
+        self.assertEqual(self.unnamedCalled, True)
+
+    def NOtestOverriddenSignal(self):
+        # Test that the pushed signal is called in with super and the override
+        # which should both increment the "value" to 3
+        obj = self.DecoratedOverride()
+        obj.connect("notify", obj.on_notify)
+        self.assertEqual(obj.value, 0)
+        #obj.notify.emit()
+        obj.value = 1
+        self.assertEqual(obj.value, 1)
+        self.assertTrue(obj.overridden_closure_called)
+        self.assertTrue(obj.notify_called)
+
+
+class TestSignalConnectors(unittest.TestCase):
+    class CustomButton(GObject.GObject):
+        value = 0
+
+        @GObject.Signal(arg_types=(int,))
+        def clicked(self, value):
+            self.value = value
+
+    def setUp(self):
+        self.obj = None
+        self.value = None
+
+    def onClicked(self, obj, value):
+        self.obj = obj
+        self.value = value
+
+    def testSignalEmit(self):
+        # standard callback connection with different forms of emit.
+        obj = self.CustomButton()
+        obj.connect('clicked', self.onClicked)
+
+        # vanilla
+        obj.emit('clicked', 1)
+        self.assertEqual(obj.value, 1)
+        self.assertEqual(obj, self.obj)
+        self.assertEqual(self.value, 1)
+
+        # using class signal as param
+        self.obj = None
+        self.value = None
+        obj.emit(self.CustomButton.clicked, 1)
+        self.assertEqual(obj, self.obj)
+        self.assertEqual(self.value, 1)
+
+        # using bound signal as param
+        self.obj = None
+        self.value = None
+        obj.emit(obj.clicked, 1)
+        self.assertEqual(obj, self.obj)
+        self.assertEqual(self.value, 1)
+
+        # using bound signal with emit
+        self.obj = None
+        self.value = None
+        obj.clicked.emit(1)
+        self.assertEqual(obj, self.obj)
+        self.assertEqual(self.value, 1)
+
+    def testSignalClassConnect(self):
+        obj = self.CustomButton()
+        obj.connect(self.CustomButton.clicked, self.onClicked)
+        obj.emit('clicked', 2)
+        self.assertEqual(obj, self.obj)
+        self.assertEqual(self.value, 2)
+
+    def testSignalBoundConnect(self):
+        obj = self.CustomButton()
+        obj.clicked.connect(self.onClicked)
+        obj.emit('clicked', 3)
+        self.assertEqual(obj, self.obj)
+        self.assertEqual(self.value, 3)
+
+
+# For this test to work with both python2 and 3 we need to dynamically
+# exec the given code due to the new syntax causing an error in python 2.
+annotated_class_code = """
+class AnnotatedSignalClass(GObject.GObject):
+    @GObject.Signal
+    def sig1(self, a:int, b:float):
+        pass
+
+    @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST)
+    def sig2_with_return(self, a:int, b:float) -> str:
+        return "test"
+"""
+
+
+class TestPython3Signals(unittest.TestCase):
+    AnnotatedClass = None
+
+    def setUp(self):
+        if sys.version_info >= (3, 0):
+            exec(annotated_class_code, globals(), globals())
+            self.assertTrue('AnnotatedSignalClass' in globals())
+            self.AnnotatedClass = globals()['AnnotatedSignalClass']
+
+    def testAnnotations(self):
+        if self.AnnotatedClass:
+            self.assertEqual(signalhelper.get_signal_annotations(self.AnnotatedClass.sig1.func),
+                             (None, (int, float)))
+            self.assertEqual(signalhelper.get_signal_annotations(self.AnnotatedClass.sig2_with_return.func),
+                             (str, (int, float)))
+
+            self.assertEqual(self.AnnotatedClass.sig2_with_return.get_signal_args(),
+                             (GObject.SignalFlags.RUN_LAST, str, (int, float)))
+            self.assertEqual(self.AnnotatedClass.sig2_with_return.arg_types,
+                             (int, float))
+            self.assertEqual(self.AnnotatedClass.sig2_with_return.return_type,
+                             str)
+
+
 if __name__ == '__main__':
     unittest.main()



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]