From ml-research
Comprehensive guide for building Python monorepos with uv workspaces - unified dependency resolution, shared lock files, editable installs, testing strategies, Docker optimization, and CI/CD patterns for managing multiple packages in a single repository
npx claudepluginhub nishide-dev/claude-code-ml-researchThis skill uses the workspace's default tool permissions.
Complete guide for building and managing Python monorepos using uv's workspace functionality.
Verifies tests pass on completed feature branch, presents options to merge locally, create GitHub PR, keep as-is or discard; executes choice and cleans up worktree.
Guides root cause investigation for bugs, test failures, unexpected behavior, performance issues, and build failures before proposing fixes.
Writes implementation plans from specs for multi-step tasks, mapping files and breaking into TDD bite-sized steps before coding.
Share bugs, ideas, or general feedback.
Complete guide for building and managing Python monorepos using uv's workspace functionality.
uv's workspace feature enables true monorepo architecture for Python projects, solving the historical challenge of managing multiple packages in a single repository. Inspired by Rust's Cargo, workspaces provide:
uv.lock file for entire repositoryWhen to use uv workspaces:
Key resources:
The most powerful feature: a single uv.lock at repository root that:
pydantic, fastapi)When you run uv lock, the system evaluates the entire workspace and creates a mathematically consistent dependency solution.
Workspaces enforce a single requires-python for the entire repository, calculated as the intersection of all members' requirements:
| Package | requires-python | Workspace Result |
|---|---|---|
| root | >=3.10 | - |
| service-a | >=3.11 | >=3.11 |
| service-b | >=3.12 | >=3.12 (strictest wins) |
This ensures all members can coexist in the shared virtual environment (.venv).
Two approaches for managing related packages:
| Feature | Workspace | Path Dependencies |
|---|---|---|
| Lock file | Single uv.lock for all | Separate per project |
| Python version | Unified (intersection) | Independent per project |
| Virtual env | Single shared .venv | Individual .venv per project |
| Consistency | Enforced | Flexible |
| Best for | Tightly coupled services | Highly independent projects |
my-monorepo/
├── pyproject.toml # Workspace root config
├── uv.lock # Unified lock file
├── .venv/ # Shared virtual environment
└── packages/
├── core/
│ ├── pyproject.toml
│ └── src/core/
├── api/
│ ├── pyproject.toml
│ └── src/api/
└── cli/
├── pyproject.toml
└── src/cli/
pyproject.toml (workspace root):
[project]
name = "my-monorepo-workspace" # Must be unique from members!
version = "0.1.0"
requires-python = ">=3.10"
[tool.uv.workspace]
members = ["packages/*"] # Glob patterns
# exclude = ["packages/legacy/*"] # Optional exclusions
[tool.uv]
package = false # Virtual root (not installable)
[dependency-groups]
dev = [
"pytest>=7.4",
"ruff>=0.1",
"mypy>=1.0",
]
The workspace root name must be unique from all member names. If both root and a member use my-app, uv sync fails with:
Error: Duplicate workspace member: my-app
Use descriptive names: my-app-workspace for root, my-app for actual package.
To make api depend on core, use two-step declaration:
packages/api/pyproject.toml:
[project]
name = "api"
dependencies = [
"core", # Standard PEP 621 declaration
]
[tool.uv.sources]
core = { workspace = true } # uv-specific: resolve from workspace
Why two declarations?
[project.dependencies]: Standard metadata, readable by all tools[tool.uv.sources]: Routing table for uv-specific resolutionIf [tool.uv.sources] is missing, uv provides helpful error:
Error: Package 'core' is a workspace member but missing sources entry
With workspace = true, uv automatically installs members in editable mode. Code changes in core are instantly reflected in api without reinstallation.
For dev tools (pytest, ruff, mypy), use dependency groups in root:
[dependency-groups]
dev = [
"pytest>=7.4",
"pytest-cov>=4.1",
"ruff>=0.1",
"mypy>=1.0",
]
Benefits:
uv sync --group devInstall:
# Install all dependencies + dev group
uv sync --group dev
# Install only production dependencies
uv sync
In monorepos with multiple tests/ directories, pytest defaults cause errors:
import file mismatch:
imported module 'test_helpers' has this __file__: .../packages/cli/tests/test_helpers.py
which is not the same as: .../packages/core/tests/test_helpers.py
Cause: Pytest's prepend mode inserts test directories into sys.path, causing name collisions.
Root pyproject.toml:
[tool.pytest.ini_options]
addopts = ["--import-mode=importlib"]
This uses Python's importlib to import tests directly without modifying sys.path, eliminating name collisions.
Important: Do NOT add __init__.py to test directories when using importlib mode. This causes silent test skipping (tests are cached under one path but collected from another).
A single uv.lock contains dependencies for ALL services. Naive Docker builds include unnecessary dependencies, inflating image size and attack surface.
FROM ghcr.io/astral-sh/uv:python3.12-alpine AS builder
WORKDIR /app
COPY uv.lock pyproject.toml ./
COPY packages/ ./packages/
# Extract only dependencies for 'api' service
RUN uv export --frozen --directory packages/api -o requirements.txt && \
# Remove workspace member references (e.g., -e ./packages/core)
sed -i '/^-e/d' requirements.txt
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip sync requirements.txt --no-cache --compile-bytecode
# Copy shared libraries first (changes less frequently)
COPY packages/core/ ./packages/core/
# Copy application code last (changes most frequently)
COPY packages/api/ ./packages/api/
# Install as editable
RUN uv pip install -e ./packages/api/
Benefits:
--compile-bytecode pre-generates .pyc files for faster startup| Stage | Command | Cache Behavior |
|---|---|---|
| Base | Copy uv binary | Rarely changes |
| Dependencies | uv export + uv pip sync | Only invalidated when uv.lock changes |
| Shared libs | Copy packages/core/ | Invalidated when core changes |
| Application | Copy packages/api/ | Invalidated on every app code change |
Problem: Per-PR caches waste storage and slow down CI.
Solution: Single cache from main branch, read-only for PRs.
name: Build Cache
on:
push:
branches: [main]
schedule:
- cron: '0 0 * * 0' # Weekly refresh
jobs:
cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: false # Manual cache control
- name: Sync dependencies
run: uv sync --all-groups
- name: Save cache
uses: actions/cache/save@v4
with:
path: |
~/.cache/uv
.venv
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
name: Test
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Restore cache
uses: actions/cache/restore@v4
with:
path: |
~/.cache/uv
.venv
key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }}
restore-keys: |
uv-${{ runner.os }}-
- uses: astral-sh/setup-uv@v5
- run: uv sync # Uses cache, downloads only diff
- run: uv run pytest
Benefits:
# Initialize new workspace
uv init --package my-monorepo
cd my-monorepo
# Add workspace member
uv init --lib packages/core
uv init --lib packages/api
# Configure workspace in root pyproject.toml
# Add: [tool.uv.workspace] members = ["packages/*"]
# Sync all dependencies
uv sync
# Sync with dev tools
uv sync --group dev
# Run command in workspace context
uv run python -c "import core; import api"
# Run from specific package directory
cd packages/api
uv run uvicorn main:app
# Build specific package
uv build --package api
# Build all packages
uv build --all-packages
# Check version
uv version --package core
# Publish to PyPI
uv publish --package core
Critical: Before creating workspace, align all dependency versions across services.
# In each service
uv sync --upgrade
uv run pytest # Verify no breakage
If Service A uses Django 4.2 and Service B uses Django 5.1, choose one version and update all services.
mkdir -p packages
mv service-a packages/
mv service-b packages/
Create root pyproject.toml with workspace configuration.
Replace relative path dependencies with workspace = true.
Expected outcome: uv lock will surface any hidden version conflicts that were masked by isolated environments.
Cause: Root and member have same name.
Solution: Rename root to project-workspace.
Cause: Package in [project.dependencies] but not in [tool.uv.sources].
Solution: Add { workspace = true } to sources.
Cause: Default prepend mode + duplicate test file names.
Solution: Add --import-mode=importlib to pytest config.
Solution: Use platform-specific members or path dependencies for GPU-specific code.
my-app-workspace not my-app[dependency-groups]--import-mode=importlib in pytest configuv export to extract service-specific depsuv workspaces provide:
Core Benefits:
Key Features:
uv.lock for entire repository.venv)Resources:
uv workspaces eliminate Python's historical monorepo pain points, bringing Cargo-like simplicity and performance to multi-package projects.