Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Datenverarbeitung und Validierung

Heinrich-Heine-Universität Düsseldorf

In realen Projekten machen das Laden, Bereinigen und Validieren von Daten oft den Großteil des Codes aus.

Worum es geht. Wie Daten in ein Programm hinein und wieder heraus kommen — zuerst die vier Bausteine der Standardbibliothek, dann tabellarische Formate und Abfragen, dann Validierung an der Systemgrenze mit Pydantic, und zuletzt strukturierte Antworten von Sprachmodellen.

Was Sie danach können. Ein Format begründet wählen, statt json aus Gewohnheit zu nehmen; einen dataclass-Spielstand verlustfrei speichern und laden; entscheiden, wann Prüfung zur Laufzeit nötig ist und wann der Type Checker genügt; ein Pydantic-Modell mit eigenen Regeln schreiben und Konfiguration aus Umgebungsvariablen und .env-Datei einlesen.

Die Java-Entsprechung in einem Satz. sqlite3 ist JDBC ohne Server, und wo Java Jackson-Annotationen an eine Klasse hängt, leitet Pydantic dasselbe aus den Type Hints ab — nur dass es zur Laufzeit prüft, was Javas Compiler bereits vorher wusste.

!uv venv --quiet
!uv sync --quiet
!uv pip --quiet install "omegaconf" "jmespath" "pydantic[email]" "validators" "pydantic-ai" "huggingface_hub" "llama-cpp-python"

Die Basisformate aus der Standardbibliothek

Bevor wir zu Validierung, Abfragesprachen und Spezialbibliotheken kommen: Vier Bausteine der Standardbibliothek decken den Großteil dessen ab, was eine Anwendung an Daten wirklich bewegen muss — json für den Austausch, tomllib für Konfiguration, sqlite3 für alles, was nach Datenbank riecht, und pickle für den internen Kurzschluss. Man braucht dafür nichts zu installieren, und für die Spielstände aus den Übungsblättern 2 und 4 reicht es vollständig.

JSON

json ist das Austauschformat: von jeder Sprache lesbar, von Menschen notfalls auch. Ein dataclass-Spielstand wird mit dataclasses.asdict zu einem Dictionary und damit schreibbar.

import json
from dataclasses import dataclass, field, asdict

@dataclass
class Spielstand:
    szene: str
    inventar: list[str] = field(default_factory=list)
    leben: int = 3

stand = Spielstand("Höhle", ["Fackel", "Seil"])

with open("spielstand.json", "w", encoding="utf-8") as f:
    json.dump(asdict(stand), f, indent=2, ensure_ascii=False)

print(open("spielstand.json", encoding="utf-8").read())
{
  "szene": "Höhle",
  "inventar": [
    "Fackel",
    "Seil"
  ],
  "leben": 3
}

Zwei Argumente lohnen sich fast immer. indent=2 macht die Datei zeilenweise lesbar — und damit in Git sinnvoll diffbar, was bei Spielständen im Repository den Unterschied zwischen “eine Zeile hat sich geändert” und “die Datei hat sich geändert” ausmacht. ensure_ascii=False schreibt Umlaute als Umlaute; ohne das Argument steht dort H\u00f6hle, was zwar korrekt, aber unlesbar ist.

Der Rückweg:

with open("spielstand.json", encoding="utf-8") as f:
    roh = json.load(f)

geladen = Spielstand(**roh)
print(geladen)
print(geladen == stand)
Spielstand(szene='Höhle', inventar=['Fackel', 'Seil'], leben=3)
True

json.dump/json.load arbeiten auf Dateiobjekten, json.dumps/json.loads auf Strings — das s steht für string, nicht für Plural.

Die Typabbildung ist dabei verlustbehaftet, und das ist die häufigste Fehlerquelle:

print(json.dumps({"schluessel": ("a", "b"), "leer": None, "ok": True}))
print(json.dumps({"ort": "Höhle"}), json.dumps({"ort": "Höhle"}, ensure_ascii=False))
{"schluessel": ["a", "b"], "leer": null, "ok": true}
{"ort": "H\u00f6hle"} {"ort": "Höhle"}

Aus dem Tupel ist eine Liste geworden. JSON kennt nur object, array, string, number, true/false und null — keine Tupel, keine Mengen, keine Datumswerte, keine eigenen Klassen. Ein datetime löst beim Schreiben einen TypeError aus; json.dump(..., default=str) ist die schnelle Notlösung, sauberer ist ein Schema, das das Umwandeln in beide Richtungen beschreibt (siehe Pydantic weiter unten).

Auch Spielstand(**roh) oben ist bewusst naiv: Es funktioniert, solange alle Felder einfache Werte sind. Sobald eine Dataclass eine andere Dataclass enthält, kommt aus dem JSON ein verschachteltes Dictionary zurück, und man baut sich die Umwandlung entweder von Hand — oder lässt sie sich von Pydantic erzeugen.

TOML

Für Konfiguration, die Menschen von Hand schreiben, hat sich TOML durchgesetzt; Sie kennen es bereits als Format der pyproject.toml. Seit Python 3.11 ist ein Parser in der Standardbibliothek, und das Skript kann seine eigene Projektdatei lesen:

import tomllib

with open("../pyproject.toml", "rb") as f:     # tomllib liest Bytes, nicht Text
    projekt = tomllib.load(f)

print(projekt["project"]["name"], "|", projekt["project"]["requires-python"])
print(len(projekt["project"]["dependencies"]), "Abhängigkeiten deklariert")
eipy-skript | >=3.13
54 Abhängigkeiten deklariert

Zwei Eigenheiten: tomllib öffnet die Datei im Binärmodus ("rb"), weil TOML immer UTF-8 ist und der Parser das Dekodieren selbst übernimmt — mit "r" gibt es einen TypeError. Und tomllib kann nur lesen. Zum Schreiben braucht man tomli-w oder, wenn Kommentare und Formatierung erhalten bleiben sollen, tomlkit.

Damit haben wir drei Konfigurationsformate nebeneinander: TOML, wenn Menschen die Datei pflegen (flache Struktur, Kommentare, keine Einrückungsfallen); YAML, wenn tiefe Hierarchien nötig sind (siehe unten, und Hydra im Testing-Kapitel); JSON, wenn Programme sich untereinander verständigen.

SQLite

Sobald Daten wachsen, gleichzeitig gelesen und geschrieben oder gesucht statt nur geladen werden sollen, ist eine Datei mit JSON die falsche Antwort. sqlite3 ist in der Standardbibliothek: eine vollständige SQL-Datenbank in einer einzigen Datei, ohne Server und ohne Installation. Wer in Java JDBC kennt, findet dasselbe Modell wieder — Connection, Cursor, Platzhalter im SQL —, allerdings ohne Treiber-JAR und ohne Verbindungs-URL.

import sqlite3

with sqlite3.connect("spielstand.db") as con:
    con.execute("CREATE TABLE IF NOT EXISTS besuche (szene TEXT PRIMARY KEY, anzahl INTEGER)")
    for szene in ["Höhle", "Wald", "Höhle"]:
        con.execute(
            "INSERT INTO besuche VALUES (?, 1) "
            "ON CONFLICT(szene) DO UPDATE SET anzahl = anzahl + 1",
            (szene,),
        )
    for zeile in con.execute("SELECT szene, anzahl FROM besuche ORDER BY anzahl DESC"):
        print(zeile)
con.close()
('Höhle', 2)
('Wald', 1)

Die Fragezeichen sind nicht Kosmetik: Sie sind der Grund, warum hier keine SQL-Injection möglich ist. f"... WHERE szene = '{szene}'" ist der klassische Weg, sich eine Sicherheitslücke einzubauen — dieselbe Regel wie bei PreparedStatement in Java.

Eine Falle, die regelmäßig übersehen wird: with sqlite3.connect(...) ist ein Kontextmanager für die Transaktion, nicht für die Verbindung. Am Ende des Blocks wird commit() (oder bei einer Exception rollback()) aufgerufen — geschlossen wird die Verbindung nicht. Deshalb steht oben ein explizites con.close().

Ab Python 3.12 gibt es sqlite3 auch als Kommandozeilenwerkzeug (python -m sqlite3 spielstand.db), praktisch, um beim Debuggen schnell in die eigene Datei zu schauen.

Pickle

pickle serialisiert fast jedes Python-Objekt direkt, ohne dass man ein Format beschreiben muss:

import pickle

als_bytes = pickle.dumps(stand)
print(type(als_bytes), len(als_bytes), "Bytes")
print(pickle.loads(als_bytes))
<class 'bytes'> 104 Bytes
Spielstand(szene='Höhle', inventar=['Fackel', 'Seil'], leben=3)

