Una librería interna mal diseñada es peor que no tener librería. Si la librería se importa en cinco servicios y tiene un bug que lanza una excepción no manejada en la inicialización, acabas de crashear cinco servicios al mismo tiempo.
He construido varias librerías internas Python para proyectos de producción: calculadores de KPIs, utilidades transversales, SDKs de observabilidad. En ese proceso aprendí que el diseño de una librería interna tiene reglas distintas a las de una aplicación.
Este post es la síntesis de esas reglas.
La regla de oro: falla silencioso o no fallas
Una dependencia transversal no puede crashear el proceso que la consume. Punto.
Si tu librería de observabilidad falla al inicializarse porque el connection string de Application Insights no está configurado, no tiene que lanzar una excepción. Tiene que logear un warning y continuar en modo no-op.
Si tu librería de KPIs falla al cargar configuración desde una fuente externa, no tiene que matar el proceso. Tiene que usar valores default razonables o exponer el error de forma manejable.
class KPICalculator:
def __init__(self, config: KPIConfig | None = None):
try:
self._config = config or KPIConfig.from_env()
self._ready = True
except ConfigurationError as e:
# Falla silenciosa — log, pero no propaga
logger.warning("kpi_calculator.config_failed", exc=e)
self._config = KPIConfig.defaults()
self._ready = False
def calculate_availability(self, data: AvailabilityInput) -> float:
if not self._ready:
logger.warning("kpi_calculator.not_configured")
return 0.0
return self._compute_availability(data)
El servicio que importa KPICalculator no sabe si la configuración falló. No le interesa. Sigue funcionando. El problema de configuración aparece en los logs, no en un 500 error de producción.
pyproject.toml — estructura moderna
Olvidate de setup.py y requirements.txt en librerías. pyproject.toml es el estándar desde PEP 517/518 y tiene soporte completo en todos los build backends modernos (hatchling, flit, setuptools).
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mi-org-kpi-core"
version = "1.3.0"
description = "Calculadores KPI para el ecosistema de auditoría"
requires-python = ">=3.10"
dependencies = [
"pymongo>=4.0,<5.0",
"pydantic>=2.0,<3.0",
]
[project.optional-dependencies]
dev = ["pytest>=7.0", "pytest-asyncio", "mypy"]
[tool.hatch.build.targets.wheel]
packages = ["src/mi_org_kpi"]
El src/ layout (código en src/mi_org_kpi/ en lugar de mi_org_kpi/ en la raíz) es la convención correcta para librerías: previene que el directorio local sea importado durante el desarrollo en lugar del paquete instalado.
Namespace packages
Si construyes múltiples librerías bajo una misma organización, los namespace packages permiten que compartan el mismo prefijo de import:
mi_org/
├── kpi_core/ # paquete: mi_org.kpi_core
├── observability/ # paquete: mi_org.observability
└── utils/ # paquete: mi_org.utils
Cada librería es un paquete separado, con su propio pyproject.toml, su propio ciclo de versiones, su propia publicación. Pero se importan con un namespace compartido:
from mi_org.kpi_core import AvailabilityCalculator
from mi_org.observability import get_tracer
from mi_org.utils import retry
La clave para que funcionen los namespace packages: no crear __init__.py en el directorio del namespace (mi_org/). Solo en los subdirectorios de cada paquete.
src/
└── mi_org/
├── # Sin __init__.py aquí — namespace package
└── kpi_core/
├── __init__.py # Sí aquí
├── calculators.py
└── models.py
Versioning y compatibilidad
Las librerías internas tienen usuarios: otros equipos, otros servicios. Un breaking change sin gestión adecuada es un bug en producción que tardas horas en diagnosticar porque “el código no cambió”.
Semver con sentido:
- PATCH (1.3.0 → 1.3.1): bug fix, no cambia la API pública
- MINOR (1.3.0 → 1.4.0): nueva funcionalidad, backward compatible
- MAJOR (1.3.0 → 2.0.0): breaking change — eliminar parámetro, cambiar tipo de retorno, renombrar método
Constraints en los consumers:
# En el servicio que consume la librería
dependencies = [
"mi-org-kpi-core>=1.3,<2.0", # acepta patches y minors, no majors
]
El <2.0 protege contra breaking changes accidentales cuando publicas un major. Sin ese constraint, pip install --upgrade puede romper el servicio.
Pinning explícito de dependencias críticas
Las librerías que tienen dependencias con historial de breaking changes entre versiones necesitan pins explícitos o rangos estrechos:
[project]
dependencies = [
"azure-monitor-opentelemetry-exporter==1.0.0b21", # exporter inestable
"pydantic>=2.0,<3.0", # rango por major
"pymongo>=4.0,<5.0", # rango por major
]
El criterio: si la librería dependencia ha tenido breaking changes entre minor versions históricamente, pinnear. Si tiene buena disciplina semver, rango por major es suficiente.
Publicación en feed privado
Para que el equipo pueda instalar la librería con pip install mi-org-kpi-core apuntando al feed privado:
# Makefile de la librería
build:
python -m build --wheel
publish: build
@VERSION=$$(python -c "import tomllib; d=tomllib.load(open('pyproject.toml','rb')); print(d['project']['version'])") && \
pip index versions $(PACKAGE_NAME) --index-url $(FEED_URL) 2>/dev/null | grep -q "$$VERSION" \
&& echo "Skipping — version $$VERSION already published" \
|| twine upload --repository-url $(FEED_URL) dist/*
.PHONY: build publish
La lógica de skip (si la versión ya existe, no falla ni repite) hace el publish idempotente — puedes llamarlo en CI sin miedo a errores por versión duplicada.
El .npmrc / pip.conf del servicio consumer apunta al feed:
# pip.conf del servicio
[global]
index-url = https://pkgs.dev.azure.com/org/project/_packaging/feed/pypi/simple/
Testing de librerías internas
Las librerías tienen que tener tests. Más que las aplicaciones, porque sus bugs afectan a múltiples servicios.
Dos tipos de tests son críticos:
Tests de contrato: verifican que la API pública funciona como se documenta. Son los tests que ejecutan los consumers de la librería antes de actualizar su versión pinned.
def test_availability_calculator_contract():
calc = AvailabilityCalculator()
result = calc.calculate(total=100, available=85)
assert result.percentage == 85.0
assert result.status == AvailabilityStatus.OK
# La API pública no cambia sin incrementar MAJOR
Tests de resiliencia: verifican el comportamiento cuando falla la inicialización o los inputs son inválidos.
def test_calculator_survives_missing_config(monkeypatch):
monkeypatch.delenv("KPI_CONFIG_URL", raising=False)
calc = KPICalculator() # No debe lanzar excepción
assert not calc._ready
result = calc.calculate_availability(mock_input)
assert result == 0.0 # Valor default, no excepción
Lo que aprendí
La documentación de la API pública es parte del entregable. Una librería sin docstrings en sus clases y métodos públicos genera soporte manual. El código fuente no es suficiente.
Los changelogs importan más en librerías que en apps. Un servicio puede leer su propio git log. Un consumer externo no tiene ese contexto. El CHANGELOG.md es la interfaz de comunicación de breaking changes.
No hagas “shotgun” de funcionalidades. Una librería que hace todo termina siendo importada entera cuando solo se necesita una función. Namespace packages separados por responsabilidad (kpi_core, observability, utils) permiten que cada consumer instale solo lo que necesita.
Versiona desde el primer commit. Empezar en 0.1.0 y subir cuando hay cambios es mejor que empezar sin versión y tener que coordinar un “primer release” cuando ya hay 3 servicios usando la librería en producción.