Migrar codeceibe de pyenv + pip + venv a uv



codeceibe corrió durante mucho tiempo con el stack de siempre: pyenv para fijar 3.13.x, un venv por cada clon del repo, y un requirements.txt compilado a mano cada vez que cambiaba una dependencia. Funcionaba, pero cada clon nuevo costaba diez minutos de "activá el venv, acordate de qué shim de pyenv está activo, reinstalá porque alguien se olvidó de fijar una dependencia transitiva." Migramos todo a uv en una sola tarde.

Fijar el intérprete

uv python pin 3.13
cat .python-version
# 3.13

uv python pin escribe un archivo .python-version y, si esa build exacta de 3.13 no está ya en la máquina, uv descarga un intérprete standalone precompilado -- sin pyenv install, sin esperar una build desde fuente la primera vez que alguien clona el repo.

De requirements.txt a un lockfile

uv init --app --no-readme
uv add $(cat requirements.txt | grep -v '^#' | cut -d= -f1)
uv add --dev pytest pytest-django ruff
uv lock

Esto genera un pyproject.toml con restricciones de dependencias reales y un uv.lock que fija cada paquete transitivo, hash incluido. A partir de ahí, uv sync reproduce exactamente el mismo entorno -- se acabó el "en mi máquina anda" de alguien cuyo pip install resolvió por casualidad una versión menor distinta de django-filter.

Cuánto más rápido es en la práctica

Grafico de barras comparando tiempo de instalacion: pip+venv 41.8s, pip-tools 33.2s, uv pip install 4.1s, uv sync con cache en caliente 0.6s

Es el mismo requirements.txt, la misma máquina, con la caché de build de Docker borrada entre corridas para medir el trabajo real de resolver y descargar. uv sync contra una caché en caliente y un lockfile existente es casi instantáneo porque uv crea hardlinks de los paquetes desde su caché global en vez de volver a descargarlos y descomprimirlos por cada proyecto.

Los problemas que nos encontramos

  • Los installs editables necesitan un pyproject.toml de verdad. Nuestro paquete interno de formato de bundle ccpost todavía era un setup.py suelto sin build backend declarado. uv pip install -e . lo rechazó de plano hasta que le agregamos un bloque mínimo [build-system] con hatchling.
  • La autenticación de índices privados no se toma de pip.conf. uv lee UV_INDEX_URL / UV_EXTRA_INDEX_URL y, para las credenciales, o bien la propia URL (https://user:token@host/simple) o keyring si fijás UV_KEYRING_PROVIDER=subprocess. El viejo ~/.pip/pip.conf con una entrada de netrc se ignora en silencio, lo que nos costó una corrida de CI confusa antes de mover el token a un secreto del repo y una variable de entorno.
  • La imagen de Docker necesita el binario de uv, no un pip install de él. Lo copiamos directo:
    COPY --from=ghcr.io/astral-sh/uv:0.9 /uv /uvx /usr/local/bin/
    RUN --mount=type=cache,target=/root/.cache/uv     uv sync --frozen --no-dev
    Ese cache mount es la mayor parte de la ganancia en CI -- la capa de encima cambia en cada build, pero la caché de uv persiste entre builds en el mismo runner.
  • uv.lock no es requirements.txt. Todo lo que queda fuera del mundo uv/Docker y todavía espera un requirements.txt plano (un par de scripts de deploy viejos, básicamente) necesita uv export --no-hashes -o requirements.txt corrido a mano; no pasa automáticamente con uv lock.

¿Valió la pena?

Sí, sobre todo por el tiempo de CI y el onboarding, no tanto por la velocidad en una máquina de desarrollo ya tibia -- ahí la diferencia entre pip install y uv sync es de pocos segundos. Donde se pagó solo fue en los builds de Docker (de unos 90 segundos instalando dependencias a menos de 10 con un cache mount en caliente) y en el setup de gente nueva en el equipo, que pasó de "instalá pyenv, instalá la versión correcta de Python, creá un venv, esperá que requirements.txt resuelva" a simplemente uv sync.

Dejar un comentario

Los comentarios anónimos se publican tras ser revisados.