DBus mit Python

Deutsch   |   Source

DBus ist der Quasi-Standard für Interprozesskommunikation zwischen Applikationen und System unter Linux. Möchte man, dass eine Applikation eine Systembenachrichtigung anzeigt oder Informationen von einem Dämon wie dem NetworkManager lädt, muss man auf DBus zurückgreifen. Es existieren mehrere Python-Wrapper für DBus, die Dokumentation ist jedoch wie so oft lückenhaft und nicht selten veraltet. Ich präsentiere hier meinen Ansatz ohne den Anspruch zu erheben, dass dies der beste Ansatz ist. Zumindest wird aber mein Beispiel anderen Einsteigern helfen und die hinzugefügten Links sollten die Zeit für die Suche nach nutzbarer Doku verkürzen.

Eine Einführung in das Konzept und die Begrifflichkeiten gibt es beim KDE-Projekt. Eine Übersicht verfügbarer Implementierungen gibt es hier. Nicht weniger als fünf Implementierungen für Python stehen zur Verfügung, aber für viele davon ist keine nutzbare Dokumentation vorhanden.

In meinem Beispiel gibt es einen Dämon im Userspace, der für einen Client Services zur Verfügung stellt. Die Services sollen letztendlich als HTTPS-Requests übers Internet abgewickelt werden (was im Beispiel nicht umgesetzt wird), sodass wir davon ausgehen müssen, dass sie relativ lange dauern. Der Client soll also den Dämon per DBus-Message nach Informationen fragen können, die dieser dann irgendwann später liefert. Währenddessen soll der Client nicht blockieren, damit während des laufenden Requests weiter Usereingaben möglich sind.

Ich habe mich für dbus-python entschieden, da diese Implementierung sowohl Python2 als auch Python3 unterstützt und eine einigermaßen brauchbare Doku besitzt. Das Tutorial dort baut auf Python2 auf, ich habe jedoch die Beispiele nach Python3 portiert. Da die Original-Beispiele im Tutorial nicht verlinkt sind, kann man einige Schritte mit der Beschreibung allein kaum nachvollziehen. Glücklicherweise konnte ich die Beispiele funden:

Mein Anwendungsfall entspricht recht genau dem Service-Beispiel mit dem Async-Client. Wer also lieber Python2 nutzen möchte sollte sich lieber direkt an der Vorlage orientieren. Wer wie ich Python3 einsetzen will, findet mit meiner Anpassung ein funktionierendes Beispiel.

Der Dämon

     #!/usr/bin/env python3
     import dbus
     from dbus.service import BusName
     from dbus.mainloop.glib import DBusGMainLoop
     from gi.repository import GObject


     class DemoException(dbus.DBusException):
         _dbus_error_name = 'de.pinyto.daemonException'


     class BackofficeInterface(dbus.service.Object):

         @dbus.service.method("de.pinyto.daemon.backoffice",
                              in_signature='s', out_signature='as')
         def HelloWorld(self, hello_message):
             print(str(hello_message))
             return ["Hello", " from example-service.py", "with unique name",
                     session_bus.get_unique_name()]

         @dbus.service.method("de.pinyto.daemon.backoffice",
                              in_signature='', out_signature='')
         def RaiseException(self):
             raise DemoException('The RaiseException method does what you might '
                                 'expect')

         @dbus.service.method("de.pinyto.daemon.backoffice",
                              in_signature='', out_signature='(ss)')
         def GetTuple(self):
             return ("Hello Tuple", " from pinytod.py")

         @dbus.service.method("de.pinyto.daemon.backoffice",
                              in_signature='', out_signature='a{ss}')
         def GetDict(self):
             return {"first": "Hello Dict", "second": " from pinytod.py"}

         @dbus.service.method("de.pinyto.daemon.backoffice",
                              in_signature='', out_signature='')
         def Exit(self):
             mainloop.quit()


     if __name__ == '__main__':
         DBusGMainLoop(set_as_default=True)

         session_bus = dbus.SessionBus()
         name = dbus.service.BusName("de.pinyto.daemon", session_bus)
         backoffice_interface = BackofficeInterface(session_bus, '/Backoffice')

         mainloop = GObject.MainLoop()
         print("Pinyto daemon is running.")
         mainloop.run()

