Coverage for pyEDAA/Reports/CodeCoverage/Cobertura/NVCCobertura.py: 99%

100 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""" 

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

33 

34NVC writes the dialect with ``nvc --cover-export --format=cobertura``. A report is validated against 

35:file:`NVC-Cobertura.xsd`, reverse-engineered from NVC 1.23, which accepts what NVC writes and nothing else. The 

36format's model is the one of :mod:`pyEDAA.Reports.CodeCoverage.Cobertura`; a :class:`Class` is a design unit and also 

37keeps its entity and architecture. 

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 directory named by ``--relative``. Classes of one file - e.g. 

42 the architectures of an entity with statements - are one file, their lines merged. 

43* A line's ``hits`` is its count: how often the statements starting in the line ran, added for all of them and all 

44 instances of the design unit. A line without a statement - e.g. an ``elsif`` - has hits ``0``. 

45* A branching line's ``condition-coverage`` - ``0 %``, ``50 %`` or ``100 %`` - states how many directions of its 

46 conditions - each ``<condition>`` a true and a false one - were taken. They become as many branches without count. A 

47 line, which ran or took a branch, is covered - partially covered, if one of its branches wasn't taken -, otherwise 

48 uncovered; a line without a statement, which took a branch, has no count. 

49* The package - a library - becomes a package, a class' entity a module in it, its architecture a module in the entity, 

50 spanning its file from the first to the last line it lists. 

51* The format has no excluded lines. 

52 

53.. rubric:: Example 

54 

55.. code-block:: Python 

56 

57 from pathlib import Path 

58 from pyEDAA.Reports.CodeCoverage.Cobertura.NVCCobertura import Document 

59 

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

61 summary = report.ToCoverageSummary() 

62 for unit in summary.IterateUnits(): 

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

64""" 

65from __future__ import annotations 

66 

67from pathlib import Path 

68from typing import Optional as Nullable 

69 

70from lxml.etree import _Element 

71from pyTooling.Common import getFullyQualifiedName 

72from pyTooling.Decorators import export, readonly 

73 

74from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, CoverageSummary, File as cc_File 

75from pyEDAA.Reports.CodeCoverage import Line as cc_Line, LineCoverageStatus, Module as cc_Module 

76from pyEDAA.Reports.CodeCoverage import Package as cc_Package 

77from pyEDAA.Reports.CodeCoverage.Cobertura import Document as cob_Document, _float 

78from pyEDAA.Reports.CodeCoverage.Cobertura.Records import Class as cob_Class, Condition, Line 

79 

80 

81__all__ = ["READ_SCHEMA"] 

82 

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

84 

85 

86@export 

87class Class(cob_Class): 

88 """ 

89 A ``<class>`` as NVC writes it: a design unit, its entity and architecture. 

90 

91 Entity ``COUNTER`` with architecture ``RTL`` is named ``COUNTER(RTL)``. 

92 """ 

93 

94 _entity: str #: Name of the design unit's entity. 

95 _architecture: str #: Name of the design unit's architecture. 

96 

97 def __init__( 

98 self, 

99 entity: str, 

100 architecture: str, 

101 filename: str, 

102 lineRate: Nullable[float] = None, 

103 branchRate: Nullable[float] = None, 

104 complexity: Nullable[float] = None 

105 ) -> None: 

106 """ 

107 Initialize a design unit. 

108 

109 The class' name is the entity's, followed by the architecture's in parentheses, e.g. ``COUNTER(RTL)``. 

110 

111 :param entity: Name of the design unit's entity. 

112 :param architecture: Name of the design unit's architecture. 

113 :param filename: Path of the design unit's source file, relative to the directory named by ``--relative``. 

114 :param lineRate: Optional, line coverage, as the report states it. Default: ``None``. 

115 :param branchRate: Optional, branch coverage, as the report states it. Default: ``None``. 

116 :param complexity: Optional, complexity, as the report states it. Default: ``None``. 

117 :raises ValueError: If parameter ``entity`` is ``None``. 

118 :raises TypeError: If parameter ``entity`` isn't of type :class:`str`. 

119 :raises ValueError: If parameter ``entity`` is empty. 

120 :raises ValueError: If parameter ``architecture`` is ``None``. 

121 :raises TypeError: If parameter ``architecture`` isn't of type :class:`str`. 

122 :raises ValueError: If parameter ``architecture`` is empty. 

123 """ 

124 super().__init__(f"{entity}({architecture})", filename, lineRate, branchRate, complexity) 

125 

126 if entity is None: 

127 raise ValueError("Parameter 'entity' is None.") 

128 elif not isinstance(entity, str): 

129 ex = TypeError("Parameter 'entity' is not of type 'str'.") 

130 ex.add_note(f"Got type '{getFullyQualifiedName(entity)}'.") 

131 raise ex 

132 elif entity == "": 

133 raise ValueError("Parameter 'entity' is empty.") 

134 

135 if architecture is None: 

136 raise ValueError("Parameter 'architecture' is None.") 

137 elif not isinstance(architecture, str): 

138 ex = TypeError("Parameter 'architecture' is not of type 'str'.") 

139 ex.add_note(f"Got type '{getFullyQualifiedName(architecture)}'.") 

140 raise ex 

141 elif architecture == "": 

142 raise ValueError("Parameter 'architecture' is empty.") 

143 

144 self._entity = entity 

145 self._architecture = architecture 

146 

147 @readonly 

148 def Entity(self) -> str: 

149 """ 

150 Read-only property to access the name of the design unit's entity (:attr:`_entity`). 

151 

152 NVC writes it in upper case, e.g. ``COUNTER``. 

153 

154 :returns: The entity's name. 

155 """ 

156 return self._entity 

157 

158 @readonly 

159 def Architecture(self) -> str: 

160 """ 

161 Read-only property to access the name of the design unit's architecture (:attr:`_architecture`). 

162 

163 NVC writes it in upper case, e.g. ``RTL``. 

164 

165 :returns: The architecture's name. 

166 """ 

167 return self._architecture 

168 

169 

170@export 

171class Document(cob_Document): 

172 """ 

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

174 """ 

175 

176 def Analyze(self) -> None: 

177 """ 

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

179 

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

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

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

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

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

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

186 """ 

187 self._Analyze(READ_SCHEMA) 

188 

189 @classmethod 

190 def _ConvertClass(cls, classElement: _Element) -> Class: 

191 """ 

192 Convert a ``<class>`` element - a design unit - and its lines. 

193 

194 :param classElement: The ``<class>`` element. 

195 :returns: The design unit. 

196 """ 

197 entity, _, architecture = classElement.attrib["name"].partition("(") 

198 klass = Class( 

199 entity, architecture[:-1], classElement.attrib["filename"], _float(classElement, "line-rate"), 

200 _float(classElement, "branch-rate"), _float(classElement, "complexity") 

201 ) 

202 

203 for lineElement in classElement.iterfind("lines/line"): # type: _Element 

204 line = cls._ConvertLine(lineElement) 

205 klass._lines[line._number] = line 

206 

207 return klass 

208 

209 @staticmethod 

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

211 """ 

212 Convert a ``<line>`` element and its conditions. 

213 

214 The line's taken and all branches are figured from its ``condition-coverage`` and its conditions, each a true and 

215 a false direction. 

216 

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

218 :returns: The line. 

219 """ 

220 conditions = [ 

221 Condition(int(element.attrib["number"]), element.attrib["type"], element.attrib["coverage"]) 

222 for element in lineElement.iterfind("conditions/condition") 

223 ] 

224 

225 conditionCoverage = None 

226 if (percentage := lineElement.attrib.get("condition-coverage")) is not None: 

227 total = 2 * len(conditions) 

228 conditionCoverage = (int(percentage.removesuffix(" %")) * total // 100, total) 

229 

230 return Line( 

231 int(lineElement.attrib["number"]), int(lineElement.attrib["hits"]), lineElement.attrib["branch"] == "true", 

232 conditionCoverage, conditions 

233 ) 

234 

235 def ToCoverageSummary(self) -> CoverageSummary: 

236 """ 

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

238 

239 Several classes of one file are one file: a line listed by several of them has their hits added, and of its 

240 branches the higher number taken. A line, which ran or took a branch, is covered - partially covered, if one of 

241 its branches wasn't taken -, otherwise uncovered; a line without a statement, which took a branch, has no count. 

242 The package becomes a package, a class' entity a module in it, its architecture a module in the entity, spanning 

243 its file from its first to its last line. 

244 

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

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

247 """ 

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

249 

250 merged: dict[int, dict[int, tuple[int, int, int]]] = {} 

251 files: dict[int, cc_File] = {} 

252 for package in self._packages: 

253 for klass in package._classes: 

254 file = summary.GetOrAddFile(klass._filename) 

255 files[id(file)] = file 

256 lines = merged.setdefault(id(file), {}) 

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

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

259 hits, oldCovered, oldTotal = lines.get(line._number, (0, 0, 0)) 

260 lines[line._number] = (hits + line._hits, max(covered, oldCovered), max(total, oldTotal)) 

261 

262 for fileID, lines in merged.items(): 

263 file = files[fileID] 

264 for number in sorted(lines): 

265 hits, covered, total = lines[number] 

266 branches = [cc_Branch(LineCoverageStatus.Covered) for _ in range(covered)] 

267 branches.extend(cc_Branch(LineCoverageStatus.Uncovered) for _ in range(total - covered)) 

268 if hits == 0 and covered == 0: 

269 status = LineCoverageStatus.Uncovered 

270 elif covered < total: 

271 status = LineCoverageStatus.PartiallyCovered 

272 else: 

273 status = LineCoverageStatus.Covered 

274 

275 cc_Line(number, status, None if hits == 0 and covered > 0 else hits, branches, parent=file) 

276 

277 for package in self._packages: 

278 if (library := summary._units.get(package._name)) is None: 278 ↛ 281line 278 didn't jump to line 281 because the condition on line 278 was always true

279 library = cc_Package(package._name, parent=summary) 

280 

281 for klass in package._classes: 

282 if (entity := library._units.get(klass._entity)) is None: 

283 entity = cc_Module(klass._entity, parent=library) 

284 

285 if klass._architecture not in entity._units: 285 ↛ 281line 285 didn't jump to line 281 because the condition on line 285 was always true

286 file = summary.GetOrAddFile(klass._filename) 

287 numbers = list(klass._lines) 

288 cc_Module( 

289 klass._architecture, 

290 file=file, 

291 startLine=file._lines[min(numbers)] if len(numbers) > 0 else None, 

292 endLine=file._lines[max(numbers)] if len(numbers) > 0 else None, 

293 parent=entity 

294 ) 

295 

296 summary.Aggregate() 

297 return summary