Möchtest Du ein Kommandozeilen-Tool (CLI = command line interface) in Python bauen? Mir ging es vor kurzem so. Ich hatte einige nützliche Funktionen geschrieben und die IT von einem Kunden wollte diese Funktionen direkt von der Kommandozeile aus nutzen. Die Funktionen haben natürlich Parameter, die übergeben werden müssen.

Was ist ein „command line interface“ (CLI)?

Jeder, der schon mal mit der Eingabeaufforderung (Command Prompt oder Shell) gearbeitet hat, sei es unter Windows oder in einer Linux-Shell, kennt es. Nach dem Programm folgen noch ein oder mehrere Parameter, z.B. ls –l, cd .. oder conda install pandas. Für Linux-User, die sowieso viel in einer Shell unterwegs sind, braucht man den Nutzen gar nicht zu erklären. Für viele Windows-User ist es ungewohnt, sind sie doch eher mit der Maus unterwegs. Als Python-Programmierer kommt man aber sowieso nicht drum herum, schließlich benötigen wir Environments und müssen Packages installieren (entweder über virtualenv und pip oder mittels conda).

Prinzipiell sind der Komplexität von CLIs keine Grenzen gesetzt. Auch verschachtelte Menüs sind denkbar, durch die der Nutzer navigieren kann. Dabei stellt sich allerdings die Frage, ob eine grafische Benutzeroberfläche nicht besser geeignet ist. Auf der anderen Seite ist eine textbasierte Benutzeroberfläche nötig, wenn es sich um einen Cloud-Rechner handelt, auf den man nur per Terminal zugreift. So gibt es CLIs für Azure und AWS.

Als besonders praktisch haben sich aber solche Kommandozeilen-Tools herausgestellt, die direkt alle Optionen in ihrem Aufruf abdecken. Die meisten Betriebssystem-Befehle funktionieren so, auch viele Programmier-Tools wie git, pip oder conda. Der Vorteil gegenüber einem Benutzermenü ist, dass solche Programme in Skripten (Batch- bzw. Bash), aus anderen Programmiersprachen oder von einer GUI aufgerufen werden können.

Arten von Parametern

Man kann drei Arten von Parametern unterscheiden:

  • positional: hier ist die Position entscheidend, also an welcher Stelle der Parameter steht.
  • optional: Die häufigste Art, der Parameter wird über ein Schlüsselwort (mit – oder — davor) sowie den Wert angegeben. Dadurch ist die Reihenfolge unwichtig. Meist wird — mit dem vollständigen Parameternamen und alternativ – mit der Abkürzung verwendet. Zum Beispiel der Namensparameter beim Erzeugen eines Environments in conda: conda create –name myenv conda create -n myenv. Interessant bei conda ist, dass das erste Argument create positional ist. Das liegt daran, dass conda im Prinzip viele Befehle in ein einziges CLI packt. Der erste Parameter wählt also wie in einem Menü aus, welcher Befehl dran ist, erst danach folgen die Parameter zu dem Befehl. Wie man diese Art von CLIs programmiert, erkläre ich im Kapitel Komplexere Kommandozeilen-Tools.
  • flag: Das sind Parameter, die nur ja/nein bzw. true/false als Ausprägungen haben. Insofern ist es meist so realisiert, dass die Abwesenheit des Flags FALSE bedeutet und die Anwesenheit TRUE. In conda zum Beispiel die Parameter –all und –y in conda update –all -y, um alle Packages upzudaten bzw. die Nachfrage im Skript zu überspringen.

Wer coole Tricks zu Linux-CLIs sucht, schaut mal auf Twitter bei @climagic vorbei.

Wie erstellt man ein command line interface (CLI) in Python?

Ein Kommandozeilen-Interface in Python zu bauen ist überhaupt nicht schwer. Ein bisschen hängt es natürlich davon ab, welche Komplexität man benötigt. Aber für (fast) jeden Anwendungsfall gibt es ein Package, welches einem die Arbeit erleichtert.

Wir wollen uns den Programmaufruf mit Parametern ansehen. Wie oben geschrieben ist das ein ganz typischer Fall, denn er erlaubt uns, unsere Python-Programme flexibel aufzurufen oder in Batch-Skripten zu nutzen.

Fangen wir einfach an. Wir wollen einen Würfel simulieren (Im Blog gibt es übrigens ein Würfelsimulations-Tutorial) und übergeben als Parameter die Anzahl Würfe.

Der manuelle Weg

