Use uv for Python dependency management#

Status#

Accepted

Context#

Since 2018, OEP-18: Python Dependency Management has defined the standard workflow for Python dependencies in Open edX repositories:

  • Direct dependencies declared in requirements/*.in files

  • pip-compile (from pip-tools) generates pinned requirements/*.txt lockfiles

  • pip-sync installs exactly those pinned versions into a virtualenv

  • make upgrade orchestrates the whole process

This workflow has served the project well, but the Python packaging ecosystem has evolved significantly:

  • PEP 621 (2021) standardized project metadata in pyproject.toml, making setup.py and setup.cfg legacy.

  • PEP 735 (2024) added dependency groups to pyproject.toml, providing a standard way to declare context-specific dependencies (test, docs, CI, etc.) — the same problem OEP-18 solved with multiple .in files.

  • uv emerged as a fast, unified tool that handles dependency resolution, locking, virtual environment management, and tool execution in a single binary. It natively understands pyproject.toml and PEP 735 dependency groups.

Meanwhile, pip-tools has not added support for PEP 735 dependency groups, and continuing to use it means maintaining a parallel dependency declaration system (requirements/*.in) alongside pyproject.toml. Additionally, Jazzband — the organization that maintains pip-tools — announced in March 2026 that it is sunsetting, leaving the long-term governance and maintenance of pip-tools uncertain.

Decision/Consequence#

Open edX Python repositories should use uv for dependency management:

  • Declare dependencies in pyproject.toml using PEP 621 metadata and PEP 735 dependency groups. This replaces requirements/*.in files.

  • Lock dependencies with uv lock, producing a single uv.lock file. This replaces multiple requirements/*.txt files generated by pip-compile.

  • Install dependencies with uv sync, which creates and manages the virtual environment. This replaces pip-sync and manual python -m venv steps.

  • Run tools with uv run when a tool is installed in the project’s virtualenv (e.g., uv run tox, uv run pytest).

  • Preserve the make upgrade pattern — the target now runs uv lock --upgrade instead of pip-compile --upgrade.

For test matrices that need multiple versions of a dependency (e.g., Django), use [tool.uv].conflicts with separate dependency groups and uv-venv-lock-runner in tox. This ensures fully reproducible, locked resolutions for each matrix combination.

Shared constraints (e.g., the global constraints file from edx-lint) are written into [tool.uv].constraint-dependencies in pyproject.toml via the edx_lint write_uv_constraints command, since uv lock does not accept a --constraint CLI flag.

Rejected Alternatives#

  1. pip-tools (status quo)

    pip-tools is mature and well-understood in the Open edX community. However, it does not support PEP 735 dependency groups, requires a separate tool for virtual environment management, and is significantly slower at dependency resolution than uv. Continuing to use it means maintaining requirements/*.in files alongside pyproject.toml, duplicating dependency declarations. The sunsetting of Jazzband also raises questions about long-term maintenance.

  2. Poetry

    Poetry is a popular all-in-one tool, but it is opinionated about project structure, uses a non-standard lock format (poetry.lock), and has limited support for the kind of dependency groups needed for Open edX code test matrices (e.g., multiple Django versions). Its resolver has historically been slower than uv’s.

  3. PDM

    PDM supports PEP 735 and produces a standard lock format, but has a smaller community and less ecosystem adoption. uv has gained broader momentum in the Python packaging space and is backed by the same team (Astral) that maintains ruff, which is already widely adopted in the broader Python community.

Examples#

The following examples are drawn from openedx/sample-plugin and illustrate the key patterns. See that repository for a complete working reference.

Example pyproject.toml#

[build-system]
requires = ["setuptools", "setuptools-scm>8.1"]
build-backend = "setuptools.build_meta"

[project]
name = "openedx-sample-plugin"
description = "A sample backend plugin for the Open edX Platform"
requires-python = ">=3.12"
license = "Apache-2.0"

dependencies = [
    "Django",
    "djangorestframework",
    "openedx-events",
]

# -- Dependency groups (PEP 735) ------------------------------------------
#
# These replace the old requirements/*.in files. Each group maps to a usage
# context (testing, quality checks, docs, CI tooling, local dev).

[dependency-groups]
# Framework-agnostic test deps. Not used directly — included by the
# version-specific groups below.
test-base = [
    "pytest-cov",
    "pytest-django",
]

# Current default Django version. Used by quality, docs, and as the default
# test matrix entry.
test = [
    {include-group = "test-base"},
    "Django>=5.0,<6.0",
]

# Additional Django versions under test. Each gets its own group so uv can
# produce a separate locked resolution via [tool.uv].conflicts.
django60 = [
    {include-group = "test-base"},
    "Django>=6.0,<7.0",
]

quality = [
    {include-group = "test"},
    "edx-lint",
    "pycodestyle",
]

doc = [
    {include-group = "test"},
    "Sphinx",
    "doc8",
]

ci = [
    "tox",
    "tox-uv",
]

dev = [
    {include-group = "quality"},
    {include-group = "ci"},
]

# -- uv configuration -----------------------------------------------------

[tool.uv]
# Mutually exclusive groups: uv produces one locked resolution per entry.
conflicts = [
    [{group = "test"}, {group = "django60"}],
]

#  DO NOT EDIT constraint-dependencies DIRECTLY.
#  This list is managed by `edx_lint write_uv_constraints`
#  and will be overwritten the next time `make upgrade` is run.
#  - GLOBAL constraints: edit edx_lint/files/common_constraints.txt
#  - REPO-SPECIFIC constraints: edit constraints.txt next to pyproject.toml
constraint-dependencies = [
    "Django<7.0",
    "elasticsearch<7.14.0",
]

Example tox.ini#

[tox]
envlist = py312-django{52,60},quality,docs
requires =
    tox-uv>=1

[testenv]
runner = uv-venv-lock-runner
dependency_groups =
    django52: test
    django60: django60
commands =
    pytest {posargs}

[testenv:quality]
dependency_groups =
    quality
commands =
    pylint my_package tests

[testenv:docs]
dependency_groups =
    doc
commands =
    doc8 docs
    make -e -C docs html

Common commands#

# Create a virtual environment
uv venv .venv --seed --python 3.12

# Install all dev dependencies (creates .venv if needed)
uv sync --group dev

# Run tests via tox
uv run tox

# Run a single tox environment
uv run tox -e py312-django60

# Upgrade all locked dependencies (the new "make upgrade")
uv lock --upgrade

# Sync shared constraints from edx-lint into pyproject.toml
uv run --with edx-lint edx_lint write_uv_constraints

# Full upgrade sequence (typically wrapped in a Makefile target)
uv run --with edx-lint edx_lint write_uv_constraints
uv lock --upgrade

References#

Change History#

2026-04-24#