Zum Inhalt springen

cat posts/python-lernen-11-werkzeugkasten-und-ausblick.md

Python lernen (Teil 11): Werkzeugkasten und Ausblick

Zum Abschluss: nützliche Module aus der Standardbibliothek, Generatoren, Decorators und der Ausblick auf Type Hints, Tests, Packaging und async.

In Teil 10 haben wir den Dungeon in mehrere Module aufgeteilt. Aus einer immer länger werdenden dungeon.py wurde ein kleines Projekt mit klareren Verantwortlichkeiten.

Damit haben wir die eigentlichen Grundlagen dieser Serie hinter uns. Du hast inzwischen mit Variablen, Collections, Bedingungen, Schleifen, Funktionen, Klassen, Exceptions, Dateien und Modulen gearbeitet. Der Dungeon kann seinen State sogar als JSON speichern und später wiederherstellen.

Im letzten Teil bauen wir deshalb kein weiteres großes Fundament. Stattdessen öffnen wir den Werkzeugkasten.

Wir schauen uns einige Module aus der Standardbibliothek an und streifen Themen, denen Du in größeren Python-Projekten früher oder später begegnen wirst: Generatoren, Decorators, Type Hints, Tests, async und Packaging.

Dieser Teil ist bewusst ein Ausblick. Du musst danach weder jeden itertools-Baustein auswendig kennen noch selbst komplizierte Decorators schreiben können. Wichtiger ist, dass Du die Werkzeuge wiedererkennst und ungefähr weißt, wann sie interessant werden.

Die Standardbibliothek als Werkzeugkasten

Python bringt eine umfangreiche Standardbibliothek mit. Ihre Module gehören zur Python-Installation und müssen nicht wie Drittanbieter-Packages separat über pip installiert werden.

Einige davon kennen wir bereits:

import json
from pathlib import Path

json haben wir für den Spielstand verwendet, pathlib für den Pfad zur Speicherdatei.

Daneben gibt es Module für viele weitere Aufgaben, zum Beispiel:

import random
import secrets
import itertools
from collections import Counter, defaultdict, deque

Bevor Du für eine alltägliche Aufgabe ein zusätzliches Package installierst, lohnt sich deshalb oft ein Blick in die Standardbibliothek.

random: Zufall ins Spiel bringen

Unser Dungeon reagiert bisher vollständig vorhersehbar. Für ein Spiel darf ruhig etwas Zufall dazukommen.

Das Modul random stellt dafür einen Pseudozufallszahlengenerator bereit:

import random

ereignisse = [
    "Eine Fledermaus flattert durch den Raum.",
    "Irgendwo tropft Wasser.",
    "Du hörst ein fernes Knurren.",
]

ereignis = random.choice(ereignisse)

print(ereignis)

random.choice(...) wählt ein Element aus einer nicht leeren Sequenz.

Für einen Würfelwurf könnten wir randint(...) verwenden:

wurf = random.randint(1, 6)

print(wurf)

Bei randint(1, 6) sind beide Grenzen enthalten. Das Ergebnis kann also 1, 6 oder eine der Zahlen dazwischen sein.

Das passt gut zu Dingen wie:

  • Würfelwürfen,
  • zufälliger Beute,
  • Gegnerauswahl,
  • Wetter,
  • kleinen Zufallsereignissen.

Zufallsereignisse im Dungeon

Wir können beispielsweise eine Funktion schreiben, die mit einer bestimmten Wahrscheinlichkeit ein Ereignis liefert:

import random


EREIGNISSE = [
    "Eine Fledermaus flattert durch den Raum.",
    "Irgendwo tropft Wasser.",
    "Du hörst ein fernes Knurren.",
    "Ein kalter Windhauch streicht über den Boden.",
]


def vielleicht_ereignis():
    """Gibt gelegentlich ein zufälliges Ereignis zurück."""
    if random.random() < 0.3:
        return random.choice(EREIGNISSE)

    return None

random.random() liefert einen Float im Bereich von 0.0 einschließlich bis 1.0 ausschließlich.

Deshalb ist

random.random() < 0.3

ungefähr in 30 Prozent der Fälle wahr.

Im Game-Loop könnten wir schreiben:

ereignis = vielleicht_ereignis()

if ereignis is not None:
    print(ereignis)

Damit kann beim Betreten eines Raums gelegentlich etwas passieren, ohne dass wir die eigentliche Spiellogik stark verändern müssen.

Reproduzierbarer Zufall mit einem Seed

Beim Debugging kann echter Spielzufall lästig sein.

Angenommen, ein Fehler tritt nur auf, wenn erst ein Goblin und danach ein Heiltrank ausgewählt wird. Dann möchtest Du denselben Ablauf vielleicht wiederholen können.

Dafür können wir einen eigenen Generator mit einem festen Seed erzeugen:

import random

zufall = random.Random(42)

print(zufall.randint(1, 10))
print(zufall.randint(1, 10))
print(zufall.randint(1, 10))

Der Generator startet mit einem reproduzierbaren internen State. Das macht zufällige Abläufe innerhalb einer kontrollierten Umgebung nachvollziehbar.

Für normalen Spielbetrieb setzt Du dagegen üblicherweise keinen festen Seed.

Ein eigener Random ist für Tests oft angenehmer als:

random.seed(42)

Denn damit veränderst Du nicht den globalen Generator des random-Moduls, sondern arbeitest mit einer eigenen Instanz:

zufall = random.Random(42)

Diese Instanz kannst Du später sogar als Dependency an Funktionen übergeben.

random ist nicht für Sicherheit gedacht

Pseudozufall für einen Dungeon ist etwas anderes als Zufall für Sicherheitsfunktionen.

Verwende random nicht für Dinge wie:

  • Passwort-Reset-Tokens,
  • Session-Tokens,
  • API-Secrets,
  • schwer erratbare Einladungslinks.

Für solche Werte bringt Python das Modul secrets mit:

import secrets

token = secrets.token_urlsafe(32)

print(token)

secrets verwendet Zufallsquellen, die für kryptografisch starke Sicherheitswerte gedacht sind.

Die praktische Faustregel ist einfach:

Spiel und Simulation: random.
Geheimnisse und Security-Tokens: secrets.

collections.Counter: Dinge zählen

Das Modul collections enthält spezialisierte Collections.

Einer davon ist Counter:

from collections import Counter

beute = [
    "Gold",
    "Trank",
    "Gold",
    "Gold",
    "Schlüssel",
]

zaehlung = Counter(beute)

print(zaehlung)

Die Ausgabe lautet:

Counter({'Gold': 3, 'Trank': 1, 'Schlüssel': 1})

Ein Counter ist eine spezielle dict-Unterklasse zum Zählen hashbarer Werte.

Wir können direkt nach einzelnen Counts fragen:

print(zaehlung["Gold"])
print(zaehlung["Trank"])
print(zaehlung["Fackel"])

Ausgabe:

3
1
0

Anders als ein normales Dictionary liefert ein Counter bei einem fehlenden Element den Count 0, statt einen KeyError auszulösen.

Die häufigsten Werte mit most_common(...)

Besonders praktisch ist:

print(zaehlung.most_common(1))

Ausgabe:

[('Gold', 3)]

most_common(...) liefert eine Liste aus Paaren von Element und Count.

Die drei häufigsten Einträge bekommst Du beispielsweise mit:

print(zaehlung.most_common(3))

Für unseren Dungeon könnten wir damit Fragen beantworten wie:

  • Welche Befehle verwendet der Spieler am häufigsten?
  • Welcher Loot wurde am häufigsten gefunden?
  • Welche Räume werden besonders oft betreten?

Befehle im Dungeon zählen

Ein leerer Counter beginnt so:

from collections import Counter

befehls_zaehler = Counter()

Im Game-Loop können wir nach dem Parsen einer Eingabe zählen:

verb, argument = teile_befehl(eingabe)

befehls_zaehler[verb] += 1

Beim Beenden:

print("Häufigste Befehle:")

for befehl, anzahl in befehls_zaehler.most_common():
    print(f"{befehl}: {anzahl}")

Für eine reine Zählaufgabe ist Counter meist aussagekräftiger als ein Dictionary, dessen Werte wir von Hand hochzählen.

defaultdict: fehlende Werte erzeugen

Ein normales Dictionary kennt einen Schlüssel zunächst nicht:

zaehlung = {}

zaehlung["Gold"] += 1

Das führt zu einem KeyError.

Wir könnten schreiben:

zaehlung["Gold"] = zaehlung.get("Gold", 0) + 1

Oder einen defaultdict verwenden:

from collections import defaultdict

zaehlung = defaultdict(int)

zaehlung["Gold"] += 1
zaehlung["Gold"] += 1

print(zaehlung["Gold"])

Ausgabe:

2

defaultdict erhält eine sogenannte Default Factory.

Hier ist das:

int

Wird ein fehlender Schlüssel über [] abgefragt, ruft der defaultdict int() auf. Ohne Argument liefert int() den Wert 0.

Danach kann unser += 1 direkt damit weiterarbeiten.

Für reine Counts ist Counter meistens die passendere Abstraktion. defaultdict kann aber wesentlich mehr.

Listen pro Schlüssel sammeln

Ein typischer Einsatz ist:

from collections import defaultdict

funde_nach_raum = defaultdict(list)

funde_nach_raum["halle"].append("Fackel")
funde_nach_raum["halle"].append("Brot")
funde_nach_raum["bibliothek"].append("Schlüssel")

print(funde_nach_raum["halle"])

Ausgabe:

['Fackel', 'Brot']

Beim ersten Zugriff auf

funde_nach_raum["halle"]

existiert der Schlüssel noch nicht. defaultdict ruft deshalb list() auf und legt eine neue leere Liste dafür an.

Ohne defaultdict müssten wir beispielsweise vorher schreiben:

if raum_id not in funde_nach_raum:
    funde_nach_raum[raum_id] = []

Ein Detail ist dabei wichtig: Der automatische Default wird beim Zugriff mit

mapping[schluessel]

erzeugt.

Ein Aufruf von:

mapping.get(schluessel)

verwendet die Default Factory dagegen nicht automatisch.

deque: eine Queue mit zwei schnellen Enden

Eine normale Liste eignet sich sehr gut, wenn wir am Ende Elemente hinzufügen oder entfernen:

werte.append(1)
werte.pop()

Unpraktischer wird sie, wenn regelmäßig das erste Element entfernt werden soll:

werte.pop(0)

Dabei müssen die nachfolgenden Elemente innerhalb der Liste verschoben werden.

Für Queues gibt es deshalb unter anderem deque:

from collections import deque

meldungen = deque()

meldungen.append("Du hörst ein Knacken.")
meldungen.append("Eine Tür fällt ins Schloss.")

print(meldungen.popleft())
print(meldungen.popleft())

Ausgabe:

Du hörst ein Knacken.
Eine Tür fällt ins Schloss.

Eine deque unterstützt effizientes Anhängen und Entfernen an beiden Enden:

append(...)
appendleft(...)
pop()
popleft()

Eine begrenzte Historie

deque kann außerdem eine maximale Länge besitzen:

from collections import deque

letzte_befehle = deque(maxlen=5)

letzte_befehle.append("umsehen")
letzte_befehle.append("nimm fackel")
letzte_befehle.append("norden")

Sobald die deque voll ist und am rechten Ende ein neuer Wert hinzukommt, verschwindet automatisch der älteste Wert auf der anderen Seite.

Das eignet sich beispielsweise für die letzten fünf:

  • Befehle,
  • Log-Meldungen,
  • Treffer,
  • besuchten Räume.

Für beliebigen Zugriff mitten in einer großen Collection bleibt eine Liste oft die passendere Struktur. Die Stärke von deque liegt vor allem an ihren beiden Enden.

itertools: Iteratoren zusammensetzen

