Wheels, sdists y extensiones C en Python

Cuando distribuyes un paquete puro de Python, el problema de empaquetado es casi trivial. Cuando ese paquete incluye código C —o cuando quieres que la instalación sea instantánea para tus usuarios— entras en un territorio donde los detalles importan mucho y los errores cuestan caro.

Dos formatos, dos contratos distintos

Un sdist (source distribution) es exactamente lo que parece: un tarball .tar.gz con tu código fuente y un pyproject.toml. Cuando pip instala desde un sdist, ejecuta el proceso de build completo en la máquina del usuario. Para paquetes puros eso es casi instantáneo, pero para paquetes con extensiones C significa que el usuario necesita un compilador, cabeceras de sistema y paciencia. El sdist es el fallback universal: siempre debes publicarlo porque es lo que pip usa cuando no encuentra un wheel compatible.

Un wheel (.whl) es un archivo ZIP renombrado con una convención de nombre que codifica su compatibilidad: {nombre}-{versión}-{python}-{abi}-{plataforma}.whl. Pip lo descomprime directamente en site-packages — sin compilación, sin build backend, sin sorpresas. La instalación es literalmente una operación de copia.

La diferencia entre un pure-python wheel (py3-none-any.whl) y un platform wheel (cp312-cp312-linux_x86_64.whl) es fundamental: el primero contiene solo .py y funciona en cualquier intérprete y sistema operativo; el segundo contiene objetos compilados (.so en Linux/macOS, .pyd en Windows) y solo funciona en la combinación exacta de Python + ABI + arquitectura codificada en su nombre.

El problema real con los wheels binarios para Linux es la fragmentación de librerías del sistema. Un .so compilado en Ubuntu 24.04 enlaza dinámicamente contra una glibc moderna que simplemente no existe en CentOS 7 o en imágenes mínimas de Docker. manylinux resuelve esto: es un estándar (PEP 600) que define un conjunto de símbolos de glibc lo suficientemente antiguo para que el wheel funcione en prácticamente cualquier Linux moderno. Compilas dentro de un contenedor Docker oficial (quay.io/pypa/manylinux_2_28_x86_64) y la herramienta auditwheel repair verifica que tu .so no tenga dependencias externas problemáticas y las empaqueta dentro del wheel si las hay.

Construir wheels para las combinaciones de Python × sistema operativo × arquitectura manualmente es inviable. cibuildwheel automatiza exactamente eso: corre en GitHub Actions, GitLab CI o cualquier CI y produce wheels para Linux (manylinux + musllinux), macOS (x86_64 + arm64) y Windows, para todas las versiones de CPython y PyPy que configures.

Extensiones C: tres caminos con trade-offs distintos

ctypes carga una biblioteca compartida existente en runtime y la llama directamente — sin compilación en tu paquete, máxima portabilidad, pero la interfaz es manual y propensa a errores de tipos. cffi (C Foreign Function Interface) mejora esto con una API más expresiva que puede parsear cabeceras C directamente; es el enfoque que usa PyPy y es más mantenible para interfaces grandes. Cython toma un camino diferente: compila código .pyx (Python con anotaciones de tipos opcionales) a C y luego a un .so — ideal cuando quieres acelerar código Python existente sin reescribirlo en C puro.

# pyproject.toml + estructura de proyecto con extensión Cython
# 
# proyecto/
# ├── pyproject.toml
# ├── src/
# │   └── fastmath/
# │       ├── __init__.py
# │       ├── _core.pyx        ← Cython
# │       └── _core.pyi        ← stubs para type checkers
# └── .github/workflows/build.yml
# pyproject.toml
[build-system]
requires = ["setuptools>=68", "Cython>=3.0"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "fastmath"
version = "0.1.0"
requires-python = ">=3.10"

[tool.cibuildwheel]
build = "cp310-* cp311-* cp312-*"
# En Linux, cibuildwheel usa automáticamente los contenedores manylinux
manylinux-x86_64-image = "manylinux_2_28"
manylinux-aarch64-image = "manylinux_2_28"

[tool.cibuildwheel.linux]
# auditwheel repair empaqueta dependencias .so externas dentro del wheel
repair-wheel-command = "auditwheel repair -w {dest_dir} {wheel}"

[tool.cibuildwheel.macos]
# Para macOS universal2 que cubre Intel y Apple Silicon en un solo wheel
archs = ["x86_64", "arm64"]
# src/fastmath/_core.pyx
# distutils: language = c
# cython: boundscheck=False, wraparound=False  ← desactiva checks en producción

import numpy as np
cimport numpy as cnp

# La declaración 'cpdef' genera tanto una versión C (rápida para llamadas internas)
# como una versión Python (accesible desde __init__.py)
cpdef double sum_squares(cnp.ndarray[cnp.float64_t, ndim=1] arr):
    """Suma de cuadrados: O(n) sin overhead de Python por elemento."""
    cdef Py_ssize_t i, n = arr.shape[0]
    cdef double total = 0.0
    for i in range(n):
        # Este loop se compila a C puro — sin GIL, sin boxing de floats
        total += arr[i] * arr[i]
    return total
# setup.py — necesario para que setuptools compile el .pyx
from setuptools import setup
from Cython.Build import cythonize
import numpy as np

setup(
    ext_modules=cythonize(
        "src/fastmath/_core.pyx",
        compiler_directives={"language_level": "3"},
    ),
    include_dirs=[np.get_include()],  # cabeceras de NumPy para cimport
)
# src/fastmath/__init__.py
from fastmath._core import sum_squares

__all__ = ["sum_squares"]
# .github/workflows/build.yml
name: Build wheels

on:
  push:
    tags:
      - "v*"

jobs:
  build_wheels:
    name: Build on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]

    steps:
      - uses: actions/checkout@v4

      - name: Build wheels
        uses: pypa/cibuildwheel@v2.19.2

      # Los wheels terminan en ./wheelhouse/
      - uses: actions/upload-artifact@v4
        with:
          name: wheels-${{ matrix.os }}
          path: ./wheelhouse/*.whl

  build_sdist:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pip install build
      # El sdist siempre se publica junto con los wheels
      - run: python -m build --sdist
      - uses: actions/upload-artifact@v4
        with:
          name: sdist
          path: dist/*.tar.gz

Lo que está pasando bajo el capó

El nombre del wheel que produce cibuildwheel para Linux con CPython 3.12 en x86_64 será algo como fastmath-0.1.0-cp312-cp312-manylinux_2_28_x86_64.whl. Cada segmento es una restricción de compatibilidad que pip evalúa antes de descargar. Si pip no encuentra ningún wheel compatible en PyPI, cae al sdist y ejecuta el build — por eso el sdist no es opcional.

La directiva boundscheck=False en el .pyx es intencional y tiene consecuencias: Cython no verificará que los índices de array estén dentro de rango. Si los datos de entrada son inválidos, en lugar de un IndexError limpio obtendrás corrupción de memoria o un segfault. Esto es el mismo trade-off que en C: a cambio de la velocidad máxima, la responsabilidad de validar los datos es tuya antes de llamar a la función.

La razón por la que setup.py sigue siendo necesario aquí, a pesar de que el ecosistema lleva años moviéndose hacia pyproject.toml puro, es que Cython necesita ejecutar código Python durante el build para generar los archivos .c desde los .pyx. El soporte de PEP 517 para este patrón en setuptools es funcional pero hay edge cases con proyectos complejos donde setup.py sigue siendo la opción más predecible.

La elección entre Cython, cffi y ctypes depende del origen del código: si estás acelerando código Python existente, Cython. Si estás haciendo bindings a una biblioteca C de terceros que ya existe como .so o .dll, cffi o ctypes — y en ese caso cffi es preferible porque el manejo de memoria es más explícito y el parseo de cabeceras reduce el mantenimiento manual.

Errores que debes conocer

Error: publicar solo wheels sin sdist hace que pip falle completamente en plataformas para las que no compilaste, como arquitecturas emergentes (riscv64, loongarch64) o intérpretes alternativos.

# ❌ Solo subir wheels a PyPI
twine upload wheelhouse/*.whl

# ✅ Incluir siempre el sdist
twine upload wheelhouse/*.whl dist/*.tar.gz

El sdist permite que pip compile en cualquier entorno donde tengas compilador, incluso si no anticipaste esa plataforma.


Error: usar auditwheel dentro del sistema host en lugar del contenedor manylinux, produciendo wheels que PyPI rechaza o que fallan en runtime en otras distribuciones.

# ❌ Compilar en Ubuntu 24.04 del host y llamar auditwheel directamente
python setup.py bdist_wheel
auditwheel repair dist/fastmath-*.whl

# ✅ Dejar que cibuildwheel gestione el contenedor correcto
cibuildwheel --platform linux

Dentro del contenedor manylinux, auditwheel comprueba los símbolos de glibc requeridos contra el baseline del estándar; fuera de él, simplemente no tiene esa referencia.


Error: omitir include_dirs=[np.get_include()] en setup.py cuando el .pyx usa cimport numpy — el build parece funcionar en la máquina del desarrollador porque NumPy está instalado en una ruta que el compilador encuentra por casualidad, pero falla en el entorno de CI limpio.

# ❌ Sin cabeceras de NumPy explícitas
setup(ext_modules=cythonize("src/fastmath/_core.pyx"))

# ✅ Rutas explícitas, siempre reproducibles
import numpy as np
setup(
    ext_modules=cythonize("src/fastmath/_core.pyx"),
    include_dirs=[np.get_include()],
)

np.get_include() devuelve la ruta exacta donde NumPy instaló sus cabeceras en el entorno activo, haciendo el build reproducible independientemente de dónde esté instalado NumPy.

177

Dejar un comentario

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

Scroll al inicio