1 # coding: utf-8 |
1 # coding: utf-8 |
2 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
2 # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
3 # For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt |
3 # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt |
4 |
4 |
5 """XML reporting for coverage.py""" |
5 """XML reporting for coverage.py""" |
6 |
6 |
7 import os |
7 import os |
8 import os.path |
8 import os.path |
9 import re |
|
10 import sys |
9 import sys |
11 import time |
10 import time |
12 import xml.dom.minidom |
11 import xml.dom.minidom |
13 |
12 |
14 from coverage import env |
13 from coverage import env |
15 from coverage import __url__, __version__, files |
14 from coverage import __url__, __version__, files |
16 from coverage.backward import iitems |
15 from coverage.backward import iitems |
17 from coverage.misc import isolate_module |
16 from coverage.misc import isolate_module |
18 from coverage.report import Reporter |
17 from coverage.report import get_analysis_to_report |
19 |
18 |
20 os = isolate_module(os) |
19 os = isolate_module(os) |
21 |
20 |
22 |
21 |
23 DTD_URL = 'https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd' |
22 DTD_URL = 'https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd' |
29 return "1" |
28 return "1" |
30 else: |
29 else: |
31 return "%.4g" % (float(hit) / num) |
30 return "%.4g" % (float(hit) / num) |
32 |
31 |
33 |
32 |
34 class XmlReporter(Reporter): |
33 class XmlReporter(object): |
35 """A reporter for writing Cobertura-style XML coverage results.""" |
34 """A reporter for writing Cobertura-style XML coverage results.""" |
36 |
35 |
37 def __init__(self, coverage, config): |
36 def __init__(self, coverage): |
38 super(XmlReporter, self).__init__(coverage, config) |
37 self.coverage = coverage |
|
38 self.config = self.coverage.config |
39 |
39 |
40 self.source_paths = set() |
40 self.source_paths = set() |
41 if config.source: |
41 if self.config.source: |
42 for src in config.source: |
42 for src in self.config.source: |
43 if os.path.exists(src): |
43 if os.path.exists(src): |
44 self.source_paths.add(files.canonical_filename(src)) |
44 self.source_paths.add(files.canonical_filename(src)) |
45 self.packages = {} |
45 self.packages = {} |
46 self.xml_out = None |
46 self.xml_out = None |
47 self.has_arcs = coverage.data.has_arcs() |
|
48 |
47 |
49 def report(self, morfs, outfile=None): |
48 def report(self, morfs, outfile=None): |
50 """Generate a Cobertura-compatible XML report for `morfs`. |
49 """Generate a Cobertura-compatible XML report for `morfs`. |
51 |
50 |
52 `morfs` is a list of modules or file names. |
51 `morfs` is a list of modules or file names. |
54 `outfile` is a file object to write the XML to. |
53 `outfile` is a file object to write the XML to. |
55 |
54 |
56 """ |
55 """ |
57 # Initial setup. |
56 # Initial setup. |
58 outfile = outfile or sys.stdout |
57 outfile = outfile or sys.stdout |
|
58 has_arcs = self.coverage.get_data().has_arcs() |
59 |
59 |
60 # Create the DOM that will store the data. |
60 # Create the DOM that will store the data. |
61 impl = xml.dom.minidom.getDOMImplementation() |
61 impl = xml.dom.minidom.getDOMImplementation() |
62 self.xml_out = impl.createDocument(None, "coverage", None) |
62 self.xml_out = impl.createDocument(None, "coverage", None) |
63 |
63 |
69 " Generated by coverage.py: %s " % __url__ |
69 " Generated by coverage.py: %s " % __url__ |
70 )) |
70 )) |
71 xcoverage.appendChild(self.xml_out.createComment(" Based on %s " % DTD_URL)) |
71 xcoverage.appendChild(self.xml_out.createComment(" Based on %s " % DTD_URL)) |
72 |
72 |
73 # Call xml_file for each file in the data. |
73 # Call xml_file for each file in the data. |
74 self.report_files(self.xml_file, morfs) |
74 for fr, analysis in get_analysis_to_report(self.coverage, morfs): |
|
75 self.xml_file(fr, analysis, has_arcs) |
75 |
76 |
76 xsources = self.xml_out.createElement("sources") |
77 xsources = self.xml_out.createElement("sources") |
77 xcoverage.appendChild(xsources) |
78 xcoverage.appendChild(xsources) |
78 |
79 |
79 # Populate the XML DOM with the source info. |
80 # Populate the XML DOM with the source info. |
98 xpackage.appendChild(xclasses) |
99 xpackage.appendChild(xclasses) |
99 for _, class_elt in sorted(iitems(class_elts)): |
100 for _, class_elt in sorted(iitems(class_elts)): |
100 xclasses.appendChild(class_elt) |
101 xclasses.appendChild(class_elt) |
101 xpackage.setAttribute("name", pkg_name.replace(os.sep, '.')) |
102 xpackage.setAttribute("name", pkg_name.replace(os.sep, '.')) |
102 xpackage.setAttribute("line-rate", rate(lhits, lnum)) |
103 xpackage.setAttribute("line-rate", rate(lhits, lnum)) |
103 if self.has_arcs: |
104 if has_arcs: |
104 branch_rate = rate(bhits, bnum) |
105 branch_rate = rate(bhits, bnum) |
105 else: |
106 else: |
106 branch_rate = "0" |
107 branch_rate = "0" |
107 xpackage.setAttribute("branch-rate", branch_rate) |
108 xpackage.setAttribute("branch-rate", branch_rate) |
108 xpackage.setAttribute("complexity", "0") |
109 xpackage.setAttribute("complexity", "0") |
113 bhits_tot += bhits |
114 bhits_tot += bhits |
114 |
115 |
115 xcoverage.setAttribute("lines-valid", str(lnum_tot)) |
116 xcoverage.setAttribute("lines-valid", str(lnum_tot)) |
116 xcoverage.setAttribute("lines-covered", str(lhits_tot)) |
117 xcoverage.setAttribute("lines-covered", str(lhits_tot)) |
117 xcoverage.setAttribute("line-rate", rate(lhits_tot, lnum_tot)) |
118 xcoverage.setAttribute("line-rate", rate(lhits_tot, lnum_tot)) |
118 if self.has_arcs: |
119 if has_arcs: |
119 xcoverage.setAttribute("branches-valid", str(bnum_tot)) |
120 xcoverage.setAttribute("branches-valid", str(bnum_tot)) |
120 xcoverage.setAttribute("branches-covered", str(bhits_tot)) |
121 xcoverage.setAttribute("branches-covered", str(bhits_tot)) |
121 xcoverage.setAttribute("branch-rate", rate(bhits_tot, bnum_tot)) |
122 xcoverage.setAttribute("branch-rate", rate(bhits_tot, bnum_tot)) |
122 else: |
123 else: |
123 xcoverage.setAttribute("branches-covered", "0") |
124 xcoverage.setAttribute("branches-covered", "0") |
134 pct = 0.0 |
135 pct = 0.0 |
135 else: |
136 else: |
136 pct = 100.0 * (lhits_tot + bhits_tot) / denom |
137 pct = 100.0 * (lhits_tot + bhits_tot) / denom |
137 return pct |
138 return pct |
138 |
139 |
139 def xml_file(self, fr, analysis): |
140 def xml_file(self, fr, analysis, has_arcs): |
140 """Add to the XML report for a single file.""" |
141 """Add to the XML report for a single file.""" |
141 |
142 |
142 # Create the 'lines' and 'package' XML elements, which |
143 # Create the 'lines' and 'package' XML elements, which |
143 # are populated later. Note that a package == a directory. |
144 # are populated later. Note that a package == a directory. |
144 filename = fr.filename.replace("\\", "/") |
145 filename = fr.filename.replace("\\", "/") |
178 |
179 |
179 # Q: can we get info about the number of times a statement is |
180 # Q: can we get info about the number of times a statement is |
180 # executed? If so, that should be recorded here. |
181 # executed? If so, that should be recorded here. |
181 xline.setAttribute("hits", str(int(line not in analysis.missing))) |
182 xline.setAttribute("hits", str(int(line not in analysis.missing))) |
182 |
183 |
183 if self.has_arcs: |
184 if has_arcs: |
184 if line in branch_stats: |
185 if line in branch_stats: |
185 total, taken = branch_stats[line] |
186 total, taken = branch_stats[line] |
186 xline.setAttribute("branch", "true") |
187 xline.setAttribute("branch", "true") |
187 xline.setAttribute( |
188 xline.setAttribute( |
188 "condition-coverage", |
189 "condition-coverage", |
194 xlines.appendChild(xline) |
195 xlines.appendChild(xline) |
195 |
196 |
196 class_lines = len(analysis.statements) |
197 class_lines = len(analysis.statements) |
197 class_hits = class_lines - len(analysis.missing) |
198 class_hits = class_lines - len(analysis.missing) |
198 |
199 |
199 if self.has_arcs: |
200 if has_arcs: |
200 class_branches = sum(t for t, k in branch_stats.values()) |
201 class_branches = sum(t for t, k in branch_stats.values()) |
201 missing_branches = sum(t - k for t, k in branch_stats.values()) |
202 missing_branches = sum(t - k for t, k in branch_stats.values()) |
202 class_br_hits = class_branches - missing_branches |
203 class_br_hits = class_branches - missing_branches |
203 else: |
204 else: |
204 class_branches = 0.0 |
205 class_branches = 0.0 |
205 class_br_hits = 0.0 |
206 class_br_hits = 0.0 |
206 |
207 |
207 # Finalize the statistics that are collected in the XML DOM. |
208 # Finalize the statistics that are collected in the XML DOM. |
208 xclass.setAttribute("line-rate", rate(class_hits, class_lines)) |
209 xclass.setAttribute("line-rate", rate(class_hits, class_lines)) |
209 if self.has_arcs: |
210 if has_arcs: |
210 branch_rate = rate(class_br_hits, class_branches) |
211 branch_rate = rate(class_br_hits, class_branches) |
211 else: |
212 else: |
212 branch_rate = "0" |
213 branch_rate = "0" |
213 xclass.setAttribute("branch-rate", branch_rate) |
214 xclass.setAttribute("branch-rate", branch_rate) |
214 |
215 |
222 def serialize_xml(dom): |
223 def serialize_xml(dom): |
223 """Serialize a minidom node to XML.""" |
224 """Serialize a minidom node to XML.""" |
224 out = dom.toprettyxml() |
225 out = dom.toprettyxml() |
225 if env.PY2: |
226 if env.PY2: |
226 out = out.encode("utf8") |
227 out = out.encode("utf8") |
227 # In Python 3.8, minidom lost the sorting of attributes: https://bugs.python.org/issue34160 |
|
228 # For the limited kinds of XML we produce, this re-sorts them. |
|
229 if env.PYVERSION >= (3, 8): |
|
230 rx_attr = r' [\w-]+="[^"]*"' |
|
231 rx_attrs = r'(' + rx_attr + ')+' |
|
232 fixed_lines = [] |
|
233 for line in out.splitlines(True): |
|
234 hollow_line = re.sub(rx_attrs, u"☺", line) |
|
235 attrs = sorted(re.findall(rx_attr, line)) |
|
236 new_line = hollow_line.replace(u"☺", "".join(attrs)) |
|
237 fixed_lines.append(new_line) |
|
238 out = "".join(fixed_lines) |
|
239 return out |
228 return out |