Para crear y publicar un paquete Python moderno en PyPI, organiza el código con un layout src, define el proyecto y el sistema de construcción en pyproject.toml, genera una wheel y una sdist mediante python -m build, compruébalas con twine check y ensaya primero en TestPyPI. En producción, conecta PyPI con GitHub Actions mediante Trusted Publishing para no guardar tokens permanentes.

Esta guía lleva un paquete pequeño, saludo-claro, desde un directorio vacío hasta un release repetible. Si necesitas refrescar conceptos, consulta qué son los módulos y paquetes de Python, trabaja en un entorno virtual y revisa la gestión de dependencias con pip.

Paquete de importación y distribución no son lo mismo

En packaging, la palabra “paquete” se usa para objetos relacionados, pero distintos. Un paquete de importación es el directorio que Python encuentra al ejecutar import saludo_claro. Una distribución es el archivo instalable alojado en un índice: por ejemplo, saludo_claro-0.1.0-py3-none-any.whl o un archivo fuente .tar.gz.

El nombre de distribución puede llevar guion, mientras que el import debe ser un identificador Python válido. El usuario ejecuta pip install saludo-claro y luego escribe from saludo_claro import saludar. Tampoco conviene confundir PyPI con pip: PyPI aloja e indexa distribuciones; pip las resuelve e instala.

Estructura mantenible con layout src

saludo-claro/
├── .github/
│   └── workflows/
│       └── publish.yml
├── src/
│   └── saludo_claro/
│       ├── __init__.py
│       └── core.py
├── tests/
│   └── test_core.py
├── LICENSE
├── README.md
└── pyproject.toml

El layout src evita que las pruebas importen por accidente el código directamente desde la raíz. Para probar hay que instalar el proyecto, lo que revela fallos de configuración y se aproxima mejor a la experiencia real del usuario. Además, separa el código importable de documentación, automatización y otros archivos.

__init__.py declara un paquete regular y puede presentar su API pública. Deja tests, README y workflows fuera de src. Cuando el proyecto crezca, los subpaquetes vivirán bajo src/saludo_claro/.

Un pyproject.toml actual con setuptools

pyproject.toml reúne cómo construir la distribución y qué metadatos incluir. La especificación oficial de pyproject.toml diferencia las tablas estándar de la configuración propia de cada herramienta.

[build-system]
requires = ["setuptools>=77.0.3"]
build-backend = "setuptools.build_meta"

[project]
name = "saludo-claro"
version = "0.1.0"
description = "Saludos pequeños y predecibles para ejemplos en Python"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [
  { name = "Tu Nombre", email = "[email protected]" },
]
keywords = ["saludo", "ejemplo", "tutorial"]
classifiers = [
  "Programming Language :: Python :: 3",
  "Operating System :: OS Independent",
]
dependencies = []

[project.optional-dependencies]
test = ["pytest>=8"]

[project.urls]
Homepage = "https://github.com/tu-usuario/saludo-claro"
Issues = "https://github.com/tu-usuario/saludo-claro/issues"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[build-system] indica al frontend que prepare setuptools en un entorno de construcción aislado. La tabla [project] emplea metadatos que comprenden los backends compatibles. Sustituye identidad, descripción y URLs, y busca primero un nombre de distribución libre en PyPI. requires-python debe representar las versiones que pruebas de verdad, no solo la instalada en tu equipo.

Las bibliotecas necesarias al ejecutar el paquete van en dependencies. Las herramientas exclusivas de desarrollo no deberían instalarse a todos los consumidores; por eso pytest está en el extra test. Para documentar mejor los contratos públicos, profundiza en type hints de Python.

README y licencia también son parte del producto

PyPI muestra el README.md declarado en la página del proyecto. Explica en pocas líneas qué resuelve la biblioteca, cómo se instala y cuál es el uso mínimo. Usa URLs absolutas para imágenes: una ruta relativa válida en GitHub puede romperse en PyPI.

## saludo-claro

Una biblioteca pequeña para producir saludos consistentes.

## Instalación

`python -m pip install saludo-claro`

## Uso

```python
from saludo_claro import saludar

print(saludar("Ada"))
```

Elige conscientemente una licencia e incluye su texto completo en LICENSE. El ejemplo declara la expresión SPDX MIT; cámbiala si no refleja tu decisión. Que el código sea visible no otorga por sí solo permiso para copiarlo, modificarlo y redistribuirlo.

Código mínimo y pruebas sobre la API pública

Crea src/saludo_claro/core.py:

def saludar(nombre: str) -> str:
    """Devuelve un saludo para un nombre no vacío."""
    nombre_limpio = nombre.strip()
    if not nombre_limpio:
        raise ValueError("el nombre no puede estar vacío")
    return f"¡Hola, {nombre_limpio}!"

Expón el contrato previsto desde src/saludo_claro/__init__.py:

from .core import saludar

__all__ = ["saludar"]

Añade tests/test_core.py:

import pytest

from saludo_claro import saludar


def test_saludar_elimina_espacios() -> None:
    assert saludar("  Ada  ") == "¡Hola, Ada!"


def test_saludar_rechaza_nombre_vacio() -> None:
    with pytest.raises(ValueError, match="no puede estar vacío"):
        saludar("  ")

Dentro del entorno virtual activado, instala el proyecto en modo editable con el extra de pruebas:

python -m pip install --upgrade pip
python -m pip install -e ".[test]"
python -m pytest

