Skip to content

Commit 9d01630

Browse files
adam-azarchscopybara-github
authored andcommitted
rules/python: Add a coverage_tool attribute to py_runtime.
This allows users to specify a target providing the coveragepy tool (and its dependencies). This is essential for hermetic python builds, where an absolute path will not really work. It's also superior to other potential methods using environment variables because the runfiles dependency on the coverage tool and its files is only incurred when building with coverage enabled. This also builds on the work @TLATER began with #14677 to integrate with `coveragepy`'s `lcov` support, with an additional step of at least attempting to convert the absolute paths which `coveragepy` uses in the lcov output into the relative paths which the rest of bazel can actually consume. This is my first time touching Java code professionally, so I'll admit to mostly cargo-culting those parts, and would welcome any feedback on how to improve things there. I also would have no objections to someone else taking over this PR to get it over the finish line. I've tested this out with our own team's internal monorepo, and have successfully generated a full combined coverage report for most of our python and go code. There's still a bunch of things which don't quite work, in particular when it comes to compiled extension modules or executables run from within python tests, but those will need to be addressed separately, and this is already a giant step forward for our team. Closes #14436. Closes #15590. PiperOrigin-RevId: 476314433 Change-Id: I4be4d10e0af741f4ba1a7b5367c6f7a338a3c43d
1 parent 4c56431 commit 9d01630

10 files changed

Lines changed: 680 additions & 50 deletions

File tree

site/en/configure/coverage.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,61 @@ py_test(
188188
],
189189
)
190190
```
191-
<!-- TODO: Allow specifying a target for `PYTHON_COVERAGE`, instead of having to use `$(location)` -->
192191

192+
If you are using a hermetic Python toolchain, instead of adding the coverage
193+
dependency to every `py_test` target you can instead add the coverage tool to
194+
the toolchain configuration.
195+
196+
Because the [pip_install][pip_install_rule] rule depends on the Python
197+
toolchain, it cannot be used to fetch the `coverage` module.
198+
Instead, add in your `WORKSPACE` e.g.
199+
200+
```starlark
201+
http_archive(
202+
name = "coverage_linux_x86_64"",
203+
build_file_content = """
204+
py_library(
205+
name = "coverage",
206+
srcs = ["coverage/__main__.py"],
207+
data = glob(["coverage/*", "coverage/**/*.py"]),
208+
visibility = ["//visibility:public"],
209+
)
210+
""",
211+
sha256 = "84631e81dd053e8a0d4967cedab6db94345f1c36107c71698f746cb2636c63e3",
212+
type = "zip",
213+
urls = [
214+
"https://files.pythonhosted.org/packages/74/0d/0f3c522312fd27c32e1abe2fb5c323b583a5c108daf2c26d6e8dfdd5a105/coverage-6.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
215+
],
216+
)
217+
```
218+
219+
Then configure your python toolchain as e.g.
220+
221+
```starlark
222+
py_runtime(
223+
name = "py3_runtime_linux_x86_64",
224+
coverage_tool = "@coverage_linux_x86_64//:coverage",
225+
files = ["@python3_9_x86_64-unknown-linux-gnu//:files"],
226+
interpreter = "@python3_9_x86_64-unknown-linux-gnu//:bin/python3",
227+
python_version = "PY3",
228+
)
229+
230+
py_runtime_pair(
231+
name = "python_runtimes_linux_x86_64",
232+
py2_runtime = None,
233+
py3_runtime = ":py3_runtime_linux_x86_64",
234+
)
235+
236+
toolchain(
237+
name = "python_toolchain_linux_x86_64",
238+
exec_compatible_with = [
239+
"@platforms//os:linux",
240+
"@platforms//cpu:x86_64",
241+
],
242+
toolchain = ":python_runtimes_linux_x86_64",
243+
toolchain_type = "@bazel_tools//tools/python:toolchain_type",
244+
)
245+
```
193246

194247
[lcov]: https://github.com/linux-test-project/lcov
195248
[rules_python]: https://github.com/bazelbuild/rules_python

src/main/java/com/google/devtools/build/lib/bazel/rules/python/BazelPythonSemantics.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,20 @@ public boolean prohibitHyphensInPackagePaths() {
9292
public void collectRunfilesForBinary(
9393
RuleContext ruleContext, Runfiles.Builder builder, PyCommon common, CcInfo ccInfo) {
9494
addRuntime(ruleContext, common, builder);
95+
// select() and build configuration should ideally remove coverage as
96+
// as dependency, but guard against including it at runtime just in case.
97+
if (ruleContext.getConfiguration().isCodeCoverageEnabled()) {
98+
addCoverageSupport(ruleContext, common, builder);
99+
}
95100
}
96101

97102
@Override
98103
public void collectDefaultRunfilesForBinary(
99104
RuleContext ruleContext, PyCommon common, Runfiles.Builder builder) {
100105
addRuntime(ruleContext, common, builder);
106+
if (ruleContext.getConfiguration().isCodeCoverageEnabled()) {
107+
addCoverageSupport(ruleContext, common, builder);
108+
}
101109
}
102110

103111
@Override
@@ -154,6 +162,9 @@ private static void createStubFile(
154162
// first-stage.
155163
String pythonBinary = getPythonBinary(ruleContext, common, bazelConfig);
156164

165+
// The python code coverage tool to use, if any.
166+
String coverageTool = getCoverageTool(ruleContext, common);
167+
157168
// Version information for host config diagnostic warning.
158169
PythonVersion attrVersion = PyCommon.readPythonVersionFromAttribute(ruleContext.attributes());
159170
boolean attrVersionSpecifiedExplicitly = attrVersion != null;
@@ -172,6 +183,7 @@ private static void createStubFile(
172183
Substitution.of(
173184
"%main%", common.determineMainExecutableSource(/*withWorkspaceName=*/ true)),
174185
Substitution.of("%python_binary%", pythonBinary),
186+
Substitution.of("%coverage_tool%", coverageTool == null ? "" : coverageTool),
175187
Substitution.of("%imports%", Joiner.on(":").join(common.getImports().toList())),
176188
Substitution.of("%workspace_name%", ruleContext.getWorkspaceName()),
177189
Substitution.of("%is_zipfile%", boolToLiteral(isForZipFile)),
@@ -461,6 +473,31 @@ private static String getPythonBinary(
461473
return pythonBinary;
462474
}
463475

476+
private static void addCoverageSupport(
477+
RuleContext ruleContext, PyCommon common, Runfiles.Builder builder) {
478+
PyRuntimeInfo provider = getRuntime(ruleContext, common);
479+
if (provider != null && provider.getCoverageTool() != null) {
480+
builder.addArtifact(provider.getCoverageTool());
481+
builder.addTransitiveArtifacts(provider.getCoverageToolFiles());
482+
}
483+
}
484+
485+
@Nullable
486+
private static String getCoverageTool(RuleContext ruleContext, PyCommon common) {
487+
if (!ruleContext.getConfiguration().isCodeCoverageEnabled()) {
488+
return null;
489+
}
490+
String coverageTool = null;
491+
PyRuntimeInfo provider = getRuntime(ruleContext, common);
492+
if (provider != null && provider.getCoverageTool() != null) {
493+
PathFragment workspaceName =
494+
PathFragment.create(ruleContext.getRule().getPackage().getWorkspaceName());
495+
coverageTool =
496+
workspaceName.getRelative(provider.getCoverageTool().getRunfilesPath()).getPathString();
497+
}
498+
return coverageTool;
499+
}
500+
464501
private static String getStubShebang(RuleContext ruleContext, PyCommon common) {
465502
PyRuntimeInfo provider = getRuntime(ruleContext, common);
466503
if (provider != null) {

0 commit comments

Comments
 (0)