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.

User Interfaces in Python

Heinrich-Heine-Universität Düsseldorf

Dieses Notebook vermittelt fortgeschrittene Konzepte zur Gestaltung von Benutzeroberflächen (UIs) und deklarativer Datenvisualisierung. Als roter Faden dient uns das Modell des Koffein-Trackers (Superposition und exponentieller Zerfall).

Business-Logik

Wir programmieren einen Koffein-Tracker, der eine Halbwertszeit (z.B. 5 Stunden) von Koffein im Blut verwaltet, und eine Liste von bisher eingenommenen Koffeindosen, aus der abgeleitet werden kann, wann der Koffeinspiegel unter ein bestimmtes Level (z.B. 50 mg) fällt bzw. welchen Koffeinspiegel man zu einem bestimmten Zeitpunkt (z.B. 22:00) haben wird. Das kann als Werkzeug dienen, den eigenen Koffeinkonsum zu reflektieren und um Python zu lernen.

Der Koffeinspiegel C(T)C(T) zu einem Zeitpunkt TT ergibt sich aus der Superposition der einzelnen exponentiellen Zerfälle aller bisherigen Einnahmen:

C(T)=i:tiTAi(12)TtihC(T) = \sum_{i: t_i \le T} A_i \cdot \left(\frac{1}{2}\right)^{\frac{T - t_i}{h}}

wobei AiA_i die konsumierte Menge in mg zum Zeitpunkt tit_i und hh die Halbwertszeit in Stunden ist.Umgekehrt lässt sich für einen reinen Zerfall (ohne weitere Einnahmen) ab einem Zeitpunkt T0T_0 mit Initialwert C0=C(T0)C_0 = C(T_0) der Zeitpunkt TT ermitteln, an dem ein Zielwert YY (Y<C0Y < C_0) unterschritten wird:

T=T0+hln(Y)ln(C0)ln(0.5)T = T_0 + h \cdot \frac{\ln(Y) - \ln(C_0)}{\ln(0.5)}

Wir setzen voraus, dass die Kernlogik vollständig getestet und einsatzbereit in einer separaten Datei coffee_tracker.py im selben Verzeichnis liegt. Hier ist eine strukturelle Übersicht der vorausgesetzten API:

# coffee_tracker.py (Auszug)
from datetime import datetime

class CaffeineEntry:
    def __init__(self, timestamp: datetime, name: str, amount_mg: float): ...

class CaffeineTracker:
    def __init__(self, half_life_hours: float = 5.0): ...
    def add_entry(self, time: datetime, name: str, amount: float) -> None: ...
    def get_entries(self) -> list[CaffeineEntry]: ...
    def calculate_level_at(self, target_time: datetime) -> float: ...
    def time_until_below(self, target_level: float, start_time: datetime) -> datetime: ...
# Abhängigkeiten in diesem Notebook:
!uv venv
!uv sync --quiet
!uv pip install --quiet textual pyside6 pygame-ce fastapi streamlit altair polars httpx
Using CPython 3.13.3
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate
!uv run coffee_tracker.py
12:44:17 - INFO - Eintrag hinzugefügt: 60.0mg 'Espresso' um 08:00.
12:44:17 - INFO - Eintrag hinzugefügt: 100.0mg 'Kaffee' um 08:00.
12:44:17 - INFO - Eintrag hinzugefügt: 100.0mg 'Kaffee' um 08:00.
✅ Alle 14 Doctests erfolgreich bestanden.

Starte Pytest Unit-Tests:
============================= test session starts ==============================
platform linux -- Python 3.13.3, pytest-9.0.3, pluggy-1.6.0 -- /home/konrad/Sciebo/hhu/eipy-26/eipy-skript/.venv/bin/python3
cachedir: .pytest_cache
hypothesis profile 'default'
rootdir: /home/konrad/Sciebo/hhu/eipy-26/eipy-skript
configfile: pyproject.toml
plugins: cov-7.1.0, hypothesis-6.152.9, hydra-core-1.3.2, anyio-4.13.0
collected 4 items                                                              

coffee_tracker.py::test_entry_invariants PASSED                          [ 25%]
coffee_tracker.py::test_tracker_sorting PASSED                           [ 50%]
coffee_tracker.py::test_update_and_delete PASSED                         [ 75%]
coffee_tracker.py::test_superposition_calculation PASSED                 [100%]

============================== 4 passed in 0.09s ===============================

Command Line Interfaces (CLI)

Das Paradigma

Ein CLI basiert auf standardisierten Datenströmen (stdin, stdout, stderr), Pipes und sequenzieller Argumentübergabe. Es ist die mächtigste Schnittstelle für Automatisierung, Scripting und Server-Umgebungen und ein einfacher Weg, um Werkzeuge für agentische KI-Systeme zur Verfügung zu stellen.

Der rohe Weg vs. Best Practice

In Java kennen wir das klassische String-Array String[] args der main-Methode. In Python greift man über sys.argv darauf zu. Das manuelle Parsen dieses Arrays führt jedoch schnell zu fragilem, unwartbarem Code (Verstoß gegen Clean Code-Prinzipien).

Historisch wurde dafür oft optparse verwendet. Dieses Modul ist jedoch seit Python 3.2 deprecated und darf in modernem Code nicht mehr verwendet werden. Der offizielle Standard der Standardbibliothek ist argparse.

Strukturierung komplexer CLI-Tools via Subcommands

Da unser Koffein-Tracker verschiedene Aktionen anbietet (add, list, level), nutzen wir add_subparsers. Dies erlaubt eine modulare Strukturierung wie bei modernen Tools (git commit, docker run, uv pip).

%%writefile coffee_cli.py
import argparse
import sys
from datetime import datetime
from coffee_tracker import CaffeineTracker

def main() -> None:
    parser = argparse.ArgumentParser(description="Koffein-Tracker Kommandozeile")
    subparsers = parser.add_subparsers(dest="command", help="Verfügbare Befehle")
    
    # Subcommand: add
    parser_add = subparsers.add_parser("add", help="Fügt eine Koffeineinnahme hinzu")
    parser_add.add_argument("--name", type=str, required=True, help="Name des Getränks")
    parser_add.add_argument("--amount", type=float, required=True, help="Koffeinmenge in mg")
    parser_add.add_argument("--time", type=str, default=None, help="ISO-Zeitstempel (Standard: Jetzt)")
    
    # Subcommand: level
    parser_level = subparsers.add_parser("level", help="Berechnet den aktuellen Spiegel")
    parser_level.add_argument("--at", type=str, default=None, help="ISO-Zeitstempel (Standard: Jetzt)")

    args = parser.parse_args()
    
    # Zustand initialisieren (In einer echten Anwendung: Laden aus Datei/DB)
    tracker = CaffeineTracker()

    if args.command == "add":
        try:
            dt = datetime.fromisoformat(args.time) if args.time else datetime.now()
            tracker.add_entry(dt, args.name, args.amount)
            print(f"✅ {args.amount}mg '{args.name}' für {dt.strftime('%H:%M')} Uhr registriert.")
        except ValueError:
            print("❌ Fehler: Das Datum muss im ISO-Format vorliegen (z.B. 2026-05-22T08:00:00).", file=sys.stderr)
            sys.exit(1)
            
    elif args.command == "level":
        try:
            target_time = datetime.fromisoformat(args.at) if args.at else datetime.now()
            level = tracker.calculate_level_at(target_time)
            print(f"☕ Berechneter Koffeinspiegel: {level:.2f} mg")
        except ValueError:
            print("❌ Fehler: Ungültiges Datumsformat.", file=sys.stderr)
            sys.exit(1)
    else:
        parser.print_help()

if __name__ == "__main__":
    main()
Writing coffee_cli.py
# Erfolgreicher Aufruf
!uv run python coffee_cli.py add --name "Espresso" --amount 60

# Fehlerhafter Aufruf provozieren (Abgefangene Exception)
!uv run python coffee_cli.py level --at "heute abend"
12:44:18 - INFO - Eintrag hinzugefügt: 60.0mg 'Espresso' um 12:44.
✅ 60.0mg 'Espresso' für 12:44 Uhr registriert.
❌ Fehler: Ungültiges Datumsformat.

Text-based User Interfaces (TUI)

Das Paradigma

Eine TUI verwandelt das Terminal von einem rein sequenziellen Zeilen-Stream in eine interaktive, zweidimensionale Matrix aus Zeichen. Sie reagiert direkt auf Tastendruck und Maus-Events, ohne den Overhead eines grafischen Window-Managers (X11/Wayland/Windows Desktop).

Der moderne Stack: Textual

Das klassische Python-Modul curses ist ein dünner Wrapper um die alte C-Bibliothek: Es ist plattformabhängig (läuft nativ nicht unter Windows), imperativ und schwer zu strukturieren.

Ein modernes TUI-Framework ist Textual. Dies steuert das Layouting über ein CSS-ähnliches deklaratives Modell.

Hier ist die Architektur-Skizze einer interaktiven TUI-Schicht für den Koffein-Tracker:

%%writefile textual_ui.py
from datetime import datetime
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, DataTable, Input

class CaffeineTuiApp(App[None]):
    """
    Eine asynchrone, ereignisgesteuerte TUI-Applikation.
    Zeigt die Entkopplung: UI-Komponenten rufen die logischen Methoden auf.
    """
    CSS = """
    Screen {
        layout: vertical;
        padding: 1;
    }
    DataTable {
        height: 60%;
        border: solid green;
    }
    Input {
        margin: 1 0;
    }
    """

    BINDINGS = [("q", "quit", "Beenden")]

    def compose(self) -> ComposeResult:
        yield Header(show_clock=True)
        yield DataTable()
        yield Input(placeholder="Getränkename eingeben...", id="input_name")
        yield Input(placeholder="Koffeinmenge (mg)...", id="input_amount")
        yield Button("Eintrag hinzufügen", variant="success", id="btn_add")
        yield Footer()

    def on_mount(self) -> None:
        table = self.query_one(DataTable)
        table.add_columns("Zeit", "Getränk", "Menge (mg)")
        # Hier würden im echten Betrieb bestehende Werte aus `tracker.get_entries()` geladen

    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "btn_add":
            name = self.query_one("#input_name", Input).value
            amount = self.query_one("#input_amount", Input).value
            
            # Validierung & Delegation an Business-Logik
            print(f"[TUI EVENT] -> UI-Eingabe abgefangen: {name}, {amount}mg")
            # tracker.add_entry(datetime.now(), name, float(amount))
            
            # UI-Refreshes laufen asynchron im Event-Loop
            table = self.query_one(DataTable)
            table.add_row(datetime.now().strftime("%H:%M:%S"), name, f"{amount} mg")

if __name__ == "__main__":
    CaffeineTuiApp().run()
Writing textual_ui.py

TUI-Apps benötigen ein echtes Terminal (TTY) zur Steuerung der Escape-Sequenzen. Sie können nicht direkt innerhalb der standardmäßigen Jupyter-Zelle gerendert werden.

uv run textual_ui.py
Screenshot der Textual UI

Graphical User Interfaces (GUI) & Game-Loops

Das Event-Loop Modell

Sowohl klassische Desktop-GUIs als auch Spiele brechen mit dem sequenziellen Kontrollfluss linearer Programme. Das Programm startet eine Endlosschleife (den Event Loop), blockiert dort und wartet rein passiv auf Betriebssystem-Signale (Mausklicks, Keyboard-Interrupts).

Der Industriestandard: das Qt-Ökosystem

Während tkinter (Integrierter Tk-Wrapper) gut für winzige Skripte geeignet ist, skaliert es architektonisch schlecht (d.h. es macht keinen Spass für größere UIs).

In Qt werden UI-Komponenten über Hierarchien von Layouts (QVBoxLayout, QHBoxLayout) strukturiert. Die Kommunikation zwischen dem Nutzer und der Business-Logik erfolgt typsicher über das Signals & Slots-Muster. Wenn der Button geklickt wird (Signal), wird unsere Logik-Methode ausgeführt (Slot). Das ist eine typsichere Variante des Observer Patterns.

Es gibt zwei Python-Bindings für Qt: PyQt6 und PySide6. Ursprünglich wurde PyQt unter der GPL-Lizenz entwickelt und PySide später vom QT-Konsortium als LGPL-Alternative. Die Bibliotheken haben derzeit einigermaßen Feature-Parität, wenngleich es naheliegend ist, für neuere Projekte PySide6 zu verwenden.

Hier ist ein Beispiel, in dem es nicht so auf PyQt6/PySide6 ankommt:

%%writefile coffee_gui.py
import sys
from datetime import datetime
#from PyQt6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout, 
#                             QLineEdit, QPushButton, QLabel, QMessageBox)
from PySide6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout,
                             QLineEdit, QPushButton, QLabel, QMessageBox)
from coffee_tracker import CaffeineTracker

class CaffeineWindow(QWidget):
    def __init__(self) -> None:
        super().__init__()
        self.tracker = CaffeineTracker()
        self.init_ui()

    def init_ui(self) -> None:
        self.setWindowTitle('Koffein-Tracker GUI')
        self.setGeometry(100, 100, 350, 150)

        # Layout-Manager instanziieren
        main_layout = QVBoxLayout()
        input_layout = QHBoxLayout()

        # UI-Widgets erstellen
        self.input_amount = QLineEdit()
        self.input_amount.setPlaceholderText("Menge in mg (z.B. 80)")
        
        self.btn_add = QPushButton("Eintragen / Berechnen")
        self.lbl_result = QLabel("Aktueller Spiegel: 0.00 mg")

        # Layout kompilieren
        input_layout.addWidget(self.input_amount)
        input_layout.addWidget(self.btn_add)
        
        main_layout.addLayout(input_layout)
        main_layout.addWidget(self.lbl_result)
        self.setLayout(main_layout)

        # Signal & Slot Registrierung
        self.btn_add.clicked.connect(self.handle_add_click)

    def handle_add_click(self) -> None:
        """Slot-Methode: Wird asynchron durch den Event-Loop bei Klick aufgerufen."""
        amount_str = self.input_amount.text()
        try:
            amount = float(amount_str)
            now = datetime.now()
            
            # Delegation an die Business-Logik
            self.tracker.add_entry(now, "GUI-Eintrag", amount)
            level = self.tracker.calculate_level_at(now)
            
            # UI aktualisieren
            self.lbl_result.setText(f"Aktueller Spiegel: {level:.2f} mg")
            self.input_amount.clear()
        except ValueError:
            QMessageBox.warning(self, "Eingabefehler", "Bitte eine numerische Menge eingeben.")

if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = CaffeineWindow()
    window.show()
    sys.exit(app.exec())
Writing coffee_gui.py
uv run python coffee_gui.py
Screenshot der PyQt6 GUI

Game-Loop (PyGame)

Achtung: Das Paket pygame ist seit ca. 2022 bzw. Python > 3.10 nicht mehr zu empfehlen. Der Fork pygame-ce stellt das Modul pygame zur Verfügung und wird aktiv gepflegt.

Im Gegensatz zu einer GUI, die nur bei Benutzerinteraktion zeichnet, läuft ein Game-Loop permanent auf maximaler Frequenz (z.B. 60 FPS).

  • Jede Iteration macht exakt drei Dinge: Input verarbeiten \rightarrow Zustand simulieren \rightarrow Frame rendern.

Wir implementieren hier ein minimales Pygame-Szenario, das die Abbaukurve des Koffeins als dynamisch schrumpfenden physikalischen Balken rendert:

%%writefile coffee_pygame.py
import os

# Pygame anweisen, kein echtes Fenster zu öffnen, um Headless-Execution im Notebook zu erlauben
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
print("Video driver:", os.environ.get("SDL_VIDEODRIVER"))

DEFAULT_MAX_TICKS = 5
STR_MAX_TICKS = os.environ.get("COFFEE_MAX_TICKS", DEFAULT_MAX_TICKS)
try:
    MAX_TICKS = int(STR_MAX_TICKS)
except ValueError:
    MAX_TICKS = DEFAULT_MAX_TICKS

import pygame
import sys

def simulate_game_loop() -> None:
    pygame.init()
    screen = pygame.display.set_mode((400, 300))
    clock = pygame.time.Clock()
    
    # Zustand der physikalischen Simulation (Koffeinspiegel in mg)
    caffeine_level = 120.0
    half_life_seconds = 5.0  # Beschleunigte Simulation für das Spiel
    
    running = True
    ticks = 0
    
    print(f"Starte deterministischen Game-Loop (Simulation läuft für {MAX_TICKS} Frames)...")

    step = max(1, MAX_TICKS // 10) # Output nicht zu oft, das ist die Hilfsvariable
    
    while running and ticks < MAX_TICKS:
        # 1. INPUT EVENT PROCESSING
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
                
        # 2. STATE UPDATE (Physikalischer exponentieller Zerfall pro Tick)
        dt = clock.tick(60) / 1000.0  # vergangene Zeit in Sekunden
        decay_factor = 0.5 ** (dt / half_life_seconds)
        caffeine_level *= decay_factor
        
        # 3. RENDERING (Zeichnen der Schichten)
        screen.fill((30, 30, 30))  # Hintergrund
        
        # Berechne Balkenbreite proportional zum Zustand
        bar_width = int(min(caffeine_level, 400))
        pygame.draw.rect(screen, (240, 140, 40), (10, 100, bar_width, 40))

        # 4. Buffer austauschen (damit man auch etwas sieht)
        pygame.display.flip()
        
        if(ticks % step == 0):
            print(f"  Frame {ticks:4d} - Berechneter Koffein-Pegel: {caffeine_level:8.4f} mg -> Render-Breite: {bar_width:4d}px")
        ticks += 1
        
    pygame.quit()

simulate_game_loop()
Writing coffee_pygame.py
!uv run python coffee_pygame.py
Video driver: dummy
pygame-ce 2.5.7 (SDL 2.32.10, Python 3.13.3)
Starte deterministischen Game-Loop (Simulation läuft für 5 Frames)...
  Frame    0 - Berechneter Koffein-Pegel: 119.7341 mg -> Render-Breite:  119px
  Frame    1 - Berechneter Koffein-Pegel: 119.4523 mg -> Render-Breite:  119px
  Frame    2 - Berechneter Koffein-Pegel: 119.1876 mg -> Render-Breite:  119px
  Frame    3 - Berechneter Koffein-Pegel: 118.9235 mg -> Render-Breite:  118px
  Frame    4 - Berechneter Koffein-Pegel: 118.6601 mg -> Render-Breite:  118px

Wenn wir das mit einem echten Video driver aufrufen, sieht das etwa so aus:

SDL_VIDEODRIVER=x11 COFFEE_MAX_TICKS=600 uv run python coffee_pygame.py
coffee_pygame_peek.gif

Web-APIs & Modernes Server-Hosting

Die Architektur-Evolution: WSGI vs. ASGI vs. RSGI

Moderne Web-Architekturen entkoppeln Frontends (Browser, Mobile-Apps) vollständig vom Backend über HTTP-REST-Schnittstellen. Die Wahl der Server-Schnittstelle in Python bestimmt maßgeblich den Durchsatz:

  • WSGI (Web Server Gateway Interface, PEP 3333): Der synchrone Urvater (z.B. Flask). Jeder Request blockiert einen Betriebssystem-Thread. Bei I/O-Bound-Anwendungen (Warten auf Datenbanken) skaliert dies extrem schlecht.

  • ASGI (Asynchronous Server Gateway Interface): Ermöglicht native asynchrone Programmierung (asyncio). Ein einzelner Thread kann Millionen paralleler, unvollständiger Anfragen verwalten, während sie auf I/O warten (z.B. FastAPI mit Uvicorn).

  • RSGI (Rust Server Gateway Interface): Die modernste Evolutionsstufe (implementiert im Server Framework Granian). Das hochkomplexe, performance-kritische HTTP/1- und HTTP/2-Parsing sowie das gesamte Verbindungsmanagement werden vollständig in eine hochperformante, speichersichere Rust-Laufzeitumgebung ausgelagert. Python erhält über den ASGI-Layer nur noch die fertig geparsten Events und konzentriert sich exklusiv auf die Ausführung der asynchronen Business-Logik.

REST-API Layer mit FastAPI & Pydantic

Wir gießen den Koffein-Tracker in eine saubere OpenAPI-Schnittstelle. Zur Typsicherheit und automatischen Request-Validierung zur Laufzeit nutzen wir Pydantic:

%%writefile fastapi_ui.py
from fastapi import FastAPI
from pydantic import BaseModel, Field
from datetime import datetime

app = FastAPI(title="Caffeine Tracker API", version="1.0.0")

# Pydantic-Modell definiert die erwartete Struktur inklusive Validierung zur Laufzeit
class CaffeineWebRequest(BaseModel):
    drink_name: str = Field(..., min_length=1, max_length=50, examples=["Filterkaffee"])
    amount_mg: float = Field(..., gt=0, description="Koffeinmenge muss strikt positiv sein.")
    timestamp: datetime = Field(default_factory=datetime.now)

@app.post("/entries", status_code=201)
async def create_entry(request: CaffeineWebRequest):
    """
    Asynchroner Endpunkt zur Registrierung einer Einnahme.
    Pydantic stellt sicher: Wenn dieser Code betreten wird, sind alle Typen valide!
    """
    # Hier fände die Delegation an das Backend statt:
    # tracker.add_entry(request.timestamp, request.drink_name, request.amount_mg)
    return {
        "status": "success",
        "saved": {
            "drink": request.drink_name,
            "mg": request.amount_mg,
            "recorded_at": request.timestamp.isoformat()
        }
    }

print("FastAPI-Routing und Pydantic-Validierungsmodelle erfolgreich initialisiert und Tschüss.")
Writing fastapi_ui.py
!uv run fastapi_ui.py
FastAPI-Routing und Pydantic-Validierungsmodelle erfolgreich initialisiert und Tschüss.

Dabei hat nun Pydantic die Deserialisierung von JSON in ein Python-Objekt übernommen, indem wir die Objekte durch Erben von BaseModel beschreiben (wie Dataclasses). Dabei lassen sich sehr detaillierte Typeninformationen einfordern (wie etwa float größergleich 0).

FastAPI setzt den Decorator @app.post um, sodass eine HTTP-Anfrage an die Adresse /entries zur Methode create_entry geroutet wird. Das Schlüsselwort async vor def bedeutet, dass hier eine Koroutine deklariert wird; wenn man eine Koroutine aufruft, wird nicht sofort der Code ausgeführt, erst wenn man das Ergebnis mit dem Schlüsselwort await aufruft (darüber sollten wir nochmal einzeln und in Ruhe sprechen; TODO: Verweis auf den Abschnitt).

FastAPI erstellt automatisch eine Dokumentation, die bereits Snippets zum Testen der API bereitstellt. Wenn man einen Server gestartet hat mit

uv run uvicorn fastapi_ui:app --reload

kann man auf localhost:8000/docs navigieren bzw. direkt zu dem Ausprobieren von create_entries; das ist ein Wrapper um

curl -X 'POST' \
  'http://localhost:8000/entries' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "drink_name": "Filterkaffee",
  "amount_mg": 1,
  "timestamp": "2026-06-03T14:28:18.588Z"
}'

