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.objy 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
- Añadir README con ejemplos de uso
- Versionado semántico
- Agregar CI (pytest + flake8/mypy opcional)
- 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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación