Pythonifying GTK

Astro Boy
15 min readApr 21, 2023

--

GTK is a free and open-source cross-platform widget toolkit for creating graphical user interfaces. In this article, I will tell you how I made GTK more pythonic. And while you might not care about GTK, I think the method I used can be applied to other libraries as well.

A bit about myself: I’m a python developer, mainly using python to build desktop apps. I created my first application with PyQt, which is a well-known GUI framework. They say PyQt looks natively on all platforms, but that’s not completely true. Surely on Mac and Windows, it looks well. On linux desktop environments that are using Qt — probably too. But I’m using Cinnamon which utilizes GTK, so my PyQt app didn’t look quite right. At least on Linux Mint, there is a qt5-gtk-platformtheme package that makes Qt apps look like GTK; but it supports only GTK 2, while the current widely adopted version is 3 (there is also GTK 4 that is slowly getting adopted), so the widgets look old.

But it wasn’t all this “non-native look” that made me move to GTK. It was when PyQt 6 came out, introducing true properties. Let me explain:

Qt is a C++ framework, which means it uses camelCase. While Python is written in C, so it uses the snake_case. Because PyQt is using camel case, the code does not look pythonic:

table = QTableWidget()
table.setColumnCount(2)
button = QPushButton("Add")
button.setEnabled(False)
layout = QVBoxLayout()
layout.addWidget(table)
layout.addWidget(button)

You need to use setters and getters (setEnabled in the example above), and camel case function names that look unnatural (addWidget). Now PEP8 mentions this:

mixedCase is allowed only in contexts where that’s already the prevailing style (e.g. threading.py), to retain backwards compatibility.

So camel case is allowed if it’s already being used, which makes sense, but I still don’t like it. With true properties, that PyQt 6 introduced, the code above could be rewritten like this:

from __feature__ import snake_case, true_property

table = QTableWidget()
table.column_count = 2
button = QPushButton("Add")
button.enabled = False
layout = QVBoxLayout()
layout.add_widget(table)
layout.add_widget(button)

This looks more beautiful! I was so excited to see this feature introduced in PyQt 6, there was only one problem: my OS didn’t support Qt 6 yet, so I couldn’t use PyQt 6. I could install it with pip, but when I launched a test app, it just showed me a white window because nothing could be rendered.

This was what got me thinking about GTK. Though GTK is less popular, it’s a great choice when developing a desktop app for Linux:

  • it looks native
  • it’s a C library, so it uses snake case, which is in line with python.

However GTK has methods like set_width(value), get_width() and set_focus_vadjustment(value). I didn't like to put get_ and set_ and braces everywhere in my code.

Compare this GTK code:

label.set_text("Hello, world!")
label.get_text()

and this:

label.text = "Hello, world!"
label.text

The latter is less cluttered, especially when the method you need to use has a long name, like set_support_multidevice(value).

Now, every widget object in GTK has a props attribute. So it could be used instead of getters and setters, it works like this:

label.props.text = "Hello, world!"
label.props.text

This works, but now instead of using get_ and set_ everywhere, you have to use .props 🤦 . There is another way though...

Python’s __getattr__ and __setattr__

Python has special methods that you can use to intervene the attribute access: __getattr__ and __setattr__. I won't go into details about how they work, you can find a lot of information online. In short, you can implement __getattr__ in your class, and every time you're trying to access an attribute of an instance of that class, the __getattr__ method is called, and it's passed a string name of an attribute that was accessed. It's also important to know that __getattr__ is called only if the attribute accessed doesn't exist.

Let’s say I have a class like this:

class Fruit:
def __init__(self, name):
self.name = name
def __getattr__(self, attr_name):
print(f"The attribute '{attr_name}' was accessed, but it doesn't exist.")

Then, __getattr__ would work like this:

>>> apple = Fruit("apple")
# attribute `name` is defined, so we get its value
>>> apple.name
'apple'
# attribute `price` is not defined, so apple.__getattr__("price") is called
>>> apple.price
The attribute 'price' was accessed, but it doesn't exist.

