Using uv workspaces gives us a few nice benefits:
- Shared lockfile: one
uv.locktracks all dependencies across apps and libs, ensuring consistent environments. - Local inter-dependencies: apps can depend on local libraries (
dummy-lib, …) without publishing to PyPI. Dependencies are wired via[tool.uv.sources]and resolved automatically. - Targeted commands: run or sync in the scope of one member:
or apply tools across the whole repo:
uv run --package dummy-app python -m entry-point uv sync --package dummy-lib
uvx ruff check . - Common tooling: formatters, linters, and test runners can be defined once (e.g. ruff, pytest) and applied everywhere.
- Easy scaling: add more apps under apps/ or libs under libs/ / packages/ without extra boilerplate.
- Release flexibility: you can:
- develop locally with workspace resolution,
- build wheels/sdists with
uv build --package <name>for external distribution.
spincycle/
├── apps/
│ └── lab/
│ ├── pyproject.toml
│ └── src/lab/__main__.py
├── libs/
│ └── dummy_lib/
│ ├── pyproject.toml
│ └── src/dummy_lib/__init__.py
├── pyproject.toml
└── uv.lock
- The root
pyproject.tomldefines a uv workspace with members underapps/*andlibs/*. - Dependencies between members are declared normally (e.g.
dependencies = ["dummy-lib"]), and resolved locally thanks to:
[tool.uv.sources]
"dummy-lib" = { workspace = true }- One shared
uv.locktracks versions for all members. uvlets us target a specific member with--package.
You can add more libs by running
uv init libs/chocolatine --libThen add "chocolatine" to [tool.uv.sources] if other apps/libs could depend on it.
You can also add more apps by running: uv init apps/airfryer.
uv lock # update lockfile for all workspace members
uv sync --package dummy-app # install only the "lab" app (and its deps)uv run --package dummy-app python -m entry_pointuv run --package dummy-app python -c "import dummy_lib; print('ok', dummy_lib.__name__)"uv add --package dummy-app rich>=13,<14
uv add --package dummy-lib numpy>=2,<3Add this file under libs/dummy_lib/tests/test_basic.py:
from dummy_lib import __name__
def test_import():
assert __name__ == "dummy_lib"Run tests for the library:
uv run --package dummy-lib pytestExpected output:
collected 1 item
libs/dummy_lib/tests/test_basic.py . [100%]
1 passed in 0.02s
We can also run commands for the whole workspace:
uv run --all-packages pytestSimilarly, we can build each target:
uv build --package dummy-libOutput:
❯ uv build --package dummy-lib
Building source distribution...
Building wheel from source distribution...
Successfully built dist/dummy_lib-0.1.0.tar.gz
Successfully built dist/dummy_lib-0.1.0-py3-none-any.whl
Or all at once
uv build --all-packagesOutput:
uv build --all-packages
[dummy-lib] Building source distribution...
[spincycle] Building source distribution...
[dummy-lib] Building wheel from source distribution...
[spincycle] Building wheel from source distribution...
Successfully built dist/dummy_lib-0.1.0.tar.gz
Successfully built dist/dummy_lib-0.1.0-py3-none-any.whl
Successfully built dist/spincycle-0.1.0.tar.gz
Successfully built dist/spincycle-0.1.0-py3-none-any.whl