Teil 2: Einfache GUIs mit Python und Qt

Einfache GUIs mit Python und Qt
Kommentare

Im mobilen Zeitalter führt an grafischen Applikationen kein Weg mehr vorbei. Allerdings wird in klassischen, statisch typisierten Programmiersprachen wie C++ relativ viel Code benötigt, um ein GUI zu implementieren. Nicht so mit Python und Qt, wie diese zweiteilige Artikelserie zeigt. Im ersten Teil wurde der Anwendungsfall vorgestellt und die Implementierung der einzelnen GUI-Komponenten erläutert. In Teil 2 beschäftigen wir uns nun mit der Datenhaltung und der Integration der einzelnen Komponenten zu einer lauffähigen Anwendung.

Ein gängiger Weg zur Implementierung ist der Einsatz von IDEs, die den benötigten Code automatisiert erzeugen. Bei diesem Vorgehen nimmt die IDE dem Entwickler viele Aufgaben ab, indem GUI-Elemente in GUI Buildern entworfen und der entsprechende Code automatisch generiert wird. Eine Alternative kann hier die Verwendung einer Kombination einer modernen, dynamisch typisierten Skriptsprache mit einem leistungsfähigen GUI-Toolkit sein.
Im ersten Teil der Artikelserie haben wir den Anwendungsfall vorgestellt und beschrieben, wie die einzelnen für den Anwender sichtbaren Elemente zusammenarbeiten; auch wurden entsprechende Komponenten prototypisch implementiert. Im Folgenden soll nun gezeigt werden, wie Daten gehalten werden und wie man die einzelnen Komponenten dazu bewegt, so zu interagieren, dass eine ausführbare Anwendung entsteht.

Und was ist mit Daten? – Die Datenhaltung

Die klassische Programmierung des letzten Jahrtausends basierte – und das war auch in jedem entsprechenden Lehrbuch zu finden – auf dem EVA-Prinzip: Eingabe-Verarbeitung-Ausgabe. Das heißt, es wurden bei Programmstart eine Menge von Eingabedaten (ursprünglich: Lochkarten) übergeben. Daraufhin erfolgte die Verarbeitung in Form eines beliebig komplizierten Algorithmus, der dann Ausgabedaten – als Bildschirmausgabe oder in einer Datei, oder ursprünglich eben auch in Form von Lochkarten – erzeugte. Die Benutzerschnittstelle trat hier nicht besonders prominent in den Vordergrund. Der wesentliche Teil des Programms war die Verarbeitung. Es gab letztendlich einen Weg durch das Programm, der vom Anwender (damals meistens = Programmierer) nicht verändert werden konnte.
In einer GUI-basierten Anwendung stellt sich die Situation anders dar. Es gibt im Allgemeinen keine klar getrennten Phasen von Eingabe, Verarbeitung und Ausgabe. Im Mittelpunkt steht die Benutzerschnittstelle, die dem Anwender erlaubt, beliebige Aktionen anzustoßen. Eine GUI-Anwendung entspricht eher einer Endlosschleife, in vereinfachtem Python-Pseudocode:

while True:
  user_input = get_user_input()
  handle_user_input(user_input)

Trotzdem gibt es auch bei GUI-basierten Anwendungen natürlich Eingangsdaten und Ausgangsdaten. In unserem Fall werden entsprechend die Eingangsdaten den Quizfragen und den Eingaben des Teilnehmers, die Ausgangsdaten den Ergebnissen, wie sie einerseits in der Ergebnisanzeige ausgegeben und andererseits persistiert werden.

Immer herein! – Verarbeitung von Eingangsdaten

Eingangsdaten sind in diesem Zusammenhang zunächst die Quizfragen selbst. Diese könnten natürlich im Quellcode selbst abgelegt werden. Das wäre aber nicht sehr flexibel. Eine bessere Lösung besteht darin, die Quizfragen in Form einer Datei bereitzustellen, die bei Programmstart eingelesen werden kann. Für das Format der Datei wären verschiedene Möglichkeiten denkbar. In diesem Fall entscheiden wir uns für ein XML-Format, und zwar aus folgenden Gründen:

  • XML ist mithilfe der betreffenden Bibliotheken leicht zu parsen.
  • Die Behandlung von Encodings und Sonderzeichen stellt mit XML kaum ein Problem dar.
  • XML ist ein Standardformat, für dessen Verarbeitung eine gute Werkzeugunterstützung gegeben ist (z. B. Editoren mit Syntax-Highlighting und -prüfung).

