diff options
author | Christoph Reiter <reiter.christoph@gmail.com> | 2018-04-03 21:07:56 +0200 |
---|---|---|
committer | Christoph Reiter <reiter.christoph@gmail.com> | 2018-04-12 16:46:42 +0200 |
commit | c745866df9e4394c6615e79779fff1221a84953a (patch) | |
tree | 0fd1a94ead9d5b0a0b0615f3db4f3ae52737278d | |
parent | 5441286a41336617b6ce9f37a41980d3941eb8ee (diff) | |
download | pygobject-c745866df9e4394c6615e79779fff1221a84953a.tar.gz |
Add a minimal implementation of Gtk.Template. See #52
This tries to add a minimal API which allows for basic template usage
with the possibility to maybe add more automation/subclassing/nesting
later on.
Compared to gi_composites.py this adds parameters to Child and Callback
to set the name, init_template() doesn't need to be called and is stricter
in what it supports to allow future improvements.
The _gtktemplate.py file should be resuable with older PyGObject versions
with the only difference that init_template() needs to be called.
-rw-r--r-- | gi/_gtktemplate.py | 218 | ||||
-rw-r--r-- | gi/gimodule.c | 19 | ||||
-rw-r--r-- | gi/overrides/Gtk.py | 5 | ||||
-rw-r--r-- | tests/test_gtk_template.py | 482 |
4 files changed, 721 insertions, 3 deletions
diff --git a/gi/_gtktemplate.py b/gi/_gtktemplate.py new file mode 100644 index 00000000..37707e8c --- /dev/null +++ b/gi/_gtktemplate.py @@ -0,0 +1,218 @@ +# Copyright 2015 Dustin Spicuzza <dustin@virtualroadside.com> +# 2018 Nikita Churaev <lamefun.x0r@gmail.com> +# 2018 Christoph Reiter <reiter.christoph@gmail.com> +# +# 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 St, Fifth Floor, Boston, MA 02110-1301 +# USA + +from gi.repository import GLib, GObject, Gio + + +def connect_func(builder, obj, signal_name, handler_name, + connect_object, flags, cls): + + if handler_name not in cls.__gtktemplate_methods__: + return + + method_name = cls.__gtktemplate_methods__[handler_name] + template_inst = builder.get_object(cls.__gtype_name__) + template_inst.__gtktemplate_handlers__.add(handler_name) + handler = getattr(template_inst, method_name) + + after = int(flags & GObject.ConnectFlags.AFTER) + swapped = int(flags & GObject.ConnectFlags.SWAPPED) + if swapped: + raise RuntimeError( + "%r not supported" % GObject.ConnectFlags.SWAPPED) + + if connect_object is not None: + if after: + func = obj.connect_object_after + else: + func = obj.connect_object + func(signal_name, handler, connect_object) + else: + if after: + func = obj.connect_after + else: + func = obj.connect + func(signal_name, handler) + + +def register_template(cls): + bound_methods = {} + bound_widgets = {} + + for attr_name, obj in list(cls.__dict__.items()): + if isinstance(obj, CallThing): + setattr(cls, attr_name, obj._func) + handler_name = obj._name + if handler_name is None: + handler_name = attr_name + + if handler_name in bound_methods: + old_attr_name = bound_methods[handler_name] + raise RuntimeError( + "Error while exposing handler %r as %r, " + "already available as %r" % ( + handler_name, attr_name, old_attr_name)) + else: + bound_methods[handler_name] = attr_name + elif isinstance(obj, Child): + widget_name = obj._name + if widget_name is None: + widget_name = attr_name + + if widget_name in bound_widgets: + old_attr_name = bound_widgets[widget_name] + raise RuntimeError( + "Error while exposing child %r as %r, " + "already available as %r" % ( + widget_name, attr_name, old_attr_name)) + else: + bound_widgets[widget_name] = attr_name + cls.bind_template_child_full(widget_name, False, 0) + + cls.__gtktemplate_methods__ = bound_methods + cls.__gtktemplate_widgets__ = bound_widgets + + cls.set_connect_func(connect_func, cls) + + base_init_template = cls.init_template + cls.__dontuse_ginstance_init__ = \ + lambda s: init_template(s, cls, base_init_template) + # To make this file work with older PyGObject we expose our init code + # as init_template() but make it a noop when we call it ourselves first + cls.init_template = cls.__dontuse_ginstance_init__ + + +def init_template(self, cls, base_init_template): + cls.init_template = lambda s: None + + if self.__class__ is not cls: + raise TypeError( + "Inheritance from classes with @Gtk.Template decorators " + "is not allowed at this time") + + self.__gtktemplate_handlers__ = set() + + base_init_template(self) + + for widget_name, attr_name in self.__gtktemplate_widgets__.items(): + self.__dict__[attr_name] = self.get_template_child(cls, widget_name) + + for handler_name, attr_name in self.__gtktemplate_methods__.items(): + if handler_name not in self.__gtktemplate_handlers__: + raise RuntimeError( + "Handler '%s' was declared with @Gtk.Template.Callback " + "but was not present in template" % handler_name) + + +class Child(object): + + def __init__(self, name=None): + self._name = name + + +class CallThing(object): + + def __init__(self, name, func): + self._name = name + self._func = func + + +class Callback(object): + + def __init__(self, name=None): + self._name = name + + def __call__(self, func): + return CallThing(self._name, func) + + +class Template(object): + + def __init__(self, **kwargs): + self.string = None + self.filename = None + self.resource_path = None + if "string" in kwargs: + self.string = kwargs.pop("string") + elif "filename" in kwargs: + self.filename = kwargs.pop("filename") + elif "resource_path" in kwargs: + self.resource_path = kwargs.pop("resource_path") + else: + raise TypeError( + "Requires one of the following arguments: " + "string, filename, resource_path") + + if kwargs: + raise TypeError("Unhandled keyword arguments %r" % kwargs) + + @classmethod + def from_file(cls, filename): + return cls(filename=filename) + + @classmethod + def from_string(cls, string): + return cls(string=string) + + @classmethod + def from_resource(cls, resource_path): + return cls(resource_path=resource_path) + + Callback = Callback + + Child = Child + + def __call__(self, cls): + from gi.repository import Gtk + + if not isinstance(cls, type) or not issubclass(cls, Gtk.Widget): + raise TypeError("Can only use @Gtk.Template on Widgets") + + if "__gtype_name__" not in cls.__dict__: + raise TypeError( + "%r does not have a __gtype_name__. Set it to the name " + "of the class in your template" % cls.__name__) + + if hasattr(cls, "__gtktemplate_methods__"): + raise TypeError("Cannot nest template classes") + + if self.string is not None: + data = self.string + if not isinstance(data, bytes): + data = data.encode("utf-8") + bytes_ = GLib.Bytes.new(data) + cls.set_template(bytes_) + register_template(cls) + return cls + elif self.resource_path is not None: + Gio.resources_get_info( + self.resource_path, Gio.ResourceLookupFlags.NONE) + cls.set_template_from_resource(self.resource_path) + register_template(cls) + return cls + else: + assert self.filename is not None + file_ = Gio.File.new_for_path(self.filename) + bytes_ = GLib.Bytes.new(file_.load_contents()[1]) + cls.set_template(bytes_) + register_template(cls) + return cls + + +__all__ = ["Template"] diff --git a/gi/gimodule.c b/gi/gimodule.c index f42ad2b1..8e5d7225 100644 --- a/gi/gimodule.c +++ b/gi/gimodule.c @@ -1031,6 +1031,7 @@ pygobject__g_instance_init(GTypeInstance *instance, { GObject *object = (GObject *) instance; PyObject *wrapper, *args, *kwargs; + PyGILState_STATE state; wrapper = g_object_get_qdata(object, pygobject_wrapper_key); if (wrapper == NULL) { @@ -1041,12 +1042,13 @@ pygobject__g_instance_init(GTypeInstance *instance, } } pygobject_init_wrapper_set(NULL); + + state = PyGILState_Ensure(); + if (wrapper == NULL) { /* this looks like a python object created through * g_object_new -> we have no python wrapper, so create it * now */ - PyGILState_STATE state; - state = PyGILState_Ensure(); wrapper = pygobject_new_full(object, /*steal=*/ FALSE, g_class); @@ -1062,8 +1064,19 @@ pygobject__g_instance_init(GTypeInstance *instance, Py_DECREF(args); Py_DECREF(kwargs); - PyGILState_Release(state); } + + /* XXX: used for Gtk.Template */ + if (PyObject_HasAttrString (wrapper, "__dontuse_ginstance_init__")) { + PyObject *result; + result = PyObject_CallMethod (wrapper, "__dontuse_ginstance_init__", NULL); + if (result == NULL) + PyErr_Print (); + else + Py_DECREF (result); + } + + PyGILState_Release(state); } /* This implementation is bad, see bug 566571 for an example why. diff --git a/gi/overrides/Gtk.py b/gi/overrides/Gtk.py index 0cdd648f..23d06adf 100644 --- a/gi/overrides/Gtk.py +++ b/gi/overrides/Gtk.py @@ -25,6 +25,7 @@ import warnings from gi.repository import GObject from .._ossighelper import wakeup_on_signal, register_sigint_fallback +from .._gtktemplate import Template from ..overrides import override, strip_boolean_result, deprecated_init from ..module import get_introspection_module from .._compat import string_types @@ -35,6 +36,10 @@ Gtk = get_introspection_module('Gtk') __all__ = [] + +Template = Template +__all__.append('Template') + if Gtk._version == '2.0': warn_msg = "You have imported the Gtk 2.0 module. Because Gtk 2.0 \ was not designed for use with introspection some of the \ diff --git a/tests/test_gtk_template.py b/tests/test_gtk_template.py new file mode 100644 index 00000000..e55f2983 --- /dev/null +++ b/tests/test_gtk_template.py @@ -0,0 +1,482 @@ +# coding: UTF-8 + +from __future__ import absolute_import + +import tempfile +import os +import pytest + +Gtk = pytest.importorskip("gi.repository.Gtk") +GLib = pytest.importorskip("gi.repository.GLib") +GObject = pytest.importorskip("gi.repository.GObject") +Gio = pytest.importorskip("gi.repository.Gio") + + +from .helper import capture_exceptions + + +def new_gtype_name(_count=[0]): + _count[0] += 1 + return "GtkTemplateTest%d" % _count[0] + + +def ensure_resource_registered(): + resource_path = "/org/gnome/pygobject/test/a.ui" + + def is_registered(path): + try: + Gio.resources_get_info(path, Gio.ResourceLookupFlags.NONE) + except GLib.Error: + return False + return True + + if is_registered(resource_path): + return resource_path + + gresource_data = ( + b'GVariant\x00\x00\x00\x00\x00\x00\x00\x00\x18\x00\x00\x00' + b'\xc8\x00\x00\x00\x00\x00\x00(\x06\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x01\x00\x00\x00\x04\x00\x00\x00\x05\x00\x00\x00' + b'\x06\x00\x00\x00KP\x90\x0b\x03\x00\x00\x00\xc8\x00\x00\x00' + b'\x04\x00L\x00\xcc\x00\x00\x00\xd0\x00\x00\x00\xb0\xb7$0' + b'\x00\x00\x00\x00\xd0\x00\x00\x00\x06\x00L\x00\xd8\x00\x00\x00' + b'\xdc\x00\x00\x00f\xc30\xd1\x01\x00\x00\x00\xdc\x00\x00\x00' + b'\n\x00L\x00\xe8\x00\x00\x00\xec\x00\x00\x00\xd4\xb5\x02\x00' + b'\xff\xff\xff\xff\xec\x00\x00\x00\x01\x00L\x00\xf0\x00\x00\x00' + b'\xf4\x00\x00\x005H}\xe3\x02\x00\x00\x00\xf4\x00\x00\x00' + b'\x05\x00L\x00\xfc\x00\x00\x00\x00\x01\x00\x00\xa2^\xd6t' + b'\x04\x00\x00\x00\x00\x01\x00\x00\x04\x00v\x00\x08\x01\x00\x00' + b'\xa5\x01\x00\x00org/\x01\x00\x00\x00gnome/\x00\x00\x02\x00\x00\x00' + b'pygobject/\x00\x00\x04\x00\x00\x00/\x00\x00\x00\x00\x00\x00\x00' + b'test/\x00\x00\x00\x05\x00\x00\x00a.ui\x00\x00\x00\x00' + b'\x8d\x00\x00\x00\x00\x00\x00\x00<interface>\n <template class="G' + b'tkTemplateTestResource" parent="GtkBox">\n <property name="spaci' + b'ng">42</property>\n </template>\n</interface>\n\x00\x00(uuay)' + ) + + resource = Gio.Resource.new_from_data(GLib.Bytes.new(gresource_data)) + Gio.resources_register(resource) + assert is_registered(resource_path) + return resource_path + + +def test_allow_init_template_call(): + + type_name = new_gtype_name() + + xml = """\ +<interface> + <template class="{0}" parent="GtkBox"> + </template> +</interface> +""".format(type_name) + + @Gtk.Template.from_string(xml) + class Foo(Gtk.Box): + __gtype_name__ = type_name + + def __init__(self): + super(Foo, self).__init__() + self.init_template() + + Foo() + + +def test_main_example(): + + type_name = new_gtype_name() + + example_xml = """\ +<interface> + <template class="{0}" parent="GtkBox"> + <property name="orientation">GTK_ORIENTATION_HORIZONTAL</property> + <property name="spacing">4</property> + <child> + <object class="GtkButton" id="hello_button"> + <property name="label">Hello World</property> + <signal name="clicked" handler="hello_button_clicked" + object="{0}" swapped="no"/> + <signal name="clicked" handler="hello_button_clicked_after" + object="{0}" swapped="no" after="yes"/> + </object> + </child> + <child> + <object class="GtkButton" id="goodbye_button"> + <property name="label">Goodbye World</property> + <signal name="clicked" handler="goodbye_button_clicked"/> + <signal name="clicked" handler="goodbye_button_clicked_after" + after="yes"/> + </object> + </child> + </template> +</interface> +""".format(type_name) + + @Gtk.Template.from_string(example_xml) + class Foo(Gtk.Box): + __gtype_name__ = type_name + + def __init__(self): + super(Foo, self).__init__() + self.callback_hello = [] + self.callback_hello_after = [] + self.callback_goodbye = [] + self.callback_goodbye_after = [] + + @Gtk.Template.Callback("hello_button_clicked") + def _hello_button_clicked(self, *args): + self.callback_hello.append(args) + + @Gtk.Template.Callback("hello_button_clicked_after") + def _hello_after(self, *args): + self.callback_hello_after.append(args) + + _hello_button = Gtk.Template.Child("hello_button") + + goodbye_button = Gtk.Template.Child() + + @Gtk.Template.Callback("goodbye_button_clicked") + def _goodbye_button_clicked(self, *args): + self.callback_goodbye.append(args) + + @Gtk.Template.Callback("goodbye_button_clicked_after") + def _goodbye_after(self, *args): + self.callback_goodbye_after.append(args) + + w = Foo() + assert w.__gtype__.name == type_name + assert w.props.orientation == Gtk.Orientation.HORIZONTAL + assert w.props.spacing == 4 + assert isinstance(w._hello_button, Gtk.Button) + assert w._hello_button.props.label == "Hello World" + assert isinstance(w.goodbye_button, Gtk.Button) + assert w.goodbye_button.props.label == "Goodbye World" + + assert w.callback_hello == [] + w._hello_button.clicked() + assert w.callback_hello == [(w,)] + assert w.callback_hello_after == [(w,)] + + assert w.callback_goodbye == [] + w.goodbye_button.clicked() + assert w.callback_goodbye == [(w.goodbye_button,)] + assert w.callback_goodbye_after == [(w.goodbye_button,)] + + +def test_duplicate_handler(): + + type_name = new_gtype_name() + + xml = """\ +<interface> + <template class="{0}" parent="GtkBox"> + <child> + <object class="GtkButton" id="hello_button"> + <signal name="clicked" handler="hello_button_clicked"> + </object> + </child> + </template> +</interface> +""".format(type_name) + + class Foo(Gtk.Box): + __gtype_name__ = type_name + + @Gtk.Template.Callback("hello_button_clicked") + def _hello_button_clicked(self, *args): + pass + + @Gtk.Template.Callback() + def hello_button_clicked(self, *args): + pass + + with pytest.raises(RuntimeError, match=".*hello_button_clicked.*"): + Gtk.Template.from_string(xml)(Foo) + + +def test_duplicate_child(): + type_name = new_gtype_name() + + xml = """\ +<interface> + <template class="{0}" parent="GtkBox"> + <child> + <object class="GtkButton" id="hello_button" /> + </child> + </template> +</interface> +""".format(type_name) + + class Foo(Gtk.Box): + __gtype_name__ = type_name + + foo = Gtk.Template.Child("hello_button") + hello_button = Gtk.Template.Child() + + with pytest.raises(RuntimeError, match=".*hello_button.*"): + Gtk.Template.from_string(xml)(Foo) + + +def test_nonexist_handler(): + type_name = new_gtype_name() + + xml = """\ +<interface> + <template class="{0}" parent="GtkBox"> + </template> +</interface> +""".format(type_name) + + @Gtk.Template.from_string(xml) + class Foo(Gtk.Box): + __gtype_name__ = type_name + + @Gtk.Template.Callback("nonexit") + def foo(self, *args): + pass + + with capture_exceptions() as exc_info: + Foo() + assert "nonexit" in str(exc_info[0].value) + assert exc_info[0].type is RuntimeError + + +def test_missing_handler_callback(): + type_name = new_gtype_name() + + xml = """\ +<interface> + <template class="{0}" parent="GtkBox"> + <child> + <object class="GtkButton" id="hello_button"> + <signal name="clicked" handler="i_am_not_used_in_python" /> + </object> + </child> + </template> +</interface> +""".format(type_name) + + class Foo(Gtk.Box): + __gtype_name__ = type_name + + Gtk.Template.from_string(xml)(Foo)() + + +def test_handler_swapped_not_supported(): + + type_name = new_gtype_name() + + xml = """\ +<interface> + <template class="{0}" parent="GtkBox"> + <child> + <object class="GtkButton" id="hello_button"> + <signal name="clicked" handler="hello_button_clicked" + object="{0}" swapped="yes" /> + </object> + </child> + </template> +</interface> +""".format(type_name) + + @Gtk.Template.from_string(xml) + class Foo(Gtk.Box): + __gtype_name__ = type_name + + hello_button = Gtk.Template.Child() + + @Gtk.Template.Callback("hello_button_clicked") + def foo(self, *args): + pass + + with capture_exceptions() as exc_info: + Foo() + assert "G_CONNECT_SWAPPED" in str(exc_info[0].value) + + +def test_handler_class_staticmethod(): + + type_name = new_gtype_name() + + xml = """\ +<interface> + <template class="{0}" parent="GtkBox"> + <child> + <object class="GtkButton" id="hello_button"> + <signal name="clicked" handler="clicked_class" /> + <signal name="clicked" handler="clicked_static" /> + </object> + </child> + </template> +</interface> +""".format(type_name) + + signal_args_class = [] + signal_args_static = [] + + @Gtk.Template.from_string(xml) + class Foo(Gtk.Box): + __gtype_name__ = type_name + + hello_button = Gtk.Template.Child() + + @Gtk.Template.Callback("clicked_class") + @classmethod + def cb1(*args): + signal_args_class.append(args) + + @Gtk.Template.Callback("clicked_static") + @staticmethod + def cb2(*args): + signal_args_static.append(args) + + foo = Foo() + foo.hello_button.clicked() + assert signal_args_class == [(Foo, foo.hello_button)] + assert signal_args_static == [(foo.hello_button,)] + + +def test_check_decorated_class(): + + NonWidget = type("Foo", (object,), {}) + with pytest.raises(TypeError, match=".*on Widgets.*"): + Gtk.Template.from_string("")(NonWidget) + + Widget = type("Foo", (Gtk.Widget,), {"__gtype_name__": new_gtype_name()}) + with pytest.raises(TypeError, match=".*Cannot nest.*"): + Gtk.Template.from_string("")(Gtk.Template.from_string("")(Widget)) + + Widget = type("Foo", (Gtk.Widget,), {}) + with pytest.raises(TypeError, match=".*__gtype_name__.*"): + Gtk.Template.from_string("")(Widget) + + with pytest.raises(TypeError, match=".*on Widgets.*"): + Gtk.Template.from_string("")(object()) + + @Gtk.Template.from_string("") + class Base(Gtk.Widget): + __gtype_name__ = new_gtype_name() + + with capture_exceptions() as exc_info: + type("Sub", (Base,), {})() + assert "not allowed at this time" in str(exc_info[0].value) + assert exc_info[0].type is TypeError + + +def test_from_file(): + fd, name = tempfile.mkstemp() + try: + os.close(fd) + + type_name = new_gtype_name() + + with open(name, "wb") as h: + h.write(u"""\ + <interface> + <template class="{0}" parent="GtkBox"> + <property name="spacing">42</property> + </template> + </interface> + """.format(type_name).encode()) + + @Gtk.Template.from_file(name) + class Foo(Gtk.Box): + __gtype_name__ = type_name + + foo = Foo() + assert foo.props.spacing == 42 + finally: + os.remove(name) + + +def test_property_override(): + type_name = new_gtype_name() + + xml = """\ + <interface> + <template class="{0}" parent="GtkBox"> + <property name="spacing">42</property> + </template> + </interface> +""".format(type_name) + + @Gtk.Template.from_string(xml) + class Foo(Gtk.Box): + __gtype_name__ = type_name + + foo = Foo() + assert foo.props.spacing == 42 + + foo = Foo(spacing=124) + assert foo.props.spacing == 124 + + +def test_from_file_non_exist(): + dirname = tempfile.mkdtemp() + try: + path = os.path.join(dirname, "noexist") + + Widget = type( + "Foo", (Gtk.Widget,), {"__gtype_name__": new_gtype_name()}) + with pytest.raises(GLib.Error, match=".*No such file.*"): + Gtk.Template.from_file(path)(Widget) + finally: + os.rmdir(dirname) + + +def test_from_string_bytes(): + type_name = new_gtype_name() + + xml = u"""\ + <interface> + <template class="{0}" parent="GtkBox"> + <property name="spacing">42</property> + </template> + </interface> + """.format(type_name).encode() + + @Gtk.Template.from_string(xml) + class Foo(Gtk.Box): + __gtype_name__ = type_name + + foo = Foo() + assert foo.props.spacing == 42 + + +def test_from_resource(): + resource_path = ensure_resource_registered() + + @Gtk.Template.from_resource(resource_path) + class Foo(Gtk.Box): + __gtype_name__ = "GtkTemplateTestResource" + + foo = Foo() + assert foo.props.spacing == 42 + + +def test_from_resource_non_exit(): + Widget = type("Foo", (Gtk.Widget,), {"__gtype_name__": new_gtype_name()}) + with pytest.raises(GLib.Error, match=".*/or/gnome/pygobject/noexit.*"): + Gtk.Template.from_resource("/or/gnome/pygobject/noexit")(Widget) + + +def test_constructors(): + with pytest.raises(TypeError): + Gtk.Template() + + with pytest.raises(TypeError): + Gtk.Template(foo=1) + + Gtk.Template(filename="foo") + Gtk.Template(resource_path="foo") + Gtk.Template(string="foo") + + with pytest.raises(TypeError): + Gtk.Template(filename="foo", resource_path="bar") + + with pytest.raises(TypeError): + Gtk.Template(filename="foo", nope="bar") + + Gtk.Template.from_string("bla") + Gtk.Template.from_resource("foo") + Gtk.Template.from_file("foo") |