Un proyecto Python no se estropea porque alguien escriba una función chapucera. Se estropea porque las dependencias cambian de versión sin avisar, los imports acaban hechos una maraña, la configuración se reparte entre doce archivos distintos, los modelos de la base de datos mutan sin que nadie genere la migración correspondiente, y llega un punto en el que nadie sabe qué se puede borrar sin romper algo.
A partir de ahí el equipo empieza a moverse por el repositorio con pies de plomo. Y eso ya es mala señal: cuando tocar código da miedo, el problema no es el código, es la falta de mecanismos que avisen cuando algo se sale de madre.
Estas son diez herramientas que ayudan a que un «servicio interno pequeño» siga siendo comprensible cuando se ha convertido en un sistema en producción con varios equipos detrás, varios entornos y ese utils.py que todo el mundo mira con sospecha pero nadie se atreve a tocar.
1. uv: que el estado de las dependencias sea reproducible de verdad
Un requirements.txt cuenta lo que alguien quiso instalar. Un lockfile cuenta lo que realmente se resolvió, y esa diferencia duele el día que un despliegue se rompe porque una dependencia transitiva publicó una versión nueva de madrugada. Tu código no cambió. El entorno sí, por debajo, sin avisar. Es de lo más típico en Python.
uv gestiona entornos, dependencias y lockfiles sin obligar al proyecto a mantener una colección de scripts de shell con nombres como setup-dev.sh, setup-dev-final.sh y setup-dev-final-de-verdad-usa-este.sh.
[project]
name = "invoice-service"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.2",
]
[dependency-groups]
dev = [
"pytest>=8.0",
"basedpyright>=1.0",
"ruff>=0.9",
]
Con eso creas y fijas el conjunto de dependencias resuelto:
uv lock uv sync --locked
Usa uv sync --locked en la integración continua. Si alguien cambia pyproject.toml y se olvida de actualizar uv.lock, el build tiene que fallar en vez de instalar en silencio lo que le salga resolver ese día.
Los equipos con más recorrido prefieren esto a gestionar entornos virtuales a mano porque el desorden de dependencias es un problema de sistema, no de disciplina del desarrollador de turno.
El límite: uv avanza rápido y su flujo de trabajo puede no parecerse al de Poetry o pip-tools que ya conoce tu equipo. No migres un proyecto estable solo porque una herramienta nueva instale más rápido. Adóptala cuando la reproducibilidad o la gestión de un workspace te esté doliendo ahora mismo.
2. Ruff: reglas de estilo que no se convierten en un descanso para el café
Un linter que tarda dos minutos se acaba ignorando. Uno que corre en una fracción de segundo se convierte en memoria muscular.
Ruff sustituye a un buen puñado de herramientas sueltas: ordenar imports, formatear, detectar errores comunes, imports sin usar, manejo dudoso de excepciones y buena parte del terreno que antes cubrían los plugins de flake8.
El error habitual es activar todas las reglas de golpe y montar un muro de avisos que nadie respeta. Mejor empezar por las que señalan problemas de mantenimiento reales:
[tool.ruff]
target-version = "py312"
line-length = 88
[tool.ruff.lint]
select = [
"E", # errores de pycodestyle
"F", # Pyflakes
"I", # orden de imports
"B", # bugbear
"UP", # pyupgrade
"SIM", # simplificación
"RUF", # reglas propias de Ruff
]
ignore = [
"E501",
]
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"]
Con esa configuración, algo así se detecta solo:
def parse_config(value: str | None) -> str:
if value:
return value
else:
return "default"
Ruff avisa de que el else después de un return sobra. No es un bug grave, pero cientos de inconsistencias pequeñas hacen que el código cueste más de leer de un vistazo, y leer código de un vistazo es donde un desarrollador con experiencia se pasa buena parte del día.
def parse_config(value: str | None) -> str:
if value:
return value
return "default"
El riesgo está en convertir el lint en teatro. No merece la pena gastar tiempo de revisión discutiendo si un if de una línea queda más elegante como operador ternario mientras la aplicación sigue sin tests de integración. Usa Ruff para eliminar el ruido mecánico repetitivo, no como sustituto del criterio técnico.
3. Basedpyright: pillar las suposiciones erróneas antes de que exploten en producción
Python es dinámico, y eso no significa que todo diccionario tenga que ser dict[str, Any] ni que cada función tenga que devolver «algo».
basedpyright es un comprobador de tipos estricto basado en Pyright. Sirve cuando quieres conservar la flexibilidad de Python sin tratar cada frontera entre funciones como un acto de fe.
from dataclasses import dataclass
@dataclass
class Customer:
id: int
email: str
def find_customer(customer_id: int) -> Customer | None:
...
def send_receipt(customer_id: int) -> None:
customer = find_customer(customer_id)
email = customer.email
send_email(email)
Esto parece inofensivo hasta que find_customer() devuelve None porque la cuenta se borró, el parámetro de la URL era inválido o hubo una condición de carrera.
Con la comprobación estricta activada:
[tool.basedpyright] pythonVersion = "3.12" typeCheckingMode = "strict" reportAny = "warning" reportUnknownMemberType = "warning" reportUnknownVariableType = "warning"
El arreglo pasa a ser explícito:
def send_receipt(customer_id: int) -> None:
customer = find_customer(customer_id)
if customer is None:
raise LookupError(f"El cliente {customer_id} no existe")
send_email(customer.email)
El valor real no está en pillar erratas, está en hacer visibles los estados inciertos: registros que faltan, datos externos sin validar, respuestas de API a medias, funciones que dicen devolver una cosa y de vez en cuando devuelven otra.
Un error de tipos sale más barato en el editor que un AttributeError cuando un cliente no puede terminar el proceso de compra, así que muchos prefieren la comprobación estática a llenar el código de bloques try/except AttributeError defensivos por todas partes.
El límite está en la adopción gradual. Activar el modo estricto de golpe en un repositorio grande sin tipar puede generar miles de avisos. Empieza por los paquetes nuevos o por la capa que hace de frontera con el exterior. No anuncies un sprint de tipado y desaparezcas tres semanas metido en anotaciones.
4. Import Linter: que la arquitectura se pueda comprobar, no solo prometer
La mayoría de documentos de arquitectura son correctos durante unas seis semanas. Luego alguien importa la capa de base de datos desde el dominio «solo por ahora», otra persona mete un objeto de petición de FastAPI dentro de un servicio, y de repente los tests unitarios necesitan levantar todo el contenedor de la aplicación.
Import Linter convierte esos límites entre capas en comprobaciones que corren en la integración continua.
[tool.importlinter]
root_package = "orders"
[[tool.importlinter.contracts]]
name = "El dominio no depende de infraestructura"
type = "forbidden"
source_modules = ["orders.domain"]
forbidden_modules = [
"orders.infrastructure",
"orders.api",
]
[[tool.importlinter.contracts]]
name = "Direccion de las capas"
type = "layers"
layers = [
"orders.api",
"orders.application",
"orders.domain",
"orders.infrastructure",
]
Si alguien añade esto:
from orders.infrastructure.postgres import OrderRepository
la comprobación de imports falla:
lint-imports
Esto importa cuando necesitas probar reglas de negocio sin base de datos, cambiar de framework sin reescribir la aplicación entera, o dejar que varios equipos trabajen en zonas cercanas sin pisarse todo el rato.
Un build que falla es menos diplomático que un comentario de revisión del tipo «esto probablemente debería ir en otra capa», pero se acuerda de las reglas aunque haya prisa por entregar.
El riesgo está en los límites mal trazados: unas malas fronteras generan reglas malas. Si tu proyecto es una CLI pequeña con 15 módulos, montar contratos de arquitectura puede ser excesivo. Úsalo cuando el grafo de dependencias empiece a costar de seguir, no porque todo proyecto necesite un diagrama con hexágonos.
5. Deptry: dependencias que nadie reclama como propias
Los archivos de dependencias se acumulan como pestañas del navegador. Un paquete que se usó hace seis meses. Una dependencia transitiva que se importa directamente porque resulta que estaba instalada. Una dependencia de test que acabó colándose en producción. Nadie quita nada porque quitar paquetes da la sensación de ser arriesgado.
deptry revisa qué dependencias sobran, cuáles faltan por declarar y cuáles se están usando de forma transitiva sin haberlas pedido.
[tool.deptry]
known_first_party = ["orders"]
per_rule_ignores = { DEP002 = ["pytest"] }
deptry .
Imagina este import:
from dateutil.parser import parse
Si python-dateutil solo está instalado porque otra librería depende de ella, tu proyecto puede funcionar en tu máquina y fallar en un entorno de producción mínimo. La declaración correcta va en pyproject.toml:
[project]
dependencies = [
"python-dateutil>=2.9",
]
Ese tipo de descuido diminuto con las dependencias es justo el que acaba montando un «en mi máquina funciona» con gabardina y pasaporte falso.
Deptry gusta porque deja claro quién es dueño de qué: si tu código importa un paquete, tu proyecto tiene que declararlo, y si tu proyecto declara un paquete, alguien tiene que poder explicar por qué está ahí.
El límite son los imports dinámicos y los sistemas de plugins. Frameworks que cargan módulos a partir de una cadena de texto, como ocurre en algunos patrones de Django o Celery, pueden necesitar reglas de exclusión. Eso está bien: una excepción documentada es mejor que una dependencia invisible.
6. detect-secrets: que una credencial no acabe metida en el historial de git
Una clave de API filtrada no se arregla borrándola del último commit. Git se acuerda. Los forks se acuerdan. Puede que los logs de la integración continua también se acuerden, y tu yo del futuro se va a acordar seguro, durante la revisión del incidente.
detect-secrets rastrea el repositorio en busca de credenciales probables y permite crear una línea base para los hallazgos que ya existen.
Crea la línea base inicial:
detect-secrets scan > .secrets.baseline
Añádelo a .pre-commit-config.yaml:
repos:
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ["--baseline", ".secrets.baseline"]
Instala el hook:
pre-commit install
Esto atrapa fallos como este antes de que lleguen al repositorio:
STRIPE_SECRET_KEY = "sk_live_..."
Una versión más segura lee la configuración desde el entorno:
import os stripe_secret_key = os.environ["STRIPE_SECRET_KEY"]
Lo útil de una línea base es que permite a un repositorio ya maduro adoptar el escaneo de secretos sin fingir que los falsos positivos históricos no existen. Puedes revisar los hallazgos conocidos, dejar constancia de las excepciones intencionadas y bloquear que aparezcan secretos nuevos.
El riesgo son los falsos positivos: fixtures de test, hashes, tokens de mentira que parecen sospechosos. No acostumbres al equipo a ignorar el aviso del hook. Revisa la línea base con calma y, si aparece una credencial real en el control de versiones, rótala de inmediato.
7. alembic check: que el modelo y la base de datos no se separen
Las migraciones de base de datos son donde un equipo descubre si está desplegando software o simplemente cruzando los dedos.
Si un modelo de SQLAlchemy cambia y nadie añade la migración, un entorno puede funcionar porque el desarrollador recreó la base de datos entera, y otro puede fallar porque tiene años de historial de esquema real detrás.
Alembic incluye el comando alembic check, que detecta cambios en el modelo que generarían nuevas operaciones de migración. Úsalo en la integración continua:
alembic check
Este es el tipo de cambio de modelo del que protege:
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
class Account(Base):
__tablename__ = "accounts"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True)
timezone: Mapped[str] = mapped_column(
String(64),
nullable=False,
server_default="UTC",
)
Si timezone es nuevo, el cambio de código tiene que venir acompañado de una migración:
alembic revision --autogenerate -m "add account timezone"
Luego revisa la migración generada. Siempre. La generación automática ayuda, pero no entiende de reglas de negocio: renombrar una columna puede parecer «borrar columna vieja, añadir columna nueva» y, en una tabla con datos de clientes, eso no es una migración, es un borrado muy caro.
La base de datos no es un detalle de almacenamiento, es una dependencia con estado y con memoria, y prevenir el desajuste de esquema sale más barato que repararlo después.
El límite: Alembic está pensado alrededor de los metadatos de SQLAlchemy. Si tu esquema lo gestiona otra herramienta o tu proceso de migración incluye rellenos masivos de datos y cambios de esquema en caliente, vas a necesitar disciplina adicional en el diseño de esas migraciones.
8. Towncrier: que las notas de versión se escriban con el cambio, no después
Las notas de versión escritas el día del lanzamiento suelen quedar vagas porque para entonces ya nadie se acuerda bien de por qué se hizo el cambio. «Se ha mejorado el rendimiento.» «Corrección de varios errores.» «Actualización de dependencias.» Útil si te gusta la arqueología.
Towncrier trabaja con fragmentos de noticia pequeños que se añaden junto al cambio de código correspondiente. La persona que hace el cambio escribe la nota mientras todavía tiene el contexto fresco.
Configúralo en pyproject.toml:
[tool.towncrier] directory = "newsfragments" filename = "CHANGELOG.md" package = "orders" underlines = ["", "", ""]
Alguien que añade protección de idempotencia crearía el archivo:
newsfragments/421.feature
con este contenido:
Rechaza los callbacks de pago duplicados que reutilizan una clave de idempotencia.
Al publicar la versión:
towncrier build --version 2.4.0
El changelog que sale de ahí se basa en trabajo real ya fusionado, no en alguien intentando reconstruir tres meses de pull requests de memoria a última hora.
Los equipos con más rodaje prefieren los fragmentos porque reparten el trabajo de documentar entre quienes de verdad entienden cada cambio, y eso es mucho más fiable que dejarle a una sola persona la tarea de resumir toda una versión.
El coste está en la fricción para cambios internos diminutos: no hace falta un fragmento de notas de versión por corregir una errata en un script privado. Define qué tipos de cambio necesitan fragmento (correcciones visibles para el usuario, cambios de API, cambios operativos y cualquier cosa que afecte a cómo se actualiza el sistema) y deja el resto fuera.
9. pytest-testmon: tests lo bastante rápidos como para que la gente los ejecute
Una batería de tests lenta enseña un hábito malo: ejecutar solo el test que has tocado y confiar en que el resto sobrevive. Así es como viajan las regresiones.
pytest-testmon registra qué tests dependen de qué código y, durante el desarrollo local, vuelve a ejecutar solo el subconjunto afectado por tus cambios.
Arranca ejecutando la batería completa una vez:
pytest --testmon
Más tarde, después de tocar un módulo del servicio:
pytest --testmon
Imagina que cambias esta lógica:
def calculate_discount(total_cents: int, is_member: bool) -> int:
if is_member and total_cents >= 10_000:
return total_cents
return 0
Testmon puede seleccionar los tests que pasaron por ese código en vez de obligarte a esperar a los tests de base de datos, API y generación de informes que no tienen nada que ver.
Esto se nota sobre todo en repositorios donde la batería completa es correcta pero tarda lo suficiente como para cargarse la concentración de cualquiera.
La distinción importante: la selección de tests en local no sustituye a la batería completa en la integración continua, la complementa. Elegir qué tests correr no es permiso para dejar de correr los demás, es una forma de hacer el bucle de feedback soportable.
El límite está en el comportamiento dinámico de Python: imports en tiempo de ejecución, código generado y huecos de cobertura pueden afectar a la precisión de la selección. Mantén la batería completa en la integración continua, a poder ser en varias versiones de Python y combinaciones de entorno relevantes.
10. pyproject-fmt: que la configuración no se convierta en su propio proyecto
Los proyectos Python modernos meten mucho dentro de pyproject.toml: metadatos de build, dependencias, Ruff, comprobación de tipos, configuración de tests, cobertura, descubrimiento de paquetes, herramientas de publicación. Con el tiempo, ese archivo acaba costando más de leer que el propio código de la aplicación.
pyproject-fmt formatea pyproject.toml de forma consistente y da pocas opciones de formato a propósito, que para esto es una virtud.
pyproject-fmt pyproject.toml
Antes de formatear:
[project] dependencies=["fastapi>=0.115","sqlalchemy>=2.0"] name="orders" requires-python=">=3.12" version="0.4.0"
Después de formatear:
[project]
name = "orders"
version = "0.4.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"sqlalchemy>=2.0",
]
Esto parece una tontería hasta que revisas un pull request que cambia una dependencia, la configuración de Ruff, el comportamiento de la integración continua y los metadatos del paquete, todo en un diff sin ninguna estructura reconocible.
La configuración merece formato por la misma razón que el código: un diff legible hace que sea mucho más fácil detectar un cambio que no debería colarse.
El límite es que formatear no valida si la configuración es correcta. pyproject-fmt puede dejar precioso y ordenado un ajuste que está mal. Combínalo siempre con la ejecución real de las herramientas en la integración continua.
Ninguna de estas herramientas sustituye al criterio
Lo que tienen en común estas diez herramientas es que convierten reglas que antes vivían en la cabeza de alguien, o en un documento que nadie relee, en algo que se comprueba solo, sin depender de que un compañero se acuerde de mencionarlo en la revisión. No hace falta meterlas todas de golpe: un uv.lock reproducible y un ruff check en el hook de pre-commit ya cambian bastante el día a día. El resto se va añadiendo según duela cada problema concreto, no porque la lista quede más completa.
Imagen: Pexels / Myburgh Roux
