Empaquetar y publicar un paquete Python en PyPI
1. Descripción general
Escribiste un módulo Python útil y quieres que otros lo instalen con pip install. Para eso, necesitas empaquetar el código en una distribución y subirla a PyPI (Python Package Index).
Este artículo recorre todo el proceso. Construirás la estructura estándar del proyecto, declararás metadatos en pyproject.toml, compilarás wheel y sdist, probarás en TestPyPI y luego publicarás de verdad. Al final, aprenderás a automatizar la publicación con GitHub Actions usando Trusted Publishing.
2. Estructura del proyecto
El ejemplo a lo largo es una pequeña biblioteca nonla-order-tools, usada para calcular el total de dinero en una orden. Usamos src layout:
nonla-order-tools/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│ └── nonla_order_tools/
│ ├── __init__.py
│ ├── pricing.py
│ └── cli.py
└── tests/
└── test_pricing.py
El código se encuentra en src/ en lugar de en el directorio raíz. La principal ventaja es que cuando ejecutas tests, Python no puede importar accidentalmente el código del directorio de trabajo. Se ve obligado a importar la versión ya instalada, así detectas temprano los errores de olvidar empaquetar archivos.
Hay dos nombres que diferenciar:
- Nombre de la distribución en PyPI:
nonla-order-tools. Los usuarios escriben este nombre conpip install. - Nombre del paquete importable:
nonla_order_tools. Se usa enimport, no puede contener guiones.
2.1. Código de la biblioteca
# src/nonla_order_tools/pricing.py
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass(frozen=True)
class OrderLine:
product: str
unit_price: Decimal
quantity: int
@dataclass
class Order:
customer: str
lines: list[OrderLine] = field(default_factory=list)
def add(self, product: str, unit_price: str, quantity: int = 1) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.lines.append(OrderLine(product, Decimal(unit_price), quantity))
def calculate_total(order: Order, vat_rate: Decimal = Decimal("0.10")) -> Decimal:
subtotal = sum((l.unit_price * l.quantity for l in order.lines), Decimal("0"))
return (subtotal * (1 + vat_rate)).quantize(Decimal("1"))
Order contiene una lista de OrderLine. calculate_total suma el dinero de cada línea, multiplica por VAT (10% por defecto) y redondea a la unidad de moneda. El código usa Decimal en lugar de float para evitar errores al calcular dinero.
# src/nonla_order_tools/__init__.py
from importlib.metadata import version
from .pricing import Order, OrderLine, calculate_total
__all__ = ["Order", "OrderLine", "calculate_total"]
__version__ = version("nonla-order-tools")
__version__ se lee de los metadatos del paquete ya instalado. Así, la versión solo se declara en un único lugar en pyproject.toml.
Agregamos un pequeño comando CLI:
# src/nonla_order_tools/cli.py
import sys
from decimal import Decimal
from .pricing import Order, calculate_total
def main() -> None:
# Uso: order-total <precio> <cantidad> [<precio> <cantidad> ...]
args = sys.argv[1:]
order = Order(customer="cli")
for price, qty in zip(args[::2], args[1::2]):
order.add("item", price, int(qty))
print(calculate_total(order, Decimal("0.10")))
3. Declarar metadatos en pyproject.toml
pyproject.toml es el único lugar que describe cómo compilar y la información del paquete. No necesitas setup.py para proyectos nuevos.
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[project]
name = "nonla-order-tools"
version = "0.1.0"
description = "Calcular el total de dinero en una orden con VAT"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Nón Lá Dev", email = "dev@nonla.example" }]
keywords = ["order", "pricing", "vat"]
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
dependencies = []
[project.optional-dependencies]
test = ["pytest>=8"]
[project.scripts]
order-total = "nonla_order_tools.cli:main"
Explicación de cada sección:
[build-system]le dice apipybuildcuál build backend usar. Aquí eshatchling. También puedes usarsetuptools,flit-coreopdm-backend. Los metadatos en[project]permanecen igual.nameyversionson obligatorios. La versión debe seguir el formatoMAJOR.MINOR.PATCH.readmese convertirá en la página de descripción en PyPI.requires-pythonimpide instalar en versiones de Python no compatibles.license = "MIT"es una expresión SPDX según el nuevo estándar. Este estándar requierehatchling>=1.27, por eso la versión mínima se especifica arriba.dependencieslista las bibliotecas requeridas en tiempo de ejecución, por ejemplo["httpx>=0.27"].optional-dependenciescrea “extras”. Los usuarios pueden instalar conpip install "nonla-order-tools[test]".[project.scripts]crea el comandoorder-totalenPATHcuando instalas el paquete. Este comando llama a la funciónmainen el módulocli.
Hatchling detecta automáticamente src/nonla_order_tools porque el nombre del directorio coincide con el nombre del proyecto normalizado (guión se convierte en guion bajo). Si los nombres son diferentes, debes declararlos manualmente:
[tool.hatch.build.targets.wheel]
packages = ["src/nonla_order_tools"]
4. Escribir tests e instalar en modo editable
Antes de publicar, asegúrate de que el comportamiento principal es correcto:
# tests/test_pricing.py
from decimal import Decimal
import pytest
from nonla_order_tools import Order, calculate_total
def test_total_includes_vat():
order = Order(customer="Lan")
order.add("Café", "25000", 2)
order.add("Pan", "20000")
assert calculate_total(order) == Decimal("77000")
def test_rejects_non_positive_quantity():
order = Order(customer="Minh")
with pytest.raises(ValueError, match="positive"):
order.add("Té", "15000", 0)
El primer test calcula (25000 × 2 + 20000) × 1.1 = 77000. El segundo test verifica que una cantidad de 0 sea rechazada.
Instala el proyecto en modo editable y ejecuta las pruebas:
python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest -q
.. [100%]
2 passed in 0.02s
La bandera -e vincula el paquete al directorio src/. Editar el código tiene efecto inmediatamente, sin necesidad de reinstalar.
5. Compilar wheel y sdist
Instala la herramienta build y luego ejecútala en el directorio raíz:
pip install --upgrade build twine
python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
- hatchling>=1.27
* Building sdist...
* Building wheel from sdist
Successfully built nonla_order_tools-0.1.0.tar.gz and nonla_order_tools-0.1.0-py3-none-any.whl
El directorio dist/ ahora contiene dos archivos:
- sdist (
.tar.gz): código fuente con metadatos. Se usa cuando no hay un wheel compatible. - wheel (
.whl): versión precompilada. El sufijopy3-none-anysignifica que funciona en cualquier sistema operativo con Python 3, porque el paquete es Python puro.
Verifica que el wheel contenga todos los archivos:
python -m zipfile -l dist/nonla_order_tools-0.1.0-py3-none-any.whl
File Name Modified Size
nonla_order_tools/__init__.py 2020-02-02 00:00:00 209
nonla_order_tools/cli.py 2020-02-02 00:00:00 363
nonla_order_tools/pricing.py 2020-02-02 00:00:00 786
nonla_order_tools-0.1.0.dist-info/METADATA 2020-02-02 00:00:00 1012
nonla_order_tools-0.1.0.dist-info/WHEEL 2020-02-02 00:00:00 87
nonla_order_tools-0.1.0.dist-info/entry_points.txt 2020-02-02 00:00:00 58
...
Las fechas fijas son intencionales: hatchling crea una compilación reproducible. El tamaño del archivo en tu máquina puede ser ligeramente diferente.
A continuación, usa twine para verificar los metadatos, especialmente si el README se renderiza correctamente:
twine check dist/*
Checking dist/nonla_order_tools-0.1.0-py3-none-any.whl: PASSED
Checking dist/nonla_order_tools-0.1.0.tar.gz: PASSED
El paso final antes de cargar es instalar el wheel en un entorno limpio e intentar importar:
python -m venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl
/tmp/verify/bin/python -c "import nonla_order_tools as n; print(n.__version__)"
/tmp/verify/bin/order-total 25000 2 20000 1
0.1.0
77000
Si este paso falla con ModuleNotFoundError, es probable que hayas olvidado empaquetar un archivo o hayas declarado incorrectamente la ruta del paquete.
6. Probar en TestPyPI primero
TestPyPI es una copia independiente de PyPI, destinada específicamente para pruebas. Ambos tienen cuentas y tokens separados.
- Crea una cuenta en TestPyPI y habilita la autenticación de dos factores (2FA).
- Ve a Account settings → API tokens y crea un token. El token comienza con
pypi-. - Carga:
twine upload --repository testpypi dist/*
Cuando se te pregunte, ingresa el usuario __token__ y la contraseña es la cadena del token. Para no tener que escribirlo de nuevo, puedes guardarlo en ~/.pypirc:
[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZwIk...
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJ...
Establece permisos chmod 600 ~/.pypirc para que otros usuarios en la máquina no puedan leer el archivo. Luego intenta instalar desde TestPyPI:
pip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
nonla-order-tools
La bandera --extra-index-url permite que pip obtenga las dependencias del PyPI real, porque TestPyPI generalmente no tiene suficientes paquetes de dependencia.
7. Publicar en PyPI
Cuando todo esté bien, el proceso en PyPI real es idéntico, solo omite --repository:
twine upload dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading nonla_order_tools-0.1.0-py3-none-any.whl
Uploading nonla_order_tools-0.1.0.tar.gz
View at:
https://pypi.org/project/nonla-order-tools/0.1.0/
A partir de ahora, cualquiera puede ejecutar pip install nonla-order-tools.
Para futuras versiones, sigues estos pasos:
- Aumenta
versionenpyproject.toml, por ejemplo a0.1.1. - Elimina el directorio anterior
dist/:rm -rf dist. - Ejecuta de nuevo
python -m build,twine check, luegotwine upload.
Nota: PyPI no permite volver a cargar un archivo con el mismo nombre de versión que ya existe, incluso si ya eliminaste esa versión. Si accidentalmente publicas una versión con error, solo puedes yankearlo y publicar una nueva versión. Por eso siempre debes probar en TestPyPI primero.
8. Publicación automática con Trusted Publishing
Almacenar un token de larga duración en CI conlleva el riesgo de exposición. Trusted Publishing elimina completamente los tokens. PyPI confía en un workflow específico de GitHub Actions a través de OIDC y emite tokens de corta duración para cada ejecución.
Configuración en PyPI: ve a Publishing del proyecto, o usa “pending publisher” si el proyecto aún no existe. Aquí, declara el propietario, el nombre del repositorio, el nombre del archivo de workflow publish.yml y el entorno pypi.
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install build
- run: python -m build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
Cómo funciona el workflow:
- El workflow se ejecuta cada vez que creas una GitHub Release.
- El job
buildcrea archivos endist/y los guarda como artefactos. - El job
publishestá separado y solo tiene permisoid-token: write. Este permiso permite solicitar un token OIDC, y la acciónpypa/gh-action-pypi-publishusa ese token para cargar. No necesitas ningún secreto. - Separar dos jobs ayuda que el paso de compilación, que ejecuta tu código y las dependencias de compilación, nunca tengan acceso a permisos de publicación.
9. Errores comunes
- Nombre ya está ocupado: PyPI informa
403o “The name is too similar to an existing project”. Verifica el nombre antes de asignarlo, y deberías agregar un prefijo único comononla-. File already exists: olvidaste aumentar la versión, o el directoriodist/aún tiene archivos antiguos. Siempre eliminadist/antes de compilar.- Archivos de datos faltantes (JSON, plantillas…): si los archivos no están dentro del directorio del paquete, entonces no se empaquetarán. Colócalos en
src/nonla_order_tools/y léelos usandoimportlib.resources. - Las pruebas pasan en tu máquina pero la importación falla después de instalar: esto es exactamente el error que src layout te ayuda a evitar. Siempre intenta instalar el wheel en un venv limpio como se describe arriba.
- Dependencias demasiado restrictivas: la biblioteca no debe fijar la versión como
requests==2.32.3, porque causará conflictos con los proyectos de los usuarios. Solo establece un límite inferior, comorequests>=2.31. El bloqueo de versión solo es apropiado para aplicaciones, y debe hacerse a través de un archivo de bloqueo. - Exposición del token: nunca hagas commit de
~/.pypirco del token en el repositorio. Deberías usar tokens limitados por proyecto, o mejor aún, Trusted Publishing.
Nota: No todo código debe subirse a PyPI. Las bibliotecas internas de la empresa deben usar un índice privado (como Artifactory, GitLab Package Registry) o instalar directamente desde Git. PyPI es un espacio público, y los nombres ya tomados serán muy difíciles de recuperar.
10. Conclusión
El proceso moderno de empaquetamiento en Python es bastante limpio. Todo gira en torno a un único archivo pyproject.toml, un build backend como hatchling, el comando python -m build para crear wheel y sdist, junto con twine para verificar y cargar.
Deberías desarrollar el hábito de seguir el orden: ejecutar pruebas, compilar, instalar en un venv limpio, probar en TestPyPI, luego publicar en PyPI. Cuando el proyecto esté estable, cambia a Trusted Publishing con GitHub Actions. Cada lanzamiento entonces solo será crear una Release, y no tendrás que gestionar tokens nunca más.