Skip to main content

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?

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.dynamic is non-empty. If you're not using dynamic fields like dynamic = ["version"], add dynamic = ["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-updates generates my-core>=1.2.3. This is the most common choice for libraries.

  • allow-minor-updates generates my-core>=1.2.3,<2.0.0. Allows minor and patch updates but not major version bumps.

  • allow-patch-updates generates my-core>=1.2.3,<1.3.0. Allows patch updates only.

  • semver is version-aware: for 0.x versions it behaves like allow-patch-updates (since minor bumps can be breaking in 0.x), and for 1.x+ it behaves like allow-minor-updates.

  • pin generates my-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:

  1. How packages are versioned — either shared or independent versions
  2. How Cada generates dependency constraints — via the strategy option
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.version in all packages
  • Use the pin strategy 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.version values 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.toml lives in a subdirectory while .git is at the root. The search_parent_directories = true option 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 environment
  • uv run poe test - Run tests
  • uv run poe lint - Run linter
  • uv run poe lint:fix - Run linter with auto-fix
  • uv run poe format - Format code
  • uv run poe format:check - Check code formatting
  • uv run poe typecheck - Run type checker
  • uv run poe lock:check - Check lockfile exists and is up to date
  • uv run poe check - Run all checks
  • uv 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

hatch_cada-1.0.0.tar.gz (94.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

hatch_cada-1.0.0-py3-none-any.whl (12.1 kB view details)

Uploaded Python 3

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

Hashes for hatch_cada-1.0.0.tar.gz
Algorithm Hash digest
SHA256 7c6d0784f392539cfa664614ff007faeed52dd96a2f8593762755382b35871f8
MD5 538da8771cfde0324a18add1b26db1b1
BLAKE2b-256 fd29d65a1909015cb5bd20f8787a25fb90a7542e27ab7cbff2d4f9be56d9c330

See more details on using hashes here.

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

Hashes for hatch_cada-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7929f47b2738a2dc911cc4ee7584d73654f8804108eaa5461638d937e5a0a5e6
MD5 40e20c90f89ae058278de7cb20d5434f
BLAKE2b-256 435dbd0cec4a189b823be15f38da42a1106935350b65682ae59cc809197945ae

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page