Monorepos en Python con uv workspaces

Tener cinco paquetes Python en el mismo repositorio —una librería core, dos microservicios, una CLI y el SDK para clientes— parece buena idea hasta que intentas que todos usen la misma versión de pydantic y descubres que cada uno tiene su propio entorno virtual con versiones distintas, resueltas de forma independiente. El resultado: el CI pasa pero producción explota porque los tipos de un paquete no son compatibles con los del otro.

uv workspaces resuelve exactamente esto. La mecánica es simple pero sus implicaciones son profundas: un único uv.lock para todo el repositorio, resuelto de forma global. Cuando servicio-a depende de pydantic>=2.5 y core depende de pydantic>=2.3, uv elige una sola versión que satisfaga ambos y la fija en el lockfile. No hay sorpresas entre paquetes.

El diseño imita lo que Cargo workspaces hace en Rust: un pyproject.toml raíz actúa como manifiesto del workspace, cada miembro tiene su propio pyproject.toml con sus dependencias particulares, y la resolución es global. Lo que no hace uv workspace es unificar el código en un solo paquete instalable —cada miembro sigue siendo un paquete independiente con su propio nombre en PyPI si decides publicarlo.

La pregunta de cuándo usar un monorepo versus múltiples repos se reduce a acoplamiento de despliegue y cadencia de cambio. Si cambiar core implica casi siempre cambiar servicio-a en el mismo commit, vivir en repos separados es fricción pura. Si son equipos distintos con ciclos de release independientes, el monorepo empieza a generar más coordinación de la que ahorra. uv workspaces no te da magia organizacional —solo elimina el dolor técnico cuando el monorepo ya tiene sentido.

El riesgo de hacerlo mal es sutil: si defines dependencias entre miembros del workspace con versiones rígidas en lugar de como dependencias de workspace ({workspace = true}), uv intentará resolver contra PyPI y fallará o instalará algo distinto de lo que tienes localmente. Ese error es difícil de diagnosticar la primera vez.

monorepo/
├── pyproject.toml          # workspace root
├── uv.lock                 # único lockfile para todo
├── packages/
│   ├── core/
│   │   ├── pyproject.toml
│   │   └── src/
│   │       └── core/
│   │           ├── __init__.py
│   │           └── models.py
│   ├── api/
│   │   ├── pyproject.toml
│   │   └── src/
│   │       └── api/
│   │           ├── __init__.py
│   │           └── app.py
│   └── cli/
│       ├── pyproject.toml
│       └── src/
│           └── cli/
│               ├── __init__.py
│               └── main.py
# pyproject.toml  (raíz del workspace)
[tool.uv.workspace]
members = ["packages/*"]

# Sin [project] aquí: la raíz es solo un manifiesto de workspace,
# no un paquete instalable por sí misma.
# packages/core/pyproject.toml
[project]
name = "core"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "pydantic>=2.7",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# packages/api/pyproject.toml
[project]
name = "api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "core",           # resuelto localmente, no busca PyPI
    "fastapi>=0.111",
    "uvicorn[standard]>=0.29",
]

[tool.uv.sources]
core = { workspace = true }   # esto es la clave

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# packages/cli/pyproject.toml
[project]
name = "cli"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "core",
    "typer>=0.12",
]

[tool.uv.sources]
core = { workspace = true }

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# packages/core/src/core/models.py
from pydantic import BaseModel


class Item(BaseModel):
    id: int
    name: str
    price: float
# packages/api/src/api/app.py
from fastapi import FastAPI
from core.models import Item   # importa directamente del paquete local

app = FastAPI()


@app.get("/items/{item_id}", response_model=Item)
async def get_item(item_id: int) -> Item:
    return Item(id=item_id, name="Widget", price=9.99)
# packages/cli/src/cli/main.py
import typer
from core.models import Item

app = typer.Typer()


@app.command()
def show_item(item_id: int) -> None:
    item = Item(id=item_id, name="Widget", price=9.99)
    typer.echo(f"{item.name}: ${item.price}")


if __name__ == "__main__":
    app()

Para instalar todo el workspace y obtener el único entorno virtual compartido:

# Desde la raíz del monorepo
uv sync

# Para trabajar solo con un miembro específico (útil en CI por paquete)
uv sync --package api

La declaración core = { workspace = true } en [tool.uv.sources] es el mecanismo que le dice al resolver que core es un miembro del workspace, no una dependencia externa. Sin esa sección, uv buscaría core en PyPI y o bien fallaría (si no existe) o instalaría algo completamente distinto. El nombre en [project].dependencies sigue siendo una string normal de PEP 508 —"core"— y la redirección al código local ocurre a través de [tool.uv.sources]. Esta separación entre qué depende y de dónde resolver es deliberada: los metadatos del paquete siguen siendo PEP-compliant y publicables.

El uv.lock resultante contiene entradas para todos los miembros del workspace y todas sus dependencias transitivas. Cuando haces uv sync, uv construye un único entorno virtual en la raíz con todo instalado en modo editable —cada miembro del workspace se instala como pip install -e packages/core lo haría, pero más rápido y con el lock garantizando reproducibilidad. Esto significa que editar core/models.py se refleja inmediatamente en api sin reinstalar nada.

La opción --package api en uv sync no crea un entorno aislado para api —sigue siendo el mismo entorno virtual de la raíz. Lo que hace es resolver solo las dependencias necesarias para api y sus dependencias transitivas (incluyendo core), omitiendo los paquetes que solo necesita cli. Es útil en pipelines de CI donde quieres ejecutar los tests de api sin instalar typer ni sus dependencias.

Errores que debes conocer

Error: Declarar la dependencia entre miembros solo en [project].dependencies sin el [tool.uv.sources] correspondiente hace que uv intente resolver el paquete contra PyPI en lugar de usar el código local.

# ❌ Wrong — falta [tool.uv.sources]
[project]
dependencies = ["core"]

# ✅ Right
[project]
dependencies = ["core"]

[tool.uv.sources]
core = { workspace = true }

La sección [tool.uv.sources] es la que activa la resolución local; [project].dependencies por sí sola no tiene forma de distinguir un workspace member de un paquete PyPI.


Error: Añadir un [project] table en el pyproject.toml raíz del workspace convierte la raíz en un paquete instalable más, lo que raramente es lo que quieres y puede generar conflictos de nombres.

# ❌ Wrong — la raíz no debería ser un paquete
[project]
name = "monorepo"
version = "0.0.1"

[tool.uv.workspace]
members = ["packages/*"]

# ✅ Right — solo el manifiesto de workspace
[tool.uv.workspace]
members = ["packages/*"]

Un pyproject.toml raíz sin [project] es perfectamente válido en uv; actúa como configuración del workspace sin ser él mismo un miembro.


Error: Usar glob demasiado amplio en members e incluir accidentalmente directorios que no son paquetes Python válidos (sin pyproject.toml) genera errores crípticos durante la resolución.

# ❌ Wrong — puede capturar directorios de scripts, docs, etc.
[tool.uv.workspace]
members = ["**"]

# ✅ Right — glob más específico o lista explícita
[tool.uv.workspace]
members = ["packages/*", "services/*"]

uv espera que cada entrada en members sea un directorio con su propio pyproject.toml válido; si no lo encuentra, falla durante uv sync con un error de workspace resolution que no siempre señala el directorio problemático directamente.


Un detalle que cambia cómo piensas el CI: como el uv.lock cubre todo el workspace, un único job de “lock check” (uv lock --check) basta para verificar que nadie comprometió cambios de dependencias sin actualizar el lockfile, independientemente de cuántos paquetes tenga el monorepo.

Dejar un comentario

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

Scroll al inicio