Arbeiten mit Python, XML and SAX

Vorbemerkung

Dieses Kapitel ist gerade erst im Entstehen, deshalb ist es noch unvollständig und gegebenenfalls fehlerhaft. Also Benutzung auf eigene Gefahr :-=

Stand 17. März 2014


Einführung

Python, XML und SAX XML steht für "Extensible Markup Language" (in Deutsch "erweiterbare Auszeichnungssprache"). Bei XML handelt es sich um eine Auszeichnungssprache zur Darstellung hierarchisch strukturierter Daten in Form von Textdateien. XML eignet sich besonders für den plattform- und implementationsunabhängigen Austausch von Daten zwischen Computersystemen.

XML seinerseits basiert auf der Metasprache SGML, was für "Standard Generalized Markup Language" (deutsch: Normierte Verallgemeinerte Auszeichnungssprache)

Nicht nur XML sondern auch HTML sind Sprachen, die auf SGML basieren. HTML könnte man als einen für Webseiten optimierten Dialekt von SGML bezeichnen. Während HTML eine Anwendung von SGML ist, bildet XML eine Untermenge von SGML, d.h. alle XML-Dokumente sind konforme SGML-Dokumente. Man könnte XML auch als eine Weiterentwicklung von SGML ansehen.

Zusammenhang zwischen XML und HTML

Vorteile von XML: (Gilt natürlich auch für andere Markup-Sprachen) Wir können leider keine umfassende Einführung in XML hier geben, da dies den Rahmen dieser Einführung sprengen würde. Ziel dieses Kapitels ist vielmehr die automatische Verarbeitung von XML-Dokumenten mit Python.

Arbeitsweise des SAX

Ein SAX-Parser liest XML-Dateien als Datenstrom und ruft für im Standard definierte Ereignisse bestimmte Rückruffunktionen (callback functions) auf.
Eine Anwendung kann mit eigenen Rückruffunktionen die Auswertung der XML-Daten beeinflussen.

Funktionsweise des Sax

Callback-Methoden im Überblick

In unseren Beispielen haben wir bereits die Methoden "startElement" und "endElement" kennengelernt. Wir haben gesehen, dass sie automatisch vom Sax aufgerufen wurden, wenn diese auf ein Start- bzw. End-Tag gestoßen ist. Methoden dieser Art nennt man Callback-Methoden. Im folgenden finden Sie eine Übersicht über die von SAX verwendeten Callback-Methoden:

Callback-Methode Erklärung
setDocumentLocator (locator) Aufruf zu Beginn des Parsings. Initialisiert den Locator, der jeweils auf die Position im XML-Dokument verweist, an der sich der Parser gerade befindet.
startDocument() Aufruf beim Beginn des Parsing-Prozesses
endDocument() Aufruf am Ende der Verarbeitung oder nach einem Fehler der zum Abbruch führt.
startPrefixMapping (prefix, uri) Aufruf beim Start eines Elementes, welches ein Namensraum-Präfix besitzt
endPrefixMapping (String prefix) Aufruf beim Abschluss eines Elementes, welches ein Namensraum-Präfix besitzt
startElement (name, attrs) Wird bei jedem öffnenden Tag aufgerufen mit dem Tag-Namen des Elementes "name" und ein Python-Dictionary "attrs" mit den Attributen.
endElement (name) ird bei jedem schließenden Tag aufgerufen mit dem Tag-Namen des Elementes "name" und ein Python-Dictionary "attrs" mit den Attributen.
ignorableWhitespace (whitespace) Die Methode ignorableWhitespace dient zur Behandlung der Whitespace Zeichen zwischen den Elementen. Leerzeichen wie Zeilenumbrüche und Tabs zwischen den Elementen werden normalerweise ignoriert. Die Methode ignorableWhitespace kann zur Behandlung dieser Zeichen verwendet werden. Der Parameter whitespace enthält den gefundenen Leerraum.
characters (content) Gibt den Text zwischen den Markup-Abschnitten zurück. Der Parameter content enthält den tatsächlich gefundenen Leerraum.
processingInstruction(target, data) Reagiert auf Steueranweisungen im Dokument, aber nicht auf die <?xml?>-Steueranweisung am Dokumentbeginn.
skippedEntity(name) Reicht alle Entity-Referenzen an den ContentHandler durch, die nicht vom Parser aufgelöst werden können.


Das SAX-API definiert vier grundlegende Interfaces. Diese sind in Python als Klassen implementiert:

Klasse Erklärung
ContentHandler implementiert das Hauptinterface für Dokumenten-Ereignissen (events)
DTDHandler zum Bearbeiten von DTD-Ereignissen
EntityResolver Klasse zum Auflösen von externen Entitäten
ErrorHandler Klasse zur Behandlung von Fehlern und Warnungen


Einfaches Beispiel mit SAX

Für die folgenden Beispiele wollen wir die folgende XML-Datei mit Informationen zu Büchern verwenden, die wir unter buecher.xml abspeichern:
<?xml version="1.0"?>
<bookstore>
<book lang="en">
	<title>The Language Instinct: How the Mind Creates Language (P.S.)</title>
	<author>Steven Pinker</author>
	<price>7.50</price>
</book>
<book lang="de">
	<title>Das unbeschriebene Blatt: Die moderne Leugnung der menschlichen Natur</title>
	<author>Steven Pinker</author>
	<price>29.80</price>
</book>
<book lang="en">
	<title>The Black Swan: The Impact of the Highly Improbable</title>
	<author>Nassim Nicholas Taleb</author>
	<price>8.10</price>
</book>
<book lang="fr">
	<title>L'Etranger</title>
	<author>Albert Camus</author>
	<price>6.10</price>
</book>
<book lang="de">
	<title>Einführung in Python 3</title>
	<author>Bernd Klein</author>
	<price>24.99</price>
</book>
<book lang="de">
	<title>Etrusker AG</title>
	<author>Bernd Klein</author>
	<price>19.99</price>
</book>
<book lang="de">
	<title>Evariste Galois oder das tragische Scheitern eines Genies</title>
	<author>Bernd Klein</author>
	<price>9.99</price>
</book>
</bookstore>
Zuerst wollen wir diese Datei mit SAX scannen und lediglich alle Tags ausgeben. Dazu benötigen wir das SAX-Modul aus dem XML-Paket. Eigentlich benötigen wir nur die beiden Methoden "make_parser" und "handler":
from xml.sax import make_parser, handler
Unser Python-Programm ("buecher.py") zum Scannen der XML-Datei sieht nun wie folgt aus:
from xml.sax import make_parser, handler

class BuecherHandler(handler.ContentHandler):
    def startElement(self, name, attrs):
        print(name)


parser = make_parser()
b = BuecherHandler()
parser.setContentHandler(b)
parser.parse("buecher.xml")
Wir haben eine Klasse BuecherHandler definiert, die von der Klasse "handler.ContentHandler" erbt. In dieser Klasse überschreiben wir die Methode "startElement". Während der Name der Klasse frei gewählt werden kann, muss die Methode genau diesen Namen und die entsprechende Anzahl der Parameter haben, da es sich ja um die entsprechende Methode von handler.ContentHandler handelt. (Wenn Sie Fragen oder Probleme zum Überschreiben von Methoden und zur Vererbung haben, sollten Sie am besten in unserem Tutorial das Kapitel Vererbung durcharbeiten.) Der Aufruf "parser = make_parser()" erzeugt ein Parser-Objekt. Dem so generierten Parser "parser" müssen wir mittels der Methode "setContentHandler" eine Instanz b unserer Klasse BuecherHandler() übergeben, die wir mit der Anweisung "b = BuecherHandler()" erzeugt haben. Dann können wir endlich unseren Parser mit "parser.parse("buecher.xml")". Das XML-Dokument wird nun geparst. Jedesmal, wenn der Parser auf ein Starttag trifft, wird unsere Methode startElement aufgerufen. An den Parameter "name" wird der Name des Tags übergeben und an "attrs" wird ein Objekt mit den Attributen für dieses Tag pbergeben, falls Attribute vorhanden sind. Uns interessiert aber für unser Beispiel nur der Name des Tags. Die Ausgabe unseres Programms sieht nun wie folgt aus:
$ python3 buecher1.py 
bookstore
book
title
author
price
book
title
author
price
book
title
author
price
book
title
author
price
book
title
author
price
book
title
author
price
book
title
author
price
Statt jeweils sofort den Tagnamen zu drucken, wollen wir nun einfach die Menge der verschiedenen Tags bestimmen. Dazu definieren wir in der nun zu schreibenden Methode "__init__" ein Attribut self.tags, in dem wir die Tagnamen sammeln. Ausserdem benötigen wir eine Methode zur Ausgabe (getTags) der gefundenen Tags:
from xml.sax import make_parser, handler

class BuecherHandler(handler.ContentHandler):

    def __init__(self):
        self.tags = set()

    def startElement(self, name, attrs):
        self.tags.add(name)

    def getTags(self):
        return self.tags



parser = make_parser()
b = BuecherHandler()
parser.setContentHandler(b)
parser.parse("buecher.xml")
print(b.getTags())
Die Ausgabe sieht nun wie folgt aus:
$ python3 buecher1.py 
{'price', 'author', 'bookstore', 'book', 'title'}
Nun wollen wir gerne statt der Tagnamen alle Buchtitel und Autoren sammeln:
from xml.sax import make_parser, handler

class BuecherHandler(handler.ContentHandler):

    def __init__(self):
        self.authors = set()
        self.titles = set()
        self.current_content = ""

    def startElement(self, name, attrs):
        self.current_content = ""

    def characters(self, content):
        self.current_content += content.strip()

    def endElement(self, name):
        if name == "title":
            self.titles.add(self.current_content)
        elif name == "author":
            self.authors.add(self.current_content)

    def getTitles(self):
        return self.titles

    def getAuthors(self):
        return self.authors


parser = make_parser()
b = BuecherHandler()
parser.setContentHandler(b)
parser.parse("buecher.xml")

print("Authors: ")
print(b.getAuthors())

print("Titles:")
print(b.getTitles())
In dem Programm haben wir drei neue Attribute eingeführt: self.authors ist eine Menge, in der wir die Namen der Autoren sammeln. self.titles dient analog für die Buchtitel. self.current_content dient zum Zwischenspeichern der textuellen Information zwischen Start- und End-Tag. Da sich diese Information über mehrere Zeilen erstrecken kann, hängen wir sie mit "+=" an. Wir überschreiben nun auch die Methode endElement, die vom Scanner immer aufgerufen wird, wenn ein Endtag erreicht wird, also z.B. </title> oder </author>. Immer wenn wir ein End-Tag "title" erreichen, addieren wir die Textinformation von self.current_content, also der Titel des Buches, zur Menge self.titles. Entsprechend addieren wir die Textinformation von self.current_content zur Menge self.authors, wenn wir auf einem Endtag "</author>" landen.

Das Programm liefert uns folgende Ausgaben:

$ python3 buecher2.py
Authors: 
{'Steven Pinker', 'Nassim Nicholas Taleb', 'Albert Camus', 'Bernd Klein'}
Titles
{'Einführung in Python 3', 'The Language Instinct: How the Mind Creates Language (P.S.)', "L'Etranger", 'Evariste Galois oder das tragische Scheitern eines Genies', 'The Black Swan: The Impact of the Highly Improbable', 'Etrusker AG', 'Das unbeschriebene Blatt: Die moderne Leugnung der menschlichen Natur'}


Schauen wir uns unsere XML-Datei nochmals genommer an, erkennen wir, dass es bei dem Tag "<book>" noch ein Attribut "<lang>" gibt. Im folgenden Programm zeigen wir nun, wie wir auch diese Information erhalten können. In der Methode startElement speichern wir den Wert von "lang" im Instanzattribut self.language, falls das Attribut vorhanden ist. In der Methode endElement hängen wir das Sprachattribut dann an den Titel an:

from xml.sax import make_parser, handler

class BuecherHandler(handler.ContentHandler):

    def __init__(self):
        self.authors = set()
        self.titles = set()
        self.current_content = ""
        self.language = ""

    def startElement(self, name, attrs):
        if "lang" in attrs:
            self.language = attrs["lang"]
        self.current_content = ""

    def characters(self, content):
        self.current_content += content.strip()

    def endElement(self, name):
        if name == "title":
            txt = self.current_content + " (" + self.language + ")"
            self.titles.add(txt)
        elif name == "author":
            self.authors.add(self.current_content)

    def getTitles(self):
        return self.titles

    def getAuthors(self):
        return self.authors


parser = make_parser()
b = BuecherHandler()
parser.setContentHandler(b)
parser.parse("buecher.xml")
print("Authors: ")
print(b.getAuthors())
print("Titles")
print(b.getTitles())
Unsere XML-Datei wird dann in folgende Ausgabe gewandelt:

$ python3 buecher2.py
Authors: 
{'Steven Pinker', 'Nassim Nicholas Taleb', 'Albert Camus', 'Bernd Klein'}
Titles
{'The Black Swan: The Impact of the Highly Improbable (en)', 'Evariste Galois oder das tragische Scheitern eines Genies (de)', 'Etrusker AG (de)', 'Das unbeschriebene Blatt: Die moderne Leugnung der menschlichen Natur (de)', 'The Language Instinct: How the Mind Creates Language (P.S.) (en)', "L'Etranger (fr)", 'Einführung in Python 3 (de)'}


Noch ein Beispiel für einen SAX-Parser

import sys
from xml.sax import make_parser, handler

class Counter(handler.ContentHandler):
    def __init__(self):
        self._elems = 0
        self._attrs = 0
        self._elem_types = {}
        self._attr_types = {}
    def startElement(self, name, attrs):
        self._elems = self._elems + 1
        self._attrs = self._attrs + len(attrs)
        self._elem_types[name] = self._elem_types.get(name, 0) + 1
        for name in attrs.keys():
     	    self._attr_types[name] = self._attr_types.get(name, 0) + 1    
    def endDocument(self):
        print("Es gibt: ", self._elems, "Elemente.")
        print("Es gibt: ", self._attrs, "Attribute.")

        print("---Element-Typen:")
        for pair in  self._elem_types.items():
            print("%20s %d" % pair)

        print("---Attribut-Typen:")
        for pair in  self._attr_types.items():
    	    print("%20s %d" % pair)

            
parser = make_parser()
parser.setContentHandler(Counter())
parser.parse(sys.argv[1])
Dieses Programm liest die verschiedenen Typen, die in einem XML-Dokument vorkommen. Um dieses Programm zu testen, benötigen wir eine XML-Datei. Wir speichern dazu die folgende Datei unter "addresses.xml" ab:
<?xml version="1.0" ?>
<address-book>
   <address id="main">
      <name>Homer Simpson</name>
      <street>
         742 Evergreen Terrace
      </street>
      <zip country="US">90701</zip>
      <location>
         Springfield
      </location>
   </address>
   <address id="main">
      <name>Charles Montgomery Burns</name>
      <street>
         1000 Mammon Street
      </street>
      <zip country="US">90701</zip>
      <location>
         Springfield
      </location>
   </address>
</address-book>
Rufen wir dieses Programm auf, erhalten wir folgende Ausgaben:
$ python3 sax_bsp.py addresses.xml 
Es gibt:  11 Elemente.
Es gibt:  4 Attribute.
---Element-Typen:
        address-book 1
             address 2
                name 2
              street 2
                 zip 2
            location 2
---Attribut-Typen:
                  id 2
             country 2


Beispiel: Serienbrief

Nehmen wir an, wir wollen einen Brief an mehrere Adressaten mit gleichem Text verschicken. Die Adresse wollen wir manuell eingeben.

Der Text könnte so aussehen:
An
Erika Mustermann
Heidestrasse 17
51147 Köln

Hallo Erika,

wir danken Ihnen für Ihre Kontaktaufnahme und bieten ihnen
....
Der Name und die Adresse soll durch die jeweils aktuelle Adresse ersetzt werden.
Mit der String-Methode "format", die wir im Tutorial unter Formatierte Ausgabe ausgiebig beschrieben haben, lässt sich dieses Problem elegant lösen. Hilfreich zum weiteren Verständnis könnte auch unser Beitrag zur Parameterübergabe im Tutorial sein, in dem wir auch die Übergabe eines Dictionaries mit doppeltem Sternchen (**) beschreiben. Zur Lösung des obigen Problems erzeugen wir ein Dictionary mit den Daten und führen dies dem format-String zu. Wir zeigen dies zunächst exemplarisch in einem reduzierten Beispiel in der interaktiven Shell:
>>> p = {"vorname":"Erika"}
>>> txt  = "Hallo {vorname}"
>>> print(txt.format(**p))
Hallo Erika
>>> 
Die Daten wollen wir mittels interaktiver Eingabe vom User des Programmes erfragen. Die Fragen ("question"), sowie die Zuordnung zu der korrespondierenden Variablen ("var_name") definieren wir in einer XML-Datei:
<?xml version="1.0" ?>
<items>
   <item>
      <var_name>vorname</var_name>
      <question>Vorname: </question>
   </item>
   <item>
      <var_name>nachname</var_name>
      <question>Familienname:</question>
      <condition>True</condition>
   </item>
   <item>
      <var_name>strasse</var_name>
      <question>Straße:</question>
   </item>
   <item>
      <var_name>plz</var_name>
      <question>Postleitzahl:</question>
      <condition><![CDATA[(len(p['plz'])==4) or (len(p['plz'])==5)]]></condition>
   </item>
   <item>
      <var_name>ort</var_name>
      <question>Wohnort:</question>
   </item>
</items>
Wir können auch Bedingungen mit dem Tag "condition" definieren. So verlangen wir in unserem Beispiel, dass die Postleitzahl nur 4 Stellen (Schweiz und Österreich) oder 5 Stellen (Deutschland) haben dürfen.

Unseren "Serienbrief" formulieren wir nun als Formatstring:
An
{vorname} {nachname}
{strasse}
{plz} {ort} 

Hallo {vorname},

wir danken Ihnen für Ihre Kontaktaufnahme und bieten ihnen
....
Jetzt müssen wir "lediglich" noch die XML-Datei parsen und das Dictionary mit den Daten erzeugen. Für den SAX-Parser definieren wir eine Klasse Questions:
import sys
from xml.sax import make_parser, handler

class Questions(handler.ContentHandler):

    def __init__(self):
        self.item = {}
        self.current_content = ""
        self.params = {}

    def askQuestion(self):
         if "question" in self.item:
             answer = input(self.item["question"] + " ")
             return answer     

    def startElement(self, name, attrs):
        if name != "items":
            self.item[name] = ""

    def characters(self, content):
        self.current_content += content.strip()

    def endElement(self, name):
        if name == "item":
            value = self.askQuestion()
            self.params[self.item["var_name"]] = value
            p = self.params
            if "condition" in self.item:
                if not eval(self.item["condition"]):
                    print("warning: condition of " + self.item["var_name"] + " not satisfied")
            self.item = {}

        else:
            if name != "items":
                self.item[name] = self.current_content
                self.current_content = ""

    def endDocument(self):
        pass

    def getParams(self):
        return self.params
Im folgenden Programm, das wir unter QaA.py" speichern, parsen wir dann unsere Datei "questions.txt".
import sys
from xml.sax import make_parser, handler

from questions import Questions
from answers import Answers

            
parser = make_parser()
q = Questions()
parser.setContentHandler(q)
parser.parse("questions.txt")
params = q.getParams()

txt = open("ausgabe_format.txt").read()

txt = txt.format(**params)
print(txt)
Wir erhalten das gewünschte Ergebnis, wenn wir unser Programm mit "python3 QaA.py" starten:
Vorname: Erika
Familienname: Mustermann
Straße: Musterstraße 42
Postleitzahl: 42424
Wohnort: Musterstadt
An
Erika Mustermann
Musterstraße 42
42424 Musterstadt 

Hallo Erika,

wir danken Ihnen für Ihre Kontaktaufnahme und bieten ihnen
....


In unserem folgenden Beispiel geht es um Währungen und deren Wechselkurse. Wir werden die XML-Datei ">currencies_quote.xml mittels SAX-Parser einlesen und ein Dictionary mit den Wechselkursen erzeugen. (Stand der Kurse: 11. November 2017) Alle Wechselkurse ind relativ zum US-Dollar angegeben. Ein Währungssatz - in unserem Beispiel der kanadische Dollar - sieht wie folgt aus:

USD/CAD
1.268100
CAD=X
1510354148
currency
2017-11-10T22:49:08+0000
0

In dem SAX-Programm lesen wir nur die Werte von den Tag field ein, falls das Attribut "name" oder "price" gesetzt ist:
from xml.sax import make_parser, handler

class CurrencyExtractor(handler.ContentHandler):

    def __init__(self):
        self.currencies = {}
        self.flag = None
        self.currency = ""

    def startElement(self, name, attrs):
        if name == "field":
            if attrs["name"] == "name":
                self.flag = "currency"
            elif attrs["name"] == "price":
                self.flag = "price"
            
    def characters(self, content):
         if self.flag == "currency":
              self.flag = None
              self.currency = content[4:]
         elif self.flag == "price":
              self.currencies[self.currency] = content
              self.flag = None
  
        
parser = make_parser()
extractor = CurrencyExtractor()
parser.setContentHandler(extractor)
res = parser.parse("currencies_quote.xml")
print(extractor.currencies)
{'KRW': '1119.369995', 'ER 1 OZ 999 NY': '0.059259', 'VND': '22702.000000', 
'BOB': '6.860000', 'MOP': '8.033700', 'BDT': '83.139999', 'MDL': '17.527000', 
'VEF': '9.974500', 'GEL': '2.627000', 'ISK': '103.400002', 
'BYR': '20020.000000', 'THB': '33.110001', 'MXV': '3.253987', 
'TND': '2.499000', 'JMD': '125.650002', 'DKK': '6.378170',

...

'SAR': '3.749900', 'UYU': '29.170000', 'GBP': '0.758220', 'UZS': '8050.000000', 
'GMD': '46.869999', 'AWG': '1.780000', 'MNT': '2448.000000', 'HKD': '7.799700', 
'ARS': '17.479000', 'HUX': '267.679993', 'BRX': '3.265500', 
'ECS': '25000.000000'}