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/*
N° 176