From b47f89dca6a40eb47e5aa0ada44bc4321d6d8b02 Mon Sep 17 00:00:00 2001 From: Adam Weidner Date: Fri, 20 Feb 2026 19:31:34 +0000 Subject: [PATCH 1/3] implements unmeasured-as-uncovered --- diff_cover/diff_cover_tool.py | 30 +++++++++-- diff_cover/diff_reporter.py | 6 ++- diff_cover/report_generator.py | 31 +++++++++++- tests/test_diff_reporter.py | 39 +++++++++++++++ tests/test_report_generator.py | 91 ++++++++++++++++++++++++++++++++++ 5 files changed, 190 insertions(+), 7 deletions(-) diff --git a/diff_cover/diff_cover_tool.py b/diff_cover/diff_cover_tool.py index c46baed8..6f763375 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()`. @@ -263,7 +274,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 +289,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 +300,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 +315,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 +325,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 +423,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..f5be4e0e 100644 --- a/diff_cover/diff_reporter.py +++ b/diff_cover/diff_reporter.py @@ -399,8 +399,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 + if 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..a1e24357 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,9 @@ 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 +39,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 +69,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 +223,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 +233,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 +316,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 +461,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 +489,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..f48fe17e 100644 --- a/tests/test_diff_reporter.py +++ b/tests/test_diff_reporter.py @@ -466,6 +466,45 @@ def test_git_diff_error( diff.lines_changed("subdir/file1.py") +def test_blank_added_lines_excluded(diff, git_diff): + """Blank/whitespace-only added lines should be excluded from 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, "", "") + + lines_changed = diff.lines_changed("file.py") + # Lines 2, 5, 6 are blank/whitespace-only, should be excluded + # Only lines 3 and 4 (non-blank added lines) should be included + assert lines_changed == [3, 4] + + +def test_whitespace_only_added_lines_excluded(diff, git_diff): + """Added lines with only spaces/tabs should be excluded from lines_changed().""" + 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, "", "") + + lines_changed = diff.lines_changed("file.py") + # Only line 3 (code_line) should be included + assert lines_changed == [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..3269f3c7 100644 --- a/tests/test_report_generator.py +++ b/tests/test_report_generator.py @@ -613,6 +613,97 @@ 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 From 5490fa68081513c59edec4193972da65475071eb Mon Sep 17 00:00:00 2001 From: Adam Weidner Date: Fri, 20 Feb 2026 20:00:00 +0000 Subject: [PATCH 2/3] formatting --- diff_cover/report_generator.py | 6 +++++- tests/test_report_generator.py | 4 +--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/diff_cover/report_generator.py b/diff_cover/report_generator.py index a1e24357..745c20eb 100644 --- a/diff_cover/report_generator.py +++ b/diff_cover/report_generator.py @@ -20,7 +20,11 @@ class DiffViolations: """ def __init__( - self, violations, measured_lines, diff_lines, treat_unmeasured_as_uncovered=False + self, + violations, + measured_lines, + diff_lines, + treat_unmeasured_as_uncovered=False, ): self.lines = {violation.line for violation in violations}.intersection( diff_lines diff --git a/tests/test_report_generator.py b/tests/test_report_generator.py index 3269f3c7..9e8b4815 100644 --- a/tests/test_report_generator.py +++ b/tests/test_report_generator.py @@ -618,9 +618,7 @@ class TestTreatUnmeasuredAsUncovered(BaseReportGeneratorTest): @pytest.fixture def report(self, coverage, diff): - return SimpleReportGenerator( - coverage, diff, treat_unmeasured_as_uncovered=True - ) + 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 From 529b1acbcf9bbc111f6799ba3d47036b41a6ed15 Mon Sep 17 00:00:00 2001 From: Adam Weidner Date: Fri, 20 Feb 2026 20:21:32 +0000 Subject: [PATCH 3/3] continue to preserve old behavior --- diff_cover/diff_cover_tool.py | 1 + diff_cover/diff_reporter.py | 9 ++++++-- tests/test_diff_reporter.py | 40 +++++++++++++++++++++++++---------- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/diff_cover/diff_cover_tool.py b/diff_cover/diff_cover_tool.py index 6f763375..438039ed 100644 --- a/diff_cover/diff_cover_tool.py +++ b/diff_cover/diff_cover_tool.py @@ -248,6 +248,7 @@ def generate_coverage_report( include_untracked=include_untracked, exclude=exclude, include=include, + filter_blank_lines=treat_unmeasured_as_uncovered, ) xml_roots = [ diff --git a/diff_cover/diff_reporter.py b/diff_cover/diff_reporter.py index f5be4e0e..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,8 @@ 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: - # Skip blank/whitespace-only added lines - if line[1:].strip(): + # 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) diff --git a/tests/test_diff_reporter.py b/tests/test_diff_reporter.py index f48fe17e..86c4a703 100644 --- a/tests/test_diff_reporter.py +++ b/tests/test_diff_reporter.py @@ -466,8 +466,8 @@ def test_git_diff_error( diff.lines_changed("subdir/file1.py") -def test_blank_added_lines_excluded(diff, git_diff): - """Blank/whitespace-only added lines should be excluded from lines_changed().""" +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 @@ @@ -481,14 +481,33 @@ def test_blank_added_lines_excluded(diff, git_diff): _set_git_diff_output(diff, git_diff, diff_str, "", "") - lines_changed = diff.lines_changed("file.py") - # Lines 2, 5, 6 are blank/whitespace-only, should be excluded - # Only lines 3 and 4 (non-blank added lines) should be included - assert lines_changed == [3, 4] + # All added lines are included (default behavior preserved) + assert diff.lines_changed("file.py") == [2, 3, 4, 5, 6] -def test_whitespace_only_added_lines_excluded(diff, git_diff): - """Added lines with only spaces/tabs should be excluded from lines_changed().""" +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 @@ @@ -500,9 +519,8 @@ def test_whitespace_only_added_lines_excluded(diff, git_diff): _set_git_diff_output(diff, git_diff, diff_str, "", "") - lines_changed = diff.lines_changed("file.py") - # Only line 3 (code_line) should be included - assert lines_changed == [3] + # Only non-blank added line is included + assert diff.lines_changed("file.py") == [3] def test_plus_sign_in_hunk_bug(diff, git_diff):