Publicar en PyPI: proceso completo con twine y uv

Publicar un paquete en PyPI no es solo copiar archivos a un servidor — es un proceso con pasos concretos donde cada herramienta cumple una función específica. Vamos a recorrerlo entero, desde preparar los artefactos hasta automatizar el release en CI.

Qué se sube realmente y por qué

Cuando publicas en PyPI, subes dos tipos de artefactos: un sdist (source distribution, un tarball .tar.gz con tu código fuente) y un wheel (archivo .whl precompilado). El sdist es el fallback universal; el wheel es lo que pip prefiere porque no necesita compilar nada en el cliente. Si solo subes el sdist, usuarios con extensiones C/Rust esperarán la compilación. Si solo subes el wheel, pierdes compatibilidad con entornos exóticos. Sube los dos.

El versionado semántico no es una convención opcional: PyPI no te deja sobreescribir una versión existente. Si subes 1.2.3 y hay un bug, tienes que publicar 1.2.4. La regla es simple: MAJOR para cambios que rompen la API pública, MINOR para features nuevas compatibles hacia atrás, PATCH para bugfixes. Saltarte esto confunde a tus usuarios y a los resolvedores de dependencias.

Lo que más se rompe en este proceso no es el código sino los metadatos: un campo description con RST inválido, una clasificadora (classifier) inexistente, o un README que PyPI no puede renderizar. Por eso twine check existe y debe ejecutarse antes de cualquier upload.

El proyecto de ejemplo

Vamos a usar un paquete pequeño y realista, con la estructura que python -m build espera:

mypackage/
├── src/
│   └── mypackage/
│       ├── __init__.py
│       └── utils.py
├── tests/
│   └── test_utils.py
├── pyproject.toml
└── README.md
# pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mypackage"
version = "1.0.0"
description = "Utilidades de texto para procesamiento NLP básico"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
dependencies = [
    "requests>=2.28",
]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]

[project.urls]
Homepage = "https://github.com/example/mypackage"
"Bug Tracker" = "https://github.com/example/mypackage/issues"

[project.optional-dependencies]
dev = ["pytest>=7", "twine", "build"]
# src/mypackage/__init__.py
__version__ = "1.0.0"

from .utils import normalize_text, tokenize

__all__ = ["normalize_text", "tokenize"]
# src/mypackage/utils.py
import re
import unicodedata


def normalize_text(text: str) -> str:
    """Elimina acentos y pasa a minúsculas. Útil como preproceso NLP."""
    nfkd = unicodedata.normalize("NFKD", text)
    # La forma NFKD separa carácter base de sus diacríticos (Mn = Mark, Nonspacing)
    ascii_text = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
    return ascii_text.lower()


def tokenize(text: str, pattern: str = r"\w+") -> list[str]:
    """Tokeniza por patrón regex. Por defecto extrae palabras alfanuméricas."""
    return re.findall(pattern, text)
# tests/test_utils.py
from mypackage.utils import normalize_text, tokenize


def test_normalize_removes_accents():
    assert normalize_text("Héroe") == "heroe"


def test_tokenize_default():
    assert tokenize("hola mundo!") == ["hola", "mundo"]

Ahora el flujo completo de publicación:

# 1. Instala las herramientas si no las tienes
pip install build twine

# 2. Limpia cualquier artefacto anterior para evitar subir versiones viejas
rm -rf dist/

# 3. Construye sdist + wheel. El flag --wheel es implícito, pero lo dejamos explícito
python -m build

# dist/ ahora contiene:
#   mypackage-1.0.0.tar.gz   ← sdist
#   mypackage-1.0.0-py3-none-any.whl  ← wheel puro Python

# 4. Verifica metadatos y rendering del README *antes* de subir
twine check dist/*
# Si hay warnings en el README (RST inválido, etc.), los ves aquí, no en PyPI

# 5. Publica primero en Test PyPI para confirmar que todo se ve bien
twine upload -r testpypi dist/*
# Visita https://test.pypi.org/project/mypackage/ y verifica descripción, versión, etc.

# 6. Publica en PyPI real
twine upload dist/*
# Pedirá __token__ como usuario y tu API token como contraseña
# O lee el token desde ~/.pypirc para no escribirlo cada vez

# Alternativa moderna: uv publish (más rápido, sin configuración extra)
# uv publish --token $PYPI_TOKEN dist/*
# ~/.pypirc  — guarda tus tokens para no teclearlos
[distutils]
index-servers =
    pypi
    testpypi

[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmc...  # tu API token de pypi.org/manage/account/token/

[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-...  # token separado de test.pypi.org

El workflow de CI que automatiza todo esto con GitHub Actions:

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - "v[0-9]+.[0-9]+.[0-9]+"  # solo dispara en tags semánticos tipo v1.2.3

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    environment: pypi  # environment con el secret PYPI_TOKEN configurado en GitHub

    permissions:
      id-token: write  # necesario para Trusted Publishing (OIDC), la opción más segura

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Install build tools
        run: pip install build twine

      - name: Build artifacts
        run: python -m build

      - name: Check metadata
        run: twine check dist/*

      - name: Run tests before publish
        run: |
          pip install -e ".[dev]"
          pytest

      # Opción A: twine con token explícito
      - name: Publish to PyPI (twine)
        if: false  # cambia a true si prefieres twine sobre OIDC
        env:
          TWINE_USERNAME: __token__
          TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
        run: twine upload dist/*

      # Opción B: Trusted Publishing con OIDC (sin tokens, la recomendada)
      - name: Publish to PyPI (OIDC)
        uses: pypa/gh-action-pypi-publish@release/v1
        # No necesita secrets si configuraste Trusted Publishing en pypi.org

Lo que importa de cada decisión

El layout src/ no es arbitrario. Cuando instalas el paquete en modo editable (pip install -e .), Python resuelve imports desde src/, no desde la raíz del repo. Esto evita un bug sutil: sin src/, import mypackage puede resolver al directorio local en lugar del paquete instalado, haciendo que tus tests pasen localmente pero fallen en CI donde el directorio no existe.

twine check ejecuta el mismo renderizador que PyPI usa para tu README. Un archivo README.md con un bloque de código sin lenguaje especificado puede mostrar warnings; un README .rst con una directiva desconocida falla silenciosamente en el render. Si saltas este paso y subes directamente, descubres el problema cuando ya enviaste una versión que no puedes borrar.

El orden de los pasos en CI importa: los tests van antes del publish. Parece obvio, pero en pipelines apurados se invierte y terminas publicando código roto. Una vez publicada una versión en PyPI, no puedes sobreescribirla ni borrarla de forma que usuarios existentes no la vean — PyPI tiene retención indefinida de artefactos por diseño.

Trusted Publishing (la opción OIDC del workflow) es la forma recomendada hoy: en lugar de guardar un PYPI_TOKEN como secret en GitHub, configuras en pypi.org que este repositorio puede publicar sin token. GitHub Actions obtiene un JWT efímero de GitHub y PyPI lo acepta directamente. Elimina la rotación de tokens y el riesgo de leak en logs.

uv publish es la alternativa si ya usas uv como gestor de entorno. Internamente hace lo mismo que twine pero con menos configuración: detecta los artefactos en dist/, verifica metadatos básicos y sube. La diferencia práctica hoy es que twine check es más exhaustivo en la validación de metadatos, así que incluso usando uv publish para subir, vale la pena correr twine check dist/* antes.

Errores que debes conocer

Error: publicar sin incrementar la versión en pyproject.toml y en __version__. PyPI rechaza el upload con 400 File already exists, pero el error no dice explícitamente qué versión conflicta si tienes varios artefactos.

# ❌ Wrong: subir dist/ sin actualizar la versión
python -m build
twine upload dist/*
# ERROR 400: File already exists.

# ✅ Right: actualizar versión primero, luego construir desde cero
# En pyproject.toml: version = "1.0.1"
# En src/mypackage/__init__.py: __version__ = "1.0.1"
rm -rf dist/
python -m build
twine check dist/*
twine upload dist/*

La versión debe ser consistente en pyproject.toml y en __init__.py; herramientas como hatch version bump patch actualizan ambos automáticamente.

Error: usar credenciales de usuario/contraseña en lugar de API tokens. PyPI deshabilitó la autenticación por contraseña para uploads en 2023.

# ❌ Wrong: usuario y contraseña real
twine upload dist/* -u mi_usuario -p mi_contraseña
# 403 Forbidden: Username/Password authentication is no longer supported

# ✅ Right: siempre __token__ como usuario y el API token como contraseña
twine upload dist/* -u __token__ -p pypi-AgEIcHlwaS5vcmc...

El string literal __token__ es exactamente lo que PyPI espera como username cuando usas tokens — no es un placeholder.

Error: no limpiar dist/ antes de construir una versión nueva. Terminas subiendo artefactos de versiones anteriores mezclados con los nuevos, y twine upload los sube todos, generando confusión en la página del paquete.

# ❌ Wrong: acumular artefactos de versiones previas
python -m build  # dist/ tiene 1.0.0 y ahora también 1.0.1
twine upload dist/*  # intenta subir 1.0.0 otra vez → error, y 1.0.1 → ok

# ✅ Right: siempre limpia antes de construir
rm -rf dist/ build/ *.egg-info
python -m build
twine upload dist/*

176

Dejar un comentario

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

Scroll al inicio