Zum Inhalt springen

cat posts/vom-skript-zum-cli.md

Vom Skript zum echten CLI: der Dungeon mit argparse und Typer

Ein gutes CLI nimmt Argumente, Optionen und Unterbefehle entgegen. Mit argparse geht das ohne Abhängigkeiten, mit Typer modern über Type Hints.

Der Dungeon aus der Python-Serie wird bisher wie ein normales Skript gestartet:

python spiel.py

Danach fragt das Programm interaktiv:

Wie heißt Du, Abenteurer?
Wie viele Goldmünzen bringst Du mit?

Für ein Spiel ist das völlig in Ordnung. Bei einem Kommandozeilen-Tool möchtest Du viele Angaben aber schon beim Start mitgeben:

python spiel.py --name Karl --gold 50

Oder Du möchtest unterschiedliche Aktionen anbieten:

python spiel.py neu --name Karl
python spiel.py laden spielstand.json
python spiel.py info spielstand.json

Damit wird aus einem Skript langsam ein CLI – ein Command Line Interface.

Ein gutes CLI nimmt Argumente entgegen, prüft sie, erzeugt Hilfe, liefert brauchbare Fehlermeldungen und signalisiert anderen Programmen über Exit-Codes, ob der Aufruf erfolgreich war.

Python bringt dafür mit argparse bereits ein Modul in der Standardbibliothek mit. Wer ein komfortableres, stärker auf Type Hints ausgerichtetes API möchte, kann beispielsweise Typer verwenden.

Warum nicht einfach input(...)?

input(...) ist für interaktive Programme gedacht:

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

Ein Mensch sitzt vor dem Terminal, liest die Frage und antwortet.

Ein CLI soll dagegen häufig auch aus einem Shell-Skript, Cronjob oder einer anderen Anwendung aufgerufen werden können:

dungeon neu --name Karl --gold 50

Alle benötigten Informationen stehen bereits im Kommando.

Das lässt sich automatisieren:

dungeon info spielstand.json > status.txt

Ein unerwartetes:

input("Name: ")

würde dagegen mitten im automatisierten Ablauf auf Tastatureingabe warten.

Das heißt nicht, dass interaktive Eingaben in einem CLI grundsätzlich verboten wären. Ein Spiel kann nach dem Start weiterhin seinen Game-Loop mit input(...) betreiben.

Startoptionen, Dateipfade und verschiedene Betriebsmodi gehören aber meistens besser auf die Kommandozeile.

Argumente, Optionen und Subcommands

Auf der Kommandozeile begegnen Dir hauptsächlich drei Formen.

Ein Argument ist positionsabhängig:

dungeon laden spielstand.json

spielstand.json steht an einer bestimmten Position und ist hier das Argument des Befehls laden.

Eine Option beginnt normalerweise mit - oder --:

dungeon neu --name Karl --gold 50

Optionen können Werte entgegennehmen.

Ein Flag ist eine Option, die allein durch ihre Anwesenheit etwas einschaltet:

dungeon neu --schwer

Und ein Subcommand bezeichnet eine Aktion:

dungeon neu
dungeon laden
dungeon info

Die genaue Terminologie unterscheidet sich zwischen Tools gelegentlich etwas. Für unseren Artikel reicht dieses Modell:

dungeon neu --name Karl --schwer
        │      │      │
        │      │      └── Flag
        │      └───────── Option mit Wert
        └──────────────── Subcommand

sys.argv: was Python tatsächlich bekommt

Die ursprünglichen Kommandozeilenargumente stellt Python über sys.argv bereit:

import sys

print(sys.argv)

Startest Du:

python beispiel.py --name Karl

bekommst Du ungefähr:

['beispiel.py', '--name', 'Karl']

Abgesehen vom ersten Eintrag sind das zunächst nur Strings.

Theoretisch könntest Du die Liste selbst auseinandernehmen:

if "--name" in sys.argv:
    position = sys.argv.index("--name")
    name = sys.argv[position + 1]

Spätestens bei mehreren Optionen wird daraus aber unnötig viel eigener Parser-Code.

Du müsstest selbst klären:

Welche Argumente sind Pflicht?
Welche Optionen dürfen mehrfach vorkommen?
Was bedeutet --help?
Was passiert bei --gold viel?
Welche Fehlermeldung erscheint?
Was ist der Exit-Code?
Welche Optionen gehören zu welchem Subcommand?

Genau dafür gibt es CLI-Parser.

argparse: ohne zusätzliche Dependency

argparse gehört zur Python-Standardbibliothek.

Du musst nichts installieren:

import argparse

Ein erstes CLI:

# dungeon_cli.py

import argparse


def main():
    parser = argparse.ArgumentParser(
        description="Starte den Dungeon.",
    )

    parser.add_argument(
        "--name",
        default="Abenteurer",
        help="Name des Helden",
    )
    parser.add_argument(
        "--gold",
        type=int,
        default=0,
        help="Startgold des Helden",
    )
    parser.add_argument(
        "--laden",
        help="Pfad zu einem Spielstand",
    )
    parser.add_argument(
        "--schwer",
        action="store_true",
        help="Startet im schweren Modus",
    )

    args = parser.parse_args()

    print(f"Name: {args.name}")
    print(f"Gold: {args.gold}")

    if args.laden is not None:
        print(f"Lade Spielstand aus {args.laden}")

    if args.schwer:
        print("Schwerer Modus aktiv.")


if __name__ == "__main__":
    main()

Aufruf:

python dungeon_cli.py --name Karl --gold 50 --schwer

Ausgabe:

Name: Karl
Gold: 50
Schwerer Modus aktiv.

argparse übernimmt bereits eine Menge Arbeit, die sonst in unserem Code landen würde.

Automatische Hilfe

Schon ohne weiteren Code funktioniert:

python dungeon_cli.py --help

Die Hilfe enthält unter anderem die Beschreibung und alle Argumente:

usage: dungeon_cli.py [-h] [--name NAME] [--gold GOLD] [--laden LADEN] [--schwer]

Starte den Dungeon.

options:
  -h, --help     show this help message and exit
  --name NAME    Name des Helden
  --gold GOLD    Startgold des Helden
  --laden LADEN  Pfad zu einem Spielstand
  --schwer       Startet im schweren Modus

Die genaue Darstellung hängt von der Python-Version und vom Terminal ab.

Seit Python 3.14 kann argparse Help-Ausgaben standardmäßig auch farbig darstellen. In einem normalen Terminal sieht das angenehmer aus; in den Textbeispielen dieses Artikels lasse ich die ANSI-Farbcodes natürlich weg.

Wenn Du Farben ausdrücklich abschalten möchtest:

parser = argparse.ArgumentParser(
    description="Starte den Dungeon.",
    color=False,
)

color gibt es ab Python 3.14.

Optionen mit Werten

Diese Definition:

parser.add_argument(
    "--name",
    default="Abenteurer",
    help="Name des Helden",
)

erlaubt:

python dungeon_cli.py --name Karl

Danach enthält:

args.name

den String:

"Karl"

Fehlt die Option, wird der Default verwendet:

"Abenteurer"

Typen mit type=...

Die Kommandozeile liefert zunächst Text.

Bei:

python dungeon_cli.py --gold 50

steht dort ursprünglich die Zeichenfolge:

50

Mit:

parser.add_argument(
    "--gold",
    type=int,
    default=0,
)

lässt argparse den Wert durch int() laufen.

Danach ist:

args.gold

tatsächlich:

50

und nicht:

"50"

Ungültiger Input:

python dungeon_cli.py --gold viel

wird automatisch abgefangen.

argparse zeigt eine Fehlermeldung und beendet den Aufruf normalerweise mit Exit-Code 2.

type für einfache Konvertierungen verwenden

type= eignet sich am besten für überschaubare Umwandlungen wie:

type=int
type=float
type=Path

Komplexe fachliche Validierung würde ich nicht in eine immer ausgefeiltere Converter-Funktion packen.

Die Kommandozeilenschicht soll beispielsweise problemlos aus:

"50"

einen Integer machen.

Ob ein Spieler laut Game Rules höchstens 1000 Gold besitzen darf, gehört dagegen eher zur Anwendungslogik.

Boolean-Flags richtig definieren

Ein Flag wie:

--schwer

hat keinen zusätzlichen Wert.

Mit argparse:

parser.add_argument(
    "--schwer",
    action="store_true",
    help="Startet im schweren Modus",
)

Ohne Flag:

args.schwer

ist:

False

Mit:

python dungeon_cli.py --schwer

wird es:

True

Das hier solltest Du dagegen normalerweise nicht verwenden:

parser.add_argument(
    "--schwer",
    type=bool,
)

Denn:

bool("False")

ergibt:

True

Jeder nicht leere String ist in Python truthy.

Positive und negative Boolean-Optionen

Manchmal möchtest Du ausdrücklich beide Varianten anbieten:

--farbe
--no-farbe

Dafür gibt es seit Python 3.9:

parser.add_argument(
    "--farbe",
    action=argparse.BooleanOptionalAction,
    default=True,
)

argparse erzeugt daraus automatisch:

--farbe
--no-farbe

Das ist praktischer, als zwei konkurrierende Flags von Hand zu verwalten.

Positionsargumente

Ein Argument ohne führenden Bindestrich ist positionsabhängig:

parser.add_argument(
    "datei",
    help="Pfad zum Spielstand",
)

Aufruf:

python dungeon_cli.py spielstand.json

Danach:

args.datei

mit:

"spielstand.json"

Fehlt ein benötigtes Positionsargument, meldet argparse den Fehler selbst.

Für ein CLI wirkt beispielsweise:

dungeon laden spielstand.json

meist natürlicher als:

dungeon laden --datei spielstand.json

weil die Datei der zentrale Gegenstand der Aktion laden ist.

Anders bei:

dungeon neu --name Karl --gold 50

name und gold konfigurieren hier den Start des Spiels.

Das ist keine harte Regel, aber eine gute Orientierung.

Pfade direkt als Path

Für Dateien können wir pathlib.Path verwenden:

from pathlib import Path

parser.add_argument(
    "--laden",
    type=Path,
    help="Pfad zu einem Spielstand",
)

Danach enthält:

args.laden

direkt ein Path-Objekt.

Du kannst also schreiben:

if args.laden is not None:
    print(args.laden.exists())

Wichtig ist der Unterschied zwischen Konvertierung und Validierung.

type=Path

macht aus dem String einen Path.

Es prüft nicht automatisch:

Existiert die Datei?
Ist sie lesbar?
Ist es wirklich eine Datei und kein Verzeichnis?

Das kannst Du nach dem Parsing kontrollieren.

Erlaubte Werte mit choices

Ein Schwierigkeitsgrad soll vielleicht nur drei Werte kennen:

parser.add_argument(
    "--modus",
    choices=[
        "normal",
        "schwer",
        "alptraum",
    ],
    default="normal",
)

Gültig:

python dungeon_cli.py --modus schwer

Ungültig:

python dungeon_cli.py --modus brutal

argparse meldet dann automatisch, welche Werte erlaubt sind.

Tippfehler vorschlagen: neu seit Python 3.14

Python 3.14 kann bei bestimmten falschen choices und Subcommand-Namen sogar einen passenden Wert vorschlagen.

Dafür:

parser = argparse.ArgumentParser(
    suggest_on_error=True,
)

Mit:

parser.add_argument(
    "--modus",
    choices=[
        "normal",
        "schwer",
        "alptraum",
    ],
)

kann ein Tippfehler wie:

python dungeon_cli.py --modus normale

zusätzlich einen Hinweis auf einen ähnlichen erlaubten Wert bekommen.

suggest_on_error ist standardmäßig False und existiert seit Python 3.14.

Wenn Dein Projekt bewusst ältere Python-Versionen unterstützt, kannst Du die Option natürlich nicht einfach im Constructor voraussetzen.

Ein vollständiger Startparser

Trennen wir das Parsing von der eigentlichen Spiellogik:

import argparse
from pathlib import Path


def parse_args():
    parser = argparse.ArgumentParser(
        prog="dungeon",
        description="Starte den Dungeon.",
        suggest_on_error=True,
    )

    parser.add_argument(
        "--name",
        default="Abenteurer",
        help="Name des Helden",
    )
    parser.add_argument(
        "--gold",
        type=int,
        default=0,
        help="Startgold des Helden",
    )
    parser.add_argument(
        "--laden",
        type=Path,
        help="Pfad zu einem Spielstand",
    )
    parser.add_argument(
        "--schwer",
        action="store_true",
        help="Startet im schweren Modus",
    )

    return parser.parse_args()

Die eigentliche Anwendung bekommt bereits fertige Python-Werte:

def starte_spiel(
    name,
    gold,
    spielstand=None,
    schwer=False,
):
    print(f"Name: {name}")
    print(f"Gold: {gold}")

    if spielstand is not None:
        print(f"Lade Spielstand aus {spielstand}")

    if schwer:
        print("Schwerer Modus aktiv.")

    # Danach startet der eigentliche Game-Loop.

Und main() verbindet beide Seiten:

def main():
    args = parse_args()

    starte_spiel(
        name=args.name,
        gold=args.gold,
        spielstand=args.laden,
        schwer=args.schwer,
    )


if __name__ == "__main__":
    main()

Damit steckt die Kommandozeilenlogik nicht mitten im Spiel.

starte_spiel() lässt sich weiterhin direkt aus einem Test oder einem anderen Python-Modul aufrufen.

Fachliche Fehler mit parser.error(...)

Ein Parser kann mehr prüfen als die reine Syntax der Kommandozeile.

Angenommen, negatives Startgold ist für unser CLI grundsätzlich ungültig:

args = parser.parse_args()

if args.gold < 0:
    parser.error("--gold darf nicht negativ sein")

parser.error(...) erzeugt eine CLI-typische Fehlermeldung auf stderr und beendet den Aufruf mit Exit-Code 2.

Das ist sinnvoll für Probleme mit dem Aufruf des CLI.

Für Fehler, die erst in der eigentlichen Anwendung auftreten, verwenden wir dagegen unsere normale Fehlerbehandlung.

Beispielsweise:

Spielstanddatei syntaktisch korrekt angegeben,
aber Datei enthält einen beschädigten Spielstand.

Das ist kein Parser-Fehler mehr.

Subcommands mit argparse

Unser Dungeon soll inzwischen mehrere Aktionen unterstützen:

dungeon neu --name Karl
dungeon laden spielstand.json
dungeon info spielstand.json

Dafür gibt es Subparser.

import argparse
from pathlib import Path


def befehl_neu(args):
    print(f"Neues Spiel für {args.name}")
    print(f"Startgold: {args.gold}")

    if args.schwer:
        print("Schwerer Modus aktiv.")


def befehl_laden(args):
    print(f"Lade Spielstand aus {args.datei}")


def befehl_info(args):
    print(f"Informationen zu {args.datei}")


def parse_args():
    parser = argparse.ArgumentParser(
        prog="dungeon",
        description="Dungeon CLI",
        suggest_on_error=True,
    )

    subparser = parser.add_subparsers(
        required=True,
    )

    neu = subparser.add_parser(
        "neu",
        help="Startet ein neues Spiel",
    )
    neu.add_argument(
        "--name",
        default="Abenteurer",
        help="Name des Helden",
    )
    neu.add_argument(
        "--gold",
        type=int,
        default=0,
        help="Startgold des Helden",
    )
    neu.add_argument(
        "--schwer",
        action="store_true",
        help="Startet im schweren Modus",
    )
    neu.set_defaults(func=befehl_neu)

    laden = subparser.add_parser(
        "laden",
        help="Lädt einen Spielstand",
    )
    laden.add_argument(
        "datei",
        type=Path,
        help="Pfad zur Spielstanddatei",
    )
    laden.set_defaults(func=befehl_laden)

    info = subparser.add_parser(
        "info",
        help="Zeigt Informationen zu einem Spielstand",
    )
    info.add_argument(
        "datei",
        type=Path,
        help="Pfad zur Spielstanddatei",
    )
    info.set_defaults(func=befehl_info)

    return parser.parse_args()


def main():
    args = parse_args()
    args.func(args)


if __name__ == "__main__":
    main()

Jetzt funktionieren:

python spiel.py neu --name Karl --gold 50
python spiel.py laden spielstand.json
python spiel.py info spielstand.json

Das Muster:

neu.set_defaults(func=befehl_neu)

speichert die passende Funktion im Ergebnis des Parsers.

Dadurch braucht main() keine eigene Verzweigung:

if args.befehl == "neu":
    ...
elif args.befehl == "laden":
    ...

sondern nur:

args.func(args)

Warum required=True sinnvoll ist

Ohne:

parser.add_subparsers(
    required=True,
)

ist ein Aufruf ohne Subcommand grundsätzlich möglich:

dungeon

Dann fehlt aber beispielsweise:

args.func

und wir müssten diesen Zustand separat behandeln.

Für unser CLI soll genau eine Aktion gewählt werden.

Deshalb passt:

required=True

hier gut.

Die Option gibt es seit Python 3.7.

Short Options

Neben langen Optionen:

--name

kannst Du eine Kurzform anbieten:

neu.add_argument(
    "-n",
    "--name",
    default="Abenteurer",
)

Nun funktionieren beide:

dungeon neu --name Karl
dungeon neu -n Karl

Bei häufig verwendeten Flags ist das angenehm:

-v
--verbose

Bei einem seltenen Parameter wie:

--maximale-speicherdauer

musst Du dagegen nicht zwanghaft irgendeinen einzelnen Buchstaben reservieren.

Options-Abkürzungen von argparse

Standardmäßig erlaubt argparse eindeutige Abkürzungen langer Optionen.

Existiert nur:

--verbose

kann unter Umständen auch:

--verb

akzeptiert werden.

Bei einem kleinen privaten Skript kann das angenehm sein. Bei einem stabilen CLI kann es aber überraschend werden.

Fügst Du später eine zweite Option hinzu:

--verbosity

ist die bisher eindeutige Abkürzung plötzlich mehrdeutig.

Wenn Du nur exakt dokumentierte Optionsnamen akzeptieren möchtest:

parser = argparse.ArgumentParser(
    allow_abbrev=False,
)

Dann gilt ausschließlich das tatsächlich definierte Interface.

Exit-Codes

Ein CLI kommuniziert nicht nur über Text.

Der aufrufende Prozess bekommt auch einen Exit-Code.

Konventionell bedeutet:

Exit-Code Bedeutung
0 erfolgreich
1 häufig allgemeiner Anwendungsfehler
2 von argparse für CLI-Syntaxfehler

Nur 0 ist die universell wichtige Aussage:

erfolgreich

Welche anderen Codes Dein eigenes Programm verwendet, kannst Du dokumentieren.

1 ist ein üblicher allgemeiner Fehlercode, aber kein Gesetz, das jede Anwendung exakt so verwenden muss.

Eine main()-Funktion kann einen Code zurückgeben:

def main():
    try:
        starte_spiel()
    except SpielstandError as fehler:
        print(
            f"Fehler: {fehler}",
            file=sys.stderr,
        )
        return 1

    return 0

Am Ende:

if __name__ == "__main__":
    raise SystemExit(main())

Der Rückgabewert wird dadurch zum Exit-Code des Prozesses.

stdout und stderr

Ein ordentliches CLI hat zwei Ausgabekanäle.

Normale Ausgabe geht nach stdout:

print("Spielstand gespeichert.")

Fehlermeldungen nach stderr:

import sys

print(
    "Fehler: Spielstand fehlt.",
    file=sys.stderr,
)

Die Shell kann beide Streams getrennt behandeln:

dungeon info spielstand.json > ausgabe.txt 2> fehler.txt

ausgabe.txt bekommt stdout.

fehler.txt bekommt stderr.

Das wird besonders wichtig, wenn ein anderes Programm die normale Ausgabe weiterverarbeiten möchte:

dungeon info spielstand.json | grep "Gold"

Eine Fehlermeldung sollte dann nicht plötzlich zwischen den regulären Daten landen.

argparse schreibt seine eigenen Parsing-Fehler bereits nach stderr.

CLI-Ausgabe und Logging nicht verwechseln

Ein CLI kann zusätzlich Logging verwenden.

Diese Zeile:

print("Spielstand gespeichert.")

ist Teil der Benutzeroberfläche.

Diese:

logger.info(
    "Spielstand gespeichert: datei=%s",
    datei,
)

ist Diagnoseinformation.

Beides kann gleichzeitig sinnvoll sein.

Wie Logging aufgebaut wird, haben wir in Logging statt print behandelt.

Wann argparse völlig ausreicht

Für viele kleine Tools sieht ein CLI ungefähr so aus:

ein paar Optionen
ein oder zwei Subcommands
keine externe Dependency gewünscht

Dann gibt es wenig Grund, argparse sofort zu ersetzen.

Es ist Bestandteil von Python, stabil, gut dokumentiert und unterstützt wesentlich mehr, als wir hier benötigen.

Der Preis dafür ist hauptsächlich die Menge an Konfigurationscode:

parser.add_argument(...)
subparser.add_parser(...)
parser.set_defaults(...)

Bei einem größeren CLI wird dieser Teil schnell umfangreich.

Typer verfolgt deshalb einen anderen Ansatz.

Typer: CLI aus Funktionen und Type Hints

Typer ist eine externe Library und baut auf Click auf.

Installieren kannst Du sie mit uv:

uv add typer

Oder in einem klassischen Virtual Environment:

python -m pip install typer

Die normale Typer-Installation bringt heute auch die Dependencies für Rich-formatierte Ausgabe und Shell-Erkennung mit.

Das Grundprinzip ist einfach:

def neu(
    name: str,
    gold: int,
):
    ...

Die Python-Typen werden gleichzeitig zur Beschreibung des CLI.

Ein einzelner Typer-Befehl

Für ein CLI mit genau einer Aktion reicht sogar:

import typer


def main(
    name: str = "Abenteurer",
    gold: int = 0,
    schwer: bool = False,
):
    print(f"Name: {name}")
    print(f"Gold: {gold}")

    if schwer:
        print("Schwerer Modus aktiv.")


if __name__ == "__main__":
    typer.run(main)

Aufruf:

python dungeon_typer.py --name Karl --gold 50 --schwer

Typer erkennt:

name     String
gold     Integer
schwer   Boolean

und erzeugt daraus die passenden CLI-Parameter.

Auch:

python dungeon_typer.py --help

funktioniert automatisch.

Für ein einziges Kommando ist typer.run(...) angenehm kompakt.

Mehrere Befehle mit Typer()

Unser Dungeon soll aber mehrere Aktionen bekommen:

dungeon neu
dungeon laden
dungeon info

Dann verwenden wir eine App:

import typer

app = typer.Typer(
    help="Verwaltet den Dungeon.",
)

Commands registrieren wir mit Decorators:

@app.command()
def neu():
    """Startet ein neues Spiel."""
    print("Neues Spiel")

Und am Ende:

if __name__ == "__main__":
    app()

Eine wichtige Besonderheit bei genau einem Command

Angenommen, Du schreibst:

import typer

app = typer.Typer()


@app.command()
def neu():
    print("Neues Spiel")


if __name__ == "__main__":
    app()

Die App besitzt genau einen Command und keinen Callback.

Typer klappt diesen einzelnen Command dann zum Hauptkommando zusammen.

Der Aufruf lautet also:

python cli.py

und nicht:

python cli.py neu

Das ist praktisch für kleine CLIs.

Wenn neu ausdrücklich ein Subcommand bleiben soll, obwohl vorerst nur dieser eine existiert, kannst Du einen Callback ergänzen:

import typer

app = typer.Typer()


@app.callback()
def haupt():
    """Verwaltet den Dungeon."""


@app.command()
def neu():
    """Startet ein neues Spiel."""
    print("Neues Spiel")


if __name__ == "__main__":
    app()

Dann ist:

python cli.py neu

korrekt.

Sobald mehrere Commands vorhanden sind, entsteht ohnehin eine Command Group.

Moderne Typer-Syntax mit Annotated

Typer unterstützt verschiedene Schreibweisen. In der aktuellen Dokumentation wird für zusätzliche CLI-Metadaten bevorzugt Annotated verwendet.

from typing import Annotated

import typer

app = typer.Typer(
    help="Dungeon CLI",
)


@app.command()
def neu(
    name: Annotated[
        str,
        typer.Option(
            "--name",
            "-n",
            help="Name des Helden",
        ),
    ] = "Abenteurer",
    gold: Annotated[
        int,
        typer.Option(
            help="Startgold des Helden",
        ),
    ] = 0,
):
    print(f"Neues Spiel für {name}")
    print(f"Startgold: {gold}")


if __name__ == "__main__":
    app()

Der Python-Typ bleibt:

str

beziehungsweise:

int

Die Informationen für das CLI stecken zusätzlich in:

typer.Option(...)

Das ist angenehmer als ältere Schreibweisen, bei denen der Default-Wert selbst ein typer.Option(...)-Objekt war.

Argumente in Typer

Ein Positionsargument deklarieren wir mit:

typer.Argument(...)

Für den Spielstand:

from pathlib import Path
from typing import Annotated

import typer

app = typer.Typer()


@app.command()
def laden(
    datei: Annotated[
        Path,
        typer.Argument(
            help="Pfad zur Spielstanddatei",
        ),
    ],
):
    print(f"Lade Spielstand aus {datei}")


if __name__ == "__main__":
    app()

Aufruf:

python dungeon_typer.py laden spielstand.json

datei ist bereits ein Path.

Dateipfade direkt validieren

Typer kann bei Path-Parametern mehr prüfen als nur die Typkonvertierung.

Unser laden-Befehl verlangt eine existierende Datei:

@app.command()
def laden(
    datei: Annotated[
        Path,
        typer.Argument(
            help="Pfad zur Spielstanddatei",
            exists=True,
            file_okay=True,
            dir_okay=False,
            readable=True,
        ),
    ],
):
    print(f"Lade Spielstand aus {datei}")

Damit kann Typer schon vor dem Funktionsaufruf ablehnen:

Pfad existiert nicht
Pfad zeigt auf ein Verzeichnis
Datei ist nicht lesbar

Weitere Optionen sind unter anderem:

writable
resolve_path

Ohne:

exists=True

bedeutet ein Path auch bei Typer nicht automatisch, dass der Pfad bereits existieren muss.

Boolean-Flags in Typer

Typer erkennt Boolean-Optionen direkt am Typ.

@app.command()
def neu(
    schwer: bool = False,
):
    print(schwer)

Typer kann daraus automatisch positive und negative Varianten erzeugen:

--schwer
--no-schwer

Wenn Du nur ein einzelnes Flag möchtest, kannst Du den Optionsnamen ausdrücklich festlegen:

from typing import Annotated

import typer


@app.command()
def neu(
    schwer: Annotated[
        bool,
        typer.Option(
            "--schwer",
            help="Startet im schweren Modus",
        ),
    ] = False,
):
    if schwer:
        print("Schwerer Modus aktiv.")

Nun gibt es bewusst nur:

--schwer

Eigene Namen für beide Boolean-Zustände

Für unseren Dungeon klingt:

--schwer / --normal

natürlicher als:

--schwer / --no-schwer

Das können wir ausdrücken:

@app.command()
def neu(
    schwer: Annotated[
        bool,
        typer.Option(
            "--schwer/--normal",
            help="Wählt den Schwierigkeitsgrad",
        ),
    ] = False,
):
    if schwer:
        print("Schwerer Modus aktiv.")
    else:
        print("Normaler Modus aktiv.")

Aufrufe:

python dungeon_typer.py neu --schwer
python dungeon_typer.py neu --normal

Das liest sich wie ein echtes CLI und nicht wie ein Python-Bool auf der Kommandozeile.

Mehrere Commands mit Typer

Bauen wir die gleiche Struktur wie bei argparse:

from pathlib import Path
from typing import Annotated

import typer

app = typer.Typer(
    help="Verwaltet den Dungeon.",
)


@app.command()
def neu(
    name: Annotated[
        str,
        typer.Option(
            "--name",
            "-n",
            help="Name des Helden",
        ),
    ] = "Abenteurer",
    gold: Annotated[
        int,
        typer.Option(
            help="Startgold des Helden",
        ),
    ] = 0,
    schwer: Annotated[
        bool,
        typer.Option(
            "--schwer",
            help="Startet im schweren Modus",
        ),
    ] = False,
):
    """Startet ein neues Spiel."""
    print(f"Neues Spiel für {name}")
    print(f"Startgold: {gold}")

    if schwer:
        print("Schwerer Modus aktiv.")


@app.command()
def laden(
    datei: Annotated[
        Path,
        typer.Argument(
            help="Pfad zur Spielstanddatei",
            exists=True,
            file_okay=True,
            dir_okay=False,
            readable=True,
        ),
    ],
):
    """Lädt einen Spielstand."""
    print(f"Lade Spielstand aus {datei}")


@app.command()
def info(
    datei: Annotated[
        Path,
        typer.Argument(
            help="Pfad zur Spielstanddatei",
            exists=True,
            file_okay=True,
            dir_okay=False,
            readable=True,
        ),
    ],
):
    """Zeigt Informationen zu einem Spielstand."""
    print(f"Informationen zu {datei}")


if __name__ == "__main__":
    app()

Aufrufe:

python dungeon_typer.py neu --name Karl --gold 50
python dungeon_typer.py laden spielstand.json
python dungeon_typer.py info spielstand.json

Die Docstrings werden gleichzeitig für die Command-Hilfe verwendet.

python dungeon_typer.py --help
python dungeon_typer.py laden --help

Typer validiert die CLI, nicht Deine gesamte Anwendung

Typer kann aus:

gold: int

einen Integer-Parameter machen.

Das bedeutet aber nicht, dass Deine komplette Anwendung plötzlich Runtime Type Checking besitzt.

Diese Funktion:

def starte_spiel(
    name: str,
    gold: int,
):
    ...

kann aus normalem Python-Code weiterhin direkt mit unsinnigen Werten aufgerufen werden:

starte_spiel(
    name=123,
    gold="viel",
)

Type Hints bleiben Type Hints.

Typer verwendet sie an der CLI-Grenze zum Parsen unterstützter Typen. Die fachlichen Regeln Deiner Anwendung musst Du weiterhin selbst definieren.

Wertebereiche in Typer

Einige typische Prüfungen lassen sich direkt an einer Option beschreiben.

Beispielsweise soll Startgold zwischen 0 und 1000 liegen:

@app.command()
def neu(
    gold: Annotated[
        int,
        typer.Option(
            min=0,
            max=1000,
            help="Startgold des Helden",
        ),
    ] = 0,
):
    print(f"Startgold: {gold}")

Damit wird beispielsweise:

dungeon neu --gold -50

bereits von der CLI-Schicht abgelehnt.

Für komplizierte Game Rules würde ich trotzdem normale Python-Funktionen verwenden.

Eigene Parameterprüfung

Für spezielle CLI-Regeln kannst Du einen Callback verwenden.

Zum Beispiel:

def pruefe_name(name: str):
    if not name.strip():
        raise typer.BadParameter(
            "Der Name darf nicht leer sein."
        )

    return name.strip()

Dann:

@app.command()
def neu(
    name: Annotated[
        str,
        typer.Option(
            callback=pruefe_name,
        ),
    ] = "Abenteurer",
):
    print(f"Neues Spiel für {name}")

typer.BadParameter erzeugt eine passende CLI-Fehlermeldung.

Auch hier gilt: Ein Callback ist gut für eine Regel des Parameters.

Wenn die Validierung zehn andere Domain-Objekte benötigt, gehört sie wahrscheinlich tiefer in die Anwendung.

Fehler in Typer ausgeben

Normale Ausgabe kann weiterhin ganz gewöhnlich mit:

print(...)

erzeugt werden.

Typer stellt zusätzlich unter anderem:

typer.echo(...)
typer.secho(...)

bereit.

Für eine Fehlermeldung auf stderr:

typer.echo(
    "Spielstand konnte nicht geladen werden.",
    err=True,
)

Und ein eigener Exit-Code:

raise typer.Exit(code=1)

Zum Beispiel:

@app.command()
def laden(
    datei: Path,
):
    try:
        lade_spielstand(datei)
    except SpielstandError as fehler:
        typer.echo(
            f"Fehler: {fehler}",
            err=True,
        )
        raise typer.Exit(code=1) from None

Damit bleibt auch ein Typer-CLI für Shell-Skripte sauber auswertbar.

CLI-Schicht und Spiellogik trennen

Ob argparse oder Typer: Die wichtigste Architekturentscheidung hat mit dem Parser selbst wenig zu tun.

Ungünstig:

@app.command()
def neu(name: str = "Abenteurer"):
    # 300 Zeilen Game-Loop
    ...

Besser:

@app.command()
def neu(
    name: str = "Abenteurer",
):
    starte_neues_spiel(
        name=name,
    )

Die Funktion:

starte_neues_spiel(...)

sollte nichts über Typer wissen müssen.

Sie bekommt normale Python-Werte.

Das hat mehrere Vorteile.

Ein Test kann schreiben:

starte_neues_spiel(
    name="Karl",
)

Eine grafische Oberfläche könnte später dieselbe Funktion verwenden.

Und wenn Du irgendwann Typer gegen ein anderes CLI-Framework austauschst, bleibt die eigentliche Anwendung unangetastet.

Unser Dungeon als Anwendung

Eine mögliche Aufteilung:

dungeon/
├── cli.py
├── spiel.py
├── modelle.py
└── speichern.py

spiel.py:

def starte_neues_spiel(
    name,
    gold,
    schwer=False,
):
    ...

speichern.py:

def lade_spielstand(datei):
    ...

cli.py enthält nur die Verbindung zur Kommandozeile:

from pathlib import Path
from typing import Annotated

import typer

from dungeon.spiel import starte_neues_spiel
from dungeon.speichern import lade_spielstand

app = typer.Typer(
    help="Verwaltet den Dungeon.",
)


@app.command()
def neu(
    name: Annotated[
        str,
        typer.Option(
            "--name",
            "-n",
            help="Name des Helden",
        ),
    ] = "Abenteurer",
    gold: Annotated[
        int,
        typer.Option(
            min=0,
            help="Startgold des Helden",
        ),
    ] = 0,
    schwer: Annotated[
        bool,
        typer.Option(
            "--schwer",
            help="Startet im schweren Modus",
        ),
    ] = False,
):
    """Startet ein neues Spiel."""
    starte_neues_spiel(
        name=name,
        gold=gold,
        schwer=schwer,
    )


@app.command()
def laden(
    datei: Annotated[
        Path,
        typer.Argument(
            help="Pfad zum Spielstand",
            exists=True,
            file_okay=True,
            dir_okay=False,
            readable=True,
        ),
    ],
):
    """Lädt einen Spielstand."""
    lade_spielstand(datei)


def main():
    app()


if __name__ == "__main__":
    main()

Die CLI-Funktion ist jetzt dünn.

Das ist ein gutes Zeichen.

Vom Python-Modul zum echten dungeon-Kommando

Bisher starten wir noch:

python dungeon_typer.py neu

oder:

python -m dungeon.cli neu

Ein richtig installiertes CLI möchten wir aber so benutzen:

dungeon neu --name Karl

Dafür braucht das installierbare Python-Projekt einen Entry Point.

In pyproject.toml:

[project]
name = "dungeon"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "typer",
]

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

Die rechte Seite bedeutet:

Python-Modul:Callable

Also:

dungeon.cli
    │
    └── main()

Unsere Funktion:

def main():
    app()

ist damit der Einstiegspunkt.

Wichtig: Damit ein [project.scripts]-Entry-Point tatsächlich installiert werden kann, braucht das Projekt ein Build-System und muss als Package installierbar sein.

Ein aktuelles:

uv init dungeon

erzeugt inzwischen standardmäßig genau so ein paketiertes Application-Projekt mit Build-System und src/-Layout.

Mehr dazu findest Du in uv: pip und venv in schnell.

Ein mögliches Projektlayout

Mit einem src-Layout könnte unser Projekt so aussehen:

dungeon/
├── pyproject.toml
├── README.md
└── src/
    └── dungeon/
        ├── __init__.py
        ├── cli.py
        ├── spiel.py
        ├── modelle.py
        └── speichern.py

Danach kann uv das Projekt in sein Environment installieren:

uv sync

Und das Entry-Point-Kommando lässt sich direkt starten:

uv run dungeon neu --name Karl

Ist das Environment aktiviert beziehungsweise das Package anderweitig installiert, steht entsprechend auch:

dungeon neu --name Karl

zur Verfügung.

Damit sind wir wirklich vom:

python spiel.py

zum installierbaren CLI gekommen.

Shell Completion mit Typer

Typer bringt Unterstützung für Shell Completion mit.

Bei einer normalen Typer-App erscheinen in der Hilfe typischerweise Optionen wie:

--install-completion
--show-completion

Dadurch kann die Shell beispielsweise Command- und Optionsnamen vervollständigen.

Das ist bei einem kleinen Dungeon Spielerei.

Bei einem CLI mit vielen Subcommands und Optionen wird:

dungeon lo<TAB>

aber schnell angenehmer als jedes Kommando vollständig einzutippen.

Wenn Du diese Typer-spezifischen Completion-Optionen nicht möchtest:

app = typer.Typer(
    add_completion=False,
)

argparse oder Typer?

Die Entscheidung ist weniger dramatisch, als sie manchmal dargestellt wird.

argparse ist kein veralteter Vorgänger, den moderne Projekte grundsätzlich ersetzen müssten.

Typer ist auch nicht automatisch die richtige Lösung für jedes Zwanzig-Zeilen-Skript.

argparse passt gut, wenn …

Du keine externe Dependency möchtest und das CLI überschaubar bleibt.

Ein kleines Admin-Skript mit:

--config
--verbose
--dry-run

braucht nicht zwingend ein Framework.

argparse ist außerdem überall verfügbar, wo Python selbst vorhanden ist.

Typer passt gut, wenn …

das CLI mehrere Commands bekommt, Du ohnehin Type Hints verwendest und möglichst wenig Parser-Boilerplate schreiben möchtest.

Aus:

def laden(
    datei: Path,
    verbose: bool = False,
):
    ...

direkt ein CLI ableiten zu können, ist angenehm.

Hinzu kommen Rich-formatierte Hilfe, Completion und die Möglichkeiten des darunterliegenden Click-Ökosystems.

Typer baut auf Click auf

Typer ersetzt Click nicht vollständig durch eine eigene CLI-Engine.

Es erzeugt auf Grundlage Deiner Funktionen und Type Hints passende Click-Strukturen.

Deshalb tauchen bei fortgeschrittener Nutzung Konzepte wie:

Context
Callbacks
Parameter
Command Groups
Exit
Abort

aus Click wieder auf.

Für viele CLIs musst Du davon kaum etwas wissen.

Wenn Du aber an Grenzen von Typer stößt oder sehr genaue Kontrolle über das CLI-Verhalten brauchst, lohnt sich der Blick in Click: Kommandozeilen-Tools mit Decorators bauen.

Nicht gleichzeitig zwei Parser pflegen

Ein Projekt sollte normalerweise nicht dieselben Commands parallel in:

argparse

und:

Typer

definieren.

Dann hättest Du zwei Stellen für:

Optionsnamen
Defaults
Validierung
Hilfetexte
Subcommands

Das wird schnell inkonsistent.

Der Vergleich in diesem Artikel zeigt zwei mögliche Wege.

Für das tatsächliche Projekt entscheidest Du Dich anschließend für einen.

Ein vorhandenes funktionierendes argparse-CLI muss auch nicht nur deshalb migriert werden, weil Typer weniger Zeilen benötigt.

Interaktive und nicht-interaktive Nutzung kombinieren

Unser Dungeon ist ein Sonderfall: Nach dem CLI-Start soll das Spiel selbst weiter interaktiv sein.

Das ist kein Problem.

Beispielsweise:

dungeon neu --name Karl --gold 50

liefert die Startkonfiguration.

Danach beginnt der bekannte Game-Loop:

Du stehst in einer dunklen Halle.

> norden
> umsehen
> nimm fackel

Was Du vermeiden möchtest, ist eine Automatisierung, bei der nicht erkennbar ist, dass noch weitere Eingaben verlangt werden.

Für einen vollständig nicht-interaktiven Modus könnte man später etwa anbieten:

dungeon info spielstand.json

oder:

dungeon validiere spielstand.json

Diese Commands geben ihr Ergebnis aus und beenden sich wieder.

Fehlende Werte optional abfragen

Manchmal soll ein Wert entweder als Option kommen oder interaktiv erfragt werden.

Zum Beispiel:

def frage_falls_fehlt(
    wert,
    text,
):
    if wert is not None:
        return wert

    return input(text)

Dann könnte:

dungeon neu --name Karl

ohne weitere Namensfrage starten.

Bei:

dungeon neu

fragt das Programm dagegen nach.

Das kann für ein Spiel angenehm sein.

Bei einem Admin-Tool wäre ich damit vorsichtiger. Automatisierbare Commands sollten idealerweise einen vollständig nicht-interaktiven Aufruf ermöglichen.

Gute CLI-Funktionen geben Ergebnisse nicht nur aus

Noch besser testbar wird Code, wenn die Anwendungslogik nicht selbst überall print() verwendet.

Statt:

def zeige_info(datei):
    print("Gold: 50")

könnte die fachliche Funktion Daten liefern:

def lese_info(datei):
    return {
        "spieler": "Karl",
        "gold": 50,
    }

Die CLI entscheidet über die Darstellung:

daten = lese_info(datei)

print(f"Spieler: {daten['spieler']}")
print(f"Gold: {daten['gold']}")

Später könntest Du problemlos zusätzlich anbieten:

dungeon info spielstand.json --json

ohne die Ladefunktion neu schreiben zu müssen.

Die CLI ist die äußere Schicht. Sie übersetzt zwischen Kommandozeile und Anwendung.

Maschinell lesbare Ausgabe mitdenken

Sobald ein CLI automatisiert benutzt wird, zählt nicht nur, ob die Ausgabe für Menschen hübsch aussieht.

Angenommen:

dungeon info spielstand.json

liefert:

Spieler: Karl
Gold: 50
Ort: Halle

Das ist angenehm zu lesen.

Für andere Programme wäre vielleicht besser:

dungeon info spielstand.json --json

mit:

{
  "spieler": "Karl",
  "gold": 50,
  "ort": "Halle"
}

Der wichtige Designgedanke ist:

stdout ist Teil der öffentlichen Schnittstelle eines CLI.

Wenn andere Skripte diese Ausgabe parsen, solltest Du sie nicht bei jeder Version willkürlich verändern.

Fehlertexte sind für Menschen, Exit-Codes für Programme

Ein Shell-Skript sollte nicht so prüfen:

if dungeon laden spielstand.json | grep -q "Fehler"; then
    ...
fi

Dafür gibt es Exit-Codes:

if dungeon laden spielstand.json; then
    echo "Erfolgreich"
else
    echo "Fehlgeschlagen"
fi

Der Mensch bekommt zusätzlich:

Fehler: Spielstanddatei ist beschädigt.

auf stderr.

Beide Schnittstellen ergänzen sich.

Häufige Stolperfallen

  • CLI und Spiellogik vermischen: Der Parser sollte Parameter entgegennehmen und normale Anwendungsfunktionen aufrufen. Der komplette Game-Loop gehört nicht in eine Typer-Command-Funktion.

  • sys.argv von Hand zerlegen: Für mehr als triviale Spezialfälle ist ein Parser einfacher und robuster.

  • type=bool mit argparse verwenden: "False" ist ein nicht leerer String und damit für bool() wahr. Verwende Flags oder BooleanOptionalAction.

  • type=Path mit Existenzprüfung verwechseln: Ein Path-Objekt kann auf einen nicht vorhandenen Pfad zeigen.

  • Komplexe Validierung in argparse type= quetschen: Einfache Konvertierungen gehören dort hin, umfangreiche Fachlogik nicht.

  • Zu viele Positionsargumente verwenden: Ein Aufruf wie tool Karl 50 true config.json ist ohne Dokumentation kaum verständlich.

  • Jeden Pflichtwert als --option definieren: Ein zentraler Dateiname oder eine ID kann als Positionsargument natürlicher sein.

  • argparse-Subcommands ohne required=True verwenden und anschließend args.func voraussetzen: Der Aufruf ohne Subcommand muss dann separat behandelt werden.

  • Unbeabsichtigte Options-Abkürzungen übersehen: Wenn ein stabiles CLI nur exakte Namen akzeptieren soll, setze allow_abbrev=False.

  • Python-3.14-Features voraussetzen, obwohl das Projekt noch 3.12 unterstützt: suggest_on_error und die neue color-Option gehören erst zu Python 3.14.

  • stdout und stderr vermischen: Normale Daten nach stdout, Fehler und Diagnose nach stderr.

  • Nur Text ausgeben und Exit-Codes ignorieren: Andere Programme brauchen eine zuverlässige Erfolgsmeldung, die nicht aus Text erraten werden muss.

  • 1 für den einzig erlaubten Fehlercode halten: Es ist ein üblicher allgemeiner Wert. Ein CLI darf weitere sinnvoll dokumentierte Codes verwenden.

  • Bei Typer einen einzelnen @app.command() automatisch als Subcommand erwarten: Bei genau einem Command ohne Callback klappt Typer ihn zum Hauptkommando zusammen.

  • Boolean-Optionen in Typer falsch einschätzen: Ein einfacher bool kann positive und negative Flags erzeugen. Wenn Du nur --schwer möchtest, definiere diesen Namen ausdrücklich.

  • Type Hints mit allgemeiner Runtime-Validierung verwechseln: Typer nutzt sie für die CLI-Grenze. Normale Python-Aufrufe werden dadurch nicht automatisch validiert.

  • Typer-spezifische Regeln in die Spiellogik ziehen: Die Domain sollte möglichst nicht von typer.Option, typer.BadParameter oder Click-Kontexten abhängen.

  • Typer und argparse parallel für dieselben Commands pflegen: Entscheide Dich für eine öffentliche CLI-Definition.

  • [project.scripts] ohne installierbares Package erwarten: Entry Points müssen von einem Build-System installiert werden.

  • Logging als normale CLI-Ausgabe verwenden: Logs und stdout/stderr der Benutzeroberfläche erfüllen unterschiedliche Aufgaben.

  • Interaktive Eingaben in einen vermeintlich automatisierbaren Command einbauen: Dokumentiere Interaktivität oder biete einen vollständig nicht-interaktiven Weg an.

