Skip to content

Commit b5a84df

Browse files
Add config options to pycoverage plugin (#153)
These changes improve the existing Pycoverage plugin. This plugin runs `coverage` util (from pytest-cov) to generate human-readable reports from .coverage files. Up to this point it would generate XML files. But it can also generate JSON files. This is relevant because ATS uses the JSON format. So far we've been telling users to add another step to their CI to create the files directly calling `coverage`, but the plugin can handle it for us. In these changes we include config options that allow us to decide what report file to generate, plus other things like giving the path directly, and options specific to the JSON reports.
1 parent 38610dd commit b5a84df

4 files changed

Lines changed: 207 additions & 32 deletions

File tree

codecov_cli/plugins/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ def _get_plugin(cli_config, plugin_name):
5050
if plugin_name == "gcov":
5151
return GcovPlugin()
5252
if plugin_name == "pycoverage":
53-
return Pycoverage()
53+
config = cli_config.get("plugins", {}).get("pycoverage", {})
54+
return Pycoverage(config)
5455
if plugin_name == "xcode":
5556
return XcodePlugin()
5657
if cli_config and plugin_name in cli_config.get("plugins", {}):

codecov_cli/plugins/pycoverage.py

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,41 +13,86 @@
1313
logger = logging.getLogger("codecovcli")
1414

1515

16+
class PycoverageConfig(dict):
17+
@property
18+
def project_root(self) -> typing.Optional[pathlib.Path]:
19+
"""
20+
The project root to search for coverage files.
21+
project_root: pathlib.Path [default os.getcwd()]
22+
"""
23+
return self.get("project_root", pathlib.Path(os.getcwd()))
24+
25+
@property
26+
def report_type(self) -> str:
27+
"""
28+
Report type to generate.
29+
Overrided if include_contexts == True
30+
report_type: str [values xml|json; default xml]
31+
"""
32+
return self.get("report_type", "xml")
33+
34+
@property
35+
def path_to_coverage_file(self) -> str:
36+
"""
37+
The coverage dir with .coverage file
38+
If set, will not look search for coverage files
39+
"""
40+
return self.get("path_to_coverage_file", None)
41+
42+
@property
43+
def include_contexts(self) -> bool:
44+
"""
45+
Includes test context in JSON report. Flag.
46+
(test contexts are the test labels used in ATS)
47+
include_contexts: bool [default True]
48+
"""
49+
return self.get("include_contexts", True)
50+
51+
1652
class Pycoverage(object):
17-
def __init__(self, project_root: typing.Optional[pathlib.Path] = None):
18-
self.project_root = project_root or pathlib.Path(os.getcwd())
53+
def __init__(self, config: dict):
54+
self.config = PycoverageConfig(config)
1955

2056
def run_preparation(self, collector) -> PreparationPluginReturn:
21-
logger.debug("Running coverage.py plugin...")
2257

2358
if shutil.which("coverage") is None:
2459
logger.warning("coverage.py is not installed or can't be found.")
2560
return
2661

27-
path_to_coverage_data = next(
62+
path_to_coverage_data = self._get_path_to_coverage()
63+
if path_to_coverage_data is None:
64+
logger.warning("No coverage data found to transform")
65+
return
66+
coverage_dir = pathlib.Path(path_to_coverage_data).parent
67+
if self.config.report_type == "xml":
68+
return self._generate_XML_report(coverage_dir)
69+
if self.config.report_type == "json":
70+
return self._generate_JSON_report(coverage_dir)
71+
return PreparationPluginReturn(
72+
success=False, messages=[f"report type {self.config.report_type} unknown"]
73+
)
74+
75+
def _get_path_to_coverage(self) -> pathlib.Path:
76+
if self.config.path_to_coverage_file:
77+
path = pathlib.Path(self.config.path_to_coverage_file)
78+
if path.exists():
79+
return pathlib.Path(self.config.path_to_coverage_file)
80+
logger.warning(
81+
f"Dir {self.config.path_to_coverage_file} doesn't exist or doesn't have .coverage file. Falling back to search"
82+
)
83+
return next(
2884
search_files(
29-
self.project_root,
85+
self.config.project_root,
3086
[],
3187
filename_include_regex=coverage_files_regex,
3288
filename_exclude_regex=None,
3389
),
3490
None,
3591
)
3692

37-
if path_to_coverage_data is None:
38-
logger.warning("No coverage data found to transform")
39-
return
40-
41-
coverage_data_directory = pathlib.Path(path_to_coverage_data).parent
42-
self._generate_XML_report(coverage_data_directory)
43-
44-
return PreparationPluginReturn(success=True, messages=[])
45-
46-
def _generate_XML_report(self, dir: pathlib.Path):
93+
def _generate_XML_report(self, dir: pathlib.Path) -> PreparationPluginReturn:
4794
"""Generates up-to-date XML report in the given directory"""
48-
4995
# the following if conditions avoid creating dummy .coverage file
50-
5196
if next(iglob(str(dir / ".coverage.*")), None) is not None:
5297
logger.info(f"Running coverage combine -a in {dir}")
5398
subprocess.run(["coverage", "combine", "-a"], cwd=dir)
@@ -60,3 +105,27 @@ def _generate_XML_report(self, dir: pathlib.Path):
60105

61106
output = completed_process.stdout.decode().strip()
62107
logger.info(output)
108+
return PreparationPluginReturn(success=True, messages=[])
109+
110+
def _generate_JSON_report(self, dir: pathlib.Path):
111+
if (dir / ".coverage").exists():
112+
logger.info(
113+
f"Generating JSON report in {dir}",
114+
extra=dict(
115+
extra_log_attributes=dict(
116+
include_contexts=self.config.include_contexts
117+
)
118+
),
119+
)
120+
command = ["coverage", "json"]
121+
if self.config.include_contexts:
122+
command.append("--show-contexts")
123+
completed_process = subprocess.run(command, cwd=dir, capture_output=True)
124+
125+
output = completed_process.stdout.decode().strip()
126+
logger.info(output)
127+
return PreparationPluginReturn(success=True, messages=[])
128+
logger.warning(f".coverage file not found at {dir}. Parsing failed")
129+
return PreparationPluginReturn(
130+
success=False, messages=[f".coverage file not found at {dir}."]
131+
)

tests/plugins/test_instantiation.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
_load_plugin_from_yaml,
77
select_preparation_plugins,
88
)
9+
from codecov_cli.plugins.pycoverage import Pycoverage, PycoverageConfig
910

1011

1112
def test_load_plugin_from_yaml(mocker):
@@ -52,6 +53,19 @@ def test_get_plugin_xcode():
5253
assert isinstance(res, XcodePlugin)
5354

5455

56+
def test_get_plugin_pycoverage():
57+
res = _get_plugin({}, "pycoverage")
58+
assert isinstance(res, Pycoverage)
59+
assert res.config == PycoverageConfig()
60+
assert res.config.report_type == "xml"
61+
62+
pycoverage_config = {"project_root": "project/root", "report_type": "json"}
63+
res = _get_plugin({"plugins": {"pycoverage": pycoverage_config}}, "pycoverage")
64+
assert isinstance(res, Pycoverage)
65+
assert res.config == PycoverageConfig(pycoverage_config)
66+
assert res.config.report_type == "json"
67+
68+
5569
def test_select_preparation_plugins(mocker):
5670
class SamplePlugin(object):
5771
def __init__(self, banana=None):

tests/plugins/test_pycoverage.py

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
import pathlib
2+
from unittest.mock import MagicMock
3+
14
import pytest
25

6+
from codecov_cli.helpers.folder_searcher import globs_to_regex
37
from codecov_cli.plugins.pycoverage import Pycoverage
48

59

@@ -15,6 +19,18 @@ def xml_subprocess_side_effect(*args, cwd, **kwargs):
1519
)
1620

