Durante años, un proyecto Python serio llegaba con una colección de archivos que nadie quería mantener: setup.py con código arbitrario que se ejecutaba al instalar el paquete, setup.cfg para lo que setup.py no manejaba bien en formato declarativo, MANIFEST.in para incluir archivos no-Python en la distribución, tox.ini para los entornos de prueba, y pytest.ini o .coveragerc por encima de todo eso. Cada herramienta inventó su propio archivo porque no existía ningún estándar.
PEP 518 (2016) estableció que los proyectos debían poder declarar sus dependencias de build antes de ejecutar cualquier código. PEP 517 definió la interfaz entre el frontend (pip) y el backend de construcción (setuptools, flit, hatch…). PEP 621 completó el cuadro al estandarizar los metadatos del proyecto dentro de ese mismo archivo. El resultado es pyproject.toml: un archivo TOML con secciones bien definidas donde cada herramienta del ecosistema sabe dónde leer su configuración.
El punto clave es que pyproject.toml no reemplaza a pip ni a tu backend de build. Es el lugar donde le dices quién construye tu paquete y cómo está configurado todo lo demás. Herramientas como ruff, mypy y pytest adoptaron la convención de leer desde secciones [tool.<nombre>], lo que elimina prácticamente todos esos archivos sueltos.
Usar esto mal —o no usarlo— tiene consecuencias concretas: pip install -e . puede fallar si [build-system] está ausente o mal declarado, los metadatos del paquete publicado en PyPI quedan incompletos, y cada colaborador acaba con versiones distintas de las herramientas de desarrollo porque nadie declaró esas dependencias de forma canónica.
# pyproject.toml
# ── 1. Build system ──────────────────────────────────────────────────────────
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"
# ── 2. Metadatos del proyecto (PEP 621) ──────────────────────────────────────
[project]
name = "invoice-processor"
version = "1.4.0"
description = "Parse and validate PDF invoices with rule-based extraction."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [
{ name = "Lucía Moreno", email = "lucia@example.com" },
]
keywords = ["invoices", "pdf", "parsing"]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3.11",
"License :: OSI Approved :: MIT License",
]
# Dependencias de runtime: lo que el usuario final necesita instalado
dependencies = [
"pdfplumber>=0.10,<0.12",
"httpx>=0.27",
"pydantic>=2.6",
]
[project.optional-dependencies]
# pip install invoice-processor[dev] instala esto además de las deps de runtime
dev = [
"pytest>=8.2",
"pytest-cov>=5.0",
"mypy>=1.10",
"ruff>=0.4",
]
[project.scripts]
# Crea el comando `invoice` en el PATH al instalar el paquete
invoice = "invoice_processor.cli:main"
[project.urls]
Repository = "https://github.com/example/invoice-processor"
"Bug Tracker" = "https://github.com/example/invoice-processor/issues"
# ── 3. Configuración de herramientas ─────────────────────────────────────────
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--strict-markers --tb=short"
# Los markers personalizados evitan el warning de "unknown marker"
markers = [
"slow: tests that hit real network or disk",
"unit: fast isolated tests",
]
[tool.mypy]
python_version = "3.11"
strict = true
# Permite ignorar stubs ausentes para librerías sin tipos declarados
ignore_missing_imports = true
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] # pycodestyle, pyflakes, isort, pyupgrade, bugbear
ignore = ["E501"] # la longitud ya la controla el formatter
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101"] # assert es válido en tests
[tool.coverage.run]
source = ["invoice_processor"]
branch = true # mide cobertura de ramas, no solo líneas
[tool.coverage.report]
show_missing = true
fail_under = 85
Lo que hace cada sección y por qué importa
[build-system] es la sección más crítica y la más incomprendida. requires lista los paquetes que pip debe instalar en un entorno aislado temporal antes de construir tu paquete. Sin esto, pip podría intentar importar setuptools desde el entorno del usuario, lo que rompe builds en entornos limpios. build-backend le dice exactamente qué objeto Python expone la interfaz PEP 517. Si usas hatchling o flit-core el valor cambia, pero la estructura es idéntica.
[project.dependencies] declara lo que necesita el usuario de tu librería o herramienta, con rangos de versión deliberadamente amplios. Esto es diferente a requirements.txt en un aspecto fundamental: requirements.txt es para reproducibilidad de entornos —suele tener versiones exactas (==) y se genera con pip freeze— mientras que [project.dependencies] es para compatibilidad —expresa los límites reales que tu código puede tolerar. Si publicas en PyPI y pones versiones fijadas en [project.dependencies], vas a crear conflictos de dependencias para cualquiera que use tu paquete junto con otro.
[project.optional-dependencies] con la clave dev es el patrón idiomático para dependencias de desarrollo. pip install -e ".[dev]" instala el proyecto en modo editable más las herramientas de desarrollo. No hay un nombre mágico; dev es convención, podrías tener test, docs, lint como grupos separados.
Las secciones [tool.*] no tienen semántica definida por PEP 621; cada herramienta documenta la suya. Lo que sí es estándar es que cualquier herramienta que respete el ecosistema moderno debe buscar su configuración aquí antes de buscar en archivos propios. pytest lee [tool.pytest.ini_options] y hace obsoletos pytest.ini y la sección [tool:pytest] de setup.cfg. mypy hace obsoleto mypy.ini. ruff reemplaza a la vez .flake8, .isort.cfg y partes de pyproject.toml que antes configuraban black.
Fíjate en la configuración de ruff: separar [tool.ruff] de [tool.ruff.lint] no es capricho estético, es la estructura que ruff espera desde su versión 0.1. Mezclarlos en una sola sección hace que las claves de lint sean ignoradas silenciosamente.
Errores que debes conocer
Error: Poner dependencias de desarrollo en [project.dependencies] en lugar de [project.optional-dependencies]. Ocurre cuando se migra desde requirements.txt sin pensar en la distinción runtime/dev.
# ❌ Wrong [project] dependencies = [ "pdfplumber>=0.10", "pytest>=8.2", # pytest nunca es una dependencia de runtime "ruff>=0.4", ] # ✅ Right [project] dependencies = [ "pdfplumber>=0.10", ] [project.optional-dependencies] dev = [ "pytest>=8.2", "ruff>=0.4", ]
Cualquiera que haga pip install invoice-processor acabaría con pytest y ruff instalados sin pedirlos, y podrías introducir conflictos de versiones en sus entornos.
Error: Omitir [build-system] asumiendo que pip lo detecta automáticamente. Pip usa setuptools como fallback legacy, pero ese comportamiento está deprecado y produce un warning que los CI tratan como error.
# ❌ Wrong — archivo sin sección [build-system] [project] name = "invoice-processor" version = "1.4.0" # ✅ Right [build-system] requires = ["setuptools>=68"] build-backend = "setuptools.backends.legacy:build" [project] name = "invoice-processor" version = "1.4.0"
Declarar [build-system] explícitamente hace que el build sea reproducible y elimina la dependencia del comportamiento de fallback de pip.
Error: Usar [tool:pytest] en lugar de [tool.pytest.ini_options]. El primero es la sintaxis de setup.cfg (que usa : como separador de sección), TOML usa . para subsecciones.
# ❌ Wrong — TOML lo parsea como una clave literal "tool:pytest" ["tool:pytest"] testpaths = ["tests"] # ✅ Right [tool.pytest.ini_options] testpaths = ["tests"]
Con la sintaxis incorrecta, pytest no encuentra su configuración y usa sus defaults, lo que puede hacer que los tests se descubran en directorios inesperados o que los markers personalizados generen warnings.
N° 72