Project Setup

uv Workspaces for Multi-Package Python CLIs

Split a Python CLI into a core library, the command package and plugins in one uv workspace: one lockfile, workspace sources, per-package runs, builds and CI.

Updated

A CLI that grows successful often stops being one package. Another team wants to import its core logic without installing a command-line tool. Optional backends — AWS, GCP, a proprietary system — should be installable separately, so most users do not pull in huge SDKs. Plugins written in-house should live beside the tool and be tested against it on every change. You could split these into separate repositories and coordinate versions across them, or keep everything in one package and accept the dependency bloat. A uv workspace is the middle path: several packages in one repository, each with its own pyproject.toml and each built and published independently, sharing a single lockfile and a single development environment. This guide sets up a workspace for a CLI, its core library and a plugin, covers everyday commands, building and publishing individual members, and CI. It belongs to the uv for Python CLI dependency management topic.

Prerequisites

  • uv 0.5 or newer, and familiarity with a single-package uv project as in uv init vs Poetry init for CLI tools.
  • A genuine reason to have more than one distributable package — see the decision below.

Do you need a workspace?

Workspace or single package? A decision for splitting a CLI into a uv workspace: only when parts are released or reused independently. Workspace or single package? Is any part installed or released on its own? Yes — a library others import, or plugins Workspace separate wheels, one lock No — it is one tool One package subpackages are enough Workspaces solve a release problem, not a code-organisation problem.

Workspaces solve a release problem: several things that are installed or published separately but developed together. If your only goal is organising code, subpackages inside one distribution (mytool.core, mytool.cli) do the job with less machinery, and import contracts can keep them separate. Reach for a workspace when someone outside the CLI needs to pip install mytool-core, or when optional backends should be separate packages rather than extras.

The layout

A workspace for a CLI and its libraries A uv workspace with a root project and member packages: the CLI, a core library, and a plugin, sharing one lockfile. A workspace for a CLI and its libraries repo/ (workspace root) one uv.lock packages/mytool-cli the command packages/mytool-core reusable library packages/mytool-aws optional plugin members depend on each other via { workspace = true } sources One lockfile means every package is tested against exactly the same dependency versions.
repo/
├── pyproject.toml              # workspace root (not published)
├── uv.lock                     # one lockfile for everything
└── packages/
    ├── mytool-core/
    │   ├── pyproject.toml
    │   └── src/mytool_core/...
    ├── mytool-cli/
    │   ├── pyproject.toml
    │   └── src/mytool_cli/...
    └── mytool-aws/
        ├── pyproject.toml
        └── src/mytool_aws/...

The recipe

The root pyproject.toml declares the workspace and holds shared development tooling. It is not itself a published package:

# pyproject.toml (root)
[project]
name = "mytool-workspace"
version = "0"
requires-python = ">=3.10"
dependencies = []

[tool.uv.workspace]
members = ["packages/*"]

[dependency-groups]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.10"]

[tool.uv]
package = false          # the root is a workspace container, not a distribution

Each member is a normal package. The CLI depends on the core library by name, and a [tool.uv.sources] entry tells uv to satisfy that dependency from the workspace rather than from PyPI:

# packages/mytool-cli/pyproject.toml
[project]
name = "mytool-cli"
version = "2.1.0"
requires-python = ">=3.10"
dependencies = ["mytool-core>=2.1,<3", "typer>=0.12"]

[project.optional-dependencies]
aws = ["mytool-aws>=2.1"]

[project.scripts]
mytool = "mytool_cli.app:app"

[tool.uv.sources]
mytool-core = { workspace = true }
mytool-aws = { workspace = true }

[build-system]
requires = ["uv_build>=0.8,<0.12"]
build-backend = "uv_build"
# packages/mytool-aws/pyproject.toml
[project]
name = "mytool-aws"
version = "2.1.0"
requires-python = ">=3.10"
dependencies = ["mytool-core>=2.1,<3", "boto3>=1.34"]

[project.entry-points."mytool.backends"]
aws = "mytool_aws:AwsBackend"

[tool.uv.sources]
mytool-core = { workspace = true }

[build-system]
requires = ["uv_build>=0.8,<0.12"]
build-backend = "uv_build"

The key idea is that the published metadata is ordinary: mytool-cli declares a normal dependency on mytool-core>=2.1,<3, which is what users' installers will resolve from PyPI. The workspace = true source only affects development inside this repository, where uv installs the local copy in editable mode. The plugin announces itself through an entry point, so the CLI discovers it at runtime when installed — the mechanism described in discovering plugins with entry points.

Everyday commands

uv sync --all-packages                 # one .venv with every member installed (editable)
uv run --package mytool-cli mytool --help
uv run --package mytool-core pytest packages/mytool-core
uv add --package mytool-aws "boto3>=1.35"
uv lock                                # re-resolve the whole workspace

--package selects which member a command applies to; without it, uv uses the member whose directory you are in (or the root). uv sync --all-packages gives developers one environment where the CLI, core and plugin are all installed from the working tree, so a change in core is immediately visible when running the CLI.

Working across members Terminal output of running commands for specific members of a uv workspace and building one member. Working across members bash $ uv run --package mytool-cli mytool --version mytool 2.1.0 (core 2.1.0) $ uv run --package mytool-core pytest -q 64 passed in 0.9s $ uv build --package mytool-cli Successfully built dist/mytool_cli-2.1.0-py3-none-any.whl --package targets one member while the shared environment and lockfile stay in place.

One lockfile, consistent versions

The single uv.lock means every member is developed and tested against exactly the same versions of shared dependencies — there is no way for mytool-core to be tested with pydantic 2.8 while mytool-cli uses 2.10. That consistency is the main technical benefit over separate repositories, and it makes CI simpler: one uv sync --locked, one cache key.

Building and publishing members

Each member builds to its own wheel and sdist:

uv build --package mytool-core
uv build --package mytool-cli
uv build --all-packages                # everything, into dist/

For releases, the simplest policy is lockstep versioning: all members share a version and are released together from one tag. It makes compatibility obvious (mytool-cli 2.1.0 works with mytool-core 2.1.0) and the constraints easy to write. Independent versioning is possible — per-package tags such as core-v2.1.0 — but every inter-package constraint then needs thought. Publishing works exactly as for a single package, with trusted publishing configured once per PyPI project; see publishing to PyPI with trusted publishing.

Moving an existing CLI into a workspace

Most workspaces start as a single package that outgrew itself. The move can be done in small, reviewable steps without breaking users:

  1. Create the root and move the existing package under packages/. Keep its distribution name and version unchanged, add the root pyproject.toml with [tool.uv.workspace], run uv lock, and confirm tests pass. Users see no difference — the published package is identical.
  2. Extract the core library. Move modules with no CLI dependencies into a new mytool-core member, and have the CLI depend on it with a workspace source. Keep thin re-export shims in the old import paths for a release or two so any external code importing mytool.models keeps working, with a deprecation warning.
  3. Split optional backends last. Turn a heavy extra into its own member only when there is a clear benefit — independent release cadence, or a dependency so large it deserves a separate install. Keep the extra on the CLI (mytool-cli[aws] depending on mytool-aws) so existing install commands continue to work.
  4. Publish the new packages before the CLI depends on them. A release of mytool-cli that requires mytool-core>=2.1 must not reach PyPI before mytool-core 2.1 does; lockstep releases from one tag, publishing members in dependency order, handle this naturally.

Each step is a normal pull request with CI green, and at no point does a user's pipx upgrade break.

UX considerations

The users of a workspace layout are both end users and contributors:

  • End users should not notice it. pipx install mytool-cli (or "mytool-cli[aws]") must work exactly as for a single package. Test that by installing the built wheels into a clean environment, not from the workspace.
  • Contributors need one command to get going. Document uv sync --all-packages as the setup step; everything else follows.
  • Keep member names parallel. mytool-core, mytool-cli, mytool-aws makes the family obvious on PyPI and in pip list.
  • Put the command in the CLI package only. A core library that installs a console script surprises people who only wanted the library.

Testing the behaviour

Test each member in isolation as well as together. The workspace environment has everything installed, which can hide a missing dependency declaration — core code that accidentally imports typer, available only because the CLI member depends on it. Run each member's tests in an environment containing only that member:

# .github/workflows/ci.yml (excerpt)
  test:
    strategy:
      matrix:
        package: [mytool-core, mytool-cli, mytool-aws]
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
        with:
          enable-cache: true
          cache-dependency-glob: |
            uv.lock
            packages/*/pyproject.toml
      - run: uv sync --locked --package ${{ matrix.package }} --group dev
      - run: uv run --package ${{ matrix.package }} pytest packages/${{ matrix.package }}

uv sync --package X installs only that member and its declared dependencies, so an undeclared import fails the job. Add one job that installs the built wheels together and runs the CLI's smoke test, proving the published packages fit together exactly as they will for users. The cache settings are explained in caching uv dependencies in CI.

Conclusion

A uv workspace keeps a family of packages — a CLI, its core library, optional backends and plugins — in one repository with one lockfile, while each remains an ordinary, independently installable distribution. Declare members in the root, connect them with workspace = true sources over normal published constraints, use --package for everyday commands, version in lockstep unless you have a reason not to, and test each member alone so undeclared dependencies cannot hide. Use it when parts are released separately; otherwise, subpackages are simpler.

Frequently asked questions

Can members require different Python versions?

The workspace resolves a single lockfile for the intersection of all members' requires-python ranges, so the effective range is the narrowest. Members that genuinely need different Python versions belong in separate projects.

How is a workspace different from path dependencies?

Path dependencies ({ path = "../core" }) link separate projects, each with its own lockfile. A workspace shares one lockfile and environment. Use path dependencies for loosely related projects; use a workspace for a family released together.

Can a member depend on another member's extra?

Yes, with standard syntax: dependencies = ["mytool-core[yaml]>=2.1"], plus the workspace source. uv resolves the extra from the workspace member.

How do I stop the root from being built or published?

Set [tool.uv] package = false in the root, as shown, and omit a build system there. uv then treats the root purely as a workspace container.