PCPP1 Block 5 (Teil 1): sqlite3, csv, xml.etree.ElementTree

Track Python · PCPP1 Block 5, Ziele 5.1 bis 5.3 · ca. 70 Min.

Worum es geht

Block 5 “Dateiverarbeitung und Umgebung” (file processing and communicating with the program environment) hat laut Quelle 15 % der PCPP1-Prüfung, das sind 7 Fragen (Stand der Quelle: 1. Oktober 2026, bitte vor der Buchung prüfen). Der Block verlangt viel Bibliothekswissen. Diese Lektion ist Teil 1 und deckt drei Bibliotheken ab: sqlite3 (SQL-Datenbank in einer Datei), csv (Tabellen als Text) und xml.etree.ElementTree (XML als Baum). logging und configparser aus demselben Block kommen in einer späteren Lektion.

Praxisbezug für dich als Freelancer: Fast jeder Kundenauftrag hat irgendwo einen CSV-Export, eine XML-Schnittstelle oder eine kleine SQLite-Datenbank. Und fast jeder Sicherheitsvorfall mit Datenbanken beginnt mit einem String, der in SQL gesteckt wurde. Darum gehört SQL-Injection (SQL injection) hier mit hinein: Du siehst, wie sie funktioniert, und warum die Platzhalter ? sie verhindern.

Nicht in dieser Lektion: Transaktionen und Isolation im Detail (siehe Lektion “Transaktionen und Race Conditions” im Track Konzepte), logging, configparser, os, datetime. Alles hier läuft im Browser: SQLite nur im Arbeitsspeicher (":memory:"), CSV und XML aus Strings mit io.StringIO.

Von JS/TS her gedacht

Thema JS/TS Python
SQL mit Parametern db.prepare("... WHERE age > ?").all(30) (z. B. better-sqlite3) cur.execute("... WHERE age > ?", (30,))
Gefährlich Template-String `... '${name}'` f-String f"... '{name}'"
Zeilen als Objekte Ergebnis ist schon ein Objekt Standard sind Tupel, row_factory = sqlite3.Row gibt Zugriff per Name
CSV parsen Bibliothek wie PapaParse csv aus der Standardbibliothek
XML parsen DOMParser und querySelector ET.fromstring und find/findall
Nichts gefunden querySelector gibt null find gibt None, .text darauf ist ein AttributeError

In TS sieht dieselbe Abfrage so aus, einmal richtig und einmal falsch:

// richtig: der Wert wird getrennt vom SQL übergeben
const rows = db.prepare("SELECT id FROM users WHERE name = ?").all(name);

// falsch: der Wert wird Teil des SQL-Texts
const rows2 = db.prepare(`SELECT id FROM users WHERE name = '${name}'`).all();

Das Prinzip ist sprachunabhängig. Es gilt in jeder Sprache und für jede SQL-Datenbank.

Konzept in kleinen Schritten

Schritt 1: Verbindung, Cursor, Parameter, fetch (Ziel 5.1)

sqlite3 ist eine dateibasierte SQL-Datenbank ohne Server. sqlite3.connect(pfad) öffnet die Datei (legt sie an, falls sie fehlt). Mit dem Pfad ":memory:" liegt die Datenbank nur im Arbeitsspeicher, ideal zum Üben. Befehle laufen über einen Cursor (conn.cursor()). Ein Cursor merkt sich, wie weit er gelesen hat. Das ist die Quelle vieler Prüfungsfragen.

Ausgabe (mit Python 3.13 ausgeführt): 1 1, dann 2, dann (1, 'Ada'), [(2, 'Bob')], None.

Merke dir:

  • Die Parameter sind ein Tupel. Bei einem einzelnen Wert braucht es das Komma: (30,). Ein nacktes ("Ada") ist nur ein String in Klammern.
  • fetchone() gibt eine Zeile oder None, fetchmany(n) bis zu n Zeilen, fetchall() alle verbleibenden Zeilen (leere Liste, wenn nichts übrig ist).
  • rowcount ist die Anzahl der betroffenen Zeilen. Ein UPDATE oder DELETE mit WHERE, das nichts trifft, gibt 0 und wirft keinen Fehler.
  • Vor INSERT, UPDATE und DELETE beginnt sqlite3 automatisch eine Transaktion. Ohne commit() gehen die Änderungen beim close() verloren.

Schritt 2: SQL-Injection und Platzhalter (Security-Bezug)

Ein f-String macht den Wert zum Teil des SQL-Textes. Dann entscheidet der Angreifer, was SQL ist. Mit der Tabelle aus Schritt 1:

Ausgabe:

SELECT name FROM users WHERE name = 'x' OR '1'='1'
[('Ada',), ('Bob',), ('Cy',)]
[]

Der Angreifer hat die Bedingung zu ... OR '1'='1' erweitert, die immer wahr ist. Statt keiner Zeile kommen alle zurück. Bei einem Login wäre das ein Zugang ohne Passwort. Mit Platzhalter ? bekommt die Datenbank SQL und Wert getrennt. Der Wert ist immer nur ein Wert, nie SQL. Die Suche nach dem Namen x' OR '1'='1 findet korrekt niemanden.

Zwei Folgen, die du im Kopf haben solltest:

  1. Ein Name wie O'Brien bricht mit dem f-String schon ohne böse Absicht das SQL (Syntaxfehler durch das Apostroph). Platzhalter lösen auch das.
  2. Platzhalter gelten für Werte, nicht für Tabellen- oder Spaltennamen. Wo ein Name dynamisch sein muss, nimmst du eine feste Liste erlaubter Namen (Allowlist) und prüfst dagegen.

Schritt 3: Zeilen mit Namen, Fehlerklassen, with conn:

Standardmäßig sind Zeilen Tupel. Mit conn.row_factory = sqlite3.Row kannst du per Spaltenname zugreifen und mit dict(row) in ein Dict umwandeln. ORDER BY und LIMIT sortieren und begrenzen, auch LIMIT ? nimmt einen Platzhalter. Mehrere Sortierkriterien trennst du mit Komma (ORDER BY age DESC, name ASC: erst nach Alter absteigend, bei Gleichstand nach Name). NULL gilt in SQLite als kleinster Wert, steht also bei ASC ganz vorn und bei DESC ganz hinten (mit Python 3.13 ausgeführt).

Ausgabe: Bob {'id': 2, 'name': 'Bob'} ['id', 'name'] und [(2, 'Bob'), (1, 'Ada')].

Die Fehlerklassen stehen unter der Basis sqlite3.Error. Zwei sind besonders prüfungsrelevant:

  • sqlite3.IntegrityError: eine Regel der Tabelle wird verletzt (z. B. NOT NULL, UNIQUE).
  • sqlite3.OperationalError: etwa eine fehlende Tabelle oder ein SQL-Syntaxfehler.

with conn: bestätigt bei Erfolg (commit) und macht bei einer Exception rollback. Wichtig: Die Verbindung wird dabei nicht geschlossen. Das Verhalten bei Fehlern in der Mitte eines Stapels:

Ausgabe: IntegrityError: NOT NULL constraint failed: users.name, dann 3 (auch die erste Zeile Di wurde zurückgerollt), dann OperationalError: no such table: gibtsnicht.

Schritt 4: CSV lesen und schreiben (Ziel 5.2)

csv.reader liefert Listen, csv.DictReader Dicts (die erste Zeile gibt die Schlüssel). Beim Schreiben entsprechend csv.writer und csv.DictWriter (braucht fieldnames, writeheader() schreibt die Kopfzeile). Dateien öffnest du mit newline="". Hier nehmen wir io.StringIO, einen Text im Speicher, der sich wie eine Datei verhält. Der Text hat ein Semikolon als Trennzeichen (delimiter=";") und ein Feld, das selbst ein Semikolon enthält. Solche Felder stehen in Anführungszeichen:

Ausgabe:

['name', 'age']
['Ada', '36']
['Müller; Bob', '40']
{'name': 'Ada', 'age': '36'}
{'name': 'Müller; Bob', 'age': '40'}
'name,age\r\nAda,36\r\n"Bob, ""B""",40\r\n'