Um zu erklären, was hier passiert beginnen wir mit der Initialisierung nach

     if __name__ == '__main__':

Da der Dämon mit asynchronen Requests hantieren muss und dabei nicht blockieren soll, muss eine MainLoop eingerichtet werden. Diese checkt auf resourcenschonende Art nach Neuigkeiten und koordiniert die richtige Ausführung. Es ergibt keinen Sinn mehr als eine MainLoop zu haben, weshalb man der DBus Schnittstelle mitteilen kann, dass die Glib MainLoop die wir benutzen der Default sein soll. Prinzipiell sollte glib-python mit verschiedenen MainLoops funktionieren, ich konnte jedoch nur zur GLib MainLoop ein funktionierendes Beispiel finden.

Bevor die MainLoop gestartet werden kann, müssen aber noch einige Dinge initialisiert werden. Zunächst brauchen wir einen Bus. DBus bietet immer zwei Standard-Busse, den SystemBus und den SessionBus. Der SystemBus dient zur Kommunikation mit Systemdiensten wie dem NetworkManager, der SessionBus zur Kommunikation von Programmen eines angemeldeten Benutzers untereinander. Da sowohl der Dämon als auch der Client in der Session des Users laufen sollen wird eine Referenz zum SessionBus geladen.

Bei diesem Bus muss sich der Dämon nun mit einem DBus-konformen Namen anmelden. Konvention sind umgekehrte Domainnamen mit dem Programmnamen hinter dem letzten Punkt. Hier also "de.pinyto.daemon". Ist der Name auf diese Weise registriert, leifert DBus interessierten Clients die Information, dass hier etwas erreichbar ist.

Unterhalb des Namens, der den Dämon identifiziert kann dieser verschiedene Dienste unter verschiedenen Pfaden anbieten. Im Beispiel ist es ein einzelner Dienst, definiert in der Klasse BackofficeInterface, der unter dem Pfad "/Backoffice" erreichbar gemacht wird.

Die Klasse BackofficeInterface wird zum DBus-Interface (Die Dienste heißen bei DBus "Interface"), indem sie von dbus.service.Object erbt. Damit ihre Methoden über das Interface aufgerufen werden können, müssen sie mit @dbus.service.method dekoriert werden. Im Decoratior wird jeweils nochmal ein Name des Service angegeben ("do.pinyto.daemon.backoffice") und die Signatur für Eingebe- und Ausgabeparameter angegeben. Dies ist nötig, da DBus im Gegensatz zu Python statisch typisiert ist. Eine Tabelle mit den verfügbaren Typen findet sich im Tutorial. Die Methoden selbst enthalten normalen Python-Code, der natürlich die passenden Typen zurückgeben muss.

Nach der Initialisierung des Interface kann ein mainloop-Objekt erzeigt werden und die MainLoop mit mainloop.run() gestartet werden.

Mit diesem Code kann der Dämon gesartet werden und läuft dann unendlich oder bis er mit der Exit-Methode gestoppt wird. Während der Dämon läuft, antwortet er auf Requests, die per DBus hereinkommen.