Eine mögliche Alternative wäre in diesem Zusammenhang z. B. JSON, das ähnliche Vorteile bietet wie XML, dabei aber kompakter ist. XML wurde hier der Vorzug gegeben wegen der aus Sicht des Autors einfacheren Definition des Schemas über eine DTD. Natürlich sind ausdrucksstärkere Formate für die Schemaspezifikation verfügbar, wie z. B. XSD (XML-Schema) oder RNC (Relax NG). Für unser Beispiel beschränken wir uns aber auf eine DTD, weil diese für unsere Zwecke ausdrucksstark genug und weit verbreitet ist.

<!ELEMENT quiz_questions (question+)>
<!ELEMENT question (question_text, answers)>
<!ELEMENT question_text (#PCDATA)>
<!ELEMENT answers (answer+)>
<!ELEMENT answer (#PCDATA)>
<!ATTLIST question correct CDATA #REQUIRED>

Die DTD definiert den Aufbau der Datei mit der Definition des Schemas für die Quizfragen. Das root-Element quiz_questions enthält mindestens ein Element question. Dieses besteht aus dem Text der Frage (question_text) sowie einem Element answers. Dieses wiederum enthält einzelne Elemente für die jeweiligen Antworten (answer). Das Element question erhält ein Attribut correct, das die Nummer der jeweils richtigen Antwort enthält. Die DTD wird in einer Datei quizquestions.dtd gespeichert. Ein Beispiel für eine entsprechende Datei zeigt Listing 1 (gekürzt).

<?xml version="1.0" ?>
<quiz_questions>
  ...
  <question correct="1">
    <question_text>The first language supporting OOP was</question_text>
    <answers>
      <answer>C++</answer>
      <answer>Smalltalk</answer>
      <answer>Java</answer>
    </answers>
  </question>
  ...
</quiz_questions>

Es handelt sich hier um eine Frage mit drei möglichen Antworten. Die korrekte Antwort (Smalltalk) ist im Attribut correct des Tags question beschrieben (1). Zu beachten ist hier, dass die Nummerierung hier der üblichen Array-Indizierung entspricht. Ein Wert von 1 entspricht also der zweiten Antwort. Es folgt jetzt der Code zum Einlesen der Quizfragen:

from lxml import etree as ET

Zunächst importieren wir einen Teil der lxml-Bibliothek. Diese stellt eine zu der zum Standard von Python gehörenden ElementTree-Bibliothek weitgehend schnittstellenkompatible Bibliothek dar, die neben allgemein verbesserter Performance auch Unterstützung für die Validierung von DTDs bietet (Listing 2).

class QuizQuestion(object):
    """
    Represent a question in a quiz with possible answers and the solution.
    """
    def __init__(self,
                 question_text,
                 answers,
                 correct):
        self.question_text = question_text
        self.answers = answers
        self.correct = correct

Wir definieren jetzt eine Klasse QuizQuestion für die Repräsentation einer einzelnen Quizfrage. Im Initializer der Klasse werden die Frage als Text (question_text), eine Liste von Strings (answers) sowie der Index der korrekten Antwort (correct) übergeben. Die Parameter werden gleichnamigen Attributen des Objekts zugewiesen:

        assert self.correct >= 0
  assert self.correct < len(self.answers)

Über die assert-Anweisungen wird sichergestellt, dass der Wert von correct einen gültigen Index für den Zugriff auf die Liste answers bildet:

def readXmlQuestions(fName):
    tree = ET.ElementTree(file=fName)
    dtd_file = open("quizquestions.dtd", "rb")
    dtd = ET.DTD(dtd_file)

Die Funktion readXmlQuestions bekommt als Parameter den Dateinamen der Datei mit den Quizfragen. Die komplette Datei wird daraufhin als XML-Baum tree eingelesen:

    dtd_file = open("quizquestions.dtd", "rb")
    dtd = ET.DTD(dtd_file)

Ein Dateideskriptor für die Datei mit der DTD wird geöffnet und als DTD eingelesen:

    root = tree.getroot()
    if not dtd.validate(root):
        raise Exception(dtd.error_log.filter_from_errors()[0])

Es wird dann das Wurzelelement (root) des XML-Baums geholt und anschließend mit

dtd.validate(root) die Gültigkeit der XML-Struktur geprüft. Im Fehlerfall wird eine Exception mit dem Text des Fehlers geworfen:

    questions = []
    for child in root:
        question_text = child.find("question_text")
        correct = int(child.attrib["correct"])
        answers_elem = child.find("answers")

Mit questions wird eine leere Liste als Container für die zurückzugebenden Objekte definiert. Anschließend wird über die Kind-Elemente des Wurzelelements der XML-Datei iteriert. Aus dem jeweiligen Kind-Objekt wird der Text der Frage (question_text) als Inhalt des gleichnamigen Elements, die korrekte Antwort als Wert des Attributs correct sowie das Element für die Liste der Antworten (answers_elem) als Inhalt des Elements answers ermittelt:

        answers = []
        for answer in answers_elem:
            answers.append(answer.text)

Es wird dann eine leere Liste answers definiert. Anschließend wird über die enthaltenen answer-Elemente iteriert und der jeweilige Text an die Liste answers angehängt:

        question = QuizQuestion(
                            question_text,
                            answers,
                            correct
                            )
        questions.append(question)
    return questions

Zum Ende der Funktion wird dann ein QuizQuestion-Objekt erstellt, dem der Text der Frage, die Liste der möglichen Antworten sowie der Index der richtigen Antwort übergeben werden. Dieses wird an die Liste der Quizfragen (questions) angehängt. Diese Liste wird dann als Ergebnis der Funktion zurückgegeben:

if "__main__" == __name__:
    xq = readXmlQuestions("questions.xml")
    assert len(xq)
    for q in xq:
        assert isinstance(q, QuizQuestion)
        assert isinstance(q.question_text, str)
        assert isinstance(q.correct, int)
        assert isinstance(q.answers, list)
        for a in q.answers:
            assert isinstance(a, str)

Wir fügen wieder etwas Testcode hinzu. Der Rückgabewert der Funktion muss eine nicht leere Liste sein. Wir iterieren dann über die enthaltenen Quizfragen und stellen sicher, dass sie Instanzen von QuizQuestion sind, das Attribut question_text ein String und das Attribut correct ein Integer ist, das Attribut answers eine Liste ist. Zum Schluss iterieren wir über die Liste der Antworten und stellen sicher, dass es sich um Strings handelt. Der entsprechende Code befindet sich in der Datei parsequestions.py.
Damit wäre die Behandlung der statischen Eingangsdaten abgeschlossen. Dynamische Eingangsdaten sind natürlich die vom Anwender gemachten Eingaben, die aber von dem entsprechenden Dialog entgegengenommen werden.

Aufmacherbild: Hand pressing modern social buttons von Shutterstock / Urheberrecht: Denphumi

[ header = Seite 2: Generierung der Ausgangsdaten]

Das muss einfach heraus – Generierung der Ausgangsdaten

Wir haben bereits eine Ergebnisanzeige vorgesehen, die dem Teilnehmer das Resultat seines Durchlaufs visualisiert. Sollen aber z. B. unter den besten Quizteilnehmern Preise verlost werden, ist es notwendig, die Ergebnisse zu persistieren. Gespeichert werden müssten in diesem Zusammenhang:

  • die Daten des Teilnehmers, wie sie im Anmeldedialog eingegeben wurden
  • das vom Teilnehmer erzielte Ergebnis
  • aus Gründen der Nachvollziehbarkeit der Bewertung: die vom Teilnehmer jeweils gegebenen Antworten

Da wir uns für die Eingangsdaten schon für eine Datenhaltung auf XML-Basis entschieden haben, benutzen wir ein entsprechendes Format auch für die Ausgabedaten und definieren eine entsprechende DTD:

<,!ELEMENT quiz_results (quiz_result+)>
<!ELEMENT quiz_result (personal_data, answers_given)>
<!ATTLIST quiz_result percent CDATA #REQUIRED>

Wir definieren zunächst ein listenartiges Element quiz_results, das als Container für 1-n-Elemente vom Typ quiz_result dient. Ein quiz_result enthält die Daten für einen Durchlauf des Quiz, insbesondere die eingegebenen Daten aus dem Anmeldedialog (personal_data) und die jeweils gegebenen Antworten (answers_given). Das Element enthält ein Attribut percent, in dem das Gesamtergebnis in Prozent abgelegt ist:

<!ELEMENT personal_data (name, christian_name, street_and_number, zip, town)>
<!ELEMENT name (#PCDATA)>
<!ELEMENT christian_name (#PCDATA)>
<!ELEMENT street_and_number (#PCDATA)>
<!ELEMENT zip (#PCDATA)>
<!ELEMENT town (#PCDATA)>

Das Element personal_data enthält als Unterelemente die im Anmeldedialog eingegebenen Daten:

<!ELEMENT answers_given (given_answer)+>
<!ELEMENT given_answer EMPTY>
<!ATTLIST given_answer question_number CDATA #REQUIRED>
<!ATTLIST given_answer picked_answer CDATA #REQUIRED>

Eine entsprechende Datei könnte so aussehen wie in Listing 3 (formatiert zur besseren Lesbarkeit).

<?xml version="1.0" ?>
<quiz_results>
  <quiz_result percent="50">
    <personal_data>
      <name>Smith</name>
      <christian_name>John</christian_name>
      <street_and_number>Park Allee 25</street_and_number>
      <zip>23889</zip>
      <town>Quiz City</town>
    </personal_data>
    <answers_given>
      <given_answer question_number="0" picked_answer="1"/>
      <given_answer question_number="1" picked_answer="0"/>
      <given_answer question_number="2" picked_answer="0"/>
      <given_answer question_number="3" picked_answer="0"/>
    </answers_given>
  </quiz_result>
</quiz_results>

Die DTD wird in einer Datei quizresults.dtd abgelegt. Wir kommen jetzt zur Implementierung des Codes für das Serialisieren der Ergebnisse in einer Datei. Der vollständige Code findet sich in der Datei appendresult.py im downloadbaren Quellcode:

import shutil
import os

from lxml import etree as ET

RESULT_FILE = "results.xml"

Wir beginnen mit dem Import der benötigten Module und definieren eine Konstante RESULT_FILE für den Namen der Ausgabedatei:

def append_result(personal_data, percent, answers_given):
  if os.path.isfile(RESULT_FILE):
    tree = ET.parse("results.xml")
    root = tree.getroot()
    all_results = tree.findall("quiz_question")
    shutil.copyfile(RESULT_FILE,"%s.bak" % RESULT_FILE)
  else:
    all_results = []

Die Funktion append_result erhält als Parameter ein Objekt, das die Personendaten enthält, das erreichte Ergebnis in Prozent und ein Dictionary, das für die einzelnen Fragen die gegebenen Antworten enthält. Es wird zunächst geprüft, ob schon eine entsprechende Datei vorliegt. Ist das der Fall, wird diese komplett als XML-Tree eingelesen, aus diesem das Root-Element ermittelt und alle quiz_question-Elemente in einer Liste all_results abgelegt. Sicherheitshalber wird dann noch eine Sicherheitskopie der Originaldatei gemacht. Existiert die Datei nicht, wird all_results als leere Liste erstellt:

    quiz_questions_elem = ET.Element("quiz_results")
    for result in all_results:
        quiz_questions_elem.append(result)

Wir erstellen zunächst das äußere Element quiz_questions und fügen ggf. eingelesene bestehende Resultate (die gefundenen quiz_question-Elemente) ein:

    quiz_result = ET.SubElement(quiz_questions_elem, "quiz_result")
    quiz_result.attrib["percent"] = str(percent)

Als Unterelement von quiz_questions wird jetzt ein Element quiz_question für das aktuell hinzuzufügende Ergebnis angelegt. Das Attribut percent wird mit dem Wert des gleichnamigen Arguments belegt:

    personal_data_elem = ET.SubElement(quiz_result, "personal_data")
    for elem_name in (
            "name", "christian_name", "street_and_number", "zip", "town"
            ):
        elem = ET.SubElement(personal_data_elem, elem_name)
        elem.text = getattr(personal_data, elem_name)

Für die Personendaten wird jetzt ein Unterelement personal_data_elem angelegt. Dann wird über die entsprechenden Attribute iteriert. Für jedes Attribut wird ein gleichnamiges Element als Unterelement von personal_data_elem angelegt und mit dem Wert des entsprechenden Attributs des übergebenen Objekts personal_data belegt:

  answers_given_elem = ET.SubElement(quiz_result, "answers_given")
  for number in sorted(answers_given):
    given_answer_elem = ET.SubElement(answers_given_elem, "given_answer")
    given_answer_elem.attrib["question_number"] = str(number)
    given_answer_elem.attrib["picked_answer"] = str(answers_given[number])

Entsprechend der DTD wird für die Antworten ein Element answers_given als Unterelement von quiz_result eingefügt. Dann wird über die sortierten Schlüssel des Dictionarys answers_given iteriert und das Attribut question_number auf die jeweilige Nummer, das Attribut picked_answer auf den jeweiligen Wert gesetzt, wobei beide Werte zunächst in Strings umgewandelt werden:

    newTree = ET.ElementTree(quiz_questions_elem)
    newTree.write(RESULT_FILE)

Abschließend wird aus dem Element quiz_question_elem ein neues ElementTree-Objekt erzeugt und dieses in die Datei geschrieben:

if "__main__" == __name__:
    class Duck(object):
        pass
    personal_data = Duck()

Zum einfacheren Testen definieren wir wieder eine Klasse Duck, die uns für das Duck Typing dient. Dann erstellen wir mit personal_data eine Instanz dieser Klasse:

    personal_data.name = "Smith"
    personal_data.christian_name = "John"
    personal_data.street_and_number = "Elm Street 25"
    personal_data.zip = "23889"
    personal_data.town = "Quiz City"

Wir attribuieren personal_data nun nacheinander mit Name, Vorname, Straße und Hausnummer, Postleitzahl und der Stadt:

  percent = 50
  answers_given = {
        0: 1, 1: 0, 2: 0, 3: 0
        }
  append_result(personal_data, percent, answers_given)

Für den Test weisen wir percent den Wert 50 zu, belegen das Dictionary answers_given mit exemplarischen Werten und rufen anschließend die Funktion append_result mit den generierten Daten auf:

  tree = ET.ElementTree(file=RESULT_FILE)
  dtd_file = open("quizresults.dtd", "rb")
  dtd = ET.DTD(dtd_file)
  root = tree.getroot()
  if not dtd.validate(root):
    raise Exception(dtd.error_log.filter_from_errors()[0])    

Zum Abschluss lesen wir die gerade erzeugte Datei wieder als XML-Baum ein, lesen die DTD-Datei ein, ermitteln aus dem XML-Baum das Root-Element und validieren es anhand der DTD.

[ header = Seite 3: Integration zu einer lauffähigen Anwendung]

Zusammenleimen der Teile – Integration zu einer lauffähigen Anwendung

Wir haben jetzt die grundlegenden Komponenten der Anwendung implementiert, nämlich die benötigten Oberflächenelemente und Module für das Einlesen von Daten und das Persistieren von Ergebnissen. Alle Komponenten sind separat ausführ- und testbar. Die Einzelteile müssen jetzt noch zusammenarbeiten.

Wie die (GUI-)Komponenten zusammenarbeiten sollen, d. h. welche Übergänge es geben soll, kann dem schon im ersten Teil gezeigten Interface-Flow-Diagramm (Abb. 1) entnommen werden.

Abb. 1: Interface-Flow-Diagramm

Das Betätigen des Buttons Start Quiz soll z. B. bewirken, dass zunächst der Anmeldedialog dargestellt wird. In Qt benutzt man dafür den Signal-Slot-Mechanismus. Ein Signal ist dabei ein Ereignis, das z. B. beim Betätigen eines Buttons durch den Anwender von Qt angestoßen wird. Ein Slot ist eine Funktion/Methode, die aufgerufen wird, wenn das entsprechende Signal ausgelöst wurde. Die Zuordnung von Slots zu Signalen erfolgt in (Py)Qt dabei über eine connect-Methode, die von jedem QWidget implementiert wird. Sie hat drei Argumente: das Objekt, für das auf Ereignisse reagiert werden soll (ein QWidget, z. B. ein Button), ein Signal, auf das reagiert werden soll (z. B. QtCore.SIGNAL(„clicked()“)) für einen Button, eine Funktion/Methode (Slot), die aufgerufen werden soll, wenn das entsprechende Signal ausgelöst wird, z. B. run_quiz. Für den Button Start Quiz unseres Hauptfensters würde der entsprechende Code so aussehen:

self.connect(start_button, QtCore.SIGNAL("clicked()"), self.run_quiz)

Ab Qt 4.5 gibt es eine alternative Möglichkeit, die Verbindung von Signalen und Slots zu implementieren. Die Syntax ist dabei:

<quellobjekt>.<signalname>.connect(<slotname>)

Für das Beispiel wäre die entsprechende Implementierung:

start_button.clicked.connect(self.run_quiz)

Die letztere Implementierung ist vorzuziehen. Sie ist weniger fehlerträchtig, da das Signal als Methode und nicht als String notiert wird, bei dem ein Schreibfehler zu Fehlern führen könnte. Außerdem kann eine Methode über die Code-Completion einer IDE hinzugefügt werden.

Nach diesem Ausflug in das Slot-/Signal-Konzept von QT ergänzen wir die Implementierung des Hauptfensters:

from __future__ import division

Diese Zeile, die am Anfang der Datei stehen muss, bewirkt, das Python bei einem Ausdruck wie

x = 1 / 2

eine Floating-Point- und keine Integer-Division ausführt. Das ist mit Python 2.X nur über Umwege möglich. Das aus der Zukunft importierte Verhalten entspricht dem Verhalten des kommenden Python 3:

from appendresult import append_result
from parsequestions import readXmlQuestions
from questiondialog import QuestionDialog
from registerdialog import RegisterDialog
from resultdialog import runResultDialog

Anschließend ergänzen wir die Importe am Anfang der Datei. Da startscreen.py den Code für das Hauptfenster der Applikation darstellt, ist es nur naheliegend, dieses zur Steuerung des Zusammenspiels der einzelnen Teile der Anwendung einzusetzen. Entsprechend importieren wir hier die meisten vorher implementierten Komponenten:

>class QuizMain(QtGui.QMainWindow):
    def __init__(self, parent=None):
        ...
  
       self.qqs = readXmlQuestions("questions.xml")

Zunächst lesen wir die Quizfragen ein. Dies muss nur einmal zum Start der Anwendung geschehen; insofern bietet sich der Initializer dafür an:

        start_button.clicked.connect(self.run_quiz)
        end_button.clicked.connect(self.end_application)
        ...
        self.showFullScreen()

Dann verbinden wir Signale mit Slots. Bei Betätigung des Buttons Start Quiz wird die Methode run_quiz, bei Betätigung des ButtonsEnd Quiz die Methode end_application aufgerufen. Wir beginnen mit der Methode run_quiz, die einen Durchlauf durch die Quizfragen steuert:

  def run_quiz(self):
    rd = RegisterDialog()
    result = rd.exec_()
    if not rd:
      return

Zunächst wird der RegisterDialog instanziiert und aufgerufen. Wenn der Anwender an dieser Stelle den Dialog nicht mit OK beendet, wird die Ausführung der Funktion einfach mit return beendet:

      
      answer_2_number = {}
      correct_answers = 0

Wir benutzen ein Dictionary, um die vom Anwender jeweils gegebene Antwortnummer der Nummer der Frage zuzuordnen, und initialisieren die entsprechende Variable mit einem leeren Dictionary. Die Anzahl der korrekten Antworten beträgt initial 0:

      for number, question in enumerate(self.qqs):
        qd = QuestionDialog(question)
        result = qd.exec_()
        if not result:
          break
        answer_2_number[number] = qd.picked
        if qd.picked == question.correct:
          correct_answers += 1

Mit enumerate(<Liste>) erzeugt man in Python einen Generator aus einer Liste. Das ist ein Objekt, über das wie über einen Iterator iteriert werden kann und das für das jeweils nächste Element der Liste ein Paar (Index, Inhalt) zurückgibt.

Mit number erhalten wir also den Index der nächsten Frage, mit question die Frage selbst.

Wir instanziieren dann QuestionDialog mit der jeweiligen Frage und rufen den Dialog auf. Liefert der Dialog kein Resultat, wird die Schleife mit break verlassen. Andernfalls wird die im Dialog gewählte Antwort für diese Frage in answer_2_number für diese Fragenummer abgelegt. Entspricht die gegebene Antwort der als korrekt definierten Antwort, wird die Anzahl der korrekt beantworteten Fragen inkrementiert:

        else:
            percent = correct_answers / len(qqs) * 100
            runResultDialog(percent)
            append_result(rd.personal_data, percent, answer_2_number)

Der else-Zweig einer for-Schleife wird in Python genau dann aufgerufen, wenn es beim Durchlauf des Schleifenkörpers nie zu einem Abbruch durch break kam. Das bedeutet in diesem Fall, dass alle Fragedialoge ohne Abbruch abgeschlossen wurden. Entsprechend kann jetzt die Auswertung erfolgen. Es wird die Quote der korrekten Antworten in Prozent berechnet, mit diesem Wert runResultDialog aufgerufen, um dem Anwender eine Rückmeldung zu geben, und abschließend mit den im RegisterDialog ermittelten persönlichen Daten, der Quote und der Beschreibung der gegebenen Antworten append_result aufgerufen, um das Ergebnis des Teilnehmers dauerhaft zu speichern:

    def end_application(self):
        mb = QtGui.QMessageBox.warning(self,
                "End application?",
                "Do you really want to quit now?",
                QtGui.QMessageBox.Yes | QtGui.QMessageBox.No
                )
        if mb == QtGui.QMessageBox.Yes:
            self.close()

Die Methode end_application fragt über eine Messagebox ab, ob die Anwendung wirklich beendet werden soll; ist das der Fall, wird sie mit dem Aufruf der vordefinierten Methode close geschlossen. Das vollständige Listing befindet sich in der Datei startscreen.py im Download. Als Nächstes muss der Anmeldedialog (Datei registerdialog.py) überarbeitet werden (Listing 4).

from PySide import QtGui, QtCore
...
class PersonalData(object):
    def __init__(self,
                 email,
                 christian_name,
                 name,
                 street_and_number,
                 zip_code,
                 town):
        self.email = email
        self.christian_name = christian_name
        self.name = name
        self.street_and_number = street_and_number
        self.zip_code = zip_code
        self.town = town

Wir fügen unterhalb des Import-Statements eine neue Klasse PersonalData hinzu, die als Container-Klasse für die Personendaten dient. Der Initializer weist lediglich den übergebenen Parametern entsprechende Attribute der Instanz zu.

[ header = Seite 4: Zusammenfassung]

Bisher nicht betrachtet wurde die Frage der Validierung von Eingaben. Wir beschränken uns hier auf die Betrachtung, ob der Anwender überhaupt in allen Feldern Daten eingegeben hat. Eine Möglichkeit wäre an dieser Stelle, nach Betätigung des Dialogs durch den Anwender die Eingaben zu prüfen und ihm dann eine Rückmeldung zu geben, falls er noch Eingaben vornehmen muss (Post-mortem-Validierung). Eleganter ist es aber in den meisten Fällen, den Anwender nicht mit einer Meldung zu konfrontieren, sondern ihn dahingehend zu führen, dass er den Dialog erst abschließen kann, wenn alle notwendigen Eingaben getätigt wurden. Wir entscheiden uns hier für letztere Möglichkeit:

class RegisterDialog(QtGui.QDialog):
    def __init__(self, parent=None):
        ...
        self.button_box = QtGui.QDialogButtonBox(
                QtGui.QDialogButtonBox.Ok|
                QtGui.QDialogButtonBox.Cancel
                )
       ...
        self.ok_button = self.button_box.button(QtGui.QDialogButtonBox.Ok)
        self.ok_button.setEnabled(False)

Wir besorgen uns von der Buttonbox zunächst den OK-Button, machen ihn zu einem Attribut der Instanz und deaktivieren ihn anschließend. Da die Felder initial leer sind, sollte der Anwender den Dialog nicht abschließen können:

        self.fields = [
                self.email_edit,
                self.christian_name_edit,
                self.name_edit,
                self.street_edit,
                self.zip_edit,
                self.town_edit
                ]

Wir definieren dann eine Liste aller Eingabefelder als Attribut der Instanz. Am Ende des Initializers fügen wir Code hinzu, der Signale mit Slots verbindet:

        for field in self.fields:
             field.textEdited.connect(self.ok_enabled)        

Wir sorgen zunächst dafür, dass bei jeder Änderung eines Feldes die Methode self.ok_enabled aufgerufen wird. Wir werden auf diese Methode gleich genauer eingehen:

                         
        self.connect(self.button_box, QtCore.SIGNAL("accepted()"),
                     self, QtCore.SLOT("accept()"))
        self.connect(self.button_box, QtCore.SIGNAL("rejected()"),
                     self, QtCore.SLOT("reject()"))

An dieser Stelle verknüpfen wir lediglich das Verhalten des Standardbuttons (OK) mit der Standardmethode accept und analog des Standardbuttons Cancel mit reject. Die entsprechenden Methoden können im Bedarfsfall überschrieben werden. Im Fall von accept werden wir davon Gebrauch machen.

Die Methode ok_enabled prüft bei jeder Änderung eines der Textfelder des Dialogs, ob der OK-Button verfügbar gemacht werden soll:

    def ok_enabled(self):
        for field in self.fields:
            if unicode(field.text()) == "":
                self.ok_button.setEnabled(False)
                break
        else:
            self.ok_button.setEnabled(True)

Es werden nacheinander alle Felder des Dialogs durchlaufen. Die jeweiligen Inhalte werden mit der Methode text() ermittelt, in Unicode gewandelt und geprüft, ob es sich um einen Leerstring handelt. Ist das der Fall, wird der Button deaktiviert und die Schleife verlassen. Nur wenn keines der Felder einen Leerstring enthält, gelangen wir in den else-Zweig der for-Schleife; in diesem Fall wird der OK-Button aktiviert.
Die Klasse QDialog verfügt über die vordefinierten Methoden accept() und reject(). Ein Aufruf von accept() bewirkt im Wesentlichen, dass die Methode exec_() des Dialogs True, reject(), dass sie False zurückgibt. Wir überschreiben an dieser Stelle die Methode accept:

    def accept(self):
        self.personal_data = PersonalData(
                unicode(self.email_edit.text()),
                unicode(self.christian_name_edit.text()),
                unicode(self.name_edit.text()),
                unicode(self.street_edit.text()),
                unicode(self.zip_edit.text()),
                unicode(self.town_edit.text())
                )
        QtGui.QDialog.accept(self)

Wir fügen ein neues Attribut zur Instanz hinzu (self.personal_data), dem wir wiederum eine Instanz der Klasse PersonalData hinzufügen, die mit den persönlichen Daten, die den Textfeldern des Dialogs entnehmen, initialisiert wird. Abschließend rufen wir die Methode accept von QDialog auf, damit die Methode exec_ in diesem Fall True zurückliefert. Das komplette Listing befindet sich in registerdialog.py in der herunterladbaren Datei.
Wir müssen jetzt auch noch den eigentlichen Quizfragendialog so ergänzen, dass wir die Buttonklicks mit Aktionen, d. h. wieder Signals mit Slots verbinden:

class QuestionDialog(QtGui.QDialog):
    def __init__(self, question, parent=None):
        ...
        end_button = button_box.addButton("&End quiz",
                QtGui.QDialogButtonBox.ActionRole)
        next_button.clicked.connect(self.store_result)
        end_button.clicked.connect(self.confirm_end)
        ...
        layout.addWidget(button_box)
        ...

Wir ergänzen dazu wieder wie im vorigen Dialog den Initializer und verbinden das Signal clicked des Continue -Buttons mit der Methode store_result und dasselbe Signal desEnd Quiz-Buttons mit der Methode confirm_end, die wir gleich im Anschluss diskutieren:

    def store_result(self):
        for number, rb in enumerate(self.radio_buttons):
            if rb.isChecked():
                self.picked = number
                break
        QtGui.QDialog.accept(self)

Wir iterieren wieder über Index und Inhalt der Radiobuttons in self.radio_buttons. Sobald ein Radiobutton aktiviert ist, setzen wir self.picked auf den betreffenden Index, um die Auswahl des Teilnehmers zu speichern, und beenden die Schleife. Anschließend rufen wir QtGui.QDialog.accept auf, damit die Methode exec_ des Dialogs in diesem Fall True zurückgibt:

    def confirm_end(self):
        confirmed = runCancelDialog(self)
        if not confirmed:
            return
        QtGui.QDialog.reject(self)

Falls der Anwender den End Quiz-Button gedrückt hat, wird noch einmal über die Funktion runCancelDialog nachgefragt, ob das Quiz wirklich beendet werden soll. Ist das nicht der Fall, kehrt die Funktion an dieser Stelle zurück. Andernfalls wird dann QtGui.QDialog.reject aufgerufen, damit exec_ in diesem Fall False zurückgibt. Das vollständige Listing befindet sich in der Datei questiondialog.py im Download.

Zusammenfassung

Wir haben anhand unseres kleinen Projekts exemplarisch eine vollständige Anwendung unter Verwendung von Python und Qt implementiert. Dazu haben wir die benötigten GUI-Elemente ohne Verwendung eines GUI-Designers „von Hand“ implementiert. Die meisten Elemente hätten auch mit dem Qt-Designer erstellt und in Python-Code transformiert werden können – das ist in vielen Fällen auch empfehlenswert. Eine Einführung in die Benutzung des Qt-Designers hätte diesen Artikel aber gesprengt.

Des Weiteren haben wir XML-Formate für den Inhalt der Quizfragen und die Ablage von Ergebnissen definiert und Komponenten zum Laden und Persistieren dieser Daten implementiert. Wir haben in diesem Fall die lxml-Bibliothek benutzt, da sie die Möglichkeit der Validierung von XML-Dateien gegen entsprechende Schemata bietet. Wir hoffen, gezeigt zu haben, dass es mit Python und Qt auch ohne Benutzung von GUI Buildern relativ einfach ist, grafische Oberflächen zu entwerfen, dass interpretierte, dynamisch typisierte Sprachen wie Python durch Duck Typing und die Möglichkeit, Code sofort im Interpreter auszuführen, das Testen von Komponenten erleichtern können, und dass der Verzicht auf statische Typdeklarationen (bei allen damit verbundenen Nachteilen) zusammen mit den leistungsfähigen Datentypen von Python einen relativ kompakten und gut verständlichen Code erlaubt.

Hinzu kommt, dass der Code ohne eine Neukompilierung auf allen Plattformen lauffähig ist, wenn die benötigten Komponenten installiert sind. Es soll nicht verschwiegen werden, dass es noch viel Raum für Verbesserungen gibt: Es fehlt noch Funktionalität, um die generierten Ergebnisdateien auszuwerten, z. B. bereitgestellt über einen Button Show Results im Startbildschirm. Für die Datenhaltung würde sich vielleicht eher die Verwendung einer Datenbank anbieten. Die GUI-Elemente könnten grafisch „aufgepeppt“ werden.

Statt der sehr rudimentären Tests könnten Unit Tests implementiert werden usw. Entsprechende Anpassungen sollen an dieser Stelle dem geneigten Leser überlassen werden, falls dieser Artikel sein Interesse geweckt hat, sich mit der Python-Programmierung mit Qt zu beschäftigen. Interessierte seien in diesem Zusammenhang auf das Buch von Mark Summerfield zu diesem Thema (Summerfield, Mark: „Rapid GUI Programming with Python and Qt“, Prentice Hall, 2008) verwiesen.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -