Skip to content

English · Español

Higiene de ingeniería — pre-commit, ruff, mypy, bandit, pip-audit, nbstripout

Resumen. "Higiene" no es estilo: es la red de seguridad que detiene bugs invisibles (tipos rotos, imports muertos, secretos en cuadernos, CVEs en dependencias) antes de llegar al commit. Las reglas las ejecuta pre-commit; las repos las ejecuta CI.

§0 El principio

Atrapa los defectos lo más cerca posible de su introducción. El coste de un defecto crece aproximadamente de forma geométrica con lo lejos que viaja:

tipo mal en editor → atrapado por mypy        ~5 segundos perdidos
atrapado por pre-commit                       ~30 segundos
atrapado por CI                               ~5 minutos
atrapado por un compañero en review           ~horas
atrapado en producción                        horas a días

La Fase 0 conecta cada puerta para que la primera fila sea el caso común.

§1 Las puertas

Puerta Qué atrapa Cuándo corre
ruff check imports muertos, nombres no definidos, defaults mutables, uso indebido de comprehension, patrones deprecados pre-commit + CI + LSP del editor
ruff format drift de estilo (bloquea el 80% de los comentarios de "estilo" en PR) pre-commit + CI
mypy --strict bugs de forma de tensor (vía type hints), mal manejo de Optional, propagación de Any pre-commit (solo src/) + CI
pytest regresiones funcionales, fixture autouse de seed atrapa no-determinismo manual + CI
bandit pickle.load sobre input no confiable, subprocess(shell=True, user_input), assert en producción, contraseñas hardcoded, criptografía débil pre-commit + CI
pip-audit CVEs conocidas en cualquier dep del lockfile just audit-deps + CI semanal
nbstripout output de notebooks commiteado (secretos, GBs de arrays, ruido en diff) pre-commit
nbqa-ruff + nbqa-mypy los mismos chequeos de arriba aplicados a notebooks pre-commit
check-added-large-files checkpoint de modelo / dataset commiteado por accidente pre-commit
detect-private-key claves SSH / PEM commiteadas por accidente pre-commit

§2 ruff — el linter + formateador

Usamos las reglas en pyproject.toml: - E / F — pycodestyle / pyflakes (lo básico). - I — orden de imports (sustituye a isort). - B — bugbear (defaults mutables, except: sin re-raise, etc.). - UP — pyupgrade (usa sintaxis moderna — Path sobre os.path, uniones |, etc.). - N — pep8-naming. - C4 — corrección de comprehensions (evita list(map(...)) cuando una comprehension es más clara). - RET — problemas de return (return None redundante, etc.). - SIM — simplificaciones de código.

ignore = ["E501"] porque el formateador fuerza la longitud de línea, y E501 de ruff se vuelve redundante.

ruff format es opinado — aceptamos sus elecciones para zanjar el debate. ¿Indent de dos espacios? ¿Strings con comilla simple? Gana el formateador. El tiempo ahorrado es real.

§3 mypy --strict

El modo strict agrupa: - --disallow-untyped-defs — cada función tiene type hints. - --disallow-any-generics — nada de list, dict, tuple desnudos; especifica el parámetro. - --disallow-untyped-decorators — nada de @some_untyped_decorator envenenando tipos en silencio. - --no-implicit-optionaldef f(x: int = None) se rechaza; debe ser Optional[int]. - --warn-return-any — marca si una función anotada como int devuelve Any. - --warn-unreachable — ramas muertas atrapadas.

Tipamos solo src/ (código de producción). Tests y experimentos son intencionadamente no tipados (son desechables / exploratorios). La configuración en pyproject.toml lo refleja.

§3.1 Por qué esto atrapa bugs de ML

Formas de tensor:

def normalize(x: NDArray[np.float32], axis: int = -1) -> NDArray[np.float32]:
    return x - x.mean(axis, keepdims=True)

Un caller posterior que accidentalmente pasa un int para x (p.ej., una reducción mal colocada) es atrapado por mypy antes de que el test siquiera corra. El default de la dimensión axis=-1 se preserva por el sistema de tipos. A medida que añadamos anotaciones de forma con jaxtyping / tensorly en fases posteriores, la cobertura de mypy crecerá.

§4 bandit

Analizador estático de seguridad común en Python (no de estilo). Los que importan para nosotros:

  • B301: pickle.load — la carga de checkpoints en la fase 16+ debe usar safetensors, no pickle, precisamente porque pickle.load puede ejecutar código arbitrario al cargar.
  • B602: subprocess(shell=True) con input del usuario — command injection.
  • B105 / B106: strings de contraseña hardcoded.
  • B324: hashlib.md5 / sha1 — hashes débiles, marca para usos de seguridad.
  • B101: sentencias assert — se eliminan con python -O, así que no valen para chequeos de seguridad (y usamos validación real donde importa).

bandit no es un linter de estilo: busca patrones de seguridad como pickle.load (puede ejecutar código arbitrario) o subprocess(shell=True) con input del usuario.

§5 pip-audit

Lee el lockfile, comprueba cada paquete del lock contra la PyPA Advisory Database. La salida es una lista de CVE con: paquete, versión instalada, versión arreglada, severidad.

Política en este repo: just audit-deps se fuerza desde la Fase 0. Cualquier CVE nueva bloquea el siguiente commit hasta que la dep se actualice o la CVE se marque como no aplicable con una justificación escrita en security/THREATS.md.

§6 nbstripout + nbqa

Los notebooks (*.ipynb) son JSON. Sus celdas outputs contienen imágenes renderizadas, HTML de dataframes, resultados de cómputo — frecuentemente MBs cada una. Commiteados: - Hacen los diffs ilegibles. - Hinchan el repo a GBs en un año. - Filtran secretos (output de celda de os.environ, print(api_key), etc.).

nbstripout corre en pre-commit y elimina outputs + execution_count de cualquier .ipynb antes de aterrizar en un commit. El notebook sigue corriendo idénticamente; solo el artefacto commiteado es la versión sin output.

nbqa-ruff + nbqa-mypy aplican nuestras reglas de ruff / mypy a las celdas de código del notebook. Los mismos estándares que src/. Los notebooks no son blocs de notas de solo-escritura — cuando se commitean, son documentación.

§7 El framework pre-commit

.pre-commit-config.yaml declara los hooks; pre-commit install los conecta como .git/hooks/pre-commit. En cada git commit, los hooks corren solo sobre los archivos staged (rápido — una ejecución típica es < 2 s en un diff de 100 archivos).

Antipatrón: git commit --no-verify para saltar hooks. No lo hacemos. Si un hook falla, arregla el problema subyacente. (CLAUDE.md §0 lo señala explícitamente.)

§8 Cómo se ve esto a nivel de commit

Una ejecución típica de pre-commit con éxito:

end-of-file-fixer....................Passed
trailing-whitespace..................Passed
check-yaml...........................Passed
check-toml...........................Passed
check-added-large-files..............Passed
detect-private-key...................Passed
ruff.................................Passed
ruff-format..........................Passed
mypy.................................Passed
bandit...............................Passed
nbstripout...........................Passed
nbqa-ruff............................Passed
nbqa-mypy............................Passed

Una ejecución que falla detiene el commit. Arregla → re-stage → reintenta.

§9 Conventional commits (una pequeña capa extra)

commitizen está instalado y adoptamos Conventional Commits:

phase: open Phase 1 — linear algebra
theory: derive softmax with max-shift
lab: write the Justfile exercise
feat(utils): add log_versions
fix(seeding): cover np.random.default_rng generator
chore: bump uv 0.4.18 → 0.4.19
docs: rewrite reproducibility theory
test(utils): add seed determinism property test
security: pin transitive cryptography>=43
ci: split lint and test jobs

Por qué: git log --grep '^phase:' da un historial por fase. git log --grep '^security:' da un historial de seguridad. La agrupación no la lleva el tooling; es documentación que sobrevive.

§10 Ejercicios (soluciones en solutions/)

  1. Añade un único hook de pre-commit que rechace cualquier commit que añada un archivo > 1 MiB. (Pista: ya existe en pre-commit-hooks — encuéntralo.)
  2. Sin correr mypy, predice si lo siguiente pasará --strict:
    def f(x):
        return x + 1
    
    Si falla, ¿por qué? Escribe el fix mínimo.
  3. Escribe una configuración de bandit que permita pickle.load solo en archivos llamados test_pickle_*.py. (Caso real: tests de ida y vuelta para formatos legacy.)

§11 Escollos

  • Autoarreglar durante una review. ruff --fix reescribe tu código. Commitea la versión sin arreglar, corre --fix, revisa el diff antes de squashear. Si no, commiteas código que no has leído.
  • Suprimir errores de mypy con # type: ignore sin código de razón. Siempre # type: ignore[error-code] para que la supresión sea auditable y se elimine cuando el bug subyacente esté arreglado.
  • Dejar acumular warnings de bandit. Si pones # nosec en algo, comenta por qué. Plantar # nosec en masa es como una CVE real se cuela.
  • Desactivar nbstripout "solo por este commit". Así es como un output de celda con print(API_KEY) acaba en GitHub.

§12 Siguiente lectura

03-dev-environment.md — IDE, plugins, CLI, personalización de Claude Code.