diff --git a/.gitignore b/.gitignore index baad8e20..37c83d08 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .idea venv 27venv +.venv # C extensions *.so diff --git a/README.rst b/README.rst index 645dd251..afcb27b1 100644 --- a/README.rst +++ b/README.rst @@ -274,6 +274,18 @@ It can be enabled by using the ``-q``/``--quiet`` flag: If enabled, the tool will only print errors and failures but no information or warning messages. +Compatibility with multi-line statements +---------------------------------------- +``diff-cover`` relies on the comparison of diff reports and coverage reports, and does not report +lines that appear in one and not in the other. While diff reports list all lines that changed, +coverage reports usually list code statements. As a result, a change in a multi-line statement may not be analyzed by ``diff-cover``. + +As a workaround, you can use the argument ``--expand-coverage-report``: lines not appearing in the coverage reports will be added to them with the same number of hits as the previously reported line. ``diff-cover`` will then perform diff coverage analysis on all changed lines. + +Notes: +- This argument is only available for XML coverage reports. +- This workaround is designed under the assumption that the coverage tool reports untested statements with hits set to 0, and it reports statements based on the opening line. + Configuration files ------------------- Both tools allow users to specify the options in a configuration file with `--config-file`/`-c`: diff --git a/diff_cover/diff_cover_tool.py b/diff_cover/diff_cover_tool.py index 5e04bcd5..97b4204f 100644 --- a/diff_cover/diff_cover_tool.py +++ b/diff_cover/diff_cover_tool.py @@ -41,6 +41,9 @@ ) QUIET_HELP = "Only print errors and failures" SHOW_UNCOVERED = "Show uncovered lines on the console" +EXPAND_COVERAGE_REPORT = ( + "Append missing lines in coverage reports based on the hits of the previous line." +) INCLUDE_UNTRACKED_HELP = "Include untracked files" CONFIG_FILE_HELP = "The configuration file to use" DIFF_FILE_HELP = "The diff file to use" @@ -93,6 +96,13 @@ def parse_coverage_args(argv): "--show-uncovered", action="store_true", default=None, help=SHOW_UNCOVERED ) + parser.add_argument( + "--expand-coverage-report", + action="store_true", + default=None, + help=EXPAND_COVERAGE_REPORT, + ) + parser.add_argument( "--external-css-file", metavar="FILENAME", @@ -183,6 +193,7 @@ def parse_coverage_args(argv): "ignore_whitespace": False, "diff_range_notation": "...", "quiet": False, + "expand_coverage_report": False, } return get_config(parser=parser, argv=argv, defaults=defaults, tool=Tool.DIFF_COVER) @@ -204,6 +215,7 @@ def generate_coverage_report( src_roots=None, quiet=False, show_uncovered=False, + expand_coverage_report=False, ): """ Generate the diff coverage report, using kwargs from `parse_args()`. @@ -231,7 +243,7 @@ def generate_coverage_report( if len(xml_roots) > 0 and len(lcov_roots) > 0: raise ValueError(f"Mixing LCov and XML reports is not supported yet") elif len(xml_roots) > 0: - coverage = XmlCoverageReporter(xml_roots, src_roots) + coverage = XmlCoverageReporter(xml_roots, src_roots, expand_coverage_report) else: coverage = LcovCoverageReporter(lcov_roots, src_roots) @@ -308,6 +320,7 @@ def main(argv=None, directory=None): src_roots=arg_dict["src_roots"], quiet=quiet, show_uncovered=arg_dict["show_uncovered"], + expand_coverage_report=arg_dict["expand_coverage_report"], ) if percent_covered >= fail_under: diff --git a/diff_cover/violationsreporters/violations_reporter.py b/diff_cover/violationsreporters/violations_reporter.py index e1d43dbd..15163c3d 100644 --- a/diff_cover/violationsreporters/violations_reporter.py +++ b/diff_cover/violationsreporters/violations_reporter.py @@ -24,7 +24,7 @@ class XmlCoverageReporter(BaseViolationReporter): Query information from a Cobertura|Clover|JaCoCo XML coverage report. """ - def __init__(self, xml_roots, src_roots=None): + def __init__(self, xml_roots, src_roots=None, expand_coverage_report=False): """ Load the XML coverage report represented by the cElementTree with root element `xml_root`. @@ -41,6 +41,7 @@ def __init__(self, xml_roots, src_roots=None): self._xml_cache = [{} for i in range(len(xml_roots))] self._src_roots = src_roots or [""] + self._expand_coverage_report = expand_coverage_report def _get_xml_classes(self, xml_document): """ @@ -216,6 +217,28 @@ def _cache_file(self, src_path): if line_nodes is None: continue + # Expand coverage report with not reported lines + if self._expand_coverage_report: + reported_line_hits = {} + for line in line_nodes: + reported_line_hits[int(line.get(_number))] = int( + line.get(_hits, 0) + ) + if reported_line_hits: + last_hit_number = 0 + for line_number in range( + min(reported_line_hits.keys()), + max(reported_line_hits.keys()), + ): + if line_number in reported_line_hits: + last_hit_number = reported_line_hits[line_number] + else: + # This is an unreported line. + # We add it with the previous line hit score + line_nodes.append( + {_hits: last_hit_number, _number: line_number} + ) + # First case, need to define violations initially if violations is None: violations = { diff --git a/tests/fixtures/coverage_missing_lines.xml b/tests/fixtures/coverage_missing_lines.xml new file mode 100644 index 00000000..45e4f95e --- /dev/null +++ b/tests/fixtures/coverage_missing_lines.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/expand_console_report.txt b/tests/fixtures/expand_console_report.txt new file mode 100644 index 00000000..b951681b --- /dev/null +++ b/tests/fixtures/expand_console_report.txt @@ -0,0 +1,10 @@ +------------- +Diff Coverage +Diff: origin/main...HEAD, staged and unstaged changes +------------- +test_src.txt (50.0%): Missing lines 2-4,8,10 +------------- +Total: 10 lines +Missing: 5 lines +Coverage: 50% +------------- diff --git a/tests/test_integration.py b/tests/test_integration.py index ac1f1fd2..1beace25 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -412,6 +412,20 @@ def test_show_uncovered_lines_console(self): ["diff-cover", "--show-uncovered", "coverage.xml"], ) + def test_expand_coverage_report_complete_report(self): + self._check_console_report( + "git_diff_add.txt", + "add_console_report.txt", + ["diff-cover", "coverage.xml", "--expand-coverage-report"], + ) + + def test_expand_coverage_report_uncomplete_report(self): + self._check_console_report( + "git_diff_add.txt", + "expand_console_report.txt", + ["diff-cover", "coverage_missing_lines.xml", "--expand-coverage-report"], + ) + class TestDiffQualityIntegration(ToolsIntegrationBase): """ diff --git a/tests/test_violations_reporter.py b/tests/test_violations_reporter.py index f97f77d4..ae088600 100644 --- a/tests/test_violations_reporter.py +++ b/tests/test_violations_reporter.py @@ -66,6 +66,21 @@ class TestXmlCoverageReporterTest: ONE_VIOLATION = {Violation(11, None)} VERY_MANY_MEASURED = {2, 3, 5, 7, 11, 13, 17, 23, 24, 25, 26, 26, 27} + MANY_VIOLATIONS_EXPANDED_MANY_MEASURED = { + Violation(3, None), + Violation(4, None), + Violation(7, None), + Violation(8, None), + Violation(9, None), + Violation(10, None), + Violation(11, None), + Violation(12, None), + Violation(13, None), + Violation(14, None), + Violation(15, None), + Violation(16, None), + } + @pytest.fixture(autouse=True) def patch_git_patch(self, mocker): # Paths generated by git_path are always the given argument @@ -266,6 +281,52 @@ def test_no_such_file(self): result = coverage.violations("file.py") assert result == set() + def test_expand_unreported_lines_when_configured(self): + # Construct the XML report + file_paths = ["file1.java"] + # fixture + violations = self.MANY_VIOLATIONS + measured = self.MANY_MEASURED + xml = self._coverage_xml(file_paths, violations, measured) + + # Parse the reports + coverage = XmlCoverageReporter([xml], expand_coverage_report=True) + + # Expect that the name is set + assert coverage.name() == "XML" + + # By construction, each file has the same set + # of covered/uncovered lines + assert self.MANY_VIOLATIONS_EXPANDED_MANY_MEASURED == coverage.violations( + "file1.java" + ) + + def test_expand_unreported_lines_without_violations(self): + # Construct the XML report + file_paths = ["file1.java"] + # fixture + violations = {} + measured = self.MANY_MEASURED + xml = self._coverage_xml(file_paths, violations, measured) + + # Parse the reports + coverage = XmlCoverageReporter([xml], expand_coverage_report=True) + + assert set() == coverage.violations("file1.java") + + def test_expand_unreported_lines_without_measured(self): + # Construct the XML report + file_paths = ["file1.java"] + # fixture + violations = {} + measured = {} + xml = self._coverage_xml(file_paths, violations, measured) + + # Parse the reports + coverage = XmlCoverageReporter([xml], expand_coverage_report=True) + + assert set() == coverage.violations("file1.java") + def _coverage_xml(self, file_paths, violations, measured, source_paths=None): """ Build an XML tree with source files specified by `file_paths`.