Cuando preparas un paquete para publicar en PyPI, la forma en que organizas los directorios no es solo estética: determina si tus tests validan lo que crees que validan, y si el build system empaqueta exactamente lo que debe empaquetar.
Hay dos convenciones establecidas. El flat-layout pone el paquete directamente en la raíz del repositorio, al mismo nivel que tests/ y pyproject.toml. El src-layout introduce un directorio intermedio src/ que contiene el paquete. Parece un detalle menor. No lo es.
El problema del flat-layout
Con flat-layout, cuando ejecutas pytest desde la raíz, Python añade automáticamente el directorio actual al sys.path. Eso significa que import mi_paquete resuelve contra la carpeta local mi_paquete/, no contra el paquete instalado en el entorno virtual. Tus tests pasan, publicas en PyPI, un usuario instala el paquete… y algo falla porque el paquete instalado tiene un comportamiento distinto al que testeaste. El directorio local enmascaró el problema completo.
Con src-layout ese accidente es imposible: no existe ningún mi_paquete/ en la raíz que Python pueda encontrar por accidente. Si no instalas el paquete (pip install -e .), el import falla limpiamente. Si lo instalas, los tests usan exactamente el código que empaquetarías.
La regla práctica: para cualquier proyecto que vayas a distribuir, usa src-layout desde el principio. El flat-layout es aceptable para scripts internos que nunca saldrán de tu repositorio.
mi_proyecto/ ├── src/ │ └── mi_paquete/ │ ├── __init__.py │ └── core.py ├── tests/ │ └── test_core.py ├── pyproject.toml └── README.md
# ── src/mi_paquete/__init__.py ───────────────────────────────────────── from .core import procesar __version__ = "0.1.0"
# ── src/mi_paquete/core.py ─────────────────────────────────────────────
def procesar(datos: list[int]) -> list[int]:
"""Devuelve solo los valores positivos, ordenados."""
return sorted(x for x in datos if x > 0)
# ── tests/test_core.py ────────────────────────────────────────────────
# Este import solo funciona si el paquete está instalado (pip install -e .)
# Con flat-layout podría funcionar aunque NO estuviera instalado,
# dando una falsa sensación de seguridad.
from mi_paquete.core import procesar
def test_filtra_negativos():
assert procesar([-3, 1, 0, 4, -1]) == [1, 4]
def test_lista_vacia():
assert procesar([]) == []
# ── pyproject.toml ────────────────────────────────────────────────────
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "mi-paquete"
version = "0.1.0"
description = "Ejemplo mínimo con src-layout"
authors = [
{ name = "Ana García", email = "ana@ejemplo.com" }
]
requires-python = ">=3.11"
dependencies = [] # lista tus dependencias reales aquí
# Apunta la descripción larga al README para que PyPI la muestre
readme = "README.md"
[tool.hatch.build.targets.wheel]
# Sin esta sección hatchling no sabe que el código vive en src/
packages = ["src/mi_paquete"]
<!-- README.md --> # mi-paquete Filtra y ordena listas de enteros. PyPI muestra este archivo como la descripción completa del paquete en su página web. ## Instalación
bash
pip install mi-paquete
## Uso rápido
python
from mi_paquete import procesar
procesar([-1, 3, 0, 2]) # → [2, 3]
Qué significa cada decisión
[build-system] le dice a herramientas como pip y build qué backend usar para construir el wheel y el sdist. Aquí elegimos hatchling, pero setuptools o flit seguirían la misma lógica. Lo importante es que esta tabla sea la primera en pyproject.toml; algunos backends se confunden si no lo es.
[project] es la metadata estándar definida en PEP 621. name es el identificador en PyPI (usa guiones, no guiones bajos). version puede ser estática como aquí o dinámica leyendo __version__ del paquete. requires-python = ">=3.11" evita que pip instale tu paquete en un entorno incompatible antes de intentar importarlo y fallar con un error críptico.
readme = "README.md" conecta tu archivo Markdown con la página del paquete en PyPI. Si omites esta línea, la página queda en blanco. PyPI renderiza Markdown y reStructuredText; asegúrate de que el campo readme apunte al archivo correcto o verás el texto crudo.
[tool.hatch.build.targets.wheel] con packages = ["src/mi_paquete"] es el punto donde le explicas al build backend que tu código no está en la raíz. Sin esta instrucción, hatchling busca el paquete en la raíz, no lo encuentra en src/, y construye un wheel vacío que instala sin errores pero no exporta nada.
Errores que debes conocer
Error: omitir pip install -e . antes de correr pytest con src-layout. Python no encuentra el paquete y el error parece un bug de estructura cuando en realidad el entorno está incompleto.
# ❌ Wrong — ejecutar pytest sin instalar primero # $ pytest # ImportError: No module named 'mi_paquete' # ✅ Right — instalar en modo editable primero # $ pip install -e . # $ pytest
Con -e (editable install), pip crea un enlace directo a src/mi_paquete en el entorno virtual, así cada cambio que hagas es inmediatamente visible sin reinstalar.
Error: usar el nombre del paquete con guiones bajos en el campo name de pyproject.toml. PyPI normaliza los nombres, pero algunos resolvedores de dependencias diferencian entre mi_paquete y mi-paquete, lo que puede causar instalaciones duplicadas en el mismo entorno.
# ❌ Wrong [project] name = "mi_paquete" # ✅ Right [project] name = "mi-paquete"
PyPI trata mi-paquete, mi_paquete y mi.paquete como el mismo nombre, pero la convención oficial (PEP 625) usa guiones, y es la que pip muestra en pip list.
Error: incluir tests/ dentro de src/. Parece organizado, pero el build backend empaquetará los tests en el wheel, lo que incrementa el tamaño del paquete instalado y expone código de pruebas a los usuarios finales.
# ❌ Wrong src/ mi_paquete/ tests/ ← esto acaba en el wheel # ✅ Right src/ mi_paquete/ tests/ ← fuera de src/, nunca se empaqueta
Los tests viven fuera de src/ precisamente porque no son parte del paquete distribuible; son herramientas de desarrollo para quien contribuye al proyecto.
Una vez que tengas esta estructura en pie, python -m build genera el wheel y el sdist listos para subir con twine upload dist/*.
N° 175