Rollup-style dependency bundling for Hatchling, built for uv projects and workspaces.
rollup-py adds a rollup build target that produces a wheel containing your project and its
runtime dependencies (PyPI packages and uv workspace members), under a distribution name of your
choice. Vendored packages keep their import names and sit at the root of the wheel, so no import is
rewritten. The standard sdist and wheel targets (what uv build and uv sync use) are left
exactly as they are.
# packages/app/pyproject.toml
[tool.hatch.build.targets.rollup]
distribution-name = "app-bundled"
external = ["certifi"]uv lock
uvx rollup-py build --package app # from anywhere in the workspace
# -> dist/app_bundled-0.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whlOther ways to run the same target:
uv run --with rollup-py rollup-py build packages/app
hatch build -t rollup # needs "rollup-py" in [build-system].requiresrollup-py build [PATH] [--package NAME] [-o DIR] [--python-version V] [--python-platform P] [-c]
| Flag | Meaning |
|---|---|
PATH |
Project directory (default: current directory). |
--package |
Workspace member to build, looked up in uv.lock. |
-o, --out-dir |
Output directory (default: dist/ of the workspace root with --package, else of the project). |
--python-version, --python-platform |
Target environment, passed to uv pip install (e.g. 3.13, x86_64-pc-windows-msvc). Defaults to the interpreter running rollup-py; pick it with e.g. uvx --python 3.12 rollup-py build. |
-c, --clean |
Remove earlier bundles (only files named after distribution-name) from the output directory. |
All options live in [tool.hatch.build.targets.rollup]. File selection options of
[tool.hatch.build.targets.wheel] (packages, only-include, sources, exclude, hooks, ...) are
inherited and can be overridden there.
| Option | Default | Meaning |
|---|---|---|
distribution-name |
"{name}-rollup" |
Name of the bundled distribution. Must differ from the project name. |
vendor |
["*"] |
Direct dependencies to bundle; "*" means all of them. |
external |
[] |
Packages never bundled. They become Requires-Dist entries of the bundle. |
extras, groups |
[] |
Extras ([project.optional-dependencies]) and dependency groups ([dependency-groups]) of the project whose dependencies are bundled like dependencies, as uv sync --extra/--group would install them. Those that are not bundled become unconditional requirements. |
transitive |
true |
Also bundle the dependencies of bundled packages. When false they become requirements. |
conditional |
"external" |
Dependencies behind an environment marker: "external" keeps them as requirements with their marker; "evaluate" evaluates the marker for the target environment and bundles or drops them. |
lock |
"auto" |
Path to uv.lock; "auto" searches upwards from the project. |
check-lock |
true |
Run uv lock --check first and fail on a stale lock. |
python-version, python-platform |
Same as the CLI flags (the flags win). |
extras and groups pick a set of dependencies that uv sync leaves out, e.g. a patched fork that
only the bundle should ship, while development uses the original:
[project.optional-dependencies]
patched = ["six"]
[dependency-groups]
dev = ["six"]
[tool.uv]
conflicts = [[{ extra = "patched" }, { group = "dev" }]]
[tool.uv.sources]
six = [{ git = "https://github.com/me/six", branch = "fix", extra = "patched" }]
[tool.hatch.build.targets.rollup]
extras = ["patched"]With [tool.uv] conflicts, uv.lock holds both versions and marks the edges of other packages
with the extra or group they belong to (extra == 'extra-3-app-patched'). The bundle follows the
selected ones, so a package that depends on six gets the fork too. Selecting two conflicting items
is an error.
uv.lockis read to find the project, walk its dependency graph (fromdependenciesplus the selectedextrasandgroups) and split it into bundled and external packages. A walk stops at every external package, since the installer handles its dependencies.- Each bundled package is installed at its locked version with
uv pip install --target <tmp> --no-deps: registry and URL packages with--require-hashesand every hash from the lock, git packages at their locked commit, workspace members from their directory. - Files listed in each installed
RECORDare added to the wheel root. Scripts and data files (bin/,share/, ...) are left out. Two packages writing the same path, or a package clashing with the project's own files, is an error; namespace packages merge. - The wheel tag is the loosest tag every bundled wheel is compatible with: e.g.
cp312-cp312andcp38-abi3givecp312-cp312, andmanylinux_2_17andmanylinux_2_28givemanylinux_2_28. A bundle of pure-Python packages stayspy3-none-any. METADATAgets the newName, loses theRequires-Distentries of bundled packages (including in extras) and gains the requirements that bundled packages have on external ones.<dist-info>/rollup.jsonlists what was bundled and<dist-info>/vendor/keeps each bundled package'sMETADATAand license files.
The build fails if an external package (or one of your extras) depends on a bundled package,
because installing both would put two copies of the same files in site-packages.
- Bundled packages have no
.dist-infoof their own (a wheel can only carry one), soimportlib.metadata.version("requests")and similar lookups fail. The build warns when bundled code usesimportlib.metadataorpkg_resources. - Installing the bundle next to a separately installed copy of a bundled package, or next to the project's standard wheel, makes the two distributions share files.
uv buildcannot build this target: hatchling's PEP 517 entry points only buildsdistandwheel. Userollup-py buildorhatch build -t rollup.- Nothing is tree-shaken; packages are bundled whole.
- Only the project's own
extrasandgroupscount for[tool.uv] conflicts; conflicting extras of other packages are resolved as if none were enabled.
uv sync
uv run ruff check && uv run ruff format --check && uv run pyright
uv run pytest -m "not e2e" # unit tests
uv run pytest -m e2e # builds tests/fixtures/ws for real; needs access to PyPI