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 immer wieder: Click.
Typer baut auf Click auf, Click lässt sich aber genauso gut direkt verwenden. Der Unterschied liegt vor allem darin, wie das CLI beschrieben wird:
argparsekonfiguriert Parser über Methodenaufrufe.- Typer leitet viel aus Type Hints ab.
- Click beschreibt Parameter und Commands explizit über Decorators.
Ein minimales Click-Kommando sieht so aus:
import click
@click.command()
@click.option(
"--name",
default="Abenteurer",
)
def spielen(name):
click.echo(
f"Willkommen, {name}."
)
if __name__ == "__main__":
spielen()
Was die Kommandozeile akzeptiert, steht direkt über dem Callback, der die Werte bekommt.
Click kümmert sich dabei unter anderem um:
Optionen
Argumente
Typkonvertierung
Hilfetexte
Subcommands
Prompts
Environment Variables
Farben
Exit-Codes
Shell Completion
CLI-Tests
Der Preis dafür ist eine zusätzliche Dependency und ein eigenes API. Dafür bekommst Du einen ziemlich vollständigen CLI-Baukasten, ohne selbst einen Parser, Hilfeausgabe und Fehlerbehandlung zusammensetzen zu müssen.
Installation
Mit uv:
uv add click
Oder klassisch in einer aktivierten virtuellen Umgebung:
python -m pip install click
Die installierte Version kannst Du über die Package-Metadaten abfragen:
python -c "from importlib.metadata import version; print(version('click'))"
Oder im Python-Code:
from importlib.metadata import version
print(version("click"))
Das ist besser als:
click.__version__
denn dieses Attribut ist seit Click 8.2 deprecated.
Aktuelle Click-Versionen benötigen Python 3.10 oder neuer. Bei älteren Projekten mit Python 3.9 oder noch früher musst Du deshalb prüfen, welche Click-Version dort noch eingesetzt werden kann.
Der erste Command
Ein Click-Command beginnt mit:
@click.command()
Zum Beispiel:
import click
@click.command()
def spielen():
"""Startet den Dungeon."""
click.echo(
"Du betrittst den Dungeon."
)
if __name__ == "__main__":
spielen()
Speichere das als:
dungeon.py
und starte:
python dungeon.py
Ausgabe:
Du betrittst den Dungeon.
Der Decorator:
@click.command()
macht aus der ursprünglichen Funktion ein Command-Objekt.
Das ist ein Detail mit praktischen Folgen.
Nach:
@click.command()
def spielen():
...
ist:
spielen
nicht mehr einfach die ursprüngliche Python-Funktion. Ein Aufruf wie:
spielen()
startet Clicks Command-Verarbeitung, liest also normalerweise die Kommandozeilenargumente und kümmert sich anschließend um Exit-Verhalten und Fehler.
Die eigentliche Anwendungslogik sollte deshalb möglichst in normalen Funktionen bleiben:
def starte_spiel():
...
und der Click-Command nur die Verbindung zur Kommandozeile herstellen:
@click.command()
def spielen():
starte_spiel()
Das macht den Code später wesentlich leichter testbar und wiederverwendbar.
Der Docstring wird zur Hilfe
Click erzeugt automatisch:
python dungeon.py --help
Eine mögliche Ausgabe:
Usage: dungeon.py [OPTIONS]
Startet den Dungeon.
Options:
--help Show this message and exit.
Click verwendet den Docstring des Commands als Hilfetext.
Bei Gruppen mit mehreren Subcommands wird daraus außerdem eine Kurzbeschreibung für die Command-Liste.
Du kannst die Hilfe natürlich auch ausdrücklich angeben:
@click.command(
help="Startet den Dungeon.",
)
def spielen():
...
Bei normalen Funktionen bevorzuge ich den Docstring. Er dokumentiert dann Python-Code und CLI gleichzeitig.
click.echo() statt print()
Normale Ausgabe:
click.echo(
"Willkommen im Dungeon."
)
Fehler- oder Diagnoseausgabe nach stderr:
click.echo(
"Spielstand konnte nicht geladen werden.",
err=True,
)
print() funktioniert selbstverständlich weiterhin.
Innerhalb eines Click-CLIs ist click.echo() trotzdem die angenehmere
Gewohnheit, weil Click damit Terminalverhalten, Unicode, Farben und seine
Testwerkzeuge kontrollieren kann.
Farbige Ausgabe:
click.secho(
"Treffer!",
fg="red",
bold=True,
)
click.secho(
"Spielstand gespeichert.",
fg="green",
)
Oder getrennt stylen:
gold = click.style(
"50",
fg="yellow",
)
click.echo(
f"Gold: {gold}"
)
click.secho() ist im Wesentlichen die Kombination aus Styling und Ausgabe.
Das hat nichts mit Logging zu tun.
Diese Meldung:
click.echo(
"Spielstand gespeichert."
)
richtet sich an den Benutzer.
Diese:
logger.info(
"Spielstand gespeichert: datei=%s",
datei,
)
dient Diagnose und Betrieb.
Mehr dazu findest Du in Logging statt print.
Optionen mit @click.option()
Eine Option beginnt auf der Kommandozeile normalerweise mit:
--
Zum Beispiel:
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:
python dungeon.py
wird der Default verwendet:
Willkommen, Abenteurer.
Short Options
Eine häufig benutzte Option kann zusätzlich eine Kurzform bekommen:
@click.option(
"-n",
"--name",
default="Abenteurer",
help="Name des Helden.",
)
Nun funktionieren:
dungeon --name Karl
und:
dungeon -n Karl
Beides liefert denselben Parameter:
name
Nicht jede lange Option braucht zwangsläufig einen einzelnen Buchstaben. Bei selten verwendeten Einstellungen ist ausschließlich:
--speicher
oft verständlicher.
Pflichtoptionen
Eine Option kann erforderlich sein:
@click.option(
"--name",
required=True,
help="Name des Helden.",
)
Fehlt sie:
python dungeon.py
meldet Click sinngemäß:
Usage: dungeon.py [OPTIONS]
Try 'dungeon.py --help' for help.
Error: Missing option '--name'.
Der Prozess endet dabei als Fehler.
Pflichtoptionen sind sinnvoll, aber nicht jede Information muss zwangsläufig eine Option sein. Wenn ein Wert zur eigentlichen Aktion gehört, kann ein Positionsargument natürlicher wirken.
Dazu kommen wir gleich.
Defaults in der Hilfe anzeigen
Ein Default ist für Benutzer nur dann wirklich hilfreich, wenn sie ihn kennen.
@click.option(
"--hp",
type=int,
default=100,
show_default=True,
help="Lebenspunkte.",
)
Die Hilfe zeigt dann beispielsweise:
--hp INTEGER Lebenspunkte. [default: 100]
Für wichtige Defaults verwende ich show_default=True meistens bewusst.
Alternativ lässt sich das auch global über den Context konfigurieren.
Parameternamen
Click leitet aus dem Optionsnamen den Namen des Python-Parameters ab:
@click.option("--name")
def spielen(name):
...
Aus:
--max-hp
wird:
max_hp
@click.option("--max-hp")
def spielen(max_hp):
...
Du kannst den internen Namen ausdrücklich festlegen:
@click.option(
"--filter",
"muster",
)
def raeume(muster):
...
Auf der Kommandozeile bleibt es:
--filter
Im Python-Code heißt der Wert:
muster
Das ist unter anderem praktisch bei CLI-Namen wie:
--filter
--type
--input
für die Du im Code lieber einen anderen Namen verwenden möchtest.
Click liest Type Hints nicht als CLI-Definition
Hier unterscheidet sich Click deutlich von Typer.
Das hier:
@click.option("--hp")
def spielen(hp: int):
...
macht --hp für Click nicht automatisch zu einem Integer.
Die Type Annotation bleibt trotzdem nützlich für mypy, Pyright und den Editor. Die Konvertierung für die Kommandozeile muss Click aber separat kennen:
@click.option(
"--hp",
type=int,
)
def spielen(
hp: int,
):
...
Alternativ kann Click den Typ aus einem Default ableiten:
@click.option(
"--hp",
default=100,
)
Für wichtige Parameter finde ich die explizite Form klarer:
@click.option(
"--hp",
type=int,
default=100,
show_default=True,
)
Typer sagt grob:
Der Type Hint beschreibt auch das CLI.
Click sagt:
Python-Typisierung und CLI-Parameterdefinition sind zwei getrennte Dinge.
Mehr zu Type Hints findest Du in Type Hints in der Praxis.
Zahlenbereiche
Click besitzt fertige Parametertypen für häufige Regeln.
Zum Beispiel HP zwischen 1 und 100:
@click.option(
"--hp",
type=click.IntRange(
1,
100,
),
default=100,
show_default=True,
help="Lebenspunkte zum Start.",
)
def spielen(hp):
click.echo(
f"Du startest mit {hp} HP."
)
Ungültig:
python dungeon.py --hp 500
Click meldet sinngemäß:
Error: Invalid value for '--hp': 500 is not in the range 1<=x<=100.
Für Fließkommazahlen gibt es:
click.FloatRange(...)
Solche einfachen Eingaberegeln gehören gut an die CLI-Grenze.
Komplexe Game Rules würde ich dagegen weiterhin in der eigentlichen Anwendung prüfen.
Erlaubte Werte mit Choice
Ein Schwierigkeitsgrad:
@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:
dungeon --modus schwer
Ungültig:
dungeon --modus brutal
Click nennt bei der Fehlermeldung automatisch die erlaubten Werte.
Wenn Groß- und Kleinschreibung egal sein sollen:
click.Choice(
[
"leicht",
"normal",
"schwer",
],
case_sensitive=False,
)
Aktuelle Click-Versionen können für Choice auch andere hashable Werte wie
Enum-Mitglieder verwenden. Für drei einfache Strings reicht die obige Variante
aber völlig aus.
Pfade mit click.Path
Für Pfade gibt es:
click.Path(...)
Mit path_type=Path bekommen wir direkt ein pathlib.Path-Objekt:
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__}"
)
Mögliche weitere Prüfungen:
click.Path(
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
)
Oder für ein Ziel, in das geschrieben werden soll:
click.Path(
dir_okay=False,
writable=True,
)
Dabei solltest Du die Bedeutung genau überlegen.
Ein Speicherziel für:
dungeon neu
darf vielleicht noch gar nicht existieren. Dann wäre:
exists=True
falsch.
Dateien mit click.File
Click kann eine Datei auch direkt öffnen:
@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 den File Handle.
Ein praktisches Detail ist:
-
click.File kann dieses Argument als Standard-Stream behandeln.
Damit kann ein Unix-artiges CLI beispielsweise Daten über stdin lesen:
cat notiz.txt | tool -
Für viele Anwendungen bevorzuge ich trotzdem:
click.Path(
path_type=Path,
)
und öffne die Datei in der eigentlichen Anwendung selbst. Dadurch bleibt Dateizugriff außerhalb der CLI-Schicht leichter wiederverwendbar.
Beide Ansätze sind legitim.
Boolean-Flags
Ein klassischer Schalter:
@click.option(
"--schwer",
is_flag=True,
help="Schwerer Modus.",
)
def spielen(schwer):
if schwer:
click.echo(
"Schwerer Modus aktiv."
)
Aufruf:
dungeon --schwer
Ohne Flag ist der Wert bei diesem einfachen Boolean-Fall:
False
mit Flag:
True
Für diese häufige Form ist das Verhalten unkompliziert.
Click besitzt zusätzlich flag_value und kann damit andere Werte als bloß
True und False modellieren. Dessen Zusammenspiel mit Defaults ist
leistungsfähig, aber subtiler. Für normale Boolean-Schalter würde ich bei
is_flag=True beziehungsweise einem expliziten Flag-Paar bleiben.
Positive und negative Flags
Ein Wert soll standardmäßig aktiv sein, aber explizit abschaltbar:
@click.option(
"--farbe/--keine-farbe",
default=True,
show_default=True,
help="Farbige Ausgabe.",
)
def spielen(farbe):
...
Nun funktionieren:
dungeon --farbe
dungeon --keine-farbe
Ein anderes Beispiel:
@click.option(
"--cache/--kein-cache",
default=True,
)
Das liest sich für Benutzer meist besser als:
--cache false
Zählende Flags
Verbosity wird häufig über mehrere -v erhöht:
@click.option(
"-v",
"--verbose",
count=True,
help="Mehr Ausgabe, mehrfach möglich.",
)
def spielen(verbose):
click.echo(
f"Verbose-Stufe: {verbose}"
)
Aufrufe:
dungeon
dungeon -v
dungeon -vv
dungeon -vvv
Werte:
0
1
2
3
Das lässt sich beispielsweise mit Logging-Levels verbinden.
Eine Option mehrfach angeben
Mit:
multiple=True
darf dieselbe Option mehrfach vorkommen:
@click.command()
@click.option(
"--gegenstand",
multiple=True,
help="Startausrüstung, mehrfach möglich.",
)
def spielen(gegenstand):
if not gegenstand:
click.echo(
"Du trägst nichts."
)
return
click.echo(
"Du trägst: "
+ ", ".join(gegenstand)
)
Aufruf:
dungeon \
--gegenstand Fackel \
--gegenstand Seil
Click liefert:
("Fackel", "Seil")
also ein Tuple.
Ohne Angabe ist der Wert:
()
und nicht None.
Mehrere Werte pro Option
Ein einzelnes --position soll zwei Integer bekommen:
@click.option(
"--position",
nargs=2,
type=int,
help="Koordinaten X und Y.",
)
def spielen(position):
click.echo(
f"Position: {position}"
)
Aufruf:
dungeon --position 3 7
Wert:
(3, 7)
Auch hier verwendet Click ein Tuple.
Wenn die beiden Werte unterschiedliche Typen besitzen sollen, kannst Du direkt ein Tuple als Typ angeben:
@click.option(
"--gegenstand",
type=(
str,
int,
),
)
def spielen(gegenstand):
...
Dann könnte der Aufruf beispielsweise sein:
dungeon --gegenstand Fackel 2
und das Ergebnis:
("Fackel", 2)
Positionsargumente mit @click.argument
Argumente stehen ohne -- an einer festen Position:
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 standardmäßig erforderlich.
Ein guter Unterschied für CLI-Design ist:
Argument:
Teil der eigentlichen Aktion
Option:
verändert oder konfiguriert die Aktion
Deshalb wirkt:
dungeon laden spielstand.json
natürlich.
Und:
dungeon neu --name Karl --hp 100
ebenfalls.
Argumente können seit Click 8.5 eigenen Hilfetext haben
Für Optionen ist:
help="..."
seit langem selbstverständlich.
Seit Click 8.5 lässt sich auch ein Positionsargument direkt so dokumentieren:
@click.argument(
"datei",
type=click.Path(
exists=True,
dir_okay=False,
path_type=Path,
),
help="Spielstanddatei, die geladen werden soll.",
)
Die Hilfe kann dadurch einen eigenen Bereich für Positionsargumente anzeigen.
Das ist praktischer, als die Bedeutung jedes Arguments ausschließlich im Command-Docstring erklären zu müssen.
Wenn Dein Projekt noch Click 8.4 oder älter unterstützt, steht dieses
help=-Argument bei click.argument() allerdings noch nicht zur Verfügung.
Beliebig viele Argumente
Ein Argument mit:
nargs=-1
sammelt beliebig viele Werte:
@click.command()
@click.argument(
"gegenstaende",
nargs=-1,
)
def nimm(gegenstaende):
for gegenstand in gegenstaende:
click.echo(
f"Du nimmst {gegenstand}."
)
Aufruf:
dungeon nimm \
Fackel \
Seil \
Schlüssel
Der Funktionsparameter enthält:
(
"Fackel",
"Seil",
"Schlüssel",
)
Nur ein Argument eines Commands darf variadisch sein.
Mindestens einen variadischen Wert verlangen
Wenn nimm ohne Gegenstand keinen Sinn ergibt, können wir das direkt
ausdrücken:
@click.argument(
"gegenstaende",
nargs=-1,
required=True,
)
Dann übernimmt Click die Fehlermeldung, falls überhaupt kein Gegenstand angegeben wird.
Click empfiehlt erforderliche variadische Argumente nicht für jeden Unix-artigen Dateibefehl, weil beispielsweise leere Wildcard-Matches manchmal als No-op sinnvoll sind.
Für unseren fachlichen Command:
nimm
ist mindestens ein Gegenstand aber eine nachvollziehbare Regel.
-- beendet die Optionsverarbeitung
Was passiert, wenn ein Positionsargument selbst mit - beginnt?
Zum Beispiel eine Datei:
--seltsamer-name.txt
Dann kannst Du den üblichen Separator verwenden:
tool -- --seltsamer-name.txt
Alles hinter:
--
wird als Argument behandelt und nicht mehr als Option interpretiert.
Das ist kein Click-Sonderfall, sondern ein etabliertes CLI-Muster, das Click unterstützt.
Prompts für fehlende Optionen
Click kann einen Optionswert interaktiv erfragen:
@click.command()
@click.option(
"--name",
prompt="Wie heißt Du, Abenteurer?",
help="Name des Helden.",
)
def neu(name):
click.echo(
f"Willkommen, {name}."
)
Ohne Option:
dungeon neu
fragt Click:
Wie heißt Du, Abenteurer?: Karl
Mit:
dungeon neu --name Karl
ist keine Rückfrage nötig.
Das ist für unseren Dungeon interessant, weil sich interaktive und automatisierbare Nutzung verbinden lassen.
Ein Shell-Skript kann alles ausdrücklich übergeben. Ein Mensch kann sich fehlende Werte erfragen lassen.
Prompts sparsam einsetzen
Prompts sind bequem, können Automatisierung aber überraschend blockieren.
Ein Admin-Tool, das scheinbar vollständig so aufrufbar ist:
tool deploy
und nach drei Minuten plötzlich fragt:
Wirklich fortfahren?
kann in CI oder einem Cronjob hängen bleiben.
Darum sollte klar sein, welche Commands interaktiv sind.
Für destruktive Aktionen ist oft ein Muster wie:
tool löschen --yes
besser als ein Command, der ausschließlich über einen Prompt funktioniert.
Verdeckte Eingaben
Für Geheimnisse kann Click Eingaben verstecken:
@click.option(
"--passwort",
prompt=True,
hide_input=True,
confirmation_prompt=True,
)
def anlegen(passwort):
click.echo(
"Passwort gesetzt."
)
hide_input=True verhindert die sichtbare Eingabe.
confirmation_prompt=True fragt zweimal.
Für genau diesen häufigen Fall gibt es auch:
@click.password_option()
Passwörter direkt als:
--passwort supergeheim
zu übergeben ist häufig ungünstig. Kommandozeilen können je nach System in Shell History, Prozessinformationen oder Diagnoseausgaben auftauchen.
Ein Prompt oder ein dafür vorgesehenes Secret-System ist meist die bessere Schnittstelle.
Bestätigungen
Eine einfache Ja-Nein-Frage:
if click.confirm(
"Spielstand überschreiben?"
):
click.echo(
"Wird überschrieben."
)
Direkter Abbruch bei Nein:
click.confirm(
"Spielstand wirklich löschen?",
abort=True,
)
Click erzeugt dann einen kontrollierten CLI-Abbruch.
Für typische gefährliche Aktionen ist das passend:
löschen
überschreiben
zurücksetzen
veröffentlichen
Für wiederkehrende Automation solltest Du zusätzlich einen nicht-interaktiven Weg wie:
--yes
anbieten.
Click besitzt dafür sogar den Shortcut:
@click.confirmation_option()
Environment Variables
Optionen können Werte aus Environment Variables lesen:
from pathlib import Path
import click
@click.command()
@click.option(
"--speicher",
envvar="DUNGEON_SPEICHER",
type=click.Path(
dir_okay=False,
path_type=Path,
),
default="spielstand.json",
show_default=True,
show_envvar=True,
help="Datei für den Spielstand.",
)
def spielen(speicher):
click.echo(
str(speicher)
)
Unter einer Unix-Shell:
DUNGEON_SPEICHER=/tmp/test.json \
dungeon
Auf der Kommandozeile kann der Wert trotzdem überschrieben werden:
DUNGEON_SPEICHER=/tmp/test.json \
dungeon --speicher anderer.json
Dann gewinnt:
anderer.json
Woher kommt ein Wert?
Click unterscheidet verschiedene Value Sources.
Für normale Parameter ist die grobe Priorität:
Kommandozeile
↓
Environment Variable
↓
default_map des Context
↓
Default des Parameters
Ein aktivierter Prompt kann zusätzlich einen Wert liefern.
Das wird bei größeren Anwendungen interessant, wenn Defaults aus einer Konfigurationsdatei und Environment Variables gleichzeitig unterstützt werden.
Click kann sogar sagen, aus welcher Quelle ein konkreter Parameter stammt.
get_parameter_source()
Mit dem Context:
import click
@click.command()
@click.option(
"--name",
envvar="DUNGEON_NAME",
default="Abenteurer",
)
@click.pass_context
def spielen(ctx, name):
quelle = (
ctx.get_parameter_source(
"name"
)
)
click.echo(
f"{name}: {quelle.name}"
)
Je nach Aufruf kann die Quelle beispielsweise sein:
COMMANDLINE
ENVIRONMENT
DEFAULT_MAP
DEFAULT
PROMPT
Das ist praktisch, wenn Verhalten davon abhängt, ob ein Benutzer etwas ausdrücklich angegeben hat oder nur ein Default aktiv ist.
Du solltest damit trotzdem sparsam umgehen. Ein CLI wird schwer verständlich, wenn seine Fachlogik ständig davon abhängt, auf welchem Weg derselbe Wert zustande kam.
Fortschrittsbalken
Für längere Arbeiten besitzt Click:
click.progressbar(...)
Zum Beispiel:
with click.progressbar(
raeume,
label="Räume laden",
) as balken:
for raum in balken:
verarbeite(raum)
Click versucht die Länge des Iterables zu ermitteln und zeigt einen Fortschrittsbalken im Terminal.
Bei einem Generator kannst Du die Länge angeben:
with click.progressbar(
generator,
length=100,
label="Verarbeiten",
) as balken:
for element in balken:
verarbeite(element)
Wenn die Ausgabe kein TTY ist, malt Click nicht einfach ANSI-Animationen in eine Pipe. Stattdessen wird standardmäßig nur das Label ausgegeben.
Bei komplexeren Anforderungen kann ein spezialisiertes Werkzeug wie tqdm
passender sein.
Fehler ohne Traceback
Ein erwartbarer CLI-Fehler sollte normalerweise nicht so aussehen:
Traceback (most recent call last):
...
FileNotFoundError
wenn die Situation für den Benutzer völlig normal sein kann.
Dafür gibt es ClickException:
raise click.ClickException(
"Kein Spielstand gefunden."
)
Ausgabe:
Error: Kein Spielstand gefunden.
Exit-Code:
1
Für einen normalen Benutzerfehler ist das deutlich angenehmer.
Aufruffehler mit UsageError
Ist dagegen die Benutzung des Commands falsch:
raise click.UsageError(
"Diese beiden Optionen dürfen "
"nicht gemeinsam verwendet werden."
)
Click zeigt zusätzlich Usage-Informationen und beendet normalerweise mit
Exit-Code 2.
Ein konkreter ungültiger Parameter:
raise click.BadParameter(
"muss positiv sein",
param_hint="--hp",
)
Auch das ist ein Benutzungsfehler.
Als Orientierung:
| Situation | Click-Fehler | Exit-Code |
|---|---|---|
| erwartbarer Laufzeitfehler | ClickException |
1 |
| CLI falsch benutzt | UsageError |
2 |
| Parameter ungültig | BadParameter |
2 |
| Benutzer bricht ab | Abort |
1 |
Eine ungefangene normale Python-Exception ist dagegen weiterhin ein Programmierfehler und darf beim Entwickeln ihren Traceback zeigen.
Domain Exceptions nicht durch Click ersetzen
Die Anwendung selbst sollte möglichst keine:
click.ClickException
werfen müssen.
Besser:
class SpielstandError(Exception):
pass
Die normale Speicherlogik:
def lade_spielstand(
pfad,
):
if not pfad.exists():
raise SpielstandError(
"Kein Spielstand gefunden."
)
...
Und erst die CLI übersetzt:
@click.command()
def spielen():
try:
lade_spielstand(...)
except SpielstandError as fehler:
raise click.ClickException(
str(fehler)
) from fehler
Damit kennt die eigentliche Anwendung Click überhaupt nicht.
Eine andere Oberfläche könnte dieselbe Funktion später ebenfalls verwenden.
Gruppen für Subcommands
Viele CLIs bestehen aus mehreren Commands:
git status
git commit
git push
Bei Click verwenden wir dafür eine 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:
python dungeon.py --help
listet die Commands automatisch.
Namen von Commands
Standardmäßig wird ein Funktionsname:
@cli.command()
def spielstand_info():
...
zu:
spielstand-info
Click ersetzt Unterstriche also durch Bindestriche.
Aktuelle Click-Versionen entfernen bei automatisch abgeleiteten Namen außerdem bestimmte typische Suffixe.
Beispielsweise wird:
def init_data_command():
...
zu:
init-data
statt:
init-data-command
Betroffen sind unter anderem:
_command
_cmd
_group
_grp
Wenn der öffentliche Name wichtig ist, würde ich ihn trotzdem ausdrücklich setzen:
@cli.command("info")
def spielstand_info():
...
Dann ist das Interface eindeutig:
dungeon info
Das ist auch praktisch bei Python-Keywords:
@cli.command("import")
def importieren():
...
Globale Optionen einer Group
Eine Option an der Gruppe gilt für die gesamte Invocation:
@click.group()
@click.option(
"--speicher",
default="spielstand.json",
)
def cli(speicher):
...
Die Option steht vor dem Subcommand:
dungeon \
--speicher test.json \
spielen
Eine Option des Subcommands steht dagegen dahinter:
dungeon \
--speicher test.json \
spielen --verbose
Das ist eine wichtige Eigenschaft des Interfaces.
Die Group und der Subcommand besitzen jeweils ihren eigenen Parser-Context.
Context und gemeinsame Daten
Globale Werte müssen anschließend zu den Subcommands gelangen.
Dafür besitzt Click den Context.
Eine einfache Form:
import click
@click.group()
@click.option(
"--speicher",
default="spielstand.json",
)
@click.option(
"-v",
"--verbose",
count=True,
)
@click.pass_context
def cli(
ctx,
speicher,
verbose,
):
ctx.ensure_object(dict)
ctx.obj["speicher"] = speicher
ctx.obj["verbose"] = verbose
Der Subcommand:
@cli.command()
@click.pass_obj
def spielen(obj):
if obj["verbose"]:
click.echo(
f"Lese {obj['speicher']}",
err=True,
)
click.echo(
"Weiter geht's."
)
@click.pass_context übergibt den aktuellen Context.
@click.pass_obj reicht nur:
ctx.obj
weiter.
Lieber ein Objekt als ein anonymes Dictionary
Bei mehreren gemeinsamen Einstellungen ist eine Dataclass angenehmer:
from dataclasses import dataclass
from pathlib import Path
@dataclass
class AppConfig:
speicher: Path
verbose: int
In der Group:
@click.pass_context
def cli(
ctx,
speicher,
verbose,
):
ctx.obj = AppConfig(
speicher=speicher,
verbose=verbose,
)
Im Command:
@click.pass_obj
def spielen(
config: AppConfig,
):
...
Das ist lesbarer und lässt sich statisch besser typisieren.
Contexts sind hierarchisch
Jeder Subcommand bekommt einen eigenen Context mit dem Context der Group als Parent.
Dadurch kann Click Konfiguration und State über verschachtelte Command-Gruppen weitergeben.
Für kleine CLIs reicht meistens:
ctx.obj
Wenn Du später Plugins, verschachtelte Groups oder komplexere Command-Bäume baust, wird das Context-Modell wesentlich wichtiger.
Ein aktueller Context lässt sich außerdem innerhalb desselben Threads über:
click.get_current_context()
abrufen.
Das sollte keine Einladung sein, die komplette Anwendung heimlich von globalem Context-State abhängig zu machen. Explizite Funktionsparameter bleiben verständlicher.
Decorator-Reihenfolge lesbar halten
Typisch ist:
@click.group()
@click.option(
"--speicher",
)
@click.pass_context
def cli(
ctx,
speicher,
):
...
und:
@cli.command()
@click.option(
"--name",
)
@click.pass_obj
def neu(
config,
name,
):
...
Da Python Decorators von unten nach oben anwendet und Click dabei Parameter an Command-Callbacks bindet, kann bei komplizierten Decorator-Kombinationen die Reihenfolge relevant werden.
Für pass_context und pass_obj ist es eine gute, gut lesbare Konvention, sie
direkt an den eigentlichen Callback zu setzen.
Vor allem solltest Du bei Custom Decorators nicht davon ausgehen, dass beliebiges Umsortieren immer folgenlos bleibt.
-h zusätzlich zu --help
Click bringt standardmäßig:
--help
mit.
Viele Benutzer erwarten außerdem:
-h
Das kannst Du über den Context konfigurieren:
CONTEXT_SETTINGS = {
"help_option_names": [
"-h",
"--help",
],
}
@click.group(
context_settings=CONTEXT_SETTINGS,
)
def cli():
"""Verwaltet den Dungeon."""
Nun funktionieren beide:
dungeon -h
dungeon --help
Eine Versionsoption
Für:
dungeon --version
gibt es:
@click.version_option(
version="1.0.0",
prog_name="dungeon",
)
Bei einem installierten Package sollte die Version besser nur an einer Stelle gepflegt werden.
Dann:
@click.version_option(
package_name="dungeon",
)
Click liest die Version aus den installierten Package-Metadaten.
Seit Click 8.5 gibt es außerdem:
click.custom_version_option(...)
wenn die Versionsausgabe mehr enthalten soll als das normale
version_option() vorsieht, beispielsweise:
Python-Version
Git-Revision
Build-Informationen
Für einen normalen:
dungeon, version 1.0.0
reicht version_option().
Ein vollständiger Dungeon als Click-CLI
Bauen wir die wichtigsten Teile zusammen.
# 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."
),
}
class SpielstandError(Exception):
"""Fehler beim Lesen eines Spielstands."""
@dataclass
class AppConfig:
speicher: Path
verbose: int
def lade_spielstand(
pfad: Path,
) -> dict:
if not pfad.exists():
raise SpielstandError(
f"Kein Spielstand in {pfad}."
)
try:
daten = json.loads(
pfad.read_text(
encoding="utf-8"
)
)
except (
OSError,
json.JSONDecodeError,
) as fehler:
raise SpielstandError(
f"Spielstand in {pfad} "
"konnte nicht gelesen werden."
) from fehler
if not isinstance(
daten,
dict,
):
raise SpielstandError(
"Der Spielstand hat "
"ein ungültiges Format."
)
benoetigte_felder = {
"name",
"hp",
"raum",
"inventar",
}
if not benoetigte_felder <= daten.keys():
raise SpielstandError(
"Im Spielstand fehlen Daten."
)
return daten
CONTEXT_SETTINGS = {
"help_option_names": [
"-h",
"--help",
],
}
@click.group(
context_settings=CONTEXT_SETTINGS,
)
@click.option(
"--speicher",
type=click.Path(
dir_okay=False,
path_type=Path,
),
default="spielstand.json",
show_default=True,
envvar="DUNGEON_SPEICHER",
show_envvar=True,
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": [],
}
try:
ziel.write_text(
json.dumps(
spielstand,
indent=2,
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
except OSError as fehler:
raise click.ClickException(
f"{ziel} konnte nicht "
"geschrieben werden."
) from fehler
click.secho(
(
f"Neuer Spielstand für "
f"{name} in {ziel}."
),
fg="green",
)
@cli.command()
@click.pass_obj
def spielen(config):
"""Lädt den Spielstand und zeigt die Lage."""
try:
spielstand = lade_spielstand(
config.speicher
)
except SpielstandError as fehler:
raise click.ClickException(
str(fehler)
) from fehler
if config.verbose:
click.echo(
f"Gelesen aus {config.speicher}.",
err=True,
)
raum = spielstand["raum"]
if raum not in RAEUME:
raise click.ClickException(
f"Unbekannter Raum im Spielstand: "
f"{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."""
muster = muster.lower()
for name, beschreibung in RAEUME.items():
if (
muster
and muster not in name.lower()
):
continue
click.echo(
f"{name:12} {beschreibung}"
)
if __name__ == "__main__":
cli()
Aufrufe:
python -m dungeon.cli \
neu --name Karl
python -m dungeon.cli spielen
python -m dungeon.cli \
raeume --filter biblio
Eine mögliche 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.
Kein Python-Traceback, weil das ein erwartbarer Benutzerfehler ist.
Ein echter Programmierfehler wird dagegen nicht pauschal verschluckt.
CLI und Anwendungslogik trennen
Im vollständigen Beispiel haben wir bereits angefangen, diese Trennung einzuführen:
def lade_spielstand(
pfad,
):
...
ist normale Python-Logik.
Der Command:
@cli.command()
def spielen(...):
...
übersetzt zwischen:
Click
↓
Anwendungsfunktion
↓
Domain-Fehler
↓
ClickException
Bei einem größeren Projekt würde ich noch weitergehen.
Zum Beispiel:
dungeon/
├── cli.py
├── modelle.py
├── spiel.py
└── speichern.py
cli.py sollte hauptsächlich:
Argumente entgegennehmen
Optionen validieren
Anwendungsfunktionen aufrufen
Ergebnisse darstellen
fachliche Fehler in CLI-Fehler übersetzen
Der eigentliche Dungeon braucht Click nicht zu kennen.
Als echtes Kommando installieren
Für Entwicklung ist:
python -m dungeon.cli
völlig okay.
Benutzer sollen aber lieber schreiben:
dungeon --help
dungeon neu --name Karl
dungeon spielen
Dafür bekommt das Package einen Entry Point.
In pyproject.toml:
[project]
name = "dungeon"
version = "1.0.0"
dependencies = [
"click",
]
[project.scripts]
dungeon = "dungeon.cli:cli"
Rechts steht:
Modul:Objekt
also:
dungeon.cli:cli
Mit einem installierbaren uv-Projekt:
uv sync
Danach:
uv run dungeon --help
Ein manuell aufgebautes Projekt benötigt dafür zusätzlich ein korrekt konfiguriertes Build-System.
Ein aktuelles:
uv init dungeon
erzeugt bereits eine installierbare Application-Struktur.
Mehr dazu findest Du in uv: pip und venv in schnell.
Shell Completion
Ein installiertes Click-Kommando kann Shell Completion für Commands, Optionen und bestimmte Parameterwerte anbieten.
Für Bash:
eval "$(_DUNGEON_COMPLETE=bash_source dungeon)"
Für Zsh:
eval "$(_DUNGEON_COMPLETE=zsh_source dungeon)"
Für Fish:
_DUNGEON_COMPLETE=fish_source dungeon | source
Seit Click 8.5 wird auch PowerShell direkt unterstützt:
$env:_DUNGEON_COMPLETE = "powershell_source"
dungeon | Out-String | Invoke-Expression
Remove-Item Env:_DUNGEON_COMPLETE
Die Environment Variable folgt dem Schema:
_<PROGRAMMNAME>_COMPLETE
Der Programmname wird großgeschrieben, Bindestriche werden zu Unterstrichen.
Aus:
mein-dungeon
wird also:
_MEIN_DUNGEON_COMPLETE
Completion besser einmal generieren
Die obigen Varianten starten beim Öffnen einer Shell das Programm, um das Completion Script zu erzeugen.
Bei häufig genutzten Tools ist es effizienter, das Script einmal in eine Datei zu schreiben.
Für Bash beispielsweise:
_DUNGEON_COMPLETE=bash_source \
dungeon \
> ~/.dungeon-complete.bash
In .bashrc:
. ~/.dungeon-complete.bash
Für ein verteilt installiertes CLI kann das Completion Script auch direkt mit dem Package ausgeliefert werden.
Wichtig ist: Shell Completion setzt ein stabiles installiertes Executable voraus.
Für:
python dungeon.py
gibt es kein dauerhaftes Command namens dungeon, an das die Shell ihre
Completion binden könnte.
Tests mit CliRunner
Click bringt ein eigenes Testwerkzeug mit:
from click.testing import CliRunner
Damit kannst Du ein CLI im Testprozess aufrufen, ohne jedes Mal einen echten Subprozess zu starten.
Das passt hervorragend zu pytest von Null.
Ein einfacher Test:
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
runner.invoke() liefert ein Result-Objekt.
Interessante Attribute sind:
ergebnis.exit_code
ergebnis.output
ergebnis.stdout
ergebnis.stderr
ergebnis.exception
ergebnis.return_value
stdout und stderr sind getrennt verfügbar
Seit Click 8.2 sind beide Streams immer separat verfügbar; der frühere
Parameter mix_stderr ist entfallen.
ergebnis.stdout
ergebnis.stderr
output enthält dagegen die gemischte Ausgabe in der Reihenfolge, in der ein
Benutzer sie am Terminal sehen würde:
ergebnis.output
Für einen Fehler kannst Du deshalb gezielt schreiben:
assert "Error:" in ergebnis.stderr
statt nur:
assert "Error:" in ergebnis.output
Welche Variante besser ist, hängt davon ab, was der Test absichern soll.
isolated_filesystem() nicht mehr für neue Tests verwenden
Ältere Click-Beispiele verwenden häufig:
with runner.isolated_filesystem():
...
Das erzeugt einen temporären Ordner und wechselt mit:
os.chdir(...)
dorthin.
Seit Click 8.5 ist CliRunner.isolated_filesystem() deprecated und soll mit
Click 9 verschwinden.
Der Grund ist nicht nur kosmetisch: Das aktuelle Arbeitsverzeichnis ist prozessweiter State. Zwei parallele Tests können sich damit gegenseitig beeinflussen.
Mit pytest ist tmp_path die bessere Lösung.
CLI-Dateitests mit tmp_path
Unser CLI besitzt bereits:
--speicher
Damit müssen wir für Tests überhaupt nicht das aktuelle Verzeichnis verändern.
from click.testing import CliRunner
from dungeon.cli import cli
def test_neu_schreibt_spielstand(
tmp_path,
):
runner = CliRunner()
speicherdatei = (
tmp_path
/ "spielstand.json"
)
ergebnis = runner.invoke(
cli,
[
"--speicher",
str(speicherdatei),
"neu",
"--name",
"Karl",
],
)
assert ergebnis.exit_code == 0
assert speicherdatei.exists()
Der Test bekommt einen eigenen temporären Ordner, ohne:
os.chdir(...)
zu verwenden.
Das ist zugleich ein gutes Beispiel dafür, warum explizite Pfade die Testbarkeit verbessern.
Den geschriebenen Inhalt testen
Nur:
assert speicherdatei.exists()
ist ein Anfang.
Interessanter ist:
import json
def test_neu_speichert_spielerdaten(
tmp_path,
):
runner = CliRunner()
speicherdatei = (
tmp_path
/ "spielstand.json"
)
ergebnis = runner.invoke(
cli,
[
"--speicher",
str(speicherdatei),
"neu",
"--name",
"Karl",
"--hp",
"80",
],
)
assert ergebnis.exit_code == 0
daten = json.loads(
speicherdatei.read_text(
encoding="utf-8"
)
)
assert daten["name"] == "Karl"
assert daten["hp"] == 80
assert daten["raum"] == "halle"
Damit prüfen wir fachliche Daten statt nur die Existenz einer Datei.
Prompts testen
CliRunner.invoke() kann simulierte stdin-Eingabe erhalten:
def test_neu_fragt_nach_dem_namen(
tmp_path,
):
runner = CliRunner()
speicherdatei = (
tmp_path
/ "spielstand.json"
)
ergebnis = runner.invoke(
cli,
[
"--speicher",
str(speicherdatei),
"neu",
],
input="Karl\n",
)
assert ergebnis.exit_code == 0
assert "Karl" in ergebnis.output
assert speicherdatei.exists()
Click verhält sich dabei so, als hätte der Benutzer:
Karl
eingetippt.
Bei versteckten Passwort-Prompts wird die Eingabe nicht in der aufgezeichneten Ausgabe gespiegelt.
Einen Abbruch testen
Wenn bereits ein Spielstand existiert:
def test_neu_bricht_bei_nein_ab(
tmp_path,
):
runner = CliRunner()
speicherdatei = (
tmp_path
/ "spielstand.json"
)
speicherdatei.write_text(
"alt",
encoding="utf-8",
)
ergebnis = runner.invoke(
cli,
[
"--speicher",
str(speicherdatei),
"neu",
"--name",
"Karl",
],
input="n\n",
)
assert ergebnis.exit_code == 1
assert (
speicherdatei.read_text(
encoding="utf-8"
)
== "alt"
)
Hier ist der Exit-Code tatsächlich bekannt:
1
weil click.confirm(..., abort=True) einen Click-Abbruch auslöst.
Noch wichtiger ist trotzdem die fachliche Erwartung:
Die alte Datei bleibt unverändert.
Einen erwartbaren Fehler testen
def test_spielen_ohne_spielstand(
tmp_path,
):
runner = CliRunner()
speicherdatei = (
tmp_path
/ "fehlt.json"
)
ergebnis = runner.invoke(
cli,
[
"--speicher",
str(speicherdatei),
"spielen",
],
)
assert ergebnis.exit_code == 1
assert (
"Kein Spielstand"
in ergebnis.stderr
)
Damit testen wir zugleich:
richtiger Fehlerstatus
richtiger Ausgabekanal
verständliche Fehlermeldung
Exceptions während eines Tests untersuchen
Wenn ein Test unerwartet endet:
assert ergebnis.exit_code == 0
aber der Wert ist:
1
ist:
ergebnis.exception
bei der Fehlersuche interessant.
Zum Beispiel:
assert ergebnis.exception is None
Bei einem unerwarteten Fehler kannst Du während der Entwicklung auch:
print(
repr(ergebnis.exception)
)
verwenden.
Der Test sollte später trotzdem eher das gewünschte Verhalten prüfen als eine zufällige interne Exception.
CliRunner verändert globalen Interpreter-State
CliRunner.invoke() ist praktisch, simuliert ein CLI aber unter anderem durch
temporären Austausch von:
sys.stdin
sys.stdout
sys.stderr
Das ist process-globaler State.
Deshalb ist CliRunner nicht threadsicher.
CLI-Tests solltest Du nicht gleichzeitig aus mehreren Threads im selben Python-Prozess ausführen.
Wenn Tests parallel laufen sollen, ist prozessbasierte Isolation wie
pytest-xdist der passende Weg.
Jeder Worker hat dann seinen eigenen Python-Prozess.
Output unterhalb von sys.stdout erfassen
Der normale CliRunner arbeitet auf Python-Ebene.
Das reicht für:
print(...)
click.echo(...)
sys.stdout.write(...)
Seit Click 8.4 gibt es zusätzlich:
CliRunner(
capture="fd",
)
Dieser Modus erfasst auch Ausgaben, die direkt auf die File Descriptors 1
und 2 des Betriebssystems schreiben.
Das kann bei:
C Extensions
Subprozessen
faulthandler
Libraries mit gecachten Streams
hilfreich sein.
Beispiel:
runner = CliRunner(
capture="fd",
)
Der FD-Capture-Modus steht nicht unter Windows zur Verfügung.
Für ein normales reines Python-Click-CLI brauchst Du ihn ohnehin selten.
Click oder Typer?
Typer baut auf Click auf. Die Wahl ist deshalb weniger:
altes Framework gegen modernes Framework
und eher eine Frage der gewünschten Abstraktion.
Typer passt gut, wenn …
Du Type Hints ohnehin konsequent verwendest und möchtest, dass aus:
def neu(
name: str,
hp: int = 100,
):
...
möglichst direkt ein CLI entsteht.
Für viele normale Business-CLIs ist das sehr angenehm.
Click passt gut, wenn …
Du das Command Interface ausdrücklich beschreiben möchtest:
@click.option(
"--hp",
type=click.IntRange(
1,
100,
),
)
und genaue Kontrolle über Parsing, Contexts, Parameterquellen, Custom Types oder dynamisch zusammengesetzte Commands brauchst.
Click ist außerdem nützlich, wenn Du Typer besser verstehen möchtest. Unter dessen Type-Hint-Schicht begegnen Dir bei komplexeren Problemen ohnehin wieder Click-Konzepte.
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 automatisch ein Framework.
Keines der drei Werkzeuge gewinnt allein aufgrund seines Alters oder seiner Zeilenzahl.
Click-Commands nicht als Business-API verwenden
Weil:
@click.command()
def neu():
...
anschließend ein Command-Objekt ist, sollte anderer Python-Code nicht
versuchen, es wie eine normale Fachfunktion aufzurufen:
neu()
mit der Erwartung:
Führe einfach meine ursprüngliche Funktion aus.
Das startet Clicks Command-Verhalten.
Besser:
def erstelle_spielstand(
name,
hp,
):
...
und:
@click.command()
def neu(...):
erstelle_spielstand(
...
)
Das gleiche Prinzip haben wir schon beim Typer-Artikel gesehen.
Die CLI ist eine äußere Schnittstelle, nicht der Kern Deiner Anwendung.
Plugins und dynamische Commands
Click-Groups sind selbst Objekte.
Du kannst Commands deshalb auch programmatisch registrieren:
cli.add_command(
anderer_command
)
Das ist interessant für größere Anwendungen und Plugin-Systeme.
Ein Plugin kann beispielsweise ein eigenes:
dungeon export
beisteuern, ohne dass der zentrale CLI-Code jeden Command über einen Decorator kennen muss.
Für unseren Lern-Dungeon wäre das unnötig kompliziert.
Es erklärt aber, warum Click auch bei großen CLIs beliebt ist: Commands sind nicht bloß Syntax, sondern echte Objekte, die zusammengesetzt werden können.
Eigene Parametertypen
Wenn IntRange, Choice, Path und die eingebauten Typen nicht reichen,
kannst Du einen eigenen click.ParamType schreiben.
Ein stark vereinfachtes Beispiel für eine Richtung:
class RichtungTyp(
click.ParamType[str]
):
name = "richtung"
def convert(
self,
value,
param,
ctx,
):
wert = value.lower()
erlaubt = {
"norden",
"osten",
"sueden",
"westen",
}
if wert not in erlaubt:
self.fail(
f"{value!r} ist "
"keine bekannte Richtung",
param,
ctx,
)
return wert
Dann:
RICHTUNG = RichtungTyp()
@click.argument(
"richtung",
type=RICHTUNG,
)
Seit Click 8.4 sind ParamType und die eingebauten Typen außerdem genauer
generisch typisiert.
Für einfache Fälle würde ich trotzdem zuerst Choice verwenden:
click.Choice(
[
"norden",
"osten",
"sueden",
"westen",
]
)
Ein eigener Typ lohnt sich erst, wenn echte eigene Konvertierungslogik dahintersteckt.
Häufige Stolperfallen
-
Click und Typer gedanklich vermischen: Click übernimmt den CLI-Typ nicht aus
hp: int. Definieretype=intoder einen passenden Click-Typ. -
Business-Logik direkt in einen riesigen Command schreiben: Der Command sollte die CLI mit normalen Anwendungsfunktionen verbinden.
-
Einen dekorierten Command wie die ursprüngliche Python-Funktion aufrufen:
@click.command()ersetzt den Namen durch ein Command-Objekt. -
Parametername und Optionsname verwechseln:
--max-hpwird normalerweise zumax_hp. Bei Bedarf kannst Du einen eigenen internen Namen angeben. -
Eine Option verwenden, obwohl der Wert eigentlich zur Aktion gehört:
dungeon laden spielstand.jsonist häufig natürlicher alsdungeon laden --datei spielstand.json. -
Arguments als grundsätzlich undokumentierbar ansehen: Seit Click 8.5 kann
click.argument()direkthelp=bekommen. -
Click-8.5-Features verwenden, obwohl das Projekt Click 8.4 unterstützt: Das betrifft beispielsweise den neuen Argument-Hilfetext und integrierte PowerShell-Completion.
-
multiple=Trueals Liste oderNoneerwarten: Click liefert ein Tuple; ohne Werte ist es leer. -
nargs=-1bei einer Option versuchen: Variadic-1ist für Arguments gedacht. Options haben eine feste Arity. -
Ein variadisches Argument manuell prüfen, obwohl mindestens ein Wert Teil des CLI-Vertrags ist:
required=Truekann die Regel direkt ausdrücken. -
Ein Dateinamen-Argument mit
-ohne--übergeben: Ein Wert wie--datei.txtkann sonst als Option interpretiert werden. -
click.Path(path_type=Path)mit Existenzprüfung verwechseln: Erstexists=Trueverlangt einen bereits vorhandenen Pfad. -
Einen neuen Ausgabepfad mit
exists=Truedefinieren: Eine Datei, die erst erzeugt werden soll, existiert naturgemäß noch nicht. -
Passwörter als normale Kommandozeilenwerte empfehlen: Shell History und Prozessinformationen können solche Werte sichtbar machen.
-
Prompts als einzige Eingabemöglichkeit verwenden: Automatisierbare CLIs brauchen einen Weg ohne Interaktion.
-
Environment Variables für einfache Defaults halten: Sie besitzen eine höhere Priorität als
defaultunddefault_map. Ein Kommandozeilenwert gewinnt wiederum gegenüber ihnen. -
Globale Group-Optionen hinter den Subcommand schreiben: Eine Gruppenoption gehört vor den Command, Command-Optionen dahinter.
-
Den Group-Callback mit teurer Anwendungslogik füllen: Er läuft beim Aufruf der Gruppe, bevor der Subcommand ausgeführt wird.
-
ctx.objzu einer unstrukturierten Ablage für alles machen: Bei mehreren Werten ist eine Dataclass oder eigene Config-Klasse übersichtlicher. -
get_current_context()als globales Konfigurationssystem missbrauchen: Explizite Dependencies bleiben für Anwendungslogik leichter verständlich und testbar. -
Erwartbare Fachfehler als Traceback zeigen: Übersetze Domain Exceptions an der CLI-Grenze beispielsweise in
ClickException. -
ClickExceptiontief in der Domain werfen: Dann ist Deine eigentliche Anwendung unnötig an Click gekoppelt. -
ClickExceptionmit einem Usage-Fehler verwechseln: Für eine falsch benutzte Kommandozeile gibt es unter anderemUsageErrorundBadParameter. -
click.echo()und Logging gleichsetzen: CLI-Ausgabe ist Benutzerkommunikation, Logging dient Diagnose und Betrieb. -
Shell Completion an
python datei.pyhängen wollen: Clicks Completion setzt ein installiertes, stabiles Entry-Point-Kommando voraus. -
CliRunner.isolated_filesystem()für neue pytest-Tests verwenden: Der Helper ist seit Click 8.5 deprecated. Nutzetmp_pathund übergib absolute Pfade. -
CliRunneraus mehreren Threads gleichzeitig verwenden:invoke()verändert prozessglobale Streams und ist nicht threadsicher. -
Bei CLI-Tests nur
ergebnis.outputkennen:stdoutundstderrlassen sich separat prüfen;outputenthält beide in Terminal-Reihenfolge. -
Erwarten, dass der normale Capture-Modus jeden Subprozess erfasst: Für tiefer liegende FD-Ausgaben gibt es seit Click 8.4
capture="fd", allerdings nicht unter Windows. -
Tests nur auf Textfragmente reduzieren: Exit-Code, stdout/stderr und erzeugte Dateien sind oft ebenso wichtig wie der sichtbare Text.
Kompakte Übersicht
| Baustein | Beispiel | Zweck |
|---|---|---|
| Command | @click.command() |
Funktion als CLI-Command registrieren |
| Group | @click.group() |
Subcommands sammeln |
| Subcommand | @cli.command() |
Command an Group registrieren |
| Option | @click.option("--name") |
benannter Parameter |
| Short Option | "-n", "--name" |
kurze und lange Form |
| Argument | @click.argument("datei") |
Positionsargument |
| Argument-Hilfe | help="..." |
seit Click 8.5 direkt möglich |
| Flag | is_flag=True |
Schalter ohne zusätzlichen Wert |
| Flag-Paar | "--farbe/--keine-farbe" |
explizit ein- und ausschalten |
| Zähler | count=True |
-vvv liefert 3 |
| mehrfach | multiple=True |
Option mehrfach, Ergebnis Tuple |
| feste Arity | nargs=2 |
zwei Werte pro Parameter |
| variadisch | nargs=-1 |
beliebig viele Argumentwerte |
| Typ | type=int |
Wert konvertieren |
| Auswahl | click.Choice(...) |
erlaubte Werte |
| Bereich | click.IntRange(...) |
Zahlenbereich |
| Pfad | click.Path(path_type=Path) |
Pfad konvertieren und prüfen |
| Datei | click.File("r") |
Datei durch Click öffnen |
| Prompt | prompt=True |
fehlenden Wert erfragen |
| Passwort | hide_input=True |
Eingabe verdecken |
| Bestätigung | click.confirm(...) |
Ja-Nein-Frage |
| Environment | envvar="DUNGEON_SPEICHER" |
Wert aus Environment |
| Parameterquelle | ctx.get_parameter_source(...) |
Herkunft eines Wertes prüfen |
| Ausgabe | click.echo(...) |
normale CLI-Ausgabe |
| Farbe | click.secho(...) |
gestylte CLI-Ausgabe |
| Context | @click.pass_context |
Click-Context übergeben |
| Shared State | @click.pass_obj |
ctx.obj übergeben |
| Fehler | ClickException |
erwartbarer Laufzeitfehler |
| Benutzungsfehler | UsageError |
falscher CLI-Aufruf |
| Version | @click.version_option() |
--version |
| Completion | _<NAME>_COMPLETE |
Shell Completion |
| Test | CliRunner().invoke(...) |
CLI im Testprozess aufrufen |
| Testdateien | pytest tmp_path |
isolierte Dateipfade ohne chdir() |
Übungen
1. Einen status-Command bauen
Baue einen Command:
status
mit dem Flag:
--kurz
Ohne Flag sollen Name, HP und Raum in einzelnen Zeilen erscheinen. Mit
--kurz 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."""
try:
stand = lade_spielstand(
config.speicher
)
except SpielstandError as fehler:
raise click.ClickException(
str(fehler)
) from fehler
if kurz:
click.echo(
f"{stand['name']} | "
f"{stand['hp']} HP | "
f"{stand['raum']}"
)
return
click.echo(
f"Name: {stand['name']}"
)
click.echo(
f"HP: {stand['hp']}"
)
click.echo(
f"Raum: {stand['raum']}"
)
2. Einen variadischen nimm-Command bauen
Der Command soll mindestens einen Gegenstand verlangen:
dungeon nimm Fackel Seil
Lösung
@cli.command()
@click.argument(
"gegenstaende",
nargs=-1,
required=True,
help="Gegenstände für das Inventar.",
)
@click.pass_obj
def nimm(
config,
gegenstaende,
):
"""Legt Gegenstände ins Inventar."""
try:
stand = lade_spielstand(
config.speicher
)
except SpielstandError as fehler:
raise click.ClickException(
str(fehler)
) from fehler
stand["inventar"].extend(
gegenstaende
)
try:
config.speicher.write_text(
json.dumps(
stand,
indent=2,
ensure_ascii=False,
)
+ "\n",
encoding="utf-8",
)
except OSError as fehler:
raise click.ClickException(
"Spielstand konnte "
"nicht geschrieben werden."
) from fehler
click.echo(
"Du trägst jetzt "
f"{len(stand['inventar'])} "
"Gegenstände."
)
3. Einen CLI-Test mit tmp_path schreiben
Teste, dass:
dungeon neu --name Karl
eine Spielstanddatei erzeugt.
Lösung
from click.testing import CliRunner
from dungeon.cli import cli
def test_neu_schreibt_spielstand(
tmp_path,
):
runner = CliRunner()
pfad = (
tmp_path
/ "spielstand.json"
)
ergebnis = runner.invoke(
cli,
[
"--speicher",
str(pfad),
"neu",
"--name",
"Karl",
],
)
assert ergebnis.exit_code == 0
assert pfad.exists()
4. Einen Prompt testen
Starte neu ohne --name und beantworte den Prompt mit Karl.
Lösung
def test_neu_fragt_nach_name(
tmp_path,
):
runner = CliRunner()
pfad = (
tmp_path
/ "spielstand.json"
)
ergebnis = runner.invoke(
cli,
[
"--speicher",
str(pfad),
"neu",
],
input="Karl\n",
)
assert ergebnis.exit_code == 0
assert "Karl" in ergebnis.output
assert pfad.exists()
5. stderr und Exit-Code prüfen
Teste einen Aufruf von spielen, wenn der Spielstand fehlt.
Lösung
def test_spielen_ohne_spielstand(
tmp_path,
):
runner = CliRunner()
pfad = (
tmp_path
/ "fehlt.json"
)
ergebnis = runner.invoke(
cli,
[
"--speicher",
str(pfad),
"spielen",
],
)
assert ergebnis.exit_code == 1
assert (
"Kein Spielstand"
in ergebnis.stderr
)
6. Eine Environment Variable testen
Teste, dass DUNGEON_SPEICHER als Speicherpfad verwendet werden kann.
Lösung
def test_speicher_aus_environment(
tmp_path,
):
runner = CliRunner()
pfad = (
tmp_path
/ "env-spielstand.json"
)
ergebnis = runner.invoke(
cli,
[
"neu",
"--name",
"Karl",
],
env={
"DUNGEON_SPEICHER": str(
pfad
),
},
)
assert ergebnis.exit_code == 0
assert pfad.exists()
7. Die Quelle eines Parameters anzeigen
Schreibe einen kleinen Command, der ausgibt, ob --name von der Kommandozeile,
aus einer Environment Variable oder vom Default stammt.
Lösung
@click.command()
@click.option(
"--name",
envvar="DUNGEON_NAME",
default="Abenteurer",
)
@click.pass_context
def quelle(
ctx,
name,
):
source = (
ctx.get_parameter_source(
"name"
)
)
click.echo(
f"{name}: {source.name}"
)
quelle --name Karl
COMMANDLINE
DUNGEON_NAME
ENVIRONMENT
Weiterlesen
- Vom Skript zum echten CLI
- Type Hints in der Praxis
- pytest von Null
- Decorators entmystifiziert
- Python lernen, Teil 10: Module und Projektstruktur
- uv: pip und venv in schnell
- Logging statt print
- Click-Dokumentation
- Click: Options
- Click: Arguments
- Click: Commands, Groups und Context
- Click: Advanced Groups and Context
- Click: Parameter Types
- Click: Prompts
- Click: Testing
- Click: Shell Completion
- Click: Exception Handling und Exit-Codes
- Click: Changelog
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.