Cómo construir un CLI robusto en Python con Click, testing y empaquetado

python Cómo construir un CLI robusto en Python con Click, testing y empaquetado

Cómo construir un CLI robusto en Python con Click, testing y empaquetado

En este tutorial vas a crear un CLI profesional en Python usando Click. Cubriremos estructura de proyecto, manejo de configuración, logging, tests con pytest, autocompletado, y empaquetado para distribuirlo como comando instalable. Código completo y explicaciones del porqué en cada paso.

Por qué Click

Click es simple, opinado y extensible. Maneja parsing, subcomandos, validación y autocompletado con muy poco código. Es ideal para CLIs que deben crecer de manera mantenible.

Estructura del proyecto

cli_project/
├── cli_project
│   ├── __init__.py
│   ├── cli.py
│   ├── config.py
│   ├── commands
│   │   ├── __init__.py
│   │   ├── greet.py
│   │   └── compute.py
│   └── utils.py
├── tests
│   ├── test_greet.py
│   └── test_compute.py
├── pyproject.toml
├── setup.cfg
└── README.md

Separar comandos en un paquete commands permite escalar fácilmente. config.py centraliza carga de configuración y utils.py funciones auxiliares reusables.

Instalación de dependencias

pip install click pytest pytest-mock

Archivo principal: cli.py

import click
from cli_project.commands import greet, compute
from cli_project.config import load_config
from cli_project.utils import setup_logging

@click.group()
@click.option('-v', '--verbose', is_flag=True, help='Habilita logging verbose')
@click.option('--config', type=click.Path(exists=True), help='Archivo de configuración')
@click.pass_context
def cli(ctx, verbose, config):
    """CLI principal."""
    cfg = load_config(config)
    setup_logging(verbose or cfg.get('verbose', False))
    ctx.obj = {'config': cfg}

# Registrar subcomandos
cli.add_command(greet.cli)
cli.add_command(compute.cli)

if __name__ == '__main__':
    cli()

Usamos ctx.obj para pasar configuración común entre comandos. Esto evita usar variables globales.

Comando ejemplo: greet.py

import click

@click.command()
@click.argument('name')
@click.option('--shout', is_flag=True, help='Convertir a mayúsculas')
@click.pass_context
def cli(ctx, name, shout):
    """Saluda a NAME"""
    cfg = ctx.obj.get('config', {})
    greeting = cfg.get('greeting', 'Hola')
    result = f"{greeting}, {name}"
    if shout:
        result = result.upper()
    click.echo(result)

Comando ejemplo: compute.py

import click
from cli_project.utils import heavy_computation

@click.command()
@click.option('--n', type=int, default=10, help='Tamaño del cálculo')
@click.option('--async', 'use_async', is_flag=True, help='Usar versión asíncrona (si está disponible)')
@click.pass_context
def cli(ctx, n, use_async):
    """Realiza un cálculo pesado"""
    # La función heavy_computation puede seleccionar impl. async si es necesario
    result = heavy_computation(n, use_async=use_async)
    click.echo(f'Resultado: {result}')

utils.py (ejemplo de utilidades)

import logging
import time

logger = logging.getLogger(__name__)

def setup_logging(verbose: bool):
    level = logging.DEBUG if verbose else logging.INFO
    logging.basicConfig(level=level, format='[%(levelname)s] %(message)s')


def heavy_computation(n: int, use_async: bool = False):
    # Ejemplo simple; en producción reemplaza con código real
    logger.debug('Iniciando heavy_computation n=%s use_async=%s', n, use_async)
    total = 0
    for i in range(n):
        time.sleep(0.01)  # simula trabajo
        total += i * i
    return total

Configuración: config.py

import json
from pathlib import Path

DEFAULT = {
    'greeting': 'Hola',
    'verbose': False
}


def load_config(path: str | None) -> dict:
    if not path:
        return DEFAULT.copy()
    p = Path(path)
    try:
        return json.loads(p.read_text())
    except Exception:
        return DEFAULT.copy()

Usamos JSON por simplicidad; cambia a YAML o TOML si prefieres. Importante: siempre devuelve una copia para evitar mutaciones globales inesperadas.

Tests con pytest

Prueba el comando greet y la función de cómputo. Usa el runner de Click para tests unitarios.

# tests/test_greet.py
from click.testing import CliRunner
from cli_project.commands.greet import cli as greet_cli


def test_greet_default():
    runner = CliRunner()
    result = runner.invoke(greet_cli, ['Alice'])
    assert result.exit_code == 0
    assert 'Hola, Alice' in result.output


def test_greet_shout():
    runner = CliRunner()
    result = runner.invoke(greet_cli, ['Alice', '--shout'])
    assert 'HOLA, ALICE' in result.output

# tests/test_compute.py
from cli_project.utils import heavy_computation


def test_heavy_computation():
    assert heavy_computation(5) == sum(i * i for i in range(5))

Ejecuta: pytest -q

Autocompletado

Click ofrece soporte para autocompletado. Para bash:

_MYCLI_COMPLETE=source_bash mycli > /etc/bash_completion.d/mycli
# o para usuario:
_Mycli_COMPLETE=source_bash mycli > ~/.mycli_completion

Ajusta mycli por el nombre del entry point (ver empaquetado).

Empaquetado y entry point

Usa pyproject.toml para modern packaging. Ejemplo mínimo:

[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "cli-project"
version = "0.1.0"
description = "Ejemplo de CLI con Click"
authors = [ {name = "Tu Nombre"} ]

[project.scripts]
mycli = "cli_project.cli:cli"

Después instala en editable para desarrollo: pip install -e .. El comando mycli estará disponible globalmente (o en el virtualenv).

Buenas prácticas aplicadas

  • Separación de responsabilidades: comandos pequeños y funciones reutilizables
  • Contexto de Click (ctx.obj) para estado compartido
  • Logging configurable, no prints dispersos
  • Tests que no dependen de shell real (CliRunner)
  • Empaquetado con entry point para instalación como comando

Errores comunes y cómo evitarlos

  • No pasar estado por ctx.obj y usar variables globales => pruebas difíciles.
  • Hacer la lógica dentro del comando en vez de delegar => difícil de testear.
  • No probar opciones edge (flags, inputs inválidos) => bugs en producción.

Checklist antes de publicar

  1. Añadir README con ejemplos de uso
  2. Versionado semántico
  3. Agregar CI (pytest + flake8/mypy opcional)
  4. Pruebas de integración para el comando instalado

Siguiente paso avanzado

Si necesitas rendimiento en cómputos, separa la lógica en un microservicio o añade una implementación asíncrona/numba y prueba con benchmarks. Para producción, agrega CI que construya la rueda (wheel) y publique en un índice privado o PyPI, y configura autocompletado automáticamente desde el instalador.

Consejo avanzado: implementa logging estructurado (JSON) y soporte para telemetría (p. ej. Sentry/Prometheus) en escenarios donde el CLI se use en pipelines automatizados.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión