12 octubre 2021

Cómo procesar los comandos de consola de tus aplicaciones Python usando Argparse

Todas las aplicaciones de consola tienen algo en común: tienen que procesar los argumentos que introduce el usuario al teclear el comando que lanza la aplicación. Pocas aplicaciones de consola carecewn de argumentos, la mayoría los necesitan para funcionar adecuadamente. Piensa por ejemplo en el comando ping: necesita al menos un argumento, la dirección IP o la URL a la que se le hace ping.

dante@Camelot:~/$ ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=113 time=3.73 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=113 time=3.83 ms
64 bytes from 8.8.8.8: icmp_seq=3 ttl=113 time=3.92 ms
^C
--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2005ms
rtt min/avg/max/mdev = 3.733/3.828/3.920/0.076 ms

dante@Camelot:~$

Cuando ejecutas una aplicación, te llegan en la lista sys.argv todos los argumentos que usuario introdujo cuando llamó a tu aplicación desde la consola. Ese comando y sus argumentos se divide  en tokens, usando los espacios en blanco como separadores, siendo cada token un elemento de la lista sys.argv. El primer elemento de esa lista es el nombre de tu aplicación (el comando en si). Por eso, si por ejemplo hemos implementado nuestra propia versión del comando ping, el elemento 0 de sys.argv será "ping" y el elemento en la posición 1 será la IP, por ejemplo "8.8.8.8".

Podrías hacer tu propio análisis de los argumentos revisando por ti mismo el contenido de sys.argv. La mayor parte de los desarrolladores lo hacen así... al principio. Si lo haces así, pronto acabas dándote cuenta de que no es tan fácil gestionar de manera limpia los argumentos de usuario y encima acabas repitiendo muchísimo código en tus diferentes aplicaciones, por eso hay librerías pensadas para analizar por ti los argumentos de usuario. Una que me encanta es Argparse.

Argparse es un módulo incluido de serie en la distribución estándar de Python, así que no hay que descargarlo de Pypi. Una vez que se comprenden sus conceptos resulta fácil, muy flexible y gestiona por ti muchas casuísticas nada evidentes a priori. Es uno de esos módulos que echas de menos cuando programas en otros lenguajes.

El primer concepto a comprender de Argparser es precisamente el de Argument Parser. Para este módulo, un Argument Parser es una palabra fija que está seguida por argumentos de usuario posicionales u opcionales. El Argument Parser por defecto es precisamente el nombre de la aplicación que ejerce de comando. En nuestro ejemplo, el comando "ping" es un Argument Parser. Cada Argument Parser puede verse acompañado de Arguments, los cuales pueden ser de dos tipos:

  • Argumentos posicionales: Son aquellos obligatorios para el usuario. El usuario tiene que introducirlos o el comando asume que la orden es incorrecta y aborta la ejecución. Este tipo de argumentos tiene que introducirse en un orden específico. En nuestro ejemplo del comando ping "8.8.8.8" es un argumento posicional. Podemos encontrar otros ejemplos, el comando "cp" por ejemplo necesita dos argumentos posicionales: el fichero origen de la copia y el fichero de destino.
  • Argumentos opcionales: Pueden ser introducidos o no. Se marcan con etiquetas. Las etiqueta sabreviadas usan un guión y un carácter, mientras que las etiquetas largas usan un guión doble y una palabra. Algunos argumentos opcionales admiten un valor y otros no (son booleanos, true si se usan o false si no).
Aquí se pueden ver algunos de los argumentos que acepta el comando "cat":

dante@Camelot:~/$ cat --help
Usage: cat [OPTION]... [FILE]...
Concatenate FILE(s) to standard output.

With no FILE, or when FILE is -, read standard input.

-A, --show-all equivalent to -vET
-b, --number-nonblank number nonempty output lines, overrides -n
-e equivalent to -vE
-E, --show-ends display $ at end of each line
-n, --number number all output lines
-s, --squeeze-blank suppress repeated empty output lines
-t equivalent to -vT
-T, --show-tabs display TAB characters as ^I
-u (ignored)
-v, --show-nonprinting use ^ and M- notation, except for LFD and TAB
--help display this help and exit
--version output version information and exit

Examples:
cat f - g Output f's contents, then standard input, then g's contents.
cat Copy standard input to standard output.

GNU coreutils online help: <https://www.gnu.org/software/coreutils/>
Full documentation at: <https://www.gnu.org/software/coreutils/cat>
or available locally via: info '(coreutils) cat invocation'

dante@Camelot:~$

Hay comandos más complejos que incluyen lo que se denomina Subparsers. Los subaparsers aparecen cuando nuestro comando incluye otras palabras "fijas", como verbos. Por ejemplo, el comando "docker" tiene muchos subparser: "docker build", "docker run" o "docker ps". Cada subparser puede aceptar su propio conjunto de argumentos. Un subparser puede incluir a otros subparsers, formando subparser anidados y dando lugar a árboles de comandos.

Para mostrar como usar el módulo argparse, voy a usar como ejemplo my propia configuración para la aplicación Cifra. En concreto usaremos su fichero cifra_launcher.py file en GitHub.

En su sección __main__ se puede ver cómo se procesan los argumentos a alto nivel:

Como se puede ver esatamos usando sys.argv para obtener los argumentos facilitados por el usuario, descartando el primero ("[1:]") porque es el comando raiz mismo: "cifra".

Me gusta usar sys.argv como parámetro por defecto de main() porque así puedo llamar a main() desde mis test funcionales, usando una lista específica de argumentos.

Los argumentos se pasan a la función parse_arguments() que es la función que hace toda la magia de argparse. Allí se puede ver la configuración base de argparse:

Ahí se puede configurar el parser base, el que se enlaza al comando raiz, definiendo la descripción del comando y una nota final ("epilog") para el comando. Esos textos aparecerán cuando el usuario llame al comando con el argumento "--help".

Mi aplicación cifra tiene algunos subparsers, al igual que hace docker. Para permitir que un parser albergue subparsers, hay que hacer:

Una vez que has permitido que tu parser tenga subparsers, ya se puede empezar a crear esos subparser. Cifra tiene 4 subparsers en ese nivel: 

 

 

Luego veremos que argparse devuelve un objeto similar a un diccionario tras realizar su labor de parseo. Con el código anterior, si por ejemplo el usuario introdujo "cifra dictionary" entonces el objeto similar a un diccionario tendrá una clave "mode" con un valor "dictionary".

Cada subparser puede tener a su vez subparsers, en realidad en nuestro código de ejemplo el subparser "dictionary" tiene más subparsers que añaden más ramas al árbol del comando. De esa manera se puede llamar a "cifra dictionary create", "cifra dictionary update", "cifra ditionary delete", etc.

Una vez que llegas a la conclusión de que una rama ha llegado a su máxima profundidad y no necesita más subparsers, se pueden añadir sus respectivos argumentos. Para el subparser de "cipher" tenemos los siguientes argumentos:


En estos argumentos, "algorithm" y "key" son posicionales y por lo tanto obligatorios. Los argumentos que se sitúen en esas posiciones serán los valores de las claves "algorithm" y "key" del objeto devuelto por argparse.

El parámetro metavar es la cadena que queremos que se use para representar este parámetro cuando se llame a la ayuda con el argumento --help. Como te puedes imaginar, el parámetro help es la cadena de ayuda que sale para explicar este argumento cuando se llama a la ayuda con --help.

El parámetro type se usa para preprocesar el argumento facilitado por el susuario. Por defecto, los argumentos se interpretan como strings, pero si se usa type=int entonces se convertirán a int (o se lanzaría un error si la conversión resultase imposible). En realidad str o int son funciones, así que se pueden pasar los nombres de tus propias funciones de conversión a type. Por ejemplo, para el parámetro file_to_cipher yo he usado mi propia función _check_is_file().

Como se puede ver, esta función comprueba si los argumentos facilitados se corresponde con una ruta de ficheros correcta y de ser así devuelve la cadena de la ruta o un error si la ruta fuese incorrecta.

Recuerda que los parámetros opcionales siempre vienen precedidos de guiones. Si esos parámetros están seguidos de un argumento dado por el usuario, ese argumento será el valor para la clave, denominada como el nombre largo del parámetro, en el objeto tipo diccionario devuelto por argparser. En nuestro ejemplo, la línea 175 significa que cuando el usuario teclea "--ciphered_file myfile.txt", argparser devolverá una clave llamada "ciphered_file" con el valor  "myfile.txt".

A veces los parámetros opcionales no necesitarán valores. Pr ejemplo, podrían ser simples parámetros booleanos para activar una salida detallada:

parser.add_argument("--verbose", help="increase output verbosity", action="store_true", default=False)

Con action="store_true" el objeto tipo diccionario tendrá la clave "verbose" asociada con un valor booleano: verdadero si se usó --verbose o falso en caso contrario.

Antes de devolver de salir de parse_arguments() me gusta filtrar el contenido del diccionario a devolver:


En esta sección de código parsed_arguments() es el objeto tipo diccionario que devuelve argparse tras procesar los argumentos del usuario con arg_parser.parse_args(args). Este objeto incluye también, con valor None, todos los argumentos opcionales que se podrían haber introducido pero que al final no se metieron. Podría devolver directamente este objeto y luego ir comprobando parámetro a parámetro si es None o no, pero me parece más limpio quitar todas las claves que sean None y luego limitarme a comprbar si cada clave está presente o no. Por eso filtro el objeto diccionario en la línea 235.

De vuelta a la función main(), tras procesar los argumentos del usuario se puede desarrollar el resto de la lógica de la aplicación dependiendo del contenido del diccionario de argumentos. Por ejemplo, para cifra:

 De esta manera, argparser te permite procesar los argumentos del usuario de una manera limpia y que te ofrece también por el mismo "precio" (gratis) mensajes de ayuda y de error, dependiendo de si el usuario llamó a --help o introdujo un comando erroneo o equivocado.En realidad, los mensajes generados son tan limpios que los uso para el redme.md de mi repositorio y conforma el punto de partida para generar mis páginas de man (aunque eso ya es material para otro artículo).