Skip to content

Commit

Permalink
Allow python_test_runner.py to get coverage for individual targets (p…
Browse files Browse the repository at this point in the history
…antsbuild#8910)

### Problem

For parity with V1 we need to generate coverage reports.

### Solution

Have the run_python_test rule optionally run coverage.

### Result

This is part one of several in getting coverage reports in V2. I've broken it up here to keep it reviewable. 

At this point if you run 
```
./pants --no-process-execution-cleanup-local-dirs --v2 --no-v1 test --test-run-coverage src/python/pants/base:tests 
```
You'll see that it produces a `.coverage` file in `<chroot>/.coverage` This is accessable via TestResult. _python_sqlite_coverage_file` (see pantsbuild#8915 for ways we might improve on that. )

### Upcoming changes:
- A rule for merging coverage data, and its product `MergedCoverageData`
- A rule for generating coverage reports, and its product `CoverageReport`
- A subsystem for all the coverage options (include test sources, coverage report format (html or xml), and coverage output dir.)

You can see the design doc for this at pantsbuild#8915
  • Loading branch information
TansyArron authored and Eric-Arellano committed Jan 10, 2020
1 parent 670f898 commit 899aadc
Show file tree
Hide file tree
Showing 2 changed files with 78 additions and 12 deletions.
74 changes: 65 additions & 9 deletions src/python/pants/backend/python/rules/python_test_runner.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Copyright 2018 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

import os
from dataclasses import dataclass
from textwrap import dedent
from typing import Optional, Tuple

from pants.backend.python.rules.pex import Pex
Expand All @@ -12,7 +14,7 @@
from pants.backend.python.subsystems.subprocess_environment import SubprocessEncodingEnvironment
from pants.build_graph.address import Address
from pants.engine.addressable import BuildFileAddresses
from pants.engine.fs import Digest, DirectoriesToMerge
from pants.engine.fs import Digest, DirectoriesToMerge, FileContent, InputFilesContent
from pants.engine.interactive_runner import InteractiveProcessRequest, InteractiveRunner
from pants.engine.isolated_process import ExecuteProcessRequest, FallibleExecuteProcessResult
from pants.engine.legacy.graph import HydratedTargets, TransitiveHydratedTargets
Expand All @@ -21,7 +23,27 @@
from pants.engine.selectors import Get
from pants.option.global_options import GlobalOptions
from pants.rules.core.strip_source_root import SourceRootStrippedSources
from pants.rules.core.test import TestDebugResult, TestResult, TestTarget
from pants.rules.core.test import TestDebugResult, TestOptions, TestResult, TestTarget


DEFAULT_COVERAGE_CONFIG = dedent(f"""
[run]
branch = True
timid = False
relative_files = True
""")


def get_coveragerc_input(coveragerc_content: str) -> InputFilesContent:
return InputFilesContent(
[
FileContent(
path='.coveragerc',
content=coveragerc_content.encode(),
is_executable=False,
),
]
)


def calculate_timeout_seconds(
Expand Down Expand Up @@ -53,8 +75,24 @@ class TestTargetSetup:
input_files_digest: Digest


def get_packages_to_cover(
test_target: PythonTestsAdaptor,
source_root_stripped_file_paths: Tuple[str, ...],
) -> Tuple[str, ...]:
if hasattr(test_target, 'coverage'):
return tuple(sorted(set(test_target.coverage)))
return tuple(sorted({
os.path.dirname(source_root_stripped_source_file_path).replace(os.sep, '.') # Turn file paths into package names.
for source_root_stripped_source_file_path in source_root_stripped_file_paths
}))

@rule
async def setup_pytest_for_target(test_target: PythonTestsAdaptor, pytest: PyTest) -> TestTargetSetup:
async def setup_pytest_for_target(
test_target: PythonTestsAdaptor,
pytest: PyTest,
test_options: TestOptions,
) -> TestTargetSetup:
# TODO: Rather than consuming the TestOptions subsystem, the TestRunner should pass on coverage configuration via #7490.
transitive_hydrated_targets = await Get[TransitiveHydratedTargets](
BuildFileAddresses((test_target.address,))
)
Expand All @@ -81,15 +119,31 @@ async def setup_pytest_for_target(test_target: PythonTestsAdaptor, pytest: PyTes

chrooted_sources = await Get[ChrootedPythonSources](HydratedTargets(all_targets))

merged_input_files = await Get[Digest](
DirectoriesToMerge(
directories=(chrooted_sources.digest, resolved_requirements_pex.directory_digest)
),
)
directories_to_merge = [
chrooted_sources.digest,
resolved_requirements_pex.directory_digest,
]

coverage_args = []
test_target_sources_file_names = source_root_stripped_test_target_sources.snapshot.files
if test_options.values.run_coverage:
coveragerc_digest = await Get[Digest](InputFilesContent, get_coveragerc_input(DEFAULT_COVERAGE_CONFIG))
directories_to_merge.append(coveragerc_digest)
packages_to_cover = get_packages_to_cover(
test_target,
source_root_stripped_file_paths=test_target_sources_file_names,
)
coverage_args = [
'--cov-report=', # To not generate any output. https://pytest-cov.readthedocs.io/en/latest/config.html
]
for package in packages_to_cover:
coverage_args.extend(['--cov', package])

merged_input_files: Digest = await Get[Digest](DirectoriesToMerge(directories=tuple(directories_to_merge)))

return TestTargetSetup(
requirements_pex=resolved_requirements_pex,
args=(*pytest.options.args, *sorted(source_root_stripped_test_target_sources.snapshot.files)),
args=(*pytest.options.args, *coverage_args, *sorted(test_target_sources_file_names)),
input_files_digest=merged_input_files
)

Expand All @@ -102,6 +156,7 @@ async def run_python_test(
python_setup: PythonSetup,
subprocess_encoding_environment: SubprocessEncodingEnvironment,
global_options: GlobalOptions,
test_options: TestOptions,
) -> TestResult:
"""Runs pytest for one target."""

Expand All @@ -121,6 +176,7 @@ async def run_python_test(
pex_path=f'./{test_setup.requirements_pex.output_filename}',
pex_args=test_setup.args,
input_files=test_setup.input_files_digest,
output_directories=('.coverage',) if test_options.values.run_coverage else None,
description=f'Run Pytest for {test_target.address.reference()}',
timeout_seconds=timeout_seconds if timeout_seconds is not None else 9999,
env=env
Expand Down
16 changes: 13 additions & 3 deletions src/python/pants/rules/core/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,19 @@ class TestOptions(GoalSubsystem):
@classmethod
def register_options(cls, register) -> None:
super().register_options(register)
register('--debug', type=bool, default=False,
help='Run a single test target in an interactive process. This is '
'necessary, for example, when you add breakpoints in your code.')
register(
'--debug',
type=bool,
default=False,
help='Run a single test target in an interactive process. This is necessary, for example, when you add '
'breakpoints in your code.'
)
register(
'--run-coverage',
type=bool,
default=False,
help='Generate a coverage report for this test run.',
)


class Test(Goal):
Expand Down

0 comments on commit 899aadc

Please sign in to comment.