⬅ genbadge/utils_coverage.py source

1 # Authors: Sylvain MARIE <sylvain.marie@se.com>
2 # + All contributors to <https://github.com/smarie/python-genbadge>
3 #
4 # License: 3-clause BSD, <https://github.com/smarie/python-genbadge/blob/master/LICENSE>
5 from __future__ import division
6  
7 from .utils_badge import Badge
8  
9 try:
10 # security patch: see https://docs.python.org/3/library/xml.etree.elementtree.html
11 import defusedxml.ElementTree as defused_etree
12 except ImportError as e:
13 ee = e # save it
14 class FakeDefusedXmlImport(object): # noqa
15 def __getattribute__(self, item):
16 raise ImportError("Could not import `defusedxml.ElementTree`, please install `defusedxml`. "
17 "Note that all dependencies for the coverage command can be installed with "
18 "`pip install genbadge[coverage]`. Caught: %r" % ee)
19 defused_etree = FakeDefusedXmlImport()
20  
21  
22 class CoverageStats(object):
23 """
24 Contains the results from parsing the coverage.xml.
25 """
26 def __init__(self,
27 branches_covered=None, branches_valid=None, branch_option=None,
28 complexity=None, lines_covered=None, lines_valid=None,
29 ):
30 self.complexity = complexity
31  
32 self.branches_covered = branches_covered
33 self.branches_valid = branches_valid
34 self.branch_option = branch_option
35  
36 self.lines_covered = lines_covered
37 self.lines_valid = lines_valid
38  
39 @property
40 def branch_rate(self):
41 """
42 Note: in --no-branch situations, the number of branches is 0.
43 In that case, the branch rate is 0 in the coverage.xml.
44 But in --branch situations without actual branches,
45 the number of branches is also 0 but the branch rate is 1.
46 We mimic both behaviours in this field to be consistent.
47 """
48 if self.branches_valid > 0:
49 return self.branches_covered / self.branches_valid
50 elif self.branch_option:
51 return 1
52 else:
53 return 0
54  
55 @property
56 def line_rate(self):
57 """See branch rate for the special case of division by zero"""
58 if self.lines_valid > 0:
59 return self.lines_covered / self.lines_valid
60 else:
61 return 0
62  
63 @property
64 def branch_coverage(self):
65 return self.branch_rate * 100
66  
67 @property
68 def line_coverage(self):
69 return self.line_rate * 100
70  
71 @property
72 def total_rate(self):
73 """
74 See XmlReport class in https://github.com/nedbat/coveragepy/blob/master/coverage/xmlreport.py
75 for the formula.
76  
77 See branch rate for the special case of division by zero.
78 """
79 denom = self.lines_valid + self.branches_valid
80 if denom > 0:
81 return (self.lines_covered + self.branches_covered) / denom
82 else:
83 return 0
84  
85 @property
86 def total_coverage(self):
87 return self.total_rate * 100
88  
89  
90 def get_coverage_stats(coverage_xml_file):
91 # type: (...) -> CoverageStats
92 """
93 Reads a coverage.xml file
94  
95 <coverage branch-rate="0.6" branches-covered="24" branches-valid="40" complexity="0" line-rate="0.8586"
96 lines-covered="170" lines-valid="198" timestamp="1620747625339" version="5.5">
97 </coverage>
98 """
99 if isinstance(coverage_xml_file, str):
100 # assume a file path
101 with open(coverage_xml_file) as f:
102 cov_stats = parse_cov(f)
103 else:
104 # assume a stream already
105 cov_stats = parse_cov(coverage_xml_file)
106  
107 return cov_stats
108  
109  
110 def get_color(
111 cov_stats # type: CoverageStats
112 ):
113 """ Returns the badge color to use depending on the coverage rate """
114  
115 if cov_stats.total_coverage < 50:
116 color = 'red'
117 elif cov_stats.total_coverage < 75:
118 color = 'orange'
119 elif cov_stats.total_coverage < 90:
120 color = 'green'
121 else:
122 color = 'brightgreen'
123  
124 return color
125  
126  
127 def get_coverage_badge(
128 cov_stats, # type: CoverageStats
  • E251 Unexpected spaces around keyword / parameter equals
  • E261 At least two spaces before inline comment
129 left_txt= "coverage" # type: str
130 ):
131 # type: (...) -> Badge
132 """Return the badge from coverage results """
133  
134 color = get_color(cov_stats)
135  
136 right_txt = "%.2f%%" % (cov_stats.total_coverage,)
137  
138 return Badge(left_txt=left_txt, right_txt=right_txt, color=color)
139  
140  
141 def parse_cov(source):
142 """Parses the coverage.xml contents from source"""
143 return CovParser().parse(source)
144  
145  
146 class CovParser(object):
147 """Parser class - inspired by the code in `xunitparser`"""
148  
149 def parse(self, source):
150 xml = defused_etree.parse(source)
151 root = xml.getroot()
152 return self.parse_root(root)
153  
154 def parse_root(self, root):
155 cov = CoverageStats()
  • S101 Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.
156 assert root.tag == 'coverage'
157  
158 cov.complexity = float(root.attrib.get('complexity'))
159  
160 cov.branches_covered = int(root.attrib.get('branches-covered'))
161 cov.branches_valid = int(root.attrib.get('branches-valid'))
162  
163 cov.lines_covered = int(root.attrib.get('lines-covered'))
164 cov.lines_valid = int(root.attrib.get('lines-valid'))
165  
166 # recompute the rates for more precision, but make sure that's correct
167 branch_rate = float(root.attrib.get('branch-rate'))
168 line_rate = float(root.attrib.get('line-rate'))
169  
170 # detect whether the --branch option were set or not
171 # so CoverageStats knows how to distinguish between them
172 cov.branch_option = cov.branches_valid > 0 or branch_rate == 1.0
173  
174 if not is_close(cov.branch_rate, branch_rate):
175 raise ValueError("Computed branch rate (%s) is different from the one in the file (%s)"
176 % (cov.branch_rate, branch_rate))
177 if not is_close(cov.line_rate, line_rate):
178 raise ValueError("Computed line rate (%s) is different from the one in the file (%s)"
179 % (cov.line_rate, line_rate))
180  
181 # for el in root:
182 # if el.tag == 'sources':
183 # self.parse_sources(el, ts)
184 # if el.tag == 'packages':
185 # self.parse_packages(el, ts)
186  
187 return cov
188  
189  
190 def is_close(a, b):
191 """Return True if there is at most a difference of 1 at the 2d decimal"""
192 return abs(a - b) <= 0.01