Drei Beobachtungen:

  • Gelesene Werte sind immer Strings, auch '36'. Zahlen musst du selbst mit int() umwandeln.
  • Der Writer setzt Felder mit Trennzeichen, Anführungszeichen oder Zeilenumbruch automatisch in Anführungszeichen und verdoppelt innere Anführungszeichen: "Bob, ""B""". Darum ist selbst gebautes ",".join(...) unsicher.
  • Der Standard-Trenner ist das Komma. Für Semikolon-Dateien (typisch bei deutschen Excel-Exporten) brauchst du delimiter=";". Weitere Optionen: quotechar und quoting (csv.QUOTE_ALL, csv.QUOTE_MINIMAL, csv.QUOTE_NONNUMERIC, csv.QUOTE_NONE).

Schritt 5: XML mit ElementTree (Ziel 5.3)

Die Grundaufrufe kennst du schon aus Lektion 16 (Schritt 5), hier kommen Suchpfade, Bauen und Fallen dazu. ET.fromstring(s) parst einen String und gibt die Wurzel (root) als Element. ET.parse(datei) parst eine Datei und gibt einen ElementTree, getroot() liefert die Wurzel. Ein Element hat tag, attrib (Dict), text und tail. Suche mit Pfaden (eine Teilmenge von XPath):

Ausgabe:

['1', '2']
['1', '2', '3']
['1', '2', '3']
None
?
<class 'str'>

Das book mit der ID 3 liegt eine Ebene tiefer, findall("book") sieht es nicht. ".//book" und iter("book") finden es.

Elemente baust du mit ET.Element(tag) und ET.SubElement(eltern, tag). Ungültiges XML löst ET.ParseError aus:

Ausgabe: <library><book id="1"><title>Python &amp; Co</title></book></library> und ParseError: mismatched tag: line 1, column 8. Das & wurde automatisch zu &amp; maskiert. Wohlgeformt (well-formed) ist ein Dokument, wenn es geparst werden kann. Das heißt nicht, dass der Inhalt für deine Anwendung gültig ist.

Falle: die Prüfungs- und Praxisklassiker

  1. find liefert None, wenn nichts passt. root.find("x").text ist dann ein AttributeError. Nimm findtext(pfad, default=...) oder prüfe auf None.
  2. findall("book") sucht nur direkte Kinder. Für beliebige Tiefe: ".//book" oder iter("book").
  3. Alle CSV-Werte und alle XML-Attribute und -Texte sind Strings. "36" + 1 ist ein TypeError, "36" > "4" ist False.
  4. ("Ada") ist kein Tupel. Parameter brauchen ("Ada",). Mit einem nackten String zählt sqlite3 die Zeichen als einzelne Parameter (ProgrammingError, falsche Anzahl).
  5. f-Strings in SQL sind eine Sicherheitslücke. Auch “selbst escapen” mit replace("'", "''") ist fehleranfällig. Platzhalter sind der vorgesehene Weg.
  6. Ohne commit() ist nichts gespeichert. with conn: committet, schließt aber nicht.
  7. CSV ohne newline="" geöffnet erzeugt unter Windows leere Zeilen.
  8. fetchall() nach fetchone() liefert nur den Rest, nicht alles.

Übungen

Übung 1: Cursor-Verhalten vorhersagen (ca. 6 Min.)

Ein Cursor liest Zeile für Zeile. Welche Werte stehen am Ende in antwort? Trage das Tupel (a, b, c, d, rowcount) ein, ohne den Code auszuführen. a, b, c, d sind genau die Werte, die in den gleichnamigen Variablen stehen würden, rowcount die Zahl aus dem UPDATE.

Welche Zeilen trifft die WHERE-Bedingung, und in welcher Reihenfolge kommen sie? Dann: Was hat der Cursor nach jedem Aufruf schon “verbraucht”? Und was zählt rowcount bei einem UPDATE ohne WHERE?

antwort = (("Tee",), [("Kaffee",)], [("Kakao",)], None, 4)
antwort

Drei Zeilen passen (Tee 3.5, Kaffee 4.0, Kakao 4.5). Jeder Aufruf liest weiter, wo der vorige aufgehört hat: fetchone gibt das Tupel ('Tee',), fetchmany(1) eine Liste mit einem Tupel, fetchall den Rest, danach ist nichts mehr da (None). Das UPDATE ohne WHERE trifft alle 4 Zeilen.

