Hace un tiempo encontré el repositorio yaacov/argparse-sh, del autor del artículo argparse.sh: Simple Yet Powerful Bash argument parsing.
Como indica en el post, argparse-sh
es una forma sencilla y potente de gestionar parámetros en Bash.
La idea en la que se basa, creando un array asociativo (un dictionary
en Python), me recordó a Cobra, en Go.
Pese a ser una library pequeña, permite definir parámetros obligatorios y opcionales, un mensaje de ayuda para cada parámetro, diferentes tipos de parámetros…
La “única pega” es que, debido a que usa arrays asociativos, require Bash 4 o superior.
Así que me puse manos a la obra para hacer un backport y hacerlo compatible con Bash 3 (la que hay por defecto en los Mac).
Objetivo
argparse
permite:
- definir parámetros
- asignar un valor por defecto a los parámetros
- especificar el tipo de parámetro
- configurar un mensaje de ayuda específico para cada parámetro
- indicar si el parámetro es requerido u opcional
Grosso modo, argparse
define dos funciones define_arg
y parse_args
.
Empezamos creando la versión mínima compatible con Bash 3 de define_arg
:
define_arg() {
arg_name=$1
arg_value=${2:-""}
export "$arg_name"="$arg_value"
}
Para ir probando el desarrollo, creamos un script de prueba:
#!/usr/bin/env bash
source argsparse3.sh
define_arg "name" "xavi"
define_arg "branch"
echo "my name is $name"
echo "branch is: $branch"
Si ejecutamos el script de prueba, el resultado es:
my name is xavi
branch is: # <-- el valor por defecto es ''
De momento, podemos definir parámetros para el script y darles un valor por defecto.
parse_args
El objetivo de parse_args
es procesar los parámetros pasados desde la línea de comandos.
Como antes, definimos una función mínima:
parse_args() {
while [[ $# -gt 0 ]]; do
key="${1#--}" # remove '--' prefix
if [[ -z "$2" || "$2" == --* ]]; then
echo "Missing value for argument --$key"
exit 1
else
export "$key"="$2"
shift 2
fi
done
}
La incluimos en el script de prueba:
#!/usr/bin/env bash
source argsparse3.sh
define_arg "name" "xavi"
define_arg "branch"
parse_args "$@"
echo "my name is $name"
echo "branch is: $branch"
Vemos que funciona correctamente:
$ bash test_argparse3.sh --name federico --branch main
my name is federico
branch is: main
Tipos de parámetros
En argsparse.sh
, una de las propiedades de los parámetros es la llamada action
.
IMO, sería mejor llamarlo type
, por ejemplo, pues define el tipo del parámetro, teniendo sólo dos opciones: string
y store_true
. Ésta segunda opción es para arguments de tipo bool
; por defecto, su valor es false
y si se explicita el flag
, entonces su valor es true
.
Por ese motivo, llamaré a esta propiedad type
(en vez de mantener action
).
A nivel funcional, un parámetro de tipo string
tiene que ir seguido del valor que se quiere asignar a ese parámetro. Por ejemplo --name xavi
.
En el segundo caso, de tipo bool
, el valor de un parámetro force
es false
por defecto. Cuando se explicita el parámetro, entonces el valor asociado es true
. Como la mera presencia del parámetro -que este caso suele llamarse flag
- indica el valor, no va seguido del valor. Como ejemplo, <cmd> --name xavi
(por omisión, el valor de force
es false
), mientras que <cmd> --name xavi --force
indica que force
es true
.
Comunicando define_arg
y parse_args
Las propiedades de los parámetros se definen en define_arg
pero se consumen en parse_args
.
Por tanto, necesito poder comunicar las propiedades de un parámetro definido en una función en otra.
Siguiendo con la idea de argsparse.sh
, defino una list global ARGS_PROPERTIES
:
ARGS_PROPERTIES=()
Ahora, actualizamos define_arg
para añadir las propiedades que se definan a esta lista global.
define_arg() {
arg_name=$1
arg_value=${2:-""}
arg_type=${3:-"string"}
ARGS_PROPERTIES+=("$arg_name" "$arg_value" "$arg_type")
echo "DEBUG: ${ARGS_PROPERTIES[*]}, ${#ARGS_PROPERTIES[@]}"
for a in ${ARGS_PROPERTIES[@]}; do echo "this is item: '$a'"; done
export "$arg_name"="$arg_value"
}
El problema que tenemos ahora es que si el valor por defecto de una variable es “”, no podemos identificar el valor de un “espacio” normal (incluso si definimos el valor como " “, con un espacio explícito).
$ bash test_argparse3.sh --branch main
DEBUG: name xavi string, 3
this is item: 'name'
this is item: 'xavi'
this is item: 'string'
DEBUG: name xavi string branch string, 6
this is item: 'name'
this is item: 'xavi'
this is item: 'string'
this is item: 'branch'
this is item: 'string'
my name is xavi
branch is: main
Por tanto, tendremos que usar algún tipo de placeholder para indicar que el valor de un argumento es la cadena “vacía”.
Esto sólo tiene sentido para parámetros de tipo
string
; el valor por defecto de un parámetro de tipobool
siempre serátrue
ofalse
, pero no puede estar “vacío”.
En vez de usar una cadena fija, definimos una variable _NULL_VALUE_
de manera que se pueda personalizar el valor del placeholder.
También actualizamos la asignación del valor por defecto a esta variable:
arg_value=${2:-"$_NULL_VALUE_"}
De esta forma, el valor vacío sí que se almacena correctamente en ARGS_PROPERTIES
.
$ bash test_argparse3.sh --branch main
DEBUG: name xavi string, 3
this is item: 'name'
this is item: 'xavi'
this is item: 'string'
DEBUG: name xavi string branch null string, 6
this is item: 'name'
this is item: 'xavi'
this is item: 'string'
this is item: 'branch'
this is item: 'null'
this is item: 'string'
my name is xavi
branch is: main
Ahora que tenemos un tipo para cada parámetro, tenemos que usarlo en parse_args
.
Como ARGS_PROPERTIES
es una lista y no un array asociativo, no podemos acceder a un elemento concreto a través del nombre del parámetro.
Tenemos que buscar en qué posición se encuentra el parámetro pasado desde la CLI en la lista de ARGS_PROPERTIES
, y así poder identificar de qué tipo es.
Iteramos sobre los elementos de ARGS_PROPERTIES
y comparamos con el parámetro pasado desde la CLI; si lo encontramos, miramos el type, que se encuentra dos posiciones más allá (si no lo encontramos, salimos del bucle con break
).
En función del tipo del argument, asignamos el siguiente valor en $@
o true
, si se trata de un parámetro de tipo bool
.
Valores “nulos”
Hemos decidido utilizar un placeholder para indicar valores vacíos por defecto en ARGS_PROPERTIES
.
Pero eso significa que si usamos null
para indicar que el valor por defecto de una variable es la cadena vacía (""
), tenemos que asignar el valor ""
a la variable, no el placeholder, como sucede ahora:
$ bash test_argparse3.sh --branch main --force --patata
my name is null # <-- Should not show the 'placeholder' for empty value
branch is: main
force is: true
Lo solucionamos añadiendo el siguiente condicional en define_arg
:
# ...
if [[ "$arg_value" == "$_NULL_VALUE_" ]]; then
arg_value=""
fi
export "$arg_name"="$arg_value"
}
Así, el ARGS_PROPERTIES
usamos el placeholder, pero asignamos a la variable el valor correcto, la cadena vacía.
$ bash test_argparse3.sh --branch main --force --patata
my name is
branch is: main
force is: true
Parámetros requeridos
La idea de que un parámetro sea requerido implica que el usuario tiene que incluirlo al llamar al script. Pero cuando se define un parámetro, se puede especificar un valor por defecto, por lo que incluso si no se proporciona, el valor está definido (y tiene un valor).
Por tanto, para un parámetro requerido, debe ignorarse el valor por defecto proporcionado, cualquiera que sea, al definirlo.
define_arg() {
arg_name=$1
arg_value=${2:-"$_NULL_VALUE_"}
arg_type=${3:-"string"}
arg_required=${4:-"no"}
if [[ "$arg_required" == "required" ]]; then
arg_value="$_NULL_VALUE_"
fi
ARGS_PROPERTIES+=("$arg_name" "$arg_value" "$arg_type" "$arg_required")
if [[ "$arg_value" == "$_NULL_VALUE_" ]]; then
arg_value=""
fi
export "$arg_name"="$arg_value"
}
Verificando si el parámetro es requerido en define_arg
, establecemos su valor como el valor nulo, sobrescribiendo el valor que se pueda haber indicado al definir el parámetro.
# define_arg "name" "xavi" "string" "required"
$ bash test_argpars3.sh
my name is # <-- Aunque se define el valor por defecto 'xavi', se ignora al ser requerido
branch is:
force is: false
¿Cómo indicar que el valor es requerido?
Usamos parámetros posicionales para indicar las diferentes propiedades de un parámetro en define_arg
, por lo que prefiero usar required
para indicar que un parámetro es requerido. Para aquellos parámetros que no lo son, usamos el valor (por defecto) optional
.
Si no se proporciona alguno de los valores requeridos, el script debe finalizar. Por tanto, obtenemos la lista de valores definidos que son requeridos y examinamos los pasados desde la CLI para identificar si hay alguno que no está presente; en ese caso, el script finaliza.
parse_args() {
# Check for missing required arguments
for ((i=0; i<${#ARGS_PROPERTIES[@]}; i+=4)); do
if [[ "${ARGS_PROPERTIES[i+_REQUIRED_]}" == "required" ]]; then
if ! (echo "$@" | grep "${ARGS_PROPERTIES[i]}") >/dev/null ; then
echo "'${ARGS_PROPERTIES[i]}' is required"
exit 1
fi
fi
done
# ...
Así, ahora:
$ bash test_argparse3.sh
'name' is required
El script finaliza en cuanto se identifica un parámetro que requerido que no se ha proporcionado, en vez de analizar todos los parámetros requeridos que faltan.
Ayuda
argsparse.sh
incluye una función show_help
que muestra la cadena de ayuda asociada a cada parámetro cuando se ejecuta el script añadiendo el flag -h
o --help
.
Empezamos por añadir la propiedad de ayuda a cada parámetro actualizando la función define_arg
:
define_arg() {
arg_name=$1
arg_value=${2:-"$_NULL_VALUE_"}
arg_type=${3:-"string"}
arg_required=${4:-"no"}
arg_help=${5:-""}
if [[ "$arg_required" == "required" ]]; then
arg_value="$_NULL_VALUE_"
fi
ARGS_PROPERTIES+=("$arg_name" "$arg_value" "$arg_type" "$arg_required" "$arg_help")
if [[ "$arg_value" == "$_NULL_VALUE_" ]]; then
arg_value=""
fi
export "$arg_name"="$arg_value"
}
Si el usuario del script pasa el flag de ayuda, no está interesado en ejecutarlo, por lo que haremos la comprobación de si se ha pasado -h
o --help
antes de hacer cualquier otra cosa (por ejemplo, de si falta alguno de los parámetros requeridos).
Empezamos probado la idea con:
show_help() {
if (echo "$@" | grep -- "-h" > /dev/null) || (echo "$@" | grep -- "--help" > /dev/null); then
echo "Help!"
exit 0
fi
}
Y vemos que, efectivamente, funciona:
$ bash test_argparse3.sh --help
Help!
$ bash test_argparse3.sh -h
Help!
Ahora lo único que tenemos que hacer es sustituir echo "Help!"
por un bucle que muestre información asociada a los diferentes parámetros definidos para el script.
show_help() {
local args=( "${ARGS_PROPERTIES[@]}" )
local prefix=" "
if (echo "$@" | grep -- "-h" > /dev/null) || (echo "$@" | grep -- "--help" > /dev/null); then
echo "usage: $0 [arguments...]"
echo "$SCRIPT_DESCRIPTION"
echo ""
echo "arguments:"
for ((i=0; i<${#args[@]}; i+=5)); do
[[ ${args[i+_DEFAULT_]} == "$_NULL_VALUE_" ]] && args[i+_DEFAULT_]=''
if [[ ${args[i+_TYPE_]} == "bool" ]]; then args[i+_TYPE_]=''; args[i+_HELP_]="${args[i+_HELP_]} $FLAG_BEHAVIOUR"; else args[i+_TYPE_]="<${args[i+_TYPE_]}>" ; fi
printf "%s %-20s: (%8s) %s\n" "$prefix" "--${args[i]} ${args[i+_TYPE_]}" "${args[i+_REQUIRED_]}" "${args[i+_HELP_]} (defaults to '${args[i+_DEFAULT_]}')"
done
printf "\n%s %-20s: Display this help\n" "$prefix" "-h | --help"
exit 0
fi
}
Tal y como hemos definido la función show_help
, sólo se muestra el mensaje si el usuario añade -h
o --help
.
Para poder mostrar el menjaje de ayuda también si se produce un error (por ejemplo, que no se encuentra un parámetro required), separamos la comprobación de la acción de mostrar el mensaje de ayuda:
parse_args() {
# Check for 'help' flags
if (echo "$@" | grep -- "-h" > /dev/null) || (echo "$@" | grep -- "--help" > /dev/null); then
show_help
exit 0
fi
# ...
Y la función show_help
:
show_help() {
local args=( "${ARGS_PROPERTIES[@]}" )
local prefix=" "
echo -e "\nusage: $0 [arguments...]"
echo "$SCRIPT_DESCRIPTION"
echo ""
echo "arguments:"
for ((i=0; i<${#args[@]}; i+=5)); do
[[ ${args[i+_DEFAULT_]} == "$_NULL_VALUE_" ]] && args[i+_DEFAULT_]=''
if [[ ${args[i+_TYPE_]} == "bool" ]]; then args[i+_TYPE_]=''; args[i+_HELP_]="${args[i+_HELP_]} $FLAG_BEHAVIOUR"; else args[i+_TYPE_]="<${args[i+_TYPE_]}>" ; fi
printf "%s %-20s: (%8s) %s\n" "$prefix" "--${args[i]} ${args[i+_TYPE_]}" "${args[i+_REQUIRED_]}" "${args[i+_HELP_]} (defaults to '${args[i+_DEFAULT_]}')"
done
printf "\n%s %-20s: Display this help\n" "$prefix" "-h | --help"
}
Finalmente, en la función check_required
:
check_required() {
for ((i=0; i<${#ARGS_PROPERTIES[@]}; i+=5)); do
if [[ "${ARGS_PROPERTIES[i+_REQUIRED_]}" == "required" ]]; then
if ! (echo "$@" | grep "${ARGS_PROPERTIES[i]}") >/dev/null ; then
echo "'${ARGS_PROPERTIES[i]}' is required"
show_help
exit 1
fi
fi
done
}
Ahora, si el usuario no proporciona uno de los parámetros requeridos:
$ bash test_argparse3.sh --branch main
'--name' is required
usage: test_argparse3.sh [arguments...]
argsparse3.sh is a Bash 3 library for defining script arguments
arguments:
--name <string> : (required) Provides the name of the person executing the script. (defaults to '')
--branch <string> : (optional) Name of the repository branch. (defaults to '')
--force : (optional) Always push changes. Add the flag to set the argument to 'true' (don't use '--flag true') (defaults to 'false')
-h | --help : Display this help
Conclusión
Con argparse3.sh
he podido implementar la misma funcionalidad incluida en argparse.sh
, pero haciéndola retro-compatible con Bash 3.