cat posts/python-lernen-10-module-und-projektstruktur.md
Python lernen (Teil 10): Module und Projektstruktur
Code auf mehrere Dateien aufteilen: eigene Module, import, der __main__-Block, klare Verantwortlichkeiten – plus ein erster Einstieg in venv und pip.
Unsere dungeon.py ist über die letzten Teile deutlich gewachsen.
Inzwischen enthält sie:
- Klassen für Spieler, Räume und Gegenstände,
- eigene Exceptions,
- die Weltdefinition,
- Eingabefunktionen,
- Befehlsverarbeitung,
- Speichern und Laden mit JSON,
- den eigentlichen Game-Loop.
Das funktioniert, wird aber zunehmend unübersichtlich. Wenn alles in einer Datei steht, musst Du beim Ändern ständig durch Code scrollen, der mit Deiner aktuellen Aufgabe gar nichts zu tun hat. Außerdem ist nicht mehr sofort erkennbar, welche Teile fachlich zusammengehören.
Heute zerlegen wir den Dungeon deshalb in mehrere Python-Dateien.
Dabei lernst Du:
- was ein Modul ist,
- wie
importfunktioniert, - was beim Import eines Moduls passiert,
- wofür
if __name__ == "__main__"gut ist, - wie wir Verantwortlichkeiten auf mehrere Dateien verteilen,
- wie
venvProjekte voneinander isoliert, - wie Du mit
pipexterne Pakete (Packages) installierst.
Ein Modul ist eine Python-Datei
Eine Python-Datei mit der Endung .py kann als Modul verwendet werden.
Angenommen, neben Deinem Hauptskript liegt eine Datei werkzeuge.py:
# werkzeuge.py
import random
def wuerfel(seiten=6):
"""Liefert eine zufällige Zahl von 1 bis seiten."""
return random.randint(1, seiten)
Diese Datei können wir aus einer anderen Python-Datei importieren:
# spiel.py
import werkzeuge
wurf = werkzeuge.wuerfel(20)
print(wurf)
Der Import
import werkzeuge
macht das Modul unter dem Namen werkzeuge verfügbar.
Auf seine Funktion greifen wir anschließend über diesen Modulnamen zu:
werkzeuge.wuerfel(20)
Das ist etwas länger als nur wuerfel(20), hat aber einen Vorteil: Im Code ist
sofort zu erkennen, woher die Funktion stammt.
Einzelne Namen importieren
Du kannst auch gezielt einen Namen aus einem Modul importieren:
from werkzeuge import wuerfel
wurf = wuerfel(20)
print(wurf)
Jetzt steht wuerfel direkt im aktuellen Namensraum und der Modulname muss beim
Aufruf nicht mehr davorstehen.
Beide Formen sind üblich:
import werkzeuge
from werkzeuge import wuerfel
Welche besser passt, hängt vom Kontext ab.
Als grobe Orientierung:
import modulhält die Herkunft der verwendeten Namen sichtbar.from modul import nameist angenehm für wenige, häufig verwendete Namen.from modul import *solltest Du im normalen Anwendungscode vermeiden.
Bei einem Star Import:
from werkzeuge import *
landen viele Namen des Moduls direkt im aktuellen Namensraum. Welche Namen dort plötzlich verfügbar sind und woher sie kommen, ist beim Lesen des Codes deutlich schwerer zu erkennen.
Explizite Imports sind fast immer verständlicher.
Die Standardbibliothek besteht ebenfalls aus Modulen
import hast Du in dieser Serie längst verwendet:
import json
import logging
from pathlib import Path
json, logging und pathlib gehören zur Python-Standardbibliothek.
Eigene Module und Module der Standardbibliothek werden mit derselben grundlegenden Syntax importiert:
import json # Standardbibliothek
import werkzeuge # unser eigenes Modul
Der Unterschied besteht darin, wo Python den jeweiligen Code findet.
Wo Python nach Modulen sucht
Bei
import werkzeuge
sucht Python nicht einfach irgendwo auf Deiner Festplatte nach
werkzeuge.py.
Es gibt dafür einen Module Search Path, den Du bei Bedarf über sys.path
ansehen kannst:
import sys
print(sys.path)
Beim Start eines Skripts gehört unter anderem das Verzeichnis des gestarteten Skripts zu diesem Suchpfad.
Haben wir also:
dungeon-projekt/
├── spiel.py
└── werkzeuge.py
und starten spiel.py, kann das danebenliegende Modul mit
import werkzeuge
gefunden werden.
Später kommen unter anderem Verzeichnisse der Python-Installation und
site-packages hinzu, wo installierte Drittanbieter-Packages liegen.
Für unsere aktuelle Projektstruktur reicht zunächst:
Liegen unsere Module neben
spiel.py, kannspiel.pysie direkt über ihren Modulnamen importieren.
Was beim Import passiert
Ein wichtiger Punkt: Ein Modul wird beim ersten Import in einem laufenden Python-Prozess ausgeführt.
Betrachten wir werkzeuge.py:
print("werkzeuge wird geladen")
def wuerfel(seiten=6):
return 4
Und spiel.py:
import werkzeuge
print("Spiel startet")
print(werkzeuge.wuerfel())
Beim Start erscheint:
werkzeuge wird geladen
Spiel startet
4
Die erste print(...)-Anweisung steht auf Modulebene in werkzeuge.py und wird
beim Import ausgeführt.
Das ist wichtig, weil ein Import nicht bedeutet:
Kopiere einfach die Funktionsdefinitionen aus dieser Datei hierher.
Python führt das Modul aus und erzeugt dabei sein eigenes Modulobjekt und seinen eigenen Namensraum.
Deshalb sollten Module beim Import möglichst wenig überraschende Seiteneffekte haben.
Funktions- und Klassendefinitionen auf Modulebene sind völlig normal:
def wuerfel():
...
class Spieler:
...
Einen interaktiven Game-Loop möchten wir dagegen nicht schon durch
import spiel
starten.
Dafür verwenden wir den __main__-Guard, den wir in den letzten Teilen bereits
eingebaut haben.
Ein Modul wird normalerweise nur einmal geladen
Importierst Du dasselbe Modul ein zweites Mal:
import werkzeuge
import werkzeuge
führt Python dessen Code im selben Interpreter-Prozess normalerweise nicht noch einmal aus.
Geladene Module werden in:
sys.modules
zwischengespeichert.
Der zweite import werkzeuge findet das bereits geladene Modul dort und
verwendet es weiter.
Es gibt Möglichkeiten, Module ausdrücklich neu zu laden. Für unseren Alltagscode ist aber die wichtigere Regel:
Ein mehrfacher
importist kein mehrfaches Ausführen derselben Datei.
Den Dungeon nach Verantwortlichkeiten aufteilen
Je größer ein Programm wird, desto wichtiger wird die Frage:
Welche Datei ist wofür zuständig?
Ein Modul sollte einen nachvollziehbaren Aufgabenbereich haben. Einfach alle hundert Zeilen eine neue Datei anzulegen hilft dagegen wenig.
Für unseren Dungeon bietet sich diese Aufteilung an:
dungeon-projekt/
├── befehle.py # Befehle verarbeiten
├── eingabe.py # Eingaben lesen und prüfen
├── fehler.py # eigene Exceptions
├── modelle.py # Gegenstand, Spieler und Raum
├── speicher.py # Spielstand speichern und laden
├── spiel.py # main() und Game-Loop
└── welt.py # Gegenstandsdaten und Dungeon-Welt
Das ist bewusst noch kein richtiges Python-Package. Wir verteilen unseren Code zunächst nur auf mehrere Module im selben Verzeichnis.
Der Dungeon wird weiterhin mit
python spiel.py
gestartet.
fehler.py: gemeinsame Exceptions
Seit Teil 8 haben wir mehrere eigene Exceptions (in Teil 9 kam SpielstandError
dazu):
class DungeonError(Exception):
...
class UngueltigerBefehlError(DungeonError):
...
class UnmoeglicheAktionError(DungeonError):
...
class SpielstandError(DungeonError):
...
Sie werden an verschiedenen Stellen benötigt:
modelle.pyverwendetUnmoeglicheAktionError.speicher.pyverwendetSpielstandError.eingabe.pyundbefehle.pyverwendenUngueltigerBefehlError.spiel.pybehandelt die gemeinsame BasisklasseDungeonError.
Deshalb bekommen sie ein kleines eigenes Modul.
# fehler.py
class DungeonError(Exception):
"""Basisklasse für erwartbare Fehler im Dungeon."""
class UngueltigerBefehlError(DungeonError):
"""Der eingegebene Befehl ist unbekannt oder unvollständig."""
class UnmoeglicheAktionError(DungeonError):
"""Eine bekannte Aktion ist im aktuellen State nicht möglich."""
class SpielstandError(DungeonError):
"""Der Spielstand kann nicht gespeichert oder geladen werden."""
fehler.py kennt weder den Spieler noch die Welt oder die Speicherdatei. Das
Modul stellt lediglich die gemeinsamen Exception Types bereit.
modelle.py: unsere Klassen
Die Klassen Gegenstand, Spieler und Raum verschieben wir nach
modelle.py.
# modelle.py
from fehler import UnmoeglicheAktionError
class Gegenstand:
"""Ein Gegenstand, der in einem Raum oder Inventar liegen kann."""
def __init__(self, kennung, name, beschreibung=""):
self.kennung = kennung
self.name = name
self.beschreibung = beschreibung
def __str__(self):
return self.name
def untersuche(self):
if self.beschreibung:
return self.beschreibung
return f"An {self.name} fällt Dir nichts Besonderes auf."
class Spieler:
"""Verwaltet den State und das Verhalten des Spielers."""
def __init__(self, name, max_hp=100, gold=0):
name = name.strip()
if not name:
raise ValueError("Der Name darf nicht leer sein.")
if max_hp <= 0:
raise ValueError("max_hp muss größer als 0 sein.")
if gold < 0:
raise ValueError("gold darf nicht negativ sein.")
self.name = name
self.hp = max_hp
self.max_hp = max_hp
self.gold = gold
self.inventar = []
def nimm(self, gegenstand):
self.inventar.append(gegenstand)
def erleide_schaden(self, menge):
if menge < 0:
raise ValueError("Schaden darf nicht negativ sein.")
self.hp = max(self.hp - menge, 0)
def heile(self, menge):
if menge < 0:
raise ValueError("Heilung darf nicht negativ sein.")
self.hp = min(self.hp + menge, self.max_hp)
def ist_besiegt(self):
return self.hp <= 0
def zeige_inventar(self):
if not self.inventar:
print("Dein Inventar ist leer.")
return
print("Inventar:")
for nummer, gegenstand in enumerate(self.inventar, start=1):
print(f" {nummer}) {gegenstand}")
def __str__(self):
return (
f"{self.name} | "
f"HP: {self.hp}/{self.max_hp} | "
f"Gold: {self.gold} | "
f"Inventar: {len(self.inventar)}"
)
class Raum:
"""Ein Raum mit Beschreibung, Ausgängen und Gegenständen."""
def __init__(
self,
name,
beschreibung,
ausgaenge=None,
gegenstaende=None,
):
self.name = name
self.beschreibung = beschreibung
self.ausgaenge = dict(ausgaenge) if ausgaenge is not None else {}
self.gegenstaende = (
list(gegenstaende)
if gegenstaende is not None
else []
)
def beschreibe(self):
print()
print(f"Ort: {self.name}")
print(self.beschreibung)
print("Ausgänge:")
for nummer, richtung in enumerate(self.ausgaenge, start=1):
print(f" {nummer}) {richtung}")
if self.gegenstaende:
print(
"Hier liegt:",
", ".join(str(g) for g in self.gegenstaende),
)
def zeige_gegenstaende(self):
if not self.gegenstaende:
print("Hier liegt nichts Brauchbares.")
return
print(
"Hier liegt:",
", ".join(str(g) for g in self.gegenstaende),
)
def nimm_gegenstand(self, name):
gesuchter_name = name.strip().lower()
for gegenstand in self.gegenstaende:
if gegenstand.name.lower() == gesuchter_name:
self.gegenstaende.remove(gegenstand)
return gegenstand
raise UnmoeglicheAktionError(
f"Hier liegt kein Gegenstand namens {name}."
)
def ziel(self, richtung):
try:
return self.ausgaenge[richtung]
except KeyError:
raise UnmoeglicheAktionError(
f"Du kannst nicht nach {richtung} gehen."
) from None
Dieses Modul kennt nur die drei Klassen und die Exception, die ein Raum bei einer unmöglichen Aktion auslösen kann.
Es importiert weder den Game-Loop noch die Speicherlogik.
welt.py: Räume und Gegenstände erzeugen
Die statische Definition unserer Dungeon-Welt kommt nach welt.py.
# welt.py
from modelle import Gegenstand, Raum
GEGENSTANDS_DATEN = {
"fackel": {
"name": "Fackel",
"beschreibung": "Eine rußige, aber noch brauchbare Fackel.",
},
"schluessel": {
"name": "Schlüssel",
"beschreibung": "Ein schwerer Eisenschlüssel mit rostigen Zähnen.",
},
}
def erstelle_gegenstand(kennung):
"""Erstellt einen Gegenstand anhand seiner stabilen Kennung."""
daten = GEGENSTANDS_DATEN[kennung]
return Gegenstand(
kennung=kennung,
name=daten["name"],
beschreibung=daten["beschreibung"],
)
def erstelle_raeume():
"""Erstellt die Dungeon-Welt."""
return {
"halle": Raum(
name="Halle",
beschreibung=(
"Du stehst in einer dunklen Halle mit moderigem Geruch."
),
ausgaenge={
"norden": "bibliothek",
"osten": "krypta",
},
gegenstaende=[erstelle_gegenstand("fackel")],
),
"bibliothek": Raum(
name="Bibliothek",
beschreibung=(
"Staubige Regale voller zerfledderter Bücher umgeben Dich."
),
ausgaenge={
"sueden": "halle",
},
gegenstaende=[erstelle_gegenstand("schluessel")],
),
"krypta": Raum(
name="Krypta",
beschreibung=(
"Feuchtigkeit glänzt auf den Wänden der alten Krypta."
),
ausgaenge={
"westen": "halle",
},
),
}
welt.py weiß, welche Gegenstände und Räume es gibt. Es weiß dagegen nichts
über JSON oder Speicherdateien.
Das ist gegenüber unserem bisherigen Ein-Datei-Code eine kleine, aber wichtige Verbesserung.
Wird beim Laden eines Spielstands eine unbekannte Gegenstandskennung gefunden,
ist die Übersetzung dieses Problems in einen SpielstandError Aufgabe von
speicher.py – nicht der Weltdefinition.
eingabe.py: Eingaben bündeln
Die Funktionen für Benutzereingaben kommen nach eingabe.py:
# eingabe.py
from fehler import UngueltigerBefehlError
def frage_ganzzahl(text, minimum=None):
"""Fragt so lange nach einer Ganzzahl, bis die Eingabe gültig ist."""
while True:
eingabe = input(text)
try:
wert = int(eingabe)
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
continue
if minimum is not None and wert < minimum:
print(f"Der Wert muss mindestens {minimum} betragen.")
continue
return wert
def frage_name(text):
"""Fragt so lange nach, bis ein nicht leerer Name eingegeben wurde."""
while True:
name = input(text).strip()
if name:
return name
print("Der Name darf nicht leer sein.")
def teile_befehl(eingabe):
"""Zerlegt den Input in ein Verb und ein optionales Argument."""
verb, _, argument = eingabe.strip().lower().partition(" ")
if not verb:
raise UngueltigerBefehlError(
"Bitte gib einen Befehl ein."
)
return verb, argument.strip()
Dieses Modul kennt weder Spieler noch Raum. Es kümmert sich nur darum,
Eingaben einzulesen und in eine für den restlichen Code brauchbare Form zu
bringen.
speicher.py: Speichern und Laden
Die komplette Savegame-Logik bekommt ihr eigenes Modul.
Darin landet auch der robustere Ladepfad aus Teil 9: Geladene Daten verändern nicht sofort den laufenden State. Stattdessen wird erst ein vollständiger neuer State rekonstruiert.
# speicher.py
import json
from pathlib import Path
from fehler import SpielstandError
from modelle import Spieler
from welt import erstelle_gegenstand, erstelle_raeume
SPEICHERDATEI = Path("spielstand.json")
def gegenstands_kennungen(gegenstaende):
"""Gibt die stabilen Kennungen mehrerer Gegenstände zurück."""
return [
gegenstand.kennung
for gegenstand in gegenstaende
]
def gegenstaende_aus_kennungen(kennungen):
"""Erstellt Gegenstände aus ihren gespeicherten Kennungen."""
if not isinstance(kennungen, list):
raise SpielstandError(
"Eine Gegenstandsliste hat ein unerwartetes Format."
)
gegenstaende = []
for kennung in kennungen:
if not isinstance(kennung, str):
raise SpielstandError(
"Eine Gegenstandskennung hat ein unerwartetes Format."
)
try:
gegenstand = erstelle_gegenstand(kennung)
except KeyError:
raise SpielstandError(
f"Unbekannter Gegenstand im Spielstand: {kennung}"
) from None
gegenstaende.append(gegenstand)
return gegenstaende
def erstelle_spielstand(spieler, ort, raeume, besuchte_raeume):
"""Wandelt den aktuellen State in JSON-kompatible Daten um."""
return {
"version": 1,
"ort": ort,
"besuchte_raeume": sorted(besuchte_raeume),
"spieler": {
"name": spieler.name,
"hp": spieler.hp,
"max_hp": spieler.max_hp,
"gold": spieler.gold,
"inventar": gegenstands_kennungen(spieler.inventar),
},
"raeume": {
raum_id: {
"gegenstaende": gegenstands_kennungen(
raum.gegenstaende
),
}
for raum_id, raum in raeume.items()
},
}
def rekonstruiere_spielstand(daten):
"""Erzeugt aus geladenen Daten einen neuen Dungeon-State."""
if not isinstance(daten, dict):
raise SpielstandError(
"Der Spielstand hat ein unerwartetes Format."
)
if daten.get("version") != 1:
raise SpielstandError(
"Der Spielstand hat eine unbekannte Version."
)
try:
spieler_daten = daten["spieler"]
raeume_daten = daten["raeume"]
ort = daten["ort"]
besuchte_daten = daten["besuchte_raeume"]
name = spieler_daten["name"]
hp = spieler_daten["hp"]
max_hp = spieler_daten["max_hp"]
gold = spieler_daten["gold"]
inventar_kennungen = spieler_daten["inventar"]
except (KeyError, TypeError) as fehler:
raise SpielstandError(
"Der Spielstand hat ein unerwartetes Format."
) from fehler
if not isinstance(name, str):
raise SpielstandError(
"Der gespeicherte Spielername ist ungültig."
)
if type(max_hp) is not int or max_hp <= 0:
raise SpielstandError(
"Die gespeicherten maximalen Lebenspunkte sind ungültig."
)
if type(hp) is not int or not 0 <= hp <= max_hp:
raise SpielstandError(
"Die gespeicherten Lebenspunkte sind ungültig."
)
if type(gold) is not int or gold < 0:
raise SpielstandError(
"Der gespeicherte Goldwert ist ungültig."
)
raeume = erstelle_raeume()
if (
not isinstance(raeume_daten, dict)
or set(raeume_daten) != set(raeume)
):
raise SpielstandError(
"Der Spielstand passt nicht zur aktuellen Dungeon-Welt."
)
if not isinstance(ort, str) or ort not in raeume:
raise SpielstandError(
f"Unbekannter aktueller Raum im Spielstand: {ort!r}"
)
if not isinstance(besuchte_daten, list):
raise SpielstandError(
"Die Liste der besuchten Räume hat ein unerwartetes Format."
)
try:
besuchte_raeume = set(besuchte_daten)
except TypeError as fehler:
raise SpielstandError(
"Die Liste der besuchten Räume hat ein unerwartetes Format."
) from fehler
if not besuchte_raeume <= set(raeume):
raise SpielstandError(
"Der Spielstand enthält unbekannte besuchte Räume."
)
try:
spieler = Spieler(
name=name,
max_hp=max_hp,
gold=gold,
)
except ValueError as fehler:
raise SpielstandError(
"Die Spielerdaten sind ungültig."
) from fehler
spieler.hp = hp
spieler.inventar = gegenstaende_aus_kennungen(
inventar_kennungen
)
try:
for raum_id, raum in raeume.items():
raum.gegenstaende = gegenstaende_aus_kennungen(
raeume_daten[raum_id]["gegenstaende"]
)
except (KeyError, TypeError) as fehler:
raise SpielstandError(
"Die Raumdaten haben ein unerwartetes Format."
) from fehler
return spieler, ort, raeume, besuchte_raeume
def speichere_spielstand(spieler, ort, raeume, besuchte_raeume):
"""Schreibt den aktuellen Spielstand als JSON-Datei."""
daten = erstelle_spielstand(
spieler=spieler,
ort=ort,
raeume=raeume,
besuchte_raeume=besuchte_raeume,
)
try:
with SPEICHERDATEI.open("w", encoding="utf-8") as datei:
json.dump(
daten,
datei,
ensure_ascii=False,
indent=2,
)
datei.write("\n")
except OSError as fehler:
raise SpielstandError(
f"Der Spielstand konnte nicht gespeichert werden: {fehler}"
) from fehler
def lade_spielstand():
"""Lädt und rekonstruiert einen gespeicherten Dungeon-State."""
try:
with SPEICHERDATEI.open(encoding="utf-8") as datei:
daten = json.load(datei)
except FileNotFoundError:
raise SpielstandError(
"Kein Spielstand gefunden."
) from None
except UnicodeDecodeError as fehler:
raise SpielstandError(
"Der Spielstand ist keine gültige UTF-8-Datei."
) from fehler
except json.JSONDecodeError as fehler:
raise SpielstandError(
"Der Spielstand enthält kein gültiges JSON."
) from fehler
except OSError as fehler:
raise SpielstandError(
f"Der Spielstand konnte nicht gelesen werden: {fehler}"
) from fehler
return rekonstruiere_spielstand(daten)
Hier wird der Vorteil der Modulgrenzen bereits sichtbar.
welt.py weiß, wie aus einer Kennung ein Gegenstand erzeugt wird:
erstelle_gegenstand("fackel")
Ob eine unbekannte Kennung beim Laden eines Savegames ein Fehler ist, entscheidet
dagegen speicher.py:
try:
gegenstand = erstelle_gegenstand(kennung)
except KeyError:
raise SpielstandError(
f"Unbekannter Gegenstand im Spielstand: {kennung}"
) from None
Die Weltdefinition muss dafür nichts über Speicherdateien wissen.
befehle.py: Befehlsverarbeitung auslagern
Die eigentliche Befehlslogik wandert nach befehle.py:
# befehle.py
from fehler import UngueltigerBefehlError
def finde_raeume_mit_loot(raeume):
"""Gibt die Namen aller Räume mit Gegenständen zurück."""
return [
raum.name
for raum in raeume.values()
if raum.gegenstaende
]
def verarbeite_befehl(
verb,
argument,
spieler,
raum,
raeume,
besuchte_raeume,
):
"""
Verarbeitet einen Befehl.
Gibt die ID eines neuen Raums oder None zurück.
"""
if verb == "umsehen":
raum.zeige_gegenstaende()
return None
if verb == "nimm":
if not argument:
raise UngueltigerBefehlError(
"Was möchtest Du nehmen?"
)
gegenstand = raum.nimm_gegenstand(argument)
spieler.nimm(gegenstand)
print(f"Du nimmst {gegenstand}.")
print(gegenstand.untersuche())
return None
if verb == "inventar":
spieler.zeige_inventar()
return None
if verb == "status":
print(spieler)
print(f"Besuchte Räume: {len(besuchte_raeume)}")
return None
if verb == "loot":
raeume_mit_loot = finde_raeume_mit_loot(raeume)
text = (
", ".join(raeume_mit_loot)
if raeume_mit_loot
else "(keine)"
)
print("Räume mit Loot:", text)
return None
if verb == "geh":
if not argument:
raise UngueltigerBefehlError(
"In welche Richtung möchtest Du gehen?"
)
return raum.ziel(argument)
if not argument:
try:
return raum.ausgaenge[verb]
except KeyError:
pass
raise UngueltigerBefehlError(
f"Den Befehl '{verb}' verstehe ich nicht."
)
Diese Datei kennt die Regeln für unsere Befehle.
Sie muss dagegen nicht wissen:
- in welcher Datei der Spielstand liegt,
- wie JSON funktioniert,
- wie die Dungeon-Welt erzeugt wird,
- wie
main()aufgebaut ist.
Die benötigten Objekte und Collections bekommt verarbeite_befehl(...) über
Parameter.
spiel.py: das Hauptprogramm
spiel.py führt die einzelnen Teile zusammen:
# spiel.py
from befehle import verarbeite_befehl
from eingabe import frage_ganzzahl, frage_name, teile_befehl
from fehler import DungeonError
from modelle import Spieler
from speicher import lade_spielstand, speichere_spielstand
from welt import erstelle_raeume
def main():
"""Startet den Dungeon und führt den Game-Loop aus."""
raeume = erstelle_raeume()
print("Du stehst vor dem rostigen Tor eines vergessenen Verlieses.")
print("Ein kalter Luftzug weht Dir entgegen.")
name = frage_name("Wie heißt Du, Abenteurer? ")
gold = frage_ganzzahl(
"Wie viele Goldmünzen bringst Du mit? ",
minimum=0,
)
spieler = Spieler(
name=name,
max_hp=100,
gold=gold,
)
ort = "halle"
besuchte_raeume = set()
raum_anzeigen = True
print()
print(f"Willkommen, {spieler.name}!")
print(
"Befehle: umsehen, nimm <Gegenstand>, inventar, status, "
"loot, speichern, laden, geh <Richtung>, ende"
)
while True:
raum = raeume[ort]
if raum_anzeigen:
besuchte_raeume.add(ort)
raum.beschreibe()
raum_anzeigen = False
eingabe = input("\n> ")
try:
verb, argument = teile_befehl(eingabe)
if verb == "ende":
print("Du verlässt das Verlies. Bis zum nächsten Mal.")
break
if verb == "speichern":
speichere_spielstand(
spieler=spieler,
ort=ort,
raeume=raeume,
besuchte_raeume=besuchte_raeume,
)
print("Spielstand gespeichert.")
continue
if verb == "laden":
(
spieler,
ort,
raeume,
besuchte_raeume,
) = lade_spielstand()
raum_anzeigen = True
print("Spielstand geladen.")
continue
neuer_ort = verarbeite_befehl(
verb=verb,
argument=argument,
spieler=spieler,
raum=raum,
raeume=raeume,
besuchte_raeume=besuchte_raeume,
)
except DungeonError as fehler:
print(fehler)
else:
if neuer_ort is not None:
ort = neuer_ort
raum_anzeigen = True
if __name__ == "__main__":
main()
spiel.py übernimmt nun vor allem die Koordination:
- Welt erzeugen,
- Spieler anlegen,
- Eingaben entgegennehmen,
- Befehle weiterreichen,
- Speichern und Laden auslösen,
- den Game-Loop am Laufen halten.
Die Details liegen in den dafür zuständigen Modulen.
Das Projekt starten
Der fertige Ordner sieht jetzt so aus:
dungeon-projekt/
├── befehle.py
├── eingabe.py
├── fehler.py
├── modelle.py
├── speicher.py
├── spiel.py
└── welt.py
Wechsle für unsere Beispiele in diesen Ordner:
cd dungeon-projekt
und starte:
python spiel.py
Dass die anderen Module gefunden werden, hängt dabei nicht ausschließlich vom aktuellen Working Directory ab. Bei einem direkt gestarteten Skript nimmt Python dessen Verzeichnis in den Module Search Path auf.
Wir starten trotzdem bewusst aus dungeon-projekt/.
Der Grund steckt in dieser Zeile aus speicher.py:
SPEICHERDATEI = Path("spielstand.json")
Ein solcher relativer Dateipfad bezieht sich auf das aktuelle Working Directory.
Startest Du das Spiel von woanders, könnte spielstand.json deshalb ebenfalls
dort landen.
Für unser einfaches Projekt ist es am übersichtlichsten, das Spiel aus seinem Projektordner zu starten.
Später können wir auch Dateipfade und Package-Struktur unabhängig davon gestalten.
__name__ verstehen
Jedes Modul besitzt einen Namen in der Variable:
__name__
Lege zum Ausprobieren eine Datei namenstest.py an:
print(__name__)
Startest Du sie direkt:
python namenstest.py
lautet die Ausgabe:
__main__
Importierst Du dieselbe Datei stattdessen:
import namenstest
wird ihr Code ebenfalls ausgeführt. Der Wert von __name__ innerhalb dieses
Moduls lautet diesmal aber:
namenstest
Vereinfacht können wir uns merken:
- direkt ausgeführtes Hauptmodul:
__name__ == "__main__" - importiertes
namenstest.py:__name__ == "namenstest"
Genau darauf basiert diese Schreibweise:
if __name__ == "__main__":
main()
Warum der __main__-Guard wichtig ist
Ohne Guard würde ein Aufruf auf Modulebene auch beim Import ausgeführt.
# spiel.py
def main():
print("Spiel startet")
main()
Ein anderes Programm müsste lediglich
import spiel
ausführen und unser Spiel würde loslaufen.
Mit:
# spiel.py
def main():
print("Spiel startet")
if __name__ == "__main__":
main()
passiert das nicht.
Dieser Aufruf:
python spiel.py
führt main() aus, weil __name__ im Hauptmodul "__main__" ist.
Dieser Code:
import spiel
importiert das Modul, führt main() aber nicht aus.
Das macht spiel.py leichter wiederverwendbar und später auch leichter zu
testen.
Was darf beim Import trotzdem passieren?
Die Faustregel
Auf Modulebene darf gar nichts ausgeführt werden.
wäre zu streng.
Ein Modul muss schließlich ausgeführt werden, damit Python seine Namen erzeugen kann.
Völlig normal sind auf Modulebene beispielsweise:
import json
SPEICHERDATEI = "spielstand.json"
class Spieler:
...
def main():
...
Auch Konstanten oder statische Daten wie unser GEGENSTANDS_DATEN werden beim
Import erzeugt.
Problematisch werden vor allem unerwartete Seiteneffekte wie:
input(...)
ein automatisch startender Game-Loop oder Schreiboperationen, die allein durch einen Import ausgelöst werden.
Der eigentliche Programmablauf gehört deshalb bei unserem Dungeon in main().
Import-Stile
Schauen wir uns die gebräuchlichen Varianten noch einmal an.
Ganzes Modul importieren
import speicher
speicher.speichere_spielstand(...)
Die Herkunft der Funktion bleibt am Aufruf sichtbar.
Einzelne Namen importieren
from speicher import speichere_spielstand
speichere_spielstand(...)
Das ist kürzer und kann bei wenigen häufig verwendeten Namen angenehm sein.
Alias verwenden
Module können beim Import einen anderen lokalen Namen bekommen:
import pathlib as pl
Technisch ist daran nichts Besonderes. Beliebige eigene Abkürzungen machen Code aber selten verständlicher.
Bei manchen Libraries haben sich feste Konventionen etabliert:
import numpy as np
Dort ist der Alias für viele Python-Entwickler sofort verständlich.
Star Imports vermeiden
from speicher import *
Diese Form erschwert zu erkennen:
- welche Namen in den aktuellen Namensraum gekommen sind,
- aus welchem Modul ein verwendeter Name stammt,
- ob unterschiedliche Imports denselben Namen liefern.
Für unseren Anwendungscode verwenden wir deshalb explizite Imports.
Unsere Imports sind noch bewusst einfach
In unserer aktuellen Struktur stehen alle Module direkt nebeneinander:
from modelle import Spieler
from welt import erstelle_raeume
from speicher import lade_spielstand
Das sind absolute Imports auf Top-Level-Module, die Python über seinen Suchpfad findet.
Für unser kleines Skriptprojekt ist das in Ordnung.
Aus dem Dungeon könnte später ein richtiges Package werden:
dungeon-projekt/
└── dungeon/
├── __init__.py
├── modelle.py
├── speicher.py
└── welt.py
Dann könnten Imports beispielsweise so aussehen:
from dungeon.modelle import Spieler
from dungeon.welt import erstelle_raeume
Innerhalb des Packages wären auch relative Imports möglich:
from .modelle import Spieler
Diesen Schritt brauchen wir heute noch nicht. Zunächst geht es darum, Verantwortlichkeiten sinnvoll auf Module zu verteilen.
Zirkuläre Importe
Beim Aufteilen eines Programms stößt man früher oder später auf Circular Imports.
Angenommen, a.py enthält:
# a.py
from b import funktion_b
def funktion_a():
...
und b.py:
# b.py
from a import funktion_a
def funktion_b():
...
a beginnt also damit, b zu importieren. Während b noch geladen wird,
versucht es wiederum, einen Namen aus dem noch nicht vollständig initialisierten
Modul a zu importieren.
Das kann beispielsweise zu einer Meldung wie dieser führen:
ImportError: cannot import name 'funktion_a' from 'a' (consider renaming '/pfad/zu/a.py' if it has the same name as a library you intended to import)
Der Hinweis auf eine Umbenennung führt hier in die Irre – die eigentliche
Ursache ist der Kreis. Ältere Python-Versionen (bis 3.12) sagen es direkter:
… from partially initialized module 'a' (most likely due to a circular import).
Nicht jeder gegenseitige Modulbezug führt zwangsläufig sofort zu einem Fehler. Circular Imports machen Abhängigkeiten aber schnell kompliziert und sind häufig ein Hinweis darauf, dass die Modulgrenzen ungünstig gewählt wurden.
Typische Lösungen sind:
- gemeinsame Definitionen in ein drittes Modul verschieben,
- Abhängigkeiten in nur eine Richtung aufbauen,
- benötigte Werte als Argumente übergeben,
- fachlich zusammengehörigen Code anders gruppieren.
Unsere fehler.py ist ein gutes Beispiel.
Statt Exceptions irgendwo in spiel.py, modelle.py oder speicher.py zu
verstecken, liegen die gemeinsam benötigten Typen in einem kleinen Modul, das
keine dieser Dateien zurückimportieren muss.
__pycache__
Nach den ersten Starts taucht möglicherweise ein Ordner namens
__pycache__/
auf.
Darin kann Python kompilierte Bytecode-Dateien seiner Module ablegen:
modelle.cpython-314.pyc
welt.cpython-314.pyc
Beim nächsten Import muss der entsprechende Source Code dann nicht jedes Mal von Grund auf neu zu Bytecode kompiliert werden, sofern der Cache noch passt.
Du musst diese Dateien weder öffnen noch selbst bearbeiten.
Löschst Du __pycache__, ist das normalerweise kein Problem. Python erzeugt
benötigte Cache-Dateien bei späteren Imports erneut.
In einem Git-Repository gehört der Cache nicht zum Projekt.
__pycache__/
*.pyc
Fremden Code installieren
Bisher braucht unser Dungeon nur die Python-Standardbibliothek und eigenen Code.
Dafür mussten wir nichts aus dem Internet installieren.
Irgendwann möchtest Du aber Libraries oder Tools verwenden, die nicht zu Python selbst gehören, zum Beispiel:
richfür aufwendigere Terminalausgaben,requestsfür HTTP-Anfragen,pytestfür Tests,rufffür Linting und Formatierung.
Solche Distribution Packages werden häufig über den Python Package Index
(PyPI) veröffentlicht und können mit pip installiert werden.
pip kann allerdings auch andere Package Indices, lokale Dateien oder andere
Quellen verwenden. PyPI ist lediglich der übliche Standardfall.
Packages für ein Projekt sollten wir dabei nicht einfach wahllos in die globale Python-Installation installieren.
Dafür verwenden wir eine virtuelle Umgebung.
Was ist eine virtuelle Umgebung?
Eine virtuelle Umgebung (Virtual Environment) ist eine isolierte Python-Umgebung für ein bestimmtes Projekt.
Sie besitzt unter anderem:
- ihren eigenen Python-Kontext auf Basis einer vorhandenen Python-Installation,
- ein eigenes Verzeichnis für installierte Packages,
- eigene ausführbare Skripte,
- standardmäßig ein eigenes
pip.
Dadurch können verschiedene Projekte unabhängig voneinander unterschiedliche Dependencies verwenden.
Zum Beispiel:
projekt-a braucht paket X in Version 1
projekt-b braucht paket X in Version 2
Mit zwei getrennten Virtual Environments geraten sich diese Installationen nicht in die Quere.
Eine venv ist dabei keine Kopie Deines ganzen Projekts. Dein Source Code bleibt weiter außerhalb der Umgebung.
Eine venv anlegen
Python bringt dafür das Standardmodul venv mit.
Im Projektordner:
python -m venv .venv
Dadurch entsteht das Verzeichnis:
.venv/
.venv ist lediglich ein verbreiteter Name. Du könntest die Umgebung auch
anders nennen.
Bei einer normalen CPython-Installation wird pip standardmäßig in die neue
Umgebung eingebunden. Mit der Option --without-pip ließe sich das ausdrücklich
abschalten.
Manche Linux-Distributionen liefern die Unterstützung für venv oder
ensurepip in separaten Systempaketen aus. Falls python -m venv .venv dort
fehlschlägt, solltest Du die Dokumentation Deiner Distribution prüfen, statt
pip mit Administratorrechten in das System-Python zu installieren.
Die venv aktivieren
Unter macOS und Linux:
source .venv/bin/activate
Unter Windows PowerShell:
.venv\Scripts\Activate.ps1
Unter Windows cmd.exe:
.venv\Scripts\activate.bat
Nach der Aktivierung erscheint häufig ein Hinweis im Prompt:
(.venv)
Wichtiger ist aber, was technisch passiert: Das Verzeichnis mit den
Executables der venv wird vorne in PATH eingetragen.
Dadurch verweist:
python
nun auf das Python der Virtual Environment.
Prüfen kannst Du unter anderem mit:
python -m pip --version
Der ausgegebene Pfad sollte auf .venv zeigen.
Aktivieren ist nur eine Bequemlichkeit
Eine venv muss technisch nicht aktiviert sein, um sie zu verwenden.
Du könntest unter macOS oder Linux beispielsweise direkt starten:
.venv/bin/python spiel.py
und unter Windows:
.venv\Scripts\python.exe spiel.py
Auch das verwendet den Interpreter der Virtual Environment.
Die Aktivierung erspart Dir lediglich, jedes Mal diesen vollständigen Pfad anzugeben. Für die Arbeit im Terminal ist sie deshalb sehr praktisch.
Warum python -m pip?
Häufig siehst Du:
pip install rich
Das funktioniert in vielen Umgebungen problemlos.
Ich verwende in dieser Serie trotzdem:
python -m pip install rich
Damit starten wir das Modul pip ausdrücklich mit dem Python-Interpreter, den
der Befehl python gerade bezeichnet.
Das reduziert typische Verwechslungen zwischen mehreren Installationen:
python -> Python A
pip -> Python B
In einer aktivierten venv sollten beide ohnehin zusammenpassen. python -m pip
macht die Verbindung aber ausdrücklich sichtbar.
Ein Package installieren
Aktiviere zunächst die venv und installiere beispielsweise rich:
python -m pip install rich
Danach können wir das Package im Projekt verwenden:
from rich import print
print("[bold green]Willkommen im Dungeon![/bold green]")
Unser Dungeon benötigt rich weiterhin nicht. Es dient nur als kleines
Installationsbeispiel.
Die installierten Distributionen zeigt:
python -m pip list
Informationen zu rich:
python -m pip show rich
Und entfernen kannst Du es wieder mit:
python -m pip uninstall rich
Die venv verlassen
Mit:
deactivate
beendest Du die Aktivierung in der aktuellen Shell.
Der (.venv)-Hinweis verschwindet und die Änderungen an PATH werden
zurückgenommen.
Die Umgebung selbst bleibt auf der Festplatte erhalten. Du kannst sie beim nächsten Arbeiten am Projekt einfach erneut aktivieren.
.venv nicht ins Git einchecken
Eine Virtual Environment ist eine lokale und wegwerfbare Arbeitsumgebung.
Sie enthält viele Dateien, teilweise mit absoluten Pfaden zur lokalen Installation, und sollte bei Bedarf neu erzeugt werden können.
In unser Git-Repository gehört sie deshalb nicht.
Eine passende .gitignore könnte inzwischen so aussehen:
.venv/
__pycache__/
*.pyc
spielstand.json
spielstand.json ignorieren wir ebenfalls, weil es ein lokaler Spielstand und
kein Bestandteil des Source Codes ist.
Was das Projekt benötigt, halten wir stattdessen als Dependencies fest.
Eine einfache requirements.txt
Ein klassischer Weg dafür ist eine Requirements-Datei:
requirements.txt
Nach der Installation von rich können wir beispielsweise schreiben:
python -m pip freeze > requirements.txt
pip freeze gibt die aktuell installierten Distributionen in einem Format aus,
das pip wieder einlesen kann.
In der Datei könnte dann unter anderem stehen:
rich==15.0.0
Da rich selbst Dependencies besitzt, kann pip freeze zusätzlich weitere
installierte Packages auflisten.
Auf einem anderen Rechner lässt sich eine passende Umgebung anschließend erzeugen:
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
Unter Windows verwendest Du den entsprechenden Aktivierungsbefehl.
Wichtig ist die Bedeutung von pip freeze: Der Befehl berichtet im
Wesentlichen, was gerade in dieser Umgebung installiert ist.
Er ermittelt daraus nicht automatisch, welche Dependencies Dein eigener Source Code tatsächlich benötigt, und erzeugt auch keinen modernen Lockfile-Resolve.
Für ein kleines Lernprojekt ist er trotzdem ein unkomplizierter Einstieg.
requirements.txt, pyproject.toml und Lockfiles
Sobald ein Python-Projekt wächst, begegnet Dir heute sehr häufig eine:
pyproject.toml
Dort können unter anderem Projektmetadaten und direkte Dependencies deklariert werden.
Das ist eine andere Aufgabe als ein mit pip freeze erzeugter Snapshot der
aktuellen Umgebung.
Moderne Projektwerkzeuge unterscheiden deshalb häufig zwischen:
- den von Dir gewünschten Projekt-Dependencies,
- den daraus aufgelösten konkreten Versionen in einem Lockfile,
- der tatsächlich installierten Umgebung.
Diesen kompletten Packaging-Workflow brauchen wir für unseren Dungeon noch nicht.
Für den Moment reicht:
venv -> isolierte Umgebung
pip -> Packages installieren
requirements.txt -> einfache Liste installierbarer Requirements
Projektstruktur mit venv
Unser Ordner könnte damit so aussehen:
dungeon-projekt/
├── .venv/ # lokale virtuelle Umgebung, nicht in Git
├── befehle.py
├── eingabe.py
├── fehler.py
├── modelle.py
├── requirements.txt
├── speicher.py
├── spiel.py
└── welt.py
Und unsere .gitignore enthält:
.venv/
__pycache__/
*.pyc
spielstand.json
Damit besteht das eigentliche Projekt aus unserem Source Code und der Beschreibung seiner Abhängigkeiten. Die lokale Umgebung kann jederzeit neu erzeugt werden.
pip, venv und uv
venv und pip gehören zum klassischen Python-Handwerkszeug:
python -m venv .venv
python -m pip install paketname
Diese Befehle solltest Du kennen. Sie tauchen in unzähligen Projekten, Dokumentationen und Fehlermeldungen auf.
Daneben gibt es inzwischen Tools, die mehrere Schritte eines modernen
Python-Workflows unter einer Oberfläche zusammenfassen. Eines davon ist uv.
In einem uv-Projekt können Dependencies beispielsweise in pyproject.toml
verwaltet, in uv.lock fest aufgelöst und in einer .venv synchronisiert
werden. Befehle lassen sich mit uv run direkt in der passenden
Projektumgebung ausführen.
Das ist komfortabel, soll uns an dieser Stelle aber nicht von den Grundlagen ablenken.
Wenn Du verstehst, was venv, Package-Installation und Dependencies grundsätzlich
machen, ist auch leichter nachvollziehbar, welche Arbeit ein Tool wie uv Dir
abnimmt.
Stolperfallen
-
Seiteneffekte auf Modulebene: Code auf Modulebene wird beim Import ausgeführt. Ein Game-Loop oder
input(...)gehört deshalb nicht einfach ungeschützt dorthin. -
if __name__ == "__main__"falsch schreiben: Sowohl__name__als auch"__main__"enthalten jeweils zwei Unterstriche vor und nach dem Wort. -
Working Directory und Importpfad verwechseln: Das Verzeichnis eines direkt gestarteten Skripts landet im Module Search Path. Relative Dateipfade wie
Path("spielstand.json")beziehen sich dagegen auf das aktuelle Working Directory. -
from modul import *verwenden: Star Imports erschweren zu erkennen, welche Namen woher stammen. -
Module wie Standardbibliothek oder Dependencies benennen: Eine eigene
json.pynebenspiel.pykann beispielsweise das erwartete Moduljsonverdecken. -
Zirkuläre Imports erzeugen: Wenn Module gegenseitig voneinander abhängen, lohnt es sich häufig, die Verantwortlichkeiten neu zu betrachten.
-
Nur wegen der Dateilänge aufteilen: Mehr Module machen ein Projekt nicht automatisch besser. Jede Datei sollte eine verständliche Aufgabe besitzen.
-
Import für Copy-and-paste halten: Ein Modul wird ausgeführt und als Modulobjekt verwaltet. Sein Code wird nicht einfach textuell an die Stelle des Imports kopiert.
-
__pycache__ins Git einchecken: Die Bytecode-Caches werden automatisch erzeugt und gehören normalerweise nicht ins Repository. -
Die venv für einen Teil des Source Codes halten:
.venvist eine lokale Umgebung und sollte lösch- und rekonstruierbar sein. -
Aktivierung mit Existenz der venv verwechseln: Eine Virtual Environment funktioniert auch ohne Aktivierung. Die Aktivierung verändert vor allem
PATH. -
Packages in die falsche Python-Installation installieren: Mit
python -m pipist klar, welcher Interpreterpipausführt. -
System-Python mit
sudo pipverändern: Gerade auf Linux können Distribution und Betriebssystem diese Python-Installation selbst verwalten. Nutze für Projekte lieber eine Virtual Environment. -
pip freezefür eine vollständige Projektbeschreibung halten: Der Befehl zeigt installierte Distributionen in Requirements-Format. Er kennt nicht die Absicht hinter Deinem Projekt und erzeugt keinen Lockfile-Resolve. -
.venvverschieben oder kopieren: Virtual Environments enthalten Pfadbezüge und gelten als nicht portabel. Erzeuge sie am Zielort neu.
Übungen
1. Ein eigenes Modul anlegen
Lege eine Datei werkzeuge.py an:
# werkzeuge.py
import random
def wuerfel(seiten=6):
"""Liefert eine zufällige Zahl von 1 bis seiten."""
return random.randint(1, seiten)
Lege daneben eine Datei test_wuerfel.py an und importiere die Funktion.
Lösung
# test_wuerfel.py
from werkzeuge import wuerfel
print(wuerfel())
print(wuerfel(20))
python test_wuerfel.py
2. __name__ beobachten
Lege eine Datei namenstest.py an:
print(__name__)
Starte sie direkt und importiere sie anschließend aus einer zweiten Datei.
Lösung
Direkt starten:python namenstest.py
__main__
import namenstest
python import_test.py
namenstest
3. Einen main()-Guard ergänzen
Schreibe eine Datei hallo.py, die beim direkten Start eine Funktion main()
ausführt, beim Import aber nichts ausgibt.
Lösung
def main():
print("Hallo aus main()")
if __name__ == "__main__":
main()
python hallo.py
import hallo
4. Eine venv anlegen und ein Package installieren
Lege eine Virtual Environment an, aktiviere sie und installiere rich.
Lösung
venv anlegen:python -m venv .venv
source .venv/bin/activate
.venv\Scripts\Activate.ps1
python -m pip install rich
from rich import print
print("[bold green]rich funktioniert[/bold green]")
5. Abhängigkeiten festhalten
Erzeuge aus Deiner aktivierten venv eine requirements.txt.
Lösung
python -m pip freeze > requirements.txt
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
6. Ein Modul nur einmal ausführen
Lege meldung.py an:
print("Modul wird ausgeführt")
Importiere es anschließend zweimal aus test_import.py:
import meldung
import meldung
Was erwartest Du als Ausgabe?
Lösung
Modul wird ausgeführt
Vertiefung
Eine moderne Alternative für viele Workflows rund um Umgebungen, Dependencies und Lockfiles:
Wie aus einem Skript ein richtiges Kommandozeilen-Tool wird:
Wie Ruff in ein Projekt eingebunden wird:
Ruff: Linter und Formatter in einem
Im nächsten Teil
Im letzten Teil schauen wir uns an, was Python neben den bisherigen Grundlagen noch mitbringt.
Wir verwenden unter anderem random, collections und itertools, streifen
Generatoren und Decorators und ordnen ein, wohin die Reise nach dieser Serie
weitergehen kann: Type Hints, Tests, Packaging und async.
Weiterlesen
- Python lernen, Teil 9: Dateien und Speichern
- Ruff: Linter und Formatter in einem
- Python-Dokumentation: Module
- Python-Dokumentation:
__main__ - Python-Dokumentation:
venv - Python-Dokumentation: Virtuelle Umgebungen und Pakete
- Python Packaging User Guide: Installing packages with pip and virtual environments
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.