Image for post Construyendo Interfaces de Línea de Comandos (CLIs) Modernas con Python, Typer y Rich

Construyendo Interfaces de Línea de Comandos (CLIs) Modernas con Python, Typer y Rich


Como desarrollador, una de las habilidades más potentes que puedes adquirir es la capacidad de crear tus propias herramientas. Las Interfaces de Línea de Comandos (CLIs) son la forma por excelencia de automatizar tareas, gestionar aplicaciones y orquestar flujos de trabajo complejos. Si alguna vez has usado git, pip o docker, ya conoces el poder de una buena CLI.

En este artículo, vamos a explorar cómo construir CLIs modernas, intuitivas y visualmente atractivas en Python. Dejaremos atrás el engorroso sys.argv y adoptaremos dos librerías fantásticas: Typer para la lógica de los comandos y Rich para enriquecer la salida en la terminal.

Contexto del Problema

Imagina que necesitas automatizar un proceso repetitivo: renombrar archivos, interactuar con una API para obtener datos, o ejecutar un pipeline de entrenamiento de un modelo de Machine Learning. Podrías escribir un script simple, pero pronto te enfrentarías a varias preguntas:

  • ¿Cómo paso parámetros de forma clara y flexible (ej. el nombre de un archivo o una URL)?
  • ¿Cómo ofrezco ayuda a los usuarios (incluyéndote a ti mismo en el futuro) para que sepan cómo usar la herramienta?
  • ¿Cómo valido las entradas para evitar errores inesperados?
  • ¿Cómo presento la información de salida de una manera que sea fácil de leer y entender?

Manejar esto manualmente con sys.argv es tedioso y propenso a errores. Librerías como argparse son una mejora, pero pueden ser verbosas y poco intuitivas. Aquí es donde Typer y Rich brillan, ofreciendo una experiencia de desarrollo moderna y eficiente.

Conceptos Clave

Antes de sumergirnos en el código, aclaremos los conceptos fundamentales.

¿Qué es Typer?

Typer es una librería para construir aplicaciones CLI, creada por el mismo autor de FastAPI. Su principal ventaja es que utiliza los type hints (pistas de tipo) de Python para definir comandos, argumentos y opciones de forma automática. Esto significa que escribes menos código, obtienes validación de datos casi gratis y tu editor te proporciona un autocompletado excelente. Typer está construido sobre Click, otra potente librería de CLI, por lo que hereda su robustez y flexibilidad.

¿Qué es Rich?

Rich es una librería para añadir texto enriquecido y formatos vistosos a la salida de tu terminal. Permite imprimir texto con colores y estilos, crear tablas bien formateadas, mostrar barras de progreso, renderizar Markdown y mucho más, todo con una API muy sencilla. Usar Rich hace que tus CLIs no solo sean funcionales, sino también agradables de usar.

Argumentos vs. Opciones

En el mundo de las CLIs, es crucial diferenciar entre argumentos y opciones:

  • Argumento (Argument): Es un parámetro posicional y, por lo general, obligatorio. Por ejemplo, en cp archivo_origen.txt archivo_destino.txt, archivo_origen.txt y archivo_destino.txt son argumentos.
  • Opción (Option): Es un parámetro nombrado, precedido por -- (o - para su versión corta) y suele ser opcional. Por ejemplo, en ls -l --all, -l y --all son opciones.

Typer maneja esta distinción de forma muy natural, como veremos a continuación.

Implementación Paso a Paso

Vamos a construir una CLI simple desde cero para entender los fundamentos.

1. Configuración del Entorno

Primero, asegúrate de tener un entorno virtual activado. Luego, instala Typer y Rich. Recomendamos instalar Typer con la opción [all], que incluye Rich y otras dependencias útiles.

pip install "typer[all]"

2. Tu Primera Aplicación con Typer

Crea un archivo llamado main.py. Nuestra primera CLI simplemente saludará a un nombre que le pasemos como argumento.

