Skip to content

Commit b47f89d

Browse files
committed
implements unmeasured-as-uncovered
1 parent 4df1cac commit b47f89d

File tree

5 files changed

+190
-7
lines changed

5 files changed

+190
-7
lines changed

diff_cover/diff_cover_tool.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
5454
TOTAL_PERCENT_FLOAT_HELP = (
5555
"Show total coverage/quality as a float rounded to 2 decimal places"
5656
)
57+
TREAT_UNMEASURED_AS_UNCOVERED_HELP = (
58+
"Treat lines in the diff that are absent from the coverage report as uncovered"
59+
)
5760

5861
LOGGER = logging.getLogger(__name__)
5962

@@ -190,6 +193,12 @@ def parse_coverage_args(argv):
190193
default=None,
191194
help=TOTAL_PERCENT_FLOAT_HELP,
192195
)
196+
parser.add_argument(
197+
"--treat-unmeasured-as-uncovered",
198+
action="store_true",
199+
default=None,
200+
help=TREAT_UNMEASURED_AS_UNCOVERED_HELP,
201+
)
193202

194203
defaults = {
195204
"show_uncovered": False,
@@ -204,6 +213,7 @@ def parse_coverage_args(argv):
204213
"quiet": False,
205214
"expand_coverage_report": False,
206215
"total_percent_float": False,
216+
"treat_unmeasured_as_uncovered": False,
207217
}
208218

209219
return get_config(parser=parser, argv=argv, defaults=defaults, tool=Tool.DIFF_COVER)
@@ -225,6 +235,7 @@ def generate_coverage_report(
225235
show_uncovered=False,
226236
expand_coverage_report=False,
227237
total_percent_float=False,
238+
treat_unmeasured_as_uncovered=False,
228239
):
229240
"""
230241
Generate the diff coverage report, using kwargs from `parse_args()`.
@@ -263,7 +274,11 @@ def generate_coverage_report(
263274
if css_url is not None:
264275
css_url = os.path.relpath(css_file, os.path.dirname(html_report))
265276
reporter = HtmlReportGenerator(
266-
coverage, diff, css_url=css_url, total_percent_float=total_percent_float
277+
coverage,
278+
diff,
279+
css_url=css_url,
280+
total_percent_float=total_percent_float,
281+
treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered,
267282
)
268283
with open_file(html_report, "wb") as output_file:
269284
reporter.generate_report(output_file)
@@ -274,15 +289,21 @@ def generate_coverage_report(
274289
if "json" in report_formats:
275290
json_report = report_formats["json"] or JSON_REPORT_DEFAULT_PATH
276291
reporter = JsonReportGenerator(
277-
coverage, diff, total_percent_float=total_percent_float
292+
coverage,
293+
diff,
294+
total_percent_float=total_percent_float,
295+
treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered,
278296
)
279297
with open_file(json_report, "wb") as output_file:
280298
reporter.generate_report(output_file)
281299

282300
if "markdown" in report_formats:
283301
markdown_report = report_formats["markdown"] or MARKDOWN_REPORT_DEFAULT_PATH
284302
reporter = MarkdownReportGenerator(
285-
coverage, diff, total_percent_float=total_percent_float
303+
coverage,
304+
diff,
305+
total_percent_float=total_percent_float,
306+
treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered,
286307
)
287308
with open_file(markdown_report, "wb") as output_file:
288309
reporter.generate_report(output_file)
@@ -294,6 +315,7 @@ def generate_coverage_report(
294315
diff,
295316
report_formats["github-annotations"],
296317
total_percent_float=total_percent_float,
318+
treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered,
297319
)
298320
reporter.generate_report(sys.stdout.buffer)
299321

@@ -303,6 +325,7 @@ def generate_coverage_report(
303325
diff,
304326
show_uncovered,
305327
total_percent_float=total_percent_float,
328+
treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered,
306329
)
307330
output_file = io.BytesIO() if quiet else sys.stdout.buffer
308331

@@ -400,6 +423,7 @@ def main(argv=None, directory=None):
400423
show_uncovered=arg_dict["show_uncovered"],
401424
expand_coverage_report=arg_dict["expand_coverage_report"],
402425
total_percent_float=arg_dict["total_percent_float"],
426+
treat_unmeasured_as_uncovered=arg_dict["treat_unmeasured_as_uncovered"],
403427
)
404428

405429
if percent_covered >= fail_under:

diff_cover/diff_reporter.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,8 +399,10 @@ def _parse_lines(self, diff_lines):
399399
# calling this method, we're guaranteed to have a source
400400
# file specified. We check anyway just to be safe.
401401
if current_line_new is not None:
402-
# Store the added line
403-
added_lines.append(current_line_new)
402+
# Skip blank/whitespace-only added lines
403+
if line[1:].strip():
404+
# Store the added line
405+
added_lines.append(current_line_new)
404406

405407
# Increment the line number in the file
406408
current_line_new += 1

diff_cover/report_generator.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@
1111

1212
from diff_cover.snippets import Snippet
1313
from diff_cover.util import to_unix_path
14+
from diff_cover.violationsreporters.base import Violation
1415

1516

1617
class DiffViolations:
1718
"""
1819
Class to capture violations generated by a particular diff
1920
"""
2021

21-
def __init__(self, violations, measured_lines, diff_lines):
22+
def __init__(
23+
self, violations, measured_lines, diff_lines, treat_unmeasured_as_uncovered=False
24+
):
2225
self.lines = {violation.line for violation in violations}.intersection(
2326
diff_lines
2427
)
@@ -36,13 +39,28 @@ def __init__(self, violations, measured_lines, diff_lines):
3639
else:
3740
self.measured_lines = set(measured_lines).intersection(diff_lines)
3841

42+
# When enabled and coverage data exists for this file,
43+
# treat diff lines absent from the coverage report as uncovered.
44+
if treat_unmeasured_as_uncovered and self.measured_lines:
45+
unmeasured_diff_lines = set(diff_lines) - set(measured_lines)
46+
for line_num in unmeasured_diff_lines:
47+
self.measured_lines.add(line_num)
48+
self.lines.add(line_num)
49+
self.violations.add(Violation(line_num, None))
50+
3951

4052
class BaseReportGenerator(ABC):
4153
"""
4254
Generate a diff coverage report.
4355
"""
4456

45-
def __init__(self, violations_reporter, diff_reporter, total_percent_float=False):
57+
def __init__(
58+
self,
59+
violations_reporter,
60+
diff_reporter,
61+
total_percent_float=False,
62+
treat_unmeasured_as_uncovered=False,
63+
):
4664
"""
4765
Configure the report generator to build a report
4866
from `violations_reporter` (of type BaseViolationReporter)
@@ -51,6 +69,7 @@ def __init__(self, violations_reporter, diff_reporter, total_percent_float=False
5169
self._violations = violations_reporter
5270
self._diff = diff_reporter
5371
self._total_percent_float = total_percent_float
72+
self._treat_unmeasured_as_uncovered = treat_unmeasured_as_uncovered
5473
self._diff_violations_dict = None
5574

5675
self._cache_violations = None
@@ -204,6 +223,7 @@ def _diff_violations(self):
204223
violations.get(to_unix_path(src_path), []),
205224
self._violations.measured_lines(src_path),
206225
self._diff.lines_changed(src_path),
226+
treat_unmeasured_as_uncovered=self._treat_unmeasured_as_uncovered,
207227
)
208228
for src_path in src_paths_changed
209229
}
@@ -213,6 +233,7 @@ def _diff_violations(self):
213233
self._violations.violations(src_path),
214234
self._violations.measured_lines(src_path),
215235
self._diff.lines_changed(src_path),
236+
treat_unmeasured_as_uncovered=self._treat_unmeasured_as_uncovered,
216237
)
217238
for src_path in src_paths_changed
218239
}
@@ -295,11 +316,13 @@ def __init__(
295316
diff_reporter,
296317
css_url=None,
297318
total_percent_float=False,
319+
treat_unmeasured_as_uncovered=False,
298320
):
299321
super().__init__(
300322
violations_reporter,
301323
diff_reporter,
302324
total_percent_float=total_percent_float,
325+
treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered,
303326
)
304327
self.css_url = css_url
305328

@@ -438,11 +461,13 @@ def __init__(
438461
diff_reporter,
439462
show_uncovered=False,
440463
total_percent_float=False,
464+
treat_unmeasured_as_uncovered=False,
441465
):
442466
super().__init__(
443467
violations_reporter,
444468
diff_reporter,
445469
total_percent_float=total_percent_float,
470+
treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered,
446471
)
447472
self.include_snippets = show_uncovered
448473

@@ -464,11 +489,13 @@ def __init__(
464489
diff_reporter,
465490
annotations_type,
466491
total_percent_float=False,
492+
treat_unmeasured_as_uncovered=False,
467493
):
468494
super().__init__(
469495
violations_reporter,
470496
diff_reporter,
471497
total_percent_float=total_percent_float,
498+
treat_unmeasured_as_uncovered=treat_unmeasured_as_uncovered,
472499
)
473500
self.annotations_type = annotations_type
474501

tests/test_diff_reporter.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,45 @@ def test_git_diff_error(
466466
diff.lines_changed("subdir/file1.py")
467467

468468

469+
def test_blank_added_lines_excluded(diff, git_diff):
470+
"""Blank/whitespace-only added lines should be excluded from lines_changed()."""
471+
diff_str = dedent("""
472+
diff --git a/file.py b/file.py
473+
@@ -1,3 +1,6 @@
474+
existing line
475+
+
476+
+def something():
477+
+ print("hello")
478+
+
479+
+
480+
""")
481+
482+
_set_git_diff_output(diff, git_diff, diff_str, "", "")
483+
484+
lines_changed = diff.lines_changed("file.py")
485+
# Lines 2, 5, 6 are blank/whitespace-only, should be excluded
486+
# Only lines 3 and 4 (non-blank added lines) should be included
487+
assert lines_changed == [3, 4]
488+
489+
490+
def test_whitespace_only_added_lines_excluded(diff, git_diff):
491+
"""Added lines with only spaces/tabs should be excluded from lines_changed()."""
492+
diff_str = dedent("""
493+
diff --git a/file.py b/file.py
494+
@@ -1,1 +1,4 @@
495+
existing line
496+
+ \t
497+
+code_line
498+
+
499+
""")
500+
501+
_set_git_diff_output(diff, git_diff, diff_str, "", "")
502+
503+
lines_changed = diff.lines_changed("file.py")
504+
# Only line 3 (code_line) should be included
505+
assert lines_changed == [3]
506+
507+
469508
def test_plus_sign_in_hunk_bug(diff, git_diff):
470509
# This was a bug that caused a parse error
471510
diff_str = dedent("""

tests/test_report_generator.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,97 @@ def test_multiple_snippets(self):
613613
self.assert_report(expected)
614614

615615

616+
class TestTreatUnmeasuredAsUncovered(BaseReportGeneratorTest):
617+
"""Tests for the treat_unmeasured_as_uncovered flag."""
618+
619+
@pytest.fixture
620+
def report(self, coverage, diff):
621+
return SimpleReportGenerator(
622+
coverage, diff, treat_unmeasured_as_uncovered=True
623+
)
624+
625+
def test_unmeasured_diff_lines_become_violations(
626+
self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines
627+
):
628+
"""When flag is enabled, diff lines absent from measured_lines become violations."""
629+
diff.src_paths_changed.return_value = ["file.py"]
630+
# Diff has lines 1, 2, 3
631+
diff_lines_changed.update({"file.py": [1, 2, 3]})
632+
# Coverage only measures line 1 (with a hit)
633+
coverage_measured_lines.update({"file.py": [1]})
634+
coverage_violations.update({"file.py": []})
635+
636+
# Lines 2 and 3 are unmeasured, should become violations
637+
assert self.report.total_num_lines() == 3
638+
assert self.report.total_num_violations() == 2
639+
assert sorted(self.report.violation_lines("file.py")) == [2, 3]
640+
641+
def test_empty_measured_lines_no_expansion(
642+
self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines
643+
):
644+
"""When measured_lines is empty (file not in coverage), no expansion happens."""
645+
diff.src_paths_changed.return_value = ["file.py"]
646+
diff_lines_changed.update({"file.py": [1, 2, 3]})
647+
# Empty measured lines means file is not in coverage report
648+
coverage_measured_lines.update({"file.py": []})
649+
coverage_violations.update({"file.py": []})
650+
651+
# No measured lines means no expansion
652+
assert self.report.total_num_lines() == 0
653+
assert self.report.total_num_violations() == 0
654+
655+
def test_measured_lines_none_no_expansion(
656+
self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines
657+
):
658+
"""When measured_lines is None (quality reporters), all diff lines are measured."""
659+
diff.src_paths_changed.return_value = ["file.py"]
660+
diff_lines_changed.update({"file.py": [1, 2, 3]})
661+
# None means all lines are measured (quality reporter convention)
662+
coverage_measured_lines.update({"file.py": None})
663+
coverage_violations.update({"file.py": []})
664+
665+
# All 3 lines measured, no violations
666+
assert self.report.total_num_lines() == 3
667+
assert self.report.total_num_violations() == 0
668+
669+
def test_existing_violations_plus_unmeasured(
670+
self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines
671+
):
672+
"""Existing violations are preserved alongside new unmeasured-line violations."""
673+
diff.src_paths_changed.return_value = ["file.py"]
674+
diff_lines_changed.update({"file.py": [1, 2, 3, 4]})
675+
# Lines 1 and 2 are measured; line 2 is a violation
676+
coverage_measured_lines.update({"file.py": [1, 2]})
677+
coverage_violations.update({"file.py": [Violation(2, None)]})
678+
679+
# Line 2 is an existing violation, lines 3 and 4 are unmeasured -> violations
680+
assert self.report.total_num_lines() == 4
681+
assert self.report.total_num_violations() == 3
682+
assert sorted(self.report.violation_lines("file.py")) == [2, 3, 4]
683+
684+
685+
class TestTreatUnmeasuredDisabled(BaseReportGeneratorTest):
686+
"""Tests to confirm default behavior (flag disabled) is preserved."""
687+
688+
@pytest.fixture
689+
def report(self, coverage, diff):
690+
return SimpleReportGenerator(coverage, diff)
691+
692+
def test_unmeasured_lines_not_flagged_by_default(
693+
self, diff, diff_lines_changed, coverage_violations, coverage_measured_lines
694+
):
695+
"""With flag disabled, unmeasured diff lines are silently ignored."""
696+
diff.src_paths_changed.return_value = ["file.py"]
697+
diff_lines_changed.update({"file.py": [1, 2, 3]})
698+
# Coverage only measures line 1
699+
coverage_measured_lines.update({"file.py": [1]})
700+
coverage_violations.update({"file.py": []})
701+
702+
# Only 1 measured line, no violations (lines 2,3 are ignored)
703+
assert self.report.total_num_lines() == 1
704+
assert self.report.total_num_violations() == 0
705+
706+
616707
class TestSimpleReportGeneratorWithBatchViolationReporter(BaseReportGeneratorTest):
617708

618709
@pytest.fixture

0 commit comments

Comments
 (0)