Die Parameter beim Aufruf des Programms heißen übrigens arguments und sind in fast allen Programmiersprachen über eine Variable, die mit arg beginnt, abzugreifen. In Python ist es eine Liste mit Namen argv aus der Standardbibliothek sys.

Mit den folgenden zwei Zeilen Python-Code geben wir also einfach die Parameter aus:

import sys
print(sys.argv)

Wenn wir dieses Mini-Programm unter miniargs.py abspeichern und aufrufen, wird eine Liste mit den Parametern ausgegeben. Beachte, dass der erste Eintrag (Index 0) das Programm selber ist.

Nun geht es an die Würfelsimulation. Dazu definieren wir die Funktion wuerfeln mittels dem Zufallsgenerator von Numpy. Dann folgt die Abfrage, ob es einen Parameter gibt (die Länge muss also zwei sein, da als erster Eintrag das Python-Skript selber übergeben wird) und ob dieser Parameter eine ganze Zahl ist. Sind die Bedingungen erfüllt, wird die wuerfel-Funktion aufgerufen und ausgegeben, andernfalls kommt die Fehlermeldung.

import numpy as np
import sys
 
def wuerfeln(wuerfe=1, seiten=6):
    return np.random.choice(np.arange(1, seiten + 1), size=wuerfe)
 
if len(sys.argv) == 2 and sys.argv[1].isdigit():
    wuerfe = int(sys.argv[1])
    print(wuerfeln(wuerfe))
else:
    print("Bitte gib die Anzahl Würfe als Parameter an, z.B. python wuerfel 3")

Das war ja schon ganz nett, aber damit wir gerade bei komplexeren Abfragen nicht alles manuell machen müssen, gibt es das tolle Package argparse.

Würfelsimulation mit dem Python-Package argparse

Das Python-Package argparse ist wirklich sehr bequem, um Kommandozeilen-Tools zu bauen. Die Installation erfolgt wie gewohnt über pip oder conda, je nachdem was ihr nutzt, also

pip install argparse bzw. conda install argparse

.
Und dann können wir mit dem Würfelbau loslegen!

Natürlich brauchen wir wieder unsere wuerfel-Funktion. Dann definieren wir den Parser und fügen ein int-Parameter, nämlich die Anzahl Würfe hinzu. In den letzten zwei Zeilen passiert dann das eigentliche Aufdröseln der Parameter (ok, hier gibt es nicht viel zu tun) und der Aufruf der wuerfel-Funktion.

Praktischerweise wird auch direkt eine Hilfe-Funktion, allerdings auf Englisch, mitgeliefert.

import argparse
import numpy as np
 
def wuerfeln(wuerfe=1, seiten=6):
    return np.random.choice(np.arange(1, seiten + 1), size=wuerfe)
 
parser = argparse.ArgumentParser(description="Würfelsimulator")
parser.add_argument("wuerfe", type=int, help="Anzahl der Würfe")
args = parser.parse_args()
print(wuerfeln(args.wuerfe))

Auf einen Parameter, den ihr mit add_argument(„parametername“, …) hinzugefügt habt, könnt ihr mit args.parametername zugreifen.

Verschiedene Parametertypen in argparse

Es gibt ja mehrere Arten von Parametern, was ich oben in Was ist ein CLI? beschrieben habe. Wir haben bisher nur positionale Parameter gebaut. Flexibler ist man mit Optionen, da dann die Reihenfolge keine Rolle mehr spielt. Dafür brauchen sie einen Namen. Das zeige ich euch jetzt. Im Anschluss kommen noch Flags in Spiel, also ein Schalter.

Benannte Parameter in CLI

Gestalten wir das vorherige Beispiel um, so dass wir zwei Parameter übergeben, die Anzahl Würfe und die Seitenanzahl des Würfels. Und das setzen wir mit Optionen um. Der Aufruf soll also z.B. so aussehen:

python wuerfeln.py --wuerfe 3 --seiten 6

Meist gibt es auch Abkürzungen, die dann nur mit einem Minus geschrieben werden:

python wuerfeln.py -w 3 -s 6

Das ist ganz einfach, wir müssen nur die eine add_argument-Zeile anpassen und eine zweite hinzufügen. Dabei fügen wir direkt noch jeweils einen Default-Wert hinzu, so dass der Parameter nicht angegeben werden muss.

parser.add_argument("--wuerfe", "-w", type=int, default=1, help="Anzahl der Würfe")
parser.add_argument("--seiten", "-s", type=int, default=6, help="Seitenanzahl des Würfels")

Statt einer beliebigen Seitenzahl wollen wir vielleicht nur bestimmte Würfel zulassen. Dazu gibt es den Parameter choices, den wir einfach einfügen können und damit die zugelassene Würfel auf W6, W12 und W20 einschränken:

parser.add_argument(    "--seiten", "-s", type=int, choices=[6, 12, 20], default=6, help="Seitenanzahl des Würfels")

Schon gibt es eine Fehlermeldung wenn wir eine andere Zahl benutzen:
error: argument –seiten/-s: invalid choice: 3 (choose from 6, 12, 20)

Flags mit argparse

Flags sind super einfach zu implementieren. Man muss nur einen Parameter mit add_argument hinzufügen, der mit action=store_true oder store_false initialisiert wird.

Also ans Werk: Wir bauen ein Flag –schummeln ein, welches die Wahrscheinlichkeit für die höchste Punktzahl verdoppelt. Also bei einem 6-seitigen Würfel würde die 6 die Wahrscheinlichkeit 1/3 statt 1/6 bekommen und die verbleibenden 5 Seiten entsprechend weniger, d.h. jede Seite (1- 1/3) * 1/5 2/15. Das Komplizierteste an diesem Beispiel ist, das für beliebige Seitenanzahlen umzusetzen.

Wir schreiben also zuerst die wuerfel-Funktion um und ergänzen dort den Schummel-Parameter. Die choose-Funktion von numpy erlaubt die Angabe eines Vektors mit den Wahrscheinlichkeiten, den müssen wir also zuerst konstruieren.

def wuerfeln(wuerfe=1, seiten=6, schummeln=False):
    if schummeln:
        w_max = 2 / seiten
        w_normal = (1 - w_max) / (1 - seiten)
        p = ([w_normal] * (seiten - 1)).append(w_max)
        return np.random.choice(np.arange(1, seiten + 1), size=wuerfe, p=p)
    else:
        return np.random.choice(np.arange(1, seiten + 1), size=wuerfe)

Ist das geschafft, ergänzen wir den Parameter in der Parser-Definiton, unter den Parameters für Anzahl Würfe und Seiten

parser.add_argument("--schummeln", "-sch", action="store_true", help="Schummel-Flag")

Komplexere Kommandozeilen mit mehreren Befehlen

Stellt euch vor, wir haben mehrere Befehle, die wir aber mit nur einer CLI aufrufen wollen. Dazu geben wir im ersten Argument den Befehl an, die Optionen zum entsprechenden Befehl folgend dann danach. So ist das z.B. bei conda, da gibt es conda activate, conda install packagename, conda create –name envname usw.

Solch eine komplexe Struktur ist mit argparse ziemlich leicht und bequem umzusetzen. Wir wollen unser Beispiel von oben erweitern, indem wir neben Würfeln auch noch Münzwurf und Lotto simulieren. Beim Lotto soll man seine Zahlen angeben müssen.

Wir wollen also, dass das CLI namens glueck.py folgendes kann:

  • python glueck.py wuerfeln -w 3 -s 6 -schummeln (wobei die Parameter wuerfe und seiten optional sind, da sie default-Werte haben und schummeln ein flag ist)
  • python glueck.py muenze -w 10 (wobei der Parameter wuerfe einen default-Wert hat)
  • python glueck.py lotto --zahlen 10 13 19 20 33 49 –superzahl 3

Zuerst definieren wir uns die eigentlichen Funktionen, die aufgerufen werden sollen:

import argparseimport numpy as npdef wuerfeln(wuerfe=1, seiten=6, schummeln=False):    if schummeln:        w_max = 2 / seiten        w_normal = (1 - w_max) / (1 - seiten)        p = ([w_normal] * (seiten - 1)).append(w_max)        return np.random.choice(np.arange(1, seiten + 1), size=wuerfe, p=p)    else:        return np.random.choice(np.arange(1, seiten + 1), size=wuerfe)def muenze(wuerfe=2):    return np.random.choice(["W", "Z"], size=wuerfe)def lotto(zahlen, superzahl):    gezogen = np.random.choice(np.arange(1, 50), size=6)    gezogen_sz = np.random.choice(np.arange(0, 10), size=1)    anzahl_richtige = np.sum(zahlen == gezogen)    nicht_sz = "" if superzahl == gezogen_sz else "nicht "    ziehung_text = "Ziehung: " + str(gezogen) + ", Superzahl: " + str(gezogen_sz)    print(ziehung_text)    return "Anzahl Richtige: " + str(anzahl_richtige) + ", Superzahl stimmt " + nicht_sz + "überein"

Nun bauen wir die CLI zusammen. Dazu definieren wir uns für jeden Befehl (also den ersten Parameter) eine Funktion, der die Argumente args übergeben werden. Prinzipiell hätten wir auch in diesen Funktionen die eigentlichen Funktionen reinschreiben können, aber durch die Entkoppelung ist es zum einen übersichtlicher, zum anderen können die Funktionen oben auch innerhalb von Python genutzt werden.

def wuerfeln_fct(args):    print(wuerfeln(args.wuerfe, args.seiten, args.schummeln))def muenze_fct(args):    print(muenze(args.wuerfe))def lotto_fct(args):    print(lotto(args.zahlen, args.superzahl))

Jetzt folgt noch die eigentliche Definition des ArgumentParsers. Dazu fügen wir Subparser hinzu, für jeden Befehl einen. Diesen Subparsern geben wir die jeweiligen Argumente mit.  Beim lotto-Parser haben wir noch eine Besonderheit, nämlich nargs, das angibt, ob ein Parameter mehrfach vorhanden sein darf/muss, in unserem Fall sollen es genau 6 Zahlen sein. Neben Zahlen funktionieren auch „*“ für beliebig viele Argumente, „+“ für mindestens ein Argument und noch ein paar andere Möglichkeiten. Schaut doch mal in die Dokumentation von argparse, was es alles für Parameter von add_argument gibt.

parser = argparse.ArgumentParser(description="Glücksspiel")subparsers = parser.add_subparsers()# wuerfeln-Befehleparser_wuerfeln = subparsers.add_parser("wuerfeln", help="Simulation eines Würfels")parser_wuerfeln.add_argument("--wuerfe", "-w", type=int, default=1, help="Anzahl der Würfe")parser_wuerfeln.add_argument(    "--seiten", "-s", type=int, choices=[6, 12, 20], default=6, help="Seitenanzahl des Würfels")parser_wuerfeln.add_argument("--schummeln", "-sch", action="store_true", help="Schummel-Flag")parser_wuerfeln.set_defaults(func=wuerfeln_fct)# muenze-Befehlparser_muenze = subparsers.add_parser("muenze", help="Simulation von Münzwürfen")parser_muenze.add_argument("--wuerfe", "-w", type=int, default=1, help="Anzahl der Würfe")parser_muenze.set_defaults(func=muenze_fct)# lotto-Befehlparser_lotto = subparsers.add_parser("lotto", help="Simulation von Lotto 6 aus 49")parser_lotto.add_argument(    "--zahlen", "-z", type=int, nargs=6, choices=np.arange(1, 50), required=True, help="Deine 6 Zahlen")parser_lotto.add_argument(    "--superzahl", "-sz", type=int, choices=np.arange(0, 10), required=True, help="Superzahl")parser_lotto.set_defaults(func=lotto_fct)args = parser.parse_args()try:        args.func(args)except AttributeError:        parser.print_help()    parser.exit()

Noch eine kleine Besonderheit in den letzten Zeilen. Statt einfach nur args.func(args) zu verwenden, habe ich es in einen try-except-Block geschoben. Hintergrund ist, dass die Funktion einen Fehler ausgibt, wenn gar kein Argument übergeben wird, also einfach nur python glueck.py aufgerufen würde. Dann ist es aber für den Nutzer schöner, wenn die Hilfe angezeigt wird.

 Und was gibt es sonst noch

Habt Ihr immer noch nicht genug und wollt CLI-Experten werden. Ok, dann habe ich Euch noch ein paar Links zum Weiterlesen zusammengesucht. Es gibt auch einige Alternativen zu argparse, die Euch vielleicht besser gefallen bzw. andere Möglichkeiten haben:

  •  Das Manual zu argparse
  • Ein Artikel über CLI Best Practices von Cody A. Ray auf Medium (auch wenn ich Medium sonst nicht mag).
  • Click ist eine Alternative zu argparse mit einer etwas anderen Herangehensweise. Der Parser wird nämlich über decorators definiert.
  • Wollt ihr interaktive Text-Menüs? Dann schaut Euch mal den PyInquirer an, der dieses coole Feature ermöglicht.
  • Clint ist ein Package, welches Farben, Einrückungen etc. unterstützt.

So, das war’s jetzt aber. Ich hoffe, Ihr konntet die Beispiele gut nachvollziehen und brennt nun darauf, selber ein Kommandozeilen-Tool in Python zu bauen.

Happy coding,
Euer Holger

 

Photo by Dan LeFebvre on Unsplash