Skip to content

Commit 9854ba2

Browse files
committed
Model coverage stats as dataclasses
Change-Id: I540182ba0eb9578df65eced9212933cfd92b7ee1
1 parent a625198 commit 9854ba2

2 files changed

Lines changed: 71 additions & 49 deletions

File tree

tests/qa_metrics/unit_test_coverage/summary.py

Lines changed: 38 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
import csv
1010
from collections import defaultdict
1111
from collections.abc import Iterable
12+
from dataclasses import asdict, dataclass
1213
from pathlib import Path
13-
from typing import TypedDict
1414

1515

16-
class CoverageStats(TypedDict):
17-
"""Type definition for coverage statistics."""
16+
@dataclass(frozen=True, kw_only=True)
17+
class CoverageStats:
18+
"""Line and function coverage of a module or the whole repo, with percentages."""
1819

1920
lines_coverage_percent: float
2021
functions_coverage_percent: float
@@ -24,25 +25,24 @@ class CoverageStats(TypedDict):
2425
total_functions: int
2526

2627

27-
class RawStats(TypedDict):
28-
"""Type definition for raw statistics from LCOV parsing."""
28+
@dataclass(kw_only=True)
29+
class RawStats:
30+
"""Covered and total line/function counts accumulated from an LCOV tracefile."""
2931

30-
lines: int
31-
lines_covered: int
32-
functions: int
33-
functions_covered: int
32+
lines: int = 0
33+
lines_covered: int = 0
34+
functions: int = 0
35+
functions_covered: int = 0
3436

37+
def record_line(self, *, hits: int) -> None:
38+
"""Count one more line, covered when ``hits > 0``."""
39+
self.lines += 1
40+
self.lines_covered += int(hits > 0)
3541

36-
def _record_line(stats: RawStats, *, hits: int) -> None:
37-
stats["lines"] += 1
38-
if hits > 0:
39-
stats["lines_covered"] += 1
40-
41-
42-
def _record_function(stats: RawStats, *, hits: int) -> None:
43-
stats["functions"] += 1
44-
if hits > 0:
45-
stats["functions_covered"] += 1
42+
def record_function(self, *, hits: int) -> None:
43+
"""Count one more function, covered when ``hits > 0``."""
44+
self.functions += 1
45+
self.functions_covered += int(hits > 0)
4646

4747

4848
def parse_lcov(lines: Iterable[str]) -> dict[str, RawStats]:
@@ -54,9 +54,7 @@ def parse_lcov(lines: Iterable[str]) -> dict[str, RawStats]:
5454
no hit count and are ignored); line coverage from ``DA:<line>,<hits>``
5555
records.
5656
"""
57-
file_data: dict[str, RawStats] = defaultdict(
58-
lambda: RawStats(lines=0, lines_covered=0, functions=0, functions_covered=0)
59-
)
57+
file_data: dict[str, RawStats] = defaultdict(RawStats)
6058

6159
current_file: str | None = None
6260
for raw_line in lines:
@@ -66,52 +64,43 @@ def parse_lcov(lines: Iterable[str]) -> dict[str, RawStats]:
6664
elif current_file is None:
6765
continue
6866
elif line.startswith("FNA:"):
69-
_record_function(file_data[current_file], hits=int(line[4:].split(",", 2)[1]))
67+
file_data[current_file].record_function(hits=int(line[4:].split(",", 2)[1]))
7068
elif line.startswith("DA:"):
71-
_record_line(file_data[current_file], hits=int(line[3:].split(",")[1]))
69+
file_data[current_file].record_line(hits=int(line[3:].split(",")[1]))
7270
return file_data
7371

7472

7573
def calculate_coverage_stats(stats: RawStats) -> CoverageStats:
7674
"""Calculate coverage percentages for given stats."""
77-
total_lines = stats["lines"]
78-
covered_lines = stats["lines_covered"]
79-
line_cov_pct = 100.0 * covered_lines / total_lines if total_lines else 0
80-
81-
total_funcs = stats["functions"]
82-
covered_funcs = stats["functions_covered"]
83-
func_cov_pct = 100.0 * covered_funcs / total_funcs if total_funcs else 0
75+
line_cov_pct = 100.0 * stats.lines_covered / stats.lines if stats.lines else 0
76+
func_cov_pct = 100.0 * stats.functions_covered / stats.functions if stats.functions else 0
8477

8578
return CoverageStats(
8679
lines_coverage_percent=round(line_cov_pct, 2),
8780
functions_coverage_percent=round(func_cov_pct, 2),
88-
covered_lines=covered_lines,
89-
total_lines=total_lines,
90-
covered_functions=covered_funcs,
91-
total_functions=total_funcs,
81+
covered_lines=stats.lines_covered,
82+
total_lines=stats.lines,
83+
covered_functions=stats.functions_covered,
84+
total_functions=stats.functions,
9285
)
9386

9487

9588
def calculate_total_coverage(file_data: dict[str, RawStats]) -> CoverageStats:
9689
"""Calculate total project coverage statistics."""
97-
totals = RawStats(lines=0, lines_covered=0, functions=0, functions_covered=0)
98-
for stats in file_data.values():
99-
totals["lines"] += stats["lines"]
100-
totals["lines_covered"] += stats["lines_covered"]
101-
totals["functions"] += stats["functions"]
102-
totals["functions_covered"] += stats["functions_covered"]
103-
104-
return calculate_coverage_stats(totals)
90+
stats = file_data.values()
91+
return calculate_coverage_stats(
92+
RawStats(
93+
lines=sum(s.lines for s in stats),
94+
lines_covered=sum(s.lines_covered for s in stats),
95+
functions=sum(s.functions for s in stats),
96+
functions_covered=sum(s.functions_covered for s in stats),
97+
)
98+
)
10599

106100

107101
def write_csv_row(writer: csv.DictWriter[str], file_path: str, stats: CoverageStats) -> None:
108102
"""Write a single coverage row to CSV."""
109-
writer.writerow(
110-
{
111-
"file_path": file_path,
112-
**stats,
113-
}
114-
)
103+
writer.writerow({"file_path": file_path, **asdict(stats)})
115104

116105

117106
def write_csv(

tests/unit/qa_metrics/unit_test_coverage/test_summary.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,39 @@
1313
)
1414

1515

16+
def test_record_line_counts_covered_when_hit() -> None:
17+
stats = RawStats()
18+
stats.record_line(hits=3)
19+
assert stats == RawStats(lines=1, lines_covered=1, functions=0, functions_covered=0)
20+
21+
22+
def test_record_line_counts_uncovered_when_not_hit() -> None:
23+
stats = RawStats()
24+
stats.record_line(hits=0)
25+
assert stats == RawStats(lines=1, lines_covered=0, functions=0, functions_covered=0)
26+
27+
28+
def test_record_function_counts_covered_when_hit() -> None:
29+
stats = RawStats()
30+
stats.record_function(hits=2)
31+
assert stats == RawStats(lines=0, lines_covered=0, functions=1, functions_covered=1)
32+
33+
34+
def test_record_function_counts_uncovered_when_not_hit() -> None:
35+
stats = RawStats()
36+
stats.record_function(hits=0)
37+
assert stats == RawStats(lines=0, lines_covered=0, functions=1, functions_covered=0)
38+
39+
40+
def test_record_accumulates_lines_and_functions_independently() -> None:
41+
stats = RawStats()
42+
stats.record_line(hits=1)
43+
stats.record_line(hits=0)
44+
stats.record_function(hits=5)
45+
stats.record_function(hits=0)
46+
assert stats == RawStats(lines=2, lines_covered=1, functions=2, functions_covered=1)
47+
48+
1649
def test_parse_lcov_counts_lcov_2_x_function_records() -> None:
1750
"""lcov 2.x emits FNL/FNA; function hits must be read from FNA records."""
1851
assert parse_lcov(

0 commit comments

Comments
 (0)