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 interaktiv gestartet:
python spiel.py
Danach fragt das Programm:
Wie heißt Du, Abenteurer?
Wie viele Goldmünzen bringst Du mit?
Für ein Spiel ist das okay. Für ein echtes Kommandozeilen-Tool möchtest Du Werte aber oft direkt beim Start übergeben:
python spiel.py --name Karl --gold 50
Oder:
python spiel.py laden spielstand.json
Solche Schnittstellen nennt man CLI: Command Line Interface.
Ein gutes CLI kann:
- Optionen entgegennehmen,
- Pflichtargumente prüfen,
- Hilfetexte anzeigen,
- Fehler verständlich melden,
- sinnvolle Exit-Codes zurückgeben,
- in Skripten und Automatisierung verwendet werden.
Python bietet dafür zwei gute Wege:
argparseaus der Standardbibliothek,- Typer als modernes Drittanbieterpaket auf Basis von Type Hints.
Warum nicht einfach input(...)?
input(...) ist gut für interaktive Programme:
name = input("Wie heißt Du? ")
Ein CLI-Tool soll aber häufig automatisierbar sein.
Dieses Kommando ist skriptfreundlich:
python dungeon.py --name Karl --gold 50 --laden spielstand.json
Es kann in Shell-Skripten, Cronjobs oder CI-Pipelines verwendet werden.
Ein Programm, das mitten im Lauf auf input(...) wartet, ist dagegen schwerer zu
automatisieren. Es blockiert, bis jemand etwas eintippt.
Das bedeutet nicht, dass input(...) verboten ist. Für ein Spiel kann es
weiterhin sinnvoll sein. Aber Startparameter, Dateipfade, Modi und Optionen
gehören bei einem CLI besser in Kommandozeilenargumente.
Argumente und Optionen
Auf der Kommandozeile unterscheidet man grob zwischen Argumenten und Optionen.
Ein Argument ist meist positionsabhängig:
python tool.py spielstand.json
Hier ist spielstand.json ein Argument.
Eine Option beginnt meistens mit - oder --:
python tool.py --datei spielstand.json --verbose
--datei erwartet einen Wert. --verbose ist ein Schalter.
Typische Formen:
tool datei.txt
tool --name Karl
tool --schwer
tool -v
tool laden spielstand.json
Ein CLI-Parser nimmt diese Zeichenketten entgegen und wandelt sie in Werte um, mit denen Dein Python-Code arbeiten kann.
sys.argv: der rohe Weg
Python stellt die ursprünglichen Kommandozeilenargumente in sys.argv bereit:
import sys
print(sys.argv)
Startest Du:
python beispiel.py --name Karl
erscheint ungefähr:
['beispiel.py', '--name', 'Karl']
sys.argv[0] ist der Skriptname. Danach folgen die Argumente als Strings.
Du könntest diese Liste selbst auswerten. Für ernsthafte CLIs solltest Du das aber nicht tun.
Warum?
Du müsstest selbst lösen:
- Welche Optionen gibt es?
- Welche Werte sind Pflicht?
- Was passiert bei Tippfehlern?
- Wie wird
--helperzeugt? - Wie werden Typen umgewandelt?
- Welche Exit-Codes sollen verwendet werden?
Dafür gibt es argparse.
argparse: der Standardweg
argparse gehört zur Standardbibliothek. Du musst nichts installieren.
Ein erstes Beispiel:
# 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:
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 dabei:
- Argumente parsen,
--helperzeugen,- Typumwandlung,
- Fehlermeldungen,
- Exit-Codes.
Automatische Hilfe
Rufe auf:
python dungeon_cli.py --help
Die Ausgabe sieht ungefähr so aus:
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
Diese Hilfe bekommst Du geschenkt, wenn Du Argumente sauber mit
add_argument(...) beschreibst.
Das ist einer der wichtigsten Vorteile gegenüber selbst gebautem Parsing.
Optionen mit Werten
Diese Option erwartet einen Wert:
parser.add_argument(
"--name",
default="Abenteurer",
help="Name des Helden",
)
Auf der Kommandozeile:
python dungeon_cli.py --name Karl
Danach steht der Wert in:
args.name
Alle Kommandozeilenwerte sind zunächst Strings. Mit type=... kannst Du sie
umwandeln lassen:
parser.add_argument(
"--gold",
type=int,
default=0,
help="Startgold des Helden",
)
Aufruf:
python dungeon_cli.py --gold 50
Dann ist:
args.gold
ein Integer.
Bei ungültigem Input:
python dungeon_cli.py --gold viel
meldet argparse automatisch einen Fehler und beendet das Programm mit einem
Fehlercode.
Flags mit store_true
Ein Boolean-Schalter wird in argparse typischerweise so definiert:
parser.add_argument(
"--schwer",
action="store_true",
help="Startet im schweren Modus",
)
Ohne Option ist der Wert False.
Mit Option ist er True:
python dungeon_cli.py --schwer
Wichtig: Verwende bei Flags nicht einfach type=bool.
Das hier ist fast immer falsch:
parser.add_argument("--schwer", type=bool)
Warum?
bool("False") ist in Python True, weil jeder nicht leere String truthy ist.
Für einfache Schalter verwendest Du deshalb:
action="store_true"
Für den umgekehrten Fall gibt es:
action="store_false"
Oder bei neueren Python-Versionen argparse.BooleanOptionalAction, wenn ein
Flag sowohl --foo als auch --no-foo unterstützen soll.
Pflichtargumente
Ein Argument ohne führendes -- ist ein positionsabhängiges Argument:
parser.add_argument(
"datei",
help="Pfad zum Spielstand",
)
Aufruf:
python dungeon_cli.py spielstand.json
Dann steht der Wert in:
args.datei
Fehlt das Argument, zeigt argparse automatisch eine Fehlermeldung.
Positionsargumente eignen sich für Werte, die zur eigentlichen Aktion gehören:
dungeon laden spielstand.json
Optionen eignen sich für Einstellungen:
dungeon neu --name Karl --gold 50
Pfade mit Path
Für Dateipfade kannst Du pathlib.Path als Typ verwenden:
from pathlib import Path
parser.add_argument(
"--laden",
type=Path,
help="Pfad zu einem Spielstand",
)
Dann bekommst Du direkt ein Path-Objekt:
if args.laden is not None:
print(args.laden.exists())
Das ist angenehmer als ein roher String, weil Du anschließend mit Methoden wie
.exists(), .open() oder .read_text() arbeiten kannst.
Choices: erlaubte Werte begrenzen
Manche Optionen sollen nur bestimmte Werte erlauben:
parser.add_argument(
"--modus",
choices=["normal", "schwer", "alptraum"],
default="normal",
help="Schwierigkeitsgrad",
)
Gültig:
python dungeon_cli.py --modus schwer
Ungültig:
python dungeon_cli.py --modus brutal
argparse zeigt dann automatisch eine Fehlermeldung und listet die erlaubten
Werte auf.
Ein CLI für den Dungeon
Im Dungeon aus Teil 10 gab es spiel.py mit einer main()-Funktion.
Für ein CLI ist es sinnvoll, den Start des Spiels etwas aufzuteilen.
Statt alle Werte direkt in main() mit input(...) zu erfragen, bauen wir eine
Funktion, die Startwerte entgegennehmen kann:
def starte_spiel(name, gold, spielstand=None, schwer=False):
"""Startet den Dungeon mit optionalen Startwerten."""
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.")
# Hier würde anschließend der eigentliche Game-Loop starten.
Dann kann main() nur noch die Kommandozeile auswerten:
import argparse
from pathlib import Path
def parse_args():
parser = argparse.ArgumentParser(
prog="dungeon",
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",
type=Path,
help="Pfad zu einem Spielstand",
)
parser.add_argument(
"--schwer",
action="store_true",
help="Startet im schweren Modus",
)
return parser.parse_args()
def main():
args = parse_args()
starte_spiel(
name=args.name,
gold=args.gold,
spielstand=args.laden,
schwer=args.schwer,
)
if __name__ == "__main__":
main()
Aufrufe:
python spiel.py --name Karl --gold 50
python spiel.py --name Lara --gold 10 --schwer
python spiel.py --laden spielstand.json
Die eigentliche Spiellogik bleibt getrennt vom CLI-Parsing. Das ist wichtig, damit Du sie später auch testen oder an anderer Stelle aufrufen kannst.
Unterbefehle mit argparse
Viele CLIs haben mehrere Aktionen.
Beispiele:
git status
git commit
git push
Oder für unseren Dungeon:
dungeon neu --name Karl
dungeon laden spielstand.json
dungeon info spielstand.json
Solche Aktionen heißen Subcommands oder Unterbefehle.
Mit argparse verwendest Du dafür add_subparsers(...).
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"Zeige Informationen zu {args.datei}")
def parse_args():
parser = argparse.ArgumentParser(
prog="dungeon",
description="Dungeon CLI",
)
subparser = parser.add_subparsers(
dest="befehl",
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 mit
set_defaults(func=...)
ist praktisch: Jeder Subcommand speichert die passende Funktion in args.func.
main() muss danach nicht mehr selbst mit if args.befehl == ... verzweigen.
Exit-Codes
Ein CLI sollte sinnvolle Exit-Codes liefern.
Üblich ist:
| Exit-Code | Bedeutung |
|---|---|
0 |
erfolgreich |
1 |
allgemeiner Fehler |
2 |
Fehler bei Kommandozeilenargumenten |
argparse beendet das Programm bei ungültigen Argumenten automatisch mit einem
Fehlercode.
Du kannst selbst einen Fehlercode zurückgeben:
import sys
def main():
try:
...
except SpielstandError as fehler:
print(f"Fehler: {fehler}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
Die Zeile
raise SystemExit(main())
sorgt dafür, dass der Rückgabewert von main() zum Exit-Code des Programms
wird.
Für einfache Skripte ist das nicht zwingend notwendig. Für echte CLI-Tools ist es eine gute Gewohnheit.
Fehlermeldungen: stdout oder stderr?
Normale Ausgaben gehören auf stdout.
Fehlermeldungen gehören auf stderr.
In Python:
import sys
print("Spielstand gespeichert.")
print("Fehler: Spielstand fehlt.", file=sys.stderr)
Warum ist das wichtig?
Shells können stdout und stderr getrennt umleiten:
dungeon info spielstand.json > ausgabe.txt 2> fehler.txt
Für kleine Skripte wird das oft ignoriert. Für gute CLI-Tools lohnt sich diese Trennung.
argparse schreibt eigene Fehlermeldungen bereits nach stderr.
Typer: CLI aus Type Hints
argparse ist robust und immer verfügbar. Bei größeren CLIs entsteht aber
relativ viel Boilerplate.
Typer geht einen anderen Weg: Du beschreibst eine Funktion mit Type Hints, und Typer baut daraus ein CLI.
Unter der Haube baut Typer auf Click auf – einer etablierten Bibliothek, die ein CLI über Decorators beschreibt. Für viele Fälle reicht Typer. Wenn Du mehr Kontrolle willst oder Typers Fehlermeldungen einordnen musst, lohnt der direkte Blick auf Clickgeplant.
Typer ist kein Teil der Standardbibliothek. Du musst es installieren.
Mit uv:
uv add typer
Oder klassisch in einer aktivierten venv:
python -m pip install typer
Ein erstes Beispiel:
# dungeon_typer.py
import typer
app = typer.Typer(help="Starte den Dungeon.")
@app.callback()
def haupt():
"""Verwaltet den Dungeon."""
@app.command()
def neu(
name: str = "Abenteurer",
gold: int = 0,
schwer: bool = False,
):
print(f"Neues Spiel für {name}")
print(f"Startgold: {gold}")
if schwer:
print("Schwerer Modus aktiv.")
if __name__ == "__main__":
app()
Aufruf:
python dungeon_typer.py neu --name Karl --gold 50 --schwer
Typer erkennt aus den Type Hints:
nameist ein String,goldist ein Integer,schwerist ein Boolean-Flag.
Auch --help wird automatisch erzeugt.
Das @app.callback() ganz oben sieht überflüssig aus, ist es aber nicht. Hat eine
Typer-App genau einen Befehl und keinen Callback, klappt Typer sie zum
Hauptkommando zusammen – der Befehlsname entfällt dann, und dungeon_typer.py neu
scheitert mit Got unexpected extra argument(s) (neu). Der Callback sorgt dafür,
dass neu ein echter Unterbefehl bleibt. Alternativ kannst Du bei einem einzelnen
Befehl auch typer.run(neu) statt typer.Typer() verwenden – dann rufst Du das
Skript ohne Befehlsnamen auf.
Typer mit Annotated
Moderne Typer-Beispiele verwenden häufig Annotated, um Typ und CLI-Metadaten
sauber zu trennen.
from typing import Annotated
import typer
app = typer.Typer(help="Dungeon CLI")
@app.callback()
def haupt():
"""Verwaltet den Dungeon."""
@app.command()
def neu(
name: Annotated[
str,
typer.Option(help="Name des Helden"),
] = "Abenteurer",
gold: Annotated[
int,
typer.Option(help="Startgold des Helden"),
] = 0,
schwer: Annotated[
bool,
typer.Option(help="Startet im schweren Modus"),
] = False,
):
print(f"Neues Spiel für {name}")
print(f"Startgold: {gold}")
if schwer:
print("Schwerer Modus aktiv.")
if __name__ == "__main__":
app()
Das ist etwas länger als die Minimalversion, aber klarer dokumentiert.
Der Typ bleibt der Typ:
name: str
Die CLI-Beschreibung steckt in:
typer.Option(...)
Argumente und Optionen in Typer
Typer unterscheidet wie argparse zwischen Argumenten und Optionen.
Ein Parameter mit typer.Argument(...) ist ein positionsabhängiges Argument:
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer()
@app.callback()
def haupt():
"""Verwaltet den Dungeon."""
@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
Eine Option verwendest Du mit typer.Option(...):
@app.command()
def neu(
name: Annotated[
str,
typer.Option("--name", "-n", help="Name des Helden"),
] = "Abenteurer",
):
print(f"Neues Spiel für {name}")
Aufruf:
python dungeon_typer.py neu --name Karl
python dungeon_typer.py neu -n Karl
Boolean-Flags in Typer
Bei Boolean-Werten erzeugt Typer automatisch Flags.
Du kannst auch ausdrücklich positive und negative Varianten festlegen:
@app.command()
def neu(
schwer: Annotated[
bool,
typer.Option(
"--schwer/--normal",
help="Schwierigkeitsgrad umschalten",
),
] = 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 ist lesbarer als ein Boolean-Wert auf der Kommandozeile:
python dungeon_typer.py neu --schwer true
Für Flags ist eine eigene Option ohne Wert fast immer natürlicher.
Mehrere Befehle mit Typer
Mit app = typer.Typer() und mehreren @app.command()-Funktionen entstehen
mehrere Subcommands:
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer(help="Dungeon CLI")
@app.command()
def neu(
name: Annotated[
str,
typer.Option(help="Name des Helden"),
] = "Abenteurer",
gold: Annotated[
int,
typer.Option(help="Startgold des Helden"),
] = 0,
):
"""Startet ein neues Spiel."""
print(f"Neues Spiel für {name}")
print(f"Startgold: {gold}")
@app.command()
def laden(
datei: Annotated[
Path,
typer.Argument(help="Pfad zur Spielstanddatei"),
],
):
"""Lädt einen Spielstand."""
print(f"Lade Spielstand aus {datei}")
@app.command()
def info(
datei: Annotated[
Path,
typer.Argument(help="Pfad zur Spielstanddatei"),
],
):
"""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 der Funktionen erscheinen in der Hilfe.
python dungeon_typer.py --help
python dungeon_typer.py neu --help
Typer und die bestehende Spiellogik
Auch bei Typer sollte die CLI-Schicht nicht die komplette Spiellogik enthalten.
Gut:
@app.command()
def neu(name: str = "Abenteurer", gold: int = 0):
starte_neues_spiel(name=name, gold=gold)
Weniger gut:
@app.command()
def neu(name: str = "Abenteurer", gold: int = 0):
# 200 Zeilen Game-Loop direkt hier
...
Die CLI-Funktion sollte Argumente entgegennehmen, validieren und an die eigentliche Anwendung weiterreichen.
Dann kannst Du die Spiellogik später auch ohne CLI testen.
Installation als echtes Kommando
Bisher starten wir:
python spiel.py
python dungeon_typer.py neu
Ein echtes CLI möchtest Du irgendwann so aufrufen:
dungeon neu --name Karl
Dafür muss das Projekt installierbar sein und einen Entry Point definieren.
In einer pyproject.toml kann das ungefähr so aussehen:
[project]
name = "dungeon"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"typer",
]
[project.scripts]
dungeon = "dungeon.cli:app"
Bei Typer funktioniert ein direkter app-Entry Point häufig bequem, wenn das
Projekt passend aufgebaut ist.
Für eine klassische main()-Funktion sähe es eher so aus:
[project.scripts]
dungeon = "dungeon.cli:main"
Dann könnte nach der Installation ein Kommando dungeon verfügbar sein.
Das vollständige Packaging mit src/-Layout, pyproject.toml und Entry Points
ist ein eigenes Thema. Für diesen Artikel reicht die Idee:
Aus einem Skript wird ein echtes Kommando, wenn das Projekt installierbar ist und einen CLI-Einstiegspunkt definiert.
argparse oder Typer?
Beide Werkzeuge sind sinnvoll.
Nimm argparse, wenn …
- Du keine zusätzliche Abhängigkeit möchtest.
- Das Skript klein bleibt.
- Du möglichst nah an der Standardbibliothek bleiben willst.
- Das CLI nur wenige Optionen braucht.
- Du maximale Verfügbarkeit ohne Installation möchtest.
Für viele Admin-Skripte reicht argparse völlig aus.
Nimm Typer, wenn …
- Dein CLI mehrere Subcommands bekommt.
- Du ohnehin Type Hints verwendest.
- Du weniger Boilerplate möchtest.
- Dir schönere Hilfeausgaben wichtig sind.
- Du später Shell Completion oder ein installierbares CLI möchtest.
- Du mit einer Drittanbieterabhängigkeit leben kannst.
Typer ist besonders angenehm, wenn das CLI wächst.
argparse und Typer nicht unnötig mischen
In einem Projekt solltest Du Dich normalerweise für einen CLI-Ansatz entscheiden.
Technisch kannst Du beides kombinieren. Praktisch wird das schnell unübersichtlich:
- Zwei unterschiedliche Konfigurationsstile.
- Unterschiedliche Fehlerausgaben.
- Unterschiedliche Denkweise bei Parametern.
- Mehr Abhängigkeiten und mehr Komplexität.
Wenn ein Projekt bereits ein kleines argparse-CLI hat, muss es nicht sofort auf
Typer umgestellt werden.
Wenn Du neu startest und Type Hints nutzt, kann Typer angenehmer sein.
CLI-freundliche Funktionen schreiben
Egal ob argparse oder Typer: Gute CLI-Programme entstehen leichter, wenn die
eigentliche Logik normale Funktionen verwendet.
Schlecht testbar:
def main():
name = input("Name: ")
print(f"Hallo {name}")
Besser:
def begruesse(name):
return f"Hallo {name}"
def main():
print(begruesse("Karl"))
Noch besser für ein CLI:
def begruesse(name):
return f"Hallo {name}"
def main():
args = parse_args()
print(begruesse(args.name))
Die Funktion begruesse(...) kann unabhängig vom CLI getestet werden.
Das gleiche Prinzip gilt für den Dungeon:
- CLI liest Optionen.
- Spiellogik verarbeitet State.
- Ausgabe wird bewusst erzeugt.
- Tests prüfen die Logik ohne Kommandozeile.
Interaktiv und automatisierbar kombinieren
Manchmal möchtest Du beides:
- Werte direkt per CLI übergeben,
- fehlende Werte interaktiv abfragen.
Das ist okay, solange es bewusst passiert.
Beispiel:
def frage_falls_fehlt(wert, text):
if wert is not None:
return wert
return input(text)
Verwendung:
name = frage_falls_fehlt(args.name, "Wie heißt Du? ")
Für ein automatisierbares Tool sollte es aber einen Weg geben, alle notwendigen Werte ohne interaktive Eingabe zu übergeben.
Sonst bleibt das Programm in Skripten plötzlich stehen.
Stolperfallen
-
CLI und Spiellogik vermischen: Die CLI-Schicht sollte Argumente auswerten und die eigentliche Logik aufrufen, nicht alles selbst enthalten.
-
input(...)mitten in automatisierbaren Tools verwenden: Wenn ein Wert als Argument übergeben werden kann, sollte das möglich sein. -
type=boolin argparse verwenden: Für Flags istaction="store_true"oderBooleanOptionalActionrichtig. -
Pflichtwerte als Optionen tarnen: Wenn ein Wert immer benötigt wird, ist ein positionsabhängiges Argument oft klarer.
-
Zu viele Positionsargumente verwenden: Mehrere nacheinander folgende Positionsargumente sind schwerer zu lesen als benannte Optionen.
-
Fehlermeldungen auf stdout schreiben: Normale Ausgabe gehört auf stdout, Fehler auf stderr.
-
Exit-Codes ignorieren: Für Automatisierung ist wichtig, ob ein Kommando erfolgreich war.
-
argparse-Subcommands ohnerequired=True: Sonst kann ein Aufruf ohne Unterbefehl zu unklarem Verhalten führen. -
Typer verwenden, ohne die Abhängigkeit festzuhalten: Typer muss installiert und als Projektabhängigkeit festgehalten werden.
-
Typer und argparse unnötig mischen: Wähle für ein CLI möglichst einen Ansatz.
-
Type Hints als Laufzeitvalidierung missverstehen: Typer nutzt Type Hints für die CLI-Umwandlung. Für Deine interne Programmlogik ersetzen sie keine fachliche Validierung.
-
Packaging unterschätzen: Ein echtes Kommando wie
dungeonbraucht mehr als nur eine.py-Datei. Dafür ist ein installierbares Projekt mit Entry Point sinnvoll.
Übungen
1. Ein kleines argparse-CLI schreiben
Schreibe ein Skript 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()
python hallo_argparse.py --name Karl
2. Einen Boolean-Schalter ergänzen
Ergänze --laut, sodass die Begrüßung in Großbuchstaben ausgegeben wird.
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()
python hallo_argparse.py --name Karl --laut
3. Einen argparse-Subcommand bauen
Schreibe ein CLI mit den Befehlen neu und laden.
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(
dest="befehl",
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()
python cli.py neu --name Karl
python cli.py laden spielstand.json
4. Dasselbe mit Typer schreiben
Schreibe ein Typer-CLI mit einem Befehl neu.
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(help="Name des Helden"),
] = "Abenteurer",
):
print(f"Neues Spiel für {name}")
if __name__ == "__main__":
app()
uv add typer
uv run python cli.py neu --name Karl
5. Typer mit Argument und Option
Ergänze einen Befehl laden, der einen Dateipfad als Argument und --verbose
als Flag akzeptiert.
Lösung
from pathlib import Path
from typing import Annotated
import typer
app = typer.Typer()
@app.callback()
def haupt():
"""Verwaltet den Dungeon."""
@app.command()
def laden(
datei: Annotated[
Path,
typer.Argument(help="Pfad zum Spielstand"),
],
verbose: Annotated[
bool,
typer.Option("--verbose", "-v", help="Mehr Ausgabe"),
] = False,
):
if verbose:
print(f"Lade Spielstand aus: {datei.resolve()}")
else:
print(f"Lade {datei}")
if __name__ == "__main__":
app()
uv run python cli.py laden spielstand.json
uv run python cli.py laden spielstand.json --verbose
Vertiefung
- Python lernen, Teil 10: Module und Projektstruktur
- Type Hints mit mypy und pyrightgeplant
- uv: pip und venv in schnell
- Ruff: Linter und Formatter in einem
Fazit
Ein CLI ist die Grenze zwischen Deinem Python-Code und der Kommandozeile.
Für kleine Skripte ist argparse der sichere Standard:
parser = argparse.ArgumentParser()
parser.add_argument("--name")
args = parser.parse_args()
Es ist überall verfügbar, erzeugt Hilfe und Fehlermeldungen automatisch und reicht für viele Tools völlig aus.
Typer ist die modernere, komfortablere Variante für größere CLIs:
@app.command()
def neu(name: str = "Abenteurer"):
...
Es nutzt Type Hints, erzeugt schöne Hilfeausgaben und macht Subcommands sehr angenehm.
Entscheidend ist aber nicht das Werkzeug, sondern die Struktur:
- CLI-Parsing bleibt am Rand des Programms.
- Die eigentliche Logik steckt in normalen Funktionen.
- Fehler werden verständlich gemeldet.
- Exit-Codes sind sinnvoll.
- Interaktive Eingaben blockieren Automatisierung nicht unnötig.
So wird aus einem Skript Schritt für Schritt ein echtes Kommandozeilen-Tool.
Weiterlesen
- Python-Dokumentation: argparse
- Python-Dokumentation: argparse Tutorial
- Python-Dokumentation: main
- Python-Dokumentation: sys.argv
- Click: Kommandozeilen-Tools mit Decorators bauengeplant – die Bibliothek, auf der Typer aufbaut.
- Typer-Dokumentation
- Typer: First Steps
- Typer: Arguments
- Typer: Options
- Typer: Commands
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.