`sys.argv`, streams estándar y códigos de salida en Python

Cuando escribes una herramienta CLI, tu script vive en un ecosistema más grande: pipelines de shell, scripts de CI/CD, otros procesos que lo invocan y deciden qué hacer según si tuvo éxito o falló. Python expone ese ecosistema a través del módulo sys, y entender cómo funciona internamente cambia la forma en que diseñas tus herramientas.

sys.argv es simplemente una lista de strings que el intérprete popula antes de ejecutar tu código. argv[0] siempre es el nombre del script (o la ruta con la que fue invocado), y argv[1:] contiene los argumentos que el usuario pasó. No hay magia: el SO pasa esos strings al proceso Python y Python los mete en esa lista. Si invocas python procesar.py --verbose archivo.txt, tienes ['procesar.py', '--verbose', 'archivo.txt'].

Los tres streams —sys.stdin, sys.stdout y sys.stderr— son file objects completamente funcionales desde el primer instante de ejecución. Stdout es para output normal; stderr es para mensajes de error y diagnóstico. La distinción importa porque cuando encadenas comandos con pipes (|), solo stdout fluye al siguiente proceso. Si mezclas errores con output en stdout, el proceso siguiente recibe basura.

Leer de sys.stdin con for linea in sys.stdin: funciona tanto si el usuario escribe interactivamente como si hay un pipe. Python simplemente consume el file object hasta EOF, igual que con cualquier archivo abierto en modo lectura.

Los códigos de salida son el protocolo de comunicación entre procesos. sys.exit(0) indica éxito; cualquier valor distinto de cero indica error. Esto no es una convención informal: bash, los operadores && y ||, y cada plataforma de CI/CD (GitHub Actions, GitLab CI, Jenkins) leen ese código para decidir si el pipeline continúa o falla. Si tu script lanza una excepción no capturada, Python sale con código 1 automáticamente, pero ese stacktrace también va a stderr, no a stdout.

Cuando un script termina normalmente sin llamar a sys.exit(), el código de salida es 0. Si llamas sys.exit("mensaje de error"), Python imprime ese string a stderr y sale con código 1.

#!/usr/bin/env python3
"""
procesar_csv.py – Lee filas de stdin o de un archivo, filtra por columna,
y produce output compatible con pipes.

Uso:
  cat datos.csv | python procesar_csv.py nombre
  python procesar_csv.py nombre datos.csv
"""

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

# Console separado para stderr: los mensajes de diagnóstico no
# contaminan stdout cuando el script se usa en un pipe.
err_console = Console(stderr=True, style="bold red")
out_console = Console()


def parse_args() -> tuple[str, object]:
    """Devuelve (columna_filtro, file_object_a_leer)."""
    if len(sys.argv) < 2:
        err_console.print(
            f"Uso: {sys.argv[0]} <columna> [archivo.csv]"
        )
        sys.exit(1)  # salida temprana: argumentos insuficientes

    columna = sys.argv[1]

    if len(sys.argv) >= 3:
        try:
            # Abrimos en modo texto con newline="" como pide el módulo csv
            return columna, open(sys.argv[2], newline="", encoding="utf-8")
        except FileNotFoundError:
            err_console.print(f"Archivo no encontrado: {sys.argv[2]}")
            sys.exit(2)  # código distinto: permite distinguir el tipo de error
    else:
        # Sin archivo → leemos de stdin (soporte para pipes)
        return columna, sys.stdin