Das Standardmodul itertools enthält Werkzeuge zum Erzeugen und Kombinieren von Iteratoren.

Viele davon arbeiten lazy: Werte werden erst erzeugt, wenn wir sie tatsächlich anfordern.

Ein einfaches Beispiel ist count(...):

from itertools import count

nummern = count(start=1)

print(next(nummern))
print(next(nummern))
print(next(nummern))

Ausgabe:

1
2
3

count(...) zählt ohne festes Ende weiter.

Mit einem anderen Schritt geht beispielsweise:

nummern = count(start=10, step=5)

Das liefert:

10, 15, 20, 25, ...

cycle(...): immer wieder von vorn

cycle(...) wiederholt die Elemente eines Iterables:

from itertools import cycle

wetter = cycle(["klar", "neblig", "regnerisch"])

print(next(wetter))
print(next(wetter))
print(next(wetter))
print(next(wetter))

Ausgabe:

klar
neblig
regnerisch
klar

Nach dem letzten Element beginnt der Iterator wieder von vorn.

Auch dieser Iterator endet nicht von selbst.

islice(...): einen Iterator begrenzen

Mit islice(...) kannst Du einen Ausschnitt aus einem Iterator nehmen:

from itertools import count, islice

erste_fuenf = list(islice(count(start=1), 5))

print(erste_fuenf)

Ausgabe:

[1, 2, 3, 4, 5]

Das ist besonders bei endlosen Iteratoren nützlich.

Dieser Code wäre dagegen keine gute Idee:

list(count())

count() hört nicht auf, neue Werte zu liefern. Die Liste könnte deshalb nie fertig aufgebaut werden.

Mit:

list(islice(count(), 10))

setzen wir ausdrücklich eine Grenze.

Generatoren: Werte auf Abruf

Das Prinzip hinter vielen Iteratoren können wir in Python selbst verwenden.

Eine normale Funktion gibt mit return ein Ergebnis zurück und beendet damit ihren aktuellen Aufruf:

def quadrat(zahl):
    return zahl * zahl

Enthält eine Funktion dagegen yield, wird sie zu einer Generatorfunktion:

def zaehle_bis_drei():
    yield 1
    yield 2
    yield 3

Wichtig ist bereits der Aufruf:

zahlen = zaehle_bis_drei()

Der Funktionskörper wird dabei noch nicht bis zum ersten yield ausgeführt. Stattdessen erhalten wir ein Generatorobjekt.

Mit next(...) fordern wir Werte an:

print(next(zahlen))
print(next(zahlen))
print(next(zahlen))

Ausgabe:

1
2
3

Bei jedem yield liefert der Generator einen Wert und pausiert seinen Ausführungszustand.

Beim nächsten next(...) läuft er an dieser Stelle weiter.

Wenn ein Generator zu Ende ist

Nach dem letzten yield ist der Generator erschöpft.

Ein weiterer Aufruf:

next(zahlen)

führt dann zu:

StopIteration

Bei einer normalen for-Schleife musst Du diese Exception nicht selbst behandeln:

for zahl in zaehle_bis_drei():
    print(zahl)

Die Schleife erkennt automatisch, wann der Iterator keine weiteren Werte mehr liefert.

Genau deshalb funktionieren Generatoren überall dort, wo Python einen Iterator erwartet.

Ein endloser Generator

Auch Generatoren können absichtlich kein Ende besitzen:

import random


def ereignis_strom(ereignisse):
    """Liefert fortlaufend zufällige Ereignisse."""
    while True:
        yield random.choice(ereignisse)

Verwendung:

ereignisse = [
    "Eine Fledermaus flattert durch den Raum.",
    "Irgendwo tropft Wasser.",
    "Du hörst ein fernes Knurren.",
]

strom = ereignis_strom(ereignisse)

print(next(strom))
print(next(strom))
print(next(strom))

Eine Schleife über den Generator würde ohne weitere Abbruchbedingung ebenfalls endlos laufen:

for ereignis in strom:
    print(ereignis)

Mit islice(...) können wir ihn begrenzen:

from itertools import islice

for ereignis in islice(strom, 3):
    print(ereignis)

So werden genau drei Ereignisse angefordert.

Generator Expressions

Für einfache Fälle brauchen wir nicht einmal eine eigene Generatorfunktion.

Eine List Comprehension kennst Du aus Teil 6:

quadrate = [zahl * zahl for zahl in range(1, 6)]

Eine Generator Expression verwendet runde Klammern:

quadrate = (zahl * zahl for zahl in range(1, 6))

Der Unterschied ist wichtiger als die Klammerform.

Die Liste erzeugt ihre Elemente sofort:

liste = [zahl * zahl for zahl in range(1, 6)]

Der Generator liefert sie erst beim Iterieren:

generator = (zahl * zahl for zahl in range(1, 6))

for quadrat in generator:
    print(quadrat)

Ausgabe:

1
4
9
16
25

Das kann Speicher sparen und erlaubt es, große oder sogar endlose Datenströme schrittweise zu verarbeiten.

Generatoren werden verbraucht

Ein Generator merkt sich, wie weit er bereits gekommen ist.

zahlen = (zahl for zahl in range(3))

print(list(zahlen))
print(list(zahlen))

Ausgabe:

[0, 1, 2]
[]

Nach dem ersten list(...) ist der Generator vollständig durchlaufen.

Er startet beim zweiten Mal nicht automatisch von vorn.

Möchtest Du dieselbe Berechnung erneut ausführen, erzeugst Du einen neuen Generator:

zahlen = (zahl for zahl in range(3))
print(list(zahlen))

zahlen = (zahl for zahl in range(3))
print(list(zahlen))

Eine normale Liste verhält sich anders. Ihre gespeicherten Elemente kannst Du beliebig oft durchlaufen.

Wann Generatoren interessant werden

Bei unseren drei Dungeon-Räumen spielt der Speicherverbrauch keine Rolle.

