uv sin miedo: diseñando la nueva capa de plataforma de Python
Publicado el 19 de mayo de 2026 • 23 minutos • 4725 palabras
Table of contents
- El problema no es la velocidad
- La toolchain es una capa de plataforma
- Las ventajas reales (las que de verdad importan)
- Antes de los ejemplos: instala uv
- Ejemplo 1 - Un proyecto desde cero, en cuatro comandos
- Ejemplo 2 - Migrar un proyecto legacy por fases (sin big-bang)
- Ejemplo 3 - Gobernanza con Ruff y pre-commit
- Ejemplo 4 - CI reproducible en 40 líneas
- Ejemplo 5 - Dockerfile multi-stage de verdad
- Ejemplo 6 - PEP 723: el Gist de Slack como artefacto reproducible
- Vale, pero ¿y si Astral desaparece mañana?
- La lista del lunes
- Cierre
- Glosario rápido
- Fuentes y referencias
Llevamos veinte años discutiendo lo mismo en Python. Que si virtualenv o venv. Que si pip o pip-tools. Que si Poetry está deprecado o solo está de mal humor. Que si pyenv es para gente seria o solo para gente con tiempo.
Si abres tu repo de hace tres proyectos verás un Frankenstein con un poco de todo: un Makefile que activa un venv, un requirements.txt generado a mano, un requirements-dev.txt que nadie mantiene desde 2022 y un Dockerfile que hace pip install fijando versiones a ojo.
Y, aun así, funciona. Más o menos. En tu máquina, los martes, después del segundo café.
Eso no es una toolchain . Eso es ingeniería de la esperanza.
Este artículo va de cómo salir del pantano de la desesperación.
Concretamente, va de uv , el gestor de Astral que lleva un año comiéndose el ecosistema y al que mucha gente sigue viendo como “pip pero rápido”.
Y no, no es eso. Es algo bastante más interesante: es una propuesta de capa de plataforma para tus proyectos Python.
Aviso para navegantes: aquí no vas a encontrar benchmarks, ni una guerra de religiones contra Poetry, ni una lista plana de comandos sin contexto.
Vas a encontrar por qué una toolchain unificada cambia cómo razonas sobre tus proyectos, qué ventajas concretas te da uv y seis ejemplos prácticos con código que funciona.
Si al final del artículo no puedes migrar un proyecto pequeño a uv, probablemente te he fallado.
El problema no es la velocidad
Cuando alguien te enseña uv por primera vez, lo normal es que te suelte el topicazo: “instala numpy en 0,3 segundos”.
Y sí, es bonito. Pero ese no es el motivo serio para cambiarte. La velocidad es la consecuencia, no la causa.
El problema real lo tienes en la lista de más abajo. Levanta la mano si en uno solo de tus repos conviven hoy:
pipopip-toolspara resolver,virtualenvovenvpara aislar,pyenv(oasdf, o nada) para versionar Python,poetryopdmopipenvohatchpara “lo demás”,- un
Makefileo untox.inipara pegarlo todo con cinta aislante, - un
Dockerfileque duplica la mitad del trabajo anterior porque “en producción no nos fiamos”.
Si tienes dos manos levantadas, enhorabuena: estás dirigiendo un zoológico. Cada animal con su carácter, su jaula, su dieta y su veterinario. Y el veterinario eres tú, los viernes a las seis.
Esto no es culpa de nadie. Es el resultado natural de un ecosistema que ha ido añadiendo capas durante veinte años sin tirar ninguna. Cada herramienta nació para resolver un problema real.
El drama no son las herramientas: es que la unión de todas no es una herramienta. Es un acuerdo tácito entre gente que ya no trabaja contigo.
A esa unión es a lo que le llamamos, casi sin querer, “la plataforma del proyecto”.
Y como no la diseña nadie, la diseña el azar.
La toolchain es una capa de plataforma
Quédate con esta idea, porque es lo único que de verdad importa del artículo:
Tu toolchain de Python no es un detalle de infraestructura. Es una capa de plataforma. Y tiene cuatro responsabilidades: proyecto, lockfile, entorno y ejecución.
El Proyecto define qué es tu código: cómo se llama, qué versión tiene, qué expone como ejecutable, qué dependencias declara y bajo qué versión de Python se ejecuta. Todo eso vive en pyproject.toml, estandarizado por los PEP 517, 518 y 621. Ya no es opinable.
El Lockfile captura exactamente qué versiones se resolvieron, con sus hashes, para que mañana otra persona obtenga bit a bit el mismo resultado. Es lo que package-lock.json, Cargo.lock o Gemfile.lock llevan haciendo en otros ecosistemas desde hace una década. En Python lo llevamos esperando bastante.
El Entorno crea, mantiene y destruye entornos virtuales aislados sin que tengas que pensar en ello. Sin pelear con source venv/bin/activate cada vez que abres una terminal nueva.
La Ejecución lanza tu código, tus tests, tus herramientas (pytest, ruff, mypy) o un script suelto, sin que tengas que pre-instalar nada en global ni rezar para que el PATH esté bien.
Ahora la pregunta: ¿Cuántas herramientas necesitas hoy para cubrir esas cuatro responsabilidades?
En la mayoría de los repos legacy (viejunos): cuatro o cinco.
Cada una con su fichero de configuración, su criterio sobre qué es “el venv actual” y su versión específica que tienes que fijar en el CI a mano.
En un proyecto con uv: una. Un solo binario. Un solo modelo mental.
Eso no es una mejora incremental de productividad. Es un cambio de arquitectura. Y como cualquier cambio de arquitectura, lo que te proporciona no es ir más rápido haciendo lo mismo, sino dejar de hacer cosas que no aportan.
Las ventajas reales (las que de verdad importan)
Antes de meternos en código, déjame enumerarte las ventajas que vas a notar al cabo de dos semanas.
No las del primer día. Las que aparecen cuando llevas un tiempo y de repente te das cuenta de que ya no piensas en cosas que antes te ocupaban un tercio del cerebro.
1. Una sola fuente de verdad por proyecto
pyproject.toml es lo que quieres.
uv.lock es lo que has conseguido.
Punto.
No hay un requirements.txt que alguien generó hace ocho meses con otras versiones. No hay un setup.py con install_requires desincronizado del requirements.txt. No hay un Pipfile durmiendo en el repo “por si acaso”.
Cuando solo hay dos archivos y los dos son obligatorios y los dos se versionan, la pregunta "¿de dónde viene esta versión?" tiene respuesta. Y es una respuesta corta.
2. Reproducibilidad real, con hashes
uv.lock no se queda en “django 5.0.4”. Guarda el hash SHA256 del wheel que se descargó.
Si mañana alguien sustituye un paquete en PyPI (o si una mirror corporativa la cachea mal), tu build falla antes de instalar nada raro.
Esto no es paranoia: es la base de cualquier supply chain seria, y lleva años siendo estándar en npm, Cargo y RubyGems.
3. Mismo modelo en local, CI y producción
Esto se merece su propio apartado, pero te lo anticipo: el desarrollador, el runner de GitHub Actions y el contenedor de producción usan el mismo lockfile.
Lo único que cambia entre ellos son los flags (--no-dev, --frozen, --no-editable).
Tres vistas filtradas del mismo modelo. No tres universos paralelos.
4. Gestión de versiones de Python sin pyenv
uv baja la versión de Python que pidas (requires-python en pyproject.toml) si no la tienes.
Sin compilar nada, sin tocar tu shell, sin alias raros.
Si tu proyecto pide 3.12.3, eso es lo que ejecutas. Punto.
5. Herramientas efímeras con uvx
Antes, instalar httpie o cookiecutter era una decisión: ¿lo meto en global? ¿hago un venv solo para esto? ¿uso pipx?
Con uv: uvx httpie GET https://example.com.
Se descarga, se cachea, se ejecuta. La próxima vez es instantáneo.
Sin dejar rastro en tu sistema.
6. Scripts de un solo fichero que son artefactos reproducibles
Esto, que parece un truco menor, es de las cosas más bonitas del ecosistema reciente.
Hablamos de PEP 723 : metadatos inline en un script.
Un .py con un bloque arriba que declara sus dependencias y la versión de Python. uv run script.py lo ejecuta.
Sin README. Sin requirements. Sin rezar.
Tienes un ejemplo más adelante.
7. Tan simple que no hace falta apuntarlo
Esta es la característica más subestimada. Cuando tu toolchain es una sola herramienta con una sola CLI, los nuevos del equipo se ponen al día en una tarde.
No tienes que escribir tres páginas de CONTRIBUTING.md explicando “primero activas el pyenv, luego entras en el venv, luego haces poetry install pero ojo si has cambiado pyproject porque entonces…”.
Tienes que escribir, literalmente: uv sync. Ya está.
Lo que ganas ahí no es velocidad. Es espacio mental. Y eso lo notas a la tercera persona que llega nueva a tu proyecto.
Antes de los ejemplos: instala uv
Si todavía no lo tienes:
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
# o si vives dentro de Homebrew
brew install uv
# o si vives dentro de Scoop
scoop install uv
Comprueba que está vivo:
uv --version
Los ejemplos que siguen asumen que estás en un bash/zsh decente.
Si estás en PowerShell o cmd, los comandos uv ... son idénticos; lo único que cambia es alguna concatenación de rutas. Desventajas de usar Windows …
Ejemplo 1 - Un proyecto desde cero, en cuatro comandos
Código en
ejemplo-01-proyecto-nuevo/.
Este es el caso de “empiezo algo hoy”.
Un CLI que genera contraseñas criptográficamente seguras desde la línea de comandos. Porque password123 no es una estrategia de seguridad, es una declaración de intenciones.
uv init --package genpass
cd genpass
uv add rich
uv add --dev pytest ruff
La estructura que te genera es esta:
genpass/
+-- .python-version # versión de Python fija para el repo
+-- pyproject.toml # contrato del proyecto
+-- README.md
+-- src/
| \-- genpass/
| +-- __init__.py
| \-- py.typed
+-- tests/
\-- uv.lock # qué se resolvió de verdad
Mira ese pyproject.toml. No lo has escrito tú y está perfecto:
[project]
name = "genpass"
version = "0.1.0"
description = "Generador de contraseñas seguras por línea de comandos."
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"rich>=13.7",
]
[project.scripts]
genpass = "genpass.cli:main"
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Aquí hay tres cosas que no has tenido que decidir y uv se ha encargado de pensar por ti:
- Layout
src/: aísla el código del entorno; no puedes hacerimportdesde la raíz por accidente. Es la forma adulta de empaquetar Python desde hace una década. - Grupos de dependencias
dev: estandarizado por PEP 735 . En producción no se instalará. En CI sí. - Backend de build hatchling: simple, mantenido, sin cosas raras. Si mañana quieres otro, cambias dos líneas.
Ahora, lanzar las cosas:
uv run pytest # ejecuta los tests del proyecto
uv run genpass # ejecuta el CLI declarado en [project.scripts]
uvx ruff check . # lanza ruff sin instalarlo en el proyecto
¿Has visto que en ningún momento has hecho source venv/bin/activate? Es porque uv lo hace por ti, en cada uv run, sin tocarte el shell.
Si vienes de la tradición del activate, vas a echarlo de menos durante 48 horas. Luego se te pasa.
Ejemplo 2 - Migrar un proyecto legacy por fases (sin big-bang)
Código en
ejemplo-02-migracion-legacy/.
Aquí está la pregunta del millón: “vale, suena bien para greenfield, pero yo tengo un repo de 2019 con setup.py y un Makefile que recuerda más cosas que yo”.
Bien. Vamos a migrarlo. Por fases. Sin tocar el código de producto.
Partimos de algo así (nuestro amigo Pakito, que ya no está en la empresa, escribió esto):
# setup.py
from setuptools import setup, find_packages
setup(
name="ventas-cli",
version="0.3.1", # escrito a mano. la release miente.
description="Procesa CSVs de ventas y saca un resumen.",
author="Pakito",
author_email="Pakito@antigua-empresa.com", # Pakito ya no trabaja aquí.
packages=find_packages(exclude=["tests"]),
install_requires=[
"click", # sin fijar. puro azar.
"requests>=2.28", # rango amplio. casi peor que nada.
"pandas==1.5.3", # exacto. funciona. no tocar. no respirar.
"python-dateutil",
],
entry_points={
"console_scripts": [
"ventas-cli = ventas.cli:main",
],
},
python_requires=">=3.8", # en producción se ejecuta en 3.11.
)
Y un requirements.txt con cosas como requests==2.28.0 que no coinciden con el setup.py, claro.
La migración limpia tiene cinco fases, y cada una es una PR independiente. Ninguna toca tu código de producto.
Fase 1 - Inventario y fijación de la versión de Python
Antes de tocar nada, entiende qué tienes.
Genera el inventario congelado del entorno actual y fija la versión real de Python con la que arranca tu producción:
# Desde el venv en ejecución hoy en serio:
pip freeze > inventario.txt
python --version # ej: Python 3.11.8
echo "3.11.8" > .python-version
Esto no rompe nada. Solo deja constancia de lo que había.
Fase 2 - pyproject.toml + uv.lock, sin tocar setup.py
Crea un pyproject.toml mínimo a partir del inventario.
Ojo: aquí viene el truco que más rompe a la gente que migra. Si el nombre del paquete (ventas-cli) no coincide con el directorio (src/ventas/), tienes que decírselo a hatchling explícitamente:
[project]
name = "ventas-cli"
version = "0.4.0" # primera release post-migración.
description = "Procesa CSVs de ventas y saca un resumen."
readme = "README.md"
requires-python = ">=3.11" # alineado con producción. ya puestos.
dependencies = [
"click>=8.1",
"requests>=2.32",
"pandas==1.5.3", # sigue fija. cuando toque, tocará.
"python-dateutil>=2.9",
]
[project.scripts]
ventas-cli = "ventas.cli:main"
[dependency-groups]
dev = [
"pytest>=8.0",
"ruff>=0.5",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/ventas"] # <- esto es lo que se olvida medio internet
Ahora:
uv lock
uv sync
git add pyproject.toml uv.lock .python-version
git commit -m "chore: introduce uv toolchain (no logic changes)"
A partir de aquí, el lockfile es la verdad. El setup.py sigue existiendo de momento, pero ya no manda.
Fase 3 - Migrar el CI
Una sola PR. Cambia el job para que use uv:
# .github/workflows/ci.yml
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- run: uv sync --frozen
- run: uv run pytest
- run: uv lock --check # falla la PR si pyproject cambió y nadie relockeó
Esa última línea es de las cosas más útiles que vas a poner en tu vida.
Fase 4 - Migrar el Dockerfile
Lo verás en el ejemplo 5. Multi-stage, builder con uv, runtime sin uv.
Fase 5 - Borrar setup.py y requirements*.txt
Última PR. Una vez tu CI lleva una semana en verde con uv y tu producción también, borras los fósiles:
git rm setup.py requirements.txt requirements-dev.txt
git commit -m "chore: remove legacy packaging files"
Si el Makefile legacy tenía targets útiles (make test, make lint), puedes dejarlos como shim apuntando a uv:
test:
uv run pytest
lint:
uvx ruff check .
sync:
uv sync --frozen
Así, el equipo que tiene make test quemado en el cerebro no tiene que reaprender nada el lunes.
La migración no toca tu producto, toca quién gobierna tu entorno.
Ejemplo 3 - Gobernanza con Ruff y pre-commit
Código en
ejemplo-03-ruff-precommit/.
Ahora que tu plataforma vive en pyproject.toml, lo coherente es que la política de calidad también viva ahí. Y que sea ejecutable, no un PDF en Confluence que nadie lee.
Ruff
es un linter y formateador que sustituye a flake8, isort, black, pyupgrade y media docena más.
Está escrito en Rust y lo configuras en cuatro líneas:
# pyproject.toml
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"] # el formatter ya mide líneas, no necesitas avisar dos veces
[tool.ruff.format]
quote-style = "double"
Tienes un fichero feo de verdad:
# ejemplo_feo.py
import os, sys # imports en una sola línea: ahí es nada
import json
import json # importado dos veces
def sumar(a,b):
if lista == []: # debería ser `if not lista:`
pass
x = a+b
return(x) # `return` no es una función
Lanzas ruff sin instalarlo en el proyecto (gracias a uvx):
uvx ruff check ejemplo_feo.py
Salida:
F401 'os' imported but unused
F811 redefinition of unused 'json'
E401 multiple imports on one line
SIM201 use `if not lista:`
F821 undefined name 'lista'
Found 5 errors.
[*] 4 fixable with the --fix option.
Cuatro de cinco arregladas automáticamente:
uvx ruff check --fix ejemplo_feo.py
uvx ruff format ejemplo_feo.py
¿Y para que no se cuele nunca un fichero feo en main? Pre-commit:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.5.6
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
Resultado: dejas de discutir el formato en las PRs. Para siempre.
Las políticas son versionables (viven en el repo), ejecutables (cualquiera las ejecuta con un comando) y automáticas (saltan en el commit).
Esto es gobernanza de verdad: la que vive en el código, no la que vive en Confluence.
Ejemplo 4 - CI reproducible en 40 líneas
Código en
ejemplo-04-ci/.
Una vez tu plataforma local es seria, el CI prácticamente se escribe solo.
El de un proyecto con uv, completo, es esto:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- run: uv python install ${{ matrix.python-version }}
- run: uv sync --frozen --all-extras
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run pytest -q --cov=src
- run: uv lock --check
Las tres líneas que de verdad importan son:
astral-sh/setup-uv@v6: instala uv y cachea el resolvedor entre runs. No vuelves a ver “resolving dependencies…. 4 minutes”.uv sync --frozen: instala exactamente lo que dice el lockfile. Si el lockfile no está actualizado respecto apyproject.toml, falla aquí. Y está bien que falle aquí.uv lock --check: la red de seguridad. Si alguien editó dependencias y no relockeó, no mergeas. Fin de la conversación.
Si publicas a PyPI, añade un workflow de release con trusted publishing (OIDC, sin tokens):
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write # necesario para OIDC
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv build
- run: uv publish # autenticación vía OIDC, sin secretos en el repo
Cero secretos. Cero PYPI_API_TOKEN rotando cada año. La identidad la pone GitHub Actions vía OIDC y PyPI confía en ella.
Esto, hace tres años, era ciencia ficción. Hoy son siete líneas.
Ejemplo 5 - Dockerfile multi-stage de verdad
Código en
ejemplo-05-dockerfile/.
La trampa típica del Dockerfile con Python es meter pip dentro y rezar.
La versión adulta es multi-stage: un builder con uv que resuelve el entorno, y un runtime que solo tiene Python y tu .venv.
Sin pip, sin compiladores, sin uv siquiera.
# syntax=docker/dockerfile:1.9
# --- STAGE 1 -- builder -------------------------------------------------------
FROM python:3.12-slim-bookworm AS builder
# uv no se instala: se copia desde su imagen oficial. Build idempotente.
COPY --from=ghcr.io/astral-sh/uv:0.5.8 /uv /uvx /bin/
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
UV_PYTHON_DOWNLOADS=never
WORKDIR /app
# Capa 1: solo metadata + lockfile. Cacheable hasta que cambien dependencias.
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-dev --no-install-project
# Capa 2: el código. Cambiarlo NO invalida la capa anterior.
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev --no-editable
# --- STAGE 2 -- runtime -------------------------------------------------------
FROM python:3.12-slim-bookworm AS runtime
RUN groupadd -r app && useradd -r -d /app -g app app
# Solo el .venv resuelto + tu código. Nada más.
COPY --from=builder --chown=app:app /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER app
WORKDIR /app
CMD ["python", "-m", "app.main"]
Hay tres decisiones aquí que merece comentar:
- uv se copia, no se instala. La imagen
ghcr.io/astral-sh/uv:0.5.8es oficial y fija. Si mañana sale uv 0.5.9, tu build sigue siendo bit a bit el de hoy. Sin sustos. - Bind mounts en lugar de
COPYparauv.lockypyproject.toml. Así no quedan en el layer y la cacheabilidad es máxima. - El runtime no contiene uv. Ni pip. Ni compiladores. Solo Python y tu
.venvya resuelto. La imagen final pesa unos 120 MB en vez de los 800-1200 MB típicos de un Dockerfile descuidado.
Y un detalle de seguridad gratis: ejecutas como usuario app no-root, sin shell de login.
Lo que sale gratis, mejor aprovecharlo.
Si tu imagen pesa 2 GB, alguien la está pagando. Probablemente tu equipo de infra. Probablemente cada vez que haces un deploy. Probablemente sin saberlo.
Ejemplo 6 - PEP 723: el Gist de Slack como artefacto reproducible
Código en
ejemplo-06-pep723/.
Este es el golpe de efecto.
Resulta que lo de “Pakito me mandó un script por Slack en 2022” tiene solución.
Crea un fichero hola_pep723.py:
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "requests>=2.32",
# "rich>=13.7",
# ]
# ///
"""Consulta la versión actual de uv en PyPI y la imprime con colorines."""
from __future__ import annotations
import requests
from rich.console import Console
def main() -> None:
console = Console()
r = requests.get("https://pypi.org/pypi/uv/json", timeout=10)
info = r.json()["info"]
console.print(f"[bold cyan]uv[/bold cyan] última versión: [green]{info['version']}[/green]")
console.print(f"[dim]{info['summary']}[/dim]")
if __name__ == "__main__":
main()
Le das permisos:
chmod +x hola_pep723.py
./hola_pep723.py
Ya está. Sin requirements.txt. Sin venv. Sin README. Sin “instala primero esto”.
El bloque # /// script es estándar (PEP 723
) y uv lo entiende: lee las dependencias, las resuelve, las cachea en un entorno aislado y ejecuta.
La primera vez tarda un segundo. Las siguientes, milisegundos.
¿Por qué importa esto? Porque convierte cada script suelto que tienes guardado en un cajón en un artefacto autocontenido. Lo metes en un Gist, alguien lo descarga, lo ejecuta, funciona.
Hace cinco años eso era un engorro. Hoy son ocho líneas de cabecera.
Vale, pero ¿y si Astral desaparece mañana?
Y con razón. Y ahora más que nunca: Astral ha sido adquirida por OpenAI, lo que convierte el “¿y si desaparecen?” en una pregunta con más aristas que antes.
Dos argumentos que siguen en pie y uno que hay que matizar:
El estándar no es uv. El estándar es pyproject.toml y los lockfiles. Si mañana uv desapareciera o lo enterraran en alguna reestructuración de OpenAI, tu pyproject.toml sigue siendo válido para pip, pdm, hatch y cualquier herramienta que cumpla los PEP. El lockfile sí es específico de uv hoy, pero está en proceso de estandarización
.
El vendor lock-in es bajo. Cambiar de uv a otra herramienta de la misma generación (pdm, hatch) es un día de trabajo, no un proyecto trimestral. El código de producto no se toca.
El futuro de Astral bajo OpenAI es incierto. No es un proyecto de hobby, pero tampoco es independiente ya. Las adquisiciones cambian prioridades, y lo que hoy es prioritario puede dejar de serlo. No es un argumento para no usarlo; es un argumento para no construir sobre ello sin red.
Así que la jugada razonable es: adopta uv, pero diseña tu repo para que el día que cambies sea barato.
¿Cómo haces que el cambio duela menos? Justo como hemos visto: que la fuente de verdad sea pyproject.toml, que el lockfile esté versionado, que la política viva en el repo, que el Dockerfile no tenga magia, que el CI use flags estándar.
Si haces eso, no te pegas un tiro en el pie. Cuándo cambias lo decides tú, no la herramienta.
La lista del lunes
Vuelves a la oficina (llorando, porque prefieres teletrabajar), hay café “de sabor cuestionable” en la cafetera y ninguna crisis activa. Es el momento.
No tienes que hacer todo esto el mismo día. De hecho, si lo haces todo el mismo día, algo habrás roto. La idea es ir en orden, una cosa por semana, sin heroicidades.
Instala uv en tu máquina. Solo para ver qué pasa. Un
curl ... | shy treinta segundos después tienes algo que funciona. No estás comprometiéndote a nada todavía; estás mirando por la ventana.Crea un proyecto de prueba con
uv init --package hola. No leas la documentación primero: abre elpyproject.tomlque te genera, mira eluv.lock, lanza unuv run. Si algo no tiene sentido, búscalo. Si lo entiendes todo, pasa al siguiente paso. La idea es cacharrear en un sitio donde no pasa nada si rompes algo.Elige el repo más pequeño y menos crítico que tengas. Ese que tiene cuatro ficheros y cero compañeros mirando. Añade
pyproject.toml+uv.locksin tocar nada de código de producto. Solo la plataforma. Cuando el pipeline siga verde, habrás aprendido más que con cualquier tutorial.Migra ese mismo CI. Una línea con
astral-sh/setup-uv@v6, cambia el install poruv sync --frozeny añadeuv lock --checkal final. Si en algún momento alguien toca dependencias sin relockearlo, el CI falla antes de que llegue a tu máquina. Es el tipo de red de seguridad que, cuando la tienes, te preguntas cómo viviste sin ella.Mira el Dockerfile. Si tienes uno con
pip installdentro y sin etapas separadas, es el candidato. El ejemplo 5 está ahí para copiarlo sin vergüenza. El resultado pesa cuatro veces menos y tiene menos superficie de ataque. Son veinte minutos de trabajo, no un proyecto.Activa Ruff en pre-commit. Y luego deja de hablar de formato en las PRs. Para siempre. Tu equipo tardará tres días en darse cuenta de lo que ha desaparecido de sus revisiones.
Borra los fósiles.
setup.py,requirements.txt,requirements-dev.txt. Cuando todo lo anterior lleve una semana en verde y nadie haya gritado, ya no hace falta. Elgit rmmás satisfactorio de tu carrera reciente.
Y si terminas los siete y quieres un octavo: lee el PR de la persona más nueva del equipo la semana después de haber hecho todo esto. Lo que ya no tiene que explicar en el CONTRIBUTING.md es la mejor métrica de lo que has conseguido.
Cierre
uv no es magia. Es consolidación. Y resulta que en ingeniería, consolidación es la palabra adulta para “diseño”.
Cada vez que reduces el número de herramientas distintas que tienes que conocer, configurar, versionar y debuggear, ganas algo más valioso que velocidad: ganas la posibilidad de pensar en tu producto en lugar de en tu plataforma.
Si te llevas una sola idea del artículo, que sea esta: tu toolchain de Python es una capa de plataforma, tiene cuatro responsabilidades, y por primera vez en veinte años existe una manera razonable de cubrirlas con una sola herramienta sin perder nada importante.
Lo demás es cuestión de empezar. Y empezar, si lo piensas, son ocho caracteres a teclear en tu terminal:
uv init
(ENTER es el octavo)
Si quieres ver todos los ejemplos de este artículo (proyecto nuevo, legacy, Ruff, CI, Docker y PEP 723) en un repo navegable y ejecutable, lo tienes en github.com/granite-stack/uv-sin-miedo .
Cualquier comentario, queja fundamentada o cuñadismo bienintencionado, en los canales de siempre.
Suerte con la migración. No es tan terrible. Pakito estaría orgulloso.
Glosario rápido
Por si llevas un rato asintiendo sin saber muy bien de qué iba la frase.
- PEP (Python Enhancement Proposal): propuesta de mejora del lenguaje Python. Es el mecanismo oficial para estandarizar nuevas características, convenciones y formatos del ecosistema. Los PEP relevantes para este artículo son el 517, 518 y 621 (empaquetado), el 723 (scripts autocontenidos), el 735 (grupos de dependencias) y el 751 (lockfile estándar).
- lockfile: fichero generado automáticamente que registra las versiones exactas, con hashes SHA256, de todas las dependencias resueltas. Garantiza que cualquier instalación posterior obtenga bit a bit el mismo resultado. En uv se llama
uv.lock. - multi-stage (Dockerfile): técnica de construcción de imágenes Docker que usa varias etapas (
FROM ... AS nombre) para separar el entorno de compilación del de ejecución. El resultado es una imagen final más pequeña y con menor superficie de ataque. - OIDC (OpenID Connect): protocolo de autenticación basado en tokens que permite a GitHub Actions demostrar su identidad a PyPI sin necesidad de guardar secretos en el repositorio. Es lo que hace posible el “trusted publishing”.
- supply chain: en software, la cadena de dependencias externas (paquetes, bibliotecas, herramientas) que tu proyecto consume. Un ataque de supply chain compromete uno de esos eslabones para afectar a todos los proyectos que dependen de él.
- toolchain: literalmente, “cadena de herramientas”. En el contexto de Python, el conjunto de utilidades que se necesitan para gestionar el ciclo de vida de un proyecto: versionar Python, crear entornos virtuales, resolver dependencias, generar lockfiles, ejecutar código y empaquetar. Lo habitual es que sean varias herramientas distintas ensambladas a mano; la propuesta de uv es que una sola cubra todo eso.
Fuentes y referencias
Las fuentes están en la web, los errores en este artículo y la responsabilidad de haber llegado hasta aquí es tuya.
- Documentación oficial de uv - Astral. Referencia completa de comandos, conceptos y configuración.
- Documentación de Ruff - Astral. Linter y formateador que sustituye a flake8, isort, black y similares.
- Astral - Empresa detrás de uv y Ruff; también gestiona un registry empresarial.
- Trusted Publishing (OIDC) - PyPI. Publicación a PyPI sin tokens ni secretos, usando OIDC desde GitHub Actions.
- PEP 723 – Inline script metadata
- Python Packaging Authority. Estándar para declarar dependencias directamente en un script con el bloque
# /// script. - PEP 735 – Dependency Groups in pyproject.toml
- Python Packaging Authority. Estandarización de grupos de dependencias (dev, test, etc.) en
pyproject.toml. - PEP 751 – A file format to record Python dependencies for installation reproducibility - Python Packaging Authority. Propuesta de estandarización del formato de lockfile.
- Repositorio de ejemplos uv-sin-miedo - Repo con los seis ejemplos del artículo, navegables y ejecutables.
