Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 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
a139761
feat(toolchain): set the coverage_tool for all platforms of a version
aignas Nov 8, 2026
27dee24
fix(toolchain): keep the strip_prefix when single_version_override ha…
aignas Nov 8, 2026
18e6ef7
fix: use the extracted wheel files instead of a py_library
aignas Nov 8, 2026
9a221f9
docs: use the extracted_whl_files target in the coverage_tool example
aignas Nov 8, 2026
e6e2738
docs: wrap the coverage_tool doc
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
d7c4235
Merge remote-tracking branch 'origin/aignas.feat.coverage-tool-py-lib…
aignas Nov 8, 2026
4edb931
test(coverage): configure the coverage tool with single_version_override
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
986d3d8
Merge branch 'aignas.feat.coverage-tool-py-library' into aignas.feat.…
aignas Nov 8, 2026
4ac5b12
news: name the strip_prefix entry after the PR
aignas Nov 8, 2026
e388a75
docs: link the coverage_tool doc to the coverage docs
aignas Nov 8, 2026
5e1aa6a
Merge branch 'aignas.feat.coverage-tool-py-library' into aignas.feat.…
aignas Nov 8, 2026
6a8db2a
Merge branch 'aignas.fix.single-version-override-keep-config' into ai…
aignas Nov 8, 2026
bbf0bdd
docs: use single_version_override in the coverage from PyPI 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
0add81e
Merge branch 'aignas.feat.coverage-tool-py-library' into aignas.feat.…
aignas Nov 8, 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 @@ -401,6 +401,26 @@ dev_python.override(
register_all_versions = True,
)

# For testing the coverage collection with the bundled coverage tool.
# See //tests/coverage:coverage_collection_test
#
# NOTE: A full version is used so that the toolchain is named after it and does
# not shadow the toolchains that are checked in //tests/toolchains.
dev_python.toolchain(
configure_coverage_tool = True,
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_override(
coverage_tool = "@dev_pip//coverage:extracted_whl_files",
python_version = "3.13.13",
)

# 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.

29 changes: 29 additions & 0 deletions docs/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,32 @@ 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.
* If it provides runfiles, then `runfiles.files` are included into `py_test`.
* Otherwise, if one of its output files is `coverage/__main__.py`, then that
file is treated as the coverage entry point. This allows using the files
of an extracted `coverage` wheel.

(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 extracted files
of the `coverage` package in your {obj}`pip.parse` hub:

```starlark
python.single_version_override(
coverage_tool = "@pypi//coverage:extracted_whl_files",
python_version = "3.13.2",
)
```

In `WORKSPACE`, pass the same `coverage_tool` to
{obj}`python_register_toolchains`. Use
{obj}`python.single_version_platform_override` to set it for a single platform
only, or set {attr}`py_runtime.coverage_tool` to
`@pypi//coverage:extracted_whl_files` when defining your own runtime.

:::{note}
Use the `extracted_whl_files` target and not `@pypi//coverage`: the
`py_library` depends on the Python toolchain itself, so using it as the
coverage tool of the toolchain creates a dependency cycle.
:::
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 the extracted files of
the `coverage` wheel, such as `@pypi//coverage:extracted_whl_files`, so the
locked version of `coverage` can be used for coverage collection
([#3902](https://github.com/bazel-contrib/rules_python/issues/3902)).
5 changes: 5 additions & 0 deletions news/4434.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
(toolchains) {attr}`python.single_version_override.coverage_tool` sets the
coverage tool for all platforms of a Python version, and
{obj}`python_register_toolchains` accepts a `coverage_tool` for the same
purpose in `WORKSPACE`
([#3902](https://github.com/bazel-contrib/rules_python/issues/3902)).
3 changes: 3 additions & 0 deletions news/4435.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
(toolchains) {obj}`python.single_version_override` without `urls` no longer
resets the `strip_prefix` of the existing archives, which broke the
freethreaded toolchains that use the `full` archives.
30 changes: 29 additions & 1 deletion python/private/py_runtime_rule.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ load(":version.bzl", "version")

_py_builtins = py_internal

_COVERAGE_MAIN = "coverage/__main__.py"

def _coverage_main(files):
"""Return the `coverage/__main__.py` entry point from the coverage files.

Args:
files: {type}`depset[File]` the files of the coverage tool target, e.g.
`@pypi//coverage:extracted_whl_files`.

Returns:
{type}`File | None` the entry point of the coverage tool.
"""
for f in files.to_list():
if f.short_path == _COVERAGE_MAIN or f.short_path.endswith("/" + _COVERAGE_MAIN):
return f

return None

def coverage_tool_missing_message(*, coverage_enabled, coverage_tool, label):
"""Build the warning for a selected runtime that cannot produce coverage.

Expand Down Expand Up @@ -133,7 +151,9 @@ def _py_runtime_impl(ctx):
elif coverage_di.files_to_run and coverage_di.files_to_run.executable:
coverage_tool = coverage_di.files_to_run.executable
else:
fail("coverage_tool must be an executable target or must produce exactly one file.")
coverage_tool = _coverage_main(coverage_di.files)
if not coverage_tool:
fail("coverage_tool must be an executable target, must produce exactly one file or must contain '{}'.".format(_COVERAGE_MAIN))

coverage_files = depset(transitive = [
coverage_di.files,
Expand Down Expand Up @@ -311,6 +331,14 @@ The entry point for the tool must be loadable by a Python interpreter (e.g. a
`.py` or `.pyc` file). It must accept the command line arguments
of [`coverage.py`](https://coverage.readthedocs.io), at least including
the `run` and `lcov` subcommands.

If the target produces multiple files and one of them is
`coverage/__main__.py`, then it is used as the entry point, e.g.
`@pypi//coverage:extracted_whl_files`. See {ref}`coverage-from-pypi`.

:::{versionchanged} VERSION_NEXT_FEATURE
The extracted files of the `coverage` wheel can be used.
:::
""",
),
"files": attr.label_list(
Expand Down
29 changes: 25 additions & 4 deletions python/private/python.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -612,12 +612,18 @@ def _process_single_version_overrides(*, tag, _fail = fail, default):
return

sha256 = dict(tag.sha256) or available_versions[tag.python_version]["sha256"]
override = {
"sha256": sha256,
"strip_prefix": {
if tag.urls:
strip_prefix = {
platform: tag.strip_prefix
for platform in sha256
},
}
else:
# The archives are not overridden, so keep their strip_prefix, which
# differs between the archive flavors.
strip_prefix = available_versions[tag.python_version]["strip_prefix"]
override = {
"sha256": sha256,
"strip_prefix": strip_prefix,
"url": {
platform: list(tag.urls)
for platform in tag.sha256
Expand All @@ -636,6 +642,10 @@ def _process_single_version_overrides(*, tag, _fail = fail, default):

available_versions[tag.python_version] = {k: v for k, v in override.items() if v}

if tag.coverage_tool:
# The Label is resolved relative to the calling module, but
# python_repository consumes it as a string.
kwargs.setdefault(tag.python_version, {})["coverage_tool"] = str(tag.coverage_tool)
if tag.distutils_content:
kwargs.setdefault(tag.python_version, {})["distutils_content"] = tag.distutils_content
if tag.distutils:
Expand Down Expand Up @@ -1452,6 +1462,17 @@ class.
#
# However, we need to understand how to accommodate the fact that
# {attr}`single_version_override.version` only allows patch versions.
"coverage_tool": attr.label(
doc = """\
The coverage tool to use for all platforms of this Python version, e.g.
`@pypi//coverage:extracted_whl_files`. See {attr}`py_runtime.coverage_tool`
for the accepted targets. {attr}`single_version_platform_override.coverage_tool`
takes precedence for a particular platform.

:::{versionadded} VERSION_NEXT_FEATURE
:::
""",
),
"distutils": attr.label(
allow_single_file = True,
doc = "A distutils.cfg file to be included in the Python installation. " +
Expand Down
15 changes: 9 additions & 6 deletions python/private/python_register_toolchains.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def python_register_toolchains(
python_version,
register_toolchains = True,
register_coverage_tool = False,
coverage_tool = None,
set_python_version_constraint = False,
libpython = None,
tool_versions = None,
Expand Down Expand Up @@ -73,6 +74,9 @@ def python_register_toolchains(
register_toolchains: {type}`bool` Whether or not to register the downloaded toolchains.
register_coverage_tool: {type}`bool` Whether or not to register the
downloaded coverage tool to the toolchains.
coverage_tool: {type}`str | None` the coverage tool to use for all
platforms that do not set a `coverage_tool` in `tool_versions`.
Takes precedence over `register_coverage_tool`.
set_python_version_constraint: {type}`bool` When set to `True`,
`target_compatible_with` for the toolchains will include a version
constraint.
Expand Down Expand Up @@ -156,20 +160,19 @@ def python_register_toolchains(
)

# allow passing in a tool version
coverage_tool = None
coverage_tool = _get_platform_setting(
platform_coverage_tool = _get_platform_setting(
version_info,
"coverage_tool",
platform = source_platform,
default = _get_platform_setting(
version_info,
"coverage_tool",
platform = base_platform,
default = None,
default = coverage_tool,
),
)
if register_coverage_tool and coverage_tool == None:
coverage_tool = coverage_dep(
if register_coverage_tool and platform_coverage_tool == None:
platform_coverage_tool = coverage_dep(
name = "{name}_{platform}_coverage".format(
name = name,
platform = platform,
Expand All @@ -194,7 +197,7 @@ def python_register_toolchains(
release_filename = release_filename,
urls = urls,
strip_prefix = strip_prefix,
coverage_tool = coverage_tool,
coverage_tool = platform_coverage_tool,
libpython = libpython or _get_platform_setting(
version_info,
"libpython",
Expand Down
62 changes: 62 additions & 0 deletions tests/coverage/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
load("//python:py_library.bzl", "py_library")
load("//python:pytest_test.bzl", "pytest_test")
load("//tests/support:py_reconfig.bzl", "py_reconfig_test")
load("//tests/support:support.bzl", "NOT_WINDOWS", "SUPPORTS_BZLMOD")

py_library(
name = "lib",
srcs = ["lib.py"],
)

# The program that is built with coverage enabled. It is run by the
# `coverage_collection_test` below, which sets the same environment variables
# that `bazel coverage` would set.
py_reconfig_test(
name = "instrumented",
srcs = ["instrumented.py"],
collect_code_coverage = True,
main = "instrumented.py",
# The bundled coverage tool is configured only for this version, see the
# `MODULE.bazel` file.
python_version = "3.12.16",
tags = ["manual"],
# The bundled coverage tool is not supported on Windows and it is only
# configured with bzlmod.
target_compatible_with = NOT_WINDOWS + SUPPORTS_BZLMOD,
deps = [":lib"],
)

pytest_test(
name = "coverage_collection_test",
srcs = ["coverage_collection_test.py"],
data = [":instrumented"],
env = {
"INSTRUMENTED": "$(rlocationpath :instrumented)",
},
target_compatible_with = NOT_WINDOWS + SUPPORTS_BZLMOD,
deps = ["//python/runfiles"],
)

# The same program, but the coverage tool is the extracted `coverage` wheel from
# the `@dev_pip` hub, see the `MODULE.bazel` file.
py_reconfig_test(
name = "instrumented_pypi",
srcs = ["instrumented.py"],
collect_code_coverage = True,
main = "instrumented.py",
python_version = "3.13.13",
tags = ["manual"],
target_compatible_with = NOT_WINDOWS + SUPPORTS_BZLMOD,
deps = [":lib"],
)

pytest_test(
name = "coverage_from_pypi_test",
srcs = ["coverage_collection_test.py"],
data = [":instrumented_pypi"],
env = {
"INSTRUMENTED": "$(rlocationpath :instrumented_pypi)",
},
target_compatible_with = NOT_WINDOWS + SUPPORTS_BZLMOD,
deps = ["//python/runfiles"],
)
Loading
Loading