[!NOTE] This repository uses the Aspect CLI for CI and local development. See the docs and install instructions to get started.
[!WARNING] This is the 2.x ALPHA branch. APIs and behavior may change without notice. For stable documentation, see the 1.x branch.
aspect_rules_py is a high-performance alternative to rules_python, the
reference Python ruleset for Bazel.
It provides drop-in replacements for py_binary, py_library, and py_test that prioritize:
uv integrationsite-packages symlink treesaspect_rules_py optimizes for modern Python development workflows, large-scale monorepos, and Remote Build Execution (
RBE) environments.
rules_python| Feature | rules_python | rules_py |
|---|---|---|
| Dependency resolution | pip.parse (repo rules, loading phase) |
Build-action wheel installs (whl_install) |
| uv integration | uv pip compile → requirements.txt → pip.parse |
Native uv.lock consumption |
| Cross-platform lockfile | requirements.txt (uv.lock via uv pip compile) |
Native single uv.lock consumption |
| sdist / PEP 517 builds | Not supported (#2410, open since Nov 2024) | Build actions (pep517_whl, pep517_native_whl) |
| Interpreter provisioning | Download via rules_python extension | Own python-build-standalone extension — no rules_python required |
| Site-packages layout | Standard site-packages layout (flag-enabled) |
Standard site-packages symlink tree |
| Cross-compilation | Limited | Native platform transitions (e.g. arm64 image on amd64 host) |
| Virtual dependencies | No | virtual_deps — swap implementations at binary level |
| PEP 735 dependency groups | No | --@pypi//dep_group=prod flag |
[!NOTE] rules_python's uv support:
rules_python's uv integration runsuv pip compileas a build action to generate arequirements.txt—it is a fasterpip-compilereplacement. The result still feeds intopip.parse()→whl_libraryrepository rules at loading phase. There is nouv.lockconsumption; the rules_python maintainer has suggested this work belongs in a dedicated project.
uv.lock Dependency ResolutionInstead of relying on legacy pip machinery, we provide native integration with uv,
a Rust-native Python package resolver.
aarch64 wheels on a macOS x86_64 host)uv.lock parsing: Consumes uv.lock directly; no requirements.txt generation stepuv.lock works across all platformsprod, dev, test dependency groups and switch between them with a flagpy_library targets via uv.override_package()aspect_rules_py ships its own python-build-standalone
interpreter extension—rules_python is not required as a toolchain provider.
[!NOTE] The
//pyand//uvextension paths provide stable APIs for interpreter provisioning and dependency resolution. They graduated from//py/unstableand//uv/unstablein rules_py v2.0.0.
interpreters = use_extension("@aspect_rules_py//py:extensions.bzl", "python_interpreters")
interpreters.toolchain(python_version = "3.12")
use_repo(interpreters, "python_interpreters")
register_toolchains("@python_interpreters//:all")
# .bazelrc
common --@aspect_rules_py//py:python_version=3.12
This enables cross-compilation from any host to any target without host-installed Python, and is the foundation for correct toolchain selection in RBE environments.
site-packages LayoutWe do not manipulate sys.path or $PYTHONPATH. Instead, we generate a standard site-packages directory structure
using symlink trees:
collections vs. a transitive dependency named collections)-I flag, preventing implicit loading of user site-packages or host
environment variablesamd64 and arm64 container imagesvirtual_deps allow external Python dependencies to be specified by package name rather than by label:
cowsnake instead of cowsay)Built-in rules for creating optimized container images:
py_image_layer: Creates layered tar files compatible with rules_ocipy_pytest_testpytest-mock, pytest-xdist, and other pluginsbazel_dep(name = "aspect_rules_py", version = "1.11.2")
The minimum supported Python version is 3.10. The launcher, test runners, and build
tools that run under your configured interpreter use 3.10 syntax, and CI only exercises
3.10 and newer. Older interpreters can still be fetched via interpreters.configure(),
but py_binary and py_test targets will fail at startup on them.
Some uv features need newer versions:
| Feature | Python |
|---|---|
Free-threaded interpreters (freethreaded = True), first shipped in CPython 3.13 |
3.13+ |
pyproject.toml parsing in sdist native-dependency detection (needs stdlib tomllib) |
3.11+ |
Load rules from aspect_rules_py in your BUILD files:
load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_library", "py_test")
py_library(
name = "lib",
srcs = ["lib.py"],
deps = ["@pypi//requests"],
)
py_binary(
name = "app",
srcs = ["main.py"],
main = "main.py",
deps = [":lib"],
)
py_test(
name = "test",
srcs = ["test.py"],
deps = [":lib"],
)
uvaspect_rules_py//uv is our alternative to rules_python's pip.parse:
uv = use_extension("@aspect_rules_py//uv:extensions.bzl", "uv")
# 1. Declare a hub (a shared dependency namespace)
uv.declare_hub(
hub_name = "pypi",
)
# 2. Register projects (lockfiles) into the hub
uv.project(
hub_name = "pypi",
lock = "//:uv.lock",
pyproject = "//:pyproject.toml",
# Build tools injected for sdist packages that need them (e.g. maturin, setuptools)
default_build_dependencies = ["build", "setuptools"],
)
# 3a. (Optional) Replace a package with a local Bazel target
uv.override_package(
name = "some_package",
lock = "//:uv.lock",
target = "//third_party/some_package",
)
# 3b. (Optional) Patch an installed wheel's file tree after unpacking
uv.override_package(
name = "some_other_package",
lock = "//:uv.lock",
post_install_patches = ["//third_party/patches:fix_some_other_package.patch"],
)
# 3c. (Optional) Remove bundled tests or other unused wheel content
uv.override_package(
name = "another_package",
lock = "//:uv.lock",
exclude_glob = ["another_package/**/tests/**"],
)
# 3d. (Optional) Restrict a package and its dependents to test targets
uv.override_package(
name = "pytest-postgresql",
lock = "//:uv.lock",
testonly = True,
)
use_repo(uv, "pypi")
Requirements are declared in standard pyproject.toml:
[project]
name = "myapp"
version = "1.0.0"
requires-python = ">= 3.11"
dependencies = [
"requests>=2.28",
"pydantic>=2.0",
]
[dependency-groups]
dev = ["pytest", "black", "mypy"]
Generate the lockfile with uv:
uv lock
Switch between dependency groups:
# Default: use all dependencies
bazel run //:app
# Use only production dependencies
bazel run //:app --@pypi//dep_group=prod
Declare virtual dependencies in libraries:
py_library(
name = "greet_lib",
srcs = ["greet.py"],
virtual_deps = ["cowsay"], # Not a label—just a package name
)
Resolve them in binaries:
py_binary(
name = "app",
srcs = ["main.py"],
deps = [":greet_lib"],
resolutions = {
"cowsay": "@pypi//cowsay",
},
)
# Or use a different implementation!
py_binary(
name = "app_snake",
srcs = ["main.py"],
deps = [":greet_lib"],
resolutions = {
"cowsay": "//cowsnake", # Swapped implementation
},
)
Build optimized OCI images with layer caching:
load("@aspect_rules_py//py:defs.bzl", "py_binary", "py_image_layer")
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load")
py_binary(
name = "app_bin",
srcs = ["main.py"],
deps = ["//:lib"],
)
py_image_layer(
name = "app_layers",
binary = ":app_bin",
)
oci_image(
name = "image",
base = "@ubuntu",
tars = [":app_layers"],
entrypoint = ["/app/app_bin"],
)
oci_load(
name = "image_load",
image = ":image",
repo_tags = ["myapp:latest"],
)
Cross-compile for Linux from macOS:
bazel build //:image --platforms=//platforms:linux_amd64
Layers are written by bsdtar, so any libarchive write filter can compress them:
none, gzip (the default, level 6), bzip2, xz, lzma, lzop, lz4,
lrzip, zstd, and compress. The file extension follows the filter, which is
what image tooling reads the layer's media type from.
The OCI image spec only defines tar, +gzip and +zstd layers, so those
are the three you can put in an image. rules_oci identifies a layer's
compression by sniffing its magic; a codec it cannot sniff is labelled an
uncompressed tar and keeps the compressed digest as its diffid, which builds
successfully and produces an invalid image. py_image_layer and py_layer_tier
therefore reject the other filters unless you set allow_non_oci_layers = True,
which declares that the tars are going somewhere other than an OCI image.
py_layer_tier sets compression for the layers it names — pip packages, the
interpreter, and first-party groups:
load("@aspect_rules_py//py:defs.bzl", "py_layer_tier")
py_layer_tier(
name = "tier",
groups = {
"@pip//torch": "heavy",
"//src/common": "common",
},
interpreter_group = "interpreter",
compression = {
"heavy": ["zstd", "19"], # [algorithm, level]
"common": ["gzip"], # level omitted: libarchive's default
"interpreter": ["none"], # an uncompressed layer
},
)
py_image_layer sets compression for the layers it creates itself — the
groups tars, the squashed pip layer ("packages"), and the source layer
("default") — and takes precedence over the tier for a group both name:
py_image_layer(
name = "app_layers",
binary = ":app_bin",
layer_tier = ":tier",
group_compression = {"default": ["zstd", "3"]},
)
For a different implementation of a codec — pigz, a tuned zstd — declare a
py_layer_compressor. bsdtar pipes the archive through the program, so anything
that reads stdin and writes compressed bytes to stdout works. The extension is
how you declare what it emits, and it decides whether the result is OCI-valid:
load("@aspect_rules_py//py:defs.bzl", "py_layer_compressor")
py_layer_compressor(
name = "pigz",
tool = "//tools:pigz",
args = ["-11"],
extension = ".tar.gz", # gzip bytes, so a valid OCI layer
)
py_layer_tier(
name = "tier",
groups = {"@pip//torch": "heavy"},
compressors = {":pigz": "heavy"},
)
A compressor declaring anything other than .tar, .tar.gz or .tar.zst is
treated as non-OCI and needs allow_non_oci_layers = True — the program's bytes
are opaque, so the extension is the only claim available about what an image
consumer would find.
py_image_layer accepts the same mapping as group_compressors for its own
tars. Note that lzop and lrzip are the two filters libarchive implements by
shelling out to a same-named binary, which a sandboxed action is not guaranteed
to have — prefer a py_layer_compressor there.
aspect_rules_py generates standard virtualenv structures that IDEs understand.
In v2.0 the venv targets that the v1.x docs auto-emitted alongside every
py_binary are opt-in — declare them on the targets you actually want your
IDE to follow.
The recommended one-liner:
load("@aspect_rules_py//py:defs.bzl", "py_binary")
py_binary(
name = "my_app",
srcs = ["main.py"],
main = "main.py",
expose_venv_link = True, # publishes :my_app.venv + :my_app.venv_link
)
expose_venv_link = True emits two sibling targets:
bazel run //:my_app.venv — drops you into the hermetic interpreter REPL
with the venv activated. Useful for ad-hoc Python sessions matching your
binary's deps.bazel run //:my_app.venv_link — materialises a workspace-local symlink
pointing at the target's complete runfiles tree and prints the venv's nested
path below that link. Point your IDE at the printed venv path.Then point your IDE to the virtualenv path printed by the command:
python.defaultInterpreterPath to the printed pathpython-lsp-server or pyright to use the virtualenvexpose_venv_link = True is sugar for the explicit two-target shape:
py_binary(
name = "my_app",
srcs = ["main.py"],
main = "main.py",
expose_venv = True,
)
py_venv_link(
name = "my_app.venv_link",
venv = ":my_app.venv",
)
Reach for the explicit form when you want to customise py_venv_link's
link_name, point it at a standalone py_venv (declared independently
of any binary, useful for an IDE-only environment), or selectively skip
the link target on a subset of binaries.
Migrating from v1.x?
py_binaryno longer auto-emits a.venvsibling. Addexpose_venv_link = Truefor the equivalent IDE-symlink behavior, or use the explicit two-target form when you want fine-grained control. The workspace link now points to the complete runfiles tree, so update IDE, shell, direnv, and automation paths such as.venv/binto use the nested virtualenv path printed bybazel run :<name>.venv_link.
Attach DAP-compatible debuggers (VSCode, PyCharm, Neovim, etc.) using
debugpy. This requires a wrapper
entrypoint that starts a debugpy listener before running your application —
simply adding debugpy to deps is not enough.
See the complete debugger example for a working
setup, including a py_debuggable_binary macro that handles the wrapper
generation automatically.
Quick overview:
cd examples/debugger
# Start with debugpy listener, wait for IDE to attach:
DEBUGPY_WAIT=1 bazel run //:app
# Release mode — no debugpy, runs directly:
bazel run //:app --config=release
VSCode launch.json:
{
"name": "Attach to Bazel py_binary",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 5678
}
}
Generate BUILD files automatically with the Gazelle extension:
# MODULE.bazel
bazel_dep(name = "gazelle", version = "0.42.0")
bazel_dep(name = "aspect_rules_py", version = "1.11.2")
# In your BUILD file
# gazelle:map_kind py_library py_library @aspect_rules_py//py:defs.bzl
# gazelle:map_kind py_binary py_binary @aspect_rules_py//py:defs.bzl
# gazelle:map_kind py_test py_test @aspect_rules_py//py:defs.bzl
# Generate BUILD files
bazel run //:gazelle
[!NOTE] For pytest suites, use
py_pytest_test(always drives pytest) instead of the genericpy_test. Because Gazelle can't infer thepytestdependency for assert-only tests, mappy_testto a thin wrapper that injects it rather than mapping directly — see docs/test-drivers.md.py_pytest_test/py_unittest_testrequire a release that includes them (later than the1.11.2pinned above).
rules_pythonaspect_rules_py is designed for incremental adoption:
py_binary, py_library, py_test from @aspect_rules_py//py:defs.bzl instead of
@rules_python//python:defs.bzlpip.parse with uv.declare_hub and generate a uv.lockrules_python interpreter provisioning with
the aspect_rules_py interpreter extension for fully independent hermetic interpretersFor detailed migration guidance, see docs/migrating.md.
uv| Layer | Implementation | Description |
|---|---|---|
| Toolchains | @aspect_rules_py//py |
Own python-build-standalone interpreter provisioning; @rules_python optional |
| Resolution | @aspect_rules_py//uv |
Fast, lockfile-backed dependency resolution with uv |
| Execution | @aspect_rules_py//py |
Drop-in replacements for py_binary, py_library, py_test with sandbox isolation |
| Generation | aspect-gazelle |
Pre-compiled Gazelle extension—no CGO toolchain required |
Apache 2.0 - see LICENSE
More compatible Bazel rules for running Python tools and building Python projects
@aspect-build/rules_py2.0.0-alpha.7 +1.1mo2026-09-29 | |
1.12.1 +4d2026-08-26 | |
1.12.0 +10d2026-08-21 | |
2.0.0-alpha.6 +19d2026-08-11 | |
2.0.0-alpha.5 +25d2026-07-22 | |
2.0.0-alpha.4 +11d2026-06-27 | |
2.0.0-alpha.3 +2d2026-06-16 | |
1.11.7 +22d2026-06-13 | |
2.0.0-alpha.1 +16d2026-05-22 | |
1.11.2 +21h2026-04-07 | |
1.11.1 +20h2026-04-06 | |
1.10.1 +16d2026-04-05 | |
1.10.0 +8d2026-03-20 | |
1.9.1 +13h2026-03-11 | |
1.9.0 +1d2026-03-11 | |
1.8.6 +3d2026-03-09 | |
1.8.5 +1.6mo2026-03-05 | |
1.8.3 +2.8mo2026-01-15 | |
1.6.5 +5d2025-10-15 | |
1.6.4-rc2 +14d2025-09-24 | |
1.6.4-rc1 +24d2025-09-09 | |
1.6.3 +1d2025-08-16 | |
1.6.2 +20d2025-08-14 | |
1.6.1 +1.4mo2025-07-24 | |
1.6.0 +20h2025-06-10 | |
1.6.0-rc0 +9d2025-06-09 | |
1.5.2 +9h2025-05-31 | |
1.5.1 +1d2025-05-30 | |
1.5.0 +19d2025-05-28 | |
1.4.0 +14d2025-05-09 | |
1.3.4 +3h2025-04-24 | |
1.3.3 +2.3mo2025-04-24 | |
1.3.2 +14d2025-02-13 | |
1.3.1 +22h2025-01-29 | |
1.3.0 +4d2025-01-28 | |
1.2.1 +11h2025-01-24 | |
1.2.0 +6d2025-01-24 | |
1.1.0 +2.1mo2025-01-17 | |
1.0.0-rc1 +21d2024-11-01 | |
1.0.0-rc0 +6d2024-10-11 | |
0.9.1 +2d2024-10-04 | |
0.8.2 +16h2024-10-02 | |
0.8.1 +26d2024-10-02 | |
0.8.0 +1.4mo2024-09-05 | |
0.7.4 +2.8mo2024-07-23 | |
0.7.2 +1.8mo2024-04-26 | |
0.4.0 +4.6mo2023-10-19 | |
0.3.02023-06-02 |