Durante años, empaquetar un proyecto Python fue sinónimo de setup.py: un script ejecutable que setuptools corría para descubrir metadatos, compilar extensiones y construir distribuciones. El problema fundamental es que ejecutar código arbitrario para obtener metadatos simples (nombre, versión, dependencias) es innecesariamente peligroso e impredecible. Pip tenía que importar setup.py en el proceso actual, sin aislamiento, y confiar en que no hiciera nada raro. Spoiler: a veces sí hacía cosas raras.
La solución llegó en tres PEPs que conviene entender juntos porque cada uno resuelve una capa distinta del problema.
PEP 518 (2016) introduce pyproject.toml con un objetivo muy concreto: permitir que un proyecto declare qué herramientas necesita para construirse antes de que se intente construirlo. Eso va en la tabla [build-system]. Nada más. No define cómo se declaran los metadatos del proyecto.
PEP 517 (2017) define la interfaz entre dos actores que antes estaban fundidos: el build frontend (quien orquesta: pip, build, uv) y el build backend (quien sabe construir: setuptools, hatchling, pdm-backend, poetry-core). El frontend invoca hooks estándar (build_wheel, build_sdist, get_requires_for_build_wheel…) sobre el backend en un subproceso aislado. Nunca más código arbitrario corriendo en tu entorno.
PEP 621 (2021) llega después y estandariza dónde viven los metadatos del proyecto dentro de pyproject.toml: la tabla [project]. Antes, cada backend tenía su propio esquema ([tool.poetry], [tool.setuptools], etc.). PEP 621 dice: hay una tabla canónica, y cualquier backend puede leerla. Hatchling, pdm-backend y setuptools modernos la soportan. Poetry todavía usa su propia tabla, lo que explica parte de la fricción cuando mezclas herramientas.
El flujo completo cuando ejecutas pip install . ahora es:
- Pip lee
[build-system]enpyproject.toml. - Crea un entorno virtual temporal e instala las
build-requiresahí. - Invoca el hook
build_wheeldel backend en ese entorno aislado. - Instala la wheel resultante.
python setup.py install se salta todo esto: corre directamente en tu entorno, sin aislamiento, sin garantías de que las dependencias de build estén disponibles, y produce un huevo (egg-info) en lugar de una wheel. Desde pip 23.1 ves deprecation warnings; la dirección es eliminarlo.
# pyproject.toml — proyecto real con hatchling como backend
[build-system]
requires = ["hatchling>=1.21"]
build-backend = "hatchling.build"
# Metadatos canónicos según PEP 621
[project]
name = "reflex-cache"
version = "0.4.2"
description = "A pluggable caching layer for ASGI applications"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"
dependencies = [
"anyio>=4.0",
"msgspec>=0.18",
]
[project.optional-dependencies]
redis = ["redis>=5.0"]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
[project.urls]
Homepage = "https://github.com/example/reflex-cache"
Repository = "https://github.com/example/reflex-cache"
[project.scripts]
reflex-cache = "reflex_cache.cli:main"
# Configuración específica del backend — aquí cada backend tiene su sección
[tool.hatch.build.targets.wheel]
packages = ["src/reflex_cache"] # layout src/, hatchling necesita saberlo
[tool.hatch.build.targets.sdist]
# Incluye tests en el sdist pero no en la wheel — control granular sin MANIFEST.in
include = ["src/", "tests/", "README.md"]
# src/reflex_cache/__init__.py __version__ = "0.4.2"
Fíjate en las decisiones que toma este pyproject.toml:
La tabla [build-system] es la única parte obligatoria para PEP 517. requires declara las dependencias de build con versión mínima (quien construye el paquete necesita esto, no quien lo instala). build-backend es el dotted path al objeto que expone los hooks.
En [project] todo es PEP 621 puro. Cualquier herramienta que entienda el estándar puede leer esto: pip, uv, build, scripts de CI. requires-python merece atención especial porque los resolvedores modernos lo usan activamente para filtrar versiones compatibles; omitirlo hace que tu paquete parezca compatible con Python 2.
[project.optional-dependencies] reemplaza el extras_require de setup.py. El grupo dev no se instala con pip install reflex-cache, pero sí con pip install "reflex-cache[dev]" o uv sync --extra dev.
[project.scripts] genera los entry points ejecutables en bin/ o Scripts/. El valor reflex_cache.cli:main es módulo:callable. Setuptools lo hacía igual, pero la declaración era en setup.py como diccionario; aquí es TOML declarativo, parseable sin ejecutar nada.
La sección [tool.hatch.*] contiene configuración específica del backend. Esto es lo que hace que mezclar frontends sea seguro: [project] es estándar, [tool.X] es el espacio privado del backend. Si mañana migras de hatchling a setuptools, tocas [build-system] y [tool.*], no [project].
El src/ layout merece una mención: poner el paquete en src/reflex_cache/ en vez de directamente en la raíz evita que accidentalmente importes el código sin instalar (porque . no está en sys.path por defecto en un entorno limpio). Es la convención que hatchling asume; con setuptools moderno también funciona con packages = {find = {where = ["src"]}}.
Errores que debes conocer
Error: Declarar version tanto en [project] como en el propio módulo hace que se desincronicen. Pasa cuando copias un pyproject.toml antiguo y editas solo uno de los dos.
# ❌ Wrong — versión duplicada que se desincroniza [project] version = "0.4.1" # se olvidaron de actualizar esto
# ✅ Right — hatchling gestiona la versión dinámica leyendo __version__ [project] dynamic = ["version"] [tool.hatch.version] path = "src/reflex_cache/__init__.py"
La tabla dynamic le dice a PEP 621 que ese campo lo proveerá el backend; hatchling lee __version__ del archivo indicado y tiene una sola fuente de verdad.
Error: Asumir que pip install -e . en modo editable funciona igual con todos los backends. Setuptools implementa editable installs con un .pth file; hatchling los implementa diferente (requiere hatchling >= 1.9 y pip >= 21.3). Con versiones viejas de pip, el install editable silenciosamente instala una copia no-editable.
# ❌ Wrong — pip antiguo con hatchling puede sillar sin avisar pip install -e . # ✅ Right — verifica que el modo editable funciona revisando el tipo de instalación pip install -e . && python -c "import reflex_cache; print(reflex_cache.__file__)" # Si muestra una ruta dentro de site-packages en lugar de src/, algo falló
Con uv esto no es un problema porque siempre viene con soporte moderno de editable installs.
Error: Incluir dependencias de desarrollo en [project.dependencies] en vez de en [project.optional-dependencies]. Quien instale tu librería arrastrará pytest y black como dependencias transitivas.
# ❌ Wrong [project] dependencies = ["anyio>=4.0", "pytest>=8.0", "black>=24.0"] # ✅ Right [project] dependencies = ["anyio>=4.0"] [project.optional-dependencies] dev = ["pytest>=8.0", "black>=24.0"]
[project.dependencies] es el runtime del paquete; todo lo que no necesita quien consume tu librería va en un extra.
N° 174