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
« 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.
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.
39:meth:`Document.ToCoverageSummary` converts the model to the common model of :mod:`pyEDAA.Reports.CodeCoverage`:
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.
53.. rubric:: Example
55.. code-block:: Python
57 from pathlib import Path
58 from pyEDAA.Reports.CodeCoverage.Cobertura.NVCCobertura import Document
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
67from pathlib import Path
68from typing import Optional as Nullable
70from lxml.etree import _Element
71from pyTooling.Common import getFullyQualifiedName
72from pyTooling.Decorators import export, readonly
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
81__all__ = ["READ_SCHEMA"]
83READ_SCHEMA = "NVC-Cobertura.xsd" #: The XML schema a report is validated against when read: strict.
86@export
87class Class(cob_Class):
88 """
89 A ``<class>`` as NVC writes it: a design unit, its entity and architecture.
91 Entity ``COUNTER`` with architecture ``RTL`` is named ``COUNTER(RTL)``.
92 """
94 _entity: str #: Name of the design unit's entity.
95 _architecture: str #: Name of the design unit's architecture.
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.
109 The class' name is the entity's, followed by the architecture's in parentheses, e.g. ``COUNTER(RTL)``.
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)
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.")
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.")
144 self._entity = entity
145 self._architecture = architecture
147 @readonly
148 def Entity(self) -> str:
149 """
150 Read-only property to access the name of the design unit's entity (:attr:`_entity`).
152 NVC writes it in upper case, e.g. ``COUNTER``.
154 :returns: The entity's name.
155 """
156 return self._entity
158 @readonly
159 def Architecture(self) -> str:
160 """
161 Read-only property to access the name of the design unit's architecture (:attr:`_architecture`).
163 NVC writes it in upper case, e.g. ``RTL``.
165 :returns: The architecture's name.
166 """
167 return self._architecture
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 """
176 def Analyze(self) -> None:
177 """
178 Parse the XML file and validate it against the strict XML schema :data:`READ_SCHEMA`.
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)
189 @classmethod
190 def _ConvertClass(cls, classElement: _Element) -> Class:
191 """
192 Convert a ``<class>`` element - a design unit - and its lines.
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 )
203 for lineElement in classElement.iterfind("lines/line"): # type: _Element
204 line = cls._ConvertLine(lineElement)
205 klass._lines[line._number] = line
207 return klass
209 @staticmethod
210 def _ConvertLine(lineElement: _Element) -> Line:
211 """
212 Convert a ``<line>`` element and its conditions.
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.
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 ]
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)
230 return Line(
231 int(lineElement.attrib["number"]), int(lineElement.attrib["hits"]), lineElement.attrib["branch"] == "true",
232 conditionCoverage, conditions
233 )
235 def ToCoverageSummary(self) -> CoverageSummary:
236 """
237 Convert the format's model to the common model, and aggregate it.
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.
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])
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))
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
275 cc_Line(number, status, None if hits == 0 and covered > 0 else hits, branches, parent=file)
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)
281 for klass in package._classes:
282 if (entity := library._units.get(klass._entity)) is None:
283 entity = cc_Module(klass._entity, parent=library)
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 )
296 summary.Aggregate()
297 return summary