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 con pip install.
  • Nombre del paquete importable: nonla_order_tools. Se usa en import, 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 a pip y build cuál build backend usar. Aquí es hatchling. También puedes usar setuptools, flit-core o pdm-backend. Los metadatos en [project] permanecen igual.
  • name y version son obligatorios. La versión debe seguir el formato MAJOR.MINOR.PATCH.
  • readme se convertirá en la página de descripción en PyPI.
  • requires-python impide instalar en versiones de Python no compatibles.
  • license = "MIT" es una expresión SPDX según el nuevo estándar. Este estándar requiere hatchling>=1.27, por eso la versión mínima se especifica arriba.
  • dependencies lista las bibliotecas requeridas en tiempo de ejecución, por ejemplo ["httpx>=0.27"].
  • optional-dependencies crea “extras”. Los usuarios pueden instalar con pip install "nonla-order-tools[test]".
  • [project.scripts] crea el comando order-total en PATH cuando instalas el paquete. Este comando llama a la función main en el módulo cli.

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 sufijo py3-none-any significa 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.

  1. Crea una cuenta en TestPyPI y habilita la autenticación de dos factores (2FA).
  2. Ve a Account settings → API tokens y crea un token. El token comienza con pypi-.
  3. 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:

  1. Aumenta version en pyproject.toml, por ejemplo a 0.1.1.
  2. Elimina el directorio anterior dist/: rm -rf dist.
  3. Ejecuta de nuevo python -m build, twine check, luego twine 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 build crea archivos en dist/ y los guarda como artefactos.
  • El job publish está separado y solo tiene permiso id-token: write. Este permiso permite solicitar un token OIDC, y la acción pypa/gh-action-pypi-publish usa 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 403 o “The name is too similar to an existing project”. Verifica el nombre antes de asignarlo, y deberías agregar un prefijo único como nonla-.
  • File already exists: olvidaste aumentar la versión, o el directorio dist/ aún tiene archivos antiguos. Siempre elimina dist/ 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 usando importlib.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, como requests>=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 ~/.pypirc o 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.

pythonpypipackagingpyprojectgithub-actions