Skip to content

Commit f4ebb5b

Browse files
authored
feat(coverage): warn when bundled coverage tool has no wheel for requested python_version/platform (#3766)
Addresses review feedback on #3764: ~~NOTE: Starlark unit tests have no way to capture stdout/stderr, so we can't directly assert that the warning is printed. I am not sure if the refactoring-for-testability I have considered would be appreciated, so this PR is a minimal approach. (I will also prepare an alternative PR that includes a small refactor and some tests, in case that is preferred.)~~ A logger has been added, and is used in testing. Previously, when `configure_coverage_tool = True` was set but the bundled `coverage.py` wheel set had no entry for the requested (python_version, platform), `coverage_dep` returned None silently. The result was that `bazel coverage` produced empty per-test lcov files for `py_test` targets with no signal to the user that coverage was unconfigured. Print a WARNING in that path so the misconfiguration is visible. Preserves the existing silent return for the windows branch, which is intentionally quiet because the upstream coverage wrapper does not support windows.
1 parent 4b99ec3 commit f4ebb5b

7 files changed

Lines changed: 153 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ END_UNRELEASED_TEMPLATE
6565
default to `true`.
6666
* (pypi) The data files of a wheel (bin, includes, etc) are now always included
6767
as a library's data dependencies.
68+
* (coverage) When `configure_coverage_tool = True` is set but the bundled
69+
`coverage.py` wheel set has no entry for the requested python version and
70+
platform, a warning is now printed instead of silently producing an empty
71+
coverage report.
6872

6973
{#v0-0-0-fixed}
7074
### Fixed

python/private/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ bzl_library(
175175
srcs = ["coverage_deps.bzl"],
176176
deps = [
177177
":bazel_tools_bzl",
178+
":repo_utils_bzl",
178179
":version_label_bzl",
179180
],
180181
)
@@ -293,6 +294,7 @@ bzl_library(
293294
":full_version_bzl",
294295
":internal_config_repo_bzl",
295296
":python_repository_bzl",
297+
":repo_utils_bzl",
296298
":toolchains_repo_bzl",
297299
"//python:versions_bzl",
298300
"//python/private/pypi:deps_bzl",

python/private/coverage_deps.bzl

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
1919
load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
20+
load("//python/private:repo_utils.bzl", "repo_utils")
2021
load("//python/private:version_label.bzl", "version_label")
2122

2223
# START: maintained by 'bazel run //tools/private/update_deps:update_coverage_deps <version>'
@@ -166,18 +167,27 @@ _coverage_deps = {
166167

167168
_coverage_patch = Label("//python/private:coverage.patch")
168169

169-
def coverage_dep(name, python_version, platform, visibility):
170+
def coverage_dep(name, python_version, platform, visibility, logger = None):
170171
"""Register a single coverage dependency based on the python version and platform.
171172
172173
Args:
173174
name: The name of the registered repository.
174175
python_version: The full python version.
175176
platform: The platform, which can be found in //python:versions.bzl PLATFORMS dict.
176177
visibility: The visibility of the coverage tool.
178+
logger: {type}`repo_utils.logger | None` Optional logger used to emit a
179+
warning when no wheel is available for the (python_version,
180+
platform) pair. If not supplied, a default logger is constructed.
177181
178182
Returns:
179183
The label of the coverage tool if the platform is supported, otherwise - None.
180184
"""
185+
if logger == None:
186+
logger = repo_utils.logger(
187+
struct(getenv = lambda _: None),
188+
name = "coverage_dep",
189+
)
190+
181191
if "windows" in platform:
182192
# NOTE @aignas 2023-01-19: currently we do not support windows as the
183193
# upstream coverage wrapper is written in shell. Do not log any warning
@@ -188,7 +198,14 @@ def coverage_dep(name, python_version, platform, visibility):
188198
url, sha256 = _coverage_deps.get(abi, {}).get(platform, (None, ""))
189199

190200
if url == None:
191-
# Some wheels are not present for some builds, so let's silently ignore those.
201+
logger.warn(lambda: (
202+
"rules_python's bundled coverage tool has no wheel for " +
203+
"python_version={}, platform={}. `bazel coverage` will produce " +
204+
"empty lcov for py_test targets in this configuration. Either " +
205+
"pin python_version to a version in the bundled set (see " +
206+
"python/private/coverage_deps.bzl), or configure coverage " +
207+
"manually via py_runtime.coverage_tool. See docs/coverage.md."
208+
).format(python_version, platform))
192209
return None
193210

194211
maybe(

python/private/python.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ def _python_impl(module_ctx):
275275
register_result = python_register_toolchains(
276276
name = toolchain_info.name,
277277
_internal_bzlmod_toolchain_call = True,
278+
_internal_module_ctx = module_ctx,
278279
**kwargs
279280
)
280281
if not register_result.impl_repos:

python/private/python_register_toolchains.bzl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ load(
2626
load(":coverage_deps.bzl", "coverage_dep")
2727
load(":full_version.bzl", "full_version")
2828
load(":python_repository.bzl", "python_repository")
29+
load(":repo_utils.bzl", "repo_utils")
2930
load(
3031
":toolchains_repo.bzl",
3132
"host_compatible_python_repo",
@@ -89,6 +90,19 @@ def python_register_toolchains(
8990
if bzlmod_toolchain_call:
9091
register_toolchains = False
9192

93+
# When invoked from the bzlmod python extension, a module_ctx is plumbed in
94+
# so the coverage_dep logger can attribute warnings to the right module and
95+
# honor module-root filtering. In the WORKSPACE/macro path no module_ctx is
96+
# available; a minimal stand-in struct gives the logger what it needs.
97+
module_ctx = kwargs.pop("_internal_module_ctx", None)
98+
if module_ctx != None:
99+
coverage_logger = repo_utils.logger(module_ctx, name = "coverage_dep")
100+
else:
101+
coverage_logger = repo_utils.logger(
102+
struct(getenv = lambda _: None),
103+
name = "coverage_dep",
104+
)
105+
92106
base_url = kwargs.pop("base_url", DEFAULT_RELEASE_BASE_URL)
93107
tool_versions = tool_versions or TOOL_VERSIONS
94108
minor_mapping = minor_mapping or MINOR_MAPPING
@@ -121,6 +135,7 @@ def python_register_toolchains(
121135
),
122136
python_version = python_version,
123137
platform = platform,
138+
logger = coverage_logger,
124139
visibility = ["@{name}_{platform}//:__subpackages__".format(
125140
name = name,
126141
platform = platform,

tests/coverage_deps/BUILD.bazel

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Copyright 2026 The Bazel Authors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
load(":coverage_deps_test.bzl", "coverage_deps_test_suite")
16+
17+
coverage_deps_test_suite(name = "coverage_deps_tests")
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Copyright 2026 The Bazel Authors. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"Tests for the warning emitted by coverage_dep when no wheel is available."
16+
17+
load("@rules_testing//lib:test_suite.bzl", "test_suite")
18+
load("//python/private:coverage_deps.bzl", "coverage_dep") # buildifier: disable=bzl-visibility
19+
load("//python/private:repo_utils.bzl", "REPO_DEBUG_ENV_VAR", "REPO_VERBOSITY_ENV_VAR", "repo_utils") # buildifier: disable=bzl-visibility
20+
21+
_tests = []
22+
23+
def _capturing_logger():
24+
"""Build a (logger, captured_messages_list) pair.
25+
26+
The logger has its verbosity set to INFO so WARN messages are captured but
27+
nothing noisier than necessary is emitted. The printer collects the second
28+
positional argument from each printer invocation (the formatted message).
29+
"""
30+
captured = []
31+
logger = repo_utils.logger(
32+
struct(
33+
getenv = {
34+
REPO_DEBUG_ENV_VAR: None,
35+
REPO_VERBOSITY_ENV_VAR: "INFO",
36+
}.get,
37+
),
38+
name = "unit-test",
39+
printer = lambda _key, message: captured.append(message),
40+
)
41+
return logger, captured
42+
43+
def _test_unsupported_python_version_warns(env):
44+
# cp37 is not in the bundled wheel set; coverage_dep should return None
45+
# and emit a warning describing the misconfiguration.
46+
logger, captured = _capturing_logger()
47+
result = coverage_dep(
48+
name = "unused_for_test",
49+
python_version = "3.7",
50+
platform = "aarch64-apple-darwin",
51+
visibility = ["//visibility:public"],
52+
logger = logger,
53+
)
54+
env.expect.that_bool(result == None).equals(True)
55+
env.expect.that_int(len(captured)).equals(1)
56+
env.expect.that_str(captured[0]).contains("no wheel for")
57+
env.expect.that_str(captured[0]).contains("python_version=3.7")
58+
env.expect.that_str(captured[0]).contains("platform=aarch64-apple-darwin")
59+
60+
_tests.append(_test_unsupported_python_version_warns)
61+
62+
def _test_windows_platform_is_silent(env):
63+
# Windows is intentionally unsupported and not actionable; coverage_dep
64+
# must return None without logging anything.
65+
logger, captured = _capturing_logger()
66+
result = coverage_dep(
67+
name = "unused_for_test",
68+
python_version = "3.10",
69+
platform = "x86_64-pc-windows-msvc",
70+
visibility = ["//visibility:public"],
71+
logger = logger,
72+
)
73+
env.expect.that_bool(result == None).equals(True)
74+
env.expect.that_int(len(captured)).equals(0)
75+
76+
_tests.append(_test_windows_platform_is_silent)
77+
78+
# NOTE: there is intentionally no unit test for the supported-wheel path
79+
# (where coverage_dep returns a non-None label and emits no warning).
80+
# That path calls `maybe(http_archive, ...)`, which calls
81+
# `native.existing_rule()`. `native.existing_rule()` is only valid during
82+
# BUILD file, legacy macro, or rule finalizer evaluation -- not during
83+
# rule analysis, which is the phase rules_testing analysis tests run in.
84+
# Calling coverage_dep with supported args from here therefore fails with
85+
# "existing_rule() can only be used while evaluating a BUILD file, ...".
86+
# The supported-wheel path is exercised end-to-end by `bazel coverage`
87+
# against a real py_test target during ordinary use of the toolchain.
88+
89+
def coverage_deps_test_suite(name):
90+
"""Create the test suite.
91+
92+
Args:
93+
name: the name of the test suite.
94+
"""
95+
test_suite(name = name, basic_tests = _tests)

0 commit comments

Comments
 (0)