Saltar al contenido
🐍 Curso de Python
/

Módulo 2 · Lección 10 de 28

Opciones y banderas

Argumentos con guion que se pueden omitir: nombres cortos y largos, valores por defecto, interruptores, contadores y listas.

⏱ 23 min de lectura

Los argumentos posicionales de la lección anterior son obligatorios y van en un orden fijo. Eso está bien para lo esencial de un programa, el fichero que hay que procesar o la cantidad que hay que convertir, pero no para todo lo demás: si quieres elegir el formato, cambiar la carpeta de destino o pedir que hable más, no puedes obligar a escribirlo todo siempre y en el mismo orden.

Para eso están las opciones, los argumentos que empiezan por guion. Se reconocen por su nombre y no por su sitio, se pueden poner en cualquier posición de la línea y, salvo que digas lo contrario, se pueden omitir. Es la diferencia entre cp origen destino, donde el orden lo es todo, y cp -r --verbose origen destino, donde -r y --verbose van donde quieras.

Posicional u opción: lo decide el guion#

La regla es literalmente esa. Si el nombre que le pasas a add_argument empieza por guion, es una opción; si no, es un posicional.

exportar.py
"""Exporta un fichero a otro formato."""

import argparse

analizador = argparse.ArgumentParser(description="Exporta un fichero a otro formato.")
analizador.add_argument("entrada", help="fichero que hay que exportar")
analizador.add_argument("-s", "--salida", help="fichero donde escribir el resultado")
argumentos = analizador.parse_args()

print(f"entrada = {argumentos.entrada!r}")
print(f"salida  = {argumentos.salida!r}")

La ayuda ya distingue los dos apartados:

bash
python3 exportar.py -h
Salida en pantalla
usage: exportar.py [-h] [-s SALIDA] entrada

Exporta un fichero a otro formato.

positional arguments:
  entrada              fichero que hay que exportar

options:
  -h, --help           show this help message and exit
  -s, --salida SALIDA  fichero donde escribir el resultado

Fíjate en la línea usage:: [-s SALIDA] entre corchetes, entrada sin ellos. Los corchetes son la forma universal de decir "esto se puede omitir".

Y si se omite, el atributo existe igualmente y vale None:

bash
python3 exportar.py datos.csv
Salida en pantalla
entrada = 'datos.csv'
salida  = None

Nombre corto y nombre largo#

A add_argument le puedes dar varias formas de la misma opción. La costumbre es dos:

  • La corta, un solo guion y una sola letra: -s. Para escribir rápido a diario.
  • La larga, dos guiones y una palabra: --salida. Para que se entienda al leerla en un script seis meses después.

Las cuatro invocaciones siguientes son idénticas para argparse:

bash
python3 exportar.py datos.csv --salida informe.json
python3 exportar.py datos.csv -s informe.json
python3 exportar.py --salida informe.json datos.csv
python3 exportar.py datos.csv --salida=informe.json
Salida en pantalla
entrada = 'datos.csv'
salida  = 'informe.json'
entrada = 'datos.csv'
salida  = 'informe.json'
entrada = 'datos.csv'
salida  = 'informe.json'
entrada = 'datos.csv'
salida  = 'informe.json'

El valor puede ir separado por un espacio o pegado con un = en la forma larga. Con la forma corta también se puede pegar directamente, -sinforme.json, aunque eso se lee fatal y no conviene usarlo.

Lo que no puede faltar es el valor: una opción declarada así exige uno detrás.

bash
python3 exportar.py -s
Salida en pantalla
usage: exportar.py [-h] [-s SALIDA] entrada
exportar.py: error: argument -s/--salida: expected one argument

Cómo se llama el atributo#

argparse deduce el nombre del atributo de la forma larga, o de la corta si no hay larga, quitando los guiones del principio. Y hay una conversión que sorprende la primera vez: los guiones interiores se cambian por guiones bajos, porque un guion no puede formar parte de un nombre de variable en Python.

nombres.py
import argparse

analizador = argparse.ArgumentParser(description="Cómo se nombran los atributos.")
analizador.add_argument("--nombre-fichero", help="con guion en medio")
analizador.add_argument("-n", "--numero-de-lineas", help="corta y larga")
analizador.add_argument("--dir", dest="directorio", help="nombre del atributo cambiado")
argumentos = analizador.parse_args()

print(vars(argumentos))
print(argumentos.nombre_fichero)
print(argumentos.numero_de_lineas)
print(argumentos.directorio)
bash
python3 nombres.py --nombre-fichero informe.md -n 20 --dir /tmp
Salida en pantalla
{'nombre_fichero': 'informe.md', 'numero_de_lineas': '20', 'directorio': '/tmp'}
informe.md
20
/tmp

En la línea de comandos se escribe --nombre-fichero, con guion, porque es lo que espera quien usa herramientas de terminal. En el código se lee argumentos.nombre_fichero, con guion bajo, porque es lo que exige Python. La traducción la hace argparse y no tienes que pensar en ella, solo recordarla.

El tercer argumento enseña el parámetro dest, que rompe esa deducción y fija el nombre del atributo a mano. Se usa en dos situaciones:

  • Cuando la opción es corta o abreviada, como --dir, y quieres un nombre legible dentro del código.
  • Cuando el nombre deducido chocaría con una palabra reservada de Python. --class daría argumentos.class, que es sintaxis inválida; con dest="clase" se arregla.

Valores por defecto#

Un None es incómodo: obliga a comprobarlo antes de usar el valor. Casi siempre es mejor dar un valor sensato con default:

python
analizador.add_argument("-p", "--patron", default="*",
                        help="patrón de nombre que deben cumplir los ficheros")

Con eso, argumentos.patron vale "*" cuando nadie escribe la opción, y el resto del programa no tiene que preguntarse nada. La regla práctica: usa default siempre que exista un comportamiento razonable por omisión, y deja None solo cuando "no lo han dicho" sea una situación de verdad distinta de cualquier valor posible.

Banderas: opciones sin valor#

Una bandera es una opción que no lleva valor detrás. Está o no está, y eso es todo. Se declaran con action:

respaldo.py
import argparse

analizador = argparse.ArgumentParser(description="Sincroniza una carpeta.")
analizador.add_argument("--forzar", action="store_true", help="sobrescribir sin preguntar")
analizador.add_argument("--sin-copia", action="store_false", dest="copia",
                        help="no crear copia de seguridad antes de escribir")
argumentos = analizador.parse_args()

print(f"forzar = {argumentos.forzar}")
print(f"copia  = {argumentos.copia}")
bash
python3 respaldo.py
Salida en pantalla
forzar = False
copia  = True
bash
python3 respaldo.py --forzar --sin-copia
Salida en pantalla
forzar = True
copia  = False

Las dos acciones son simétricas:

  • store_true: el valor por defecto es False y escribir la opción lo pone a True. Es el caso normal, para activar algo que de serie está apagado.
  • store_false: el valor por defecto es True y escribir la opción lo pone a False. Sirve para apagar algo que de serie está encendido.

Con store_false casi siempre querrás un dest. Sin él, la opción --sin-copia produciría el atributo argumentos.sin_copia, y tendrías que escribir if not argumentos.sin_copia:, una doble negación que se lee mal. Con dest="copia" el código queda if argumentos.copia:, que dice lo que hace.

Contar repeticiones con action="count"#

Casi todas las herramientas de terminal usan -v para hablar más y -vvv para hablar mucho más. Eso se consigue con count, que suma uno por cada vez que aparece la opción:

detalle.py
import argparse

NIVELES = ["solo errores", "normal", "detallado", "depuración"]

analizador = argparse.ArgumentParser(description="Niveles de detalle con -v.")
analizador.add_argument("-v", "--verboso", action="count", default=0,
                        help="sube el nivel de detalle, se puede repetir")
argumentos = analizador.parse_args()

nivel = min(argumentos.verboso, len(NIVELES) - 1)
print(f"verboso={argumentos.verboso}, salida {NIVELES[nivel]}")
bash
python3 detalle.py
python3 detalle.py -v
python3 detalle.py -vv
python3 detalle.py -vvv
python3 detalle.py -vvvvv
Salida en pantalla
verboso=0, salida solo errores
verboso=1, salida normal
verboso=2, salida detallado
verboso=3, salida depuración
verboso=5, salida depuración

Dos detalles que importan:

  • default=0 no es opcional en la práctica. Sin él, el valor por defecto es None, y None >= 1 lanza un TypeError. Con count pon siempre default=0.
  • El min que recorta el nivel. Nada impide escribir -vvvvvvv, así que el programa tiene que aguantarlo sin salirse del índice de la lista. Es un descuido muy fácil de cometer.

Fíjate también en que -vvv es un solo argumento en la terminal. Las opciones cortas de una letra se pueden apelotonar, y argparse las desmonta: -vvv equivale a -v -v -v, y -rv equivale a -r -v.

Acumular valores con action="append"#

Cuando una opción tiene sentido varias veces, append guarda todas las apariciones en una lista en vez de quedarse con la última:

filtrar.py
import argparse

analizador = argparse.ArgumentParser(description="Acumula exclusiones.")
analizador.add_argument("-e", "--excluir", action="append", default=[],
                        help="texto que descarta un fichero, se puede repetir")
argumentos = analizador.parse_args()

print(argumentos.excluir)
bash
python3 filtrar.py -e "*.tmp" -e "*.log"
Salida en pantalla
['*.tmp', '*.log']

Sin append, la segunda aparición pisaría a la primera y solo quedaría '*.log'. Y si no se escribe ninguna vez, el atributo es la lista vacía, que se puede recorrer sin comprobar nada:

bash
python3 filtrar.py
Salida en pantalla
[]

Las dos caras de una bandera con BooleanOptionalAction#

store_true sirve para encender algo apagado, y store_false para apagar algo encendido, pero ninguna de las dos permite las dos cosas a la vez. Cuando el valor por defecto depende de la situación y quien usa el programa necesita poder forzarlo en cualquier dirección, la solución es argparse.BooleanOptionalAction, que crea las dos opciones de golpe:

color.py
import argparse

analizador = argparse.ArgumentParser(description="Bandera de dos caras.")
analizador.add_argument("--color", action=argparse.BooleanOptionalAction, default=True,
                        help="colorear la salida")
argumentos = analizador.parse_args()

print(argumentos.color)
bash
python3 color.py -h
Salida en pantalla
usage: color.py [-h] [--color | --no-color]

Bandera de dos caras.

options:
  -h, --help           show this help message and exit
  --color, --no-color  colorear la salida

La ayuda las presenta como alternativas con la barra vertical, y funcionan las tres invocaciones:

bash
python3 color.py
python3 color.py --color
python3 color.py --no-color
Salida en pantalla
True
True
False

Es exactamente el caso del color en la terminal que viste en la lección de colores y estilos: lo razonable es colorear cuando se está mirando la pantalla y no colorear cuando la salida va a un fichero, así que el programa lo decide solo. Pero quien lo use tiene que poder imponer su criterio en las dos direcciones, y para eso hacen falta las dos opciones.

Ten en cuenta que BooleanOptionalAction se pasa como objeto, sin comillas, al contrario que "store_true" y las demás, que son cadenas. Es un despiste habitual.

Opciones obligatorias con required=True#

Se puede obligar a escribir una opción:

obligatoria.py
import argparse

analizador = argparse.ArgumentParser(description="Una opción obligatoria.")
analizador.add_argument("--destino", required=True, help="carpeta donde dejar el resultado")
argumentos = analizador.parse_args()

print(argumentos.destino)
bash
python3 obligatoria.py
Salida en pantalla
usage: obligatoria.py [-h] --destino DESTINO
obligatoria.py: error: the following arguments are required: --destino

Fíjate en que en la línea usage: el --destino DESTINO sale sin corchetes, porque ya no es opcional.

Todas las acciones de un vistazo#

actionQué haceValor por defecto recomendado
"store"Guarda el valor. Es lo que ocurre si no pones actionNone o el que tenga sentido
"store_true"True si aparece, False si noLo pone solo
"store_false"False si aparece, True si noLo pone solo
"count"Cuenta las veces que aparece0, siempre
"append"Acumula cada aparición en una lista[]
"store_const"Guarda el valor fijo de const si apareceEl que tenga sentido
"append_const"Añade el valor fijo de const a una lista[]
argparse.BooleanOptionalActionCrea --x y --no-xTrue o False, explícito
"version"Imprime la versión y terminaNo aplica
"help"Imprime la ayuda y termina. Es lo que hace -hNo aplica

Un programa con todo junto#

Este listador de ficheros usa un posicional y cinco opciones de tipos distintos:

listar.py
"""Lista los ficheros de una carpeta con unas cuantas opciones."""

import argparse
from pathlib import Path


def construir_analizador() -> argparse.ArgumentParser:
        analizador = argparse.ArgumentParser(description="Lista los ficheros de una carpeta.")
    analizador.add_argument("carpeta", help="carpeta que hay que listar")
    analizador.add_argument("-p", "--patron", default="*",
                            help="patrón de nombre que deben cumplir los ficheros")
    analizador.add_argument("-e", "--excluir", action="append", default=[],
                            help="texto que descarta un fichero, se puede repetir")
    analizador.add_argument("-r", "--recursivo", action="store_true",
                            help="entrar también en las subcarpetas")
    analizador.add_argument("--ocultos", action=argparse.BooleanOptionalAction, default=False,
                            help="incluir los ficheros que empiezan por punto")
    analizador.add_argument("-v", "--verboso", action="count", default=0,
                            help="explica lo que hace, repetible para más detalle")
    return analizador


def main() -> None:
        argumentos = construir_analizador().parse_args()
    carpeta = Path(argumentos.carpeta)

    if argumentos.verboso >= 1:
                print(f"Buscando '{argumentos.patron}' en {carpeta}")
    if argumentos.verboso >= 2:
                print(f"  recursivo={argumentos.recursivo} ocultos={argumentos.ocultos}")
        print(f"  exclusiones={argumentos.excluir}")

    patron = f"**/{argumentos.patron}" if argumentos.recursivo else argumentos.patron
    encontrados = 0

    for ruta in sorted(carpeta.glob(patron)):
                if not ruta.is_file():
                        continue
        if not argumentos.ocultos and ruta.name.startswith("."):
                        continue
        if any(texto in ruta.name for texto in argumentos.excluir):
                        if argumentos.verboso >= 2:
                                print(f"  descartado {ruta.name}")
            continue
        print(ruta)
        encontrados += 1

    if argumentos.verboso >= 1:
                print(f"Total: {encontrados} ficheros")


if __name__ == "__main__":
        main()

La ayuda que produce, sin escribir ni una línea de texto de uso:

bash
python3 listar.py -h
Salida en pantalla
usage: listar.py [-h] [-p PATRON] [-e EXCLUIR] [-r] [--ocultos | --no-ocultos]
                 [-v]
                 carpeta

Lista los ficheros de una carpeta.

positional arguments:
  carpeta               carpeta que hay que listar

options:
  -h, --help            show this help message and exit
  -p, --patron PATRON   patrón de nombre que deben cumplir los ficheros
  -e, --excluir EXCLUIR
                        texto que descarta un fichero, se puede repetir
  -r, --recursivo       entrar también en las subcarpetas
  --ocultos, --no-ocultos
                        incluir los ficheros que empiezan por punto
  -v, --verboso         explica lo que hace, repetible para más detalle

Sobre una carpeta proyecto/ que contiene notas.md, informe.md, datos.csv, borrador.tmp.md, un .oculto.md y una subcarpeta informes/ con anual.md:

bash
python3 listar.py proyecto
Salida en pantalla
proyecto/borrador.tmp.md
proyecto/datos.csv
proyecto/informe.md
proyecto/notas.md
bash
python3 listar.py proyecto -p "*.md" --ocultos -e tmp
Salida en pantalla
proyecto/.oculto.md
proyecto/informe.md
proyecto/notas.md
bash
python3 listar.py proyecto -p "*.md" -r -v
Salida en pantalla
Buscando '*.md' en proyecto
proyecto/borrador.tmp.md
proyecto/informe.md
proyecto/informes/anual.md
proyecto/notas.md
Total: 4 ficheros
bash
python3 listar.py proyecto -p "*.md" -e tmp -vv
Salida en pantalla
Buscando '*.md' en proyecto
  recursivo=False ocultos=False
  exclusiones=['tmp']
  descartado borrador.tmp.md
proyecto/informe.md
proyecto/notas.md
Total: 2 ficheros