Automatisierte API-Tests ohne Server-Start

Um eine Web-Schnittstelle zu testen, müssen wir nicht den gesamten ASGI-Server (z.B. uvicorn oder granian) auf einem echten Port starten. FastAPI bringt den TestClient mit, der HTTP-Requests direkt an die interne Router-Logik weiterreicht. Dies ist deterministisch, extrem schnell und blockiert den Event-Loop nicht.

%%writefile test_api.py
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
from datetime import datetime

# 1. API-Definition (würde in der Praxis aus main.py importiert)
app = FastAPI()

class CaffeineRequest(BaseModel):
    name: str
    amount: float
    time: datetime

@app.post("/entries")
def add_entry(req: CaffeineRequest):
    # Dummy-Rückgabe (hier würde tracker.add_entry gekoppelt)
    return {"status": "ok", "recorded_mg": req.amount}

# 2. Test-Konfiguration
client = TestClient(app)

def test_valid_post_request() -> None:
    """Testet den korrekten Durchstich (Status 200)."""
    response = client.post(
        "/entries",
        json={"name": "Filterkaffee", "amount": 150.0, "time": datetime.now().isoformat()}
    )
    assert response.status_code == 200
    assert response.json()["recorded_mg"] == 150.0
    print("✅ Validierungs-Test: Akzeptierte Eingabe erfolgreich geprüft.")