La instalación editable refleja cambios en el código sin reinstalar manualmente, pero mantiene el proyecto instalado y su importación realista. La guía de pruebas automatizadas con pytest desarrolla fixtures, organización y cobertura.

Construir wheel y sdist y validarlas

python -m pip install --upgrade build twine
python -m build
python -m twine check dist/*

La construcción aislada produce una wheel Python pura, como saludo_claro-0.1.0-py3-none-any.whl, y una sdist terminada en .tar.gz. La wheel ya está construida y suele instalarse rápido. La sdist contiene fuentes y se construye en el entorno de destino. Publica ambas para servir la wheel cuando sea posible sin perder la alternativa fuente.

twine check detecta metadatos defectuosos y problemas de renderizado del README, pero no demuestra que el import funcione. Instala la wheel por su ruta en otro entorno y realiza un smoke test. Elimina artefactos antiguos de dist antes de construir cada release. El proceso coincide con el tutorial oficial de packaging.

Ensayar la subida en TestPyPI

TestPyPI es un servicio independiente, con cuentas, proyectos y credenciales propios. Crea allí un token de API para el ensayo manual y sube los artefactos:

python -m twine upload --repository testpypi dist/*

Cuando Twine pregunte, usa __token__ como usuario y el token completo como contraseña. Nunca lo confirmes en Git ni lo escribas directamente en un comando que quede registrado. Verifica desde un entorno nuevo:

python -m pip install --index-url https://test.pypi.org/simple/ --no-deps saludo-claro
python -c "from saludo_claro import saludar; print(saludar('Ada'))"

--no-deps es correcto porque este ejemplo no tiene dependencias de ejecución. En proyectos reales, TestPyPI quizá no contenga todas. Instálalas por separado en vez de combinar índices sin cuidado, práctica que puede abrir la puerta a dependency confusion.

Publicar desde GitHub Actions sin tokens

Trusted Publishing intercambia una identidad OIDC de GitHub por una credencial breve de PyPI. Configura en PyPI un publisher con propietario, repositorio, nombre exacto del workflow publish.yml y environment pypi. Puede asociarse a un proyecto existente o prepararse como publisher pendiente para el primero. La documentación de Trusted Publishers de PyPI explica la configuración vigente.

Crea .github/workflows/publish.yml:

name: Publish package

on:
  release:
    types: [published]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.13"
      - run: python -m pip install --upgrade build
      - run: python -m build
      - uses: actions/upload-artifact@v4
        with:
          name: python-package-distributions
          path: dist/

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/p/saludo-claro
    permissions:
      id-token: write
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: python-package-distributions
          path: dist/
      - uses: pypa/gh-action-pypi-publish@release/v1

El permiso limitado id-token: write permite solicitar la identidad OIDC; no concede escritura general en el repositorio. Si necesitas control humano, añade revisores al environment pypi. La CI habitual debería probar pushes y pull requests, mientras este workflow publica únicamente al crear un GitHub Release. Consulta la guía de CI/CD de Python con GitHub Actions para separar ambos procesos.

Versionado y rutina de release

PyPI no permite sobrescribir los archivos de una versión existente. Una convención práctica es MAJOR.MINOR.PATCH: PATCH para correcciones compatibles, MINOR para nuevas funciones compatibles y MAJOR para cambios incompatibles. Una versión candidata puede ser 0.2.0rc1. Sea cual sea tu política, la versión de pyproject.toml, la etiqueta Git y las notas deben contar la misma historia.

  1. Actualiza código, documentación, changelog y versión.
  2. Ejecuta tests y el comprobador de tipos, si el proyecto lo usa.
  3. Construye desde cero y ejecuta twine check.
  4. Instala la wheel en un entorno limpio y comprueba el import.
  5. Crea la etiqueta y publica un GitHub Release para activar el workflow.

Errores frecuentes al empaquetar y publicar

  • Nombre ocupado: el nombre de distribución debe ser único en el índice, aunque el import tenga otro nombre.
  • Paquete ausente en la wheel: revisa el directorio bajo src, __init__.py y el descubrimiento de setuptools.
  • Descripción rota: ejecuta twine check y elimina markup no admitido o recursos locales.
  • Archivos inesperados: inspecciona los dos artefactos; .gitignore no define el contenido distribuido.
  • Subida rechazada: incrementa la versión y vuelve a construir; no intentes sustituir archivos publicados.
  • Workflow no confiable: propietario, repositorio, archivo y environment deben coincidir exactamente con el registro de PyPI.

Checklist previo al release

  • Nombre disponible y metadatos correctos.
  • Paquete bajo src instalable y API pública importable.
  • README renderizable, licencia incluida y URLs válidas.
  • Versiones de Python declaradas cubiertas por CI.
  • Pruebas verdes en un entorno limpio.
  • Wheel y sdist nuevas, inspeccionadas y aprobadas por twine check.
  • Instalación e import verificados mediante TestPyPI.
  • Trusted Publisher y environment coincidentes, sin token persistente.
  • Versión, etiqueta, changelog y notas de release coherentes.

Un buen packaging es más que conseguir que una subida termine sin errores. Proporciona instalación predecible, metadatos útiles, permisos claros y un proceso repetible sin improvisar en cada release. Esta base sirve tanto para una biblioteca didáctica pequeña como para un proyecto futuro con múltiples módulos y colaboradores.