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:
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:
hpwird alsinterwartet,mengewird alsinterwartet,- der Return Value soll ein
intsein.
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:
asyncioist 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
-
randomfür Security verwenden: Für Spielzufall ist es richtig, für sicherheitsrelevante Tokens gibt essecrets. -
Einen festen Seed versehentlich im Spielbetrieb lassen: Reproduzierbarer Zufall ist für Tests praktisch, macht ein Spiel aber ebenfalls reproduzierbar.
-
Counterunddefaultdictfür dasselbe halten:Counterist speziell fürs Zählen gedacht.defaultdictkann beliebige Default-Werte über eine Factory erzeugen. -
Bei
defaultdictjeden 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 einedequemeist 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
yielderzeugt 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.wrapsvergessen: 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.
-
asyncmit 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)
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))
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))
['nimm fackel', 'norden', 'status']
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
zahlen = zaehler(10)
print(next(zahlen))
print(next(zahlen))
print(next(zahlen))
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")
[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)
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
python -m pytest
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"
dungeon.spiel.main
Vertiefungen zu diesem Ausblick
Fast jedes Thema aus diesem Abschluss verdient einen eigenen Artikel:
- Type Hints in der Praxis: mypy, Pyright und Co.
- pytest von Null
- Generatoren und yield wirklich verstehen
- Decorators entmystifiziert
- Vom Skript zum echten CLI
- uv: pip und venv in schnell
- Ruff: Linter und Formatter in einem
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
- Python lernen, Teil 10: Module und Projektstruktur
- Logging statt print
- Debuggen mit breakpoint() und pdb
- Python-Dokumentation:
random - Python-Dokumentation:
secrets - Python-Dokumentation:
collections - Python-Dokumentation:
itertools - Python-Dokumentation: Generatoren
- Python-Dokumentation:
functools.wraps - Python-Dokumentation:
typing - pytest-Dokumentation: Getting started
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.