Übung 2: SQL-Injection schließen (ca. 10 Min.)

Die Funktion finde sucht Konten nach Namen. Sie steckt den Namen per f-String ins SQL. Repariere sie: Der Name darf nie Teil des SQL-Textes werden, sondern wird als Parameter übergeben. Die Rückgabe bleibt eine Liste von Tupeln (id, rolle). Die Prüfung testet normale Namen, O'Brien, zwei Injection-Versuche und einen Versuch, die Tabelle zu löschen. Sie schaut auch, was an die Datenbank geschickt wurde.

Wo steht im SQL-Text das, was du von außen bekommst? Die Datenbank kann SQL und Werte getrennt entgegennehmen. Achte auf die Form des zweiten Arguments.

def finde(con, name):
    return con.execute(
        "SELECT id, rolle FROM konten WHERE name = ?", (name,)
    ).fetchall()

finde

Der Platzhalter ? steht für einen Wert, den die Datenbank nie als SQL liest. (name,) ist ein Tupel mit einem Element. Der Angriffs-String ist dann nur ein Name, den es nicht gibt.

Übung 3: Bericht mit row_factory, ORDER BY, LIMIT (ca. 10 Min.)

Die Tabelle personen hat die Spalten id, name, age. age kann NULL sein (unbekanntes Alter). Schreibe top_n(con, n): Es gibt die n ältesten Personen als Liste von Dicts {"name": ..., "age": ...} zurück. Älteste zuerst, bei gleichem Alter alphabetisch nach Name, unbekanntes Alter (NULL, in Python None) ganz am Ende. Ist n größer als die Zahl der Zeilen, kommen alle. Bei n = 0 kommt eine leere Liste. Die Daten dürfen nicht verändert werden.

Zwei Teilaufgaben: Die Datenbank soll sortieren und begrenzen (zwei Schlüsselwörter, für die Sortierung brauchst du zwei Kriterien). Danach müssen aus Tupeln Dicts werden, entweder über die Zeilenfabrik der Verbindung oder von Hand. Wo landet NULL bei absteigender Sortierung in SQLite?

def top_n(con, n):
    con.row_factory = sqlite3.Row
    zeilen = con.execute(
        "SELECT name, age FROM personen ORDER BY age DESC, name ASC LIMIT ?", (n,)
    ).fetchall()
    return [dict(z) for z in zeilen]

top_n

SQLite behandelt NULL als kleinsten Wert, bei DESC landet es also ganz hinten. LIMIT ? nimmt einen Platzhalter, dict(row) macht aus einer sqlite3.Row ein Dict.

Übung 4: CSV lesen und schreiben (ca. 12 Min.)

Zwei Funktionen für Semikolon-CSV mit den Spalten name und age. Nutze das Modul csv und io.StringIO, nicht split.

  • lese_personen(text): gibt eine Liste von Dicts {"name": str, "age": int} zurück. Die erste Zeile ist die Kopfzeile. Das Alter muss ein int sein.
  • schreibe_personen(zeilen): bekommt eine Liste solcher Dicts und gibt den CSV-Text (Kopfzeile plus Zeilen, Trennzeichen ;) als String zurück. Namen mit Semikolon oder Anführungszeichen müssen beim Zurücklesen wieder genauso herauskommen.

Zum Lesen: Welche Klasse macht aus jeder Zeile ein Dict, und welches Argument ändert den Trenner? Gelesen wird immer Text, also muss das Alter noch umgewandelt werden. Zum Schreiben: Ein StringIO als Ziel, und dieselbe Option für den Trenner. Wer schreibt dabei die Kopfzeile?

import csv
import io

def lese_personen(text):
    reader = csv.DictReader(io.StringIO(text), delimiter=";")
    return [{"name": z["name"], "age": int(z["age"])} for z in reader]

def schreibe_personen(zeilen):
    buf = io.StringIO()
    writer = csv.DictWriter(buf, fieldnames=["name", "age"], delimiter=";")
    writer.writeheader()
    writer.writerows(zeilen)
    return buf.getvalue()

lese_personen, schreibe_personen

Der Reader und der Writer kümmern sich um Anführungszeichen, Trennzeichen im Feld und Zeilenumbrüche im Feld. Mit split(";") ginge das schief.

Übung 5: Fehler finden im XML-Katalog (ca. 10 Min.)

Die Funktion katalog soll aus einem XML-Dokument ein Dict {id: titel} aller book-Elemente bauen, egal wie tief sie im Dokument stehen. Die ID bleibt ein String (wie im Dokument). Der Titel wird von Leerzeichen am Rand befreit. Fehlt das title-Element oder ist der Titel leer oder nur Leerzeichen, steht "?" im Dict. Ist das Dokument kein gültiges XML, gibt die Funktion None zurück. Der Code unten hat drei Fehler. Finde und behebe sie.

Teste im Kopf drei Dokumente: ein Buch ohne title, ein Buch in einem shelf-Element, und ein kaputtes Dokument. Was passiert jeweils? Und was liefert der Text eines leeren <title></title>?

import xml.etree.ElementTree as ET

def katalog(xml_text):
    try:
        root = ET.fromstring(xml_text)
    except ET.ParseError:
        return None
    ergebnis = {}
    for buch in root.findall(".//book"):
        titel = (buch.findtext("title") or "").strip()
        ergebnis[buch.get("id")] = titel or "?"
    return ergebnis

katalog

Fehler 1: findall("book") sieht nur direkte Kinder, ".//book" auch tiefere. Fehler 2: find("title") gibt None, wenn das Element fehlt, dann ist .text ein AttributeError. findtext gibt None (oder einen Default) zurück, und bei einem leeren Element "". Daher or "", strip() und or "?". Fehler 3: kein try/except ET.ParseError.

Übung 6: Multiple Choice mit Begründung (ca. 6 Min.)

Ein Kassensystem liefert dieses Dokument als String xml_text:

<kasse>
  <bon nr="7">
    <posten><artikel>Tee</artikel></posten>
  </bon>
</kasse>

Mit root = ET.fromstring(xml_text) willst du den Text "Tee" bekommen. Welche Aussage stimmt?

  • A root.findtext(".//artikel") gibt "Tee": Der Pfad sucht den Namen in allen Ebenen unter der Wurzel.
  • B root.find("artikel").text gibt "Tee": find sucht automatisch in allen Ebenen unter der Wurzel.
  • C root.get("artikel") gibt "Tee": get liest den Inhalt des ersten passenden Kindes.
  • D root.iter("artikel") gibt "Tee": iter liefert direkt den Text des ersten Treffers.

Vergleiche für jede Option: Wie tief sucht der Aufruf, und was gibt er zurück (Element, Text, Attributwert oder Iterator)?

antwort = "A"
antwort

.//artikel heißt: in jeder Tiefe unterhalb der Wurzel. findtext gibt den Text des ersten Treffers. B scheitert, weil find("artikel") nur direkte Kinder von kasse prüft (dort ist bon) und None liefert, dann ist .text ein AttributeError.

Merksatz

Werte gehören als Parameter an die Datenbank, nie in den SQL-Text. Bei CSV und XML ist alles Text, find kann None liefern, und Sonderzeichen überlässt du dem Reader und dem Writer.

Prüfstein

Ein Kollege baut eine Suche mit f"SELECT ... WHERE name = '{eingabe}'" und sagt: “Ich ersetze einfach jedes Apostroph durch zwei.” Warum ist das trotzdem der falsche Weg, was machst du stattdessen, und was gilt für dynamische Tabellen- oder Spaltennamen, bei denen Platzhalter nicht funktionieren?


Quelle: quellen/python-glossar-pcap-pcpp1.md, Abschnitt “PCPP1 Block 5: Dateiverarbeitung und Umgebung”, Unterabschnitte 5.1 (sqlite3), 5.2 (csv), 5.3 (xml.etree.ElementTree). Die Prüfungsgewichtung (15 %, 7 Fragen) steht dort; vor der Buchung bitte prüfen. Die Aussagen zur Allowlist für dynamische Bezeichner und zum Verhalten von NULL bei ORDER BY ... DESC sind allgemeines SQL-Wissen, nicht aus der Quelle (bitte prüfen).