Das ist bequem — und gefährlich. Ein Pickle ist kein Datenformat, sondern ein kleines Programm: Beim Laden wird ausgeführt, was darin steht. Wie wenig dazu nötig ist, zeigt dieses Beispiel; im Quelltext steht kein einziger Aufruf, und trotzdem passiert etwas:

class HarmlosAussehend:
    def __reduce__(self):
        return (print, ("Beim Laden wurde fremder Code ausgeführt.",))

praeparierte_datei = pickle.dumps(HarmlosAussehend())
pickle.loads(praeparierte_datei)   # kein Aufruf im Quelltext, trotzdem passiert etwas
Beim Laden wurde fremder Code ausgeführt.

Statt print stünde in einem echten Angriff ein Aufruf, der Dateien löscht oder eine Verbindung nach außen öffnet. Deshalb die Regel ohne Ausnahme: Niemals ein Pickle laden, das man nicht selbst erzeugt hat. Für Spielstände, die Studierende untereinander austauschen, ist pickle damit disqualifiziert, so verlockend die eine Zeile Code auch ist. Sinnvoll bleibt es innerhalb eines Programms, etwa um einen Zwischenstand zwischen zwei Prozessen zu übergeben (multiprocessing benutzt es intern genau dafür).

Was wofür

Formatgut fürGrenze
jsonAustausch, Spielstände, APIsnur sechs Typen; kein Datum, kein Tupel, keine Menge
tomllibKonfiguration von Handliest nur; für tiefe Verschachtelung unhandlich
YAMLhierarchische KonfigurationEinrückung ist Syntax; safe_load benutzen
sqlite3suchen, ändern, viele Datensätzeerfordert SQL und ein Schema
csvflache Tabellen, Excel-Nachbarschaftalles ist ein String, keine Typen
pickleObjekte innerhalb eines Programmsunsicher, Python-spezifisch, versionsabhängig

Mit diesen Bausteinen im Rücken sehen wir uns nun die tabellarischen Formate und anschließend die Validierung an.

CSV

Für flache, tabellarische Daten sind CSV und Excel der Standard. Während csv in der Standardbibliothek ist, benötigt Excel externe Hilfe (openpyxl oder pandas).

import csv

# CSV schreiben
with open("users.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["id", "name", "email"])
    writer.writeheader()
    writer.writerow({"id": 1, "name": "Alice", "email": "alice@example.com"})

# CSV lesen
with open("users.csv", encoding="utf-8") as f:
    reader = csv.DictReader(f)
    for row in reader:
        print(f"Geladen: {row['name']}")

# Hinweis zu Excel:
# import pandas as pd
# df = pd.read_excel('data.xlsx') # Benötigt openpyxl
Geladen: Alice

Konfigurationsmanagement

YAML ist ideal für hierarchische Konfigurationen. OmegaConf (sieh den Abschnitt zu Hydra im Testing-Kapitel) bietet zusätzlich einen objektorientierten Zugriff und Merging-Funktionen.

import yaml
from omegaconf import OmegaConf

yaml_data = """
server:
  port: 8080
  host: localhost
database:
  driver: postgres
"""

# Mit pyyaml laden
config_dict = yaml.safe_load(yaml_data)

# Mit OmegaConf für hierarchisches Management nutzen
conf = OmegaConf.create(config_dict)
print(f"Port: {conf.server.port}")

# Mergen von Defaults und Overrides
overrides = OmegaConf.create({"server": {"port": 9000}})
final_conf = OmegaConf.merge(conf, overrides)
print(f"Neuer Port: {final_conf.server.port}")
Port: 8080
Neuer Port: 9000

JSON Queries mit jmespath

Wenn REST-APIs tief verschachtelte JSON-Strukturen liefern, wird der Zugriff über Dictionaries (data['a']['b'][0]['c']) schnell unübersichtlich. jmespath erlaubt deklarative Abfragen.

import jmespath

complex_json = {
    "users": [
        {"id": 1, "info": {"email": "alice@example.com", "active": True}},
        {"id": 2, "info": {"email": "bob_at_example.com", "active": False}},
        {"id": 3, "info": {"email": "charlie@example.com", "active": True}},
    ]
}

# Extrahiere Emails aller aktiven Nutzer
query = "users[?info.active == `true`].info.email"
active_emails = jmespath.search(query, complex_json)
print(f"Aktive Emails: {active_emails}")
Aktive Emails: ['alice@example.com', 'charlie@example.com']

Datenvalidierung mit Pydantic

Pydantic ist das Standardwerkzeug für Datenvalidierung. Im Gegensatz zu dataclasses erzwingt Pydantic Typen zur Laufzeit und führt automatisches Casting (Coercion) durch.

Abgrenzung zu Dataclasses

  • Dataclasses: Primär zur Reduktion von Boilerplate (Record-Typen). Keine Laufzeitvalidierung.

  • Pydantic: Validierung, Parsing, Fehlerbehandlung.

Tutorial: Pydantic Get Started

Pydantic ist die Antwort auf eine Lücke, die die Dataclass offen lässt: Deren Typannotationen werden zur Laufzeit nicht geprüft — Gegenstand("ding", "fünf") läuft durch. Solange die Daten aus dem eigenen Code kommen, genügt das; sobald sie aus einer Datei, einem Formular oder einer API kommen, nicht mehr.

from pydantic import BaseModel, EmailStr, Field, ValidationError


class User(BaseModel):
    id: int
    name: str = Field(min_length=2)
    email: EmailStr


try:
    # Beachte: '1' (String) wird automatisch zu 1 (int) konvertiert
    user = User(id="1", name="Alice", email="alice@example.com")
    print(user.model_dump())
except ValidationError as e:
    print(e.json())
{'id': 1, 'name': 'Alice', 'email': 'alice@example.com'}

Eigene Regeln mit field_validator

Field(min_length=2) deckt ab, was sich als Zahl ausdrücken lässt. Für alles andere gibt es @field_validator — eine Methode, die einen Feldwert entgegennimmt, ihn prüft und zurückgibt. Wirft sie einen ValueError, wird daraus ein Eintrag im Validierungsbericht.

Dazu die zweite wichtige Methode: model_validate_json parst und validiert in einem Schritt. Der Unterschied zu Modell(**json.loads(text)) ist nicht nur Bequemlichkeit — Pydantic V2 liest das JSON in Rust und weiß dabei schon, welche Typen es erwartet, statt erst ein Dictionary zu bauen und es dann zu prüfen.

from pydantic import BaseModel, Field, ValidationError, field_validator


class Spielstand(BaseModel):
    spieler: str = Field(min_length=2, max_length=20)
    szene: str
    inventar: list[str] = Field(default_factory=list)

    @field_validator("spieler")
    @classmethod
    def ohne_randleerzeichen(cls, v: str) -> str:
        if v != v.strip():
            raise ValueError("darf nicht mit Leerzeichen beginnen oder enden")
        return v

    @field_validator("inventar")
    @classmethod
    def ohne_duplikate(cls, v: list[str]) -> list[str]:
        if len(set(v)) != len(v):
            raise ValueError("Gegenstaende duerfen nicht doppelt vorkommen")
        return v


rohdaten = '{"spieler": "Alice", "szene": "hoehle", "inventar": ["fackel", "seil"]}'
stand = Spielstand.model_validate_json(rohdaten)
print(stand)
print(stand.model_dump_json())
spieler='Alice' szene='hoehle' inventar=['fackel', 'seil']
{"spieler":"Alice","szene":"hoehle","inventar":["fackel","seil"]}

Und der Fehlerfall, mit zwei Verstößen gleichzeitig:

try:
    Spielstand.model_validate_json(
        '{"spieler": " Bob ", "szene": "wald", "inventar": ["seil", "seil"]}'
    )
except ValidationError as e:
    print(e)
2 validation errors for Spielstand
spieler
  Value error, darf nicht mit Leerzeichen beginnen oder enden [type=value_error, input_value=' Bob ', input_type=str]
    For further information visit https://errors.pydantic.dev/2.13/v/value_error
inventar
  Value error, Gegenstaende duerfen nicht doppelt vorkommen [type=value_error, input_value=['seil', 'seil'], input_type=list]
    For further information visit https://errors.pydantic.dev/2.13/v/value_error

Beachten Sie die erste Zeile: 2 validation errors. Pydantic bricht nicht beim ersten Problem ab, sondern sammelt alle und meldet sie zusammen, jeweils mit Feldnamen und dem tatsächlich gelieferten Wert. Für ein Formular oder eine API ist das der Unterschied zwischen einer brauchbaren Rückmeldung und fünf Runden Ausprobieren. Maschinenlesbar bekommt man dieselbe Liste mit e.errors(), als JSON mit e.json().

Zwei Verwandte von field_validator, die man kennen sollte:

  • @model_validator(mode="after") prüft das fertige Modell und kommt damit an alle Felder gleichzeitig — nötig für Regeln, die mehrere Felder betreffen (“szene muss in bekannte_szenen vorkommen”).

  • mode="before" lässt einen den Rohwert anfassen, bevor Pydantic ihn in den Zieltyp umwandelt. Damit normalisiert man Eingaben, etwa ein Datum in drei akzeptierten Schreibweisen.

Die Reihenfolge der Dekoratoren ist übrigens nicht beliebig: @field_validator steht über @classmethod. Andersherum sieht es genauso plausibel aus und funktioniert nicht.

Konfiguration aus der Umgebung: pydantic-settings

Im Kapitel Input und Output haben wir Konfiguration über os.environ gelesen — mit try/except KeyError und einer Umwandlung von Hand für jeden Wert, der keine Zeichenkette ist. pydantic-settings macht daraus eine Modellklasse: Feldnamen werden auf Umgebungsvariablen abgebildet, Typen umgewandelt, Fehler gesammelt.

%%writefile spiel.env
SPIEL_DATENBANK=spielstand.db
SPIEL_MAX_SPIELER=4
Writing spiel.env
%%writefile settings_demo.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Einstellungen(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="SPIEL_", env_file="spiel.env")

    datenbank: str
    max_spieler: int = Field(default=2, ge=1, le=8)
    debug: bool = False


print(Einstellungen())
Writing settings_demo.py
!uv run python settings_demo.py
print("--- Umgebungsvariablen schlagen die .env-Datei ---")
!SPIEL_MAX_SPIELER=8 SPIEL_DEBUG=true uv run python settings_demo.py
datenbank='spielstand.db' max_spieler=4 debug=False
--- Umgebungsvariablen schlagen die .env-Datei ---
datenbank='spielstand.db' max_spieler=8 debug=True

Drei Dinge passieren hier, die man sonst selbst schreiben müsste:

  • Rangfolge. Ein echter Umgebungsvariablenwert gewinnt gegen die Datei, die Datei gewinnt gegen den Default im Modell. Genau diese Reihenfolge braucht man in der Praxis: Datei für die Entwicklung, Umgebungsvariablen in Container und CI.

  • Umwandlung. SPIEL_MAX_SPIELER=8 kommt als Zeichenkette an und wird zu int, SPIEL_DEBUG=true zu True — ebenso 1, yes, on. Wer das von Hand macht, schreibt beim dritten Feld den ersten Fehler.

  • Prüfung. ge=1, le=8 gilt auch hier. SPIEL_MAX_SPIELER=99 führt zu einem ValidationError beim Start — und nicht zu einem Fehlverhalten irgendwann später.

Ein Hinweis zur Sicherheit: Die .env-Datei ist der übliche Ort für API-Schlüssel und gehört deshalb in die .gitignore — nicht ins Repository. Ins Repository gehört eine .env.example mit den Namen und Beispielwerten, aber ohne Geheimnisse. (Die Datei heißt hier spiel.env, damit das Notebook keine echte .env überschreibt.) Dasselbe gilt gegenüber Coding-Agenten, die im Projektverzeichnis arbeiten — siehe Berechtigungen und Sandbox.

Was nehme ich wofür?

Vier Werkzeuge beschreiben in Python die Struktur von Daten, und sie lösen verschiedene Aufgaben. Die häufigste Fehlentscheidung ist, Pydantic überall zu nehmen — es kostet eine Abhängigkeit und Laufzeit pro Objekt.

dictTypedDict@dataclassBaseModel (Pydantic)
Prüfung zur Laufzeitkeinekeinekeineja, mit Umwandlung
Prüfung durch Mypykeinejajaja
Zugriffd["a"]d["a"]o.ao.a
Kosten pro Objektkeinekeinegeringmessbar
Abhängigkeit–––pydantic
Zur Hand, wennDaten kurzlebig durchgeschoben werdenbestehender dict-Code Typen bekommen soll, ohne ihn umzubaueneigene Daten im eigenen Code strukturiert werdenDaten von außen kommen

Die Entscheidungsregel steht in der letzten Zeile: Wo die Daten herkommen, entscheidet. Kommen sie aus Ihrem eigenen Code, hat sie der Type Checker schon geprüft, und eine dataclass genügt — eine zweite Prüfung zur Laufzeit hätte nichts zu finden. Kommen sie aus einer Datei, einem Formular, einer HTTP-Anfrage oder von einem Sprachmodell, weiß niemand, was drin steht, und Pydantic ist die richtige Antwort.

In der Praxis kombiniert man beides in einer Schicht-Architektur: Pydantic-Modelle am Rand des Systems, wo die Daten ankommen, und dataclass-Objekte im Kern, wo gerechnet wird. Genau so ist es in FastAPI angelegt, dessen Request-Modelle Pydantic-Modelle sind, und genau diesen Schnitt zeigt auch die refaktorisierte Wirtschaftssimulation — nur ohne Pydantic, weil dort keine Daten von außen kommen.

Oxidization: Pydantic V2

Pydantic V2 wurde in Rust neu geschrieben (pydantic-core). Dieser Prozess (“Oxidization”) macht die Validierung um Faktoren schneller und zeigt den Trend im Python-Ökosystem: Performance-kritische Teile werden in Rust implementiert, während die API in Python bleibt.

Weitere Validierungstools

  • Validators: Für einfache Checks ohne Objektmodell.

  • Great Expectations (GX): Für die Validierung ganzer Daten-Pipelines (Data Quality).

import validators

# Einfacher Check
email_ok = validators.email("someone@example.com")
print(f"Email valid: {email_ok}")
Email valid: True

Serialisierung über Prozessgrenzen: Protobuf

pickle haben wir oben gesehen: bequem, aber Python-spezifisch und nicht sicher gegenüber fremden Daten. Sobald zwei Dienste in verschiedenen Sprachen miteinander reden, braucht man ein Format mit Schema.

  • Protobuf: Sprachübergreifend, performant, Schema-basiert — aus einer .proto-Datei werden Klassen für Python, Java, Go usw. erzeugt. Ideal für Microservices und die Grundlage von gRPC.

  • JSON bleibt die pragmatische Alternative, solange Nachrichtenmenge und Latenz keine Rolle spielen: Es braucht keinen Generierungsschritt, und man kann eine Nachricht im Zweifel lesen.

Strukturierte KI-Datentransformation mit pydantic-ai

Ein grundlegendes Problem beim Einsatz von LLMs in produktiver Software ist die Unvorhersehbarkeit des Text-Outputs. Wenn eine API strukturierte Daten (wie JSON) erwartet, reicht ein normaler Prompt oft nicht aus, um Typ-Sicherheit zu garantieren.

Hier setzt pydantic-ai an. Es nutzt die Bibliothek pydantic, um Agenten zu zwingen, Antworten exakt nach einem vorgegebenen Schema zurückzugeben. Das Framework validiert den Output des LLMs automatisch; ist er fehlerhaft, bittet pydantic-ai das LLM um Korrektur.

Kernkonzepte:

  • Type-Safe: Die Rückgabe der KI ist kein einfacher String mehr, sondern ein instanziiertes Pydantic-Objekt.

  • Model Agnostic: Es lässt sich mit Modellen von Google, OpenAI oder lokalen Modellen nutzen.

  • Tools als Funktionen: Man kann dem Agenten reguläre Python-Funktionen als Werkzeuge mitgeben, die er bei Bedarf aufruft.

Beispiel mit Hilfe einer (bezahlten) API

%%writefile pydantic_ai_example.py
from pydantic import BaseModel
from pydantic_ai import Agent

class UserProfile(BaseModel):
    name: str
    age: int
    hobbies: list[str]

agent = Agent(
    'google-gla:gemini-2.5-flash',
    output_type=UserProfile,
    system_prompt="Extrahiere die Benutzerdaten aus dem Text."
)

# 3. Ausführung (In einem echten Skript ggf. asynchron)
result = agent.run_sync("Das ist Thomas, er ist 35 Jahre alt und geht gerne wandern und liest Science-Fiction.")
print(result.output.hobbies) # Output: ['Wandern', 'Science-Fiction']
Writing pydantic_ai_example.py

Dieser Code führt zu einem Fehler, wenn kein Google-API-Key konfiguriert wurde:

!uv run python pydantic_ai_example.py

Testing mit Mockup

Wir können natürlich mit Mockups arbeiten:

%%writefile pydantic_ai_mockup.py
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
from pydantic import BaseModel

class UserProfile(BaseModel):
    name: str
    age: int
    hobbies: list[str]

agent = Agent(
    TestModel(), 
    output_type=UserProfile,
    system_prompt="Extrahiere die Benutzerdaten aus dem Text."
)

result = agent.run_sync("Das ist Thomas, er ist 35 Jahre alt...")
print(result.output)
Writing pydantic_ai_mockup.py
!uv run python pydantic_ai_mockup.py
name='a' age=0 hobbies=['a']

Wenn man anstelle des Mockups ein richtiges LLM (oder auch ein relativ kleines mit einigen hundert MB, siehe unten) einsetzt, generiert das LLM einen JSON-Output, der von Pydantic in ein Python-Objekt umgewandelt wird. Das sähe dann so aus:

name='Thomas' age=35 hobbies=[]

Beispiel mit kleinem lokalen Modell

Wir nutzen llama.cpp über llama-cpp-python und laden ein Modell (bezogen über Huggingface) lokal:

%%writefile downloader.py
from huggingface_hub import hf_hub_download

model_path = hf_hub_download(
    repo_id="wattsonandme/survival-qwen-0.5b",
	filename="v3-survival-qwen-0.5b-q4_k_m.gguf",
)
print(model_path)
Writing downloader.py
!uv run python downloader.py
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
/home/user/.cache/huggingface/hub/models--wattsonandme--survival-qwen-0.5b/snapshots/0ed76b2ca44add906844a758475f36920e127636/v3-survival-qwen-0.5b-q4_k_m.gguf

Das ist optional, man kann auch die Datei .gguf manuell herunterladen und auf huggingface_hub verzichten.

%%writefile llama_example.py

from llama_cpp import Llama

GGUF_FILE = "/home/user/.cache/huggingface/hub/models--wattsonandme--survival-qwen-0.5b/snapshots/0ed76b2ca44add906844a758475f36920e127636/v3-survival-qwen-0.5b-q4_k_m.gguf"

def run_local_chat(model_path: str, prompt: str) -> str:
    # 1. Modell laden
    llm = Llama(
        model_path=model_path,
        n_ctx=32768,
        verbose=False
    )

    # 2. Inferenz über die Chat-API (wendet das interne Template an)
    response = llm.create_chat_completion(
        messages=[
            {"role": "system", "content": "You are a precise and helpful assistant."},
            {"role": "user", "content": prompt}
        ],
        max_tokens=100,
        temperature=0.1 # Niedrige Temperatur für deterministischere Mathe-Antworten
    )

    # 3. Output extrahieren
    return response["choices"][0]["message"]["content"].strip()

if __name__ == "__main__":
    test_prompt = "What is 2 + 3 expressed as a single number? Answer a number only."
    print(run_local_chat(GGUF_FILE, test_prompt))
Writing llama_example.py
!uv run python llama_example.py
2 + 3 = 5. This calculation is not accurate because it is incorrect in the first half of the calculation. It is important to verify the results against the original calculations. Always verify with multiple methods to ensure results are accurate.

Hierbei sehen wir ganz schön die typischen Schwierigkeiten mit LLM-gestützter Datenextraktion: Obwohl im Prompt um ein bestimmtes Format gebettelt wurde mit “Answer a number only” enthält die Antwort einen Freitext, der zudem sachlich falsch ist.

Wir kombinieren jetzt Llama.cpp mit Pydantic:

%%writefile llama_pydantic.py
import json
from pydantic import BaseModel
from llama_cpp import Llama

GGUF_FILE = "/home/user/.cache/huggingface/hub/models--wattsonandme--survival-qwen-0.5b/snapshots/0ed76b2ca44add906844a758475f36920e127636/v3-survival-qwen-0.5b-q4_k_m.gguf"

class UserProfile(BaseModel):
    name: str
    age: int
    hobbies: list[str]

def extract_profile(model_path: str, text: str) -> UserProfile:
    # 1. Modell laden
    llm = Llama(model_path=model_path, n_ctx=1024, verbose=False)

    # 2. Inferenz mit Grammar/Schema Enforcement
    response = llm.create_chat_completion(
        messages=[
            {"role": "system", "content": "Extract the user profile data as JSON."},
            {"role": "user", "content": text}
        ],
        # Hier zwingen wir Llama, exakt das Pydantic-Schema auszuspucken
        response_format={
            "type": "json_object",
            "schema": UserProfile.model_json_schema()
        },
        temperature=0.1,
        max_tokens=200, # um Endlosigkeit zu vermeiden
        repeat_penalty=1.2
    )

    # 3. Rohen JSON-String in Pydantic-Objekt umwandeln
    json_string = response["choices"][0]["message"]["content"]
    return UserProfile.model_validate_json(json_string)

if __name__ == "__main__":
    text = "This is Thomas, Thomas is 35 years old and likes hiking and reading."
    
    result = extract_profile(GGUF_FILE, text)
    print(result.hobbies) # Gewünschter Output: ['hiking', 'reading']
Writing llama_pydantic.py
!uv run python llama_pydantic.py
llama_context: n_ctx_seq (1024) < n_ctx_train (32768) -- the full capacity of the model will not be utilized
['hike', 'work', 'watch', 'museum', 'jambalunka']

Das Ergebnis ist immer noch unterirdisch, kann aber durch ein Modell mit mehr Parametern (mehrere GB statt der ca. 400 MB des hier verwendeten Modells) verbessert werden.

Komplexe Datentypen

Für bestimmte Datentypen und Datenstrukturen gibt es spezialisierte Lösungen. Die folgende Liste ist bewusst eine Landkarte und kein Kapitel: Sie soll die Namen liefern, nach denen man sucht, wenn die Aufgabe auftaucht. Ein eigenes Kapitel zu Bildverarbeitung, Web-Scraping oder Machine Learning gibt es hier nicht, und das ist eine Entscheidung und kein Rückstand — diese Themen haben an der HHU eigene Veranstaltungen, und ein zweiter Durchgang im Schnelldurchlauf würde beiden nicht guttun.

  • Graphen bzw. Netzwerke mit networkx (klassich) oder dem Drop-in Ersatz rustworkx (https://www.rustworkx.org/networkx.html).

  • Parsen von Webseiten mit selectolax oder dem älteren beautifulsoup oder parsel.

  • Klicken auf Webseiten mit playwright oder dem älteren selenium.

  • HTTP-Zugriffe mit httpx sowie Retries mit stamina (ein Wrapper um tenacity).

  • Bildverarbeitung mit pillow (Ersatz für PIL = Python Imaging Library) und opencv für komplexere Transformationen und einfaches Machine Learning.

  • Textverarbeitungstools (trrex, flashtext, hyperscan) und für einfache NLP-Aufgaben spaCy (eher nicht die älteren NLTK und GenSim).

  • Lese-, Schreib- und Extraktionsvorgänge bei PDFs mit pdfminer.six, pypdf und pymupdf (import fitz) sowie wrapper pdfplumber.

  • Datenformate wie Tabellen oder Datenbanken einlesen mit Polars, siehe Big-Data Kapitel.

  • GIS/GNSS-Daten mit geopandas bzw. geopolars und shapely (siehe auch pythongis.org )

Zusammenfassung und Kontrollfragen

  1. Vier Bausteine der Standardbibliothek decken das meiste ab: json zum Austauschen, tomllib zum Konfigurieren, sqlite3 sobald gesucht und geändert wird, pickle nur innerhalb eines Programms.

  2. pickle führt beim Laden Code aus — ein Pickle aus fremder Hand zu öffnen ist kein Risiko, sondern eine Übergabe der Kontrolle.

  3. Woher die Daten kommen, entscheidet über das Werkzeug: Aus dem eigenen Code genügt eine dataclass, von außen braucht es Pydantic. Deshalb stehen Pydantic-Modelle am Rand des Systems und Dataclasses im Kern.

  4. Pydantic wandelt Typen um, prüft eigene Regeln über field_validator und sammelt dabei alle Verstöße, statt beim ersten abzubrechen.

  5. pydantic-settings macht aus Konfiguration ein geprüftes Modell mit fester Rangfolge: Umgebungsvariable schlägt .env-Datei schlägt Default im Modell.

Kontrollfragen

  1. Ein CYOA-Spielstand soll den Zeitpunkt des Speicherns (datetime) und die Menge der besuchten Szenen (set) enthalten und für andere lesbar sein. Welches Format nehmen Sie, und welche zwei Probleme müssen Sie dabei von Hand lösen?

  2. An welcher Stelle eines Programms gehört ein Pydantic-Modell hin, und warum ist es an allen anderen Stellen eher Ballast als Gewinn?

  3. In spiel.env steht SPIEL_MAX_SPIELER=4, in der Umgebung SPIEL_MAX_SPIELER=8. Welcher Wert gilt? Und was passiert beim Start, wenn dort 99 steht?

# Aufräumen der temporären Dateien
!rm -f downloader.py llama_example.py llama_pydantic.py pydantic_ai_example.py \
       pydantic_ai_mockup.py settings_demo.py spiel.env users.csv \
       spielstand.json spielstand.db