__setattr__ works like __getattr__, but additionally to the attribute name, it's passed the value that we're trying to set. So apple.price = 10 would become apple.__setattr__("price", 10).

By implementing __getattr__ and __setattr__ we could avoid using getters and setters in python. But wait, how are we going to implement them? Let's take a Gtk.Label class, how do you implement __getattr__ for it? I didn't write the code for Gtk.Label, so I can't add __getattr__ to it.

One option would be to create your own class inheriting from Gtk.Label:

from gi.repository import Gtk

class MyLabel(Gtk.Label):
def __init__(self, *args):
Gtk.Label.__init__(self, *args)
def __getattr__(self, attr_name):
...

Then, to create a label, you would have to use MyLabel class instead of Gtk.Label. But what about other widgets? GTK has lots of widgets. Creating a separate class for each of them is not an option. So what should we do?

Python is very flexible

Always remember: python is very flexible! We can solve this problem with inheritance and replacing some attributes.

Every widget or any other class in GTK inherits from the GObject.Object superclass. That means if we implement __getattr__ for GObject.Object, every other GTK class will inherit our __getattr__. So we don't need to implement it for every class.

But there is still another problem: how to implement __getattr__ for GObject.Object? It's simple! Every python method is an attribute. Look at this class:

class Fruit:
def __init__(self, name):
self.name = name
def eat(self):
print(f"Eating {self.name}")

The Fruit class has a method eat. But this method is just an attribute of the class, you can access it with Fruit.eat:

>>> Fruit.eat
<function Fruit.eat at 0x7f7bc0a54820>

And you can even call it. The method accepts one argument self, which should be an instance of the Fruit class:

>>> apple = Fruit("apple")
>>> Fruit.eat(apple)
Eating apple

But if eat is an attribute, you can not only get it but also set it, effectively replacing the method.

# first, define a function
>>> def fake_eat(self):
... print("This is a fake method.")
# then, replace Fruit.eat with fake_eat
>>> Fruit.eat = fake_eat
# now whenever we call `.eat()`, `fake_eat` will be used
>>> apple.eat()
This is a fake method.

You can see above that without creating a separate class that would inherit from Fruit, we were able to swap Fruit's method. We can do the same with GObject.Object.

Here is how I implemented the fake __getattr__:

def _getattr(self, attr_name): # 1, 2
getter_name = f"get_{attr_name}" # 3
getter = object.__getattribute__(self, getter_name) # 4
result = getter() # 5
return result
  1. I called it _getattr, with an _ at the beginning because getattr is a reserved name in python.
  2. it accepts an attribute name as a parameter.
  3. using attr_name we construct the getter name.
  4. we obtain the getter using object.__getattribute__. The reason I'm using object.__getattribute__ and not getattr is: getattr calls __getattr__ under the hood, and we're going to replace __getattr__ with our _getattr, so using getattr inside of _getattr would lead to infinite recursion.
  5. once we have the getter, we can call it to get the value, which we then return.

Hopefully, I explained it well enough, and you didn’t get lost in all those “getattrs”: getattr, _getattr, __getattr__, __getattribute__ :)

Now we can use our _getattr to swap GObject.Object.__getattr__:

from gi.repository import GObject

def _getattr(self, attr_name):
getter_name = f"get_{attr_name}"
getter = object.__getattribute__(self, getter_name)
result = getter()
return result

GObject.Object.__getattr__ = _getattr

For __setattr__, the code is similar, but a bit more complicated. You see, __getattr__ is called only when the attribute you're trying to access does not exist. Taking our previous example of the Fruit class:

class Fruit:
def __init__(self, name):
self.name = name

def __getattr__(self, attr_name):
print(f"The attribute '{attr_name}' was accessed, but it doesn't exist.")

If you try to access the name attribute, __getattr__ won't be called; but if you try to access an attribute that doesn't exist (e.g. price) – __getattr__ will be called:

>>> apple = Fruit("apple")
>>> apple.name
'apple'
>>> apple.price
The attribute 'price' was accessed, but it doesn't exist.

There is also __getattribute__, which doesn't care about any of this – it's called no matter if the attribute exists or not.

class Fruit:
def __init__(self, name):
self.name = name

def __getattribute__(self, attr_name):
print(f"The attribute '{attr_name}' was accessed.")
>>> apple = Fruit("apple")
>>> apple.name
The attribute 'name' was accessed.
>>> apple.price
The attribute 'price' was accessed.

__setattr__, however, doesn't have a __setattribute__ counterpart (here is why). So there is no object.__setattribute__. For that reason, we'll have to take care of the logic for when the attribute exists or doesn't exist ourselves:

# save original __setattr__
original_setattr = GObject.Object.__setattr__ # 0

# 1
def _setattr(self, attr_name, value): # 2
try: # 6
setter_name = f"set_{attr_name}" # 3
setter = getattr(self, setter_name) # 4
setter(value) # 5
except AttributeError:
original_setattr(self, attr_name, value)

GObject.Object.__setattr__ = _setattr # 7
  1. Firstly, we need to save GObject.Object's __setattr__ because we'll use it in _setattr. Remember that later, we're going to replace GObject.Object's __setattr__ with our _setattr, that's why we need to save it here.
  2. I called the function _setattr, again with an _ at the beginning because setattr is a reserved name.
  3. it accepts an attribute name but also a value as parameters.
  4. using attr_name we construct the setter name.
  5. we obtain the setter using getattr, it's fine to use getattr inside of a __setattr__ since it won't cause an infinite recursion.
  6. we use the setter to set the value
  7. all points from 3 to 5 are happening inside of a try-except block. This way we can handle the logic of when an attribute exists or doesn't exist. So we try to use the setter if the attribute has it, if it doesn't – we set the attribute the regular way (using previously saved original_setattr).
  8. finally, we replace GObject.Object's __setattr__ with our _setattr.

The _setattr can be hard to understand. So let me walk you through 2 examples: when we need to set a GTK attribute (it has a setter), and when we need to set a regular attribute (it doesn't have a setter).

Let’s say we have a label (Gtk.Label), and we're trying to set its text:

  • we can use label.text = "Hello"
  • under the hood, our _setattr will be called like this: _setattr(label, "text", "Hello")
  • first, using the attribute name "text", we'll construct the setter name: it will be "set_text"
  • then, we’ll get the setter using the previously obtained setter name: setter = getattr(label, "set_text")
  • then, we’ll use the setter to set the value: setter("Hello")
  • under the hood, the call setter("Hello") will translate to Gtk.Label.set_text(label, "Hello"), which is the same as label.set_text("Hello")
  • this way, label.text = "Hello" becomes label.set_text("Hello")

Now imagine you’re trying to set a custom attribute, not a GTK attribute. For example, you could try label.tag = "mylabel"; in this case, there is no setter method set_tag:

  • under the hood, our _setattr would be called, like this: _setattr(label, "tag", "mylabel")
  • then, we’d construct the setter name. It would be "set_tag"
  • then, we would try to get the setter: setter = getattr(label, "set_tag"). At this point, we'd get an AttributeError exception because a Gtk.Label doesn't have the set_tag method.
  • we would catch the exception and set the attribute the normal way: using original_setattr(label, "tag", "mylabel").

Putting it all together

Now that we have our own implementation of __getattr__ and __setattr__, we can put them in a file called gtk_utils.py:

from gi.repository import GObject

def _getattr(self, attr_name):
getter_name = f"get_{attr_name}"
getter = object.__getattribute__(self, getter_name)
result = getter()
return result

# save original __setattr__
original_setattr = GObject.Object.__setattr__

def _setattr(self, attr_name, value):
try:
setter_name = f"set_{attr_name}"
setter = getattr(self, setter_name)
setter(value)
except AttributeError:
original_setattr(self, attr_name, value)

GObject.Object.__getattr__ = _getattr
GObject.Object.__setattr__ = _setattr

gtk_utils module needs to be the first imported module in your app. This way, it will correctly patch the __getattr__ and __setattr__ methods. So the best place to import gtk_utils would be the file containing the if __name__ == "__main__", the one that creates your application:

import gtk_utils
import sys

# some code ...

if __name__ == "__main__":
app = Application()
app.run(sys.argv)

Here’s an example application using our gtk_utils module:

import gi
gi.require_version("Gtk", "3.0")

from gi.repository import Gtk
import gtk_utils

win = Gtk.Window()
label = Gtk.Label()

label.text = "Hello, world!"
label.tag = "mytag"

win.connect("destroy", Gtk.main_quit)
win.add(label)
win.show_all()

print(label.text)
print(label.tag)
Gtk.main()

If you put the code above into a file called main.py, you can run it using python3 main.py. Don't forget to pip install PyGObject. The output of running the script will be:

Hello, world!
mytag

and you should see a window with a label saying "Hello, world!".

What about code completion?

Perfect, it’s working! But now we have a problem with code completion. You see, IDEs like PyCharm and VS Code will provide auto-completion for getters and setters:

PyCharm’s auto-completion for getters

but they won’t provide any code completion for the true properties that we implemented with _getattr and _setattr:

No auto-completion for true properties

The reason for this is: our true properties are dynamic. Gtk.Label class doesn't have the attribute text, but with _getattr and _setattr we make it look like the attribute is there. PyCharm, however, is not smart enough to deduce that using our own implementation of __getattr__ and __setattr__ we provide text and other attributes. So what should we do? Code completion is very important, was all this effort with _getattr and _setattr for nothing?

Python’s type hints

In python 3.5, type hints were introduced. A powerful feature we didn’t know we wanted.

At first, I was pretty skeptical about them, “they are just cluttering everything!”, I was thinking. But once I learned Kotlin, which has similar syntax for specifying types, type hints started to look more attractive. Now I find them extremely useful.

PyQt has the PyQt5-stubs package you can install to get nice auto-completion for your PyQt code. You see, this is how it's done in PyQt – a popular library with big community: they managed to quickly annotate PyQt bindings.

GTK (and PyGObject, in particular) has a substantially smaller community. So it took them more time to create the PyGObject-stubs package. But thanks to their hard work, in 2022, we finally got the type hints for PyGObject.

Type annotations allow us to help PyCharm know that besides regular PyGObject getters and setters, we also have corresponding attributes.

First, you need to install PyGObject-stubs with pip. You have to use this command:

pip install pygobject-stubs --no-cache-dir --config-settings=config=Gtk3,Gdk3

otherwise, the stubs for Gtk4 and Gdk4 will be installed, we need version 3.

I installed them into a virtual environment called venv. Now, if I go to venv/lib/python3.10/site-packages/gi-stubs/repository, I can see .pyi files for each GTK module. So Gtk.pyi, Gdk.pyi, etc...

If you open Gtk.pyi, for instance, it will have this:

from typing import Any
from typing import Callable
from typing import Iterator
from typing import Literal
from typing import Optional

# ...

BINARY_AGE: int = 2434
INPUT_ERROR: int = -1
INTERFACE_AGE: int = 30
LEVEL_BAR_OFFSET_FULL: str = "full"
LEVEL_BAR_OFFSET_HIGH: str = "high"
LEVEL_BAR_OFFSET_LOW: str = "low"

# ...

class AboutDialog(Dialog, Atk.ImplementorIface, Buildable):
# ...
# these are the getters and setters of the AboutDialog class
def get_artists(self) -> list[str]: ...
def get_authors(self) -> list[str]: ...
def get_comments(self) -> str: ...
def get_copyright(self) -> str: ...
def get_documenters(self) -> list[str]: ...
def get_license(self) -> str: ...
def get_license_type(self) -> License: ...
def get_logo(self) -> GdkPixbuf.Pixbuf: ...
def get_logo_icon_name(self) -> str: ...
def get_program_name(self) -> str: ...
def get_translator_credits(self) -> str: ...
def get_version(self) -> str: ...
def get_website(self) -> str: ...
def get_website_label(self) -> str: ...
def get_wrap_license(self) -> bool: ...
@classmethod
def new(cls) -> AboutDialog: ...
def set_artists(self, artists: Sequence[str]) -> None: ...
def set_authors(self, authors: Sequence[str]) -> None: ...
def set_comments(self, comments: Optional[str] = None) -> None: ...
def set_copyright(self, copyright: Optional[str] = None) -> None: ...
def set_documenters(self, documenters: Sequence[str]) -> None: ...
def set_license(self, license: Optional[str] = None) -> None: ...
def set_license_type(self, license_type: License) -> None: ...
def set_logo(self, logo: Optional[GdkPixbuf.Pixbuf] = None) -> None: ...
def set_logo_icon_name(self, icon_name: Optional[str] = None) -> None: ...
def set_program_name(self, name: str) -> None: ...
def set_translator_credits(
self, translator_credits: Optional[str] = None
) -> None: ...
def set_version(self, version: Optional[str] = None) -> None: ...
def set_website(self, website: Optional[str] = None) -> None: ...
def set_website_label(self, website_label: str) -> None: ...
def set_wrap_license(self, wrap_license: bool) -> None: ...

# ...

This is how python type stubs look. It's basically a list of all constants, functions, classes, etc. of a module; which are annotated with types.

Using .pyi files, PyCharm can tell which constants, functions, classes, etc... are contained in a module, and the types used in them. It utilizes this information to provide code completion.

However, you can write anything in the stub files. For instance, I can open Gtk.pyi and add my own function:

# ...
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Pango

def say_hello(greating: str) -> None: ...

# ...

The function say_hello is fake. It doesn't actually exist in the Gtk.py module. But PyCharm doesn't care, it trusts the Gtk.pyi file for auto-complete, so it will suggest say_hello in the code completion list:

Because of this, we can extend the stub files with our own stuff. You can probably guess where I’m going: we can add our true properties to the .pyi files, and the auto-complete for them will work!

But how do we do that? There are lots of classes in GTK, and each of them has lots of properties, so we must automate the process. Every true property has a corresponding getter or setter (or both), and all getters and setters are defined in .pyi files. So we can parse the stub files extracting all getters and setters (including their type hints). Then define a property for each getter and setter.

Here is the gtkstubs.py script I wrote to do exactly that:

#!/usr/bin/env python3

"""
Extends PyGObject-stubs to include our pythonic properties.
This way, we get autocompletion for them.
See _getattr and _setattr from the gtk_utils module for more details.
"""

import re
import shutil
from pathlib import Path

GTK_STUBS_PATH = "venv/lib/python3.10/site-packages/gi-stubs/repository/"

for module in ("Gtk", "Gdk", "Gio"):
venv_path = f"{GTK_STUBS_PATH}/{module}.pyi"
home_path = f"{Path.home()}/{module}.pyi"
_in = open(venv_path)
out = open(home_path, "w")
fields = set()

for line in _in:
out.write(line)

if line.startswith(" def get_"):
prop = re.search("get_([a-z0-9_]*)", line)[1]
fields.add(prop)
type_hint = "Any"

if "->" in line:
type_hint = re.search(r"->(.*?):", line)[1]
out.write(f" {prop}:{type_hint}\n")

if line.startswith(" def set_"):
prop = re.search("set_([a-z0-9_]*)", line)[1]
type_hint = "Any"
if prop in fields:
continue

if "->" in line:
type_hint = re.search(r"->(.*?):", line)[1]
out.write(f" {prop}:{type_hint}\n")

Path(venv_path).unlink()
shutil.move(home_path, venv_path)

This script adds true properties to Gtk.pyi, Gdk.pyi, and Gio.pyi. I'm not touching other GTK modules because I personally was using mostly only Gtk, Gdk, and Gio. But you can always modify the script to parse the modules you want.

You can see how Gtk.pyi looks after being processed by the script:

# ...
class AboutDialog(Dialog, Atk.ImplementorIface, Buildable):
def get_artists(self) -> list[str]: ...
artists: list[str]
def get_authors(self) -> list[str]: ...
authors: list[str]
def get_comments(self) -> str: ...
comments: str
def get_copyright(self) -> str: ...
copyright: str
def get_documenters(self) -> list[str]: ...
documenters: list[str]
def get_license(self) -> str: ...
license: str
def get_license_type(self) -> License: ...
license_type: License
def get_logo(self) -> GdkPixbuf.Pixbuf: ...
logo: GdkPixbuf.Pixbuf
def get_logo_icon_name(self) -> str: ...
logo_icon_name: str
def get_program_name(self) -> str: ...
program_name: str
def get_translator_credits(self) -> str: ...
translator_credits: str
def get_version(self) -> str: ...
version: str
def get_website(self) -> str: ...
website: str
def get_website_label(self) -> str: ...
website_label: str
def get_wrap_license(self) -> bool: ...
wrap_license: bool
@classmethod
def new(cls) -> AboutDialog: ...
def set_artists(self, artists: Sequence[str]) -> None: ...
def set_authors(self, authors: Sequence[str]) -> None: ...
def set_comments(self, comments: Optional[str] = None) -> None: ...
def set_copyright(self, copyright: Optional[str] = None) -> None: ...
def set_documenters(self, documenters: Sequence[str]) -> None: ...
def set_license(self, license: Optional[str] = None) -> None: ...
def set_license_type(self, license_type: License) -> None: ...
def set_logo(self, logo: Optional[GdkPixbuf.Pixbuf] = None) -> None: ...
def set_logo_icon_name(self, icon_name: Optional[str] = None) -> None: ...
def set_program_name(self, name: str) -> None: ...
def set_translator_credits(
self, translator_credits: Optional[str] = None
) -> None: ...
def set_version(self, version: Optional[str] = None) -> None: ...
def set_website(self, website: Optional[str] = None) -> None: ...
def set_website_label(self, website_label: str) -> None: ...
def set_wrap_license(self, wrap_license: bool) -> None: ...
# ...

After def get_artists(self) -> list[str]: ..., artists: list[str] was added; after def get_authors(self) -> list[str]: ..., authors: list[str] was added, etc… They even have the type hints. Now if you go to PyCharm, the auto-completion for Gtk.Label's text will work:

PyCharm’s auto-completion for Gtk.Label’s text

Issues and remarks

Our true properties are working, but they have their problems too. Don’t worry. I’ve used them to build 2 middle-sized apps, and everything was fine 98% of the time.

One of the problems is the error message when you’re trying to access an attribute that doesn’t exist. For instance, Gtk.Label doesn't have an attribute bad_attr, and if you try to access it:

from gi.repository import Gtk
import gtk_utils

label = Gtk.Label()
label.bad_attr

you’ll get a slightly weird error:

Traceback (most recent call last):
File "/home/.../bad_attr.py", line 5, in <module>
label.bad_attr
File "/home/.../gtk_utils.py", line 6, in _getattr
getter = object.__getattribute__(self, getter_name)
AttributeError: 'Label' object has no attribute 'get_bad_attr'

It says get_bad_attr instead of bad_attr because our _getattr adds get_ every time the attribute doesn't exist. It's not a big problem, but something to watch out for.

Another small thing to keep in mind is: some setters accept more than one argument, for example, Gtk.Window's set_default_size(width, height). In this case, you can't set a default_size property, you have to use the full method name window.set_default_size(1280, 720).

Conclusion

Well done! We implemented true properties for a third-party library without giving up code completion. This was my first article ever, so feedback is welcome. Hopefully, it wasn’t boring, and I explained everything well enough.

Always remember — python is very flexible. In this article, we patched __getattr__ and __setattr__, but you can replace almost anything. You can write your own version of print or __import__ if you need to. Python allows it, so go on and do something crazy! :)

--

--