Was eine intuitiv bedienbare Oberfläche für grafische Anwendungen ist, sind bei Kommandozeilenprogrammen einprägsame Optionen. Die Implementation eines entsprechenden Parsers erfordert einiges Know-how.
Auf unixoiden Systemen haben sich eine Reihe von Konventionen für die Befehlszeile durchgesetzt. So erzeugt der Aufruf eines Befehls mit »-h« in aller Regel eine kurze Ausgabe, die die wichtigsten Kommandozeilenparameter erklärt.
Neben kurzen Optionen mit nur einem Buchstaben gibt es meist auch lange Optionen wie »–help«, die leichter im Gedächtnis bleiben. Die Eingabe von »–help« liefert üblicherweise eine Beschreibung der wichtigsten oder gar aller Optionen, die ein Befehl kennt. Diese Konvention geht allerdings auf das GNU-Projekt zurück und ist daher unter Linux weiter verbreitet als unter BSD.
Neben kurzen und langen Optionen gibt es auch solche, denen ein Argument folgt, zum Beispiel eine Pfadangabe, der Name des Empfängers oder ähnliches. Optionen und Argumente bilden zusammen die Kommandozeilenparameter. Übergibt der Anwender ungültige Kommandozeilenparameter, sollte er eine entsprechende Meldung erhalten.
Einen Parser zu implementieren, der alle Eingabemöglichkeiten auf der Kommandozeile richtig behandeln kann, nimmt schnell hundert Zeilen oder mehr in Anspruch, selbst wenn man auf den Befehl »getopt« zurückgreift. Deshalb wollen wir in diesem Beitrag das Modul »opt« schreiben, das uns diese Arbeit in Zukunft abnimmt.
|
Verteilte Systeme in der Bash (Teil 1) |
LM 06/2022, S. 76 |
|
|
Verteilte Systeme in der Bash (Teil 2) |
LM 07/2022, S. 82 |
|
|
Framework für automatisierte Tests |
LM 08/2022, S. 80 |
|
|
Komfortables Logging für die Bash |
LM 09/2022, S. 78 |
|
|
Kommandozeilenparser im Eigenbau |
LM**10/2022, S.**72 |
<U>https://www.lm-online.de/48240<U> |
Optionen lernen
Um die Befehlszeile effektiv parsen zu können, muss der Parser wissen, welche Optionen der Benutzer eingeben darf. Wir teilen das Modul daher in drei Funktionen auf, die der Reihe nach aufgerufen werden müssen. Die Deklaration der Optionen erfolgt in »opt_add()«, das Parsen der Befehlszeile übernimmt »opt_parse()«, »opt_get()« erledigt die Abfrage der Optionen.
Die Funktion »opt_add()« deklariert pro Aufruf eine Option. Wir übergeben ihr neben dem kurzen und langen Optionsnamen auch Attribute, einen Vorgabewert sowie eine kurze Beschreibung der Option. Die Attribute liefern wir als Zeichenkette mit Buchstaben wie »v« (für “verbose”) und »r« (für “required”). Beim Aufruf lässt sich festlegen, ob der Option ein Parameter folgt beziehungsweise ob der Parser einen Fehler ausgeben soll, wenn die Option nicht in der Befehlszeile auftaucht. Der Vorgabewert erlaubt dem Entwickler, sinnvolle Einstellungen vorzugeben, die der Benutzer bei Bedarf per Option überstimmen kann. Der Beschreibungstext wird angezeigt, wenn der Benutzer das Skript mit »-h« oder »–help« aufruft.
Für einen passablen Parser reicht das zwar schon aus, aber wir haben Größeres vor. Da es sehr aufwendig sein kann, die Gültigkeit aller Benutzereingaben einzeln zu überprüfen, wollen wir das ebenfalls dem Parser überlassen. Der Aufrufer soll daher optional einen regulären Ausdruck an »opt_add()« übergeben können, mit dem der Parser die Parameter schon beim Parsen validiert.
Reguläre Ausdrücke sind allerdings kein Allheilmittel, da sie nur Zeichenketten überprüfen. Ob ein Dateiname einem bestimmten Schema folgt, kann man mit ihnen zwar leicht feststellen, nicht aber, ob die Datei auch existiert. Um derartige Validierungen zu ermöglichen, soll der Aufrufer »opt_add()« auch den Namen einer Funktion übergeben können. Diese Funktion ruft der Parser immer dann auf, wenn er in der Befehlszeile auf die Option trifft.
Die Anforderungen hören sich zunächst kompliziert an. Im Wesentlichen muss »opt_add()« jedoch nicht viel mehr tun, als die vom Aufrufer übergebenen Werte zu validieren und in eine Reihe assoziativer Arrays einzufügen (Listing 1). Das wichtigste dieser Arrays ist »__opt_map«, das jeder Option einen Namen zuordnet. Im Fall der Hilfe-Option speichert es etwa hinter den Indizes »-h« und »–help« den Wert »help«. Letzterer dient wiederum als Index in die übrigen assoziativen Arrays, wenn das Skript Daten zur Option speichert oder abruft. Das erlaubt uns, viele Operationen in konstanter Zeit und ohne komplizierte Schleifen oder Verzweigungen zu erledigen.
Listing 1
opt_add() (Auszug)
opt_add() {
local short="$1"
local long="$2"
local attrs="$3"
local default="$4"
local desc="$5"
local regex="${6-.*}"
local callback="${7-true}"
if _opt_is_defined "-$short" "--$long" || ! _opt_attrs_valid "$attrs"; then
return 1
fi
__opt_map["-$short"]="$long"
__opt_map["--$long"]="$long"
__opt_short["$long"]="$short"
__opt_attrs["$long"]="$attrs"
__opt_regex["$long"]="$regex"
__opt_callback["$long"]="$callback"
if ! [[ "$attrs" =~ v ]]; then
__opt_value["$long"]=0
fi
if [[ "$attrs" =~ r ]]; then
__opt_required["$long"]="$long"
fi
if [[ -n "$default" ]]; then
__opt_default["$long"]="$default"
fi
return 0
}
Ganz ohne Verzweigungen kommen wir in dieser Funktion allerdings nicht aus. Falls eine Option keinen Parameter hat, drückt der Wert in »__opt_value« aus, wie oft die Option auf der Befehlszeile gefunden wurde, und muss deshalb mit 0 initialisiert werden. In das Array »__opt_required« braucht die Option nur dann eingefügt zu werden, wenn es sich um eine benötigte Option handelt. Den Wert in »__opt_default« setzen wir nur dann, wenn ein Standardwert übergeben wurde.
Dass wir die Arrays »__opt_regex« und »__opt_callback« ohne Prüfung setzen können, liegt daran, wie die Variablen »regex« und »callback« aus den Positionsparametern initialisiert werden: Der Ausdruck »ziel=”${quelle-alternative}”« weist der Variablen »ziel« den Wert aus »quelle« zu, sofern diese nicht leer ist. Anderenfalls erhält Ziel den Inhalt der Zeichenkette »alternative« zugewiesen. Auf diese Weise geben wir den Parametern der Funktion Standardwerte, die dann zum Zug kommen, wenn der Aufrufer keinen regulären Ausdruck oder kein Callback übergibt. Der reguläre Ausdruck ».*« und der Befehl »true« lassen alle Werte zu, was effektiv die Validierung von Parametern für die Option deaktiviert.
Damit lässt sich auch erahnen, was sich hinter den (in Listing 1 nicht abgedruckten) Hilfsfunktionen »_opt_is_defined()« und »_opt_attrs_valid()« verbirgt. Erstere testet, ob »__opt_map« die übergebenen Optionen bereits enthält, während letztere prüft, ob unbekannte Attribute übergeben wurden. Beide Funktionen geben bei Bedarf eine Fehlermeldung aus.
Befehlszeile parsen
Die Funktion »opt_parse()« (Listing 2) ist für das Verarbeiten der Befehlszeile zuständig. Der Aufrufer übergibt ihr die zu verarbeitenden Argumente, die sie in einer Schleife nacheinander durchgeht. Was in der Schleife passiert, hängt vom Inhalt der assoziativen Arrays ab.
Ist dem Argument kein Wert in »__opt_map« zugewiesen, handelt es sich um eine ungültige Option, und der Parser bricht vorzeitig mit einem Fehler ab. Ist die Option gültig, hängt das Verhalten von den Attributen der Option ab. Bei Optionen, die der Benutzer übergeben muss, entfernt der Parser den Eintrag der Option aus »__opt_required«. Hat die Option einen Parameter, interpretiert der Parser das nächste Argument (sofern vorhanden) als Wert der Option und validiert das Konstrukt mit dem regulären Ausdruck der Option. Schlägt die Validierung fehl oder wird kein Parameter gefunden, bricht der Parser mit einer Fehlermeldung ab.
Hat die Option keinen Parameter, so soll ihr Wert in »__opt_value« zählen, wie oft sie auf der Befehlszeile gefunden wurde. Der Parser inkrementiert also lediglich ihren Wert. Unabhängig von den Attributen der Option ruft er dann das Callback der Option auf. Mit einem Rückgabewert ungleich 0 signalisiert das Callback dem Parser, dass er vorzeitig abbrechen soll. Der Parser vergleicht daher den Rückgabewert des Callbacks und reicht ihn an den Aufrufer durch, falls das Callback nicht 0 zurückgeliefert hat. Erst ein erfolgreiches Callback schließt die Validierung der Option ab. Nun können wir den Wert in »__opt_value« aktualisieren und mit dem nächsten Argument fortfahren.
Ist die Schleife beendet, wurden alle Argumente erfolgreich abgearbeitet. Das heißt aber noch nicht, dass die Befehlszeile korrekt ist. Zum Schluss müssen wir noch prüfen, ob benötigte Optionen ausgelassen wurden. Das erledigt ein Aufruf der (nicht abgedruckten) Hilfsfunktion »_opt_have_required()«. Sie prüft lediglich, ob im Array »__opt_required« Einträge verblieben sind, und gibt für jeden eine Fehlermeldung aus.
Listing 2
opt_parse() (Auszug)
opt_parse() {
local args=("$@")
local -i i
for(( i = 0; i < ${#args[@]}; i++ )); do
local arg
local value
local long
local -i err
arg="${args[$i]}"
long="${__opt_map[$arg]}"
if [[ -z "$long" ]]; then
log_error "Ungueltige Option: $arg"
return 1
fi
if [[ "${__opt_attrs[$long]}" =~ r ]]; then
unset __opt_required["$long"]
fi
if [[ "${__opt_attrs[$long]}" =~ v ]]; then
(( i++ ))
if (( i >= ${#args[@]} )); then
log_error "Option $arg benoetigt einen Parameter"
return 1
fi
value="${args[$i]}"
if ! [[ "$value" =~ ${__opt_regex["$long"]} ]]; then
log_error "Ungueltiger Parameter fuer $arg: $value"
return 1
fi
else
value=$(( _opt_value["$long"] + 1 ))
fi
"${__opt_callback[$value]}" "$long" "$value"
err="$?"
if (( err != 0 )); then
return "$err"
fi
__opt_value["$long"]="$value"
done
if ! _opt_have_required; then
return 1
fi
return 0
}
Werte abfragen
Hat der Parser die Befehlszeile erfolgreich verarbeitet, kann der Benutzer die Werte der Optionen abfragen. Das Modul stellt zu diesem Zweck die Funktion »opt_get()« bereit. Sie bekommt als einziges Argument den langen Namen der Option übergeben, den sie ausgeben soll.
Das ist nicht ganz so trivial, wie es auf den ersten Blick scheint. So kann die übergebene Option ungültig sein. Selbst wenn sie korrekt ist, kann sie einen ungültigen Wert haben. Letzteres ist dann der Fall, wenn der Entwickler keinen Wert vorgegeben hat, aber auch keiner auf der Befehlszeile übergeben wurde. Ist beides nicht der Fall, hat die Option entweder vom Entwickler oder vom Benutzer einen Wert zugewiesen bekommen. Die Funktion gibt einen der beiden Werte aus, bevorzugt den vom Benutzer definierten.
Der Aufrufer soll außerdem am Rückgabewert die drei Fälle unterscheiden können. Für eine ungültige Option soll der Rückgabewert »1« sein. Ist die Option gültig, aber ihr Wert ungültig, lautet der Rückgabewert »2«. Bei Erfolg gibt die Funktion wie gewohnt »0« zurück. Wir schreiben »opt_get()« (Listing 3) also so, dass es zunächst die assoziativen Arrays »__opt_value« und »__opt_default« prüft und den gefundenen Wert zurückgibt. War der Wert in keinem der beiden Arrays enthalten, prüft die Funktion, ob die Option in »__opt_map« enthalten ist, und gibt entsprechend »1« oder »2« zurück.
Anders als beim Test von »__opt_map« müssen wir die Arrays »__opt_value« und »__opt_default« mithilfe von »array_contains()« durchsuchen, da das Konstrukt »[[ -n “$Variable” ]]« eine nicht gesetzte Variable nicht von einer leeren – und damit einem gültigen Wert für eine Option – unterscheiden kann.
Listing 3
opt_get()
opt_get() {
local long="$1"
if array_contains "$long" "${!__opt_value[@]}"; then
printf '%s\n' "${__opt_value[$long]}"
elif array_contains "$long" "${!__opt_default[@]}"; then
printf '%s\n' "${__opt_default[$long]}"
elif [[ -n "${__opt_map[--$long]}" ]]; then
return 2
else
return 1
fi
return 0
}
Erste Hilfe
Mit diesen drei Funktionen haben wir bereits fast alle der eingangs erwähnten Features implementiert. Es fehlt allerdings noch die Ausgabe der Hilfe, wenn der Benutzer »-h« oder »–help« auf der Befehlszeile übergibt.
Dieses Feature lässt sich aber mithilfe der soeben implementierten Callbacks schnell implementieren. Im Modulkonstruktor fügen wir dazu nach dem Initialisieren der assoziativen Arrays einen Aufruf von »opt_add()« ein, der die Hilfeoption deklariert:
opt_add "h" "help" "" 0 \
"Diesen Text ausgeben" \
"" _opt_help
Wir implementieren die Anzeige der Hilfe also in einer privaten Funktion »_opt_help()«, die der Parser aufruft, wenn entweder »-h« oder »–help« in der Befehlszeile auftaucht. Die Funktion ist dabei denkbar einfach: Sie iteriert in einer Schleife über die alphabetisch sortierte Liste der kurzen Optionen und gibt zu jeder den kurzen und den langen Namen sowie die Beschreibung durch Tabulatoren getrennt aus. Hat die Option einen vorgegebenen Wert, wird er in der nächsten Zeile unter der Beschreibung ausgegeben.
Die Tabulatoren alleine genügen aber noch nicht, um die Tabelle ordentlich auszurichten, da lange Optionsnamen länger als ein Tabstopp sein können. Wir verfüttern die Ausgabe der gesamten Schleife daher mit einer Pipe an den Befehl »column«, der für die korrekte Ausrichtung der Spalten sorgt (Listing 4).
Listing 4
_opt_help()
_opt_help() {
local short
echo "Aufruf: $0 [OPTIONEN]"
echo ""
echo "Optionen"
while read -r short; do
local long
long="${__opt_map[$short]}"
printf '\t-%s\t--%s\t%s\n' "$short" "$long" "${__opt_desc[$long]}"
if array_contains "$long" "${!__opt_default[@]}"; then
printf '\t\t\t%s\n' "${__opt_default[$long]}"
fi
done < <(array_sort "${__opt_short[@]}") | column -s $'\t' -t
return 2
}
Ausprobiert
Das damit fertiggestellte Modul speichern wir unter »/usr/local/share/bms/include/opt.sh«. Nun ist es an der Zeit, das Ergebnis unserer Arbeit zu testen. Als Erstes wollen wir wissen, ob der Hilfetext auch dann ordentlich aussieht, wenn wir Optionen mit unterschiedlich langen Namen deklariert haben.
Ein kleines Skript definiert dazu mit »opt_add()« mehrere Optionen, deren Namen teils kürzer und teils länger ausfallen als ein Tabstopp. Außerdem legen wir Optionen sowohl mit als auch ohne voreingestellte Werte fest. Zu guter Letzt ruft das Skript »opt_parse()« auf und liefert den Rückgabewert zurück (Listing 5). Das erstellte Skript führen wir nun mit dem Argument »–help« aus. Bei korrekter Implementation sollte die Ausgabe so aussehen wie in Abbildung 1.
Listing 5
Die Hilfe testen
#!/bin/bash
main() {
opt_add "k" "kurz" "" 0 "Eine kurze Option"
opt_add "l" "lange-option" "v" "hallo" "Eine lange Option mit Voreinstellung"
opt_add "L" "sehr-lange-option" "rv" "" "Eine lange Option ohne Voreinstellung"
opt_parse "$@"
}
{
if ! . bms.sh ||
! include "opt"; then
exit 1
fi
main "$@"
exit "$?"
}
Nun verändern wir das Skript aus Listing 5 so, dass es nach erfolgreichem Aufruf von »opt_parse()« die Werte der drei Optionen ausgibt. Dazu müssen wir den Rückgabewert der Funktion prüfen und drei Zeilen nach dem Schema »echo “kurz = $(opt_get “kurz”)”« an das Ende von »main()« anhängen. Abbildung 2 zeigt einige Ausgaben, wie sie beim Ausführen dieses Skripts mit verschiedenen Kombinationen von Parametern entstehen.
Isolierte Tests
In einem früheren Beitrag dieser Reihe haben wir bereits gesehen, dass ein einziger erfolgreicher Test noch nicht beweist, dass die Implementierung korrekt ist. Wir schreiben also auch dieses Mal Unittests, die das Modul auf Herz und Nieren prüfen. Dabei konzentrieren wir uns auf die wichtigsten Aspekte der drei vorgestellten Funktionen.
Beim Verhalten von »opt_add()« gibt es drei Punkte, die es zu überprüfen gilt. Zum einen soll die Funktion 0 zurückgeben, wenn eine Option erfolgreich deklariert wurde. Lässt sich die Option nicht definieren, zum Beispiel weil der kurze oder lange Name der Option bereits verwendet wird, liefert sie stattdessen 1 zurück. Zuletzt soll ein Testfall prüfen, ob die Buchstaben »r« und »v« in den Attributen akzeptiert beziehungsweise andere Buchstaben abgelehnt werden. Dazu bietet sich ein parametrisierter Test Case an.
Die Funktion »opt_parse()« kommt mit drei Testfällen nicht aus. Wir müssen nicht nur prüfen, ob die Funktion gültige Optionen akzeptiert und ungültige ablehnt, sondern auch Tests für die Behandlung von Parametern, benötigten Funktionen, regulären Ausdrücken und Callbacks implementieren. Hier benötigen wir mindestens zwei Testfälle pro genanntem Feature, wobei reguläre Ausdrücke und Callbacks ebenfalls durch jeweils einen parametrisierten Test Case geprüft werden. Für »opt_get()« brauchen wir jeweils einen Test Case für die drei Rückgabewerte und zwei weitere, die testen, ob der benutzerdefinierte beziehungsweise der vom Entwickler festlegte Wert korrekt ausgegeben werden.
Anders als unsere bisherigen Module verwendet »opt« globale Variablen, weshalb wir aufpassen müssen, dass die Tests sich nicht gegenseitig beeinflussen. Da Kindprozesse nicht die Variablen des Elternprozesses verändern können, genügt es aber, alle Tests in einer separaten Subshell laufen zu lassen. Dazu bedienen wir uns eines kleinen Tricks: Man kann eine Funktion auch mit runden anstelle von geschweiften Klammern schreiben. Das bewirkt, dass die Funktion immer in einer Subshell läuft.
Indem wir die Testfunktionen mit runden Klammern schreiben, verhindern wir Wechselwirkungen zwischen den Testfällen, unabhängig davon, wie Shell-Spec die Tests ausführt. Die Test Cases schreiben wir nun mithilfe von eingebetteten Hilfsfunktionen, wie im dritten Teil dieser Serie bereits behandelt. Listing 6 zeigt ein Beispiel für die Testfälle der Regex-Validierung von »opt_parse()«.
Listing 6
opt-spec.sh (Auszug)
Describe "opt_parse()"
It "gibt einen Fehler aus, wenn Regex-Validierung fehlschlaegt"
_test_opt_parse_regex_fail() (
opt_add "o" "opt" "rv" "" "" '^[0-9]+$'
opt_parse "-o" "abc"
)
When call _test_opt_parse_regex_fail
The status should equal 1
The stderr should not equal ""
End
It "gibt keinen Fehler aus, wenn Regex-Validierung erfolgreich ist"
_test_opt_parse_regex_pass() (
opt_add "o" "opt" "rv" "" "" '^[0-9]+$'
opt_parse "-o" "123"
)
When call _test_opt_parse_regex_pass
The status should equal 0
End
End
Das Specfile für Shell-Spec speichern wir nun unter »/usr/local/share/bms/test/opt_spec.sh« und führen es mit dem Befehl aus Listing 7 aus. Klappt alles, erhalten wir eine Ausgabe wie die in Abbildung 3 – und die Sicherheit, dass der Parser alle unsere Anforderungen erfüllt.
Listing 7
Specfile ausführen
$ shellspec --shell bash --format doc /usr/local/share/bms/test/opt_spec.sh
Fazit
Parser für die Befehlszeile sind unverzichtbar, aber auch nicht trivial zu implementieren. Dadurch, dass wir unseren Parser als Modul realisiert haben, geben wir allen unseren Skripten eine einheitliche Handhabung und sorgen dafür, dass wir diese Arbeit kein zweites Mal verrichten müssen. In der nächsten Folge dieser Serie schaffen wir die Grundlagen für parallel laufende Skripte, indem wir mithilfe von Locks und Semaphoren für eine sichere Verwendung geteilter Ressourcen sorgen. (jcb/jlu)







