Zum Inhalt springen

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:

  • argparse konfiguriert 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. Definiere type=int oder 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-hp wird normalerweise zu max_hp. Bei Bedarf kannst Du einen eigenen internen Namen angeben.

  • Eine Option verwenden, obwohl der Wert eigentlich zur Aktion gehört: dungeon laden spielstand.json ist häufig natürlicher als dungeon laden --datei spielstand.json.

  • Arguments als grundsätzlich undokumentierbar ansehen: Seit Click 8.5 kann click.argument() direkt help= 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=True als Liste oder None erwarten: Click liefert ein Tuple; ohne Werte ist es leer.

  • nargs=-1 bei einer Option versuchen: Variadic -1 ist 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=True kann die Regel direkt ausdrücken.

  • Ein Dateinamen-Argument mit - ohne -- übergeben: Ein Wert wie --datei.txt kann sonst als Option interpretiert werden.

  • click.Path(path_type=Path) mit Existenzprüfung verwechseln: Erst exists=True verlangt einen bereits vorhandenen Pfad.

  • Einen neuen Ausgabepfad mit exists=True definieren: 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 default und default_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.obj zu 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.

  • ClickException tief in der Domain werfen: Dann ist Deine eigentliche Anwendung unnötig an Click gekoppelt.

  • ClickException mit einem Usage-Fehler verwechseln: Für eine falsch benutzte Kommandozeile gibt es unter anderem UsageError und BadParameter.

  • click.echo() und Logging gleichsetzen: CLI-Ausgabe ist Benutzerkommunikation, Logging dient Diagnose und Betrieb.

  • Shell Completion an python datei.py hä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. Nutze tmp_path und übergib absolute Pfade.

  • CliRunner aus mehreren Threads gleichzeitig verwenden: invoke() verändert prozessglobale Streams und ist nicht threadsicher.

  • Bei CLI-Tests nur ergebnis.output kennen: stdout und stderr lassen sich separat prüfen; output enthä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']}"
    )
Der frühe `return` hält den restlichen Code flach.

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."
    )
`required=True` sorgt dafür, dass Click einen Aufruf ohne Gegenstand selbst ablehnt.

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()
`runner.isolated_filesystem()` brauchen wir dafür nicht.

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()
`CliRunner.invoke()` kann für den Test eine eigene Environment bereitstellen, ohne die echte Shell-Environment dauerhaft zu verändern.

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}"
    )
Direkt angegeben:
quelle --name Karl
liefert als Quelle:
COMMANDLINE
Über:
DUNGEON_NAME
entsprechend:
ENVIRONMENT

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!