Cuando escribes una herramienta de línea de comandos, la tentación es parsear sys.argv a mano o saltar directamente a click o typer. El problema con lo primero es evidente; el problema con lo segundo es que introduces una dependencia en algo que Python ya resuelve bien. argparse es el módulo de la stdlib que convierte sys.argv en un namespace tipado, valida entradas, genera ayuda automática y maneja errores con mensajes claros, todo sin instalar nada.
El ArgumentParser es el objeto central. Lo instancias una vez, registras los argumentos que esperas recibir, y llamas a parse_args() al final. Lo que obtienes de vuelta es un objeto Namespace cuyos atributos corresponden exactamente a los argumentos que declaraste. No hay magia de decoradores ni metaclases: es una API imperativa directa.
La razón por la que argparse genera los mensajes de error y la ayuda automáticamente es que cada argumento que registras lleva consigo metadatos: tipo esperado, valor por defecto, descripción, opciones válidas. Cuando el usuario pasa --format xml y tú declaraste choices=['json', 'csv'], el parser ya sabe cómo quejarse sin que escribas una sola línea de validación. Eso es lo que lo hace práctico: la especificación del argumento es la validación.
Úsalo cuando tu herramienta tenga más de dos o tres flags, cuando necesites subcomandos estilo git, o cuando quieras que otros desarrolladores puedan leer el contrato de la CLI sin ejecutarla. Si el proyecto ya tiene click o typer como dependencia transitiva, úsalos, pero para una herramienta standalone o un script interno, argparse elimina una dependencia sin ceder funcionalidad real.
Lo que se rompe cuando lo usas mal es casi siempre una de dos cosas: registrar argumentos opcionales cuando deberían ser posicionales (o viceversa), o no especificar type= y recibir strings donde esperabas enteros o rutas, con errores crípticos mucho más adelante en el código.
from argparse import ArgumentParser
from pathlib import Path
import json
import csv
import sys
def build_parser() -> ArgumentParser:
parser = ArgumentParser(
description="Convierte archivos de datos entre formatos.",
# Agrega -h/--help automáticamente; prog se toma del nombre del script
)
# Argumento posicional obligatorio: no lleva '--', el orden importa
parser.add_argument(
"archivo",
type=Path, # argparse llama Path(valor) antes de devolverlo
help="Archivo de entrada a procesar",
)
parser.add_argument(
"--formato",
choices=["json", "csv"],
default="json",
help="Formato de salida (default: %(default)s)", # %(default)s se rellena solo
)
parser.add_argument(
"--output",
type=Path,
default=None,
help="Archivo de salida (default: stdout)",
)
parser.add_argument(
"--columnas",
nargs="+", # uno o más valores: --columnas nombre edad email
metavar="COL",
help="Columnas a incluir (todas si se omite)",
)
parser.add_argument(
"--limite",
type=int,
default=None,
help="Máximo de filas a procesar",
)
return parser
def build_subcommand_parser() -> ArgumentParser:
"""Parser con subcomandos para CLIs más complejas."""
root = ArgumentParser(description="Herramienta de datos multi-comando")
# add_subparsers devuelve un objeto especial; dest nombra el atributo
# en el Namespace que indica qué subcomando se invocó
subparsers = root.add_subparsers(dest="comando", required=True)
# Subcomando 'inspect': solo lee y describe el archivo
inspect_p = subparsers.add_parser("inspect", help="Muestra estadísticas del archivo")
inspect_p.add_argument("archivo", type=Path)
inspect_p.add_argument("--verbose", action="store_true") # flag booleano
# Subcomando 'convert': convierte formato
convert_p = subparsers.add_parser("convert", help="Convierte entre formatos")
convert_p.add_argument("archivo", type=Path)
convert_p.add_argument("--formato", choices=["json", "csv"], required=True)
convert_p.add_argument("--output", type=Path, required=True)
return root
# ── Lógica de ejemplo para el parser simple ───────────────────────────
def load_data(path: Path) -> list[dict]:
suffix = path.suffix.lower()
if suffix == ".json":
return json.loads(path.read_text())
if suffix == ".csv":
with path.open(newline="") as fh:
return list(csv.DictReader(fh))
raise ValueError(f"Formato no soportado: {suffix}")
def main() -> None:
parser = build_parser()
args = parser.parse_args()
# args es un Namespace; accedes a los valores como atributos normales
# args.archivo → Path
# args.formato → 'json' | 'csv'
# args.columnas → list[str] | None
# args.limite → int | None
if not args.archivo.exists():
# parser.error() imprime el mensaje, muestra el usage y sale con código 2
parser.error(f"El archivo '{args.archivo}' no existe")
data = load_data(args.archivo)
if args.columnas:
data = [{col: row[col] for col in args.columnas if col in row} for row in data]
if args.limite is not None:
data = data[: args.limite]
# Decide destino: Path abierto o stdout
out_stream = args.output.open("w") if args.output else sys.stdout
try:
if args.formato == "json":
json.dump(data, out_stream, indent=2, ensure_ascii=False)
out_stream.write("\n")
else:
if not data:
return
writer = csv.DictWriter(out_stream, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
finally:
if args.output:
out_stream.close()
if __name__ == "__main__":
main()
Desglose del código
La separación de build_parser() de main() no es estética: permite testear el parser de forma aislada con parser.parse_args(["archivo.json", "--limite", "10"]) sin tocar el sistema de archivos ni el stdout. Si metes parse_args() dentro de la lógica de negocio, probar la CLI se vuelve un dolor.
El argumento "archivo" sin guiones es posicional: obligatorio, sin prefijo, y el usuario no escribe su nombre en la terminal. En cambio --formato es opcional y tiene un default. Esta distinción es fundamental porque argparse la usa para decidir si el argumento es requerido o no —los posicionales siempre lo son a menos que les pongas nargs='?'.
Con type=Path, argparse llama Path(valor) antes de devolvertelo. Eso significa que si el usuario pasa algo que no puede construirse como Path (raro, casi todo es un path válido), el error sale del propio parser con contexto. Lo mismo aplica a type=int: si el usuario escribe --limite abc, el mensaje de error menciona explícitamente el argumento y el valor inválido, no un ValueError anónimo en tu código.
nargs="+" convierte el argumento en una lista de uno o más valores. Si el usuario no lo pasa, args.columnas vale None (no una lista vacía), lo que permite distinguir entre “no especificado” y “especificado vacío”. Fíjate en que el código chequea if args.columnas: y funciona correctamente en ambos casos.
La función build_subcommand_parser() muestra el patrón para CLIs con subcomandos. dest="comando" hace que args.comando sea "inspect" o "convert" según lo que escribió el usuario, y desde ahí puedes hacer un simple if args.comando == "inspect": sin tener que parsear strings a mano. Con required=True en add_subparsers, el parser rechaza la invocación sin subcomando en lugar de silenciosamente ignorarla.
parser.error() es el mecanismo correcto para validaciones que argparse no puede hacer por sí solo (como verificar que el archivo exista): imprime el mensaje al stderr, muestra el usage, y sale con código 2, que es la convención Unix para error de uso incorrecto.
Errores que debes conocer
Error: Usar type=int pero olvidar que nargs='+' devuelve una lista de strings, no de ints — la conversión de tipo aplica a cada elemento de la lista por separado, así que nargs='+' con type=int funciona, pero la gente asume que no y añade una conversión manual encima.
# ❌ Wrong
parser.add_argument("--ids", nargs="+")
# args.ids → ['1', '2', '3'] — strings, no ints
# ✅ Right
parser.add_argument("--ids", nargs="+", type=int)
# args.ids → [1, 2, 3] — argparse aplica int() a cada elemento
type= se aplica elemento a elemento cuando hay nargs, así que no necesitas hacer [int(x) for x in args.ids] después.
Error: Llamar parse_args() en el módulo top-level en lugar de dentro de main(), lo que hace que el parser se ejecute al hacer import del módulo desde tests o desde otro script.
# ❌ Wrong
args = parser.parse_args() # se ejecuta en import time
def main():
procesar(args)
# ✅ Right
def main():
args = parser.parse_args()
procesar(args)
Mover parse_args() adentro de main() lo convierte en código que solo se ejecuta cuando se invoca explícitamente, no como efecto secundario de un import.
N° 191