Módulo 2 · Lección 11 de 28
Tipos y validación
Convertir los argumentos al tipo que toca y rechazar los valores imposibles antes de que lleguen a tu código.
Todo lo que llega por la línea de comandos es texto. Eso significa que el -n 3 que has escrito no es el número 3, sino la cadena '3', y que multiplicarla por dos da '33'. La conversión hay que hacerla, y hasta ahora la hacías tú con un try/except alrededor de int().
No hace falta. argparse puede convertir el valor mientras lo lee, y puede además rechazar los que no tengan sentido: un puerto fuera de rango, un formato que no existe, una fecha mal escrita. Hacerlo ahí tiene una ventaja que no es solo de comodidad: cuando parse_args() termina, todo lo que hay en el Namespace está ya convertido y comprobado, y el resto del programa puede confiar en ello sin volver a mirar.
El parámetro type#
Se le pasa una función, y argparse la aplica al texto antes de guardarlo. Las dos más habituales son int y float:
import argparse
analizador = argparse.ArgumentParser(description="Repite un mensaje.")
analizador.add_argument("mensaje", help="lo que hay que repetir")
analizador.add_argument("-n", "--veces", type=int, default=1, help="cuántas veces repetirlo")
analizador.add_argument("--pausa", type=float, default=0.0, help="segundos entre repeticiones")
argumentos = analizador.parse_args()
print(type(argumentos.veces), argumentos.veces)
print(type(argumentos.pausa), argumentos.pausa)
for numero in range(argumentos.veces):
print(f"{numero + 1}: {argumentos.mensaje}")python3 repetir.py "Hola" -n 3 --pausa 0.5<class 'int'> 3
<class 'float'> 0.5
1: Hola
2: Hola
3: HolaLos valores llegan ya como int y como float, y el range funciona directamente. Fíjate también en que el default=1 no pasa por la conversión: se guarda tal cual, así que tiene que ser ya del tipo correcto. Poner default="1" con type=int es un error silencioso que solo se nota cuando nadie escribe la opción.
Cuando el texto no se puede convertir, argparse se encarga:
python3 repetir.py "Hola" -n tresusage: repetir.py [-h] [-n VECES] [--pausa PAUSA] mensaje
repetir.py: error: argument -n/--veces: invalid int value: 'tres'python3 repetir.py "Hola" --pausa mediousage: repetir.py [-h] [-n VECES] [--pausa PAUSA] mensaje
repetir.py: error: argument --pausa: invalid float value: 'medio'El mensaje dice qué opción ha fallado, a qué tipo intentaba convertir y qué texto recibió. Es más información de la que darían tres líneas de try/except escritas a mano, y sale con código 2 como cualquier otro error de invocación.
Cualquier función vale como type#
type no se limita a los tipos incorporados. Acepta cualquier función que reciba una cadena y devuelva algo. Con eso, la conversión y la validación se hacen en el mismo sitio.
La convención es lanzar argparse.ArgumentTypeError cuando el valor no sirve, porque argparse la captura y la convierte en un mensaje de error con el formato de siempre.
"""Arranca un servidor de mentira en un puerto validado."""
import argparse
from datetime import date
def puerto(texto: str) -> int:
"""Convierte el texto en un número de puerto válido."""
try:
numero = int(texto)
except ValueError:
raise argparse.ArgumentTypeError(f"'{texto}' no es un número entero")
if not 1 <= numero <= 65535:
raise argparse.ArgumentTypeError(f"{numero} está fuera del rango 1-65535")
if numero < 1024:
raise argparse.ArgumentTypeError(f"{numero} es un puerto reservado del sistema")
return numero
def fecha_iso(texto: str) -> date:
"""Convierte el texto en una fecha, esperando el formato AAAA-MM-DD."""
try:
return date.fromisoformat(texto)
except ValueError:
raise argparse.ArgumentTypeError(f"'{texto}' no es una fecha AAAA-MM-DD")
analizador = argparse.ArgumentParser(description="Arranca el servidor de informes.")
analizador.add_argument("-p", "--puerto", type=puerto, default=8000,
help="puerto de escucha, entre 1024 y 65535")
analizador.add_argument("--desde", type=fecha_iso, default=date.today(),
help="primera fecha del informe, en formato AAAA-MM-DD")
argumentos = analizador.parse_args()
print(f"puerto = {argumentos.puerto!r}")
print(f"desde = {argumentos.desde!r}")Con valores correctos, el programa recibe un int y un objeto date, listos para usar:
python3 servidor.py -p 8080 --desde 2026-01-15puerto = 8080
desde = datetime.date(2026, 1, 15)Y cada validación fallida da su propio mensaje, escrito por ti:
python3 servidor.py -p 80usage: servidor.py [-h] [-p PUERTO] [--desde DESDE]
servidor.py: error: argument -p/--puerto: 80 es un puerto reservado del sistemapython3 servidor.py -p 99999usage: servidor.py [-h] [-p PUERTO] [--desde DESDE]
servidor.py: error: argument -p/--puerto: 99999 está fuera del rango 1-65535python3 servidor.py --desde 15/01/2026usage: servidor.py [-h] [-p PUERTO] [--desde DESDE]
servidor.py: error: argument --desde: '15/01/2026' no es una fecha AAAA-MM-DDTres cosas que merece la pena notar de estas funciones:
- El nombre de la función se ve. Cuando falla por una vía que no controlas,
argparselo usa para construir el mensaje genérico, del estiloinvalid puerto value: '80'. Por eso conviene que describa lo que la función devuelve, no lo que comprueba:puerto, novalidar_puerto. - Cada motivo de rechazo tiene su frase. Un solo mensaje genérico del estilo "puerto no válido" obliga a adivinar. Tres mensajes distintos cuestan tres líneas.
- El mensaje va sin el nombre de la opción.
argparseya anteponeargument -p/--puerto:, así que repetirlo en tu texto lo duplica.
type=Path para las rutas#
La clase Path de pathlib recibe una cadena y devuelve un objeto, así que encaja como type sin ninguna envoltura:
import argparse
from pathlib import Path
analizador = argparse.ArgumentParser(description="Recibe rutas ya convertidas.")
analizador.add_argument("entrada", type=Path, help="fichero de entrada")
analizador.add_argument("-d", "--destino", type=Path, default=Path("."),
help="carpeta donde escribir")
argumentos = analizador.parse_args()
print(repr(argumentos.entrada))
print(argumentos.entrada.suffix)
print(argumentos.entrada.stem)
print(argumentos.entrada.exists())
print(argumentos.destino / argumentos.entrada.with_suffix(".html").name)python3 rutas.py notas.md -d salidaPosixPath('notas.md')
.md
notas
True
salida/notas.htmlEs la forma correcta de recibir rutas: te ahorras el Path(argumentos.entrada) repetido por todo el programa y tienes disponibles .suffix, .stem, .exists() y el operador / desde la primera línea. Ese PosixPath de la primera línea es lo que devuelve Path en macOS y Linux; en Windows verás WindowsPath, y todo lo demás funciona igual.
Lo que no hace type=Path es comprobar que el fichero existe. Path acepta cualquier texto:
python3 rutas.py no-existe.mdPosixPath('no-existe.md')
.md
no-existe
False
no-existe.htmlY es lo lógico: un argumento puede ser un fichero que hay que crear, no solo uno que hay que leer. Comprobar la existencia es una validación aparte, y la verás al final de la lección.
Limitar los valores con choices#
Cuando solo unos pocos valores tienen sentido, choices los declara de una vez. Acepta cualquier cosa por la que se pueda iterar y en la que se pueda buscar con in: una lista, una tupla, un range o las claves de un diccionario.
import argparse
analizador = argparse.ArgumentParser(description="Genera un informe.")
analizador.add_argument("-f", "--formato", choices=["json", "csv", "texto"], default="texto",
help="formato del informe")
analizador.add_argument("-n", "--nivel", type=int, choices=range(1, 4), default=1,
help="profundidad del detalle")
argumentos = analizador.parse_args()
print(f"formato={argumentos.formato} nivel={argumentos.nivel}")Lo mejor es que las opciones válidas aparecen solas en la ayuda, sin tener que enumerarlas en el help:
python3 informe.py -husage: informe.py [-h] [-f {json,csv,texto}] [-n {1,2,3}]
Genera un informe.
options:
-h, --help show this help message and exit
-f, --formato {json,csv,texto}
formato del informe
-n, --nivel {1,2,3} profundidad del detalleY el error dice exactamente qué se admite:
python3 informe.py -f xmlusage: informe.py [-h] [-f {json,csv,texto}] [-n {1,2,3}]
informe.py: error: argument -f/--formato: invalid choice: 'xml' (choose from 'json', 'csv', 'texto')Cuántos valores consume un argumento: nargs#
De serie, cada argumento consume exactamente un valor. Con nargs eso cambia, y hay cuatro formas.
nargs="?": cero o uno#
El argumento se puede poner o no. Si no está, se usa el default. Sirve para hacer opcional un posicional:
import argparse
analizador = argparse.ArgumentParser(description="nargs='?': cero o uno.")
analizador.add_argument("entrada", help="fichero de entrada")
analizador.add_argument("salida", nargs="?", default="salida.html",
help="fichero de salida, opcional")
argumentos = analizador.parse_args()
print(f"entrada={argumentos.entrada!r} salida={argumentos.salida!r}")En la ayuda se ve el posicional opcional entre corchetes:
python3 nargs_interrogacion.py -husage: nargs_interrogacion.py [-h] entrada [salida]
nargs='?': cero o uno.
positional arguments:
entrada fichero de entrada
salida fichero de salida, opcional
options:
-h, --help show this help message and exitpython3 nargs_interrogacion.py notas.md
python3 nargs_interrogacion.py notas.md web.htmlentrada='notas.md' salida='salida.html'
entrada='notas.md' salida='web.html'Es el patrón de cp y compañía: lo esencial es obligatorio, el destino tiene un valor razonable por defecto.
nargs="*": ninguno, uno o muchos#
El valor que llega es siempre una lista, vacía si no hay nada:
import argparse
analizador = argparse.ArgumentParser(description="nargs='*' y nargs='+'.")
analizador.add_argument("ficheros", nargs="*", help="ninguno, uno o muchos")
analizador.add_argument("-t", "--etiqueta", action="append", help="etiquetas sueltas")
argumentos = analizador.parse_args()
print(f"ficheros={argumentos.ficheros!r}")python3 nargs_asterisco.py
python3 nargs_asterisco.py a.md b.md c.mdficheros=[]
ficheros=['a.md', 'b.md', 'c.md']Esto es lo que permite escribir programa *.md y que la terminal expanda el asterisco en veinte ficheros.
nargs="+": uno o muchos#
Igual que el anterior, pero exige al menos uno. Es lo que faltaba para el programa de estadísticas de la lección de sys.argv:
import argparse
analizador = argparse.ArgumentParser(description="nargs='+': uno o más.")
analizador.add_argument("numeros", nargs="+", type=float, help="al menos un número")
argumentos = analizador.parse_args()
print(f"{len(argumentos.numeros)} números, suma {sum(argumentos.numeros)}")python3 nargs_mas.py 10 20 5.53 números, suma 35.5Y el caso vacío ya no es tuyo:
python3 nargs_mas.pyusage: nargs_mas.py [-h] numeros [numeros ...]
nargs_mas.py: error: the following arguments are required: numerosFíjate en que type=float se aplica a cada elemento de la lista, no a la lista entera. Es lo que quieres el cien por cien de las veces.
nargs=N: exactamente N#
Con un número, el argumento consume esa cantidad justa de valores y devuelve una lista de ese tamaño:
import argparse
analizador = argparse.ArgumentParser(description="nargs=N: exactamente N.")
analizador.add_argument("--recorte", nargs=4, type=int,
help="zona que hay que recortar: x y ancho alto")
argumentos = analizador.parse_args()
print(argumentos.recorte)python3 nargs_numero.py --recorte 0 0 800 600[0, 0, 800, 600]python3 nargs_numero.py --recorte 0 0 800usage: nargs_numero.py [-h] [--recorte RECORTE RECORTE RECORTE RECORTE]
nargs_numero.py: error: argument --recorte: expected 4 argumentsLa ayuda de ese último es fea: RECORTE RECORTE RECORTE RECORTE no le dice a nadie qué son esos cuatro números. Se arregla con metavar, en la última lección del tema.
nargs | Cuántos valores | Qué llega al Namespace |
|---|---|---|
| Sin poner | Exactamente uno | El valor suelto |
"?" | Cero o uno | El valor suelto, o el default |
"*" | Cualquier cantidad, incluida ninguna | Una lista, vacía si no hay nada |
"+" | Al menos uno | Una lista con uno o más elementos |
N | Exactamente N | Una lista de N elementos |
Opciones incompatibles entre sí#
Algunas opciones no tienen sentido juntas. add_mutually_exclusive_group crea un grupo del que como mucho se puede usar una:
import argparse
analizador = argparse.ArgumentParser(description="Sube o baja el volumen.")
grupo = analizador.add_mutually_exclusive_group()
grupo.add_argument("-s", "--silencio", action="store_true", help="salida sin ningún mensaje")
grupo.add_argument("-v", "--verboso", action="store_true", help="salida con todo el detalle")
argumentos = analizador.parse_args()
print(f"silencio={argumentos.silencio} verboso={argumentos.verboso}")Los argumentos se añaden al grupo, no al analizador, pero salen en la ayuda como cualquier otro. Lo que cambia es la línea usage:, donde aparecen separados por una barra:
python3 volumen.py -husage: volumen.py [-h] [-s | -v]
Sube o baja el volumen.
options:
-h, --help show this help message and exit
-s, --silencio salida sin ningún mensaje
-v, --verboso salida con todo el detallepython3 volumen.py -s -vusage: volumen.py [-h] [-s | -v]
volumen.py: error: argument -v/--verboso: not allowed with argument -s/--silencioEl grupo también admite required=True, y entonces significa "exactamente una, ni ninguna ni dos":
import argparse
analizador = argparse.ArgumentParser(description="De dónde salen los datos.")
grupo = analizador.add_mutually_exclusive_group(required=True)
grupo.add_argument("--fichero", help="leer los datos de un fichero")
grupo.add_argument("--url", help="descargar los datos de una dirección")
grupo.add_argument("--entrada-estandar", action="store_true", help="leer de la entrada estándar")
argumentos = analizador.parse_args()
print(vars(argumentos))Ahora el usage: las encierra en paréntesis en vez de en corchetes, que es la forma de decir "una de estas, obligatoriamente":
python3 origen.py -husage: origen.py [-h] (--fichero FICHERO | --url URL | --entrada-estandar)
De dónde salen los datos.
options:
-h, --help show this help message and exit
--fichero FICHERO leer los datos de un fichero
--url URL descargar los datos de una dirección
--entrada-estandar leer de la entrada estándarpython3 origen.pyusage: origen.py [-h] (--fichero FICHERO | --url URL | --entrada-estandar)
origen.py: error: one of the arguments --fichero --url --entrada-estandar is requiredLo que argparse no puede validar#
Un grupo excluyente sabe que dos opciones no pueden ir juntas, y una función type sabe si un valor suelto es correcto. Lo que ninguno de los dos puede hacer es mirar dos argumentos a la vez ni el estado del sistema, porque cuando se ejecutan todavía no se ha terminado de analizar la línea.
Esas comprobaciones van después de parse_args(). Y para que el mensaje salga con el mismo aspecto que los automáticos, no se usa un print con un sys.exit: se usa analizador.error(), que imprime el usage:, el nombre del programa y el mensaje, y termina con código 2.
"""Extrae un rango de fechas de un registro."""
import argparse
from datetime import date
from pathlib import Path
def fecha_iso(texto: str) -> date:
"""Convierte el texto en una fecha, esperando el formato AAAA-MM-DD."""
try:
return date.fromisoformat(texto)
except ValueError:
raise argparse.ArgumentTypeError(f"'{texto}' no es una fecha AAAA-MM-DD")
def main() -> None:
analizador = argparse.ArgumentParser(description="Extrae un tramo del registro.")
analizador.add_argument("registro", type=Path, help="fichero de registro que hay que leer")
analizador.add_argument("--desde", type=fecha_iso, required=True, help="primera fecha")
analizador.add_argument("--hasta", type=fecha_iso, required=True, help="última fecha")
argumentos = analizador.parse_args()
# Validaciones que argparse no puede hacer por su cuenta.
if not argumentos.registro.is_file():
analizador.error(f"el registro {argumentos.registro} no existe")
if argumentos.desde > argumentos.hasta:
analizador.error("--desde tiene que ser anterior o igual a --hasta")
dias = (argumentos.hasta - argumentos.desde).days + 1
print(f"Leyendo {argumentos.registro} de {argumentos.desde} a {argumentos.hasta} ({dias} días)")
if __name__ == "__main__":
main()python3 rango.py servidor.log --desde 2026-01-01 --hasta 2026-01-31Leyendo servidor.log de 2026-01-01 a 2026-01-31 (31 días)python3 rango.py servidor.log --desde 2026-03-01 --hasta 2026-01-31usage: rango.py [-h] --desde DESDE --hasta HASTA registro
rango.py: error: --desde tiene que ser anterior o igual a --hastapython3 rango.py no-esta.log --desde 2026-01-01 --hasta 2026-01-31usage: rango.py [-h] --desde DESDE --hasta HASTA registro
rango.py: error: el registro no-esta.log no existeIndistinguibles de los errores que genera argparse. Quien usa el programa no tiene por qué saber cuáles comprueba el módulo y cuáles has escrito tú.