1721

22+
@pytest.fixture
23+
def json_subprocess_mock(mocker):
24+
def json_subprocess_side_effect(*args, cwd, **kwargs):
25+
(cwd / "coverage.json").touch()
26+
return mocker.MagicMock(stdout=b"Wrote JSON report to coverage.json\n")
27+
28+
yield mocker.patch(
29+
"codecov_cli.plugins.pycoverage.subprocess.run",
30+
side_effect=json_subprocess_side_effect,
31+
)
32+
33+
1834
@pytest.fixture
1935
def combine_subprocess_mock(mocker):
2036
def combine_subprocess_side_effect(first_arg, *args, cwd, **kwargs):
@@ -40,11 +56,49 @@ def generate_XML_report_side_effect(working_dir, *args, **kwargs):
4056
)
4157

4258

43-
class TestPycoverage(object):
59+
class TestPycoveragePathToCoverage(object):
60+
def test_path_from_config(self, tmp_path, mocker):
61+
(tmp_path / ".coverage").touch()
62+
config = {
63+
"project_root": tmp_path,
64+
"path_to_coverage_file": pathlib.Path.joinpath(tmp_path, ".coverage"),
65+
}
66+
plugin = Pycoverage(config)
67+
68+
mock_search_path = mocker.patch("codecov_cli.plugins.pycoverage.search_files")
69+
path = plugin._get_path_to_coverage()
70+
assert path == pathlib.Path.joinpath(tmp_path, ".coverage")
71+
assert path.parent == tmp_path
72+
mock_search_path.assert_not_called()
73+
74+
def test_path_from_config_fallback(self, tmp_path, mocker):
75+
config = {
76+
"project_root": tmp_path,
77+
"path_to_coverage_file": pathlib.Path.joinpath(tmp_path, ".coverage"),
78+
}
79+
plugin = Pycoverage(config)
80+
81+
mock_search_path_return = MagicMock()
82+
mock_search_path = mocker.patch(
83+
"codecov_cli.plugins.pycoverage.search_files",
84+
return_value=mock_search_path_return,
85+
)
86+
path = plugin._get_path_to_coverage()
87+
mock_search_path.assert_called_with(
88+
tmp_path,
89+
[],
90+
filename_include_regex=globs_to_regex([".coverage", ".coverage.*"]),
91+
filename_exclude_regex=None,
92+
)
93+
assert path == mock_search_path_return.__next__.return_value
94+
95+
96+
class TestPycoverageXMLReportGeneration(object):
4497
def test_coverage_combine_called_if_coverage_data_exist(
4598
self, tmp_path, mocker, combine_subprocess_mock
4699
):
47-
Pycoverage(tmp_path)._generate_XML_report(tmp_path)
100+
config = {"project_root": tmp_path}
101+
Pycoverage(config)._generate_XML_report(tmp_path)
48102
assert not combine_subprocess_mock.called
49103

50104
combine_subprocess_mock.reset_mock()
@@ -54,7 +108,7 @@ def test_coverage_combine_called_if_coverage_data_exist(
54108
p = tmp_path / name
55109
p.touch()
56110

57-
Pycoverage(tmp_path)._generate_XML_report(tmp_path)
111+
Pycoverage(config)._generate_XML_report(tmp_path)
58112

59113
combine_subprocess_mock.assert_any_call(
60114
["coverage", "combine", "-a"], cwd=tmp_path
@@ -64,29 +118,56 @@ def test_coverage_combine_called_if_coverage_data_exist(
64118
def test_xml_reports_generated_if_coverage_file_exists(
65119
self, tmp_path, mocker, xml_subprocess_mock
66120
):
67-
68-
Pycoverage(tmp_path)._generate_XML_report(tmp_path)
121+
config = {"project_root": tmp_path}
122+
Pycoverage(config)._generate_XML_report(tmp_path)
69123
xml_subprocess_mock.assert_not_called()
70124

125+
config = {"project_root": tmp_path}
71126
(tmp_path / ".coverage").touch()
72-
Pycoverage(tmp_path)._generate_XML_report(tmp_path)
127+
Pycoverage(config)._generate_XML_report(tmp_path)
73128
xml_subprocess_mock.assert_called_with(
74129
["coverage", "xml", "-i"], cwd=tmp_path, capture_output=True
75130
)
76131
assert (tmp_path / ".coverage").exists()
77132

78-
def test_run_preparation_creates_nothing_if_nothing(
79-
self, mocked_generator, tmp_path, mocker
133+
134+
class TestPycoverageJSONReportGeneration(object):
135+
def test_report_not_generated_if_coverage_not_there(
136+
self, tmp_path, mocker, json_subprocess_mock
80137
):
81-
Pycoverage(tmp_path).run_preparation(None)
82-
assert not (tmp_path / "coverage.xml").exists()
83-
assert not mocked_generator.called
138+
config = {"project_root": tmp_path}
139+
Pycoverage(config)._generate_JSON_report(tmp_path)
140+
json_subprocess_mock.assert_not_called()
84141

142+
def test_reports_generated_if_coverage_file_exists(
143+
self, tmp_path, mocker, json_subprocess_mock
144+
):
145+
config = {"project_root": tmp_path}
146+
(tmp_path / ".coverage").touch()
147+
Pycoverage(config)._generate_JSON_report(tmp_path)
148+
json_subprocess_mock.assert_called_with(
149+
["coverage", "json", "--show-contexts"], cwd=tmp_path, capture_output=True
150+
)
151+
assert (tmp_path / ".coverage").exists()
152+
153+
def test_reports_generated_with_no_context_if_option(
154+
self, tmp_path, mocker, json_subprocess_mock
155+
):
156+
config = {"project_root": tmp_path, "include_contexts": False}
157+
(tmp_path / ".coverage").touch()
158+
Pycoverage(config)._generate_JSON_report(tmp_path)
159+
json_subprocess_mock.assert_called_with(
160+
["coverage", "json"], cwd=tmp_path, capture_output=True
161+
)
162+
163+
164+
class TestPycoverageRunPreparation(object):
85165
def test_run_preparation_creates_reports_in_root_dir(
86166
self, mocked_generator, tmp_path, mocker
87167
):
88168
(tmp_path / ".coverage").touch()
89-
Pycoverage(tmp_path).run_preparation(None)
169+
config = {"project_root": tmp_path}
170+
Pycoverage(config).run_preparation(None)
90171
assert (tmp_path / "coverage.xml").exists()
91172
mocked_generator.assert_called_with(tmp_path)
92173

@@ -95,7 +176,8 @@ def test_run_preparation_creates_reports_in_sub_dirs(
95176
):
96177
(tmp_path / "sub").mkdir()
97178
(tmp_path / "sub" / ".coverage").touch()
98-
Pycoverage(tmp_path).run_preparation(None)
179+
config = {"project_root": tmp_path}
180+
Pycoverage(config).run_preparation(None)
99181

100182
assert (tmp_path / "sub" / "coverage.xml").exists()
101183
mocked_generator.assert_called_with(tmp_path / "sub")
@@ -104,5 +186,14 @@ def test_aborts_plugin_if_coverage_is_not_installed(
104186
self, tmp_path, mocker, mocked_generator
105187
):
106188
mocker.patch("codecov_cli.plugins.pycoverage.shutil.which", return_value=None)
107-
Pycoverage(tmp_path).run_preparation(None)
189+
config = {"project_root": tmp_path}
190+
Pycoverage(config).run_preparation(None)
191+
assert not mocked_generator.called
192+
193+
def test_run_preparation_creates_nothing_if_nothing(
194+
self, mocked_generator, tmp_path, mocker
195+
):
196+
config = {"project_root": tmp_path}
197+
Pycoverage(config).run_preparation(None)
198+
assert not (tmp_path / "coverage.xml").exists()
108199
assert not mocked_generator.called

0 commit comments

Comments
 (0)