Construye tus propias herramientas CLI ligeras con Python

Si trabajas con Python a diario, en algún momento has escrito un script que acabas pasando a tus compañeros con instrucciones del tipo "ejecútalo así, con estos parámetros". Ese momento es exactamente cuando merece la pena convertirlo en una herramienta CLI de verdad: con argumentos bien definidos, mensajes de ayuda automáticos y salida clara en la terminal. No hace falta mucho más esfuerzo del que ya tienes.

Por qué Python funciona tan bien para esto

Python viene instalado en prácticamente cualquier sistema Linux o macOS, y en Windows lo tienes disponible desde la Microsoft Store en dos clics. Eso ya es una ventaja enorme frente a otros lenguajes que requieren compilar o distribuir un binario por plataforma.

Además, el lenguaje en sí está pensado para scripting rápido. Puedes tener un prototipo funcional en 20 líneas, y si luego necesitas crecer, las librerías del ecosistema te lo ponen fácil: parseo de argumentos, salida con colores, barras de progreso, lectura de ficheros CSV, llamadas HTTP... todo está a un pip install de distancia.

Los casos de uso habituales son automatizar tareas repetitivas, crear herramientas internas para el equipo, escribir scripts de despliegue o construir procesadores de datos que se integren en pipelines. Para todo eso, la CLI es la interfaz perfecta: sin interfaz gráfica, sin servidor, sin dependencias extra. Simplemente funciona.

argparse: la librería estándar

Si no quieres instalar nada, argparse ya viene con Python. Es la opción más conservadora, pero cubre perfectamente muchos casos.

import argparse

parser = argparse.ArgumentParser(description='Procesa un fichero de datos')
parser.add_argument('fichero', help='Ruta al fichero de entrada')
parser.add_argument('--salida', default='resultado.csv', help='Fichero de salida')
parser.add_argument('--verbose', action='store_true', help='Mostrar más información')
parser.add_argument('--limite', type=int, default=100, help='Número máximo de filas')

args = parser.parse_args()
print(args.fichero, args.salida, args.verbose, args.limite)

Con esto ya tienes argumentos posicionales (fichero), opcionales con valor (--salida, --limite), flags booleanos (--verbose) y tipos automáticos. El parámetro type=int hace que argparse convierta el valor por ti y muestre un error descriptivo si el usuario pasa algo que no es un número.

Lo mejor es que --help se genera solo, con los textos que has puesto en help=. No tienes que escribir ni una línea más.

Subcomandos con argparse

Si tu herramienta necesita varios modos de operación, como git commit o git push, puedes usar subparsers:

parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='comando')

sub_listar = subparsers.add_parser('listar', help='Lista los elementos')
sub_listar.add_argument('--filtro', help='Filtro de búsqueda')

sub_borrar = subparsers.add_parser('borrar', help='Borra un elemento')
sub_borrar.add_argument('id', type=int, help='ID del elemento a borrar')

args = parser.parse_args()

if args.comando == 'listar':
    print(f'Listando con filtro: {args.filtro}')
elif args.comando == 'borrar':
    print(f'Borrando elemento {args.id}')

argparse es suficiente cuando tu herramienta es interna, la usas tú o un equipo pequeño y no necesitas una experiencia de usuario pulida. En cuanto empieces a querer colores, confirmaciones interactivas o algo más amigable, es el momento de mirar Click.

Click: el siguiente nivel

Click usa decoradores para definir los comandos, lo que hace que el código quede mucho más limpio. Primero instálalo:

pip install click

Un comando básico con Click tiene esta pinta:

import click

@click.command()
@click.argument('fichero')
@click.option('--salida', default='resultado.csv', help='Fichero de salida')
@click.option('--verbose', is_flag=True, help='Mostrar más información')
@click.option('--limite', default=100, type=int, help='Número máximo de filas')
def procesar(fichero, salida, verbose, limite):
    """Procesa un fichero de datos y guarda el resultado."""
    click.echo(f'Procesando {fichero}...')
    if verbose:
        click.echo(click.style('Modo verbose activado', fg='yellow'))

if __name__ == '__main__':
    procesar()

Fíjate en click.echo(): es preferible a print() porque gestiona mejor la codificación en terminales Windows y maneja los pipes correctamente. Si redirigues la salida a un fichero, click.echo() no falla donde sí lo haría un print() con caracteres especiales.

Grupos de comandos

