Zum Inhalt springen

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 fremden Code scrollen. Außerdem ist nicht mehr sofort erkennbar, welche Teile fachlich zusammengehören.

Heute zerlegen wir den Dungeon in mehrere Dateien.

Dabei lernst Du:

  • was ein Modul ist,
  • wie import funktioniert,
  • warum Code beim Import ausgeführt wird,
  • wofür if __name__ == "__main__" gut ist,
  • wie eine kleine Projektstruktur aussehen kann,
  • wie venv und pip für externe Pakete verwendet werden.

Ein Modul ist eine Python-Datei

Eine 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)

Dann kannst Du dieses Modul in einer anderen Datei importieren:

# spiel.py
import werkzeuge

wurf = werkzeuge.wuerfel(20)

print(wurf)

Der Ausdruck

import werkzeuge

macht das Modul unter dem Namen werkzeuge verfügbar.

Die Funktion rufst Du anschließend über den Modulnamen auf:

werkzeuge.wuerfel(20)

Das hat einen Vorteil: Im Code bleibt sichtbar, woher wuerfel(...) stammt.

Einzelne Namen importieren

Du kannst auch gezielt einzelne Namen aus einem Modul importieren:

from werkzeuge import wuerfel

wurf = wuerfel(20)

print(wurf)

Das ist kürzer, weil der Modulname beim Aufruf entfällt.

Beide Formen sind üblich:

import werkzeuge
from werkzeuge import wuerfel

Als grobe Orientierung:

  • import modul ist oft klarer, weil die Herkunft sichtbar bleibt.
  • from modul import name ist praktisch bei häufig verwendeten Namen.
  • from modul import * solltest Du vermeiden.

Diese Schreibweise:

from werkzeuge import *

importiert alle öffentlich sichtbaren Namen des Moduls in den aktuellen Namensraum. Dadurch wird schnell unklar, woher eine Funktion oder Variable stammt.

Die Standardbibliothek besteht ebenfalls aus Modulen

Du hast import bereits mehrfach verwendet:

import json
import logging
from pathlib import Path

Diese Module gehören zur Standardbibliothek. Sie werden mit Python mitgeliefert.

Selbst geschriebene Module und Module der Standardbibliothek werden grundsätzlich mit derselben Syntax importiert.

import json       # Standardbibliothek
import werkzeuge  # eigene Datei werkzeuge.py

Python sucht das angegebene Modul in bestimmten Verzeichnissen. Für den Einstieg ist vor allem wichtig:

Liegt eine Datei werkzeuge.py im selben Ordner wie Dein gestartetes Skript, kann sie mit import werkzeuge importiert werden.

Was beim Import passiert

Ein wichtiger Punkt: Beim ersten Import führt Python den Code eines Moduls aus.

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 von spiel.py erscheint:

werkzeuge wird geladen
Spiel startet
4

Das print(...) auf oberster Ebene von werkzeuge.py wird beim Import ausgeführt.

Deshalb solltest Du Module so schreiben, dass beim Import möglichst wenig passiert. Funktions- und Klassendefinitionen sind unproblematisch. Ein automatisch startender Game-Loop wäre dagegen störend.

Ungünstig:

# welt.py
print("Spiel startet sofort")
main()

Besser:

# welt.py
def main():
    ...


if __name__ == "__main__":
    main()

Dazu kommen wir gleich genauer.

Python importiert ein Modul nur einmal

Wird ein Modul mehrfach importiert, führt Python seinen Code normalerweise nur beim ersten Import aus.

import werkzeuge
import werkzeuge

Der zweite Import verwendet das bereits geladene Modul weiter.

Python merkt sich geladene Module intern in sys.modules. Für den Alltag musst Du diese Details selten anfassen. Wichtig ist nur: Ein Import ist nicht einfach ein textuelles Einfügen des Codes an dieser Stelle.

Das unterscheidet Python-Module von einem simplen Kopieren und Einfügen.

Namen sauber aufteilen

Je größer ein Programm wird, desto wichtiger wird die Frage:

Welche Datei ist wofür zuständig?

Eine sinnvolle Aufteilung entsteht meist entlang von Verantwortlichkeiten.

Für unseren Dungeon bietet sich diese Struktur an:

