Calidad de código
Linting, formato, tipado y testing en Python
Sobre calidad de código en Python
El stack de calidad de código en Python ha cambiado radicalmente en los últimos cinco años. La pila tradicional, flake8 (lint) + isort (orden de imports) + pylint (lint profundo) + pyupgrade (modernización de sintaxis) + black (formato), ha sido sustituida en proyectos serios por una herramienta única: ruff, un linter y formateador escrito en Rust que reproduce casi todas las reglas anteriores con dos órdenes de magnitud más de velocidad. Hoy una configuración nueva sin razones específicas para lo contrario debería partir de ruff y construir hacia arriba.
Sobre esa base se montan tres capas adicionales:
- Tipado estático:
mypy(referencia histórica) opyright(más rápido, integrado con el LSP de VS Code/Pylance). La elección no es neutra, sus modelos de inferencia divergen en casos límite. - Testing:
pytestcomo estándar de facto, conhypothesispara property-based testing ycoverage(típicamente víapytest-cov) para medir cobertura. - Seguridad:
banditpara detectar patrones inseguros (uso deeval,pickle,subprocessconshell=True, secretos hardcoded).
La capa de integración es pre-commit: ejecuta todas las herramientas anteriores en cada commit, antes de que el código mal formateado o sin tipos llegue al repositorio. Es la pieza que convierte un conjunto de tools sueltas en un quality gate real.
Esta página cataloga ocho herramientas. El orden refleja jerarquía operativa: primero el linter/formateador (ruff, black), después el tipado (mypy, pyright), después testing (pytest, hypothesis, coverage), después seguridad (bandit) y finalmente la capa de integración (pre-commit).
ruff
ruff es un linter y formateador para Python escrito en Rust que reemplaza, en una sola herramienta, a flake8, isort, pylint (parcialmente), pyupgrade, pydocstyle, bandit (subset) y black (vía ruff format). Su selling point real no es solo la velocidad, aunque corre 10-100× más rápido que la pila anterior, , sino consolidar configuración, dependencias y reglas en un único pyproject.toml.
Desarrollado por Astral (los mismos detrás de uv). En 2026 es el estándar de facto en proyectos nuevos y la mayoría de proyectos serios ya han migrado.
Cuándo usarlo
Cualquier proyecto Python nuevo. Cualquier proyecto existente que mantenga flake8 + isort + pylint por inercia, la migración es directa con ruff check --fix y ruff format, y casi siempre rinde una mejora neta en tiempo de CI y simplicidad de configuración.
Cuándo NO usarlo
- Si dependes de reglas específicas de
pylintque aún no están portadas (el conjunto cubierto crece cada release. Consulta Rules antes de migrar). En proyectos muy grandes con reglas custom de pylint, una migración parcial, ruff para lo cubierto, pylint solo para lo no portado, es razonable como paso intermedio. - Si el equipo no acepta
ruff formatcomo reemplazo deblack. Aunque la salida es prácticamente idéntica (diferencias documentadas y mínimas), algunos equipos prefieren mantenerblackpor estabilidad histórica.
Conceptos clave
- Configuración centralizada en
[tool.ruff]delpyproject.toml. La sección[tool.ruff.lint]controla qué reglas se aplican (select = ["E", "F", "I", ...]) y[tool.ruff.format]controla el formateo. - Códigos de regla agrupados por familia:
E/W(pycodestyle),F(pyflakes),I(isort),N(pep8-naming),UP(pyupgrade),B(bugbear),S(bandit),D(pydocstyle),ANN(annotations). Activa familias completas, no reglas sueltas. ruff check --fixaplica correcciones automáticas seguras.--unsafe-fixesactiva las que pueden cambiar semántica.ruff formates un drop-in parablackcon la misma filosofía: opinionado, sin opciones de estilo serias.- Cache incremental en
.ruff_cache/, añádelo a.gitignore.
Patrón mínimo
# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = [
"E", "W", # pycodestyle
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
"S", # flake8-bandit
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
]
ignore = ["E501"] # line-too-long: lo controla el formatter
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"] # asserts permitidos en tests
[tool.ruff.format]
quote-style = "double"ruff check . # lint
ruff check --fix . # lint + autofix seguro
ruff format . # formato
ruff check --watch . # modo desarrolloTrampas habituales
- No actives todas las reglas con
select = ["ALL"]. Genera cientos de avisos contradictorios entre familias (p. ej.DconANN). Empieza por el subset arriba y añade lo que tu equipo realmente quiera. per-file-ignoreses imprescindible paratests/(asserts) y__init__.py(imports no usados que reexportan). Sin ello, mucho ruido.ruff formatvsblack. La salida es prácticamente idéntica pero no bit-a-bit. Si migras, un primer commit de “reformat” masivo es lo limpio, luego no mezclar.target-versiondebe coincidir con elpython_requiresreal. Conpy312activarás reglasUPque sugieren sintaxis de 3.12 (p. ej.typestatements) que romperán en 3.10.
Enlaces
Relacionados en esta página
black, el formateador clásico al queruff formatsustituye.bandit, las reglasSde ruff cubren un subset. Bandit standalone tiene más.pre-commit, donde encaja ruff en el flujo real.
black
black es el formateador de código Python que normalizó la idea de “sin opciones”: una sola fuente de verdad para el estilo, sin discusiones de equipo. Reformatea cualquier código Python válido a un estilo determinista, derivado pero más estricto que PEP 8.
Mantenido por la PSF. Sigue siendo la opción más extendida en proyectos pre-2024, aunque ruff format lo está sustituyendo en proyectos nuevos.
Cuándo usarlo
- Proyectos existentes que ya usan black y donde la migración a
ruff formatno aporta valor inmediato. - Cuando necesitas
--previewu opciones experimentales que ruff aún no ofrece. - En entornos donde la estabilidad de salida bit-a-bit a lo largo de los años es crítica (CI estricto con diffs como gates).
Cuándo NO usarlo
- Proyecto nuevo en 2026: usa
ruff formatsalvo razones específicas. Misma filosofía, integración nativa con el linter, una herramienta menos. - Si solo lo querías por imports:
ruff check --select I --fixreemplazaisort. No necesitas black para eso.
Conceptos clave
- Cero opciones de estilo (
--line-lengthy--target-versionson las únicas relevantes). Resistir la tentación de configurar es el valor del producto. --checkno modifica, solo retorna exit code distinto de cero si el archivo no está formateado. Pensado para CI.--diffmuestra qué cambiaría sin escribir.- Idempotente por diseño: aplicarlo dos veces produce el mismo resultado.
Patrón mínimo
# pyproject.toml
[tool.black]
line-length = 100
target-version = ["py312"]black . # formatear in-place
black --check . # CI: falla si hay archivos sin formatear
black --diff src/ # ver qué cambiaríaTrampas habituales
line-lengthpor debajo de 88 (el default) genera líneas que black no puede partir limpiamente. El resultado se afea. 88-100 es el rango sano.- No combinar con otro formateador. Si activas reglas de formato en ruff y black, entran en bucle. Elige uno.
- Migración black → ruff format: hazla en un único commit de “reformat” masivo y configura
git blame --ignore-revs-filepara no contaminar el historial.
Enlaces
Relacionados en esta página
ruff,ruff formates el sustituto natural.
mypy
mypy es el type checker estático original de Python, desarrollado por Jukka Lehtosalo (que después trabajaría en Dropbox y en el grupo de tipado de Python). Es la referencia histórica del PEP 484 y de todas las extensiones posteriores (PEPs 526, 544, 561, 585, 604, 612, 695…). Su comportamiento define implícitamente qué es “Python tipado correctamente” en buena parte del ecosistema.
Su tradeoff frente a pyright es importante: mypy es más lento (Python puro, no daemon por defecto), su inferencia en algunos casos límite difiere (especialmente con TypeVar y protocols estructurales), y su integración con LSP es menos pulida. A cambio, es la implementación de referencia y produce mensajes generalmente más legibles.
Cuándo usarlo
- Proyectos donde la referencia normativa importa (librerías publicadas en PyPI, código que otros van a tipear contra ti).
- CI estricto con
--strictactivado por bloque. - Equipos donde el editor principal no es VS Code y la integración LSP de pyright no se aprovecha.
Cuándo NO usarlo
- Si tu editor es VS Code/Cursor y ya estás en Pylance, estás usando el motor de
pyrightpara feedback en vivo. Mantener ambos en CI genera divergencias frustrantes, elige uno como fuente de verdad. - Codebases muy grandes con feedback lento: pyright en modo daemon es notablemente más rápido para iteraciones locales.
Conceptos clave
- Configuración en
[tool.mypy]delpyproject.toml. Las flags clave sonstrict = true(activa un conjunto recomendado de chequeos estrictos),disallow_untyped_defs,warn_return_any,no_implicit_optional. # type: ignore[error-code]silencia errores específicos línea a línea. Sin código de error es un anti-pattern, siempre el específico.mypy --strictes agresivo. En codebases existentes se aplica por módulos con[[tool.mypy.overrides]].- Stubs (
.pyi): para librerías sin tipos, se instalan paquetestypes-X(típicamente publicados entypeshed). dmypy(daemon mode) acelera invocaciones repetidas reusando análisis previo, útil en pre-commit y en watch loops.
Patrón mínimo
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
warn_unreachable = true
pretty = true
# Permitir más laxitud en tests
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
# Librerías sin stubs en typeshed
[[tool.mypy.overrides]]
module = ["scipy.*", "matplotlib.*"]
ignore_missing_imports = truemypy src/ # check
dmypy run -- src/ # check vía daemon (más rápido en iteración)
mypy --strict src/ # ignora pyproject, activa strictTrampas habituales
# type: ignoresin código. Silencia todo error en esa línea, incluidos los que aparecerán después al refactorizar. Usa# type: ignore[union-attr]con el código específico que sale en el mensaje.Anyse propaga. Si una función devuelveAny(típicamente por una librería sin tipos), todo el código que la consume queda parcialmente sin chequear.warn_return_anylo detecta.Optional[X]vsX | None. En Python ≥3.10 confrom __future__ import annotations, la sintaxisX | Nonees preferible. Las reglasUPde ruff lo refactorizan automáticamente.- mypy y pyright divergen en protocols estructurales con métodos genéricos. Si una librería pública la usan ambos, ten ambos en CI aceptando el subconjunto común.
Enlaces
Relacionados en esta página
pyright, alternativa de Microsoft, más rápida y con mejor LSP.ruff, las reglasANNcubren chequeos sintácticos sobre anotaciones, no semánticos.
pyright
pyright es el type checker estático de Microsoft, escrito en TypeScript y publicado en open source. Es el motor que alimenta Pylance, la extensión de tipado de Python en VS Code, lo que le da una integración LSP que mypy no iguala: feedback en vivo, hover types precisos, quick fixes y autoimport tipado.
Más rápido que mypy en frío (sin daemon) y notablemente más rápido en codebases grandes. Su modelo de inferencia es ligeramente distinto, más agresivo con narrowing y con protocols estructurales, y eso a veces marca código como tipado correcto que mypy rechazaría, o viceversa.
Cuándo usarlo
- Proyectos donde VS Code/Cursor es el editor principal del equipo: ya estás usando pyright vía Pylance, mantenerlo también en CI da consistencia entre editor y pipeline.
- Codebases grandes donde mypy es demasiado lento incluso con daemon.
- Cuando necesitas chequeo de tipos con narrowing avanzado (
assert isinstance,matchstatements).
Cuándo NO usarlo
- Si tu codebase está calibrada contra mypy y los mensajes de error de tu equipo están escritos en términos de mypy. Migrar a pyright es viable pero produce un periodo de transición ruidoso.
- Si necesitas máxima portabilidad: mypy es la implementación de referencia, lo que en librerías publicadas en PyPI es una ventaja política aunque no técnica.
Conceptos clave
- Configuración en
pyproject.toml([tool.pyright]) o enpyrightconfig.json. La opción más importante estypeCheckingMode = "strict"|"basic"|"off". - Tres modos:
off(solo síntaxis),basic(chequeos suaves),strict(equivalente moral amypy --strict). # pyright: ignore[reportName]silencia errores línea a línea. Como en mypy, siempre el código específico.- Soporta
# type: ignoretambién, pero el código de error entre paréntesis es propio de pyright (reportGeneralTypeIssues, etc.), no compatible bit-a-bit con mypy. - Inferencia más agresiva con
assert,isinstance, walrus operator y exhaustiveness checks enmatch.
Patrón mínimo
# pyproject.toml
[tool.pyright]
include = ["src", "tests"]
pythonVersion = "3.12"
typeCheckingMode = "strict"
reportMissingTypeStubs = false
reportPrivateImportUsage = false
executionEnvironments = [
{ root = "tests", reportUnknownArgumentType = false }
]pyright # usa la config del pyproject
pyright --warnings # tratar warnings como info
pyright --watch src/ # modo desarrollo
pyright --outputjson > out.json # para CI estructuradoTrampas habituales
- Dos type checkers, dos verdades. Si tienes mypy en CI y pyright en el editor, vas a recibir avisos en VS Code que CI no marca, y viceversa. Decide cuál es la fuente de verdad y, si necesitas ambos, acepta el subconjunto común.
reportMissingTypeStubsmarca casi todas las librerías científicas sin stubs (scipy, matplotlib parcial). Enstrictmolesta, desactívalo o usaexecutionEnvironmentspara subdirectorios.- Versión de Python.
pythonVersion = "3.12"no infiere desde tupython_requires. Sincronízalos a mano. - Pylance ≠ pyright open source. Pylance añade funcionalidad propietaria (autoimport, indexado de workspace). El motor de tipos es el mismo, pero algunos diagnósticos solo aparecen en Pylance.
Enlaces
Relacionados en esta página
mypy, la alternativa de referencia.
pytest
pytest es el framework de testing estándar de facto en Python. Reemplaza al unittest de la stdlib con una API más concisa: funciones de test en lugar de clases, fixtures como inyección de dependencias, parametrización idiomática y un ecosistema enorme de plugins (pytest-cov, pytest-mock, pytest-xdist, pytest-asyncio, …).
Mantenido por la comunidad pytest-dev. La mayoría de librerías y aplicaciones Python serias lo usan. unittest se conserva en código viejo o donde no se quiere dependencia externa.
Cuándo usarlo
- Cualquier proyecto nuevo. La curva es plana y la legibilidad gana siempre frente a
unittest. - Migración progresiva desde
unittest: pytest ejecuta tests deunittestsin cambios, así que se puede convivir y reescribir gradualmente.
Cuándo NO usarlo
- Si tienes una política estricta de “stdlib only” (algunos proyectos críticos de infraestructura). En ese caso,
unittestsigue siendo la opción. - Tests que dependen fuertemente de
unittest.mocky de su claseTestCase: viable en pytest pero menos idiomático.
Conceptos clave
- Una función de test es una función que empieza por
test_. Sin clases, sinsetUp. La aserción esassert exprdirecto, pytest reescribe el assert para dar mensajes detallados. - Fixtures (
@pytest.fixture): inyección de dependencias. Una fixture es una función decorada. Los tests la reciben como parámetro por nombre. Hay scopes:function(default),class,module,session. - Parametrización:
@pytest.mark.parametrize("input,expected", [...])ejecuta el mismo test sobre múltiples casos. conftest.pydefine fixtures compartidas en un directorio. Es el patrón principal de organización.- Marks (
@pytest.mark.slow,@pytest.mark.skip,@pytest.mark.xfail): etiquetar y filtrar tests (pytest -m "not slow"). - Plugins clave:
pytest-cov(cobertura),pytest-xdist(paralelización),pytest-mock(atajo aunittest.mock),pytest-asyncio(soporteasync def test_…).
Patrón mínimo
# pyproject.toml
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
addopts = [
"-ra",
"--strict-markers",
"--strict-config",
"--cov=src",
"--cov-report=term-missing",
]
markers = [
"slow: tests lentos (excluidos por defecto en CI rápido)",
"integration: requieren servicios externos",
]# tests/test_calc.py
import pytest
@pytest.fixture
def sample_data():
return {"a": 1, "b": 2}
@pytest.mark.parametrize("x,y,expected", [
(1, 2, 3),
(0, 0, 0),
(-1, 1, 0),
])
def test_suma(x, y, expected):
assert x + y == expected
def test_acceso_fixture(sample_data):
assert sample_data["a"] == 1pytest # corre todo
pytest tests/test_calc.py # archivo concreto
pytest -k "suma" # filtro por nombre
pytest -m "not slow" # filtro por mark
pytest -n auto # paralelización (pytest-xdist)
pytest --lf # solo los que fallaron la última vezTrampas habituales
- Fixtures con scope incorrecto.
scope="session"para algo que muta estado entre tests contamina ejecuciones. Empieza confunction(default) y sube de scope solo cuando hay un coste real (conexión a DB, fixture que tarda). - Mocking demasiado profundo. Si un test mockea 6 cosas, lo que estás probando es el mock, no el código. Refactoriza hacia inyección de dependencias y mockea en los bordes.
autouse=Trueen fixtures parece elegante pero crea efectos invisibles. Úsalo solo para setup auténticamente global (variables de entorno, logging).assert x == ycon floats. Usapytest.approx(y)omath.isclose. Sin eso, el test es frágil a la precisión.- No mezclar
unittest.TestCasey fixtures de pytest. Las fixtures por argumento no funcionan dentro de clasesTestCaseclásicas, pytest las salta. Si arrastrasTestCase, conviértelas a funciones.
Enlaces
Relacionados en esta página
hypothesis, añade property-based testing sobre pytest.coverage, víapytest-cov, integrado nativamente.
hypothesis
hypothesis es la librería de property-based testing de Python, la idea, importada de Haskell (QuickCheck), de no escribir casos de test concretos sino propiedades que el código debe satisfacer para cualquier input válido, y dejar que la librería genere y reduzca casos automáticamente.
El valor es práctico, no académico: encuentra bugs en edge cases que ningún humano escribiría como test (cadenas vacías, NaN, listas con duplicados profundos, unicode en bordes), y cuando encuentra un fallo minimiza el contraejemplo a la forma más simple, esto cambia cualitativamente el flujo de debugging.
Cuándo usarlo
- Funciones puras con propiedades algebraicas claras (idempotencia, asociatividad, inversa, roundtrip serialización ↔︎ deserialización).
- Parsers, encoders/decoders, normalizadores, cualquier código donde el espacio de inputs es grande y los edge cases importan.
- Refactorings críticos: un test de propiedad bien escrito atrapa regresiones que la suite unitaria no ve.
Cuándo NO usarlo
- Tests de integración con servicios externos (DB, API). El tiempo de cada caso es alto y la generación masiva no aporta.
- Funciones cuyo dominio es trivialmente enumerable: parametrizar manualmente con
@pytest.mark.parametrizees más claro. - Tests donde lo único que verificas es comportamiento exacto contra una salida (snapshot testing). No hay propiedad que satisfacer en sentido propio.
Conceptos clave
- Strategies: generadores de inputs (
st.integers(),st.text(),st.lists(st.floats()),st.dictionaries(...)). Componibles. @given(strategy)decora el test e inyecta el input generado.- Shrinking: cuando un test falla, hypothesis simplifica el contraejemplo al más pequeño que sigue fallando. Esto es el corazón del valor de la librería.
@example(...)añade casos concretos a probar siempre (útil para regresiones conocidas).- Stateful testing (
RuleBasedStateMachine): generar secuencias de operaciones sobre un objeto con estado. Útil para probar máquinas de estado, parsers incrementales, structuras mutables. - Hypothesis guarda en
.hypothesis/la base de datos de fallos previos, los reintenta automáticamente en cada ejecución (regression testing implícito).
Patrón mínimo
from hypothesis import given, strategies as st, example, settings
@given(st.lists(st.integers()))
@example([]) # caso degenerado: siempre probar lista vacía
def test_reverse_idempotente(xs):
assert list(reversed(list(reversed(xs)))) == xs
@given(st.text(min_size=1))
@settings(max_examples=500)
def test_roundtrip_json(s):
import json
assert json.loads(json.dumps(s)) == s
# Encadenando strategies
@given(st.dictionaries(st.text(), st.integers(), min_size=1))
def test_keys_subset_of_items(d):
assert set(d.keys()) <= {k for k, _ in d.items()}Trampas habituales
- No filtrar dentro del test (
assume(x > 0)). Si rechazas mucho, hypothesis avisa y al final aborta. Mejor: usa una strategy más restringida (st.integers(min_value=1)). max_examplespor defecto = 100. Suficiente para smoke, pero para propiedades críticas sube a 500-1000 vía@settings.- No mezclar lógica de producción con strategy generation. Si la strategy reproduce parcialmente el código bajo test, lo que estás probando es la consistencia interna de hypothesis, no tu código.
- Tests no deterministas. Si tu función depende de aleatoriedad o tiempo, controla la semilla y los relojes explícitamente, sin eso, los fallos de hypothesis no son reproducibles.
- Shrinking lento. Si shrink tarda mucho, suele ser señal de que la propiedad está mal acotada o que el código bajo test tiene side effects.
Enlaces
Relacionados en esta página
pytest, hypothesis se integra como decoradores sobre funciones pytest.
coverage
coverage (alias del paquete coverage.py) es la librería estándar para medir cobertura de tests en Python: qué líneas del código se ejecutan durante la suite, cuáles no, y opcionalmente qué branches (condicionales binarios) se han recorrido. La integración estándar con pytest es vía el plugin pytest-cov.
Es importante recordar qué mide cobertura y qué no: mide ejecución, no correctitud. 100% de cobertura no significa que los tests sean buenos, significa que cada línea se tocó al menos una vez. Aun así, es la métrica más simple y útil para detectar código huérfano (sin tests) y dead code.
Cuándo usarlo
- Cualquier suite de tests en producción debería medirla. Establece un umbral mínimo en CI (
--cov-fail-under=80) y no lo bajes, solo subir. - Para encontrar código que nadie ejerce: branches que nunca se entran, error handlers que nunca se prueban, funciones muertas.
Cuándo NO usarlo (matiz)
No hay un “cuándo no usarlo” real, todo proyecto serio debe medirla. El matiz es cómo interpretarla: 100% line coverage con asserts vacíos es peor que 70% con buenos asserts. La cobertura es señal necesaria pero no suficiente.
Conceptos clave
- Line coverage (default): cuántas líneas se ejecutaron.
- Branch coverage (
--cov-branch): cuántas ramas de cada condicional se tomaron. Más estricto y revelador. pragma: no coveren un comentario excluye líneas/bloques de la métrica. Úsalo con criterio en defensive code que es imposible de testear sin construir un mundo absurdo (if TYPE_CHECKING:típicamente).- Reportes:
term(consola),term-missing(consola + líneas no cubiertas),html(interactivo enhtmlcov/),xml(para CI tipo SonarQube),json(para badges). .coveragerco[tool.coverage.*]enpyproject.tomlpara configuración.
Patrón mínimo
# pyproject.toml
[tool.coverage.run]
source = ["src"]
branch = true
omit = [
"*/tests/*",
"*/__init__.py",
"*/_version.py",
]
[tool.coverage.report]
fail_under = 80
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"raise NotImplementedError",
"if TYPE_CHECKING:",
"if __name__ == .__main__.:",
"\\.\\.\\.", # protocols con `...`
]pytest --cov=src --cov-report=term-missing --cov-report=html
coverage report # tras pytest --cov, ver el resumen
coverage html # genera htmlcov/index.html
coverage erase # limpiar entre runsTrampas habituales
exclude_linesdemasiado laxo. Cada patrón que añadas reduce señal. Mantén la lista pequeña y justificada.- Cobertura sin branch.
branch = truecambia mucho los números, unif/elsecon solo elifcubierto pasa de 100% a 50% en esa función. Sin branch, mides poco. - Tests sin asserts cuentan como cobertura. Una llamada a función sin asserts sigue ejerciendo el código. La cobertura no detecta este antipatrón, para eso, revisión de código.
coverage combinees necesario en proyectos con paralelización (pytest-xdist) o ejecuciones en múltiples procesos. Sin combinar los.coverage.*, el reporte queda incompleto.
Enlaces
Relacionados en esta página
pytest, la integración real es víapytest-cov.
bandit
bandit es el linter de seguridad estática para Python mantenido por PyCQA. Detecta patrones inseguros conocidos: uso de eval y exec, pickle sobre input no confiable, subprocess con shell=True, yaml.load sin Loader seguro, secrets hardcoded, generadores de aleatoriedad débiles (random cuando hace falta secrets), SSL con verificación deshabilitada, etc.
No es un análisis exhaustivo, es un catálogo de errores frecuentes. Su valor es prevenir el patrón obvio antes de que llegue al review, no sustituir un análisis de seguridad real (que requiere modelado de amenazas y herramientas más sofisticadas como Semgrep o CodeQL).
Cuándo usarlo
- Cualquier proyecto que procese input externo (web apps, parsers de archivos subidos por usuarios, integraciones con APIs externas).
- En CI como gate ligero. Tiempos de ejecución bajos, ratio señal/ruido razonable si se configura.
- Como capa complementaria a las reglas
Sde ruff (que cubren un subset de bandit, no todo).
Cuándo NO usarlo
- Scripts internos sin superficie de ataque: no aporta señal.
- Si confías exclusivamente en ruff
S: cubre el ~70% de las reglas de bandit. Las reglas no cubiertas en ruff son las que dependen de análisis de flujo (taint analysis básico) que ruff no implementa todavía.
Conceptos clave
- Severidad y confianza: cada hallazgo tiene
severity(LOW/MEDIUM/HIGH) yconfidence(idem). Filtra por ambos en CI (-iii -lll= solo high/high). - Suppression:
# nosecen una línea silencia bandit en esa línea. Como en mypy/ruff, prefiere el código específico:# nosec B101. - Configuración en
pyproject.toml([tool.bandit]) o enbandit.yaml. - Plugins para reglas adicionales (
bandit-baselinepara diffs incrementales en codebases legacy).
Patrón mínimo
# pyproject.toml
[tool.bandit]
exclude_dirs = ["tests", "build", "dist"]
skips = ["B101"] # assert_used: irrelevante fuera de tests
[tool.bandit.assert_used]
skips = ["*/test_*.py", "*/tests/*.py"]bandit -r src/ # análisis recursivo
bandit -r src/ -iii -lll # solo high severity + high confidence
bandit -r src/ -f json -o bandit.json # output para CI
bandit -r src/ --baseline baseline.json # solo hallazgos nuevos vs baselineTrampas habituales
# nosecglobal (sin código de regla) silencia todo en esa línea, incluyendo problemas reales futuros. Especifica siempre:# nosec B602.- Falsos positivos en tests.
assertes el caso clásico (B101). Exclúyelo entests/. Si tu suite usa subprocess, también molesta, configuraskipscon criterio. - No es taint analysis. Bandit reconoce patrones sintácticos, no rastrea de dónde viene un valor. Si necesitas análisis de flujo (input de usuario → query SQL), usa Semgrep o CodeQL.
- Baseline congelado. En codebases existentes, generar un baseline y revisar solo lo nuevo es pragmático, pero el baseline se queda obsoleto si nadie lo revisita. Programa revisiones periódicas.
Enlaces
Relacionados en esta página
ruff, la familia de reglasScubre un subset de bandit.pre-commit, donde encaja bandit en el flujo.
pre-commit
pre-commit es el framework de gestión de git hooks multilenguaje desarrollado por Anthony Sottile. Resuelve un problema operativo: cómo asegurar que linter, formateador, type checker y resto de quality gates se ejecutan antes de que el commit entre al repositorio, sin depender de que cada desarrollador recuerde correrlos a mano.
Es la capa de integración. Sin pre-commit, todas las herramientas anteriores son sugerencias que se aplican cuando alguien acuerda. Con pre-commit, son contratos: el commit no entra si fallan.
Cuándo usarlo
- Siempre. Cualquier proyecto compartido por más de una persona se beneficia. El coste de configuración es de minutos. El coste de no tenerlo se paga durante todo el ciclo de vida del proyecto.
- Como segunda línea de defensa en CI: si el desarrollador no instaló los hooks localmente, CI ejecuta
pre-commit run --all-filesy rechaza el PR.
Cuándo NO usarlo
- Si los hooks tardan demasiado y el equipo los desactiva con
--no-verifypor costumbre, has perdido. Mantén los hooks rápidos (típicamente <5 s), los chequeos pesados van solo a CI. - En git workflows muy especializados (rebase masivos, scripts que generan commits programáticamente) donde el hook interfiere. Solución:
SKIP=hook-id git commitopre-commit run --hook-stage manual.
Conceptos clave
.pre-commit-config.yamldeclara los hooks. Cada hook es un repo de GitHub que expone hooks pre-definidos (ruff, black, mypy, etc.) o un hook local.pre-commit installregistra el hook en.git/hooks/pre-commit. Hay que ejecutarlo una vez por clon del repo.pre-commit autoupdateactualiza losrev:a la última versión de cada hook.- Solo corre sobre archivos staged. Es lo que lo hace rápido. Para correr sobre todo el repo:
pre-commit run --all-files. - Stages (
pre-commit,pre-push,manual): permite separar hooks rápidos (commit) de pesados (push o manual). - CI:
pre-commit/action@v3en GitHub Actions ejecuta los mismos hooks que en local. Una única fuente de verdad.
Patrón mínimo
# .pre-commit-config.yaml
default_language_version:
python: python3.12
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: ['--maxkb=500']
- id: check-merge-conflict
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
hooks:
- id: mypy
additional_dependencies: [pydantic, types-requests]
args: [--config-file=pyproject.toml]
- repo: https://github.com/PyCQA/bandit
rev: 1.7.9
hooks:
- id: bandit
args: [-c, pyproject.toml]
additional_dependencies: ['bandit[toml]']pre-commit install # registra los hooks
pre-commit run --all-files # corre sobre todo el repo (CI-equivalent)
pre-commit autoupdate # actualiza revs
SKIP=mypy git commit -m "wip" # saltar un hook puntual (evitar como hábito)Trampas habituales
additional_dependenciesen mypy. Sin esto, mypy en pre-commit corre en un venv aislado y no ve las dependencias del proyecto, genera errores de import. Lista las dependencias relevantes (Pydantic, types-X, librerías que aportan stubs) enadditional_dependenciesdel hook.--no-verifycultural. Si el equipo lo usa de forma rutinaria, los hooks han fallado. Investiga qué hook es lento o ruidoso, no normalices el bypass.- Hooks que reformatean (
ruff --fix,ruff-format) dejan archivos modificados sin stagear. El commit falla y hay quegit add+ recommit. Es el comportamiento correcto, pero hay que explicarlo en el onboarding. rev:desactualizado. Si no correspre-commit autoupdateperiódicamente, los hooks divergen de las versiones del proyecto. Programa una revisión trimestral.- CI sin pre-commit. Si solo los desarrolladores que instalaron el hook lo respetan, el quality gate es opcional. Añade
pre-commit run --all-filescomo step obligatorio en CI.