Coverage for pyEDAA/Reports/CodeCoverage/UCIS/__init__.py: 96%
230 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"""
32The XML interchange format of the Accellera UCIS standard: a model of the format, read from a report and converted to
33the common model.
35The `Unified Coverage Interoperability Standard (UCIS) <https://www.accellera.org/downloads/standards/ucis>`__ 1.0
36specifies a coverage database's data model, a C API and an XML interchange format. An XML report names the namespace
37``UCIS`` and its root ``<UCIS>``, which states the UCIS version (:class:`FormatVersion`), the tool, which wrote the
38report, and when. It is validated against the XML schema of that version (:data:`SCHEMAS`): :file:`UCIS-1.0.xsd`,
39written after the schema the standard specifies.
41The format's model keeps what the report states: a :class:`Document` holds
42:class:`~pyEDAA.Reports.CodeCoverage.UCIS.Instances.SourceFile` elements - by their ID -,
43:class:`~pyEDAA.Reports.CodeCoverage.UCIS.HistoryNodes.HistoryNode` elements - the tests and merges, by their ID - and
44:class:`~pyEDAA.Reports.CodeCoverage.UCIS.Instances.InstanceCoverage` elements. An instance holds its coverage of each
45kind, once per metric mode: :class:`~pyEDAA.Reports.CodeCoverage.UCIS.Blocks.BlockCoverage` - statements, blocks,
46processes -, :class:`~pyEDAA.Reports.CodeCoverage.UCIS.Branches.BranchCoverage` - branching statements and their
47branches -, :class:`~pyEDAA.Reports.CodeCoverage.UCIS.Toggles.ToggleCoverage`,
48:class:`~pyEDAA.Reports.CodeCoverage.UCIS.Conditions.ConditionCoverage`,
49:class:`~pyEDAA.Reports.CodeCoverage.UCIS.FSMs.FSMCoverage`,
50:class:`~pyEDAA.Reports.CodeCoverage.UCIS.Assertions.AssertionCoverage` and
51:class:`~pyEDAA.Reports.CodeCoverage.UCIS.Covergroups.CovergroupCoverage`. A coverage item states its count in a
52:class:`~pyEDAA.Reports.CodeCoverage.UCIS.Bins.Bin`, and where it is in a
53:class:`~pyEDAA.Reports.CodeCoverage.UCIS.Elements.StatementID`: the source file's ID, the line and the index of the
54statement in the line. Each element's constructor takes typed values, so the model can be built by hand: an element
55below the report names its parent with the keyword parameter ``parent`` and is added to it. Its class method ``Parse``
56reads the element's XML element.
58:meth:`Document.ToCoverageSummary` converts the statement and branch coverage to the common model of
59:mod:`pyEDAA.Reports.CodeCoverage`:
61* The statements are the statements of the block coverages, and the statements of their blocks, which ran as often as
62 their block. A statement is identified by its file, its line and its index in the line; with ``mergeInstances``, the
63 statements of all instances are merged into one, which ran as often as in all instances together.
64* A file's path is the one its source file states.
65* A line ran as often as its least often run statement: it is covered, if all its statements ran. A line, whose
66 statements are all excluded, is excluded; an excluded statement doesn't count for its line.
67* A branching statement's branches - with ``mergeInstances`` summed over the instances - become branches of its line; a
68 line, which ran without taking all its branches, is partially covered. A branch goes to the line of its first
69 statement. An excluded branch or branching statement isn't converted.
70* A design unit, whose instances name it, becomes a :class:`~pyEDAA.Reports.CodeCoverage.Module`, spanning the lines
71 of its statements, if they are in one file.
72* The toggle, condition, expression, FSM, assertion and covergroup coverage stays in the format's model.
74.. rubric:: Example
76.. code-block:: Python
78 from pathlib import Path
79 from pyEDAA.Reports.CodeCoverage.UCIS import Document
81 report = Document(Path("coverage.xml"), analyzeAndConvert=True)
82 summary = report.ToCoverageSummary(mergeInstances=True)
83 for file in summary.IterateFiles():
84 print(f"{file.Path}: {file.LineCoverage:.1%}")
85"""
86from __future__ import annotations
88from datetime import datetime
89from io import BytesIO
90from pathlib import Path
91from typing import ClassVar, Generator, Optional as Nullable
93from lxml.etree import XMLParser, XMLSchema, XMLSchemaParseError, XMLSyntaxError
94from lxml.etree import _Element, _ElementTree, parse
95from pyTooling.Common import getFullyQualifiedName, getResourceFile, StringEnum
96from pyTooling.Decorators import export, readonly
97from pyTooling.Exceptions import ToolingException
98from pyTooling.MetaClasses import ExtendedType
99from pyTooling.Stopwatch import Stopwatch
101from pyEDAA.Reports import Resources
102from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, CodeCoverageError, CoverageSummary
103from pyEDAA.Reports.CodeCoverage import Document as cc_Document, Line as cc_Line, LineCoverageStatus
104from pyEDAA.Reports.CodeCoverage import Module as cc_Module
105from pyEDAA.Reports.CodeCoverage.UCIS.Blocks import Statement
106from pyEDAA.Reports.CodeCoverage.UCIS.Branches import BranchStatement
107from pyEDAA.Reports.CodeCoverage.UCIS.HistoryNodes import HistoryNode
108from pyEDAA.Reports.CodeCoverage.UCIS.Instances import InstanceCoverage, SourceFile
111__all__ = ["NAMESPACE", "SCHEMAS"]
113NAMESPACE = "UCIS" #: The XML namespace of the format's elements.
116@export
117class FormatVersion(StringEnum):
118 """
119 Version of the UCIS XML interchange format: the UCIS version a report's root states in ``ucisVersion``.
120 """
122 Version1_0 = "1.0" #: UCIS 1.0, June 2012.
125# A class with a property named like a class - ``FormatVersion`` - can't name that class in the annotation of a field:
126# the class body's namespace, where annotations are evaluated, binds the name to the property.
127_FormatVersion = FormatVersion
129SCHEMAS: dict[FormatVersion, str] = {
130 FormatVersion.Version1_0: "UCIS-1.0.xsd"
131} #: Per format version, the XML schema a report of that version is validated against.
134@export
135class Report(metaclass=ExtendedType, mixin=True):
136 """
137 The root element ``<UCIS>``: its UCIS version, the tool, which wrote it, and when, its source files, history nodes and
138 instances.
140 The root of the format's model: a source file, a history node or an instance names it as its parent.
141 """
143 _formatVersion: Nullable[_FormatVersion] #: The UCIS version the report states.
144 _writtenBy: Nullable[str] #: Name of the tool, which wrote the report.
145 _writtenTime: Nullable[datetime] #: When the report was written.
146 _sourceFiles: dict[int, SourceFile] #: The source files, by ID.
147 _historyNodes: dict[int, HistoryNode] #: The history nodes, by ID.
148 _instances: list[InstanceCoverage] #: The instances.
150 def __init__(self) -> None:
151 """
152 Initialize an empty report.
153 """
154 self._formatVersion = None
155 self._writtenBy = None
156 self._writtenTime = None
157 self._sourceFiles = {}
158 self._historyNodes = {}
159 self._instances = []
161 @readonly
162 def FormatVersion(self) -> Nullable[_FormatVersion]:
163 """
164 Read-only property to access the UCIS version the report states (:attr:`_formatVersion`).
166 :returns: The version; ``None`` before the report was analyzed.
167 """
168 return self._formatVersion
170 @readonly
171 def WrittenBy(self) -> Nullable[str]:
172 """
173 Read-only property to access the name of the tool, which wrote the report (:attr:`_writtenBy`).
175 :returns: The tool's name, as stated; ``None`` before the report was converted.
176 """
177 return self._writtenBy
179 @readonly
180 def WrittenTime(self) -> Nullable[datetime]:
181 """
182 Read-only property to access when the report was written (:attr:`_writtenTime`).
184 :returns: The date and time; ``None`` before the report was converted.
185 """
186 return self._writtenTime
188 @readonly
189 def SourceFiles(self) -> dict[int, SourceFile]:
190 """
191 Read-only property to access the source files (:attr:`_sourceFiles`).
193 :returns: The source files, by the ID statement identifiers name them by.
194 """
195 return self._sourceFiles
197 @readonly
198 def HistoryNodes(self) -> dict[int, HistoryNode]:
199 """
200 Read-only property to access the history nodes (:attr:`_historyNodes`).
202 :returns: The history nodes, by the ID bins name them by.
203 """
204 return self._historyNodes
206 @readonly
207 def Instances(self) -> list[InstanceCoverage]:
208 """
209 Read-only property to access the instances (:attr:`_instances`).
211 :returns: The instances, in the order the report lists them.
212 """
213 return self._instances
215 def IterateStatements(self) -> Generator[Statement, None, None]:
216 """
217 Iterate the statements of all instances' block coverages.
219 :returns: A generator of the statements, in the order the report lists them.
220 """
221 for instance in self._instances:
222 for blockCoverage in instance._blockCoverages:
223 yield from blockCoverage._statements
225 def IterateBranchStatements(self) -> Generator[BranchStatement, None, None]:
226 """
227 Iterate the branching statements of all instances' branch coverages, nested ones included.
229 :returns: A generator of the branching statements, each followed by the branching statements nested in it.
230 """
231 for instance in self._instances:
232 for branchCoverage in instance._branchCoverages:
233 yield from branchCoverage.IterateStatements()
236@export
237class Document(cc_Document, Report):
238 """
239 A UCIS XML code coverage report: read into the format's model, and converted to the common model.
240 """
242 _NAMESPACE: ClassVar[Nullable[str]] = NAMESPACE #: The XML namespace of the elements; ``None`` for none.
243 _SCHEMAS: ClassVar[dict[FormatVersion, str]] = SCHEMAS #: Per format version, the XML schema to validate with.
245 _xmlDocument: Nullable[_ElementTree] #: The parsed and validated XML document, after :meth:`Analyze`.
247 def __init__(self, xmlReportFile: Path, analyzeAndConvert: bool = False) -> None:
248 """
249 Initialize the report, and optionally read it.
251 :param xmlReportFile: Path to the UCIS XML file.
252 :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
253 """
254 super().__init__(xmlReportFile)
255 Report.__init__(self)
257 self._xmlDocument = None
259 if analyzeAndConvert:
260 self.Analyze()
261 self.Convert()
263 def Analyze(self) -> None:
264 """
265 Parse the XML file, check its root element, read its UCIS version, and validate the file against the XML schema of
266 that version (:attr:`_SCHEMAS`).
268 :raises CodeCoverageError: If the file doesn't exist.
269 :raises CodeCoverageError: If the file can't be read.
270 :raises CodeCoverageError: If the file isn't well-formed XML.
271 :raises CodeCoverageError: If the root element isn't ``<UCIS>`` of the namespace :attr:`_NAMESPACE`.
272 :raises CodeCoverageError: If the file states no supported UCIS version.
273 :raises CodeCoverageError: If the XML schema can't be located or parsed.
274 :raises CodeCoverageError: If the file isn't valid according to the XML schema.
275 """
276 if not self._path.exists():
277 raise CodeCoverageError(f"UCIS XML file '{self._path}' does not exist.") \
278 from FileNotFoundError(f"File '{self._path}' not found.")
280 with Stopwatch() as sw:
281 try:
282 content = self._path.read_bytes()
283 except OSError as ex:
284 raise CodeCoverageError(f"Couldn't read UCIS XML file '{self._path}'.") from ex
286 try:
287 xmlDocument = parse(BytesIO(content), XMLParser(ns_clean=True), base_url=str(self._path))
288 except XMLSyntaxError as ex:
289 raise CodeCoverageError(f"XML syntax error in UCIS XML file '{self._path}'.") from ex
291 rootElement: _Element = xmlDocument.getroot()
292 rootTag = "UCIS" if self._NAMESPACE is None else f"{{{self._NAMESPACE}}}UCIS"
293 if rootElement.tag != rootTag:
294 ex = CodeCoverageError(f"Root element of '{self._path}' is not '<{rootTag}>'.")
295 ex.add_note(f"Got root element '<{rootElement.tag}>'.")
296 raise ex
298 version = rootElement.attrib.get("ucisVersion")
299 if version not in [member.value for member in FormatVersion]:
300 ex = CodeCoverageError(f"UCIS XML file '{self._path}' states an unsupported UCIS version.")
301 ex.add_note(f"Got version '{version}'." if version is not None else "Got no version.")
302 ex.add_note(f"Supported UCIS versions: {', '.join(member.value for member in FormatVersion)}.")
303 raise ex
305 formatVersion = FormatVersion(version)
306 schemaFile = self._SCHEMAS[formatVersion]
307 try:
308 schemaResourceFile = getResourceFile(Resources, schemaFile)
309 except ToolingException as ex:
310 raise CodeCoverageError(f"Couldn't locate XML Schema '{schemaFile}' in package resources.") from ex
312 try:
313 xmlSchema = XMLSchema(parse(schemaResourceFile, XMLParser(ns_clean=True)))
314 except (XMLSyntaxError, XMLSchemaParseError) as ex:
315 raise CodeCoverageError(f"Error while parsing XML Schema '{schemaFile}'.") from ex
317 if not xmlSchema.validate(xmlDocument):
318 ex = CodeCoverageError(f"Validation error for '{self._path}' using XSD schema '{schemaFile}'.")
319 for logEntry in xmlSchema.error_log:
320 ex.add_note(str(logEntry))
321 raise ex
323 self._xmlDocument = xmlDocument
324 self._formatVersion = formatVersion
326 self._analysisDuration = sw.Duration
328 def Convert(self) -> None:
329 """
330 Convert the parsed and validated XML document to the format's model.
332 :raises CodeCoverageError: If the XML file was not analyzed before. |br|
333 Call 'Document.Analyze()' or create the document using
334 'Document(path, analyzeAndConvert=True)'.
335 :raises CodeCoverageError: If the report states a source file's or a history node's ID twice.
336 :raises CodeCoverageError: If a user-defined attribute's value isn't of the type the attribute states.
337 """
338 if self._xmlDocument is None:
339 ex = CodeCoverageError(f"UCIS XML file '{self._path}' needs to be read and analyzed by an XML parser.")
340 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
341 raise ex
343 with Stopwatch() as sw:
344 root: _Element = self._xmlDocument.getroot()
345 self._writtenBy = root.attrib["writtenBy"]
346 self._writtenTime = datetime.fromisoformat(root.attrib["writtenTime"])
348 for sourceFileElement in root.iterfind("{*}sourceFiles"):
349 if (sourceFileID := int(sourceFileElement.attrib["id"])) in self._sourceFiles:
350 raise CodeCoverageError(f"UCIS XML file '{self._path}' states source file ID '{sourceFileID}' twice.")
352 SourceFile.Parse(sourceFileElement, parent=self)
354 for historyNodeElement in root.iterfind("{*}historyNodes"):
355 if (historyNodeID := int(historyNodeElement.attrib["historyNodeId"])) in self._historyNodes: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true
356 raise CodeCoverageError(f"UCIS XML file '{self._path}' states history node ID '{historyNodeID}' twice.")
358 HistoryNode.Parse(historyNodeElement, parent=self)
360 for instanceElement in root.iterfind("{*}instanceCoverages"):
361 InstanceCoverage.Parse(instanceElement, parent=self)
363 self._conversionDuration = sw.Duration
365 def ToCoverageSummary(self, mergeInstances: bool = False) -> CoverageSummary:
366 """
367 Convert the statement and branch coverage of the format's model to the common model, and aggregate it.
369 A line ran as often as its least often run statement: it is covered, if all its statements ran, otherwise
370 uncovered; partially covered, if it ran without taking all its branches. A line, whose statements are all excluded,
371 is excluded. A design unit, whose statements are in one file, becomes a module spanning their lines.
373 :param mergeInstances: Optional, if true, merge the statements and branches of all instances. Default:
374 ``False``.
375 :returns: The report's root of the common model, named after the report file. Its source
376 directories are the directories the tests ran in.
377 :raises ValueError: If parameter ``mergeInstances`` is ``None``.
378 :raises TypeError: If parameter ``mergeInstances`` isn't of type :class:`bool`.
379 :raises CodeCoverageError: If a statement identifier names a source file the report doesn't state.
380 :raises CodeCoverageError: If a file's path runs through another file.
381 """
382 if mergeInstances is None:
383 raise ValueError("Parameter 'mergeInstances' is None.")
384 elif not isinstance(mergeInstances, bool):
385 ex = TypeError("Parameter 'mergeInstances' is not of type 'bool'.")
386 ex.add_note(f"Got type '{getFullyQualifiedName(mergeInstances)}'.")
387 raise ex
389 # A statement's or branching statement's key: the instance's index - 0, if merged -, file ID, line, index in line.
390 statementCounts: dict[tuple[int, int, int, int], Nullable[int]] = {} # None: excluded
391 branchCounts: dict[tuple[int, int, int, int], list[Nullable[int]]] = {} # per branch; None: excluded
392 branchTargets: dict[tuple[int, int, int, int], list[int]] = {} # per branch: the line it goes to
393 moduleLines: dict[str, set[tuple[int, int]]] = {} # file ID and line, per design unit
395 for instanceIndex, instance in enumerate(self._instances):
396 scope = 0 if mergeInstances else instanceIndex
397 lines = moduleLines.setdefault(instance._moduleName, set()) if instance._moduleName is not None else set()
398 for blockCoverage in instance._blockCoverages:
399 counted = [(statement._id, statement._bin, statement._isExcluded) for statement in blockCoverage._statements]
400 for block in blockCoverage.IterateBlocks():
401 for statementID in block._statementIDs if len(block._statementIDs) > 0 else (block._id, ):
402 counted.append((statementID, block._bin, block._isExcluded))
404 for statementID, coverBin, isExcluded in counted:
405 if statementID._fileID not in self._sourceFiles:
406 ex = CodeCoverageError(f"UCIS statement identifier names source file ID '{statementID._fileID}'.")
407 ex.add_note(f"The report states source file IDs: {', '.join(str(i) for i in self._sourceFiles)}.")
408 raise ex
410 key = (scope, statementID._fileID, statementID._lineNumber, statementID._inlineCount)
411 if isExcluded or coverBin._isExcluded:
412 statementCounts.setdefault(key, None)
413 else:
414 previous = statementCounts.get(key)
415 statementCounts[key] = coverBin._contents._coverageCount + (0 if previous is None else previous)
417 lines.add((statementID._fileID, statementID._lineNumber))
419 for branchCoverage in instance._branchCoverages:
420 for branchStatement in branchCoverage.IterateStatements():
421 statementID = branchStatement._id
422 if statementID._fileID not in self._sourceFiles: 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true
423 ex = CodeCoverageError(f"UCIS statement identifier names source file ID '{statementID._fileID}'.")
424 ex.add_note(f"The report states source file IDs: {', '.join(str(i) for i in self._sourceFiles)}.")
425 raise ex
426 elif branchStatement._isExcluded:
427 continue
429 lines.add((statementID._fileID, statementID._lineNumber))
430 key = (scope, statementID._fileID, statementID._lineNumber, statementID._inlineCount)
431 counts = branchCounts.setdefault(key, [])
432 targets = branchTargets.setdefault(key, [])
433 for index, branch in enumerate(branchStatement._branches):
434 count = None if branch._bin._isExcluded else branch._bin._contents._coverageCount
435 if index < len(counts):
436 counts[index] = count if counts[index] is None else counts[index] + (0 if count is None else count)
437 else:
438 counts.append(count)
439 targets.append(branch._id._lineNumber if branch._id._fileID == statementID._fileID else 0)
441 lineCounts: dict[tuple[int, int], list[Nullable[int]]] = {}
442 for (_, fileID, lineNumber, _), count in statementCounts.items():
443 lineCounts.setdefault((fileID, lineNumber), []).append(count)
445 lineBranches: dict[tuple[int, int], list[tuple[int, int]]] = {}
446 for key, counts in branchCounts.items():
447 branches = lineBranches.setdefault((key[1], key[2]), [])
448 branches.extend(
449 (count, target) for count, target in zip(counts, branchTargets[key]) if count is not None
450 )
451 if (key[1], key[2]) not in lineCounts:
452 lineCounts[(key[1], key[2])] = [sum(count for count in counts if count is not None)]
454 sourceDirectories = {
455 historyNode._runDirectory: None
456 for historyNode in self._historyNodes.values() if historyNode._runDirectory is not None
457 }
458 summary = CoverageSummary(self._path.stem, sourceDirectories=sourceDirectories)
459 files = {
460 fileID: summary.GetOrAddFile(self._sourceFiles[fileID]._path) for fileID in sorted({key[0] for key in lineCounts})
461 }
462 for (fileID, lineNumber) in sorted(lineCounts):
463 counts = [count for count in lineCounts[(fileID, lineNumber)] if count is not None]
464 if len(counts) == 0:
465 cc_Line(lineNumber, LineCoverageStatus.Excluded, parent=files[fileID])
466 continue
468 count = min(counts)
469 branches = lineBranches.get((fileID, lineNumber), [])
470 if count == 0:
471 status = LineCoverageStatus.Uncovered
472 elif any(branchCount == 0 for branchCount, _ in branches):
473 status = LineCoverageStatus.PartiallyCovered
474 else:
475 status = LineCoverageStatus.Covered
477 cc_Line(lineNumber, status, count, parent=files[fileID])
479 for (fileID, lineNumber), branches in lineBranches.items():
480 file = files[fileID]
481 line = file.GetLine(lineNumber)
482 for count, target in branches:
483 status = LineCoverageStatus.Covered if count > 0 else LineCoverageStatus.Uncovered
484 targetLine = file.GetLine(target) if 0 < target <= file._lastLineNumber else None
485 cc_Branch(status, count, targetLine, parent=line)
487 for moduleName, lines in moduleLines.items():
488 if len(fileIDs := {fileID for fileID, _ in lines}) == 1:
489 file = files[fileIDs.pop()]
490 lineNumbers = [lineNumber for _, lineNumber in lines]
491 cc_Module(
492 moduleName,
493 file=file,
494 startLine=file.GetLine(min(lineNumbers)),
495 endLine=file.GetLine(max(lineNumbers)),
496 parent=summary
497 )
499 summary.Aggregate()
500 return summary