Kompakte Gegenüberstellung

Aufgabe argparse Typer
Installation Standardbibliothek externe Dependency
Parameter definieren add_argument(...) Funktionsparameter und Type Hints
String → Integer type=int gold: int
Positionsargument add_argument("datei") typer.Argument(...)
Option add_argument("--name") typer.Option(...)
Boolean-Flag action="store_true" bool beziehungsweise typer.Option(...)
--foo/--no-foo BooleanOptionalAction bei Bool-Optionen direkt unterstützt
erlaubte Werte choices=... passende Typen/Option-Konfiguration
Dateipfad type=Path Path
Path validieren eigene Prüfung exists=, file_okay=, readable= usw.
Subcommands add_subparsers() @app.command()
Help automatisch automatisch
Shell Completion nicht als vergleichbarer Komplettworkflow eingebaut
Parsing-Fehler standardmäßig Exit-Code 2 Click/Typer übernimmt CLI-Fehler
eigene Fehler stderr + SystemExit z. B. typer.echo(..., err=True) + Exit
kein zusätzliches Package großer Vorteil nein
Boilerplate bei großem CLI vergleichsweise viel meist deutlich weniger

Übungen

1. Ein kleines argparse-CLI

Schreibe hallo_argparse.py, das eine Option --name akzeptiert und:

Hallo, <Name>!

ausgibt.

Lösung
import argparse


def main():
    parser = argparse.ArgumentParser()

    parser.add_argument(
        "--name",
        default="Welt",
        help="Name für die Begrüßung",
    )

    args = parser.parse_args()

    print(f"Hallo, {args.name}!")


if __name__ == "__main__":
    main()
Aufruf:
python hallo_argparse.py --name Karl

2. Einen Boolean-Schalter ergänzen

Ergänze --laut, sodass die Begrüßung in Großbuchstaben erscheint.

Lösung
import argparse


def main():
    parser = argparse.ArgumentParser()

    parser.add_argument(
        "--name",
        default="Welt",
    )
    parser.add_argument(
        "--laut",
        action="store_true",
        help="Gibt die Begrüßung in Großbuchstaben aus",
    )

    args = parser.parse_args()

    text = f"Hallo, {args.name}!"

    if args.laut:
        text = text.upper()

    print(text)


if __name__ == "__main__":
    main()
Aufruf:
python hallo_argparse.py --name Karl --laut

3. Zwei argparse-Subcommands

Baue die Befehle:

neu
laden

laden soll einen Dateipfad als Positionsargument bekommen.

Lösung
import argparse
from pathlib import Path


def befehl_neu(args):
    print(f"Neues Spiel für {args.name}")


def befehl_laden(args):
    print(f"Lade {args.datei}")


def main():
    parser = argparse.ArgumentParser(
        prog="dungeon",
    )

    subparser = parser.add_subparsers(
        required=True,
    )

    neu = subparser.add_parser("neu")
    neu.add_argument(
        "--name",
        default="Abenteurer",
    )
    neu.set_defaults(
        func=befehl_neu,
    )

    laden = subparser.add_parser("laden")
    laden.add_argument(
        "datei",
        type=Path,
    )
    laden.set_defaults(
        func=befehl_laden,
    )

    args = parser.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
Aufrufe:
python cli.py neu --name Karl
python cli.py laden spielstand.json

4. Python-3.14-Vorschläge aktivieren

Aktiviere bei einem argparse-CLI Vorschläge für vertippte choices.

Lösung
import argparse

parser = argparse.ArgumentParser(
    suggest_on_error=True,
)

parser.add_argument(
    "--modus",
    choices=[
        "normal",
        "schwer",
        "alptraum",
    ],
)

args = parser.parse_args()
Die Option `suggest_on_error` setzt Python 3.14 oder neuer voraus.

5. Einen Typer-Command schreiben

Erstelle ein Typer-CLI mit dem Subcommand:

neu

und der Option:

--name
Lösung
from typing import Annotated

import typer

app = typer.Typer()


@app.callback()
def haupt():
    """Verwaltet den Dungeon."""


@app.command()
def neu(
    name: Annotated[
        str,
        typer.Option(
            "--name",
            help="Name des Helden",
        ),
    ] = "Abenteurer",
):
    print(f"Neues Spiel für {name}")


if __name__ == "__main__":
    app()
Installation:
uv add typer
Aufruf:
uv run python cli.py neu --name Karl
Der Callback sorgt hier bewusst dafür, dass `neu` auch bei nur einem registrierten Command als Subcommand erhalten bleibt.

6. Einen Spielstand mit Typer validieren

Schreibe einen Command:

dungeon laden spielstand.json

Der Pfad muss existieren und eine Datei sein.

Lösung
from pathlib import Path
from typing import Annotated

import typer

app = typer.Typer()


@app.command()
def laden(
    datei: Annotated[
        Path,
        typer.Argument(
            exists=True,
            file_okay=True,
            dir_okay=False,
            readable=True,
        ),
    ],
):
    print(f"Lade {datei}")


if __name__ == "__main__":
    app()

7. Einen Fehler korrekt melden

Schreibe mit Typer eine Fehlermeldung nach stderr und beende das Programm mit Exit-Code 1.

Lösung
import typer


def fehler():
    typer.echo(
        "Spielstand konnte nicht geladen werden.",
        err=True,
    )

    raise typer.Exit(code=1)

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!