aspectrulespy//uv
July 3, 2026 · View on GitHub
aspect_rules_py provides an alternative to the venerable rules_python
pip.parse implementation, which leverages the
uv lockfiles instead of requirements.txt to
configure PyPi dependencies.
Our uv is a drop-in replacement for basic pip.parse usage, but provides a
number of additional features.
Dependency groups - Uv supports PEP 735 dependency
groups: each [dependency-groups] entry
in your pyproject.toml registers as a named group your build can switch
between. Flip the --@<hub>//dep_group=<name> flag, or set
dep_group="<name>" on a py_binary / py_test target.
Effortless Crossbuilds - Uv delays building and installing packages until
the build is configured. This allows uv to build your requirements in crossbuild
configurations, such as going from a Darwin macbook to a Linux container image
using only the normal Bazel platforms machinery.
Correct source builds - Because uv performs package source builds as a
normal part of your build, it's able to use hermetic or even source built Python
toolchains in addition to Bazel-defined dependencies and C compilers. Future
support for sysroots is planned. Due to its phasing, pip.parse is stuck doing
all this non-hermetically.
Editable requirements - Uv provides an uv.override_requirement() tag which
allows locked requirements to be replaced with 1stparty Bazel py_library
targets. This lets you substitute in vendored code, use custom build actions to
produce library files, or just iterate on patches easily.
Lightning fast configuration - The only work uv has to do at repository time is reading toml files. Downloads and builds all happen lazily.
Platform independence - No more need to separate requirements_mac,
requirements_linux and requirements_windows or your build exploding because
you query-ed a platform incompatible requirement. Uv can always configure all
of your requirements, and all hub labels are always available.
Mirror friendly - Relying on uv's locked dependency graph allows the extension to only use the Bazel downloader, ensuring compatibility with private or mirrored wheels.
Automatic cycle support - Requirement dependency cycles such as those in Airflow are automatically detected and resolved. User intervention is no longer required.
Quickstart
The first step is to generate a uv.lock file. In contrast to a conventional
requirements.txt, the uv lockfile contains both the dependency graph between
requirements and detailed information about the wheels and sdists.
The uv_bin.toolchain() tag below registers a Bazel-managed uv, so you can
invoke it via bazel run @uv -- … without installing uv globally. From a
workspace that already has a pyproject.toml:
bazel run @uv -- lock
If you're migrating from requirements.txt, use uv's own import flow to
create a pyproject.toml and seed it:
bazel run @uv -- init --no-workspace
bazel run @uv -- add -r requirements_lock.txt
We can now use the lockfile to configure our build.
This configuration declares a dependency hub, creates two dependency groups
(default and vendored_say), and shows how to use uv.override_package
to swap a locked requirement (cowsay) for a local one.
# MODULE.bazel
bazel_dep(name = "aspect_rules_py", version = "1.6.7") # Or later
uv_bin = use_extension("@aspect_rules_py//uv:extensions.bzl", "uv_bin")
uv_bin.toolchain(version = "0.11.6")
use_repo(uv_bin, "uv")
uv = use_extension("@aspect_rules_py//uv:extensions.bzl", "uv")
uv.declare_hub(
hub_name = "pypi", # Or whatever you wish
)
uv.project(
hub_name = "pypi", # Must be a declared hub
pyproject = "//:pyproject.toml",
lock = "//:uv.lock",
)
uv.override_package(
lock = "//:uv.lock",
name = "cowsay",
# version = "", Optional but may be required for disambiguation
target = "//third_party/py/cowsay:cowsay",
)
# This one hub now has two configurations ("dependency groups") available
use_repo(uv, "pypi")
register_toolchains("@uv//:all")
We can configure a default dependency group by setting the dep_group flag on our hub as part of the .bazelrc.
Each [dependency-group] of the pyproject.toml is registered as a named dependency group.
If no dependency groups are listed, an implicit default group with the name of the project itself is created.
# .bazelrc
common --@pypi//dep_group=dummy
Individual targets can request different dependency groups if multiple dependency groups are configured.
# BUILD.bazel
load("@aspect_rules_py//py:defs.bzl", "py_binary")
py_binary(
name = "say",
srcs = ["__main__.py_"],
deps = ["@pypi//cowsay"],
)
py_binary(
name = "say_vendored",
srcs = ["__main__.py_"],
deps = ["@pypi//cowsay"],
dep_group = "vendored_say", # Change the default dep_group choice
)
Targets that need every dependency in one dependency group can use the
group-specific lists generated in defs.bzl. The group_deps() helper follows
the consuming target's dep_group, so the group name is never repeated:
load("@pypi//:defs.bzl", "group_deps")
py_binary(
name = "all_vendored",
dep_group = "vendored_say",
deps = group_deps(), # resolves to vendored_say's dependencies
)
group_deps() returns a select() keyed on the active dep_group; if no
dep_group is set it fails with the list of known groups. It is a function so
future options (such as PEP 508 package extras) can be added as arguments
without breaking callers.
Macros that must inspect membership while loading a BUILD file can use
group_deps_for(name), which returns the membership of one explicit group as
sorted Label values and fails if the group is unknown. Read Label.package
off the results for normalized names, or pass the labels as deps to pin one
group. The aliases still resolve according to the active dep_group, so a
target using these labels as deps must set the matching group itself. Prefer
group_deps() for ordinary rule attributes so the dependency list follows the
consuming target's group automatically.
all_requirements (in requirements.bzl) remains the hub-wide union for
compatibility with rules_python. Under a dependency-group transition, that
union may contain targets which are incompatible with the selected group, so
use the group-specific helpers above when the consuming target sets
dep_group. This per-group API has no rules_python equivalent, so it lives
in the rules_py native defs.bzl rather than the rules_python-compatible
requirements.bzl.
The uv toolchain
uv_bin.toolchain() fetches the UV binary for the required platform(s) and
publishes @uv:
@uv//:uv— host-platform alias for ad-hoc use (bazel run @uv,genrule(tools=…),sh_binary(data=…)).@uv//:all— per-platform toolchains forregister_toolchains.
Optional attributes:
name— hub repo name; defaults to"uv"(i.e.@uv). Set to a distinct value to publish an additional hub (e.g.@uv_legacy//:uv) alongside the default.version— defaults to the latest version known to aspect_rules_py. Unknown versions are still fetchable but non-reproducible unless paired withsha256s.urls— mirror templates with{version},{platform},{ext}placeholders; tried in order. Defaults to the upstream GitHub release URL.sha256s— map of platform triple to SHA256. Overrides or supplies the hashes baked into aspect_rules_py. Entries are optional per-platform; a platform with an empty-string hash fetches without integrity verification and the download is marked non-reproducible (its bytes may vary across users).
Relationship to interpreter provisioning
Unlike rules_python, there is no coupling between uv.project() and Python
interpreter provisioning. The uv extension only reads lockfiles and generates
build targets — it does not download or configure Python interpreters.
At build time, Bazel's normal toolchain resolution selects an interpreter. You
can provide one via aspect_rules_py's own python_interpreters extension
(see interpreter.md), via rules_python's
python.toolchain(), or any other mechanism that registers a
@bazel_tools//tools/python:toolchain_type toolchain. As long as an
appropriate Python toolchain is registered for the target platform, uv targets
will build correctly regardless of how that toolchain was provisioned.
Example: Doing crossbuilds
The uv machinery honors the @platforms//cpu and @platforms//os constraint
settings, and will attempt to provide installations of libraries matching the
active constraint set.
In order to cope with various libcs and libc compatibility ranges, uv also has two internal config setting flags
--@aspect_rules_py//uv/private/constraints/platform:platform_libc=glibc
--@aspect_rules_py//uv/private/constraints/platform:platform_version=2.39
The platform_libc flag must be the name of a libc (eg. glibc, musl, libsystem,
...) and the platform_version flag must be the major.minor version of that
libc on the targeted system. This allows for users to specify that they're
crossbuilding from linux-glibc@2.40 to linux-musl@1.2 and such.
Crossbuilds can be accomplished simply by setting the --platform flag, or
using platform transitions.
load("@bazel_lib//lib:transitions.bzl", "platform_transition_filegroup")
load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_image_layer")
platform(
name = "arm64_linux",
constraint_values = [
"@platforms//os:linux",
"@platforms//cpu:aarch64",
],
# These flags must be reset to values appropriate for the target.
# Their default values are appropriate to the host.
flags = [
"--@aspect_rules_py//uv/private/constraints/platform:platform_libc=glibc",
"--@aspect_rules_py//uv/private/constraints/platform:platform_version=2.39",
],
)
py_binary(
name = "app_bin",
srcs = ["__main__.py"],
main = "__main__.py",
python_version = "3.12",
dep_group = "psql",
deps = [
"@pypi//psycopg2_binary",
],
)
# OCI layers containing the binary
py_image_layer(
name = "app_layers",
binary = ":app_bin",
)
# The layers reconfigured according to the specified platform
platform_transition_filegroup(
name = "arm64_layers",
srcs = [":app_layers"],
target_platform = ":arm64_linux",
)
Example: Constraining library compatibility
By default uv hubs let you write py_library and other targets which are
compatible with any dependency group providing all the needed requirements.
But sometimes you want a library to be incompatible with a dependency group; either because it depends on packages at versions below what are available in that dependency group or as part of an internal migration or for some other reason.
As a facility each hub's @<hub>//:defs.bzl provides a pair of helper macros
for generating appropriate target_compatible_with logics. These helpers return
case dicts which may either be manipulated or select()ed on.
load("@pypi//:defs.bzl", "compatible_with", "incompatible_with")
py_library(
name = "requires_prod",
srcs = ["foo.py"],
deps = ["@pypi//cowsay"],
# Allowlist
target_compatible_with = select(compatible_with(["prod"])),
)
py_library(
name = "not_in_prod",
srcs = ["foo.py"],
deps = ["@pypi//cowsay"],
# Allowlist
target_compatible_with = select(incompatible_with(["prod"])),
)
A mental model
@pypi # Your UV built hub repository
@pypi//requests:requests # The library for a requirement
@pypi//jinja2-cli/entrypoints:jinja2-cli # A requirement's declared entrypoint
This central hub wraps "spoke" internal dependency group repos. For instance if you have two
dependency groups "a" and "b", then each hub target for a requirement is a select() alias
over the dependency group targets in which that requirement is defined.
Hub requirement targets are incompatible with dependency group configurations in which the requirement in question is not defined.
Conditional dependencies and the empty target
When a package is gated entirely behind an environment marker (e.g.
iniconfig; sys_platform == 'win32'), the hub generates a select() alias
with one arm per marker expression plus a //conditions:default arm that
resolves to an internal empty py_library. This makes the select() total on
all platforms: on non-Windows hosts the alias selects the empty library, which
contributes no sources and no transitive deps, rather than failing analysis.
If you inspect the generated BUILD files under the hub repository you will see
targets named empty and empty_whl — these are intentional stubs and not a
sign of a missing dependency.
Each dependency group requirement is backed by a whl_install rule which chooses among
prebuilt wheels listed in the lockfile to produce the equivalent of a
py_library.
An sdist (if available) will be built into a wheel for installation if no wheels are available, or no wheels matching the target configuration are found. Sdist builds occur using the configured Python and Cc toolchains.
Declaring source-built console scripts
Downloaded wheels expose their console scripts while repositories are generated. A wheel built from an sdist does not exist until execution, after analysis has already assembled console-script wrappers. Declare the complete, nonempty script map on that package's override:
uv.override_package(
lock = "//:uv.lock",
name = "example-package",
console_scripts = {
"example": "example_package.cli:main",
},
)
The optional version on override_package follows the existing override
rules: omit it when the lock resolves one version of the package. The
declaration is attached only to the source-build select arm. If a prebuilt
wheel is selected instead, its inspected metadata remains authoritative. The
package layout remains unknown at analysis time, so the complete source-built
wheel still participates in the normal .pth fallback.
Best practices
Consolidate your hubs. In rules_python, environments with multiple depsets
needed to make multiple pip.parse() calls each of which created a hub. This
created the problem of transitive depset inconsistency (this target uses deps
from this hub but depends on a library that uses deps from elsewhere).
By using single hub throughout your repository and leaning on dependency group configuration to choose the right one at the right point in time, your dependency management gets a lot easier and your builds become internally consistent.
Only use one hub. The hub name is configurable in order to accommodate
whatever your existing pip.parse may be called, but there's no reason to use
more than one hub within a single repository. Each dependency set should be
registered as a separate dependency group within the same hub.
Gazelle integration
If you use Gazelle with the
aspect-gazelle Python
extension, you need a gazelle_python.yaml manifest that maps Python import
names to their PyPI package names. The gazelle_python_manifest rule generates
this file from your locked wheels.
# BUILD.bazel (typically at the workspace root)
load("@aspect_rules_py//uv:defs.bzl", "gazelle_python_manifest")
gazelle_python_manifest(
name = "gazelle_python_manifest",
hub = "pypi",
venvs = ["default"],
)
Parameters:
hub— The name of your uv hub (must matchuv.declare_hub(hub_name = ...)).venvs— List of dependency group names whose wheels should be indexed. Module mappings from all listed dependency groups are merged into a single manifest.
This creates two targets:
:gazelle_python_manifest— builds the manifest YAML:gazelle_python_manifest.update— copies the built manifest into your source tree
To generate or refresh the manifest:
bazel run //:gazelle_python_manifest.update
This writes gazelle_python.yaml next to the BUILD file. Commit it to your
repository so that Gazelle can resolve Python imports without rebuilding the
manifest on every invocation.
If you have multiple dependency groups with different dependency sets, list them all to produce a complete mapping:
gazelle_python_manifest(
name = "gazelle_python_manifest",
hub = "pypi",
venvs = ["web", "ml", "cli"],
)
Troubleshooting
Verbose logging for uv repository rules
To diagnose issues with source distribution (sdist) builds or git archive fetching,
set the RULES_PY_UV_VERBOSE environment variable to any non-empty value:
bazel build --repo_env=RULES_PY_UV_VERBOSE=1 //...
You can also set it in your .bazelrc to apply to all builds:
# .bazelrc
common --repo_env=RULES_PY_UV_VERBOSE=1
When enabled, rules_py prints additional diagnostic information during
repository rule execution, including:
- Sdist inspection failures — output from the sdist configure tool when it fails.
- Missing archives — warnings when an archive path cannot be resolved from a source label.
- Native source detection — confirmation when native (non-Python) sources are detected in an sdist.
- Pure-Python fallback — warnings when an sdist cannot be inspected and a pure-Python build is assumed.
- Git archive commands — the exact
gitcommand executed and its stdout/stderr. - Package version overrides — confirmation when a package version is overridden with a local target.
Differences and Gotchas
Lock your build tools. In order to perform sdist builds and support
libraries which are packaged only as sdists (which is more common than you'd
think) uv needs a Python build tool to use. Uv currently uses setuptools and
build, both of which must be installed in your lock solution. You may
encounter configuration errors if these tools would be required and are not
available.
No default dependency group? In order to implement the dep_group= transition on py_binary
et. all, the dep_group flag has to be statically known. This means we get one global
"current dependency group" flag, no matter how many hubs you have.
It only really makes sense to use the --@pypi//dep_group=default flag as part of
your .bazelrc, because then the scope of where that default is applied is well
bounded to your repository with your hub.
We could allow the _main repository to set a default dependency group name, but the
semantics are weird if the _main repository defines more than one hub. Which
is poor practice but possible. So rather than have weird behavior we don't
support this.
What's with annotations? The uv.lock format is great, but it's missing
some key information. Such as what requirements apply when performing sdist
builds. Annotations are the current workaround for how to associate such
required but nonstandardized and missing dependency data with requirements.
Set native = true|false on a package annotation to override automatic sdist
native detection and select the native or pure-Python wheel build path.
Why aren't entrypoints automatically created? Downloaded-wheel entrypoints
are discovered while repositories are generated. Source-built wheels do not
exist until execution, so declare their console scripts with
uv.override_package(console_scripts = {...}).
If you need a given entrypoint as a Bazel target, it needs to be manually
declared. In most cases of normal entrypoints this is quite easy. Tools like
ruff which distribute binaries as "wheels" are tricky and not yet supported.
Acknowledgements
-
Jeremy Volkman's
rules_pycrossis in a direct precursor and inspiration for this tool. They use the same strategy, uv is just able to leverage an off the shelf lockfile format which postdates Jeremy's efforts. -
Richard Levasseur and Ignas Anikevicius of
rules_pythonhave been great collaborators and good sports in my treating therules_pythonauthors meeting as the bazel-python-sig. Ignas in particular created the marker evaluation code which makes uv's conditional dependency activation possible, and Richard provided the example for programmable constraints with flags.
This work was made possible by support from Physical Intelligence, the RAI Institute and others to whom we're grateful.