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 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 import funktioniert,
  • was beim Import eines Moduls passiert,
  • wofür if __name__ == "__main__" gut ist,
  • wie wir Verantwortlichkeiten auf mehrere Dateien verteilen,
  • wie venv Projekte voneinander isoliert,
  • wie Du mit pip externe 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 modul hält die Herkunft der verwendeten Namen sichtbar.
  • from modul import name ist 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, kann spiel.py sie 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 import ist 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.py verwendet UnmoeglicheAktionError.
  • speicher.py verwendet SpielstandError.
  • eingabe.py und befehle.py verwenden UngueltigerBefehlError.
  • spiel.py behandelt die gemeinsame Basisklasse DungeonError.

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:

  • rich für aufwendigere Terminalausgaben,
  • requests für HTTP-Anfragen,
  • pytest für Tests,
  • ruff fü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.py neben spiel.py kann beispielsweise das erwartete Modul json verdecken.

  • 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: .venv ist 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 pip ist klar, welcher Interpreter pip ausführt.

  • System-Python mit sudo pip verändern: Gerade auf Linux können Distribution und Betriebssystem diese Python-Installation selbst verwalten. Nutze für Projekte lieber eine Virtual Environment.

  • pip freeze fü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.

  • .venv verschieben 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))
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__
Lege anschließend `import_test.py` an:
import namenstest
und starte:
python import_test.py
Ausgabe:
namenstest
Beim direkten Start läuft `namenstest.py` als Hauptmodul. Beim Import trägt das Modul seinen normalen Namen.

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()
Direkt:
python hallo.py
gibt den Text aus. Dieser Import:
import hallo
führt dagegen `main()` nicht aus.

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
macOS/Linux:
source .venv/bin/activate
Windows PowerShell:
.venv\Scripts\Activate.ps1
Package installieren:
python -m pip install rich
Test:
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
Eine neue Umgebung kannst Du damit beispielsweise unter macOS oder Linux so aufbauen:
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
Unter Windows verwendest Du den passenden Aktivierungsbefehl für PowerShell oder `cmd.exe`.

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
Der Code von `meldung` wird beim ersten Import ausgeführt. Der zweite Import verwendet im selben Python-Prozess das bereits geladene Modul aus `sys.modules`.

Vertiefung

Eine moderne Alternative für viele Workflows rund um Umgebungen, Dependencies und Lockfiles:

uv: pip und venv in schnell

Wie aus einem Skript ein richtiges Kommandozeilen-Tool wird:

Vom Skript zum echten CLI

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

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!