def procesar(columna: str, fuente) -> None:
    reader = csv.DictReader(fuente)

    if reader.fieldnames is None:
        err_console.print("El CSV está vacío o no tiene cabecera.")
        sys.exit(1)

    if columna not in reader.fieldnames:
        err_console.print(
            f"Columna '{columna}' no existe. "
            f"Columnas disponibles: {', '.join(reader.fieldnames)}"
        )
        sys.exit(1)

    # Construimos la tabla solo con las filas que tienen valor en la columna
    filas = [fila for fila in reader if fila.get(columna, "").strip()]

    if not filas:
        # Escribimos a stderr para no romper un pipe vacío que aún es válido
        err_console.print(f"Sin resultados para columna '{columna}'.")
        sys.exit(0)  # no es un error, simplemente no hay datos

    tabla = Table(title=f"Filas con '{columna}' no vacío", highlight=True)

    for campo in reader.fieldnames:
        tabla.add_column(campo, overflow="fold")

    for fila in filas:
        tabla.add_row(*[fila[c] for c in reader.fieldnames])

    out_console.print(tabla)


def main() -> None:
    columna, fuente = parse_args()

    try:
        procesar(columna, fuente)
    finally:
        # Cerramos solo si abrimos nosotros el archivo; stdin no se cierra
        if fuente is not sys.stdin:
            fuente.close()


if __name__ == "__main__":
    main()

Desglose del código

La primera decisión interesante es tener dos instancias de Console. rich escribe a stdout por defecto, pero necesitamos que los mensajes de error vayan a stderr. Crear Console(stderr=True) nos da exactamente eso: el output diagnóstico nunca contamina el stream de datos.

Fíjate en los códigos de salida diferenciados: usamos 1 para errores lógicos genéricos y 2 para archivo no encontrado. Esto permite que un script de shell que nos invoque distinga entre “argumentos mal formados” y “archivo inexistente” con $?, y reaccione diferente. En CI/CD no suele importar el valor exacto (cualquier no-cero falla el step), pero en scripts de bash complejos puede ser la diferencia entre reintentar o abortar.

El soporte para pipes viene de sys.stdin como fuente por defecto. csv.DictReader acepta cualquier iterable de strings, y sys.stdin cumple ese contrato perfectamente. Cuando haces cat datos.csv | python procesar_csv.py nombre, Python no sabe ni le importa que hay un pipe; simplemente lee líneas hasta EOF.

La decisión de sys.exit(0) cuando no hay resultados en lugar de sys.exit(1) es un trade-off de diseño consciente: “sin datos” no es un fallo del script, es una condición válida. Si usáramos 1, un pipeline que encadene este script con un grep posterior fallaría aunque todo funcionó correctamente.

El bloque finally en main() garantiza que cerramos el archivo si lo abrimos nosotros, pero no tocamos sys.stdin: cerrarlo en un script que puede ser parte de un pipeline más largo es un error sutil que puede cortar el flujo de datos prematuramente.

Errores que debes conocer

Error: Escribir mensajes de error a stdout en lugar de stderr contamina el output cuando el script se usa en un pipe.

# ❌ Wrong
print(f"Error: archivo no encontrado {path}")
sys.exit(1)

# ✅ Right
print(f"Error: archivo no encontrado {path}", file=sys.stderr)
sys.exit(1)

print acepta file= como argumento; usarlo con sys.stderr mantiene stdout limpio para datos.


Error: Olvidar llamar a sys.exit() en rutas de error deja que el script termine con código 0 aunque algo salió mal.

# ❌ Wrong
if not datos:
    print("Sin datos", file=sys.stderr)
    return  # el proceso termina con código 0 → el pipeline cree que todo fue bien

# ✅ Right
if not datos:
    print("Sin datos", file=sys.stderr)
    sys.exit(1)

Un proceso que retorna 0 en condición de error hace que los operadores && de bash y los steps de CI continúen cuando deberían detenerse.


Error: Leer sys.argv[1] sin verificar la longitud lanza IndexError en lugar de un mensaje de uso claro.

# ❌ Wrong
columna = sys.argv[1]  # IndexError si el usuario no pasó argumentos

# ✅ Right
if len(sys.argv) < 2:
    print(f"Uso: {sys.argv[0]} <columna>", file=sys.stderr)
    sys.exit(1)
columna = sys.argv[1]

Un IndexError genera un stacktrace en stderr y sale con código 1, pero la experiencia de usuario es terrible; el mensaje de uso es lo que corresponde aquí.

193

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio