src-layout vs flat-layout en paquetes Python publicables

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/*.

175

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio