Python Desktop Handbook

Chapter 10 · GTK 4 with PyGObject

D-Bus

Every listing in this chapter is a file under examples/gtk4/dbus/. They are run on each build, under a private session bus, so the client and the service in the last two sections are known to talk to each other.

Introduction

D-Bus is how programs on a Linux desktop talk to each other. Your music player tells the shell what is playing; the shell asks your application to open a file; your program asks the desktop to show a notification, unlock a keyring or pick a file on its behalf. Almost everything in Desktop Integration is D-Bus with a wrapper over it.

There are two buses:

The session bus
one per logged-in session, for the programs a user is running. This is where nearly everything in this chapter happens.
The system bus
one per machine, for services that outlive a login — NetworkManager, systemd, UPower. Reading from it is usually allowed; changing things needs authorisation through polkit.

Use GDBus, which is part of GLib and therefore already imported. The old dbus-python module still exists and still appears in search results; it has its own main loop integration, its own type system and its own pitfalls, and there is no reason to start with it now.

from gi.repository import Gio, GLib

connection = Gio.bus_get_sync(Gio.BusType.SESSION, None)

The vocabulary

Five words, and then everything else is detail:

A bus name identifies a connection. Well-known names look like org.gnome.Shell; unique names look like :1.42 and are handed out by the bus.

An object path looks like a file path: /org/gnome/Shell. One program can export many objects.

An interface is a named group of members: org.freedesktop.DBus.Properties. One object can implement several.

Methods are calls with a reply. Signals are broadcasts with no reply. Properties are values, read and written through a standard interface.

Signatures describe types: s string, i int32, u uint32, b boolean, d double, o object path, v variant, as array of strings, a{sv} a dictionary from string to variant — the shape almost every “options” argument has. (is) is a struct.

Looking before you write

Do not start by writing code. Start by looking at what is on the bus, with gdbus, busctl or the D-Spy application:

gdbus introspect --session --dest org.freedesktop.Notifications \
  --object-path /org/freedesktop/Notifications

busctl --user list

The same three moves in Python are listing the names, introspecting an object, and calling a method:

reply = connection.call_sync(
    "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus",
    "ListNames", None,
    GLib.VariantType("(as)"),          # the reply signature you expect
    Gio.DBusCallFlags.NONE, -1, None,
)
(names,) = reply.unpack()

That (names,) is not a typo. A D-Bus reply is always a tuple, even when it carries one value, so unpacking one result means unpacking a one-element tuple. Forgetting it gives you a tuple where you expected a list, and the error appears somewhere else entirely.

Gio.DBusNodeInfo.new_for_xml() turns the introspection XML into objects, which is how the graphical tools build their trees:

info = Gio.DBusNodeInfo.new_for_xml(xml)
for interface in info.interfaces:
    for method in interface.methods:
        print(method.name, [a.signature for a in method.in_args])

The full example is examples/gtk4/dbus/explore-the-bus.py.

Calling a service

call_sync() is fine for one call. For an object you use repeatedly, a proxy is much better: it fetches the introspection data, caches the properties, and turns method calls into ordinary Python calls.

proxy = Gio.DBusProxy.new_for_bus_sync(
    Gio.BusType.SESSION,
    Gio.DBusProxyFlags.NONE,
    None,                      # introspection data; None means fetch it
    "com.example.Counter",     # bus name
    "/com/example/Counter",    # object path
    "com.example.Counter",     # interface
    None,
)

print(proxy.Increment("(i)", 3))     # method name, signature, arguments
print(proxy.Describe())

That "(i)" is the signature of the arguments, and it is required because Python cannot tell an int32 from an int64 from a uint32. Get it wrong and the call fails with a type error rather than silently sending the wrong thing.

Two things about proxies surprise people:

Creating a proxy succeeds even when nobody is there. It is a placeholder that starts working when the service appears. To find out whether anything is home:

if proxy.get_name_owner() is None:
    print("not running")

Properties are cached, and the cache is only as fresh as the last PropertiesChanged signal.

proxy.get_cached_property("Value").unpack()

costs nothing because the value arrived with the proxy — but if the service changes it without announcing it, you will read the old value forever. This cuts both ways: when you write a service, emit PropertiesChanged, and when you use one that does not, call Get explicitly instead of trusting the cache.

Calling asynchronously

call_sync() and the proxy’s attribute-style calls block until the reply comes back, and the default timeout is 25 seconds. In a program with a window that is 25 seconds of frozen interface. Anything triggered by a user action should be asynchronous:

def on_reset(proxy, result, _data=None):
    try:
        proxy.call_finish(result)
    except GLib.Error as error:
        print(f"failed: {error.message}")

proxy.call("Reset", None, Gio.DBusCallFlags.NONE, -1, None, on_reset)

Same shape as every other asynchronous call in this book: a callback, a *_finish() inside a try, and GLib.Error for the failure. A D-Bus call can fail because the service is not running, because it returned an error, because it took too long, or because you were not allowed — all of them arrive here.

The full example is examples/gtk4/dbus/call-our-service.py.

Listening

A proxy re-emits its object’s signals as g-signal:

proxy.connect("g-signal", lambda p, sender, signal, params: print(signal, params.unpack()))
proxy.connect("g-properties-changed", on_properties_changed)

When you want to hear about something that is not tied to one object — or from a program that is not running yet — subscribe on the connection:

subscription = connection.signal_subscribe(
    None,                            # sender
    "org.freedesktop.DBus",          # interface
    "NameOwnerChanged",              # signal
    "/org/freedesktop/DBus",         # path
    None,                            # first argument must equal this
    Gio.DBusSignalFlags.NONE,
    on_name_owner_changed,
)

Any filter may be None, meaning “do not care”, but the more of them you fill in the less traffic the bus sends you. NameOwnerChanged in particular fires constantly on a busy session.

For the common case — “tell me when this service comes and goes” — there is a helper that also handles the case where it is already running, which a plain subscription does not:

watch = Gio.bus_watch_name(
    Gio.BusType.SESSION, "com.example.Counter",
    Gio.BusNameWatcherFlags.NONE, on_appeared, on_vanished,
)

Keep the ids and undo both when you are finished: connection.signal_unsubscribe(subscription) and Gio.bus_unwatch_name(watch).

The full example is examples/gtk4/dbus/watch-signals.py.

Being a service

Exporting your own object is three things: own a name, describe an interface, and answer calls.

Describe the interface in the same XML that Introspect returns:

<node>
  <interface name="com.example.Counter">
    <method name="Increment">
      <arg type="i" name="by" direction="in"/>
      <arg type="i" name="value" direction="out"/>
    </method>
    <method name="Reset"/>
    <signal name="Changed">
      <arg type="i" name="value"/>
    </signal>
    <property name="Value" type="i" access="read"/>
    <property name="Label" type="s" access="readwrite"/>
  </interface>
</node>

Register an object with one callback per job:

node = Gio.DBusNodeInfo.new_for_xml(INTERFACE_XML)
connection.register_object(
    PATH, node.interfaces[0],
    counter.on_method_call, counter.on_get_property, counter.on_set_property,
)

The method handler dispatches on the name:

def on_method_call(self, _connection, _sender, _path, _interface,
                   method, parameters, invocation):
    if method == "Increment":
        (by,) = parameters.unpack()
        self.value += by
        invocation.return_value(GLib.Variant("(i)", (self.value,)))
    elif method == "Reset":
        self.value = 0
        invocation.return_value(None)
    else:
        invocation.return_error_literal(
            Gio.dbus_error_quark(), Gio.DBusError.UNKNOWN_METHOD,
            f"no such method: {method}",
        )

Always answer the invocation, on every path through the handler, including the ones you did not expect. A caller that gets no reply does not get an error — it waits for its timeout and then gets a confusing one. return_value(None) is the reply for a method that returns nothing, and it is not optional.

The reply is a GVariant tuple again, so a method returning one integer returns GLib.Variant("(i)", (value,)).

Signals are emitted on the connection:

connection.emit_signal(
    None, PATH, NAME, "Changed", GLib.Variant("(i)", (self.value,))
)

And when a property changes, say so — nothing does it for you:

connection.emit_signal(
    None, PATH, "org.freedesktop.DBus.Properties", "PropertiesChanged",
    GLib.Variant("(sa{sv}as)", (NAME, {"Value": GLib.Variant("i", value)}, [])),
)

Leave that out and every client’s cached copy stays at whatever it was when they connected. The three parts of the payload are the interface, the properties whose new values you are sending, and a list of properties that changed but whose values you are not sending.

Owning the name

You can call Gio.bus_own_name() directly, but if your program is a Gio.Application — or a Gtk.Application — it already owns a name: the application id is the bus name.

app = Gio.Application(application_id="com.example.Counter",
                      flags=Gio.ApplicationFlags.IS_SERVICE)
app.connect("startup", on_startup)          # register the object here

app.get_dbus_connection() gives you the connection to register on. IS_SERVICE means the process exists to serve, and set_inactivity_timeout() lets it exit when nothing has called it for a while — combined with a .service file, that is how a service gets started on demand rather than at login.

There is a bonus you get without asking. Every Gio.Action you add to a Gtk.Application is already exported over D-Bus, on the standard org.gtk.Actions interface. Which means this works against any GTK application, with no code on your side at all:

gdbus call --session --dest com.example.App --object-path /com/example/App \
  --method org.gtk.Actions.Activate quit "[]" "{}"

That is also how the shell shows your application’s menu, and how a desktop file with DBusActivatable=true gets you started.

The full example is examples/gtk4/dbus/export-a-service.py.

Testing without a desktop

D-Bus code is easy to test, because a bus is cheap:

dbus-run-session -- python3 my-service.py
dbus-run-session -- sh -c "python3 export-a-service.py & sleep 1; python3 call-our-service.py"

dbus-run-session starts a private session bus, runs the command with DBUS_SESSION_BUS_ADDRESS pointing at it, and tears it down afterwards. Nothing else on the machine can see it, so tests cannot collide, and it is how the examples in this chapter are verified on each build.

Portals are D-Bus

The portals from the desktop integration chapter are ordinary D-Bus services on org.freedesktop.portal.Desktop at /org/freedesktop/portal/desktop, and anything without a GTK wrapper is reachable the way anything else is.

They have one unusual convention. A portal method does not return the answer; it returns an object path for a request, and the answer arrives later as a Response signal on that object. That is because a portal call may involve asking the user, which can take as long as the user takes. So the sequence is: subscribe to the response path, make the call, and wait for the signal.

This is exactly the sort of thing worth using a library for. libportal wraps every portal in a normal asynchronous API and has GObject introspection, so it is Xdp in PyGObject:

gi.require_version("Xdp", "1.0")
from gi.repository import Xdp

portal = Xdp.Portal.new()
portal.request_background(parent, "Syncing in the background", None,
                          Xdp.BackgroundFlags.AUTOSTART, None, on_done)

Screenshots, screen casting, location, inhibiting suspend, autostart, opening a URI, and the trash all live there.

Summary

  • Use GDBus from GLib, not dbus-python.
  • Session bus for the user’s programs, system bus for the machine’s.
  • Every reply is a tuple: (value,) = reply.unpack().
  • Method arguments need an explicit signature, because Python’s ints do not have one.
  • A proxy exists even when the service does not; check get_name_owner().
  • Cached properties are only as fresh as the last PropertiesChanged — emit it from your services, and do not trust it from services that do not.
  • Use the asynchronous calls in anything with a window. The default timeout is 25 seconds.
  • In a method handler, answer the invocation on every path, including the unexpected ones.
  • A Gtk.Application’s id is already a bus name, and its actions are already exported.
  • dbus-run-session gives you a private bus for testing.

Animation and Transitions is next.