[pygobject] [API add] Add Signal class for adding and connecting custom signals.
- From: Martin Pitt <martinpitt src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [pygobject] [API add] Add Signal class for adding and connecting custom signals.
- Date: Mon, 3 Sep 2012 13:47:51 +0000 (UTC)
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]