Coverage for pyEDAA/Reports/CodeCoverage/Cobertura/CoveragePyCobertura.py: 100%

61 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-21 23:49 +0000

1# ==================================================================================================================== # 

2# _____ ____ _ _ ____ _ # 

3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ # 

4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| # 

5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ # 

6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ # 

7# |_| |___/ |_| # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2026-2026 Electronic Design Automation Abstraction (EDA²) # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32coverage.py's dialect of the Cobertura XML code coverage format: read strictly, and converted to the common model. 

33 

34coverage.py writes the dialect with ``coverage xml``. A report is validated against :file:`CoveragePy-Cobertura.xsd`, 

35reverse-engineered from coverage.py 7.x, which accepts what coverage.py writes and nothing else. The format's model is 

36the one of :mod:`pyEDAA.Reports.CodeCoverage.Cobertura`; a :class:`Line` also keeps the targets of its branches never 

37taken, which coverage.py adds as ``missing-branches``. 

38 

39:meth:`Document.ToCoverageSummary` converts the model to the common model of :mod:`pyEDAA.Reports.CodeCoverage`: 

40 

41* A class' ``filename`` is a file's path, relative to the ``<source>`` directory; coverage.py writes a class per file. 

42* A line's ``hits`` - ``0`` or ``1`` - says whether it ran: a line, which ran, is covered - partially covered, if one of 

43 its branches wasn't taken -, otherwise uncovered. The format has no counts. 

44* A branching line's ``condition-coverage`` - e.g. ``50% (1/2)`` - states its taken and all branches. A branch never 

45 taken names its target line - none for an exit of a function, which coverage.py states as ``exit`` -, a taken one 

46 doesn't. 

47* A file's directories become packages, the file a module spanning the whole file. The format names no classes or 

48 functions of the language. 

49* The format has no excluded lines. 

50 

51.. rubric:: Example 

52 

53.. code-block:: Python 

54 

55 from pathlib import Path 

56 from pyEDAA.Reports.CodeCoverage.Cobertura.CoveragePyCobertura import Document 

57 

58 report = Document(Path("coverage.xml"), analyzeAndConvert=True) 

59 summary = report.ToCoverageSummary() 

60 for unit in summary.IterateUnits(): 

61 print(f"{unit.QualifiedName}: {unit.LineCoverage:.1%}") 

62""" 

63from __future__ import annotations 

64 

65from pathlib import Path 

66from typing import Iterable, Optional as Nullable 

67 

68from lxml.etree import _Element 

69from pyTooling.Decorators import export, readonly 

70 

71from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, CoverageSummary, Line as cc_Line 

72from pyEDAA.Reports.CodeCoverage import LineCoverageStatus, Module as cc_Module, Package as cc_Package 

73from pyEDAA.Reports.CodeCoverage import Unit as cc_Unit 

74from pyEDAA.Reports.CodeCoverage.Cobertura import CONDITION_COVERAGE, Document as cob_Document, Line as cob_Line 

75 

76 

77__all__ = ["READ_SCHEMA"] 

78 

79READ_SCHEMA = "CoveragePy-Cobertura.xsd" #: The XML schema a report is validated against when read: strict. 

80 

81 

82@export 

83class Line(cob_Line): 

84 """ 

85 A ``<line>`` as coverage.py writes it: whether it ran, and the targets of its branches never taken. 

86 """ 

87 

88 _missingBranches: list[Nullable[int]] #: Target lines of the branches never taken; ``None`` for a function's exit. 

89 

90 def __init__( 

91 self, 

92 number: int, 

93 hits: int, 

94 branch: bool = False, 

95 conditionCoverage: Nullable[tuple[int, int]] = None, 

96 missingBranches: Nullable[Iterable[Nullable[int]]] = None 

97 ) -> None: 

98 """ 

99 Initialize a line. 

100 

101 :param number: Line number. 

102 :param hits: Whether the line ran: ``1`` or ``0``. 

103 :param branch: Optional, whether the line branches. Default: ``False``. 

104 :param conditionCoverage: Optional, taken and all branches. Default: ``None``. 

105 :param missingBranches: Optional, target lines of the branches never taken; ``None`` for an exit of a function. 

106 Default: none. 

107 """ 

108 super().__init__(number, hits, branch, conditionCoverage) 

109 

110 self._missingBranches = [] if missingBranches is None else list(missingBranches) 

111 

112 @readonly 

113 def MissingBranches(self) -> list[Nullable[int]]: 

114 """ 

115 Read-only property to access the target lines of the branches never taken (:attr:`_missingBranches`). 

116 

117 :returns: The target lines, ``None`` for an exit of a function; empty, if the line took all its branches. 

118 """ 

119 return self._missingBranches 

120 

121 

122@export 

123class Document(cob_Document): 

124 """ 

125 A Cobertura XML code coverage report as coverage.py writes it: read strictly, and converted to the common model. 

126 """ 

127 

128 def Analyze(self) -> None: 

129 """ 

130 Parse the XML file and validate it against the strict XML schema :data:`READ_SCHEMA`. 

131 

132 :raises CodeCoverageError: If the file doesn't exist. 

133 :raises CodeCoverageError: If the file can't be read. 

134 :raises CodeCoverageError: If the file isn't well-formed XML. 

135 :raises CodeCoverageError: If the root element isn't ``<coverage>``. 

136 :raises CodeCoverageError: If the XML schema can't be located or parsed. 

137 :raises CodeCoverageError: If the file isn't valid according to the XML schema. 

138 """ 

139 self._Analyze(READ_SCHEMA) 

140 

141 @staticmethod 

142 def _ConvertLine(lineElement: _Element) -> Line: 

143 """ 

144 Convert a ``<line>`` element and the targets of its branches never taken. 

145 

146 :param lineElement: The ``<line>`` element. 

147 :returns: The line. 

148 """ 

149 conditionCoverage = None 

150 if (match := CONDITION_COVERAGE.search(lineElement.attrib.get("condition-coverage", ""))) is not None: 

151 conditionCoverage = (int(match[1]), int(match[2])) 

152 

153 missingBranches: list[Nullable[int]] = [] 

154 if (targets := lineElement.attrib.get("missing-branches")) is not None: 

155 missingBranches.extend(None if target == "exit" else int(target) for target in targets.split(",")) 

156 

157 return Line( 

158 int(lineElement.attrib["number"]), int(lineElement.attrib["hits"]), "branch" in lineElement.attrib, 

159 conditionCoverage, missingBranches 

160 ) 

161 

162 def ToCoverageSummary(self) -> CoverageSummary: 

163 """ 

164 Convert the format's model to the common model, and aggregate it. 

165 

166 A line, which ran, is covered - partially covered, if one of its branches wasn't taken -, otherwise uncovered; the 

167 format has no counts. A branch never taken names its target line, a taken one doesn't. A file's directories 

168 become packages, the file a module spanning the whole file. 

169 

170 :returns: The report's root of the common model, named after the report file. 

171 :raises CodeCoverageError: If a file's path runs through another file. 

172 :raises CodeCoverageError: If two classes name the same file. 

173 """ 

174 summary = CoverageSummary(self._path.stem, sourceDirectories=[Path(source) for source in self._sources]) 

175 

176 for package in self._packages: 

177 for klass in package._classes: 

178 file = summary.GetOrAddFile(klass._filename) 

179 for line in klass._lines.values(): 

180 covered, total = (0, 0) if line._conditionCoverage is None else line._conditionCoverage 

181 if line._hits == 0: 

182 status = LineCoverageStatus.Uncovered 

183 elif covered < total: 

184 status = LineCoverageStatus.PartiallyCovered 

185 else: 

186 status = LineCoverageStatus.Covered 

187 

188 cc_Line(line._number, status, parent=file) 

189 

190 lines = file._lines 

191 lastLineNumber = file._lastLineNumber 

192 for line in klass._lines.values(): 

193 source = lines[line._number] 

194 for _ in range(0 if line._conditionCoverage is None else line._conditionCoverage[0]): 

195 cc_Branch(LineCoverageStatus.Covered, parent=source) 

196 

197 for target in line._missingBranches: 

198 cc_Branch( 

199 LineCoverageStatus.Uncovered, 

200 target=lines[target] if target is not None and target <= lastLineNumber else None, 

201 parent=source 

202 ) 

203 

204 path = Path(klass._filename) 

205 parent: cc_Unit | CoverageSummary = summary 

206 for part in path.parent.parts: 

207 parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent) 

208 

209 cc_Module( 

210 path.stem, 

211 file=file, 

212 startLine=next(file.IterateLines(), None), 

213 endLine=lines[lastLineNumber] if lastLineNumber > 0 else None, 

214 parent=parent 

215 ) 

216 

217 summary.Aggregate() 

218 return summary