subprocess.run: la API moderna para ejecutar procesos

Cuando necesitas lanzar un proceso externo desde Python, la respuesta correcta desde Python 3.5 es subprocess.run. No os.system, no os.popen, no subprocess.check_output. Esas APIs existen todavía en la stdlib, pero son fragmentos de una evolución histórica que culminó en subprocess.run precisamente porque las anteriores eran incómodas, inseguras o ambas.

subprocess.run es una función síncrona que lanza un proceso hijo, espera a que termine, y te devuelve un objeto subprocess.CompletedProcess con todo lo que necesitas saber: salida estándar, salida de error y código de retorno. La API anterior te obligaba a elegir: os.system si solo querías el código de retorno, os.popen si querías stdout pero no stderr, check_output si querías stdout y que lanzara excepción en error. subprocess.run unifica todo eso en un único punto de entrada configurable.

La decisión de diseño más importante que debes entender es pasar el comando como lista de strings, nunca como un string. Cuando pasas ['ls', '-la', '/tmp'], Python construye el array argv directamente y exec lo pasa al SO sin interpretación. Cuando pasas 'ls -la /tmp' como string con shell=True, estás invocando /bin/sh -c 'ls -la /tmp' — y si alguna parte de ese string viene de input externo, has abierto una vulnerabilidad de inyección de shell. La lista es el camino predeterminado seguro.

El parámetro capture_output=True es azúcar para stdout=subprocess.PIPE, stderr=subprocess.PIPE: le dice al runtime que conecte los file descriptors del proceso hijo a tuberías internas en lugar de heredarlos del proceso padre (lo que imprimiría directo a tu terminal). Sin él, la salida del proceso hijo va al stdout/stderr del proceso Python que lo lanzó, y no puedes leer nada programáticamente.

text=True decodifica esas tuberías usando la codificación del locale (típicamente UTF-8 en sistemas modernos) para que .stdout y .stderr sean str en lugar de bytes. Omitirlo y lidiar con b'...' es ruido innecesario en la mayoría de los casos. La excepción es cuando procesas output binario — ejecutables que emiten bytes arbitrarios, comprimir/descomprimir, etc.

check=True convierte un código de retorno distinto de 0 en subprocess.CalledProcessError. Sin él, subprocess.run es silenciosamente optimista: si tu comando falla, obtienes un CompletedProcess con returncode=1 y no hay ninguna señal de error salvo que tú la verifiques explícitamente. En código de producción, check=True es el comportamiento correcto por defecto salvo que deliberadamente quieras inspeccionar el fallo.

timeout es el parámetro que más gente omite y que más problemas genera en producción. Si el proceso hijo cuelga, sin timeout tu hilo de Python queda bloqueado indefinidamente. subprocess.run lanza subprocess.TimeoutExpired si el proceso no termina en el tiempo dado, y tú puedes manejarlo limpiamente.

import subprocess
import sys
from pathlib import Path


def count_lines_in_directory(directory: str) -> dict[str, int]:
    """
    Cuenta líneas en archivos .py de un directorio usando wc -l.
    Devuelve un dict {nombre_archivo: num_lineas}.
    """
    # Construimos argv como lista — sin shell=True, sin riesgo de inyección
    find_cmd = [
        "find", directory,
        "-name", "*.py",
        "-not", "-path", "*/__pycache__/*",
    ]

    try:
        find_result = subprocess.run(
            find_cmd,
            capture_output=True,   # stdout y stderr en tuberías internas
            text=True,             # decodificar a str, no bytes
            check=True,            # CalledProcessError si find falla
            timeout=10,            # nunca bloquear indefinidamente
        )
    except subprocess.TimeoutExpired:
        print(f"find tardó demasiado en {directory}", file=sys.stderr)
        return {}
    except subprocess.CalledProcessError as exc:
        # exc.returncode, exc.stdout, exc.stderr disponibles
        print(f"find falló (código {exc.returncode}): {exc.stderr}", file=sys.stderr)
        return {}

    files = find_result.stdout.strip().splitlines()
    if not files:
        return {}

    # wc -l acepta múltiples archivos; el último "total" lo descartamos
    wc_result = subprocess.run(
        ["wc", "-l", *files],
        capture_output=True,
        text=True,
        check=True,
        timeout=15,
    )

    counts: dict[str, int] = {}
    for line in wc_result.stdout.strip().splitlines():
        parts = line.split()
        # wc -l emite "  42 /path/to/file.py"; la línea "total" tiene " total"
        if len(parts) == 2 and parts[1] != "total":
            num, path = parts
            counts[Path(path).name] = int(num)

    return counts


if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "."
    results = count_lines_in_directory(target)
    for filename, lines in sorted(results.items(), key=lambda x: -x[1]):
        print(f"{lines:>6}  {filename}")

Lo que hace cada decisión

Fíjate en que find_cmd se construye como una lista Python normal. Cuando tienes parámetros que vienen de variables, simplemente los añades a la lista — ningún carácter especial tiene poder aquí porque no hay shell interpretando el string. Si un nombre de directorio contiene espacios o comillas, no importa: llega al proceso hijo sin modificar.

El objeto que devuelve cada subprocess.run es un CompletedProcess. En el código de arriba no lo guardamos explícitamente cuando lo usamos inmediatamente (accedemos a .stdout en la línea siguiente), pero cuando necesitas inspeccionar la respuesta más tarde, asignarlo a una variable es lo natural: result.returncode, result.stdout, result.stderr.

La excepción CalledProcessError hereda el contexto completo del fallo: .returncode te dice cuál fue el código, .stdout y .stderr contienen la salida capturada hasta ese punto. Eso es lo que hace inútil a os.system para cualquier cosa seria — te devuelve solo el código de retorno y pierdes completamente el contexto del error.

Hay un caso deliberado donde no uso check=True: si quisiera ejecutar algo como grep para buscar patrones, grep retorna código 1 cuando no encuentra coincidencias — eso no es un error, es un resultado válido. En esos casos capturas el CompletedProcess y examinas .returncode tú mismo.

El segundo subprocess.run usa [*files] para desempaquetar la lista de rutas directamente en argv. Esto es completamente seguro independientemente de cuántos archivos haya o cómo se llamen.

Errores que debes conocer

Error: pasar el comando como string sin shell=True, lo que hace que Python intente ejecutar el string completo como nombre de ejecutable.

# ❌ Wrong
result = subprocess.run("ls -la /tmp", capture_output=True, text=True)
# FileNotFoundError: [Errno 2] No such file or directory: 'ls -la /tmp'

# ✅ Right
result = subprocess.run(["ls", "-la", "/tmp"], capture_output=True, text=True)

Cuando el primer argumento es un string, subprocess intenta ejecutarlo literalmente como nombre de archivo. Como no existe ningún ejecutable llamado "ls -la /tmp", falla.


Error: omitir check=True y asumir que si no hay excepción, el comando funcionó.

# ❌ Wrong
result = subprocess.run(["git", "push", "origin", "main"], capture_output=True, text=True)
# Si el push falla, result.returncode != 0 pero el código sigue ejecutándose
deploy_to_production()  # se ejecuta aunque el push falló

# ✅ Right
result = subprocess.run(
    ["git", "push", "origin", "main"],
    capture_output=True,
    text=True,
    check=True,  # CalledProcessError si retorna != 0
)
deploy_to_production()

subprocess.run no es inherentemente “fail-fast” — tienes que pedirlo explícitamente con check=True o verificar result.returncode tú mismo.


Error: capturar output con capture_output=True pero no usar text=True, y luego tratar el resultado como string.

# ❌ Wrong
result = subprocess.run(["hostname"], capture_output=True, check=True)
hostname = result.stdout.strip()        # AttributeError: bytes no tiene strip que devuelva str útil
print(f"Host: {hostname.upper()}")      # b'myserver\n'.upper() funciona pero es b'MYSERVER\n'

# ✅ Right
result = subprocess.run(["hostname"], capture_output=True, text=True, check=True)
hostname = result.stdout.strip()        # str limpio
print(f"Host: {hostname.upper()}")

Sin text=True, .stdout es bytes. Eso funciona para procesamiento binario, pero para texto es ruido que se propaga por todo el código que consume el resultado.

194

Dejar un comentario

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

Scroll al inicio