Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 86 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,20 @@ Built-in rules for creating optimized container images:
bazel_dep(name = "aspect_rules_py", version = "1.11.2")
```

### Requirements

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](https://peps.python.org/pep-0703/) | 3.13+ |
| `pyproject.toml` parsing in sdist native-dependency detection (needs stdlib `tomllib`) | 3.11+ |

### Quick Start

Load rules from `aspect_rules_py` in your `BUILD` files:
Expand Down Expand Up @@ -164,6 +178,75 @@ py_test(
)
```

### First-party bytecode

`py_binary` and `py_test` accept `pyc = "source" | "pyc" | "pyc_only"`
(a `select()` value is also accepted). The default is `source`, and may be
changed for inheriting targets with
`--@aspect_rules_py//py:pyc=source|pyc|pyc_only`; an explicit `pyc`
attribute pins the target's mode regardless of the flag.

- `source` packages first-party `.py` sources.
- `pyc` packages sources and PEP 3147 `__pycache__` bytecode.
- `pyc_only` packages colocated first-party `.pyc` files without their source.
Tracebacks then carry no source lines, and every first-party source must be
directly owned by a rules_py `py_*` target. A `.py` file also declared through
`data` remains available as source because explicit runtime data takes
precedence over source stripping.

Only `.py` files listed in a `py_*` target's `srcs` by their own file label
(checked-in or generated) are compiled. A `.py` file reached through a rule
target in `srcs` — a `filegroup`, a `genrule`, or another `py_library` —
stays in source form; `pyc_only` reports it as missing bytecode.

Only sources directly owned by a `py_*` target's package are compiled.
Files a target lists from another package have no bytecode: `pyc` mode
ships them as plain source, and `pyc_only` fails analysis listing them.

Dependencies built by rules_python rules (`py_proto_library`, pip hub
packages, unconverted `py_library` targets) are compiled by rules_py through
an aspect over `deps`. No rules_python `precompile` attribute or flag is
needed; with interpreters from the rules_py interpreter extension,
rules_python never precompiles. Bytecode rules_python does compile is reused.

Limits: a `*_pb2.py` is compiled only when its `py_proto_library` shares the
`proto_library`'s package; the protobuf runtime is runfiles data and stays
source; a rules_python `py_library` carries its sources in its own runfiles,
so under `pyc_only` they ship beside the bytecode until the library is
converted to rules_py.

Bytecode is compiled by an exec-platform interpreter of the target's
implementation/cache tag and feature version (major.minor; prereleases must
match exactly) when one is provisioned (the default with the
rules_py interpreter hub), so cross-platform builds work out of the box. With
toolchains not provisioned by rules_py (e.g. rules_python runtimes) the
target interpreter itself must be runnable on the build host, unless the
toolchain supplies a custom `pyc_compile_tool`.

Compilation runs one `PyCompile` action per source, served by a Bazel
persistent worker so the interpreter starts once per worker rather than per

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Benchmark the bytecode modes before drawing performance conclusions. The published analysis, startup, and image jobs never set the new pyc flag or attribute, so all three run the default source mode. They do not measure enabled compile cost, startup benefit, worker versus non-worker execution, or the new bytecode image traversal. Please benchmark source, pyc, and pyc_only on an import-heavy first-party graph, including cold builds and a one-source incremental rebuild; include the non-worker strategy rather than assuming the local worker represents remote execution. Published benchmark results.

-zbarskybot

file; size the pool with `--worker_max_instances=PyCompile=N`.

`bazel coverage` always runs `pyc_only` targets from sources so coverage.py
can instrument them.

Bytecode is compiled at optimization level 0. Under `pyc` an optimized
interpreter (`-O`/`-OO`, `PYTHONOPTIMIZE`) ignores the cache and runs from
source. Under `pyc_only` there is no source, so `-O`/`-OO`
`interpreter_options` and `PYTHONOPTIMIZE` set or inherited by the launcher
or its venv fail analysis; with `isolated = False` a `PYTHONOPTIMIZE` from the
invoking shell still runs the level-0 bytecode unoptimized.

`py_unittest_test` supports `pyc_only`. Because pytest collects `.py` source
files, `py_pytest_test` automatically falls back to `pyc` when `pyc_only` is
requested explicitly or through the global flag.

`py_image_layer` accepts the same `pyc` attribute as a plain string (a
transition reads it, so no `select()`); unset, it inherits the
`--@aspect_rules_py//py:pyc` flag. Binaries with an unset `pyc` attribute
follow the image's mode automatically; a binary whose explicit `pyc`
attribute disagrees with the image fails analysis.

## Dependency Resolution with `uv`

`aspect_rules_py//uv` is our alternative to `rules_python`'s `pip.parse`:
Expand Down Expand Up @@ -532,8 +615,9 @@ bazel run //:gazelle
1. **Swap the rules**: Load `py_binary`, `py_library`, `py_test` from `@aspect_rules_py//py:defs.bzl` instead of
`@rules_python//python:defs.bzl`
2. **Migrate dependencies**: Replace `pip.parse` with `uv.declare_hub` and generate a `uv.lock`
3. **Optionally migrate toolchains**: Replace `rules_python` interpreter provisioning with
the `aspect_rules_py` interpreter extension for fully independent hermetic interpreters
3. **Migrate toolchains**: Replace `rules_python` interpreter provisioning with
the `aspect_rules_py` interpreter extension; rules_py then also compiles
bytecode for the rules_python-built targets that remain

For detailed migration guidance, see [docs/migrating.md](docs/migrating.md).

Expand Down
10 changes: 7 additions & 3 deletions docs/api/py.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ workspace symlink in one step, set `expose_venv_link = True`.
| <a id="py_binary-name"></a>name | Name of the rule. | none |
| <a id="py_binary-srcs"></a>srcs | Python source files. | `[]` |
| <a id="py_binary-main"></a>main | Entry point. Like rules_python, this is treated as a suffix of a file that should appear among the srcs. If absent, then `[name].py` is tried. As a final fallback, if the srcs has a single file, that is used as the main.<br><br>Note: the fallback runs at macro-evaluation time and operates on label strings, not resolved files — it cannot inspect a generated target's output basename. If `main` would resolve to a file produced by another rule (e.g. a `genrule` whose output happens to be `<name>.py`), the macro can't see that and you must pass `main =` explicitly. | `None` |
| <a id="py_binary-kwargs"></a>kwargs | additional named parameters forwarded to the underlying rule and the sibling py_venv. Three extras are handled by this macro:<br><br>* `include_console_scripts` (bool, default `False`) — when `True`, the binary's runfiles include the venv's wheel-declared `bin/<name>` console-script wrappers so subprocesses can invoke them by name via `PATH`. Independent of `expose_venv`: the `.venv` target always carries wrappers for `bazel run`, the binary only with this flag. * `expose_venv` (bool, default `False`) — when `True`, emit a sibling `:{name}.venv` py_venv carrying all venv-shaping attrs (deps, imports, package_collisions, include_*_site_packages, interpreter_options). The `.venv` target is runnable (`bazel run :{name}.venv` drops into the hermetic interpreter). * `expose_venv_link` (bool, default `False`) — when `True`, additionally emit a `:{name}.venv_link` py_venv_link. `bazel run :{name}.venv_link` links the target's runfiles tree into the workspace and prints the nested venv path suitable for an IDE's interpreter setting. Implies `expose_venv = True`; passing `expose_venv = False, expose_venv_link = True` explicitly is rejected with a clear error. Equivalent to declaring an explicit `py_venv_link(name = "{name}.venv_link", venv = ":{name}.venv")` alongside the binary. | none |
| <a id="py_binary-kwargs"></a>kwargs | additional named parameters forwarded to the underlying rule and the sibling py_venv. Three extras are handled by this macro:<br><br>* `include_console_scripts` (bool, default `False`) — when `True`, the binary's runfiles include the venv's wheel-declared `bin/<name>` console-script wrappers so subprocesses can invoke them by name via `PATH`. Independent of `expose_venv`: the `.venv` target always carries wrappers for `bazel run`, the binary only with this flag. * `expose_venv` (bool, default `False`) — when `True`, emit a sibling `:{name}.venv` py_venv carrying all venv-shaping attrs (deps, imports, package_collisions, include_*_site_packages, interpreter_options). The `.venv` target is runnable (`bazel run :{name}.venv` drops into the hermetic interpreter). * `expose_venv_link` (bool, default `False`) — when `True`, additionally emit a `:{name}.venv_link` py_venv_link. `bazel run :{name}.venv_link` links the target's runfiles tree into the workspace and prints the nested venv path suitable for an IDE's interpreter setting. Implies `expose_venv = True`; passing `expose_venv = False, expose_venv_link = True` explicitly is rejected with a clear error. Equivalent to declaring an explicit `py_venv_link(name = "{name}.venv_link", venv = ":{name}.venv")` alongside the binary. * `pyc` (string) — first-party bytecode packaging. `"source"` ships only `.py` sources; `"pyc"` additionally ships PEP 3147 `__pycache__` bytecode; `"pyc_only"` ships colocated sourceless `.pyc` files (tracebacks then carry no source lines). Unset inherits the global `--@aspect_rules_py//py:pyc` flag; an explicit value pins the mode regardless of the flag. Configurable: a `select()` value is accepted. Bytecode compilation requires an executable, bytecode-compatible target interpreter, and `"pyc_only"` requires every first-party source to be directly owned by a rules_py `py_*` target; other first-party sources ship as source under `"pyc"`. A `.py` file also declared through `data` remains source because explicit runtime data takes precedence over source stripping. Only `.py` files listed in `srcs` by their own file label are compiled; files reached through a rule target in `srcs` (filegroup, genrule, py_library) stay source. `bazel coverage` always runs `"pyc_only"` targets from sources so coverage.py can instrument them. `"pyc_only"` rejects `-O`/`-OO` interpreter options and `PYTHONOPTIMIZE` settings because its level-0 bytecode has no source to fall back to; `"pyc"` runs from source instead. | none |


<a id="py_image_layer"></a>
Expand All @@ -476,8 +476,8 @@ load("@aspect_rules_py//py:defs.bzl", "py_image_layer")

py_image_layer(<a href="#py_image_layer-name">name</a>, <a href="#py_image_layer-binary">binary</a>, <a href="#py_image_layer-groups">groups</a>, <a href="#py_image_layer-group_execution_requirements">group_execution_requirements</a>, <a href="#py_image_layer-group_compress_levels">group_compress_levels</a>,
<a href="#py_image_layer-group_compression">group_compression</a>, <a href="#py_image_layer-group_compressors">group_compressors</a>, <a href="#py_image_layer-allow_non_oci_layers">allow_non_oci_layers</a>,
<a href="#py_image_layer-warn_remote_cache_threshold_mb">warn_remote_cache_threshold_mb</a>, <a href="#py_image_layer-warn_layer_count">warn_layer_count</a>, <a href="#py_image_layer-platform">platform</a>, <a href="#py_image_layer-layer_tier">layer_tier</a>, <a href="#py_image_layer-launcher_dir">launcher_dir</a>,
<a href="#py_image_layer-binaries">binaries</a>, <a href="#py_image_layer-kwargs">**kwargs</a>)
<a href="#py_image_layer-warn_remote_cache_threshold_mb">warn_remote_cache_threshold_mb</a>, <a href="#py_image_layer-warn_layer_count">warn_layer_count</a>, <a href="#py_image_layer-platform">platform</a>, <a href="#py_image_layer-layer_tier">layer_tier</a>, <a href="#py_image_layer-pyc">pyc</a>,
<a href="#py_image_layer-launcher_dir">launcher_dir</a>, <a href="#py_image_layer-binaries">binaries</a>, <a href="#py_image_layer-kwargs">**kwargs</a>)
</pre>

Create OCI-compatible tars from one or more py_binary targets.
Expand Down Expand Up @@ -517,6 +517,7 @@ or pin a tier to a specific rule via the `py_layer_tier` attr below.
| <a id="py_image_layer-warn_layer_count"></a>warn_layer_count | Warn when total layers exceed this. Default: 90. | `90` |
| <a id="py_image_layer-platform"></a>platform | Platform transition target. | `None` |
| <a id="py_image_layer-layer_tier"></a>layer_tier | Optional py_layer_tier target pinned for this rule. Sets the `@aspect_rules_py//py:layer_tier` label_flag via the rule transition, overriding any command-line value for this rule's subgraph. | `None` |
| <a id="py_image_layer-pyc"></a>pyc | First-party bytecode mode for the image: "source", "pyc", or "pyc_only". Sets the `@aspect_rules_py//py:pyc` flag via the rule transition, so a `select()` is not accepted; empty (the default) inherits the flag's value. Binaries with an unset `pyc` attribute follow it automatically; a binary whose explicit `pyc` attribute disagrees fails analysis. | `""` |
| <a id="py_image_layer-launcher_dir"></a>launcher_dir | Absolute image directory for the binary launchers. Defaults to /app/bin with multiple binaries. Set RUNFILES_DIR=/app.runfiles in the image. | `""` |
| <a id="py_image_layer-binaries"></a>binaries | Alternative to binary. A nonempty list of py_binary targets to include in the image. | `None` |
| <a id="py_image_layer-kwargs"></a>kwargs | Forwarded to inner rule. | none |
Expand Down Expand Up @@ -568,6 +569,9 @@ Pytest is always the driver, so the entrypoint wiring is unambiguous.
Include the `pytest` package (and `coverage`, if you want coverage) in
`deps`.

Because pytest collects `.py` source paths, `pyc_only` requests fall back
to source-retaining `pyc` mode for these targets.

Every file in `srcs` is a test module that pytest collects (scoped to the
target, not the whole runfiles tree). Put importable support code in `deps`
and pytest's `conftest.py` in `data`; to select tests by name pattern, use
Expand Down
13 changes: 8 additions & 5 deletions docs/interpreter.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ are discovered automatically from PBS release artifacts and cached in your
it — no repinning, no manifest regeneration.

**No editorial decisions.** We don't decide which Python versions you can use.
Any version published in a PBS release is available. Need Python 3.8? Add an
older release date that includes it.
Any version published in a PBS release is available. Need a version that newer
releases dropped? Add an older release date that includes it. Note that the
rules themselves require Python 3.10 or newer at runtime (see
[Requirements](../README.md#requirements)).

**Windows and cross-platform support.** 9 platforms are registered out of the
box, including Windows (x86_64, aarch64, i686), Linux (glibc and musl), and
Expand Down Expand Up @@ -65,7 +67,7 @@ interpreters.configure(
)

interpreters.toolchain(python_version = "3.12")
interpreters.toolchain(python_version = "3.8") # Resolved from 20241002
interpreters.toolchain(python_version = "3.10") # Resolved from 20241002 once newer releases drop it

use_repo(interpreters, "python_interpreters")
register_toolchains("@python_interpreters//:all")
Expand Down Expand Up @@ -316,8 +318,9 @@ This interpreter provisioning is designed to coexist with `rules_python`:
falls back to the hub's highest provisioned version — including the hub
rules_py itself registers, so this resolves even in modules that provision
interpreters only through `rules_python`'s `python.toolchain()`. rules_py
registers nothing under `rules_python`'s exec-tools type, leaving it —
including precompiling — entirely to `rules_python`.
registers nothing under `rules_python`'s exec-tools type; with interpreters
from `interpreters.toolchain()` alone, `rules_python` never precompiles and
rules_py compiles bytecode for `rules_python`-built dependencies itself.

Note that runtimes provisioned by `interpreters.toolchain()` carry
`rules_python`'s public `PyRuntimeInfo` (re-exported from
Expand Down
8 changes: 8 additions & 0 deletions docs/migrating.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ providers. Temporary scaffolding: [virtual deps](/docs/virtual_deps.md) are not
expressible in those providers (resolve them concretely in `deps`), and the
flag belongs in `.bazelrc` only until the last rules_python target is gone.

## Bytecode for unconverted targets

rules_py's `pyc` modes compile dependencies still built by rules_python rules
(`py_proto_library`, pip hub packages, unconverted `py_library` targets)
itself. Set no rules_python `precompile` attribute or flag. Under `pyc_only`
a rules_python `py_library` still ships its sources from its own runfiles;
converting it to rules_py's `py_library` is the fix.

## Remaining notes

Users are encouraged to send a Pull Request to add more documentation as they uncover issues during migrations.
6 changes: 3 additions & 3 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ workspace above must not carry: its `.bazelrc` turns on the rules_python provide
compatibility layer, so rules_python `py_*` targets can depend on a rules_py `py_library`.
Its `test.sh` asserts the same dependency is rejected with the flag off.

`rules-python-protobuf` exercises rules_proto_grpc_python-generated bindings in
an isolated module so its rules_python/protobuf/grpc dependency graph does not
leak into the main test module.
`rules-python-protobuf` contains protobuf's native `py_proto_library` and
rules_proto_grpc_python consumer tests. Keeping both generators here prevents
their rules_python/protobuf dependency graph from leaking into the main test module.

`crossbuild` covers `pep517_native_whl`'s cross-compilation path across the
PEP 517 backends, each with more than one real package so no backend's cross
Expand Down
12 changes: 12 additions & 0 deletions e2e/cases/coverage-drivers/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ py_pytest_test(
],
)

py_unittest_test(
name = "coverage_pyc_only_test",
srcs = ["cov_add_unittest_test.py"],
dep_group = "coverage-drivers",
imports = ["."],
pyc = "pyc_only",
deps = [
":lib",
"@pypi_coverage_drivers//coverage",
],
)

# Baked pytest_args route py_pytest_test through its codegen branch, which
# renders a per-test copy of pytest_main.py (via py_pytest_main) instead of the
# shared main. Confirms the coverage teardown survives that template rendering.
Expand Down
1 change: 1 addition & 0 deletions e2e/cases/coverage-drivers/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,6 @@ check_coverage //coverage-drivers:coverage_pytest_test bazel-testlogs/coverage-d
check_coverage //coverage-drivers:coverage_pytest_codegen_test bazel-testlogs/coverage-drivers/coverage_pytest_codegen_test/coverage.dat
check_coverage //coverage-drivers:coverage_pytest_chdir_test bazel-testlogs/coverage-drivers/coverage_pytest_chdir_test/coverage.dat
check_coverage //coverage-drivers:coverage_unittest_test bazel-testlogs/coverage-drivers/coverage_unittest_test/coverage.dat
check_coverage //coverage-drivers:coverage_pyc_only_test bazel-testlogs/coverage-drivers/coverage_pyc_only_test/coverage.dat

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Check that the new coverage regression actually records a hit for foo.py. The reused helper checks for an SF:...foo.py anywhere and a nonzero DA anywhere, not in that file’s record. An LCOV file with only DA:1,0 for foo.py and DA:1,1 for an unrelated file passes both exact checks. That means the new pyc_only coverage test can go green when coverage of the shared Python dependency is lost, even though that is the behavior this test promises to protect. Scope the nonzero-line assertion to the foo.py record ending at end_of_record; no extra fixture matrix is needed.

-zbarskybot


echo "All coverage driver checks passed."
Loading
Loading