Generatoren werden interessanter, wenn Du beispielsweise:

  • eine sehr große Datei zeilenweise verarbeitest,
  • Ergebnisse einer Datenbank nach und nach erhältst,
  • eine Pipeline aus mehreren Verarbeitungsschritten baust,
  • nur die ersten Treffer einer großen Berechnung brauchst,
  • eine potenziell endlose Folge modellierst.

Du musst deshalb nicht jede List Comprehension sofort in eine Generator Expression umwandeln.

Wenn Du alle Werte ohnehin mehrfach brauchst, ist eine Liste oft genau richtig.

Decorators: Verhalten um Definitionen legen

Decorators hast Du vielleicht schon in fremdem Python-Code gesehen:

@dataclass
class Gegenstand:
    ...

oder:

@pytest.fixture
def spieler():
    ...

Die @...-Syntax erlaubt es, eine Funktion oder Klasse bei ihrer Definition durch einen Decorator verarbeiten zu lassen.

Für den Anfang betrachten wir einen Function Decorator:

def mit_protokoll(funktion):
    def wrapper(*args, **kwargs):
        print(f"[log] {funktion.__name__} wird aufgerufen")
        return funktion(*args, **kwargs)

    return wrapper

Damit dekorieren wir eine Funktion:

@mit_protokoll
def speichern(name):
    print(f"{name} gespeichert.")

Ein Aufruf:

speichern("Karl")

liefert:

[log] speichern wird aufgerufen
Karl gespeichert.

Was @mit_protokoll bedeutet

Vereinfacht entspricht:

@mit_protokoll
def speichern(name):
    print(f"{name} gespeichert.")

dieser Idee:

def speichern(name):
    print(f"{name} gespeichert.")


speichern = mit_protokoll(speichern)

Der Decorator bekommt das Funktionsobjekt und liefert einen Wert zurück, der anschließend an den Namen speichern gebunden wird.

In unserem Beispiel ist dieser neue Wert die innere Funktion wrapper.

Damit können wir Verhalten um eine Funktion legen, ohne ihren eigentlichen Funktionskörper zu verändern.

Typische Anwendungen von Decorators sind beispielsweise:

  • Caching,
  • Logging,
  • Registrierung,
  • Zugriffskontrolle,
  • Web-Routing,
  • Tests und Fixtures.

*args und **kwargs im Wrapper

Unser Wrapper enthält:

def wrapper(*args, **kwargs):

Damit kann er beliebige Positions- und Keyword Arguments entgegennehmen.

Anschließend reicht er sie an die ursprüngliche Funktion weiter:

return funktion(*args, **kwargs)

Ohne diese Schreibweise müsste unser Decorator die genaue Signatur jeder dekorierten Funktion kennen.

*args sammelt Positionsargumente, **kwargs Keyword Arguments.

Das ist ein weiterer Bereich, den Du später noch deutlich tiefer erkunden kannst. Für das Grundprinzip des Decorators reicht zunächst zu verstehen, warum ein allgemeiner Wrapper diese Argumente durchreichen kann.

functools.wraps: die Originalfunktion erkennbar halten

Unser einfacher Decorator hat einen Haken.

Nach dem Dekorieren:

print(speichern.__name__)

würde ohne weitere Maßnahmen ausgeben:

wrapper

Auch andere Metadaten stammen dann vom Wrapper statt von der ursprünglichen Funktion.

Dafür gibt es functools.wraps:

from functools import wraps


def mit_protokoll(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        print(f"[log] {funktion.__name__} wird aufgerufen")
        return funktion(*args, **kwargs)

    return wrapper

Nun:

@mit_protokoll
def speichern(name):
    """Speichert den aktuellen Spielstand."""
    print(f"{name} gespeichert.")

und:

print(speichern.__name__)
print(speichern.__doc__)

liefern wieder Metadaten der ursprünglichen Funktion.

wraps setzt außerdem unter anderem __wrapped__, wodurch Werkzeuge bei Bedarf an die verpackte Funktion gelangen können.

Wenn Du selbst Wrapper-Decorators schreibst, gehört @wraps(...) deshalb fast immer dazu.

Decorators mit Argumenten

Manche Decorators stehen direkt über der Definition:

@cache
def berechne_etwas():
    ...

Andere sehen aus wie ein Funktionsaufruf:

@route("/status")
def status():
    ...

Hier wird zunächst der Ausdruck

route("/status")

ausgewertet. Das Ergebnis muss wiederum etwas sein, das als Decorator verwendet werden kann.

Deshalb steckt hinter Decorators mit eigenen Argumenten meist eine weitere Ebene.

Für diese Grundlagenserie müssen wir das nicht selbst bauen. Wichtig ist, die Struktur wiederzuerkennen.

Wenn Du Decorators genauer verstehen möchtest:

Decorators entmystifiziert

Type Hints: Typinformationen für Menschen und Werkzeuge

Python ist dynamisch typisiert:

wert = 10
wert = "zehn"

Der Name wert ist nicht dauerhaft an int gebunden.

Trotzdem können wir Typen annotieren:

def erleide_schaden(hp: int, menge: int) -> int:
    return max(hp - menge, 0)

Damit drücken wir aus:

  • hp wird als int erwartet,
  • menge wird als int erwartet,
  • der Return Value soll ein int sein.

Python erzwingt diese Angaben bei einem normalen Funktionsaufruf nicht.

Der Interpreter verhindert also nicht allein wegen der Annotation:

erleide_schaden("viel", 3)

Dass dieser konkrete Code anschließend bei der Subtraktion scheitert, ist eine andere Sache.

Type Hints helfen stattdessen vor allem:

  • Lesern des Codes,
  • IDEs und Editoren,
  • Autocompletion,
  • Refactoring-Werkzeugen,
  • statischen Type Checkern.

Bekannte Type Checker sind beispielsweise mypy, Pyright und ty.

Type Hints im Dungeon

Einige unserer Funktionen könnten so aussehen:

def frage_ganzzahl(
    text: str,
    minimum: int | None = None,
) -> int:
    ...

Oder:

def teile_befehl(eingabe: str) -> tuple[str, str]:
    ...

Für Collections:

def finde_raeume_mit_loot(
    raeume: dict[str, Raum],
) -> list[str]:
    ...

Auch Attribute können annotiert werden:

class Spieler:
    name: str
    hp: int
    max_hp: int
    gold: int
    inventar: list[Gegenstand]

Bei Dataclasses spielen Annotationen sogar eine zentrale Rolle:

from dataclasses import dataclass


@dataclass
class Gegenstand:
    name: str
    beschreibung: str = ""

Du musst deshalb nicht sofort jedes lokale Detail mit einem Typ versehen. Besonders wertvoll sind Type Hints an den Schnittstellen zwischen verschiedenen Teilen eines Programms.

Was sich mit Python 3.14 bei Annotationen geändert hat

Bis Python 3.13 wurden Annotation-Ausdrücke standardmäßig beim Definieren einer Funktion oder Klasse ausgewertet.

Seit Python 3.14 verwendet Python standardmäßig deferred evaluation: Annotationen werden erst ausgewertet, wenn ihr Wert tatsächlich angefordert wird.

Das hilft unter anderem bei Forward References.

Ein vereinfachtes Beispiel:

def begruesse(spieler: Spieler) -> None:
    print(spieler.name)


class Spieler:
    ...

In Python 3.14 muss Spieler beim Definieren von begruesse(...) noch nicht bereits als normaler Name aufgelöst werden.

Das bedeutet aber nicht, dass Annotationen nur noch bedeutungslose Strings wären. Python 3.14 hat dafür ein neues Auswertungsmodell und mit annotationlib auch neue Werkzeuge zur Introspection.

Für diese Serie reicht die praktische Konsequenz:

Type Hints werden von Python nicht automatisch als Laufzeit-Typprüfung durchgesetzt.

Wenn Dein Programm echte Runtime Validation benötigt, brauchst Du dafür weiterhin eigenen Code oder ein dafür vorgesehenes Werkzeug.

Tests: Erwartungen automatisch prüfen

Bisher haben wir den Dungeon vor allem manuell ausprobiert:

Programm starten
Fackel aufnehmen
Inventar anzeigen
Spiel speichern
Spielstand laden
Ergebnis ansehen

Nach jeder Änderung alles von Hand zu wiederholen wird schnell mühsam.

Automatische Tests halten Erwartungen als ausführbaren Code fest.

Ein einfacher Test für unseren Spieler könnte so aussehen:

from modelle import Spieler


def test_schaden_nicht_unter_null():
    spieler = Spieler("Test", max_hp=10)

    spieler.erleide_schaden(99)

    assert spieler.hp == 0

Die Aussage:

assert spieler.hp == 0

ist hier keine interne Zustandsprüfung wie das assert aus Teil 8 (und erst recht keine Validierung von Benutzereingaben), sondern eine Test-Erwartung.

Wenn die Implementierung später versehentlich -89 liefert, schlägt der Test fehl.

pytest

Python bringt mit unittest bereits ein Test-Framework in der Standardbibliothek mit.

Sehr verbreitet ist außerdem das externe Test-Framework pytest.

In einer aktivierten venv:

python -m pip install pytest

Tests starten:

python -m pytest

pytest sucht standardmäßig unter anderem nach Dateien wie:

test_modelle.py
modelle_test.py

und darin nach Funktionen, deren Namen mit test beginnen:

def test_heilung():
    ...

Unser Beispiel wird dadurch ohne weitere Registrierung gefunden.

pytest mit uv

Wenn Du Dein Projekt mit uv verwaltest, kannst Du pytest als Development Dependency hinzufügen:

uv add --dev pytest

Anschließend:

uv run pytest

Das ist dieselbe grundsätzliche Aufgabe wie bei der klassischen Kombination aus venv und pip: pytest gehört zu den Entwicklungswerkzeugen des Projekts, nicht zur Python-Standardbibliothek.

Was wir im Dungeon testen könnten

Die Klassen aus unserem Dungeon bieten bereits einige gute kleine Testfälle:

def test_heilung_nicht_ueber_max_hp():
    spieler = Spieler("Test", max_hp=100)
    spieler.erleide_schaden(50)

    spieler.heile(999)

    assert spieler.hp == 100

Oder:

def test_raum_ziel_fuer_gueltige_richtung():
    raum = Raum(
        name="Halle",
        beschreibung="Eine dunkle Halle.",
        ausgaenge={"norden": "bibliothek"},
    )

    assert raum.ziel("norden") == "bibliothek"

Auch unsere Eingabezerlegung lässt sich ohne interaktiven Terminal-Input testen:

def test_teile_befehl_mit_argument():
    verb, argument = teile_befehl("nimm fackel")

    assert verb == "nimm"
    assert argument == "fackel"

Hier zeigt sich der Nutzen der bisherigen Refactorings.

Eine kleine Funktion wie:

teile_befehl(...)

lässt sich viel leichter automatisiert testen als ein riesiger Game-Loop, der gleichzeitig input(...), Spiellogik und Ausgabe übernimmt.

Tests machen Refactoring weniger riskant

Angenommen, wir möchten teile_befehl(...) später komplett umbauen.

Solange unser Test weiterhin besteht:

assert teile_befehl("nimm fackel") == ("nimm", "fackel")

können wir nach jeder Änderung prüfen, ob dieses erwartete Verhalten erhalten geblieben ist.

Tests beweisen nicht, dass ein Programm fehlerfrei ist. Sie können nur die Fälle prüfen, die wir tatsächlich beschrieben haben.

Aber sie machen bereits bekannte Erwartungen wiederholbar.

Genau das wird bei wachsendem Code sehr wertvoll.

async: wenn ein Programm viel warten muss

Zum Schluss noch ein Thema, das in Python-Code häufig komplizierter aussieht, als seine Grundidee eigentlich ist.

Eine Coroutine Function wird mit async def definiert:

import asyncio


async def lade_welt():
    await asyncio.sleep(1)
    return "Welt geladen"

Der Aufruf:

lade_welt()

führt die Coroutine nicht einfach wie eine normale Funktion vollständig aus. Er erzeugt zunächst ein Coroutine Object.

Für einen obersten Einstiegspunkt können wir beispielsweise verwenden:

ergebnis = asyncio.run(lade_welt())

print(ergebnis)

asyncio.run(...) startet den Asyncio-Event-Loop für diese Coroutine und wartet auf ihr Ergebnis.

Was await macht

Innerhalb einer Coroutine kann:

await asyncio.sleep(1)

die aktuelle Task pausieren.

Während sie wartet, kann der Event Loop andere bereite Tasks ausführen.

Das ist der entscheidende Punkt: async hilft vor allem dabei, Concurrency zu organisieren, wenn mehrere Aufgaben immer wieder auf etwas warten.

Typische Beispiele sind:

  • Netzwerkzugriffe,
  • viele gleichzeitige Verbindungen,
  • Webserver,
  • Bots,
  • asynchrone Datenbanktreiber,
  • andere I/O-lastige Abläufe.

Das bedeutet nicht, dass jede normale blockierende Operation durch ein vorangestelltes await plötzlich asynchron wird. Die verwendete Operation muss selbst als Awaitable in dieses Modell passen.

async ist nicht dasselbe wie Parallelität

asyncio kann mehrere Tasks so koordinieren, dass während einer Wartezeit eine andere weiterarbeitet.

Das ist nicht dasselbe wie mehrere CPU-Kerne gleichzeitig mit rechenintensiven Aufgaben zu beschäftigen.

Ein Beispiel:

async def berechne_sehr_lange():
    while True:
        komplizierte_berechnung()

wird nicht allein durch async def freundlich zum Event Loop.

Solange der Code keine passende Stelle erreicht, an der die Kontrolle zurückgegeben wird, kann er andere Tasks blockieren.

Darum ist diese Aussage eine brauchbare Faustregel:

asyncio ist besonders interessant für I/O-bound Concurrency, nicht als allgemeiner Turbo für CPU-Berechnungen.

Für unseren kleinen Terminal-Dungeon brauchen wir es nicht.

Packaging: aus dem Projekt wird ein Package

In Teil 10 haben wir mehrere .py-Dateien in einen Ordner gelegt:

dungeon-projekt/
├── befehle.py
├── eingabe.py
├── fehler.py
├── modelle.py
├── speicher.py
├── spiel.py
└── welt.py

Das funktioniert für unser Lernprojekt.

Ein nächster Schritt könnte eine richtige Package-Struktur sein. Eine häufig verwendete Variante ist das src Layout:

dungeon-projekt/
├── pyproject.toml
├── src/
│   └── dungeon/
│       ├── __init__.py
│       ├── befehle.py
│       ├── eingabe.py
│       ├── fehler.py
│       ├── modelle.py
│       ├── speicher.py
│       ├── spiel.py
│       └── welt.py
└── tests/
    └── test_modelle.py

src/ ist keine Pflicht für Python-Packages. Es ist eine verbreitete Projektstruktur, die unter anderem sauber zwischen dem importierbaren Package und den übrigen Projektdateien trennt.

Die Datei:

pyproject.toml

beschreibt unter anderem Metadaten, Build-Konfiguration und Dependencies des Projekts.

Ein echtes Kommando definieren

In Teil 10 starteten wir:

python spiel.py

Ein installiertes Package kann stattdessen ein richtiges Kommando bereitstellen:

dungeon

Dafür können wir in pyproject.toml beispielsweise einen Script Entry Point deklarieren:

[project.scripts]
dungeon = "dungeon.spiel:main"

Der Name links:

dungeon

ist das spätere Kommando.

Der Wert rechts verweist auf:

dungeon.spiel:main

also die Funktion main im Modul dungeon.spiel.

Nach einer passenden Installation erzeugt das Packaging-Werkzeug dafür einen ausführbaren Wrapper.

Unsere bisherige Entscheidung, den Programmablauf in:

def main():
    ...

zu kapseln, zahlt sich damit erneut aus.

Type Hints, Tests und Packaging greifen ineinander

An dieser Stelle beginnen viele Themen aus dem Ausblick zusammenzuarbeiten.

Eine Funktion:

def heile(hp: int, menge: int, max_hp: int) -> int:
    return min(hp + menge, max_hp)

hat eine klar erkennbare Schnittstelle.

Ein Test beschreibt das erwartete Verhalten:

def test_heile_nicht_ueber_max_hp():
    assert heile(90, 20, 100) == 100

Ein Package sorgt dafür, dass der Code sauber importiert und installiert werden kann.

Ein Type Checker untersucht die Typbeziehungen, pytest prüft konkrete Laufzeiterwartungen und das Packaging organisiert, wie das Projekt verteilt und gestartet wird.

Keines dieser Werkzeuge ersetzt die anderen.

Was Du nach der Serie können solltest

Das Ziel dieser Serie war nicht, möglichst viel Python-Syntax auswendig zu lernen.

Nach elf Teilen solltest Du aber die grundlegenden Bausteine wiedererkennen und selbst zusammensetzen können.

Du weißt inzwischen unter anderem:

  • wie ein Python-Programm ausgeführt wird,
  • wie Namen und Werte verwendet werden,
  • wie Bedingungen und Schleifen den Kontrollfluss steuern,
  • wofür Listen, Tupel, Dictionaries und Sets gedacht sind,
  • wie Funktionen Code in Aufgaben zerlegen,
  • was Parameter, Return Values und Scope bedeuten,
  • wie Klassen State und Verhalten zusammenfassen,
  • wie Exceptions entstehen und behandelt werden,
  • wie Dateien gelesen und geschrieben werden,
  • wie ein Spielstand als JSON gespeichert werden kann,
  • wie Module ein größeres Programm aufteilen,
  • wofür Virtual Environments und Dependencies gebraucht werden.

Mit diesem Fundament wirst Du in fremdem Python-Code weiterhin Dinge finden, die Du noch nicht kennst. Der Unterschied ist: Viele davon lassen sich jetzt in einen bereits bekannten Zusammenhang einordnen.

Was Du nicht sofort lernen musst

Python ist groß genug, dass auch erfahrene Entwickler regelmäßig in der Dokumentation nachsehen.

Du musst deshalb nicht direkt Themen wie diese beherrschen:

  • Metaclasses,
  • Descriptors im Detail,
  • komplexe Generic Types,
  • asynchrone Generatoren,
  • Multiprocessing,
  • C Extensions,
  • eigene Import Hooks,
  • fortgeschrittenes Packaging,
  • komplizierte Decorator Factories.

Wenn Du einen Begriff davon in einem Projekt brauchst, hast Du einen guten Grund, ihn dann genauer zu lernen.

Sinnvolle nächste Schritte

Was nach dieser Serie sinnvoll ist, hängt stärker von Deinem Ziel ab als von einer festen Reihenfolge.

Den Dungeon weiterbauen

Der naheliegendste Weg ist, das vorhandene Projekt auszubauen.

Zum Beispiel mit:

  • verschlossenen Türen,
  • Gegnern,
  • einem Kampfsystem,
  • Heiltränken,
  • verschiedenen Gegenstandstypen,
  • mehreren Savegames,
  • zufälligen Ereignissen,
  • einer Karte,
  • einem Shop.

Dabei werden schnell echte Designfragen auftauchen.

Wo gehört eine Kampfregel hin? Wie speichert man verschiedene Gegenstandstypen? Wie testet man zufällige Ereignisse? Wie migriert man einen alten Spielstand?

Genau an solchen Problemen lernt man viel mehr als an isolierten Syntaxbeispielen.

Type Hints ergänzen

Du kannst den bestehenden Dungeon schrittweise annotieren:

def teile_befehl(eingabe: str) -> tuple[str, str]:
    ...

Danach kannst Du einen Type Checker auf das Projekt loslassen.

Du musst dafür nicht sofort jede Variable annotieren. Starte an den Grenzen zwischen Modulen und bei Funktionen, deren Ein- und Ausgaben wichtig sind.

Tests schreiben

Ein guter Anfang sind Regeln, die ohne Terminal-Eingabe getestet werden können:

Spieler.erleide_schaden(...)
Spieler.heile(...)
Raum.ziel(...)
teile_befehl(...)
Savegame erzeugen und rekonstruieren

Beim Speichern eignen sich temporäre Dateien, damit Tests nicht Deinen echten spielstand.json verändern.

Ein kleines echtes Werkzeug bauen

Ein zweites Projekt muss kein Spiel sein.

Nimm eine Aufgabe, die Du selbst tatsächlich hast, zum Beispiel:

  • Dateien umbenennen,
  • Markdown-Dateien prüfen,
  • JSON formatieren,
  • Logs auswerten,
  • Daten aus CSV-Dateien verarbeiten,
  • wiederkehrende Arbeiten automatisieren.

Damit triffst Du automatisch auf neue Python-Themen, ohne künstlich nach einer Übung suchen zu müssen.

Ein Framework ausprobieren

Wenn Dich Webentwicklung interessiert, kannst Du Dir beispielsweise Flask, FastAPI oder Django ansehen.

Für Datenanalyse wirst Du schnell auf Pandas, NumPy und Jupyter stoßen.

Für Command-Line-Tools gibt es in der Standardbibliothek argparse; externe Libraries wie Click oder Typer bauen darauf einen komfortableren Workflow.

Frameworks werden wesentlich verständlicher, wenn Du die Python-Konzepte darunter bereits erkennst.

Stolperfallen

  • random für Security verwenden: Für Spielzufall ist es richtig, für sicherheitsrelevante Tokens gibt es secrets.

  • Einen festen Seed versehentlich im Spielbetrieb lassen: Reproduzierbarer Zufall ist für Tests praktisch, macht ein Spiel aber ebenfalls reproduzierbar.

  • Counter und defaultdict für dasselbe halten: Counter ist speziell fürs Zählen gedacht. defaultdict kann beliebige Default-Werte über eine Factory erzeugen.

  • Bei defaultdict jeden Zugriff für gleich halten: Der Zugriff mit [] kann einen fehlenden Eintrag erzeugen. .get(...) verwendet die Default Factory nicht auf dieselbe Weise.

  • Für eine Queue ständig list.pop(0) verwenden: Wenn Du häufig an beiden Enden arbeitest, ist eine deque meist besser geeignet.

  • Endlose Iteratoren vollständig materialisieren:

python list(count())

Dieser Ausdruck kann nicht regulär fertig werden.

  • Generatoren zweimal durchlaufen wollen: Ein erschöpfter Generator beginnt nicht automatisch von vorn.

  • Generator und Generatorfunktion verwechseln: Die Funktion mit yield erzeugt beim Aufruf ein Generatorobjekt. Die Werte entstehen erst beim Iterieren.

  • Eine Generator Expression nur wegen des geringeren Speichers verwenden: Wenn Du die Werte mehrmals brauchst, kann eine Liste die bessere Wahl sein.

  • Decorators als besondere Magie behandeln: Eine Decorator Expression wird bei der Definition ausgewertet und verarbeitet anschließend die Funktion oder Klasse.

  • Bei Wrappern functools.wraps vergessen: Dann zeigen Metadaten wie __name__ und __doc__ häufig auf den Wrapper statt auf die ursprüngliche Funktion.

  • Type Hints als automatische Runtime Validation verstehen: Python erzwingt Function- und Variable-Annotations normalerweise nicht.

  • Alte Annahmen über Annotationen auf Python 3.14 übertragen: Annotationen werden seit Python 3.14 standardmäßig deferred beziehungsweise lazy ausgewertet.

  • Tests nur über den kompletten Game-Loop schreiben: Kleine Funktionen und Methoden sind wesentlich einfacher gezielt zu testen.

  • Einen erfolgreichen Test mit einem Beweis verwechseln: Ein Test bestätigt nur die Fälle, die Du tatsächlich beschrieben hast.

  • async mit Parallelität gleichsetzen: Ein Event Loop kann während geeigneter Wartezeiten andere Tasks ausführen. Das macht CPU-lastigen Code nicht automatisch parallel.

  • Eine Coroutine wie eine normale Funktion aufrufen: Der Aufruf einer async def-Funktion erzeugt zunächst ein Coroutine Object. Es muss ausgeführt beziehungsweise awaited werden.

  • src/ für zwingend halten: Das src Layout ist eine verbreitete Packaging-Struktur, aber keine Voraussetzung für jedes Python-Package.

  • Module, Import Packages und Distribution Packages verwechseln: Diese Begriffe beschreiben unterschiedliche Ebenen des Python-Ökosystems und müssen nicht denselben Namen besitzen.

Übungen

1. Zufällige Beute erzeugen

Schreibe eine Funktion zufalls_beute(), die zufällig einen Gegenstand aus einer Liste zurückgibt.

Lösung
import random


def zufalls_beute():
    beute = [
        "Gold",
        "Trank",
        "Fackel",
        "Schlüssel",
    ]

    return random.choice(beute)
Verwendung:
print(zufalls_beute())

2. Beute zählen

Nutze Counter, um zu zählen, wie oft jeder Gegenstand in dieser Liste vorkommt:

beute = ["Gold", "Gold", "Trank", "Fackel", "Gold"]
Lösung
from collections import Counter

beute = ["Gold", "Gold", "Trank", "Fackel", "Gold"]

zaehlung = Counter(beute)

print(zaehlung)
print(zaehlung.most_common(1))
Ausgabe:
Counter({'Gold': 3, 'Trank': 1, 'Fackel': 1})
[('Gold', 3)]

3. Die letzten Befehle speichern

Verwende eine deque, die nur die letzten drei Befehle behält.

Lösung
from collections import deque

letzte_befehle = deque(maxlen=3)

letzte_befehle.append("umsehen")
letzte_befehle.append("nimm fackel")
letzte_befehle.append("norden")
letzte_befehle.append("status")

print(list(letzte_befehle))
Ausgabe:
['nimm fackel', 'norden', 'status']
Nach dem vierten Eintrag wurde `"umsehen"` automatisch entfernt.

4. Einen Generator schreiben

Schreibe einen Generator zaehler(start), der ab start endlos aufwärts zählt.

Lösung
def zaehler(start):
    zahl = start

    while True:
        yield zahl
        zahl += 1
Verwendung:
zahlen = zaehler(10)

print(next(zahlen))
print(next(zahlen))
print(next(zahlen))
Ausgabe:
10
11
12

5. Einen Decorator schreiben

Schreibe einen Decorator mit_log(...), der vor jedem Aufruf den Namen der dekorierten Funktion ausgibt.

Lösung
from functools import wraps


def mit_log(funktion):
    @wraps(funktion)
    def wrapper(*args, **kwargs):
        print(f"[log] {funktion.__name__} wird aufgerufen")
        return funktion(*args, **kwargs)

    return wrapper


@mit_log
def begruesse(name):
    print(f"Hallo, {name}!")


begruesse("Karl")
Ausgabe:
[log] begruesse wird aufgerufen
Hallo, Karl!

6. Eine Funktion typisieren

Ergänze Type Hints:

def heile(hp, menge, max_hp):
    return min(hp + menge, max_hp)
Lösung
def heile(hp: int, menge: int, max_hp: int) -> int:
    return min(hp + menge, max_hp)
Die Annotationen dokumentieren die erwarteten Typen. Python erzwingt sie beim normalen Aufruf nicht automatisch.

7. Einen ersten Test schreiben

Schreibe mit pytest einen Test dafür, dass die Heilfunktion max_hp nicht überschreitet.

Lösung
def heile(hp: int, menge: int, max_hp: int) -> int:
    return min(hp + menge, max_hp)


def test_heile_nicht_ueber_max_hp():
    assert heile(90, 20, 100) == 100
Mit pytest:
python -m pytest
Oder in einem uv-Projekt:
uv run pytest

8. Einen CLI Entry Point definieren

Angenommen, Deine Funktion main() liegt nach dem Packaging in dungeon/spiel.py.

Ergänze in pyproject.toml einen Eintrag, damit nach der Installation das Kommando dungeon zur Verfügung steht.

Lösung
[project.scripts]
dungeon = "dungeon.spiel:main"
Der Eintrag verweist auf die Funktion:
dungeon.spiel.main
Ein kompatibles Installationstool kann daraus beim Installieren des Projekts das Kommando `dungeon` erzeugen.

Vertiefungen zu diesem Ausblick

Fast jedes Thema aus diesem Abschluss verdient einen eigenen Artikel:

Geschafft

Damit endet die Python-Grundlagenserie.

In Teil 1 bestand unser Programm im Wesentlichen aus:

print("Hallo, Welt!")

Inzwischen haben wir daraus Schritt für Schritt ein kleines Textadventure gebaut. Es besitzt Spieler-State, Räume und Gegenstände, verarbeitet Befehle, behandelt Fehler, speichert seinen State als JSON und ist auf mehrere Module verteilt.

Dabei war der Dungeon vor allem Mittel zum Zweck. Dieselben Grundlagen findest Du in Webanwendungen, Automatisierungsskripten, CLI-Tools, Bots, Datenverarbeitung und vielen anderen Python-Projekten wieder.

Du wirst beim nächsten Projekt trotzdem Dinge nachschlagen müssen. Das gehört dazu. Entscheidend ist, dass Begriffe wie Iterator, Exception, Decorator, Dependency oder Entry Point jetzt nicht mehr völlig losgelöst vor Dir stehen.

Such Dir als Nächstes ein konkretes Problem und bau etwas damit.

Zurück zum Anfang der Serie.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!