Crear y publicar un paquete Python en PyPI: guía completa
Publicar un paquete en PyPI (Python Package Index) permite que cualquiera lo instale con pip install tu-paquete. Este tutorial cubre la estructura moderna con pyproject.toml (PEP 517/518).
1. Estructura de un paquete moderno
mi_paquete/
├── src/
│ └── mi_paquete/
│ ├── __init__.py
│ ├── core.py
│ ├── utils.py
│ └── py.typed # Indica soporte para type checking
├── tests/
│ ├── __init__.py
│ ├── test_core.py
│ └── test_utils.py
├── docs/
│ └── index.md
├── pyproject.toml # Configuración del paquete (moderno)
├── README.md
├── LICENSE
└── .gitignore
2. pyproject.toml: la configuración moderna
[build-system]
requires = ["hatchling"] # o "setuptools>=68", "flit_core", etc.
build-backend = "hatchling.build"
[project]
name = "mi-paquete"
version = "0.1.0"
description = "Un paquete Python de ejemplo"
readme = "README.md"
license = { file = "LICENSE" }
authors = [{ name = "Tu Nombre", email = "tu@email.com" }]
requires-python = ">=3.9"
keywords = ["python", "conversión", "archivos"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
# Dependencias obligatorias
dependencies = [
"requests>=2.28.0",
"Pillow>=10.0.0",
]
# URLs del proyecto
[project.urls]
Homepage = "https://github.com/tuusuario/mi-paquete"
Documentation = "https://mi-paquete.readthedocs.io"
Repository = "https://github.com/tuusuario/mi-paquete"
"Bug Tracker" = "https://github.com/tuusuario/mi-paquete/issues"
# Dependencias opcionales (extras)
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"mypy>=1.0",
"ruff>=0.1.0",
]
excel = ["openpyxl>=3.1"]
async = ["httpx>=0.25", "aiofiles>=23.0"]
# Scripts de línea de comandos (CLI)
[project.scripts]
mi-herramienta = "mi_paquete.cli:main"
3. Código del paquete
# src/mi_paquete/__init__.py
"""Mi paquete de ejemplo."""
__version__ = "0.1.0"
__author__ = "Tu Nombre"
__all__ = ["convertir", "validar"]
from .core import convertir, validar
# src/mi_paquete/core.py
"""Funciones principales del paquete."""
from pathlib import Path
from typing import Optional
def convertir(
origen: str | Path,
destino: str | Path,
*,
calidad: int = 85,
sobrescribir: bool = False,
) -> Path:
"""
Convierte un archivo a otro formato.
Args:
origen: Ruta del archivo de entrada.
destino: Ruta del archivo de salida.
calidad: Calidad de compresión (1-100). Por defecto 85.
sobrescribir: Si True, sobreescribe el archivo destino.
Returns:
Path del archivo convertido.
Raises:
FileNotFoundError: Si el archivo de origen no existe.
FileExistsError: Si el destino existe y sobrescribir=False.
"""
origen = Path(origen)
destino = Path(destino)
if not origen.exists():
raise FileNotFoundError(f"Archivo no encontrado: {origen}")
if destino.exists() and not sobrescribir:
raise FileExistsError(f"El archivo ya existe: {destino}. Usa sobrescribir=True.")
# ... lógica de conversión ...
return destino
def validar(ruta: str | Path, extensiones: Optional[list[str]] = None) -> bool:
"""Valida si un archivo tiene una extensión permitida."""
ruta = Path(ruta)
if extensiones is None:
return ruta.exists()
return ruta.suffix.lower() in {ext.lower() for ext in extensiones}
# src/mi_paquete/cli.py
"""Interfaz de línea de comandos."""
import argparse
import sys
from .core import convertir
def main():
parser = argparse.ArgumentParser(
prog="mi-herramienta",
description="Convierte archivos entre formatos."
)
parser.add_argument("origen", help="Archivo de entrada")
parser.add_argument("destino", help="Archivo de salida")
parser.add_argument("--calidad", type=int, default=85, help="Calidad (1-100)")
parser.add_argument("--forzar", action="store_true", help="Sobrescribir si existe")
args = parser.parse_args()
try:
resultado = convertir(args.origen, args.destino,
calidad=args.calidad, sobrescribir=args.forzar)
print(f"Convertido: {resultado}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
4. Tests
# tests/test_core.py
import pytest
from pathlib import Path
from mi_paquete.core import convertir, validar
def test_validar_extension_valida(tmp_path):
archivo = tmp_path / "foto.jpg"
archivo.write_bytes(b"FAKE")
assert validar(archivo, [".jpg", ".png"])
def test_validar_extension_invalida(tmp_path):
archivo = tmp_path / "foto.jpg"
archivo.write_bytes(b"FAKE")
assert not validar(archivo, [".png", ".webp"])
def test_convertir_origen_no_existe(tmp_path):
with pytest.raises(FileNotFoundError):
convertir(tmp_path / "no_existe.jpg", tmp_path / "salida.png")
def test_convertir_destino_existe_sin_forzar(tmp_path):
origen = tmp_path / "input.txt"
destino = tmp_path / "output.txt"
origen.write_text("hola")
destino.write_text("ya existe")
with pytest.raises(FileExistsError):
convertir(origen, destino, sobrescribir=False)
5. Versionado semántico
MAJOR.MINOR.PATCH
0.1.0 → Primera versión alfa
0.2.0 → Nueva funcionalidad (compatible)
0.2.1 → Corrección de bug
1.0.0 → Primera versión estable (API pública definida)
1.1.0 → Nueva funcionalidad estable
2.0.0 → Cambios incompatibles (breaking changes)
Actualizar la versión en pyproject.toml y src/mi_paquete/__init__.py.
6. Construir y publicar en PyPI
# Instalar herramientas
pip install build twine
# Construir distribuciones
python -m build
# Genera: dist/mi_paquete-0.1.0-py3-none-any.whl
# dist/mi_paquete-0.1.0.tar.gz
# Probar en TestPyPI primero
twine upload --repository testpypi dist/*
# Instalar desde TestPyPI
pip install --index-url https://test.pypi.org/simple/ mi-paquete
# Publicar en PyPI real
twine upload dist/*
# (requiere cuenta en pypi.org y token de API)
# Instalar después de publicar
pip install mi-paquete
7. Configurar token de PyPI
# Crear ~/.pypirc
[distutils]
index-servers = pypi testpypi
[pypi]
username = __token__
password = pypi-AgENdGVzdC...
[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-AgENdGVzdC...
O usando variable de entorno:
TWINE_USERNAME=__token__ TWINE_PASSWORD=pypi-tu-token twine upload dist/*
8. GitHub Actions: publicar automáticamente
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install build twine
- run: python -m build
- run: twine upload dist/*
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
9. Buenas prácticas
- Usa
src/layout: evita importaciones accidentales del directorio raíz durante los tests. - Semver estricto: romper la API pública incrementa MAJOR; nuevas funciones, MINOR; bugs, PATCH.
- Documenta con docstrings en formato Google, NumPy o reStructuredText.
py.typed: archivo vacío en el paquete que indica que tiene anotaciones de tipo.- No subas claves al paquete: usa
.gitignorey.pypircfuera del repositorio. - Prueba en TestPyPI antes de publicar en PyPI real.
- Un único
__version__: en__init__.pyo usandoimportlib.metadata.
# Leer la versión desde los metadatos del paquete instalado
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version("mi-paquete")
except PackageNotFoundError:
__version__ = "0.0.0" # En desarrollo
Conversiones relacionadas
Conversiones frecuentes del catálogo: