Skip to content

Commit

Permalink
Add support for global coverage reports. (pantsbuild#12080)
Browse files Browse the repository at this point in the history
Add `--coverage-py-global-report` that causes all Python coverage
reports generated by Pants to include coverage for all Python files in
all registered source roots.

Closes pantsbuild#12078
  • Loading branch information
jsirois committed May 14, 2021
1 parent 24a9007 commit 487c466
Show file tree
Hide file tree
Showing 2 changed files with 137 additions and 12 deletions.
115 changes: 103 additions & 12 deletions src/python/pants/backend/python/goals/coverage_py.py
Expand Up @@ -31,19 +31,22 @@
from pants.core.util_rules.config_files import ConfigFiles, ConfigFilesRequest
from pants.engine.addresses import Address, Addresses
from pants.engine.fs import (
EMPTY_DIGEST,
AddPrefix,
CreateDigest,
Digest,
DigestContents,
FileContent,
MergeDigests,
PathGlobs,
Snapshot,
)
from pants.engine.process import ProcessResult
from pants.engine.rules import Get, MultiGet, collect_rules, rule
from pants.engine.target import TransitiveTargets, TransitiveTargetsRequest
from pants.engine.unions import UnionRule
from pants.option.custom_types import file_option
from pants.source.source_root import AllSourceRoots
from pants.util.logging import LogLevel

"""
Expand Down Expand Up @@ -158,6 +161,16 @@ def register_options(cls, register):
f"non-standard location."
),
)
register(
"--global-report",
type=bool,
default=False,
help=(
"If true, Pants will generate a global coverage report.\n\nThe global report will "
"include all Python source files in the workspace and not just those depended on "
"by the tests that were run."
),
)

@property
def filter(self) -> Tuple[str, ...]:
Expand Down Expand Up @@ -190,6 +203,10 @@ def config_request(self) -> ConfigFilesRequest:
},
)

@property
def global_report(self) -> bool:
return cast(bool, self.options.global_report)


@dataclass(frozen=True)
class PytestCoverageData(CoverageData):
Expand Down Expand Up @@ -315,29 +332,103 @@ class MergedCoverageData:

@rule(desc="Merge Pytest coverage data", level=LogLevel.DEBUG)
async def merge_coverage_data(
data_collection: PytestCoverageDataCollection, coverage_setup: CoverageSetup
data_collection: PytestCoverageDataCollection,
coverage_setup: CoverageSetup,
coverage: CoverageSubsystem,
source_roots: AllSourceRoots,
) -> MergedCoverageData:
if len(data_collection) == 1:
if len(data_collection) == 1 and not coverage.global_report:
return MergedCoverageData(data_collection[0].digest)
# We prefix each .coverage file with its corresponding address to avoid collisions.
coverage_digests = await MultiGet(
Get(Digest, AddPrefix(data.digest, prefix=data.address.path_safe_spec))
for data in data_collection
)
input_digest = await Get(Digest, MergeDigests(coverage_digests))
prefixes = sorted(f"{data.address.path_safe_spec}/.coverage" for data in data_collection)

coverage_digest_gets = []
coverage_data_file_paths = []
for data in data_collection:
# We prefix each .coverage file with its corresponding address to avoid collisions.
coverage_digest_gets.append(
Get(Digest, AddPrefix(data.digest, prefix=data.address.path_safe_spec))
)
coverage_data_file_paths.append(f"{data.address.path_safe_spec}/.coverage")

if coverage.global_report:
global_coverage_base_dir = PurePath("__global_coverage__")

global_coverage_config_path = global_coverage_base_dir / "pyproject.toml"
global_coverage_config_content = toml.dumps(
{
"tool": {
"coverage": {
"run": {
"relative_files": True,
"source": list(source_root.path for source_root in source_roots),
}
}
}
}
).encode()

no_op_exe_py_path = global_coverage_base_dir / "no-op-exe.py"

all_sources_digest, no_op_exe_py_digest, global_coverage_config_digest = await MultiGet(
Get(
Digest,
PathGlobs(globs=[f"{source_root.path}/**/*.py" for source_root in source_roots]),
),
Get(Digest, CreateDigest([FileContent(path=str(no_op_exe_py_path), content=b"")])),
Get(
Digest,
CreateDigest(
[
FileContent(
path=str(global_coverage_config_path),
content=global_coverage_config_content,
),
]
),
),
)
extra_sources_digest = await Get(
Digest, MergeDigests((all_sources_digest, no_op_exe_py_digest))
)
input_digest = await Get(
Digest, MergeDigests((extra_sources_digest, global_coverage_config_digest))
)
result = await Get(
ProcessResult,
VenvPexProcess(
coverage_setup.pex,
argv=("run", "--rcfile", str(global_coverage_config_path), str(no_op_exe_py_path)),
input_digest=input_digest,
output_files=(".coverage",),
description="Create base global Pytest coverage report.",
level=LogLevel.DEBUG,
),
)
coverage_digests = await MultiGet(
Get(
Digest, AddPrefix(digest=result.output_digest, prefix=str(global_coverage_base_dir))
),
*coverage_digest_gets,
)
coverage_data_file_paths.append(str(global_coverage_base_dir / ".coverage"))
input_digest = await Get(Digest, MergeDigests(coverage_digests))
else:
extra_sources_digest = EMPTY_DIGEST
input_digest = await Get(Digest, MergeDigests(await MultiGet(coverage_digest_gets)))

result = await Get(
ProcessResult,
VenvPexProcess(
coverage_setup.pex,
argv=("combine", *prefixes),
argv=("combine", *sorted(coverage_data_file_paths)),
input_digest=input_digest,
output_files=(".coverage",),
description=f"Merge {len(prefixes)} Pytest coverage reports.",
description=f"Merge {len(coverage_data_file_paths)} Pytest coverage reports.",
level=LogLevel.DEBUG,
),
)
return MergedCoverageData(result.output_digest)
return MergedCoverageData(
await Get(Digest, MergeDigests((result.output_digest, extra_sources_digest)))
)


@rule(desc="Generate Pytest coverage reports", level=LogLevel.DEBUG)
Expand Down
Expand Up @@ -57,6 +57,10 @@ def test_add():
)
"""
),
"src/python/core/BUILD": "python_library()",
"src/python/core/__init__.py": "",
"src/python/core/untested.py": "CONSTANT = 42",
"foo/bar.py": "BAZ = True",
# Test that a `tests/` source root accurately gets coverage data for the `src/`
# root.
"tests/python/project_test/__init__.py": "",
Expand Down Expand Up @@ -113,6 +117,7 @@ def run_coverage(tmpdir: str, *more_args: str) -> PantsResult:
f"{tmpdir}/tests/python/project_test:multiply",
f"{tmpdir}/tests/python/project_test:arithmetic",
f"{tmpdir}/tests/python/project_test/no_src",
f"--source-root-patterns=['/{tmpdir}/src/python', '{tmpdir}/tests/python', '{tmpdir}/foo']",
*more_args,
]
result = run_pants(command)
Expand Down Expand Up @@ -148,6 +153,35 @@ def test_coverage() -> None:
)


def test_coverage_global() -> None:
with setup_tmpdir(SOURCES) as tmpdir:
result = run_coverage(tmpdir, "--coverage-py-global-report")
assert (
dedent(
f"""\
Name Stmts Miss Cover
---------------------------------------------------------------------------------
{tmpdir}/foo/bar.py 1 1 0%
{tmpdir}/src/python/core/__init__.py 0 0 100%
{tmpdir}/src/python/core/untested.py 1 1 0%
{tmpdir}/src/python/project/__init__.py 0 0 100%
{tmpdir}/src/python/project/lib.py 6 0 100%
{tmpdir}/src/python/project/lib_test.py 3 0 100%
{tmpdir}/src/python/project/random.py 2 2 0%
{tmpdir}/tests/python/project_test/__init__.py 0 0 100%
{tmpdir}/tests/python/project_test/no_src/BUILD.py 1 1 0%
{tmpdir}/tests/python/project_test/no_src/__init__.py 0 0 100%
{tmpdir}/tests/python/project_test/no_src/test_no_src.py 2 0 100%
{tmpdir}/tests/python/project_test/test_arithmetic.py 3 0 100%
{tmpdir}/tests/python/project_test/test_multiply.py 3 0 100%
---------------------------------------------------------------------------------
TOTAL 22 5 77%
"""
)
in result.stderr
), result.stderr


def test_coverage_with_filter() -> None:
with setup_tmpdir(SOURCES) as tmpdir:
result = run_coverage(tmpdir, "--coverage-py-filter=['project.lib', 'project_test.no_src']")
Expand Down

0 comments on commit 487c466

Please sign in to comment.