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)
Truejson.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 deklariertZwei 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 etwasBeim 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¶
| Format | gut für | Grenze |
|---|---|---|
json | Austausch, Spielstände, APIs | nur sechs Typen; kein Datum, kein Tupel, keine Menge |
tomllib | Konfiguration von Hand | liest nur; für tiefe Verschachtelung unhandlich |
| YAML | hierarchische Konfiguration | Einrückung ist Syntax; safe_load benutzen |
sqlite3 | suchen, ändern, viele Datensätze | erfordert SQL und ein Schema |
csv | flache Tabellen, Excel-Nachbarschaft | alles ist ein String, keine Typen |
pickle | Objekte innerhalb eines Programms | unsicher, 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 openpyxlGeladen: 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 (“szenemuss inbekannte_szenenvorkommen”).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=4Writing 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.pydatenbank='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=8kommt als Zeichenkette an und wird zuint,SPIEL_DEBUG=truezuTrue— ebenso1,yes,on. Wer das von Hand macht, schreibt beim dritten Feld den ersten Fehler.Prüfung.
ge=1, le=8gilt auch hier.SPIEL_MAX_SPIELER=99führt zu einemValidationErrorbeim 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.
dict | TypedDict | @dataclass | BaseModel (Pydantic) | |
|---|---|---|---|---|
| Prüfung zur Laufzeit | keine | keine | keine | ja, mit Umwandlung |
| Prüfung durch Mypy | keine | ja | ja | ja |
| Zugriff | d["a"] | d["a"] | o.a | o.a |
| Kosten pro Objekt | keine | keine | gering | messbar |
| Abhängigkeit | – | – | – | pydantic |
| Zur Hand, wenn | Daten kurzlebig durchgeschoben werden | bestehender dict-Code Typen bekommen soll, ohne ihn umzubauen | eigene Daten im eigenen Code strukturiert werden | Daten 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.pyname='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.pyWarning: 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.py2 + 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.pyllama_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 Ersatzrustworkx(https://www .rustworkx .org /networkx .html). Parsen von Webseiten mit
selectolaxoder dem älterenbeautifulsoupoderparsel.Klicken auf Webseiten mit
playwrightoder dem älterenselenium.HTTP-Zugriffe mit
httpxsowie Retries mitstamina(ein Wrapper umtenacity).Bildverarbeitung mit
pillow(Ersatz für PIL = Python Imaging Library) undopencvfür komplexere Transformationen und einfaches Machine Learning.Textverarbeitungstools (
trrex,flashtext,hyperscan) und für einfache NLP-AufgabenspaCy(eher nicht die älteren NLTK und GenSim).Lese-, Schreib- und Extraktionsvorgänge bei PDFs mit
pdfminer.six,pypdfundpymupdf(import fitz) sowie wrapperpdfplumber.Datenformate wie Tabellen oder Datenbanken einlesen mit Polars, siehe Big-Data Kapitel.
GIS/GNSS-Daten mit
geopandasbzw.geopolarsundshapely(siehe auch pythongis.org )
Zusammenfassung und Kontrollfragen¶
Vier Bausteine der Standardbibliothek decken das meiste ab:
jsonzum Austauschen,tomllibzum Konfigurieren,sqlite3sobald gesucht und geändert wird,picklenur innerhalb eines Programms.pickleführt beim Laden Code aus — ein Pickle aus fremder Hand zu öffnen ist kein Risiko, sondern eine Übergabe der Kontrolle.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.Pydantic wandelt Typen um, prüft eigene Regeln über
field_validatorund sammelt dabei alle Verstöße, statt beim ersten abzubrechen.pydantic-settingsmacht aus Konfiguration ein geprüftes Modell mit fester Rangfolge: Umgebungsvariable schlägt.env-Datei schlägt Default im Modell.
Kontrollfragen
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?An welcher Stelle eines Programms gehört ein Pydantic-Modell hin, und warum ist es an allen anderen Stellen eher Ballast als Gewinn?
In
spiel.envstehtSPIEL_MAX_SPIELER=4, in der UmgebungSPIEL_MAX_SPIELER=8. Welcher Wert gilt? Und was passiert beim Start, wenn dort99steht?
# 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