dungeon-projekt/
├── befehle.py     # Befehle verarbeiten
├── eingabe.py     # Eingabefunktionen
├── fehler.py      # eigene Exceptions
├── modelle.py     # Klassen: Gegenstand, Spieler, Raum
├── speicher.py    # Spielstand speichern und laden
├── spiel.py       # main() und Game-Loop
└── welt.py        # Gegenstandsdaten und Räume erzeugen

Das ist noch kein installiertes Package mit pyproject.toml, sondern zunächst nur ein Projektordner mit mehreren Python-Dateien.

Gestartet wird später:

python spiel.py

Wichtig ist: Starte den Befehl im Ordner dungeon-projekt/, also dort, wo spiel.py und die anderen Dateien liegen.

Warum eine eigene fehler.py?

In Teil 9 hatten wir mehrere eigene Exceptions:

class DungeonError(Exception):
    ...


class UngueltigerBefehlError(DungeonError):
    ...


class UnmoeglicheAktionError(DungeonError):
    ...


class SpielstandError(DungeonError):
    ...

Diese Exceptions werden von verschiedenen Modulen gebraucht:

  • modelle.py braucht UnmoeglicheAktionError.
  • speicher.py braucht SpielstandError.
  • befehle.py braucht UngueltigerBefehlError.
  • spiel.py fängt DungeonError.

Würden wir sie irgendwo zwischen den anderen Modulen verstecken, entstehen leicht zirkuläre Importe.

Deshalb bekommen sie eine eigene Datei.

# 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."""

Dieses Modul enthält keine Spiellogik. Es stellt nur gemeinsame Fehlertypen zur Verfügung.

modelle.py: Klassen auslagern

Die Klassen wandern 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 Klassen und die Exception, die ein Raum auslösen kann. Es importiert weder den Game-Loop noch die Speicherlogik.

Das ist wichtig: Je weniger ein Modul wissen muss, desto leichter bleibt die Struktur.

welt.py: Räume und Gegenstände erzeugen

Die Weltdefinition kommt in welt.py.

# welt.py
from fehler import SpielstandError
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."""
    try:
        daten = GEGENSTANDS_DATEN[kennung]
    except KeyError:
        raise SpielstandError(
            f"Unbekannter Gegenstand im Spielstand: {kennung}"
        ) from None

    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",
            },
        ),
    }

Dieses Modul ist für den Aufbau der Spielwelt zuständig.

Es importiert:

from modelle import Gegenstand, Raum

weil es Objekte dieser Klassen erzeugt.

eingabe.py: Eingaben bündeln

Die Eingabefunktionen aus Teil 8 wandern 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 enthält keine Dungeon-Welt und keine Spielerklasse. Es kümmert sich nur um Benutzereingaben.

speicher.py: Speichern und Laden

Die Speicherlogik bekommt ein eigenes Modul.

# speicher.py
import json
from pathlib import Path

from fehler import SpielstandError
from welt import erstelle_gegenstand


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."""
    return [
        erstelle_gegenstand(kennung)
        for kennung in kennungen
    ]


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 wende_spielstand_an(daten, spieler, raeume):
    """Überträgt geladene Daten auf Spieler und Räume."""
    if daten.get("version") != 1:
        raise SpielstandError(
            "Der Spielstand hat eine unbekannte Version."
        )

    spieler_daten = daten["spieler"]

    spieler.name = spieler_daten["name"]
    spieler.hp = spieler_daten["hp"]
    spieler.max_hp = spieler_daten["max_hp"]
    spieler.gold = spieler_daten["gold"]
    spieler.inventar = gegenstaende_aus_kennungen(
        spieler_daten["inventar"]
    )

    for raum_id, raum_daten in daten["raeume"].items():
        if raum_id not in raeume:
            raise SpielstandError(
                f"Unbekannter Raum im Spielstand: {raum_id}"
            )

        raeume[raum_id].gegenstaende = gegenstaende_aus_kennungen(
            raum_daten["gegenstaende"]
        )

    ort = daten["ort"]

    if ort not in raeume:
        raise SpielstandError(
            f"Unbekannter aktueller Raum im Spielstand: {ort}"
        )

    return ort, set(daten.get("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(spieler, raeume):
    """Lädt den Spielstand und gibt Ort sowie besuchte Räume zurück."""
    if not SPEICHERDATEI.exists():
        raise SpielstandError("Kein Spielstand gefunden.")

    try:
        with SPEICHERDATEI.open(encoding="utf-8") as datei:
            daten = json.load(datei)
    except json.JSONDecodeError as fehler:
        raise SpielstandError(
            "Der Spielstand ist keine gültige JSON-Datei."
        ) from fehler
    except OSError as fehler:
        raise SpielstandError(
            f"Der Spielstand konnte nicht gelesen werden: {fehler}"
        ) from fehler

    try:
        return wende_spielstand_an(
            daten=daten,
            spieler=spieler,
            raeume=raeume,
        )
    except (KeyError, TypeError) as fehler:
        raise SpielstandError(
            "Der Spielstand hat ein unerwartetes Format."
        ) from fehler

Dieses Modul importiert aus welt.py die Funktion erstelle_gegenstand(...), weil gespeicherte Gegenstandskennungen beim Laden wieder zu echten Objekten werden müssen.

befehle.py: Befehlsverarbeitung auslagern

Die Befehlslogik kommt in 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 Befehle. Sie muss aber nicht wissen, wie der Spielstand gespeichert oder wie die Welt aufgebaut wird.

spiel.py: das Hauptprogramm

spiel.py führt alles 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":
                ort, besuchte_raeume = lade_spielstand(
                    spieler=spieler,
                    raeume=raeume,
                )
                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()

Jetzt ist spiel.py vor allem Koordination:

  • Welt erstellen,
  • Spieler anlegen,
  • Eingaben lesen,
  • Befehle weiterreichen,
  • Speichern und Laden auslösen.

Die Details liegen in den jeweiligen Modulen.

Das Projekt starten

Wechsle in den Projektordner:

cd dungeon-projekt

Starte dann:

python spiel.py

Wichtig: Starte nicht aus einem beliebigen anderen Verzeichnis, solange wir noch keine richtige Package-Struktur verwenden.

Diese Struktur:

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

ist für den Anfang bewusst einfach gehalten.

Ein installierbares Package mit src/, pyproject.toml und sauberem Package-Namen ist ein späterer Schritt.

__name__ verstehen

Jedes Modul besitzt eine Variable namens __name__.

Lege eine Datei namenstest.py an:

print(__name__)

Startest Du sie direkt:

python namenstest.py

lautet die Ausgabe:

__main__

Wird dieselbe Datei dagegen importiert:

import namenstest

lautet __name__ im Modul:

namenstest

Python unterscheidet also:

  • Datei wird direkt gestartet: __name__ == "__main__"
  • Datei wird importiert: __name__ == "modulname"

Genau darauf basiert die bekannte Schreibweise:

if __name__ == "__main__":
    main()

Warum der __main__-Block wichtig ist

Ohne Schutz würde ausführbarer Code beim Import direkt laufen.

Ungünstig:

# spiel.py
def main():
    print("Spiel startet")


main()

Wenn ein anderes Modul nun spiel.py importiert, startet das Spiel sofort:

import spiel

Das ist meistens unerwünscht.

Besser:

# spiel.py
def main():
    print("Spiel startet")


if __name__ == "__main__":
    main()

Jetzt gilt:

python spiel.py

startet das Spiel.

Aber:

import spiel

definiert nur die Funktion main(), ohne sie auszuführen.

Das macht Module wiederverwendbar und testbarer.

Ausführbarer Code gehört in main()

Schlecht:

name = input("Wie heißt Du? ")
spieler = Spieler(name)

Dieser Code läuft sofort beim Import.

Besser:

def main():
    name = input("Wie heißt Du? ")
    spieler = Spieler(name)


if __name__ == "__main__":
    main()

Die Faustregel:

Auf oberster Ebene eines Moduls stehen Imports, Konstanten, Klassen und Funktionen. Der eigentliche Programmstart gehört in main().

Das verhindert überraschende Seiteneffekte beim Import.

Import-Stile

Es gibt mehrere Arten, Namen zu importieren.

Ganzes Modul importieren

import speicher

speicher.speichere_spielstand(...)

Vorteil: Die Herkunft bleibt sichtbar.

Einzelne Namen importieren

from speicher import speichere_spielstand

speichere_spielstand(...)

Vorteil: Kürzer bei häufig verwendeten Funktionen.

Alias verwenden

import pathlib as pl

Das ist erlaubt, aber nicht immer sinnvoll. Aliase sind besonders bei sehr etablierten Konventionen üblich, zum Beispiel:

import numpy as np

Für eigene kleine Module solltest Du Namen meistens nicht unnötig abkürzen.

Star Imports vermeiden

from speicher import *

Diese Form solltest Du in normalem Anwendungscode vermeiden.

Sie macht unklar:

  • welche Namen importiert wurden,
  • aus welchem Modul ein Name stammt,
  • ob lokale Namen überschrieben werden.

Explizite Imports sind fast immer besser.

Absolute Imports im einfachen Projekt

In unserer aktuellen Struktur verwenden wir Imports wie:

from modelle import Spieler
from welt import erstelle_raeume
from speicher import lade_spielstand

Das funktioniert, weil alle Dateien im selben Projektordner liegen und spiel.py von dort gestartet wird.

Diese Imports sind für den aktuellen Lernstand in Ordnung.

Später, wenn aus dem Dungeon ein richtiges Package wird, sehen Imports eher so aus:

from dungeon.modelle import Spieler
from dungeon.welt import erstelle_raeume

Oder innerhalb eines Packages relativ:

from .modelle import Spieler

Das behandeln wir hier noch nicht vollständig. Für heute reicht:

Mehrere .py-Dateien im selben Ordner können einander über ihren Modulnamen importieren.

Zirkuläre Importe

Ein häufiger Stolperstein sind zirkuläre Importe.

Beispiel:

# a.py
from b import funktion_b


def funktion_a():
    ...
# b.py
from a import funktion_a


def funktion_b():
    ...

a importiert b, während b wieder a importiert.

Solche Abhängigkeiten führen schnell zu Fehlern wie:

ImportError: cannot import name 'funktion_a' from partially initialized module 'a'

Das bedeutet meist: Python hat ein Modul noch nicht fertig geladen, aber ein anderes Modul versucht bereits, daraus Namen zu importieren.

Zirkuläre Importe sind oft ein Zeichen dafür, dass Verantwortlichkeiten nicht sauber getrennt sind.

Typische Lösungen:

  • gemeinsame Dinge in ein drittes Modul auslagern,
  • Imports anders herum vermeiden,
  • Funktionen so umbauen, dass Werte als Argumente übergeben werden,
  • Code mit Seiteneffekten aus der Modulebene entfernen.

Unsere fehler.py ist genau so ein drittes gemeinsames Modul. Mehrere Dateien dürfen daraus Exceptions importieren, ohne einander direkt importieren zu müssen.

__pycache__

Nach dem Ausführen Deines Projekts siehst Du vielleicht einen Ordner:

__pycache__/

Darin speichert Python kompilierte Bytecode-Dateien. Sie beschleunigen spätere Starts und Imports.

Du musst diesen Ordner nicht bearbeiten.

In Git sollte er normalerweise ignoriert werden:

__pycache__/

Wenn Du den Ordner löschst, ist das nicht schlimm. Python erzeugt ihn bei Bedarf neu.

Fremder Code: Pakete installieren

Bisher verwendet der Dungeon nur die Standardbibliothek:

  • json
  • pathlib
  • später vielleicht random
  • logging

Dafür musst Du nichts installieren.

Früher oder später möchtest Du aber ein Paket nutzen, das nicht mit Python mitgeliefert wird.

Beispiele:

  • rich für schönere Terminalausgabe,
  • requests für HTTP-Anfragen,
  • pytest für Tests,
  • ruff für Linting und Formatierung.

Solche Pakete kommen typischerweise aus dem Python Package Index, kurz PyPI, und werden mit pip installiert.

Aber: Installiere Pakete für Projekte möglichst nicht einfach systemweit.

Dafür gibt es virtuelle Umgebungen.

Was ist eine virtuelle Umgebung?

Eine virtuelle Umgebung ist ein eigener kleiner Python-Bereich für ein Projekt.

Sie enthält:

  • einen Python-Interpreter beziehungsweise Verweise darauf,
  • eigene installierte Pakete,
  • eigene Skripte wie pip,
  • eigene Metadaten.

Dadurch können verschiedene Projekte unterschiedliche Paketversionen verwenden, ohne sich gegenseitig zu stören.

Beispiel:

projekt-a braucht rich==13.x
projekt-b braucht rich==14.x

Mit getrennten virtuellen Umgebungen ist das kein Problem.

Eine venv anlegen

Python bringt das Modul venv mit.

Im Projektordner:

python -m venv .venv

Dadurch entsteht ein Ordner .venv/.

Der Name .venv ist eine verbreitete Konvention. Du könntest die Umgebung auch anders nennen, aber .venv wird von vielen Editoren und Tools automatisch erkannt.

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 zeigt der Prompt häufig den Namen der Umgebung:

(.venv)

Nun verwenden python und pip normalerweise die Umgebung aus .venv.

Prüfen kannst Du das mit:

python --version
python -m pip --version

Warum python -m pip?

Du wirst oft sehen:

pip install rich

Das funktioniert häufig.

Robuster ist aber:

python -m pip install rich

Damit führst Du pip ausdrücklich mit genau dem Python aus, das gerade aktiv ist.

Das verhindert typische Verwechslungen wie:

  • Paket wurde für eine andere Python-Version installiert.
  • Paket wurde außerhalb der venv installiert.
  • pip und python zeigen auf unterschiedliche Umgebungen.

Für Anfänger ist python -m pip ... deshalb eine gute Gewohnheit.

Ein Paket installieren

In der aktivierten venv:

python -m pip install rich

Danach kannst Du rich im Projekt verwenden:

from rich import print

print("[bold green]Willkommen im Dungeon![/bold green]")

Unser Dungeon braucht dieses Paket nicht. Es dient nur als Beispiel für ein externes Paket.

Installierte Pakete anzeigen:

python -m pip list

Informationen zu einem Paket:

python -m pip show rich

Ein Paket wieder entfernen:

python -m pip uninstall rich

Die venv verlassen

Mit:

deactivate

verlässt Du die virtuelle Umgebung.

Der (.venv)-Hinweis verschwindet.

Die installierten Pakete bleiben im .venv-Ordner erhalten und stehen wieder zur Verfügung, sobald Du die Umgebung erneut aktivierst.

.venv nicht ins Git einchecken

Der Ordner .venv/ kann viele Dateien enthalten und ist nur eine lokale Arbeitsumgebung.

Er gehört normalerweise nicht ins Git-Repository.

Ergänze Deine .gitignore:

.venv/
__pycache__/
*.pyc

Statt die komplette Umgebung zu speichern, dokumentierst Du, welche Pakete das Projekt benötigt.

requirements.txt

Eine einfache Möglichkeit ist eine requirements.txt.

Installierte Pakete in eine Datei schreiben:

python -m pip freeze > requirements.txt

Die Datei enthält dann Zeilen wie:

rich==14.0.0

Auf einem anderen Rechner kann die Umgebung so wiederhergestellt werden:

python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt

Unter Windows aktivierst Du die venv entsprechend mit dem passenden Befehl aus dem vorherigen Abschnitt.

requirements.txt ist ein einfacher Einstieg. Moderne Python-Projekte verwenden häufig zusätzlich oder stattdessen eine pyproject.toml. Das wird relevant, wenn ein Projekt installierbar werden oder seine Metadaten sauber beschreiben soll.

Für diesen Teil reicht requirements.txt.

Projektstruktur mit venv

Ein kleiner Projektordner könnte jetzt 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

In .gitignore:

.venv/
__pycache__/
*.pyc
spielstand.json

Ob spielstand.json ins Git gehört, hängt vom Projekt ab. Für einen lokalen Spielstand normalerweise nicht.

pip, venv und uv

venv und pip gehören zum klassischen Standard-Handwerkszeug:

python -m venv .venv
python -m pip install paketname

Das solltest Du kennen, weil es in vielen Projekten, Dokumentationen und Fehlermeldungen auftaucht.

Es gibt inzwischen modernere Werkzeuge, die vieles davon komfortabler und schneller machen. Eines davon ist uv.

Mit uv lassen sich unter anderem virtuelle Umgebungen, Paketinstallation und Lockfiles moderner verwalten. Das ist aber ein eigenes Thema.

Für heute ist wichtig:

venv isoliert die Umgebung. pip installiert Pakete in diese Umgebung.

Stolperfallen

  • Code auf Modulebene ausführen: Alles auf oberster Ebene eines Moduls läuft beim Import. Starte Programme über main() und den __main__-Block.

  • if __name__ == "__main__" falsch schreiben: Es sind jeweils zwei Unterstriche: __name__ und "__main__".

  • Aus dem falschen Ordner starten: Unsere einfachen Imports funktionieren nur zuverlässig, wenn Du python spiel.py im Projektordner ausführst.

  • from modul import * verwenden: Star Imports machen unklar, woher Namen kommen, und können vorhandene Namen überschreiben.

  • Zirkuläre Importe erzeugen: Wenn zwei Module einander importieren, ist die Aufteilung oft nicht sauber. Gemeinsame Dinge gehören in ein drittes Modul.

  • Module mit Paketnamen verwechseln: Eine Datei json.py in Deinem Projekt kann das Standardbibliotheksmodul json verdecken. Benenne eigene Module nicht wie bekannte Standardmodule oder installierte Pakete.

  • Nur wegen der Dateianzahl aufteilen: Mehr Dateien sind nicht automatisch besser. Ein Modul sollte eine erkennbare Verantwortung haben.

  • Die venv vergessen zu aktivieren: Dann installierst Du Pakete womöglich in die falsche Umgebung.

  • pip und python aus verschiedenen Umgebungen verwenden: Nutze python -m pip, damit pip zum aktiven Python gehört.

  • .venv/ ins Git einchecken: Die virtuelle Umgebung ist lokal und sollte ignoriert werden.

  • pip freeze blind als Projektbeschreibung verstehen: requirements.txt ist ein praktischer Einstieg, aber keine vollständige Projektmetadatei.

  • __pycache__/ bearbeiten: Das ist ein automatisch erzeugter Cache. In Git ignorieren, ansonsten in Ruhe lassen.

Ü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))
Start:
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
Ausgabe:
__main__
Import aus `import_test.py`:
import namenstest
Start:
python import_test.py
Ausgabe:
namenstest
Beim direkten Start heißt das Modul `__main__`. Beim Import erhält es seinen Modulnamen.

3. Einen main()-Block 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()
Direkt:
python hallo.py
gibt den Text aus. Import:
import hallo
gibt nichts aus.

4. Eine venv anlegen und ein Paket installieren

Lege eine virtuelle Umgebung an, aktiviere sie und installiere rich.

Lösung venv anlegen:
python -m venv .venv
macOS/Linux:
source .venv/bin/activate
Windows PowerShell:
.venv\Scripts\Activate.ps1
Paket installieren:
python -m pip install rich
Test:
from rich import print

print("[bold green]rich funktioniert[/bold green]")

5. Abhängigkeiten festhalten

Erzeuge eine requirements.txt aus der aktivierten venv.

Lösung
python -m pip freeze > requirements.txt
Wiederherstellen auf einem anderen Rechner:
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
Unter Windows verwendest Du statt `source ...` den passenden Aktivierungsbefehl für PowerShell oder `cmd.exe`.

Vertiefung

Der schnelle, moderne Nachfolger beziehungsweise Ergänzer zu vielen venv- und pip-Workflows:

uv: pip und venv in schnellgeplant

Wie aus einem Skript ein echtes Kommandozeilen-Tool wird:

Vom Skript zum echten CLIgeplant

Wie Ruff in ein Projekt eingebunden wird:

Ruff: Linter und Formatter in einem

Im nächsten Teil

Im letzten Teilgeplant bringen wir das Spiel mit weiteren Modulen aus der Standardbibliothek zum Funkeln.

Wir schauen auf random, collections, itertools und andere nützliche Werkzeuge, streifen Generatoren und Decorators und ordnen ein, wohin die Reise nach dieser Serie weitergehen kann: Type Hints, Tests, Packaging und async.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!