Der Client

     import sys
     import traceback

     from gi.repository import GObject

     import dbus
     import dbus.mainloop.glib


     # Callbacks for asynchronous calls
     def handle_hello_reply(r):
         global hello_replied
         hello_replied = True

         print(str(r))

         if hello_replied and raise_replied:
             loop.quit()


     def handle_hello_error(e):
         global failed
         global hello_replied
         hello_replied = True
         failed = True

         print("HelloWorld raised an exception! That's not meant to happen...")
         print("\t" + str(e))

         if hello_replied and raise_replied:
             loop.quit()


     def handle_raise_reply():
         global failed
         global raise_replied
         raise_replied = True
         failed = True

         print("RaiseException returned normally! That's not meant to happen...")

         if hello_replied and raise_replied:
             loop.quit()


     def handle_raise_error(e):
         global raise_replied
         raise_replied = True

         print("RaiseException raised an exception as expected:")
         print("\t" + str(e))

         if hello_replied and raise_replied:
             loop.quit()


     def make_calls():
         # To make an async call, use the reply_handler and error_handler kwargs
         remote_object.HelloWorld("Hello from dbus_test.py!",
                                  dbus_interface='de.pinyto.daemon.backoffice',
                                  reply_handler=handle_hello_reply,
                                  error_handler=handle_hello_error)

         # Interface objects also support async calls
         iface = dbus.Interface(remote_object, 'de.pinyto.daemon.backoffice')

         iface.RaiseException(reply_handler=handle_raise_reply,
                              error_handler=handle_raise_error)

         return False

     if __name__ == '__main__':
         dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)

         bus = dbus.SessionBus()
         try:
             remote_object = bus.get_object("de.pinyto.daemon",
                                            "/Backoffice")
         except dbus.DBusException:
             traceback.print_exc()
             sys.exit(1)

         # Make the method call after a short delay
         GObject.timeout_add(1000, make_calls)

         failed = False
         hello_replied = False
         raise_replied = False

         loop = GObject.MainLoop()
         loop.run()
         if failed:
             raise SystemExit("Async client failed!")

Da der Client seine Requests asynchron absetzen soll, braucht auch er eine MainLoop. Natürlich muss er auch den selben Bus benutzen. Da der Client aber keine Dienste anbietet, muss sein Name nicht am Bus angemeldet werden. Stattdessen holt er sich von DBus ein Proxy-Objekt, das die Signaturen des Interface beim Dämon übernimmt. Mit dem Proxy-Objekt (remote_object) kann dann wie mit einer Instanz der Interface-Klasse verfahren werden. Damit das Proxy-Objekt passend aufgebaut werden kann muss natürlich das Interface eindeutig mit Name und Pfad definiert werden. Das schlägt natürlich fehl, wenn sich der Dämon nicht am Bus angemeldet hat, was z.B. der Fall wäre, wenn er nicht läuft.

Anschließend wird make_calls nach einer Sekunde Wartezeit ausgeführt. Der Timeout zählt ab dem starten der MainLoop. In make_calls wird Hello direkt aufgerufen, indem das Interface als String benannt wird. RaiseException wird danach auf einem Interface-Objekt aufgerufen. Beide Varianten funktionieren. Für viele Requests lohnt es sich ein Interface-Objekt zu erzeugen. Für einzelne Calls ist die erste Variante sinnvoller. In beiden Fällen muss ein reply_handler und ein error_handler angegeben werden. Beides sind normale Python-Funktionen, die im Erfolgs- oder Fehlerfall aufgerufen werden, sobald der Request abgeschlossen ist. Ein großer Teil des Codes dient lediglich dazu die MainLoop in dem Moment zu stoppen, wenn alle Requests abgeschlossen sind. In einem grafischen Programm ist es gut möglich, dass es durch Benutzerinteraktion beendet wird und der Code daher sogar einfacher wird.

Fazit

DBus in Python ist möglich und erfordert auch nicht übermäßig komplizierten Code. Wirklich pythonic kommt dbus-python nicht daher, weshalb pydbus durchaus noch etwas verbessern könnte. Da dort aber noch weniger Dokumentation vorhanden ist, bin ich als Einsteiger einfach außer stande es zu benutzen. Zur Prformance kann ich noch nicht viel sagen, aber es scheint bisher alles hinreichend schnell zu sein.

Prinzipiell ist DBus ein sehr interessantes System, da es Desktop-Applikationen ermöglicht, die selbst wenig machen und trotzdem im Zusammenspiel mit anderen Diensten mächtige Funktionalität anbieten können. Allerdings muss die Dokumentation rund um die DBus implementierungen deutlich besser werden, wozu ich hiermit hoffentlich meinen Beitrag geleistet habe.

Weitergehende Infos

  • Hier wird erklärt, wie man mit einem .service File DBus dazu bringen kann den Dämon genau dann zu starten, wenn er gebraucht wird.