cat posts/click-cli-bauen.md
Click: Kommandozeilen-Tools mit Decorators bauen
Click baut CLIs aus Decorators: Optionen, Argumente, Unterbefehle, Prompts, Fehlerbehandlung und Tests mit CliRunner – der Baukasten, auf dem auch Typer aufsetzt.
In Vom Skript zum echten CLI haben wir uns argparse und
Typer angesehen. Dort fiel ein Name nur am Rande: Click.
Click ist die Bibliothek, auf der Typer intern aufbaut. Sie ist aber auch für sich genommen ein sehr guter Baukasten für Kommandozeilen-Tools.
Die drei Werkzeuge setzen an unterschiedlichen Stellen an:
argparsebeschreibt ein CLI über Methodenaufrufe.- Typer leitet ein CLI stark aus Type Hints ab.
- Click beschreibt ein CLI über Decorators direkt an den Funktionen.
Ein minimales Click-Kommando sieht so aus:
import click
@click.command()
@click.option("--name", default="Abenteurer")
def spielen(name):
click.echo(f"Willkommen, {name}.")
Was das Kommando annimmt, steht direkt über der Funktion, die es ausführt.
Click kümmert sich dabei um viele Dinge, die man bei CLIs sonst selbst bauen müsste:
- Optionen und Argumente,
- Hilfetexte,
- Unterbefehle,
- Typumwandlung,
- Prompts,
- farbige Ausgabe,
- Fehler und Exit-Codes,
- Shell Completion,
- Tests mit
CliRunner.
Installation
Click ist eine externe Abhängigkeit.
Mit uv:
uv add click
Oder klassisch in einer aktivierten virtuellen Umgebung:
python -m pip install click
Die installierte Version kannst Du mit importlib.metadata prüfen:
python -c "from importlib.metadata import version; print(version('click'))"
Oder im Python-Code:
from importlib.metadata import version
print(version("click"))
Click ist kein Teil der Standardbibliothek. Wenn Dein Projekt Click verwendet,
gehört es in die Projektabhängigkeiten, zum Beispiel in die pyproject.toml.
Aktuelle Click-Versionen setzen Python 3.10 oder neuer voraus. Prüfe bei sehr alten Projekten deshalb, welche Click-Version dort noch verwendet werden kann.
Der erste Befehl
Ein Click-Befehl ist eine Funktion mit @click.command() darüber:
import click
@click.command()
def spielen():
"""Startet den Dungeon."""
click.echo("Du betrittst den Dungeon.")
if __name__ == "__main__":
spielen()
Speichere die Datei als dungeon.py und starte sie:
python dungeon.py
Ausgabe:
Du betrittst den Dungeon.
Der Decorator
@click.command()
verwandelt die Funktion in ein Click-Command-Objekt.
Das ist wichtig: spielen ist nach dem Decorator nicht mehr einfach eine
Funktion. Beim Aufruf liest Click die Kommandozeile aus, verarbeitet
Optionen und Argumente und ruft dann Deine Funktion mit den passenden Werten auf.
Der Docstring wird zur Hilfe
Rufe auf:
python dungeon.py --help
Die Ausgabe sieht ungefähr so aus:
Usage: dungeon.py [OPTIONS]
Startet den Dungeon.
Options:
--help Show this message and exit.
Click verwendet den Docstring der Funktion als Hilfetext.
Das ist praktisch: Eine gut dokumentierte Funktion liefert gleichzeitig eine brauchbare CLI-Hilfe.
Die erste Zeile des Docstrings ist besonders wichtig. Bei Unterbefehlen verwendet Click sie als Kurzbeschreibung in der Befehlsübersicht.
click.echo(...) statt print(...)
Click bringt mit click.echo(...) eine eigene Ausgabefunktion mit:
click.echo("Normale Ausgabe.")
Fehlermeldungen oder Diagnoseausgaben können nach stderr geschrieben werden:
click.echo("Fehlerhafte Eingabe.", err=True)
Für kleine Skripte funktioniert print(...) natürlich weiterhin. In Click-Tools
ist click.echo(...) aber meist die bessere Gewohnheit, weil es sauberer mit
Terminalausgabe, Umleitungen und farbiger Ausgabe zusammenspielt.
Farbige Ausgabe:
click.secho("Treffer!", fg="red", bold=True)
click.secho("Gerettet.", fg="green")
Oder mit click.style(...):
text = click.style("Gold: 50", fg="yellow")
click.echo(f"{text} im Beutel.")
click.secho(...) ist im Grunde click.style(...) plus click.echo(...).
Wichtig bleibt die Trennung:
click.echo(...)ist Benutzerkommunikation.- Logging ist Diagnose für Betrieb und Fehlersuche.
Optionen mit @click.option(...)
Optionen sind benannte Parameter mit -- oder -.
import click
@click.command()
@click.option(
"--name",
default="Abenteurer",
help="Name des Helden.",
)
def spielen(name):
"""Startet den Dungeon."""
click.echo(f"Willkommen, {name}.")
if __name__ == "__main__":
spielen()
Aufruf:
python dungeon.py --name Karl
Ausgabe:
Willkommen, Karl.
Ohne Option wird der Default verwendet:
python dungeon.py
Ausgabe:
Willkommen, Abenteurer.
Pflichtoptionen und Defaults
Eine Option kann verpflichtend sein:
@click.option(
"--name",
required=True,
help="Name des Helden.",
)
Fehlt sie, meldet Click den Fehler:
Usage: dungeon.py [OPTIONS]
Try 'dungeon.py --help' for help.
Error: Missing option '--name'.
Defaults kannst Du in der Hilfe anzeigen lassen:
@click.option(
"--hp",
default=100,
show_default=True,
help="Lebenspunkte.",
)
In der Hilfe erscheint dann sinngemäß:
--hp INTEGER Lebenspunkte. [default: 100]
Das ist eine kleine, aber wichtige Freundlichkeit für Benutzer Deines Tools.
Parameternamen
Click leitet den Funktionsparameter aus dem Optionsnamen ab:
@click.option("--name")
def spielen(name):
...
--name wird zu name.
Bei Bindestrichen werden Unterstriche verwendet:
@click.option("--max-hp")
def spielen(max_hp):
...
--max-hp wird zu max_hp.
Du kannst den Parameternamen auch ausdrücklich setzen. Dafür gibst Du einen zusätzlichen Namen ohne Bindestriche an:
@click.option("--filter", "muster", default="")
def raeume(muster):
...
Auf der Kommandozeile heißt die Option weiterhin:
--filter
In der Funktion heißt der Parameter aber:
muster
Das ist nützlich, wenn der natürliche Optionsname mit einem Python-Builtin kollidiert oder in Python kein guter Parametername wäre.
Beispiele:
@click.option("--filter", "muster")
@click.option("--type", "typ")
@click.option("--input", "eingabe")
Die Werte übergibt Click als Keyword Arguments. Deshalb müssen die Namen passen.
Click liest keine Type Hints
Ein häufiger Irrtum bei Umsteigern von Typer:
@click.option("--hp")
def spielen(hp: int):
...
Die Annotation hp: int macht --hp in Click nicht automatisch zu einer Zahl.
Click wertet Type Hints nicht als CLI-Typen aus.
Der Typ kommt entweder aus type=...:
@click.option("--hp", type=int)
oder wird aus dem Default abgeleitet:
@click.option("--hp", default=100)
Wenn Du präzise sein willst, gib den Typ ausdrücklich an:
@click.option(
"--hp",
type=int,
default=100,
show_default=True,
)
def spielen(hp):
...
Typer baut sein CLI aus Type Hints. Click nicht.
Zahlen und Bereiche
Click kann Werte umwandeln und prüfen.
Ein Integer:
@click.option("--hp", type=int, default=100)
Ein begrenzter Integer-Bereich:
@click.option(
"--hp",
type=click.IntRange(1, 100),
default=100,
show_default=True,
)
Kompletter Ausschnitt:
import click
@click.command()
@click.option(
"--hp",
type=click.IntRange(1, 100),
default=100,
show_default=True,
help="Lebenspunkte von 1 bis 100.",
)
def spielen(hp):
click.echo(f"Du startest mit {hp} HP.")
if __name__ == "__main__":
spielen()
Ungültiger Aufruf:
python dungeon.py --hp 500
Click meldet sauber:
Usage: dungeon.py [OPTIONS]
Try 'dungeon.py --help' for help.
Error: Invalid value for '--hp': 500 is not in the range 1<=x<=100.
Für Fließkommazahlen gibt es entsprechend click.FloatRange(...).
Auswahlwerte mit Choice
Wenn nur bestimmte Werte erlaubt sind, verwende click.Choice(...):
@click.option(
"--modus",
type=click.Choice(["leicht", "normal", "schwer"]),
default="normal",
show_default=True,
help="Schwierigkeitsgrad.",
)
def spielen(modus):
click.echo(f"Modus: {modus}")
Gültig:
python dungeon.py --modus schwer
Ungültig:
python dungeon.py --modus brutal
Click zeigt dann automatisch die erlaubten Werte an.
Wenn Groß- und Kleinschreibung egal sein soll:
click.Choice(
["leicht", "normal", "schwer"],
case_sensitive=False,
)
Pfade mit click.Path(...)
Für Dateipfade gibt es click.Path(...).
from pathlib import Path
import click
@click.command()
@click.option(
"--speicher",
type=click.Path(
dir_okay=False,
path_type=Path,
),
default="spielstand.json",
show_default=True,
help="Datei für den Spielstand.",
)
def spielen(speicher):
click.echo(f"Spielstand: {speicher}")
click.echo(f"Typ: {type(speicher).__name__}")
Mit
path_type=Path
liefert Click direkt ein pathlib.Path-Objekt.
Weitere nützliche Optionen:
click.Path(exists=True)
click.Path(file_okay=True, dir_okay=False)
click.Path(writable=True)
click.Path(readable=True)
Damit kannst Du früh prüfen lassen, ob ein Pfad existiert, eine Datei ist oder beschreibbar sein soll.
Dateien mit click.File(...)
click.File(...) öffnet eine Datei direkt für Dich:
@click.command()
@click.argument(
"datei",
type=click.File("r", encoding="utf-8"),
)
def anzeigen(datei):
click.echo(datei.read())
Aufruf:
python tool.py notiz.txt
Click kümmert sich um das Öffnen und Schließen.
Außerdem versteht click.File(...) standardmäßig - als stdin oder stdout. Das
ist praktisch für Tools, die in Pipes verwendet werden.
Für viele Anwendungen verwende ich trotzdem gerne click.Path(...) und öffne die
Datei selbst mit pathlib, weil der Code dadurch expliziter bleibt. Beide Wege
sind sinnvoll.
Boolean-Flags
Ein Schalter ohne Wert wird mit is_flag=True definiert:
@click.option(
"--schwer",
is_flag=True,
help="Schwerer Modus.",
)
def spielen(schwer):
if schwer:
click.echo("Schwerer Modus aktiv.")
Aufruf:
python dungeon.py --schwer
Ohne --schwer ist der Wert False, mit --schwer ist er True.
Für ein Flag mit positiver und negativer Form:
@click.option(
"--farbe/--keine-farbe",
default=True,
show_default=True,
help="Farbige Ausgabe.",
)
def spielen(farbe):
...
Dann gibt es beide Formen:
python dungeon.py --farbe
python dungeon.py --keine-farbe
Das ist praktisch, wenn ein Wert normalerweise aktiv, aber gezielt abschaltbar sein soll.
Zählende Flags
Für Verbosity ist count=True nützlich:
@click.option(
"-v",
"--verbose",
count=True,
help="Mehr Ausgabe, mehrfach möglich.",
)
def spielen(verbose):
click.echo(f"Verbose-Stufe: {verbose}")
Aufrufe:
python dungeon.py
python dungeon.py -v
python dungeon.py -vv
python dungeon.py -vvv
Ergebnisse:
Verbose-Stufe: 0
Verbose-Stufe: 1
Verbose-Stufe: 2
Verbose-Stufe: 3
Das Muster ist bei CLI-Tools sehr verbreitet.
Optionen mehrfach verwenden
Mit multiple=True darf eine Option mehrfach vorkommen.
@click.command()
@click.option(
"--gegenstand",
multiple=True,
help="Startausrüstung, mehrfach möglich.",
)
def spielen(gegenstand):
if gegenstand:
click.echo(f"Du trägst: {', '.join(gegenstand)}")
else:
click.echo("Du trägst nichts.")
Aufruf:
python dungeon.py --gegenstand Fackel --gegenstand Seil
Ausgabe:
Du trägst: Fackel, Seil
Wichtig: multiple=True liefert ein Tupel.
Ohne Angabe ist es ein leeres Tupel, nicht None.
if gegenstand:
...
ist deshalb die richtige Prüfung.
Feste Anzahl von Werten mit nargs
Manche Optionen brauchen mehrere Werte auf einmal.
@click.command()
@click.option(
"--position",
nargs=2,
type=int,
help="Koordinaten X und Y.",
)
def spielen(position):
click.echo(f"Position: {position}")
Aufruf:
python dungeon.py --position 3 7
Ausgabe:
Position: (3, 7)
Auch hier liefert Click ein Tupel.
Argumente mit @click.argument(...)
Argumente stehen ohne Bindestriche und sind positionsabhängig.
import click
@click.command()
@click.argument("richtung")
def geh(richtung):
"""Geht in eine Richtung."""
click.echo(f"Du gehst nach {richtung}.")
Aufruf:
python geh.py norden
Ausgabe:
Du gehst nach norden.
Ein normales Argument ist verpflichtend. Fehlt es, meldet Click einen Fehler.
Argumente eignen sich für Werte, die zur eigentlichen Aktion gehören:
dungeon laden spielstand.json
dungeon geh norden
Optionen eignen sich für Einstellungen:
dungeon neu --name Karl --hp 100
Beliebig viele Argumente
Mit nargs=-1 akzeptiert ein Argument beliebig viele Werte:
@click.command()
@click.argument("gegenstaende", nargs=-1)
def nimm(gegenstaende):
"""Nimmt beliebig viele Gegenstände auf."""
if not gegenstaende:
raise click.UsageError("Gib mindestens einen Gegenstand an.")
for gegenstand in gegenstaende:
click.echo(f"Du nimmst {gegenstand}.")
Aufruf:
python nimm.py Fackel Seil Schlüssel
Ausgabe:
Du nimmst Fackel.
Du nimmst Seil.
Du nimmst Schlüssel.
Bei nargs=-1 erhältst Du ein Tupel.
Ohne Werte ist dieses Tupel leer. Wenn mindestens ein Wert nötig ist, prüfst Du das selbst, wie im Beispiel.
Prompts: Werte erfragen
Click kann fehlende Werte erfragen.
@click.command()
@click.option(
"--name",
prompt="Wie heißt Du, Abenteurer?",
help="Name des Helden.",
)
def neu(name):
click.echo(f"Willkommen, {name}.")
Aufruf ohne Option:
python dungeon.py
Click fragt:
Wie heißt Du, Abenteurer?: Karl
Willkommen, Karl.
Aufruf mit Option:
python dungeon.py --name Karl
Dann fragt Click nicht.
Das ist der entscheidende Vorteil gegenüber einem fest eingebauten input(...):
Automatisierung bleibt möglich, weil alle Werte übergeben werden können.
Verdeckte Eingaben und Bestätigung
Für Passwörter oder Tokens:
@click.option(
"--passwort",
prompt=True,
hide_input=True,
confirmation_prompt=True,
)
def anlegen(passwort):
click.echo("Passwort gesetzt.")
hide_input=True zeigt die Eingabe nicht an.
confirmation_prompt=True fragt zur Sicherheit zweimal.
Für echte Passwörter solltest Du trotzdem vorsichtig sein: Kommandozeilenoptionen
können in Shell-History oder Prozesslisten auftauchen. Prompts sind dafür oft
sicherer als --passwort geheim.
Umgebungsvariablen
Optionen können ihren Wert aus einer Umgebungsvariable beziehen:
@click.option(
"--speicher",
envvar="DUNGEON_SPEICHER",
type=click.Path(dir_okay=False, path_type=Path),
default="spielstand.json",
show_default=True,
help="Datei für den Spielstand.",
)
def spielen(speicher):
...
Aufruf:
DUNGEON_SPEICHER=/tmp/spielstand.json python dungeon.py
Das wirkt so, als wäre die Option angegeben worden.
Die übliche Reihenfolge:
- Wert von der Kommandozeile,
- Wert aus Umgebungsvariable,
- Default.
Die Kommandozeile gewinnt also gegenüber der Umgebung.
Fragen und Bestätigungen
Auch außerhalb von Options-Prompts kann Click fragen.
name = click.prompt("Name", default="Abenteurer")
hp = click.prompt("Lebenspunkte", type=int, default=100)
Eine Ja-Nein-Frage:
if click.confirm("Spielstand überschreiben?"):
click.echo("Wird überschrieben.")
Mit automatischem Abbruch bei „nein“:
click.confirm("Wirklich löschen?", abort=True)
Bei Abbruch gibt Click eine Meldung aus und beendet das Programm mit einem Fehlerstatus.
Solche Sicherheitsfragen sind für destruktive Aktionen sinnvoll:
- löschen,
- überschreiben,
- zurücksetzen,
- veröffentlichen.
Fortschritt anzeigen
Für länger laufende Aktionen gibt es click.progressbar(...).
with click.progressbar(raeume, label="Räume laden") as balken:
for raum in balken:
verarbeite(raum)
Click zeigt den Fortschritt an, wenn die Ausgabe ein Terminal ist.
Bei Pipes oder nicht interaktiver Ausgabe zeichnet Click keinen Balken, sondern
gibt das Label einmal aus. Mit hidden=True bleibt auch das weg.
Wenn Click die Länge nicht kennt, kannst Du sie mitgeben:
with click.progressbar(generator, length=100, label="Verarbeiten") as balken:
for element in balken:
...
Fehler und Exit-Codes
Ein gutes CLI zeigt erwartbare Fehler ohne Traceback.
Click bringt dafür eigene Exceptions mit.
raise click.ClickException("Kein Spielstand gefunden.")
Das ergibt eine saubere Meldung:
Error: Kein Spielstand gefunden.
und beendet mit Exit-Code 1.
Für falsche Benutzung des Tools:
raise click.UsageError("Entweder --laden oder --neu verwenden.")
Das ist ein Aufruffehler und führt zu Exit-Code 2.
Für einen konkreten Parameter:
raise click.BadParameter(
"muss positiv sein",
param_hint="--hp",
)
Typische Orientierung:
| Situation | Exception | Exit-Code |
|---|---|---|
| Datei fehlt, Netzwerk nicht erreichbar, erwartbarer Laufzeitfehler | ClickException |
1 |
| Tool falsch aufgerufen | UsageError |
2 |
| konkreter Parameter ungültig | BadParameter |
2 |
| Benutzer bricht ab | Abort |
1 |
Eine normale, nicht abgefangene Exception zeigt weiterhin einen Traceback. Das ist für Programmierfehler gut, für erwartbare Fehler aber unschön.
Gruppen: mehrere Unterbefehle
Bisher hatten wir einzelne Kommandos.
Viele Tools haben mehrere Unterbefehle:
git status
git commit
git push
In Click verwendest Du dafür @click.group().
import click
@click.group()
def cli():
"""Verwaltet und spielt den Dungeon."""
@cli.command()
def neu():
"""Legt einen neuen Spielstand an."""
click.echo("Neuer Spielstand.")
@cli.command()
def spielen():
"""Lädt den Spielstand und spielt weiter."""
click.echo("Weiter geht's.")
if __name__ == "__main__":
cli()
Aufrufe:
python dungeon.py neu
python dungeon.py spielen
Die Hilfe listet die Unterbefehle automatisch:
Usage: dungeon.py [OPTIONS] COMMAND [ARGS]...
Verwaltet und spielt den Dungeon.
Options:
--help Show this message and exit.
Commands:
neu Legt einen neuen Spielstand an.
spielen Lädt den Spielstand und spielt weiter.
Aus @click.command() wird bei Unterbefehlen:
@cli.command()
Die Gruppe registriert den Befehl.
Befehlsnamen anpassen
Standardmäßig macht Click aus Funktionsnamen mit Unterstrich einen CLI-Befehlsnamen mit Bindestrich.
@cli.command()
def spielstand_info():
...
wird zu:
spielstand-info
Du kannst den Namen ausdrücklich setzen:
@cli.command("info")
def spielstand_info():
"""Zeigt Informationen zum Spielstand."""
...
Dann lautet der Befehl:
dungeon info
Das ist auch nützlich, wenn ein gewünschter CLI-Name in Python kein guter Funktionsname wäre.
Beispiel:
@cli.command("import")
def importieren():
"""Importiert einen Spielstand."""
import ist in Python ein Keyword, aber als CLI-Befehl völlig in Ordnung.
Gemeinsame Optionen an der Gruppe
Optionen an einer Gruppe stehen auf der Kommandozeile vor dem Unterbefehl.
@click.group()
@click.option(
"--speicher",
default="spielstand.json",
show_default=True,
)
def cli(speicher):
...
Aufruf:
python dungeon.py --speicher test.json spielen
Nicht:
python dungeon.py spielen --speicher test.json
Die Position ist wichtig. Gruppenoptionen gehören vor den Unterbefehl. Optionen des Unterbefehls gehören hinter den Unterbefehl.
Bei Click kann man das bewusst nutzen:
dungeon --speicher test.json spielen --verbose
Hier ist --speicher eine Gruppenoption, --verbose eine Option des Befehls
spielen.
Der Context: Daten weitergeben
Gruppenoptionen müssen an Unterbefehle weitergegeben werden.
Dafür gibt es den Click-Context.
import click
@click.group()
@click.option(
"--speicher",
default="spielstand.json",
show_default=True,
)
@click.option(
"-v",
"--verbose",
count=True,
help="Mehr Ausgabe, mehrfach möglich.",
)
@click.pass_context
def cli(ctx, speicher, verbose):
"""Verwaltet und spielt den Dungeon."""
ctx.ensure_object(dict)
ctx.obj["speicher"] = speicher
ctx.obj["verbose"] = verbose
@cli.command()
@click.pass_obj
def spielen(obj):
"""Lädt den Spielstand und spielt weiter."""
if obj["verbose"]:
click.echo(f"Lese {obj['speicher']}", err=True)
click.echo("Weiter geht's.")
@click.pass_context übergibt den Context als ersten Parameter.
def cli(ctx, speicher, verbose):
ctx.obj ist ein freier Platz für eigene Daten.
@click.pass_obj übergibt später direkt ctx.obj an den Unterbefehl:
def spielen(obj):
Für kleine Tools reicht ein Dictionary. Für größere Tools ist eine Dataclass oft schöner:
from dataclasses import dataclass
from pathlib import Path
@dataclass
class AppConfig:
speicher: Path
verbose: int
Dann kannst Du ctx.obj auf ein AppConfig-Objekt setzen.
Reihenfolge der Decorators
Bei Click ist die Reihenfolge der Decorators wichtig.
Typisch:
@click.group()
@click.option("--speicher")
@click.pass_context
def cli(ctx, speicher):
...
@click.pass_context steht direkt über der Funktion.
Auch bei Befehlen:
@cli.command()
@click.option("--name")
@click.pass_obj
def neu(obj, name):
...
@click.pass_obj steht direkt über der Funktion.
Wenn die Reihenfolge nicht stimmt, passen die übergebenen Parameter schnell nicht mehr zu Deiner Funktionssignatur.
Als Merksatz:
pass_contextundpass_objgehören direkt an die Funktion.
Hilfe und Version
Click bietet standardmäßig --help.
Viele Benutzer erwarten zusätzlich -h.
Das kannst Du über context_settings ergänzen:
@click.group(
context_settings={
"help_option_names": ["-h", "--help"],
}
)
def cli():
"""Verwaltet und spielt den Dungeon."""
Eine Version kannst Du ergänzen mit:
@click.version_option(version="1.0.0", prog_name="dungeon")
Komplett:
@click.group(
context_settings={
"help_option_names": ["-h", "--help"],
}
)
@click.version_option(version="1.0.0", prog_name="dungeon")
def cli():
"""Verwaltet und spielt den Dungeon."""
Wenn Dein Projekt installiert ist und eine Version in der pyproject.toml
besitzt, kann Click die Version auch aus Paketmetadaten lesen:
@click.version_option(package_name="dungeon")
Das ist besser, sobald Dein Tool ein richtiges Paket ist. Dann steht die Version nur an einer Stelle.
Der Dungeon als Click-CLI
Jetzt bauen wir ein kleines, vollständiges Dungeon-CLI.
Die Datei liegt zum Beispiel unter:
dungeon/
└── cli.py
Inhalt:
# dungeon/cli.py
import json
from dataclasses import dataclass
from pathlib import Path
import click
RAEUME = {
"halle": "Eine kalte Halle. Ausgänge: norden, osten.",
"bibliothek": "Regale bis zur Decke. Ausgänge: sueden.",
"krypta": "Es riecht nach altem Stein. Ausgänge: westen.",
}
@dataclass
class AppConfig:
speicher: Path
verbose: int
@click.group(
context_settings={
"help_option_names": ["-h", "--help"],
}
)
@click.option(
"--speicher",
type=click.Path(
dir_okay=False,
path_type=Path,
),
default="spielstand.json",
show_default=True,
envvar="DUNGEON_SPEICHER",
help="Datei für den Spielstand.",
)
@click.option(
"-v",
"--verbose",
count=True,
help="Mehr Ausgabe, mehrfach möglich.",
)
@click.version_option(version="1.0.0", prog_name="dungeon")
@click.pass_context
def cli(ctx, speicher, verbose):
"""Verwaltet und spielt den Dungeon."""
ctx.obj = AppConfig(
speicher=speicher,
verbose=verbose,
)
@cli.command()
@click.option(
"--name",
prompt="Wie heißt Du, Abenteurer?",
help="Name des Helden.",
)
@click.option(
"--hp",
type=click.IntRange(1, 100),
default=100,
show_default=True,
help="Lebenspunkte zum Start.",
)
@click.pass_obj
def neu(config, name, hp):
"""Legt einen neuen Spielstand an."""
ziel = config.speicher
if ziel.exists():
click.confirm(f"{ziel} überschreiben?", abort=True)
spielstand = {
"name": name,
"hp": hp,
"raum": "halle",
"inventar": [],
}
ziel.write_text(
json.dumps(spielstand, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
click.secho(
f"Neuer Spielstand für {name} in {ziel}.",
fg="green",
)
@cli.command()
@click.pass_obj
def spielen(config):
"""Lädt den Spielstand und zeigt die Lage."""
quelle = config.speicher
if not quelle.exists():
raise click.ClickException(
f"Kein Spielstand in {quelle}. "
"Lege einen mit 'dungeon neu' an."
)
try:
spielstand = json.loads(
quelle.read_text(encoding="utf-8")
)
except json.JSONDecodeError as fehler:
raise click.ClickException(
f"Der Spielstand in {quelle} ist kein gültiges JSON."
) from fehler
if config.verbose:
click.echo(f"Gelesen aus {quelle}.", err=True)
raum = spielstand["raum"]
click.echo(
f"{spielstand['name']} | "
f"HP: {spielstand['hp']} | "
f"Raum: {raum}"
)
click.echo(RAEUME[raum])
@cli.command("raeume")
@click.option(
"--filter",
"muster",
default="",
help="Nur Räume mit diesem Text.",
)
def raeume(muster):
"""Zeigt alle bekannten Räume."""
for name, beschreibung in RAEUME.items():
if muster and muster.lower() not in name.lower():
continue
click.echo(f"{name:12} {beschreibung}")
if __name__ == "__main__":
cli()
Ein Durchlauf:
python -m dungeon.cli neu --name Karl
python -m dungeon.cli spielen
python -m dungeon.cli raeume --filter biblio
Ausgabe:
Neuer Spielstand für Karl in spielstand.json.
Karl | HP: 100 | Raum: halle
Eine kalte Halle. Ausgänge: norden, osten.
bibliothek Regale bis zur Decke. Ausgänge: sueden.
Ohne Spielstand:
Error: Kein Spielstand in spielstand.json. Lege einen mit 'dungeon neu' an.
Kein Traceback, Exit-Code 1.
So sollte sich ein CLI bei erwartbaren Fehlern verhalten.
Als echtes Kommando installieren
python -m dungeon.cli ist für die Entwicklung okay, aber für ein echtes Tool
umständlich.
Besser ist ein Kommando:
dungeon --help
dungeon neu --name Karl
dungeon spielen
Dafür definierst Du einen Entry Point in der pyproject.toml:
[project]
name = "dungeon"
version = "1.0.0"
dependencies = [
"click",
]
[project.scripts]
dungeon = "dungeon.cli:cli"
Links steht der Name des Kommandos:
dungeon
Rechts steht:
dungeon.cli:cli
also das Objekt cli im Modul dungeon.cli.
Mit uv:
uv sync
uv run dungeon --help
Oder klassisch als editierbare Installation:
python -m pip install -e .
dungeon --help
Für ein richtiges Package brauchst Du zusätzlich eine passende Projektstruktur
mit pyproject.toml, Package-Ordner und meistens Tests. Das knüpft an
Teil 10: Module und Projektstruktur
an.
Shell Completion
Click kann Completion für Shells erzeugen.
Wenn das Kommando dungeon installiert ist, kann Bash-Completion so eingebunden
werden:
eval "$(_DUNGEON_COMPLETE=bash_source dungeon)"
Für Zsh:
eval "$(_DUNGEON_COMPLETE=zsh_source dungeon)"
Für Fish:
_DUNGEON_COMPLETE=fish_source dungeon | source
Das Schema der Umgebungsvariable lautet:
_<KOMMANDO>_COMPLETE
Bei Kommandonamen mit Bindestrichen werden daraus Unterstriche.
Für häufig genutzte Tools ist es besser, das Completion-Skript einmal zu generieren und dauerhaft einzubinden, statt es bei jedem Shell-Start neu zu erzeugen.
Completion funktioniert sinnvoll erst, wenn Dein Tool als Kommando installiert
ist. Mit python dungeon.py gibt es für die Shell kein stabiles Kommando, das sie
vervollständigen könnte.
Tests mit CliRunner
Click bringt ein eigenes Testwerkzeug mit:
from click.testing import CliRunner
Damit musst Du Dein CLI nicht als Subprozess starten. Du kannst es direkt im Testprozess aufrufen.
Das passt gut zu pytest von Null.
Beispiel:
# tests/test_cli.py
from pathlib import Path
from click.testing import CliRunner
from dungeon.cli import cli
def test_raeume_zeigt_halle():
runner = CliRunner()
ergebnis = runner.invoke(cli, ["raeume"])
assert ergebnis.exit_code == 0
assert "halle" in ergebnis.output
def test_neu_schreibt_spielstand():
runner = CliRunner()
with runner.isolated_filesystem():
ergebnis = runner.invoke(
cli,
["neu", "--name", "Karl"],
)
assert ergebnis.exit_code == 0
assert Path("spielstand.json").exists()
def test_spielen_ohne_spielstand_meldet_fehler():
runner = CliRunner()
with runner.isolated_filesystem():
ergebnis = runner.invoke(cli, ["spielen"])
assert ergebnis.exit_code == 1
assert "Kein Spielstand" in ergebnis.output
runner.invoke(...) liefert ein Result-Objekt mit unter anderem:
exit_code,output,exception.
Prompts testen
Prompts kannst Du mit input= beantworten:
def test_neu_fragt_nach_dem_namen():
runner = CliRunner()
with runner.isolated_filesystem():
ergebnis = runner.invoke(
cli,
["neu"],
input="Karl\n",
)
assert ergebnis.exit_code == 0
assert "Karl" in ergebnis.output
assert Path("spielstand.json").exists()
Click verhält sich im Test so, als hätte der Benutzer Karl eingegeben.
Temporäres Dateisystem
runner.isolated_filesystem() legt ein temporäres Arbeitsverzeichnis an und
wechselt hinein.
Das ist ideal für CLI-Tests, die Dateien erzeugen:
with runner.isolated_filesystem():
...
Dein Test kann dort Dateien schreiben, ohne den Projektordner zu verschmutzen.
stdout und stderr in Tests
Standardmäßig fängt der CliRunner die Ausgabe ein.
Meist reicht:
ergebnis.output
Für viele Tests ist das bewusst einfach.
Seit Click 8.2 sind die Streams getrennt: ergebnis.stdout und ergebnis.stderr
gibt es einzeln, ergebnis.output mischt beide in der Reihenfolge, in der sie
geschrieben wurden – so, wie der Benutzer es im Terminal sieht.
Für normale CLI-Tests reichen aber meistens:
assert ergebnis.exit_code == 0
assert "Text" in ergebnis.output
Click oder Typer?
Typer baut auf Click auf. Die Wahl ist deshalb weniger „entweder modernes Tool oder altes Tool“ als vielmehr eine Stilfrage.
Typer passt gut, wenn …
- Du Type Hints ohnehin konsequent nutzt,
- die CLI-Signatur direkt aus Funktionsparametern entstehen soll,
- Du wenig Boilerplate möchtest,
- Dein CLI viele einfache Optionen und Subcommands hat.
Click passt gut, wenn …
- Du das CLI ausdrücklich über Decorators beschreiben willst,
- Du sehr genaue Kontrolle über Parameter brauchst,
- Du eigene Parametertypen bauen möchtest,
- Befehle dynamisch zusammengesetzt werden,
- Plugins eigene Commands beisteuern sollen,
- Du Click ohnehin direkt verstehen musst.
argparse passt gut, wenn …
- Du keine externe Abhängigkeit möchtest,
- das CLI klein bleibt,
- Standardbibliothek genügt,
- Installation zusätzlicher Pakete unerwünscht ist.
Praktisch hilfreich: Wer Click verstanden hat, kann viele Typer-Fehlermeldungen einordnen, weil Typer unter der Haube Click verwendet.
Stolperfallen
-
Click liest keine Type Hints:
hp: intmacht eine Option nicht automatisch numerisch. Verwendetype=int,click.IntRange(...)oder einen passenden Default. -
Parameternamen kollidieren mit Builtins: Bei
--filter,--typeoder--inputbesser einen internen Namen setzen:@click.option("--filter", "muster"). -
Gruppenoptionen an die falsche Stelle schreiben: Optionen der Gruppe stehen vor dem Unterbefehl:
dungeon --speicher x spielen, nichtdungeon spielen --speicher x. -
Die Gruppenfunktion zu schwer machen: Der Rumpf der Gruppenfunktion läuft vor Unterbefehlen. Halte ihn schlank und baue teure Dinge erst bei Bedarf.
-
ctx.objnicht initialisieren: Verwendectx.ensure_object(dict)oder setze bewusst ein eigenes Objekt. -
@click.pass_contextoder@click.pass_objfalsch platzieren: Beide sollten direkt über der Funktion stehen. -
multiple=TruealsNoneerwarten: Ohne Werte liefert Click ein leeres Tupel. -
is_flag=Truevergessen: Sonst erwartet die Option einen Wert und verhält sich nicht wie ein Schalter. -
print(...)stattclick.echo(...)verwenden: Funktioniert, passt aber weniger gut zum Click-Ökosystem. -
Erwartbare Fehler als Traceback zeigen: Für erwartbare CLI-Fehler verwende
ClickException,UsageErroroderBadParameter. -
Prompts als einzige Eingabemöglichkeit bauen: Ein gutes CLI sollte auch ohne Interaktion automatisierbar sein.
-
Tests als Subprozesse starten: Für Click-CLIs ist
CliRunnermeist einfacher und schneller. -
Click und Typer gedanklich vermischen: Typer nutzt Type Hints. Click nutzt explizite Decorators und Parameterdefinitionen.
Übungen
1. Ein Kommando status bauen
Baue ein Kommando status, das eine Option --kurz als Flag kennt.
Ohne Flag gibt es Name, HP und Raum in je einer Zeile aus. Mit Flag steht alles in einer Zeile.
Lösung
@cli.command()
@click.option(
"--kurz",
is_flag=True,
help="Alles in einer Zeile.",
)
@click.pass_obj
def status(config, kurz):
"""Zeigt den Spielstand."""
quelle = config.speicher
if not quelle.exists():
raise click.ClickException(
f"Kein Spielstand in {quelle}."
)
stand = json.loads(
quelle.read_text(encoding="utf-8")
)
if kurz:
click.echo(
f"{stand['name']} | {stand['hp']} HP | {stand['raum']}"
)
return
click.echo(f"Name: {stand['name']}")
click.echo(f"HP: {stand['hp']}")
click.echo(f"Raum: {stand['raum']}")
2. Ein Kommando nimm bauen
Ergänze ein Kommando nimm, das beliebig viele Gegenstände als Argumente nimmt,
sie in den Spielstand schreibt und am Ende die Gesamtzahl meldet.
Lösung
@cli.command()
@click.argument("gegenstaende", nargs=-1)
@click.pass_obj
def nimm(config, gegenstaende):
"""Legt Gegenstände ins Inventar."""
if not gegenstaende:
raise click.UsageError(
"Gib mindestens einen Gegenstand an."
)
quelle = config.speicher
if not quelle.exists():
raise click.ClickException(
f"Kein Spielstand in {quelle}."
)
stand = json.loads(
quelle.read_text(encoding="utf-8")
)
stand["inventar"].extend(gegenstaende)
quelle.write_text(
json.dumps(stand, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
click.echo(
f"Du trägst jetzt {len(stand['inventar'])} Gegenstände."
)
3. Einen Test für neu schreiben
Schreibe einen Test, der prüft, dass neu eine Datei schreibt.
Lösung
from pathlib import Path
from click.testing import CliRunner
from dungeon.cli import cli
def test_neu_schreibt_spielstand():
runner = CliRunner()
with runner.isolated_filesystem():
ergebnis = runner.invoke(
cli,
["neu", "--name", "Karl"],
)
assert ergebnis.exit_code == 0
assert Path("spielstand.json").exists()
4. Einen Prompt testen
Schreibe einen Test, der neu ohne --name startet und den Prompt mit Karl
beantwortet.
Lösung
from pathlib import Path
from click.testing import CliRunner
from dungeon.cli import cli
def test_neu_fragt_nach_dem_namen():
runner = CliRunner()
with runner.isolated_filesystem():
ergebnis = runner.invoke(
cli,
["neu"],
input="Karl\n",
)
assert ergebnis.exit_code == 0
assert "Karl" in ergebnis.output
assert Path("spielstand.json").exists()
5. Abbruch beim Überschreiben testen
Schreibe einen Test, der prüft, dass neu bei einer bestehenden Datei nachfragt
und bei n nicht überschreibt.
Lösung
from pathlib import Path
from click.testing import CliRunner
from dungeon.cli import cli
def test_neu_bricht_bei_nein_ab():
runner = CliRunner()
with runner.isolated_filesystem():
Path("spielstand.json").write_text(
"alt",
encoding="utf-8",
)
ergebnis = runner.invoke(
cli,
["neu", "--name", "Karl"],
input="n\n",
)
assert ergebnis.exit_code != 0
assert Path("spielstand.json").read_text(
encoding="utf-8"
) == "alt"
Kompakte Übersicht
| Baustein | Beispiel | Zweck |
|---|---|---|
| Befehl | @click.command() |
Funktion wird ein Kommando |
| Gruppe | @click.group() |
sammelt Unterbefehle |
| Unterbefehl | @cli.command() |
registriert Befehl an Gruppe |
| Option | @click.option("--name") |
benannte Option |
| Argument | @click.argument("datei") |
positionsabhängiges Argument |
| Flag | is_flag=True |
Schalter ohne Wert |
| Flag-Paar | "--farbe/--keine-farbe" |
positive und negative Form |
| Zähler | count=True |
-vvv ergibt 3 |
| mehrfach | multiple=True |
Option mehrfach, Ergebnis Tupel |
| feste Anzahl | nargs=2 |
zwei Werte in einem Rutsch |
| beliebig viele | nargs=-1 |
Restwerte als Tupel |
| Typ | type=int |
Wert umwandeln |
| Auswahl | click.Choice([...]) |
erlaubte Werte begrenzen |
| Bereich | click.IntRange(1, 100) |
Zahlenbereich prüfen |
| Pfad | click.Path(path_type=Path) |
Pfad prüfen und umwandeln |
| Datei | click.File("r") |
Datei öffnen |
| Prompt | prompt=True |
fehlenden Wert erfragen |
| Umgebung | envvar="DUNGEON_SPEICHER" |
Wert aus Umgebungsvariable |
| Ausgabe | click.echo(...) |
CLI-Ausgabe |
| Farbe | click.secho(...) |
farbige Ausgabe |
| Context | @click.pass_context |
Click-Context übergeben |
| Objekt | @click.pass_obj |
ctx.obj übergeben |
| Fehler | ClickException |
erwartbarer Fehler |
| Benutzungsfehler | UsageError |
falscher Aufruf |
| Tests | CliRunner().invoke(...) |
CLI im Testprozess aufrufen |
Fazit
Click ist ein Baukasten für Kommandozeilen-Programme.
Der Kern besteht aus drei Ideen:
- Decorators beschreiben das Interface. Optionen, Argumente und Unterbefehle stehen direkt an der Funktion.
- Commands sind Objekte. Sie lassen sich zu Gruppen zusammenstecken, schachteln und über Modulgrenzen hinweg organisieren.
- CLI-Alltag ist eingebaut. Hilfe, Prompts, Farben, Fehler, Exit-Codes, Completion und Tests müssen nicht jedes Mal neu erfunden werden.
Für kleine Skripte reicht argparse oft aus. Für moderne Type-Hint-basierte CLIs
ist Typer sehr angenehm. Click sitzt genau dazwischen: explizit, kontrollierbar,
erweiterbar und bewährt.
Wenn Dein CLI wachsen soll oder Du Typer unter der Haube besser verstehen möchtest, lohnt sich Click auf jeden Fall.
Weiterlesen
- Vom Skript zum echten CLI
- Type Hints in der Praxis
- pytest von Null
- Decorators entmystifiziertgeplant
- Python lernen, Teil 10: Module und Projektstruktur
- Click-Dokumentation
- Click: Commands and Groups
- Click: Options
- Click: Arguments
- Click: Prompts
- Click: Testing
- Click: Shell Completion
- Click: Changelog
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.