I am Lino
19 de mayo de 2026

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

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:

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:

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:

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:

  1. uv se copia, no se instala. La imagen ghcr.io/astral-sh/uv:0.5.8 es oficial y fija. Si mañana sale uv 0.5.9, tu build sigue siendo bit a bit el de hoy. Sin sustos.
  2. Bind mounts en lugar de COPY para uv.lock y pyproject.toml. Así no quedan en el layer y la cacheabilidad es máxima.
  3. El runtime no contiene uv. Ni pip. Ni compiladores. Solo Python y tu .venv ya 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.

  1. Instala uv en tu máquina. Solo para ver qué pasa. Un curl ... | sh y treinta segundos después tienes algo que funciona. No estás comprometiéndote a nada todavía; estás mirando por la ventana.

  2. Crea un proyecto de prueba con uv init --package hola. No leas la documentación primero: abre el pyproject.toml que te genera, mira el uv.lock, lanza un uv 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.

  3. 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.lock sin tocar nada de código de producto. Solo la plataforma. Cuando el pipeline siga verde, habrás aprendido más que con cualquier tutorial.

  4. Migra ese mismo CI. Una línea con astral-sh/setup-uv@v6, cambia el install por uv sync --frozen y añade uv lock --check al 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.

  5. Mira el Dockerfile. Si tienes uno con pip install dentro 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.

  6. 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.

  7. 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. El git rm má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.


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.

Sígueme

Escribo y opino sobre tecnología, desarrollo de software y lo que se me pase por la cabeza.