Para estructurar una herramienta con subcomandos, Click tiene @click.group():

@click.group()
def cli():
    """Herramienta de gestión de proyectos."""
    pass

@cli.command()
@click.argument('nombre')
def crear(nombre):
    """Crea un proyecto nuevo."""
    click.echo(f'Creando proyecto: {nombre}')

@cli.command()
def listar():
    """Lista todos los proyectos."""
    click.echo('Proyectos disponibles...')

if __name__ == '__main__':
    cli()

Confirmaciones interactivas y colores

Click tiene soporte nativo para pedir confirmación antes de acciones destructivas:

@cli.command()
@click.argument('id', type=int)
def borrar(id):
    """Borra un proyecto."""
    click.confirm(f'¿Seguro que quieres borrar el proyecto {id}?', abort=True)
    click.echo(click.style(f'Proyecto {id} borrado.', fg='red'))

Si el usuario responde que no, abort=True cancela la ejecución sin necesidad de gestionar el flujo a mano. Con click.style() puedes pintar el texto de cualquier color disponible en ANSI: red, green, yellow, blue, cyan, magenta y white.

Typer: Click con type hints

Typer está construido sobre Click pero aprovecha las anotaciones de tipo de Python para inferir el comportamiento de cada parámetro. Así el código queda aún más limpio:

pip install typer
import typer
from pathlib import Path

app = typer.Typer()

@app.command()
def procesar(
    fichero: Path = typer.Argument(..., help='Ruta al fichero de entrada'),
    limite: int = typer.Option(100, help='Número máximo de filas'),
    verbose: bool = typer.Option(False, '--verbose', '-v', help='Modo detallado'),
):
    """Procesa un fichero y muestra los resultados."""
    if not fichero.exists():
        typer.echo(f'Error: el fichero {fichero} no existe.', err=True)
        raise typer.Exit(1)
    typer.echo(f'Procesando {fichero} (límite: {limite} filas)')

if __name__ == '__main__':
    app()

Typer infiere que fichero es de tipo Path y valida la entrada automáticamente. Además genera autocompletado para bash y zsh sin configuración adicional. Si tu equipo ya usa type hints en el proyecto, Typer encaja de forma natural y reduce el código repetitivo.

Rich: salida bonita en la terminal

Rich es una librería para hacer que la salida en terminal sea legible y visualmente clara. No se trata de poner colores por decoración, sino de presentar información de forma que se pueda leer de un vistazo:

pip install rich

Tablas

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

console = Console()

tabla = Table(title='Resultados del análisis')
tabla.add_column('Fichero', style='cyan')
tabla.add_column('Tamaño', justify='right')
tabla.add_column('Estado', style='bold')

tabla.add_row('datos.csv', '2.3 MB', '[green]OK[/green]')
tabla.add_row('errores.log', '45 KB', '[red]Con errores[/red]')
tabla.add_row('config.json', '1.2 KB', '[green]OK[/green]')

console.print(tabla)

Barra de progreso

Para operaciones que tardan, una barra de progreso marca la diferencia entre una herramienta que parece que se ha colgado y una que da confianza:

from rich.progress import Progress
import time

with Progress() as progress:
    tarea = progress.add_task('[cyan]Procesando ficheros...', total=100)
    for i in range(100):
        time.sleep(0.05)
        progress.update(tarea, advance=1)

Spinners y Markdown

from rich.console import Console
from rich.markdown import Markdown

console = Console()

# Spinner mientras se ejecuta algo
with console.status('[bold green]Conectando con la API...'):
    time.sleep(2)

# Renderizar Markdown en terminal
md = Markdown('## ResultadonnEl proceso **completó** correctamente.')
console.print(md)

Rich se lleva muy bien con Click y Typer: los usas juntos sin ningún conflicto. Puedes gestionar los argumentos con Click y usar Rich solo para la salida.

Distribuir tu herramienta

Un script que vive en tu máquina tiene un alcance limitado. Para que otros lo puedan instalar con pip install, necesitas configurar el proyecto correctamente.

pyproject.toml

[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "mi-herramienta"
version = "0.1.0"
description = "Herramienta CLI para procesar ficheros de datos"
requires-python = ">=3.9"
dependencies = ["click>=8.0", "rich>=13.0"]

[project.scripts]
mi-herramienta = "mi_herramienta.cli:cli"

La clave está en [project.scripts]: ahí defines el nombre del comando que tendrá tu herramienta en la terminal y a qué función apunta. Después de instalarlo, el usuario escribe mi-herramienta directamente, sin necesidad de python -m ....

Instalación en desarrollo

# Instala en modo editable: los cambios en el código se reflejan al instante
pip install -e .

Publicar en PyPI

pip install build twine

# Genera los paquetes
python -m build

# Sube a PyPI (necesitas cuenta en pypi.org)
twine upload dist/*

pipx: la alternativa recomendada para herramientas

Si tu herramienta es un programa que el usuario va a ejecutar, no una librería que va a importar, pipx es la mejor forma de distribuirla. Instala la herramienta en un entorno virtual aislado, sin tocar el Python del sistema:

pipx install mi-herramienta

El usuario la tiene disponible globalmente, pero sin riesgo de conflictos de dependencias. Para desarrollo local también funciona bien: pipx install -e .

Ejemplo completo: herramienta de renombrado de ficheros

Para cerrar, un ejemplo práctico que une todo lo anterior. Esta herramienta lista ficheros con un filtro, muestra una previsualización de los cambios y pide confirmación antes de aplicarlos:

import click
from rich.console import Console
from rich.table import Table
from pathlib import Path
import re

console = Console()

@click.group()
def cli():
    """Herramienta de renombrado de ficheros."""
    pass

@cli.command()
@click.argument('directorio', type=click.Path(exists=True, file_okay=False))
@click.option('--filtro', default='*', help='Patrón glob para filtrar ficheros')
def listar(directorio, filtro):
    """Lista los ficheros del directorio con un filtro opcional."""
    ruta = Path(directorio)
    ficheros = list(ruta.glob(filtro))

    if not ficheros:
        console.print('[yellow]No se encontraron ficheros con ese filtro.[/yellow]')
        return

    tabla = Table(title=f'Ficheros en {directorio}')
    tabla.add_column('Nombre', style='cyan')
    tabla.add_column('Tamaño', justify='right')

    for f in sorted(ficheros):
        if f.is_file():
            tabla.add_row(f.name, f'{f.stat().st_size / 1024:.1f} KB')

    console.print(tabla)

@cli.command()
@click.argument('directorio', type=click.Path(exists=True, file_okay=False))
@click.argument('patron')
@click.argument('reemplazo')
@click.option('--filtro', default='*', help='Patrón glob para filtrar ficheros')
@click.option('--dry-run', is_flag=True, help='Muestra los cambios sin aplicarlos')
def renombrar(directorio, patron, reemplazo, filtro, dry_run):
    """Renombra ficheros reemplazando PATRON por REEMPLAZO en el nombre."""
    ruta = Path(directorio)
    ficheros = [f for f in ruta.glob(filtro) if f.is_file()]
    cambios = []

    for f in sorted(ficheros):
        nuevo_nombre = re.sub(patron, reemplazo, f.name)
        if nuevo_nombre != f.name:
            cambios.append((f, f.parent / nuevo_nombre))

    if not cambios:
        console.print('[yellow]Ningún fichero coincide con el patrón.[/yellow]')
        return

    tabla = Table(title='Cambios propuestos')
    tabla.add_column('Original', style='red')
    tabla.add_column('Nuevo', style='green')

    for original, nuevo in cambios:
        tabla.add_row(original.name, nuevo.name)

    console.print(tabla)

    if dry_run:
        console.print('[cyan]Modo dry-run: no se han aplicado cambios.[/cyan]')
        return

    click.confirm(f'n¿Aplicar estos {len(cambios)} cambios?', abort=True)

    for original, nuevo in cambios:
        original.rename(nuevo)
        console.print(f'[green]?[/green] {original.name} ? {nuevo.name}')

    console.print(f'n[bold green]{len(cambios)} ficheros renombrados.[/bold green]')

if __name__ == '__main__':
    cli()

Para usarla: python renombrar.py listar ./fotos --filtro "*.jpg" lista las fotos, y python renombrar.py renombrar ./fotos "IMG_" "foto_" --filtro "*.jpg" --dry-run muestra qué cambiaría sin tocar nada. Sin --dry-run, pide confirmación antes de renombrar.

Si quieres seguir por este camino, te puede interesar ver cómo encaja esto con la automatización de tareas con Python, o explorar interfaces de usuario en Python más allá del terminal cuando el proyecto crezca y necesites algo visual.

Imagen: Pexels / Tima Miroshnichenko

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP