Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1909697
test(coverage): check the coverage collection with the bundled covera…
aignas Nov 7, 2026
e0351f6
fix: register the coverage dev toolchain with a full python version
aignas Nov 7, 2026
088bce4
test(coverage): check the covered and not covered lines in separate t…
aignas Nov 7, 2026
62be724
feat(toolchain): allow using a py_library as the coverage_tool
aignas Nov 8, 2026
18e6ef7
fix: use the extracted wheel files instead of a py_library
aignas Nov 8, 2026
6b8bbf1
Merge remote-tracking branch 'origin/aignas.test.coverage-collection'…
aignas Nov 8, 2026
08d2743
build(dev): add coverage to the dev dependencies
aignas Nov 8, 2026
0e78f1d
test(coverage): check the coverage collection with coverage from a pi…
aignas Nov 8, 2026
2f48dcb
test(coverage): simplify the coverage collection test setup
aignas Nov 8, 2026
3304042
test(coverage): make collect_code_coverage a bool py_reconfig attr
aignas Nov 8, 2026
4c7e034
Merge remote-tracking branch 'origin/aignas.test.coverage-collection'…
aignas Nov 8, 2026
924c4a8
test(coverage): use the bool collect_code_coverage for instrumented_pypi
aignas Nov 8, 2026
e388a75
docs: link the coverage_tool doc to the coverage docs
aignas Nov 8, 2026
35e08ea
test(coverage): use pytest_test for the coverage collection test
aignas Nov 8, 2026
cb1632f
Merge branch 'aignas.test.coverage-collection' into aignas.feat.cover…
aignas Nov 8, 2026
c9b66df
test(coverage): use pytest_test for the coverage from PyPI test
aignas Nov 8, 2026
a7894e2
Merge branch 'main' into aignas.feat.coverage-tool-py-library
aignas Nov 9, 2026
79d9d12
Merge branch 'main' into aignas.feat.coverage-tool-py-library
aignas Nov 9, 2026
197fcce
fix(toolchain): build the coverage_tool without the toolchain coverag…
aignas Nov 9, 2026
6f653a3
feat(toolchain): import the coverage package using the PyInfo imports
aignas Nov 9, 2026
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
20 changes: 20 additions & 0 deletions MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,26 @@ dev_python.toolchain(
python_version = "3.12.16",
)

# For testing the coverage collection with the coverage tool from a PyPI hub.
# See //tests/coverage:coverage_from_pypi_test
dev_python.toolchain(
python_version = "3.13.13",
)

[
dev_python.single_version_platform_override(
coverage_tool = "@dev_pip//coverage",
platform = platform,
python_version = "3.13.13",
)
for platform in [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
]
]

# For testing an arbitrary runtime triggered by a custom flag.
# See //tests/toolchains:custom_platform_toolchain_test
dev_python.single_version_platform_override(
Expand Down
2 changes: 2 additions & 0 deletions dev/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ dependencies = [
"sphinx_rtd_theme >=2.0", # uv insists on downgrading for some reason
"readthedocs-sphinx-ext",
"absl-py",
# Used to test the coverage collection with coverage from a PyPI hub.
"coverage",
"typing-extensions",
"sphinx-reredirects",
"pefile",
Expand Down
264 changes: 264 additions & 0 deletions dev/uv.lock

Large diffs are not rendered by default.

33 changes: 31 additions & 2 deletions docs/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,35 @@ filegroup(
Using `filegroup` isn't required, nor are including client libraries. The
important behaviors of the target are:

* It provides a single output file OR it provides an executable output; this
output is treated as the coverage entry point.
* It provides {obj}`PyInfo` (e.g. it is a `py_library`), in which case its
import paths are used to import the `coverage` package. See
{ref}`coverage-from-pypi`.
* Otherwise, it provides a single output file OR it provides an executable
output; this output is treated as the coverage entry point.
* If it provides runfiles, then `runfiles.files` are included into `py_test`.

(coverage-from-pypi)=
### Using `coverage` from PyPI

To use the version of `coverage` that is locked in your requirements instead of
the one bundled with `rules_python`, point the toolchain to the `coverage`
package in your {obj}`pip.parse` hub:

```starlark
python.single_version_platform_override(
coverage_tool = "@pypi//coverage",
platform = "x86_64-unknown-linux-gnu",
python_version = "3.13.2",
)
```

Or set {attr}`py_runtime.coverage_tool` to `@pypi//coverage` when defining
your own runtime.

:::{note}
The `py_library` itself depends on the Python toolchain. To avoid a dependency
cycle, the coverage tool is built in a configuration where the toolchain has
no coverage tool, which is similar to how `rules_go` builds `nogo`. This only
works when the runtime is defined with the {obj}`py_runtime` macro, which the
toolchains of `rules_python` use.
:::
4 changes: 4 additions & 0 deletions news/4433.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
(toolchains) {attr}`py_runtime.coverage_tool` accepts targets providing
{obj}`PyInfo`, such as `@pypi//coverage`, so the locked version of `coverage`
can be used for coverage collection
([#3902](https://github.com/bazel-contrib/rules_python/issues/3902)).
22 changes: 21 additions & 1 deletion python/private/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.

load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
load("@bazel_skylib//rules:common_settings.bzl", "bool_flag")
load("@bazel_skylib//rules:common_settings.bzl", "bool_flag", "bool_setting")
load("//python:py_binary.bzl", "py_binary")
load("//python:py_library.bzl", "py_library")
load(":bazel_config_mode.bzl", "bazel_config_mode")
Expand Down Expand Up @@ -164,6 +164,25 @@ alias(

bazel_config_mode(name = "bazel_config_mode")

# Usually False. The `py_runtime.coverage_tool` transition sets it to True so
# that the coverage tool and its dependencies (e.g. a `py_library` from a pip hub)
# resolve to a toolchain without a coverage tool, which would otherwise be a
# dependency cycle.
bool_setting(
name = "bootstrap_coverage_tool",
build_setting_default = False,
visibility = ["//visibility:private"],
)

config_setting(
name = "coverage_tool_active",
flag_values = {
":bootstrap_coverage_tool": "False",
},
# This is only because it is an implicit dependency by the toolchains.
visibility = NOT_ACTUALLY_PUBLIC,
)

# This should only be set by analysis tests to expose additional metadata to
# aid testing, so a setting instead of a flag.
bool_flag(
Expand Down Expand Up @@ -689,6 +708,7 @@ bzl_library(
deps = [
":common_labels",
":flags",
":py_info",
":py_internal",
":py_runtime_info",
":reexports",
Expand Down
1 change: 1 addition & 0 deletions python/private/common_labels.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
labels = struct(
# keep sorted
ADD_SRCS_TO_RUNFILES = str(Label("//python/config_settings:add_srcs_to_runfiles")),
BOOTSTRAP_COVERAGE_TOOL = str(Label("//python/private:bootstrap_coverage_tool")),
BOOTSTRAP_IMPL = str(Label("//python/config_settings:bootstrap_impl")),
BUILD_PYTHON_ZIP = str(Label("//python/config_settings:build_python_zip")),
# NOTE: Special target; see definition for details.
Expand Down
12 changes: 11 additions & 1 deletion python/private/py_executable.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ def _create_venv(ctx, output_prefix, imports, runtime_details, add_runfiles_root
output = site_init,
substitutions = {
"%add_runfiles_root_to_sys_path%": add_runfiles_root_to_sys_path,
"%coverage_imports%": _get_coverage_imports(ctx, runtime),
"%coverage_tool%": _get_coverage_tool_runfiles_path(ctx, runtime),
"%import_all%": "True" if read_possibly_native_flag(ctx, "python_import_all_repositories") else "False",
"%interpreter_actual_path%": interpreter_actual_path,
Expand Down Expand Up @@ -879,6 +880,14 @@ def _venv_details(
def _map_each_identity(v):
return v

def _get_coverage_imports(ctx, runtime):
if (ctx.configuration.coverage_enabled and
runtime and
getattr(runtime, "coverage_imports", None)):
return ":".join(runtime.coverage_imports.to_list())
else:
return ""

def _get_coverage_tool_runfiles_path(ctx, runtime):
if (ctx.configuration.coverage_enabled and
runtime and
Expand Down Expand Up @@ -2088,7 +2097,8 @@ def _add_provider_py_runtime_info(providers, runtime_details):
interpreter = py_runtime_info.interpreter,
files = py_runtime_info.files,
coverage_tool = py_runtime_info.coverage_tool,
coverage_files = py_runtime_info.coverage_files,
# The builtin provider only knows about coverage_tool.
coverage_files = py_runtime_info.coverage_files if py_runtime_info.coverage_tool else None,
python_version = py_runtime_info.python_version,
stub_shebang = py_runtime_info.stub_shebang,
bootstrap_template = py_runtime_info.bootstrap_template,
Expand Down
30 changes: 25 additions & 5 deletions python/private/py_runtime_info.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def _PyRuntimeInfo_init(
interpreter_files_to_run = None,
coverage_tool = None,
coverage_files = None,
coverage_imports = None,
pyc_tag = None,
python_version,
stub_shebang = None,
Expand All @@ -84,11 +85,16 @@ def _PyRuntimeInfo_init(
if interpreter_files_to_run.executable != interpreter:
fail("'interpreter_files_to_run.executable' must match 'interpreter'")

if (coverage_tool and not coverage_files) or (not coverage_tool and coverage_files):
if coverage_tool and coverage_imports:
fail("cannot specify both 'coverage_tool' and 'coverage_imports'")

has_coverage = bool(coverage_tool or coverage_imports)
if has_coverage != bool(coverage_files):
fail(
"coverage_tool and coverage_files must both be set or neither must be set, " +
"got coverage_tool={}, coverage_files={}".format(
"coverage_files must be set if and only if coverage_tool or " +
"coverage_imports is set, got coverage_tool={}, coverage_imports={}, coverage_files={}".format(
coverage_tool,
coverage_imports,
coverage_files,
),
)
Expand Down Expand Up @@ -118,6 +124,7 @@ def _PyRuntimeInfo_init(
"abi_flags": abi_flags,
"bootstrap_template": bootstrap_template,
"coverage_files": coverage_files,
"coverage_imports": coverage_imports,
"coverage_tool": coverage_tool,
"files": files,
"implementation_name": implementation_name,
Expand Down Expand Up @@ -221,8 +228,18 @@ The set of substitutions depends on {obj}`--bootstrap_impl`
"coverage_files": """
:type: depset[File] | None

The files required at runtime for using `coverage_tool`. Will be `None` if no
`coverage_tool` was provided.
The files required at runtime for using `coverage_tool` or `coverage_imports`.
Will be `None` if neither was provided.
""",
"coverage_imports": """
:type: depset[str] | None

Runfiles-root-relative import paths that make the `coverage` package importable,
e.g. the {obj}`PyInfo.imports` of a `py_library` used as the coverage tool.
Mutually exclusive with `coverage_tool`.

:::{versionadded} VERSION_NEXT_FEATURE
:::
""",
"coverage_tool": """
:type: File | None
Expand Down Expand Up @@ -322,6 +339,9 @@ The following substitutions are made during template expansion:
can be a `.py` or `.pyc` file, depending on precompile settings.
* `%coverage_tool%`: Runfiles-relative path to the coverage library's entry point.
If coverage is not enabled or available, an empty string.
* `%coverage_imports%`: A colon-delimited string of runfiles-relative paths to
add to sys.path to import the `coverage` package. If coverage is not enabled
or the runtime has no `coverage_imports`, an empty string.
* `%import_all%`: The string `True` if all repositories in the runfiles should
be added to sys.path. The string `False` otherwise.
* `%imports%`: A colon-delimited string of runfiles-relative paths to add to
Expand Down
29 changes: 27 additions & 2 deletions python/private/py_runtime_macro.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,30 @@ load(":py_runtime_rule.bzl", py_runtime_rule = "py_runtime")
# NOTE: The function name is purposefully selected to match the underlying
# rule name so that e.g. 'generator_function' shows as the same name so
# that it is less confusing to users.
def py_runtime(**kwargs):
py_runtime_rule(**kwargs)
def py_runtime(*, name, coverage_tool = None, **kwargs):
"""Wraps the py_runtime rule.

The `coverage_tool` is dropped when building the coverage tool itself, so
that it can depend on the Python toolchain, e.g. a `py_library` from a pip
hub. Otherwise, it would be a dependency cycle.

Args:
name: {type}`str` the name of the target.
coverage_tool: {type}`Label | select | None` the coverage tool.
**kwargs: forwarded to the rule.
"""

# NOTE: A select can't be nested, so it is passed as is. The hermetic
# toolchains select on `collect_code_coverage`, which the `coverage_tool`
# transition disables, so that also breaks the cycle.
if coverage_tool != None and type(coverage_tool) != "select":
coverage_tool = select({
Label("//python/private:coverage_tool_active"): coverage_tool,
"//conditions:default": None,
})

py_runtime_rule(
name = name,
coverage_tool = coverage_tool,
**kwargs
)
Loading
Loading