Managing Python workspaces with uv
Table of Contents
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
.
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