def test_pydantic_type_rejection() -> None:
    """Prüft, ob Pydantic ungültige Typen vor Erreichen der Logik blockiert."""
    response = client.post(
        "/entries",
        json={"name": "Mate", "amount": "viel zu viel", "time": "gestern"}
    )
    # FastAPI/Pydantic wirft automatisch 422 (Unprocessable Entity)
    assert response.status_code == 422
    print("✅ Rejection-Test: Fehlerhafte Datentypen mit HTTP 422 abgeblockt.")

if __name__ == "__main__":
    test_valid_post_request()
    test_pydantic_type_rejection()
Writing test_api.py
!uv run python test_api.py
✅ Validierungs-Test: Akzeptierte Eingabe erfolgreich geprüft.
✅ Rejection-Test: Fehlerhafte Datentypen mit HTTP 422 abgeblockt.

Web-UIs & Deklarative Visualisierung

Data Science Prototyping mit Streamlit

Streamlit bricht mit dem klassischen MVC-Muster. Es erlaubt das Schreiben von Web-UIs als reines, sequenzielles Python-Skript. Sobald der Nutzer ein UI-Widget (z.B. einen Slider) bewegt, wird das gesamte Skript von oben nach unten neu ausgeführt.

Deklarative Visualisierung via Grammar of Graphics

Anstatt dem Computer imperativ Zeichenbefehle zu erteilen („Zeichne eine Linie von Punkt A nach Punkt B, färbe sie rot“), beschreibt ein deklarativer Ansatz das Mapping von Datendimensionen auf visuelle Kanäle (Geometrien, X/Y-Positionen, Farben).

  • Altair: Der etablierte Goldstandard für deklarative Grafiken in Python. Er basiert auf der Vegalite-Spezifikation entlang der Grammatik der Grafiken (wie ggplot2 oder plotnine).

  • Charton: Die absolute Speerspitze moderner Visualisierungen im Python-Umfeld. Charton ist von Grund auf für Polars-Workflows optimiert. Da Polars-DataFrames im Hintergrund in hocheffizienten Apache-Arrow-Speicherstrukturen liegen, erlaubt Charton ein extrem schnelles, ressourcenschonendes Zeichnen großer Datenmengen ohne teure Serialisierungs-Overheads (Zero-Copy). Es teilt die elegante API-Philosophie von Altair, verschiebt das rechenintensive SVG-Rendering jedoch direkt auf den Server.

#Cleanup:
!rm -f textual_ui.py coffee_cli.py coffee_gui.py coffee_pygame.py fastapi_ui.py test_api.py