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
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.tomlde verdad. Nuestro paquete interno de formato de bundleccposttodavía era unsetup.pysuelto sin build backend declarado.uv pip install -e .lo rechazó de plano hasta que le agregamos un bloque mínimo[build-system]conhatchling. - La autenticación de índices privados no se toma de
pip.conf. uv leeUV_INDEX_URL/UV_EXTRA_INDEX_URLy, para las credenciales, o bien la propia URL (https://user:token@host/simple) okeyringsi fijásUV_KEYRING_PROVIDER=subprocess. El viejo~/.pip/pip.confcon 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:
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.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 uv.lockno esrequirements.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) necesitauv export --no-hashes -o requirements.txtcorrido a mano; no pasa automáticamente conuv 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.

