Hatchling metadata hook that rewrites workspace dependencies with version constraints at build time
Project description
Cada
uv workspaces are great for developing multiple Python packages together in a monorepo. But uv doesn't currently solve how to build and publish packages that depend on each other.
Cada is a simple hatchling build plugin for uv workspaces. It rewrites workspace dependencies with version constraints at build time, supporting both independent versioning (each package has its own version) and lockstep versioning (all packages share the same version).
Table of contents
- Why?
- Quickstart
- Configuration
- Monorepo versioning strategies
- Comparison with other plugins
- Development
- License
Why?
Suppose you have a uv workspace with packages that depend on each other:
# packages/my-core/pyproject.toml
[project]
name = "my-core"
version = "2.0.0"
# packages/my-client/pyproject.toml
[project]
name = "my-client"
version = "1.0.0"
dependencies = ["my-core"]
[tool.uv.sources]
my-core = { workspace = true }
This works for local development. But when you build my-client, the resulting wheel depends on my-core with no version constraint: pip will install whatever version it finds.
Cada generates version constraints at build time:
my-core-2.0.0.whl
my-client-1.0.0.whl # depends on my-core>=2.0.0
Each package keeps its own version and release cycle. When you release a new my-core, existing my-client releases already accept it via their >= constraint: no cascade of releases needed.
Whether versions come from project.version or VCS tags, Cada reads them and generates constraints based on the compatibility strategy you choose. You focus on code, not version bookkeeping.
Quickstart
Here's a complete minimal workspace setup:
my-monorepo/
├── pyproject.toml
├── packages/
│ ├── my-core/
│ │ └── pyproject.toml
│ └── my-client/
│ └── pyproject.toml
Root pyproject.toml — defines the workspace:
[project]
name = "my-monorepo"
version = "0.0.0"
requires-python = ">=3.12"
[tool.uv.workspace]
members = ["packages/*"]
packages/my-core/pyproject.toml:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "my-core"
version = "2.0.0"
requires-python = ">=3.12"
packages/my-client/pyproject.toml — uses Cada to resolve workspace dependencies at build time:
[build-system]
requires = ["hatchling", "hatch-cada"]
build-backend = "hatchling.build"
[project]
name = "my-client"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["my-core"]
dynamic = ["license-files"]
[tool.uv.sources]
my-core = { workspace = true }
[tool.hatch.metadata.hooks.cada]
strategy = "allow-all-updates"
[!NOTE] Cada requires Hatchling as your build backend. The uv build backend does not support plugins yet.
[!WARNING] On current versions of hatchling, metadata hooks only run when
project.dynamicis non-empty. If you're not using dynamic fields likedynamic = ["version"], adddynamic = ["license-files"]as a workaround. See pypa/hatch#2153 for details.
Build using your usual build frontend:
uv build packages/my-client
Verify the constraint was added by inspecting the wheel metadata:
unzip -p dist/my_client-1.0.0-py3-none-any.whl 'my_client-1.0.0.dist-info/METADATA' | grep Requires-Dist
# Requires-Dist: my-core>=2.0.0
Configuration
The strategy option controls how version constraints are generated for workspace dependencies.
[tool.hatch.metadata.hooks.cada]
strategy = "..."
If my-core is at version 1.2.3:
-
allow-all-updatesgeneratesmy-core>=1.2.3. This is the most common choice for libraries. -
allow-minor-updatesgeneratesmy-core>=1.2.3,<2.0.0. Allows minor and patch updates but not major version bumps. -
allow-patch-updatesgeneratesmy-core>=1.2.3,<1.3.0. Allows patch updates only. -
semveris version-aware: for0.xversions it behaves likeallow-patch-updates(since minor bumps can be breaking in0.x), and for1.x+it behaves likeallow-minor-updates. -
pingeneratesmy-core==1.2.3. Locks to the exact current version in your workspace.
You can override the strategy for specific dependencies:
[tool.hatch.metadata.hooks.cada]
strategy = "allow-all-updates"
[tool.hatch.metadata.hooks.cada.overrides]
my-core = "pin"
my-utils = "semver"
With this configuration, my-core will be pinned to its exact version, my-utils will use semver-aware constraints, and all other workspace dependencies will use the default allow-all-updates strategy.
Monorepo versioning strategies
Choosing a versioning strategy involves two decisions:
- How packages are versioned — either shared or independent versions
- How Cada generates dependency constraints — via the
strategyoption
| Approach | Package versions | Cada strategy | Constraint |
|---|---|---|---|
| Lockstep | All packages share one version | pin |
==1.2.3 |
| Independent | Each package has its own version | Any other (e.g., semver) |
>=1.2.3,<2.0.0 |
Lockstep versioning
All packages share the same version number and release together.
Setup:
- Set the same
project.versionin all packages - Use the
pinstrategy so dependencies require exact versions
[tool.hatch.metadata.hooks.cada]
strategy = "pin"
Building produces my-core-1.2.0.whl and my-client-1.2.0.whl, where my-client depends on my-core==1.2.0.
Pros:
- Simple to manage (one version number, one tag)
Cons:
- Must release all packages even when only one changes
- Users may receive irrelevant new versions of packages with no actual changes, just a version bump to stay in sync
- Doesn't scale well if packages have very different release cadences
Independent versioning
Each package has its own version and release cycle.
Setup:
- Set different
project.versionvalues per package - Use a non-pinning strategy so downstream packages accept new releases
[tool.hatch.metadata.hooks.cada]
strategy = "allow-all-updates"
When you release my-core@2.4.0, existing my-client releases already accept it via their >=2.3.0 constraint: no cascade of releases needed.
Pros:
- Packages release on their own schedule
- Avoids cascading releases when a change isn't relevant to all consumers
- Users only install and update what they need
Cons:
- More complex to manage (multiple version numbers, tag conventions)
Deriving versions from git tags
The examples above assume versions are statically set in project.version. For fully automated versioning, combine Cada with hatch-vcs to derive versions from git tags. Tag a commit, and both the package version and its dependency constraints are derived automatically at build time.
[!NOTE] In a monorepo, each package's
pyproject.tomllives in a subdirectory while.gitis at the root. Thesearch_parent_directories = trueoption tells hatch-vcs to look in parent directories for the git repository.
hatch-vcs setup for lockstep versioning
Use regular git tags (e.g., v1.2.0). All packages derive their version from the same tag:
# packages/my-core/pyproject.toml
[project]
name = "my-core"
dynamic = ["version"]
[build-system]
requires = ["hatchling", "hatch-vcs", "hatch-cada"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"
[tool.hatch.version.raw-options]
search_parent_directories = true
# packages/my-client/pyproject.toml
[project]
name = "my-client"
dynamic = ["version"]
dependencies = ["my-core"]
[build-system]
requires = ["hatchling", "hatch-vcs", "hatch-cada"]
build-backend = "hatchling.build"
[tool.uv.sources]
my-core = { workspace = true }
[tool.hatch.version]
source = "vcs"
[tool.hatch.version.raw-options]
search_parent_directories = true
[tool.hatch.metadata.hooks.cada]
strategy = "pin"
With the tag v1.2.0, building both packages produces my-core-1.2.0.whl and my-client-1.2.0.whl, where my-client depends on my-core==1.2.0.
hatch-vcs setup for independent versioning
To distinguish tags for different packages, use a naming convention that prefixes the version with the package name (e.g., my-core@1.2.0, my-client@0.5.0). Then configure hatch-vcs with tag_regex and git_describe_command to match only tags for that specific package:
# packages/my-core/pyproject.toml
[project]
name = "my-core"
dynamic = ["version"]
[build-system]
requires = ["hatchling", "hatch-vcs", "hatch-cada"]
build-backend = "hatchling.build"
[tool.hatch.version]
source = "vcs"
fallback-version = "0.0.0"
[tool.hatch.version.raw-options]
tag_regex = "^my-core@(?P<version>.*)$"
search_parent_directories = true
git_describe_command = [
"git",
"describe",
"--tags",
"--long",
"--match",
"my-core@*",
]
# packages/my-client/pyproject.toml
[project]
name = "my-client"
dynamic = ["version"]
dependencies = ["my-core"]
[build-system]
requires = ["hatchling", "hatch-vcs", "hatch-cada"]
build-backend = "hatchling.build"
[tool.uv.sources]
my-core = { workspace = true }
[tool.hatch.version]
source = "vcs"
[tool.hatch.version.raw-options]
tag_regex = "^my-client@(?P<version>.*)$"
search_parent_directories = true
git_describe_command = [
"git",
"describe",
"--tags",
"--long",
"--match",
"my-client@*",
]
[tool.hatch.metadata.hooks.cada]
strategy = "allow-all-updates"
When you build my-client, Cada reads my-core's version from its tag and generates the appropriate constraint. For example, with the following tags:
my-core@2.3.0
my-client@1.0.0
Building my-client produces a wheel with version 1.0.0 that depends on my-core>=2.3.0. You can release my-core@2.4.0 independently: existing my-client releases already accept it via their >=2.3.0 constraint.
Comparison with other plugins
Una
Una takes a different approach: instead of publishing workspace packages independently, it bundles all workspace dependencies into a single wheel:
my-client-1.0.0.whl
├── my_client/
└── my_core/ # vendored inside
Use Una when you're building an application you deploy yourself (Docker images, Lambda functions, CLI tools). You get a single artifact with everything included—no external dependencies to manage at install time.
Use Cada when you're building libraries for external consumers. Users only install what they need.
hatch-dependency-coversion
hatch-dependency-coversion rewrites dependency versions to match the current package's version. This is useful for lockstep versioning where all packages in a monorepo share the same version number.
Cada is different: it resolves each dependency's version through hatchling's plugin system. This is more flexible: it supports lockstep versioning (if you declare the same version in all projects) but also independent versioning where each package has its own version and release cycle.
uv-dynamic-versioning
uv-dynamic-versioning is another hatchling plugin that can handle workspace dependencies. The key difference is in how dependencies are declared.
uv-dynamic-versioning requires moving dependencies out of the standard project.dependencies field and uses non-standard templating in the version specifier:
[project]
name = "my-client"
dynamic = ["dependencies"]
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
dependencies = ["my-core=={{ version }}"]
Cada keeps standard dependencies syntax and location:
[project]
name = "my-client"
dependencies = ["my-core"]
[tool.uv.sources]
my-core = { workspace = true }
[tool.hatch.metadata.hooks.cada]
strategy = "allow-all-updates"
This matters because many tools in the Python ecosystem rely on the standard project.dependencies field:
- Monorepo build tools (Nx, Moon, etc.) auto-detect internal dependencies to build task graphs and determine what to rebuild when code changes. Non-standard dependency locations break this detection.
- Dependency scanners (pip-audit, safety, etc.) won't detect vulnerabilities and won't be able to rewrite your dependencies if they are in a non-standard location.
- Dependency update tools like Dependabot only support PEP 621 standard locations and have no workaround. Renovate can be configured with custom regex managers, but this requires manual setup.
- Import linters (deptry) can't verify imports match declared dependencies
Cada preserves compatibility with these tools out of the box by keeping your pyproject.toml structure standard.
Development
Set up the development environment:
uv sync --group dev
uv run poe setup
Available tasks via Poe the Poet:
uv run poe setup- Set up development environmentuv run poe test- Run testsuv run poe lint- Run linteruv run poe lint:fix- Run linter with auto-fixuv run poe format- Format codeuv run poe format:check- Check code formattinguv run poe typecheck- Run type checkeruv run poe lock:check- Check lockfile exists and is up to dateuv run poe check- Run all checksuv run poe release- Trigger release workflow on GitHub
License
Cada is distributed under the terms of the MIT license.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file hatch_cada-1.0.0.tar.gz.
File metadata
- Download URL: hatch_cada-1.0.0.tar.gz
- Upload date:
- Size: 94.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.18 {"installer":{"name":"uv","version":"0.9.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7c6d0784f392539cfa664614ff007faeed52dd96a2f8593762755382b35871f8
|
|
| MD5 |
538da8771cfde0324a18add1b26db1b1
|
|
| BLAKE2b-256 |
fd29d65a1909015cb5bd20f8787a25fb90a7542e27ab7cbff2d4f9be56d9c330
|
File details
Details for the file hatch_cada-1.0.0-py3-none-any.whl.
File metadata
- Download URL: hatch_cada-1.0.0-py3-none-any.whl
- Upload date:
- Size: 12.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.9.18 {"installer":{"name":"uv","version":"0.9.18","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7929f47b2738a2dc911cc4ee7584d73654f8804108eaa5461638d937e5a0a5e6
|
|
| MD5 |
40e20c90f89ae058278de7cb20d5434f
|
|
| BLAKE2b-256 |
435dbd0cec4a189b823be15f38da42a1106935350b65682ae59cc809197945ae
|