diff --git a/diff_cover/diff_cover_tool.py b/diff_cover/diff_cover_tool.py index c46baed8..438039ed 100644 --- a/diff_cover/diff_cover_tool.py +++ b/diff_cover/diff_cover_tool.py @@ -54,6 +54,9 @@ TOTAL_PERCENT_FLOAT_HELP = ( "Show total coverage/quality as a float rounded to 2 decimal places" ) +TREAT_UNMEASURED_AS_UNCOVERED_HELP = ( + "Treat lines in the diff that are absent from the coverage report as uncovered" +) LOGGER = logging.getLogger(__name__) @@ -190,6 +193,12 @@ def parse_coverage_args(argv): default=None, help=TOTAL_PERCENT_FLOAT_HELP, ) + parser.add_argument( + "--treat-unmeasured-as-uncovered", + action="store_true", + default=None, + help=TREAT_UNMEASURED_AS_UNCOVERED_HELP, + ) defaults = { "show_uncovered": False, @@ -204,6 +213,7 @@ def parse_coverage_args(argv): "quiet": False, "expand_coverage_report": False, "total_percent_float": False, + "treat_unmeasured_as_uncovered": False, } return get_config(parser=parser, argv=argv, defaults=defaults, tool=Tool.DIFF_COVER) @@ -225,6 +235,7 @@ def generate_coverage_report( show_uncovered=False, expand_coverage_report=False, total_percent_float=False, + treat_unmeasured_as_uncovered=False, ): """ Generate the diff coverage report, using kwargs from `parse_args()`. @@ -237,6 +248,7 @@ def generate_coverage_report( include_untracked=include_untracked, exclude=exclude, include=include, + filter_blank_lines=treat_unmeasured_as_uncovered, ) xml_roots = [ @@ -263,7 +275,11 @@ def generate_coverage_report( if css_url is not None: css_url = os.path.relpath(css_file, os.path.dirname(html_report)) reporter = HtmlReportGenerator( - coverage, diff, css_url=css_url, total_percent_float=total_percent_float + coverage, + diff, + css_url=css_url, + total_percent_float=total_percent_float, + treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered, ) with open_file(html_report, "wb") as output_file: reporter.generate_report(output_file) @@ -274,7 +290,10 @@ def generate_coverage_report( if "json" in report_formats: json_report = report_formats["json"] or JSON_REPORT_DEFAULT_PATH reporter = JsonReportGenerator( - coverage, diff, total_percent_float=total_percent_float + coverage, + diff, + total_percent_float=total_percent_float, + treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered, ) with open_file(json_report, "wb") as output_file: reporter.generate_report(output_file) @@ -282,7 +301,10 @@ def generate_coverage_report( if "markdown" in report_formats: markdown_report = report_formats["markdown"] or MARKDOWN_REPORT_DEFAULT_PATH reporter = MarkdownReportGenerator( - coverage, diff, total_percent_float=total_percent_float + coverage, + diff, + total_percent_float=total_percent_float, + treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered, ) with open_file(markdown_report, "wb") as output_file: reporter.generate_report(output_file) @@ -294,6 +316,7 @@ def generate_coverage_report( diff, report_formats["github-annotations"], total_percent_float=total_percent_float, + treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered, ) reporter.generate_report(sys.stdout.buffer) @@ -303,6 +326,7 @@ def generate_coverage_report( diff, show_uncovered, total_percent_float=total_percent_float, + treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered, ) output_file = io.BytesIO() if quiet else sys.stdout.buffer @@ -400,6 +424,7 @@ def main(argv=None, directory=None): show_uncovered=arg_dict["show_uncovered"], expand_coverage_report=arg_dict["expand_coverage_report"], total_percent_float=arg_dict["total_percent_float"], + treat_unmeasured_as_uncovered=arg_dict["treat_unmeasured_as_uncovered"], ) if percent_covered >= fail_under: diff --git a/diff_cover/diff_reporter.py b/diff_cover/diff_reporter.py index 0cb77e8e..ebbf9293 100644 --- a/diff_cover/diff_reporter.py +++ b/diff_cover/diff_reporter.py @@ -117,6 +117,7 @@ def __init__( supported_extensions=None, exclude=None, include=None, + filter_blank_lines=False, ): """ Configure the reporter to use `git_diff` as the wrapper @@ -148,6 +149,7 @@ def __init__( self._ignore_unstaged = ignore_unstaged self._include_untracked = include_untracked self._supported_extensions = supported_extensions + self._filter_blank_lines = filter_blank_lines # Cache diff information as a dictionary # with file path keys and line number list values @@ -377,6 +379,9 @@ def _parse_lines(self, diff_lines): where `ADDED_LINES` and `DELETED_LINES` are lists of line numbers added/deleted respectively. + If `self._filter_blank_lines` is True, blank/whitespace-only + added lines are excluded from `ADDED_LINES`. + Raises a `GitDiffError` if the diff lines are in an invalid format. """ @@ -399,8 +404,10 @@ def _parse_lines(self, diff_lines): # calling this method, we're guaranteed to have a source # file specified. We check anyway just to be safe. if current_line_new is not None: - # Store the added line - added_lines.append(current_line_new) + # Skip blank/whitespace-only added lines when filtering + if not self._filter_blank_lines or line[1:].strip(): + # Store the added line + added_lines.append(current_line_new) # Increment the line number in the file current_line_new += 1 diff --git a/diff_cover/report_generator.py b/diff_cover/report_generator.py index f862e615..745c20eb 100644 --- a/diff_cover/report_generator.py +++ b/diff_cover/report_generator.py @@ -11,6 +11,7 @@ from diff_cover.snippets import Snippet from diff_cover.util import to_unix_path +from diff_cover.violationsreporters.base import Violation class DiffViolations: @@ -18,7 +19,13 @@ class DiffViolations: Class to capture violations generated by a particular diff """ - def __init__(self, violations, measured_lines, diff_lines): + def __init__( + self, + violations, + measured_lines, + diff_lines, + treat_unmeasured_as_uncovered=False, + ): self.lines = {violation.line for violation in violations}.intersection( diff_lines ) @@ -36,13 +43,28 @@ def __init__(self, violations, measured_lines, diff_lines): else: self.measured_lines = set(measured_lines).intersection(diff_lines) + # When enabled and coverage data exists for this file, + # treat diff lines absent from the coverage report as uncovered. + if treat_unmeasured_as_uncovered and self.measured_lines: + unmeasured_diff_lines = set(diff_lines) - set(measured_lines) + for line_num in unmeasured_diff_lines: + self.measured_lines.add(line_num) + self.lines.add(line_num) + self.violations.add(Violation(line_num, None)) + class BaseReportGenerator(ABC): """ Generate a diff coverage report. """ - def __init__(self, violations_reporter, diff_reporter, total_percent_float=False): + def __init__( + self, + violations_reporter, + diff_reporter, + total_percent_float=False, + treat_unmeasured_as_uncovered=False, + ): """ Configure the report generator to build a report from `violations_reporter` (of type BaseViolationReporter) @@ -51,6 +73,7 @@ def __init__(self, violations_reporter, diff_reporter, total_percent_float=False self._violations = violations_reporter self._diff = diff_reporter self._total_percent_float = total_percent_float + self._treat_unmeasured_as_uncovered = treat_unmeasured_as_uncovered self._diff_violations_dict = None self._cache_violations = None @@ -204,6 +227,7 @@ def _diff_violations(self): violations.get(to_unix_path(src_path), []), self._violations.measured_lines(src_path), self._diff.lines_changed(src_path), + treat_unmeasured_as_uncovered=self._treat_unmeasured_as_uncovered, ) for src_path in src_paths_changed } @@ -213,6 +237,7 @@ def _diff_violations(self): self._violations.violations(src_path), self._violations.measured_lines(src_path), self._diff.lines_changed(src_path), + treat_unmeasured_as_uncovered=self._treat_unmeasured_as_uncovered, ) for src_path in src_paths_changed } @@ -295,11 +320,13 @@ def __init__( diff_reporter, css_url=None, total_percent_float=False, + treat_unmeasured_as_uncovered=False, ): super().__init__( violations_reporter, diff_reporter, total_percent_float=total_percent_float, + treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered, ) self.css_url = css_url @@ -438,11 +465,13 @@ def __init__( diff_reporter, show_uncovered=False, total_percent_float=False, + treat_unmeasured_as_uncovered=False, ): super().__init__( violations_reporter, diff_reporter, total_percent_float=total_percent_float, + treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered, ) self.include_snippets = show_uncovered @@ -464,11 +493,13 @@ def __init__( diff_reporter, annotations_type, total_percent_float=False, + treat_unmeasured_as_uncovered=False, ): super().__init__( violations_reporter, diff_reporter, total_percent_float=total_percent_float, + treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered, ) self.annotations_type = annotations_type diff --git a/tests/test_diff_reporter.py b/tests/test_diff_reporter.py index fa523cd2..86c4a703 100644 --- a/tests/test_diff_reporter.py +++ b/tests/test_diff_reporter.py @@ -466,6 +466,63 @@ def test_git_diff_error( diff.lines_changed("subdir/file1.py") +def test_blank_lines_included_by_default(diff, git_diff): + """Without filter_blank_lines, blank added lines are included in lines_changed().""" + diff_str = dedent(""" + diff --git a/file.py b/file.py + @@ -1,3 +1,6 @@ + existing line + + + +def something(): + + print("hello") + + + + + """) + + _set_git_diff_output(diff, git_diff, diff_str, "", "") + + # All added lines are included (default behavior preserved) + assert diff.lines_changed("file.py") == [2, 3, 4, 5, 6] + + +def test_blank_lines_filtered_when_flag_set(git_diff): + """With filter_blank_lines=True, blank added lines are excluded from lines_changed().""" + diff = GitDiffReporter(git_diff=git_diff, filter_blank_lines=True) + diff_str = dedent(""" + diff --git a/file.py b/file.py + @@ -1,3 +1,6 @@ + existing line + + + +def something(): + + print("hello") + + + + + """) + + _set_git_diff_output(diff, git_diff, diff_str, "", "") + + # Only non-blank added lines are included + assert diff.lines_changed("file.py") == [3, 4] + + +def test_whitespace_only_lines_filtered_when_flag_set(git_diff): + """With filter_blank_lines=True, whitespace-only added lines are excluded.""" + diff = GitDiffReporter(git_diff=git_diff, filter_blank_lines=True) + diff_str = dedent(""" + diff --git a/file.py b/file.py + @@ -1,1 +1,4 @@ + existing line + + \t + +code_line + + + """) + + _set_git_diff_output(diff, git_diff, diff_str, "", "") + + # Only non-blank added line is included + assert diff.lines_changed("file.py") == [3] + + def test_plus_sign_in_hunk_bug(diff, git_diff): # This was a bug that caused a parse error diff_str = dedent(""" diff --git a/tests/test_report_generator.py b/tests/test_report_generator.py index 2136ce99..9e8b4815 100644 --- a/tests/test_report_generator.py +++ b/tests/test_report_generator.py @@ -613,6 +613,95 @@ def test_multiple_snippets(self): self.assert_report(expected) +class TestTreatUnmeasuredAsUncovered(BaseReportGeneratorTest): + """Tests for the treat_unmeasured_as_uncovered flag.""" + + @pytest.fixture + def report(self, coverage, diff): + return SimpleReportGenerator(coverage, diff, treat_unmeasured_as_uncovered=True) + + def test_unmeasured_diff_lines_become_violations( + self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines + ): + """When flag is enabled, diff lines absent from measured_lines become violations.""" + diff.src_paths_changed.return_value = ["file.py"] + # Diff has lines 1, 2, 3 + diff_lines_changed.update({"file.py": [1, 2, 3]}) + # Coverage only measures line 1 (with a hit) + coverage_measured_lines.update({"file.py": [1]}) + coverage_violations.update({"file.py": []}) + + # Lines 2 and 3 are unmeasured, should become violations + assert self.report.total_num_lines() == 3 + assert self.report.total_num_violations() == 2 + assert sorted(self.report.violation_lines("file.py")) == [2, 3] + + def test_empty_measured_lines_no_expansion( + self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines + ): + """When measured_lines is empty (file not in coverage), no expansion happens.""" + diff.src_paths_changed.return_value = ["file.py"] + diff_lines_changed.update({"file.py": [1, 2, 3]}) + # Empty measured lines means file is not in coverage report + coverage_measured_lines.update({"file.py": []}) + coverage_violations.update({"file.py": []}) + + # No measured lines means no expansion + assert self.report.total_num_lines() == 0 + assert self.report.total_num_violations() == 0 + + def test_measured_lines_none_no_expansion( + self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines + ): + """When measured_lines is None (quality reporters), all diff lines are measured.""" + diff.src_paths_changed.return_value = ["file.py"] + diff_lines_changed.update({"file.py": [1, 2, 3]}) + # None means all lines are measured (quality reporter convention) + coverage_measured_lines.update({"file.py": None}) + coverage_violations.update({"file.py": []}) + + # All 3 lines measured, no violations + assert self.report.total_num_lines() == 3 + assert self.report.total_num_violations() == 0 + + def test_existing_violations_plus_unmeasured( + self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines + ): + """Existing violations are preserved alongside new unmeasured-line violations.""" + diff.src_paths_changed.return_value = ["file.py"] + diff_lines_changed.update({"file.py": [1, 2, 3, 4]}) + # Lines 1 and 2 are measured; line 2 is a violation + coverage_measured_lines.update({"file.py": [1, 2]}) + coverage_violations.update({"file.py": [Violation(2, None)]}) + + # Line 2 is an existing violation, lines 3 and 4 are unmeasured -> violations + assert self.report.total_num_lines() == 4 + assert self.report.total_num_violations() == 3 + assert sorted(self.report.violation_lines("file.py")) == [2, 3, 4] + + +class TestTreatUnmeasuredDisabled(BaseReportGeneratorTest): + """Tests to confirm default behavior (flag disabled) is preserved.""" + + @pytest.fixture + def report(self, coverage, diff): + return SimpleReportGenerator(coverage, diff) + + def test_unmeasured_lines_not_flagged_by_default( + self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines + ): + """With flag disabled, unmeasured diff lines are silently ignored.""" + diff.src_paths_changed.return_value = ["file.py"] + diff_lines_changed.update({"file.py": [1, 2, 3]}) + # Coverage only measures line 1 + coverage_measured_lines.update({"file.py": [1]}) + coverage_violations.update({"file.py": []}) + + # Only 1 measured line, no violations (lines 2,3 are ignored) + assert self.report.total_num_lines() == 1 + assert self.report.total_num_violations() == 0 + + class TestSimpleReportGeneratorWithBatchViolationReporter(BaseReportGeneratorTest): @pytest.fixture