Skip to main content

Managing Python workspaces with uv

·5 mins

What is uv #

uv is a Python project management tool that aims to replace many other tools in the Python ecosystem, such as pip, pip-tools, pipx, poetry, pyenv, twine, virtualenv, and more. It is backed by Astral, the same company behind the ruff linter.

Workspaces #

What made me look into uv is its capability to manage workspaces, a concept borrowed from Rust’s cargo.

A workspace is a collection of one or more workspace members managed together. Each member defines its own pyproject.toml, but there is a single uv.lock lockfile: this guarantees that there cannot be conflicting dependency requirements in the workspace. Each member can be either an application or a library: the difference between them is in their configuration.

Workspaces are a game-changer when you want to break a big Python project into smaller projects: this enables you to define clear boundaries between them and assign the required dependencies to each of them… and since a workspace is a single entity, workspace members can depend on each other locally!

I have created a uv-workspace-example repository with a minimalistic (but complete) example of uv workspace, including linting with ruff and an example of Dockerfile.

This example uses uv version 0.5.7, the latest at the moment of writing: uv is still being actively developed and has not yet reached version 1.x, so it’s important to use the same version.

Layout #

This is what its layout looks like:

my-app/
|__ packages/
|   |__ my_lib/
|       |__ src/
|       |   |__ my_lib/
|       |   |   |__  __init__.py
|       |__ tests/
|       |   |__  __init__.py
|       |__ pyproject.toml
|__ src/
|   |__ my_app/
|       |__  __init__.py
|__ tests/
|   |__  __init__.py
|__ Dockerfile
|__ pyproject.toml
|__ uv.lock

This example follows the src layout, where Python code is stored inside a src directory. This aligns with uv’s packaged application layout.

Python tests are stored in a separate test directory, so that they can be easily excluded from published artifacts and/or Docker images. Python tests directories are arranged as packages, as per Pytest’s recommendation when using the default import process.

Definining a workspace #

This is what the root pyproject.toml looks like:

[project]
name = "my-app"
version = "0.1.0"
description = "An example of uv workspace"
readme = "README.md"
requires-python = ">=3.9"
dependencies = [                # (3)
    "my-lib",
]

[dependency-groups]             # (4)
dev = [
    "ruff >= 0.8.1",
    "pytest >= 8.3.4",
]

[tool.uv.sources]               # (1)
my-lib = { workspace = true }

[tool.uv.workspace]             # (2)
members = [ "packages/*" ]

It defines that this is a workspace using the [tool.uv.sources] and [tool.uv.workspace] tables ((1) and (2), respectively): if those are absent, the repository is treated as a single Python project.

[tool.uv.sources] looks at the package name (my-lib) as defined in the package’s own pyproject.toml and not at the name of the packages/’s subdirectory (my_lib, note the snake case).

It also defines that my-app depends on my-lib ((3), note the absence of version constraints since it is a local dependency) and some dev dependencies ((4), only provided as an example of dependency groups).

With the exception of (1) and (2), it’s exactly what a typical application pyproject.toml file would look like.

A library pyproject.toml file needs to define the build system, as in the following example:

[project]
name = "my-lib"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.9"
dependencies = []

[dependency-groups]
dev = [
    "ruff >= 0.8.1",
    "pytest >= 8.3.4",
]

[build-system]                      # (1)
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]    # (2)
packages = ["src/my_lib"]

(1) instructs uv to build using hatch as backend (other options are possible). (2) tells hatch which directories to include in the packaged artifact.

Dependency management #

Each workspace member defines its dependencies in its own pyproject.toml file: the root directory’s pyproject.toml file should only contain any dependencies that are needed by the root project and/or shared among all workspace members.

Cheatsheet #

Note: uv commands behave according to the pyproject.toml of the current directory, so running uv sync (to install dependencies) in the root directory gives you a different result than running it inside packages/my_lib. The exception to the above is uv lock, which works at the workspace level.

For this reason, I find it useful to create a Makefile file in each workspace member’s directory, as well as one in the root directory to rule them all. You can see an example in the uv-example repository.

Adding packages to the workspace #

mkdir -p packages
uv init packages/another_app            # create an application
uv init --package packages/another_app  # create a packaged application
uv init --lib packages/another_lib      # create a library

Installing dependencies #

uv sync                     # install current package's dependencies
uv sync --package my-lib    # install dependencies of specified package
uv sync --all-packages      # install dependencies of all packages

Running Python files #

uv run path/to/run.py

Building package(s) #

uv build                  # build current project
uv build --package my-lib # build a specific package

Build files are stored in the dist/ directory.

Publishing package(s) #

uv publish # publish all packages found in dist/

Working with the lockfile #

uv lock          # update the lockfile (e.g. after manually changing pyproject.toml)

uv lock --locked # check if the lockfile is still consistent with pyproject.toml
uv lock --check  # same as the above (introduced in uv 0.5.8)

Linting #

Note: ruff must be listed among the dependencies or the following commands will not work.

# note: these will only report issues, without fixing them
uv run ruff check
uv run ruff format --check

# note: these will report and fix issues
uv run ruff check --fix
uv run ruff format

Running tests #

Note: pytest must be listed among the dependencies or the following command will not work.

uv run pytest tests