import typer

def main(name: str):
    """Saluda a NAME."""
    print(f"Hola {name}")

if __name__ == "__main__":
    typer.run(main)

¡Eso es todo! Fíjate cómo el type hint name: str le dice a Typer que espere un argumento de tipo string. El docstring de la función se usará para generar el mensaje de ayuda.

Ejecútalo desde tu terminal:

python main.py Mundo

La salida será: Hola Mundo.

¿Qué pasa si no proporcionas un argumento? Typer te dará un error claro. ¿Y si pides ayuda?

python main.py --help

Typer genera automáticamente un menú de ayuda útil:

Usage: main.py [OPTIONS] NAME

  Saluda a NAME.

Arguments:
  NAME  [required]

Options:
  --install-completion  Install completion for the current shell.
  --show-completion     Show completion for the current shell, to copy it or
                        customize the installation.
  --help                Show this message and exit.

3. Añadiendo Opciones

Ahora, añadamos una opción para hacer el saludo más formal. Las opciones se definen como parámetros de función con un valor por defecto.

import typer

def main(name: str, lastname: str = "", formal: bool = typer.Option(False, "--formal")):
    """Saluda a NAME, opcionalmente con un apellido y de manera formal."""
    if formal:
        print(f"Buenos días, Sr./Sra. {name} {lastname}")
    else:
        print(f"Hola {name} {lastname}")

if __name__ == "__main__":
    typer.run(main)

Aquí hemos hecho dos cambios:

  • lastname: str = "": Al tener un valor por defecto, Typer lo interpreta como un argumento opcional.
  • formal: bool = typer.Option(False, "--formal"): Usamos typer.Option() para configurar explícitamente una opción. Al ser de tipo bool, se convierte en una bandera (flag). Si se incluye --formal, su valor será True.

Pruébalo:

python main.py Ada --formal
# Salida: Buenos días, Sr./Sra. Ada 

python main.py Grace --lastname Hopper
# Salida: Hola Grace Hopper

4. Integrando Rich para una Salida Atractiva

Reemplacemos los print estándar con la funcionalidad de Rich para añadir color y estilo. Rich puede interpretar una sintaxis similar a la de BBCode para formatear el texto.

import typer
from rich import print

def main(name: str, lastname: str = "", formal: bool = typer.Option(False, "--formal")):
    """Saluda a NAME, opcionalmente con un apellido y de manera formal."""
    message = f"{name} {lastname}"
    if formal:
        print(f"[bold green]Buenos días, Sr./Sra. {message}[/bold green]")
    else:
        print(f"[yellow]Hola {message}[/yellow]")

if __name__ == "__main__":
    typer.run(main)

Ahora, al ejecutar los mismos comandos, la salida aparecerá coloreada en tu terminal, haciéndola mucho más legible y atractiva.

Mini Proyecto: Una CLI para Consultar el Clima

Para consolidar lo aprendido, construiremos una herramienta práctica: una CLI que consulta el clima de una ciudad utilizando una API pública. Usaremos la API de Open-Meteo, que no requiere clave de API para consultas básicas, lo que simplifica nuestro proyecto.

Primero, instala la librería requests para hacer las llamadas HTTP:

pip install requests

Ahora, crea un nuevo archivo weather_cli.py:

import typer
import requests
from rich.console import Console
from rich.table import Table

app = typer.Typer()
console = Console()

@app.command()
def city(name: str):
    """Obtiene el clima actual para una CIUDAD."""
    console.print(f":earth_americas:  Buscando el clima para [bold blue]{name}[/bold blue]...")

    # 1. Obtener coordenadas de la ciudad
    geo_url = f"https://geocoding-api.open-meteo.com/v1/search?name={name}&count=1&language=es&format=json"
    try:
        geo_response = requests.get(geo_url)
        geo_response.raise_for_status() # Lanza un error si la petición falla
        geo_data = geo_response.json()
        if not geo_data.get("results"):
            console.print(f"[bold red]Error:[/bold red] No se pudo encontrar la ciudad '{name}'.")
            raise typer.Exit()
    except requests.RequestException as e:
        console.print(f"[bold red]Error de red:[/bold red] {e}")
        raise typer.Exit()

    location = geo_data["results"][0]
    latitude = location["latitude"]
    longitude = location["longitude"]
    
    # 2. Obtener el clima para esas coordenadas
    weather_url = f"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}&current_weather=true"
    try:
        weather_response = requests.get(weather_url)
        weather_response.raise_for_status()
        weather_data = weather_response.json()
    except requests.RequestException as e:
        console.print(f"[bold red]Error de red al obtener el clima:[/bold red] {e}")
        raise typer.Exit()

    # 3. Mostrar los datos con una tabla de Rich
    current_weather = weather_data["current_weather"]
    
    table = Table(title=f"Clima Actual en {location['name']}, {location['country_code']}")
    table.add_column("Parámetro", justify="right", style="cyan", no_wrap=True)
    table.add_column("Valor", style="magenta")

    table.add_row("Temperatura", f"{current_weather['temperature']} °C")
    table.add_row("Velocidad del Viento", f"{current_weather['windspeed']} km/h")
    table.add_row("Dirección del Viento", f"{current_weather['winddirection']}°")

    console.print(table)

if __name__ == "__main__":
    app()

Snippet de Ejecución

Guarda el código y ejecútalo desde la terminal:

python weather_cli.py city Madrid

Verás una tabla bien formateada y coloreada con la información del clima actual en Madrid. Hemos usado typer.Typer() para crear una aplicación con comandos, y @app.command() para registrar nuestra función city como un comando. La tabla de Rich (rich.table.Table) nos permite presentar los datos de una forma mucho más clara que un simple texto.

Errores Comunes y Depuración

  • Olvidar typer.run(main) o app(): Un error muy común es escribir toda la lógica pero olvidar la línea que efectivamente ejecuta la aplicación Typer. Si tu script no hace nada, revisa que esa llamada esté al final, dentro del bloque if __name__ == "__main__":.
  • Confundir Argumento con Opción: Recuerda que un parámetro sin valor por defecto es un argumento requerido. Si quieres una opción opcional, debe tener un valor por defecto (ej. param: str = "default") o usar typer.Option().
  • Manejo de Errores de API: En nuestro mini-proyecto, usamos un bloque try...except para capturar errores de red o respuestas inesperadas de la API. Es una buena práctica manejar estos casos para que tu CLI no se rompa abruptamente y pueda dar un mensaje de error útil al usuario.
  • Dependencias no instaladas: Si recibes un ModuleNotFoundError, asegúrate de haber instalado todas las dependencias (typer, rich, requests) en tu entorno virtual.

Aprendizaje Futuro / Próximos Pasos

Has aprendido los fundamentos para crear CLIs potentes. ¿Qué sigue?

  • Subcomandos: Para CLIs más complejas, puedes anidar comandos (ej. git remote add ...). Typer maneja esto de forma muy elegante con app.add_typer().
  • Validación Avanzada: Typer permite añadir validaciones más complejas, como rangos de números, a través de sus parámetros.
  • Callbacks: Puedes ejecutar código antes de que se ejecuten los comandos, útil para verificar configuraciones, versiones o estados.
  • Testing: Typer incluye una utilidad CliRunner que facilita la escritura de pruebas para tus comandos, asegurando que tu aplicación funcione como se espera.
  • Empaquetado y Distribución: Aprende a empaquetar tu CLI con herramientas como Poetry o setuptools para que otros (o tú mismo en otros proyectos) puedan instalarla fácilmente con pip.

Crear tus propias herramientas de línea de comandos es una habilidad increíblemente gratificante y útil. Con Typer y Rich, el proceso no solo es eficiente, sino también divertido. ¡Ahora ve y automatiza todo!