11#!/usr/bin/env python3
2- """Normalize C# Cobertura paths so Codecov can match repository files."""
2+ """Normalize C# Cobertura paths so Codecov can match repository files.
3+
4+ Merges all coverlet coverage reports into a single clean Cobertura XML
5+ that only contains source files (no generated code). This avoids:
6+ 1. Codecov silently failing on large files with mostly generated code
7+ 2. Duplicate source files across multiple coverage reports confusing merging
8+ """
39
410from __future__ import annotations
511
1016REPO_PREFIX = "sdks/csharp/"
1117REPORT_GLOB = "sdks/csharp/TestResults/*/coverage.cobertura.xml"
1218OUTPUT_DIR = Path ("sdks/csharp/TestResults/codecov" )
19+ OUTPUT_FILE = OUTPUT_DIR / "coverage.xml"
20+
21+ # Files to exclude from the merged report
22+ IGNORE_PATTERNS = ["/generated/" , "/obj/" ]
1323
1424
1525def normalize_filename (filename : str , source : str ) -> str :
@@ -27,18 +37,106 @@ def normalize_filename(filename: str, source: str) -> str:
2737 return filename
2838
2939
30- def normalize_report (report : Path , target : Path ) -> None :
31- text = report .read_text (encoding = "utf-8" )
32- source_match = re .search (r"<source>(.*?)</source>" , text )
33- source = source_match .group (1 ) if source_match else ""
34-
35- text = re .sub (
36- r'filename="([^"]+)"' ,
37- lambda match : f'filename="{ normalize_filename (match .group (1 ), source )} "' ,
38- text ,
39- )
40- text = re .sub (r"<source>.*?</source>" , "<source>.</source>" , text )
41- target .write_text (text , encoding = "utf-8" )
40+ def should_ignore (filename : str ) -> bool :
41+ for pat in IGNORE_PATTERNS :
42+ if pat in filename :
43+ return True
44+ if not filename .startswith (REPO_PREFIX ):
45+ return True
46+ return False
47+
48+
49+ def merge_reports (reports : list [Path ], target : Path ) -> None :
50+ """Merge multiple Cobertura XML reports into one clean report."""
51+ # {filename: [(class_name, line_rate, branch_rate, complexity, lines_xml), ...]}
52+ file_classes : dict [str , list [tuple [str , float , float , int , str ]]] = {}
53+
54+ for report in reports :
55+ text = report .read_text (encoding = "utf-8" )
56+ source_match = re .search (r"<source>(.*?)</source>" , text )
57+ source = source_match .group (1 ) if source_match else ""
58+
59+ class_pattern = re .compile (
60+ r'<class\s+name="([^"]+)"\s+filename="([^"]+)"\s+'
61+ r'line-rate="([^"]+)"\s+branch-rate="([^"]+)"\s+complexity="([^"]+)"'
62+ r'(.*?)(?=</class>)' ,
63+ re .DOTALL ,
64+ )
65+
66+ for m in class_pattern .finditer (text ):
67+ class_name = m .group (1 )
68+ raw_filename = m .group (2 )
69+ line_rate = float (m .group (3 ))
70+ branch_rate = float (m .group (4 ))
71+ complexity = int (m .group (5 ))
72+ class_body = m .group (6 )
73+
74+ norm_fn = normalize_filename (raw_filename , source )
75+ if should_ignore (norm_fn ):
76+ continue
77+
78+ lines_section = re .search (r'<lines>(.*?)</lines>' , class_body , re .DOTALL )
79+ lines_xml = lines_section .group (0 ) if lines_section else "<lines/>"
80+
81+ if norm_fn not in file_classes :
82+ file_classes [norm_fn ] = []
83+ file_classes [norm_fn ].append ((class_name , line_rate , branch_rate , complexity , lines_xml ))
84+
85+ if not file_classes :
86+ raise SystemExit ("no source files found after filtering" )
87+
88+ # Build merged XML
89+ classes_xml_parts = []
90+ total_lines_covered = 0
91+ total_lines_valid = 0
92+ total_branches_covered = 0
93+ total_branches_valid = 0
94+ total_complexity = 0
95+
96+ for norm_fn in sorted (file_classes .keys ()):
97+ classes = file_classes [norm_fn ]
98+ for class_name , lr , br , cx , lines_xml in classes :
99+ total_complexity += cx
100+ # Count lines
101+ line_hits = re .findall (r'number="\d+"\s+hits="(\d+)"' , lines_xml )
102+ for h in line_hits :
103+ total_lines_valid += 1
104+ if int (h ) > 0 :
105+ total_lines_covered += 1
106+ # Count branches
107+ conditions = re .findall (r'coverage="(\d+)%"' , lines_xml )
108+ total_branches_valid += len (conditions )
109+ for cov in conditions :
110+ if int (cov ) > 0 :
111+ total_branches_covered += 1
112+
113+ classes_xml_parts .append (
114+ f' <class name="{ class_name } " filename="{ norm_fn } " '
115+ f'line-rate="{ lr } " branch-rate="{ br } " complexity="{ cx } ">'
116+ f'{ lines_xml } '
117+ f'</class>'
118+ )
119+
120+ line_rate = total_lines_covered / total_lines_valid if total_lines_valid else 0
121+ branch_rate = total_branches_covered / total_branches_valid if total_branches_valid else 0
122+
123+ classes_xml = "\n " .join (classes_xml_parts )
124+
125+ xml_content = f'''<?xml version="1.0" encoding="utf-8"?>
126+ <coverage line-rate="{ line_rate :.4f} " branch-rate="{ branch_rate :.4f} " version="1.9" timestamp="0" lines-covered="{ total_lines_covered } " lines-valid="{ total_lines_valid } " branches-covered="{ total_branches_covered } " branches-valid="{ total_branches_valid } ">
127+ <sources>
128+ <source>.</source>
129+ </sources>
130+ <packages>
131+ <package name="Croupier.Sdk" line-rate="{ line_rate :.4f} " branch-rate="{ branch_rate :.4f} " complexity="{ total_complexity } ">
132+ <classes>
133+ { classes_xml }
134+ </classes>
135+ </package>
136+ </packages>
137+ </coverage>
138+ '''
139+ target .write_text (xml_content , encoding = "utf-8" )
42140
43141
44142def main () -> None :
@@ -47,13 +145,12 @@ def main() -> None:
47145 raise SystemExit ("no C# Cobertura coverage reports found" )
48146
49147 OUTPUT_DIR .mkdir (parents = True , exist_ok = True )
50- for stale_report in OUTPUT_DIR .glob ("coverage-*.cobertura .xml" ):
51- stale_report .unlink ()
148+ for stale in OUTPUT_DIR .glob ("coverage* .xml" ):
149+ stale .unlink ()
52150
53- for index , report in enumerate (reports , start = 1 ):
54- target = OUTPUT_DIR / f"coverage-{ index } .cobertura.xml"
55- normalize_report (report , target )
56- print (target )
151+ target = OUTPUT_DIR / "coverage.xml"
152+ merge_reports (reports , target )
153+ print (target )
57154
58155
59156if __name__ == "__main__" :
0 commit comments