Source code for pyEDAA.Reports.CodeCoverage.Cobertura.Records

# ==================================================================================================================== #
#              _____ ____    _        _      ____                       _                                              #
#  _ __  _   _| ____|  _ \  / \      / \    |  _ \ ___ _ __   ___  _ __| |_ ___                                        #
# | '_ \| | | |  _| | | | |/ _ \    / _ \   | |_) / _ \ '_ \ / _ \| '__| __/ __|                                       #
# | |_) | |_| | |___| |_| / ___ \  / ___ \ _|  _ <  __/ |_) | (_) | |  | |_\__ \                                       #
# | .__/ \__, |_____|____/_/   \_\/_/   \_(_)_| \_\___| .__/ \___/|_|   \__|___/                                       #
# |_|    |___/                                        |_|                                                              #
# ==================================================================================================================== #
# Authors:                                                                                                             #
#   Patrick Lehmann                                                                                                    #
#                                                                                                                      #
# License:                                                                                                             #
# ==================================================================================================================== #
# Copyright 2026-2026 Electronic Design Automation Abstraction (EDA²)                                                  #
#                                                                                                                      #
# Licensed under the Apache License, Version 2.0 (the "License");                                                      #
# you may not use this file except in compliance with the License.                                                     #
# You may obtain a copy of the License at                                                                              #
#                                                                                                                      #
#   http://www.apache.org/licenses/LICENSE-2.0                                                                         #
#                                                                                                                      #
# Unless required by applicable law or agreed to in writing, software                                                  #
# distributed under the License is distributed on an "AS IS" BASIS,                                                    #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.                                             #
# See the License for the specific language governing permissions and                                                  #
# limitations under the License.                                                                                       #
#                                                                                                                      #
# SPDX-License-Identifier: Apache-2.0                                                                                  #
# ==================================================================================================================== #
#
"""
The elements of the Cobertura XML format below ``<coverage>``: its packages, classes, methods, lines and conditions.
"""
from __future__                  import annotations

from typing                      import Iterable, Optional as Nullable

from lxml.etree                  import Element as XMLElement, SubElement, _Element
from pyTooling.Common            import getFullyQualifiedName
from pyTooling.Decorators        import export, readonly
from pyTooling.MetaClasses       import ExtendedType

from pyEDAA.Reports.CodeCoverage import Class as cc_Class, CoverageSummary, File as cc_File, Function as cc_Function, \
                                        Line as cc_Line, LineCoverageStatus, Method as cc_Method, Module as cc_Module, \
                                        Package as cc_Package, SourceFile as cc_SourceFile, Unit as cc_Unit


[docs] @export class Condition(metaclass=ExtendedType, slots=True): """ A ``<condition>`` of a line: a condition of a branching line, as gcovr writes it. """ _number: int #: Number of the condition in its line. _type: str #: Kind of condition, e.g. ``jump``. _coverage: str #: Coverage of the condition, e.g. ``50%``.
[docs] def __init__(self, number: int, conditionType: str, coverage: str) -> None: """ Initialize a condition. :param number: Number of the condition in its line. :param conditionType: Kind of condition, e.g. ``jump``. :param coverage: Coverage of the condition, e.g. ``50%``. """ self._number = number self._type = conditionType self._coverage = coverage
@readonly def Number(self) -> int: """ Read-only property to access the number of the condition in its line (:attr:`_number`). :returns: The number. """ return self._number @readonly def Type(self) -> str: """ Read-only property to access the kind of condition (:attr:`_type`). The kind is stated as the report states it, e.g. ``jump``. :returns: The kind. """ return self._type @readonly def Coverage(self) -> str: """ Read-only property to access the coverage of the condition (:attr:`_coverage`). The coverage is stated as the report states it, e.g. ``50%``. :returns: The coverage. """ return self._coverage
[docs] def Generate(self) -> _Element: """ Generate the condition's ``<condition>`` element. :returns: The ``<condition>`` element. """ conditionElement = XMLElement("condition") conditionElement.attrib["number"] = str(self._number) conditionElement.attrib["type"] = self._type conditionElement.attrib["coverage"] = self._coverage return conditionElement
[docs] @export class Line(metaclass=ExtendedType, slots=True): """ A ``<line>``: a line's hits, whether it branches, its condition coverage and its conditions. """ _number: int #: Line number. _hits: int #: How often the line ran. _branch: bool #: Whether the line branches. _conditionCoverage: Nullable[tuple[int, int]] #: Taken and all branches, if the line states them. _conditions: list[Condition] #: The line's conditions.
[docs] def __init__( self, number: int, hits: int, branch: bool = False, conditionCoverage: Nullable[tuple[int, int]] = None, conditions: list[Condition] | None = None ) -> None: """ Initialize a line. :param number: Line number. :param hits: How often the line ran. :param branch: Optional, whether the line branches. Default: ``False``. :param conditionCoverage: Optional, taken and all branches. Default: ``None``. :param conditions: Optional, the line's conditions. Default: none. """ self._number = number self._hits = hits self._branch = branch self._conditionCoverage = conditionCoverage self._conditions = [] if conditions is None else conditions
@readonly def Number(self) -> int: """ Read-only property to access the line number (:attr:`_number`). :returns: The line number. """ return self._number @readonly def Hits(self) -> int: """ Read-only property to access how often the line ran (:attr:`_hits`). :returns: The hits. """ return self._hits @readonly def Branch(self) -> bool: """ Read-only property to access whether the line branches (:attr:`_branch`). :returns: ``True``, if the line branches. """ return self._branch @readonly def ConditionCoverage(self) -> Nullable[tuple[int, int]]: """ Read-only property to access the line's taken and all branches (:attr:`_conditionCoverage`). A line with one of two branches taken has ``(1, 2)``. :returns: The taken and all branches; ``None`` if the line doesn't state them. """ return self._conditionCoverage @readonly def Conditions(self) -> list[Condition]: """ Read-only property to access the line's conditions (:attr:`_conditions`). :returns: The conditions. """ return self._conditions
[docs] @classmethod def FromLine(cls, line: cc_Line) -> Line: """ Convert a line of the common model. A line without count has ``1`` hit, if it ran, else ``0``. A line with branches is a branching line; its condition coverage counts its branches with state :attr:`~pyEDAA.Reports.CodeCoverage.LineCoverageStatus.Covered` and all its branches. :param line: The line of the common model. :returns: The line. :raises ValueError: If parameter ``line`` is ``None``. :raises TypeError: If parameter ``line`` isn't of type :class:`~pyEDAA.Reports.CodeCoverage.Line`. """ if line is None: raise ValueError("Parameter 'line' is None.") elif not isinstance(line, cc_Line): ex = TypeError("Parameter 'line' is not of type 'Line'.") ex.add_note(f"Got type '{getFullyQualifiedName(line)}'.") raise ex if line._coverageCount is not None: hits = line._coverageCount elif line._status in (LineCoverageStatus.Covered, LineCoverageStatus.PartiallyCovered): hits = 1 else: hits = 0 if len(line._branches) == 0: return cls(line._lineNumber, hits) covered = sum(1 for branch in line._branches if branch._status is LineCoverageStatus.Covered) return cls(line._lineNumber, hits, True, (covered, len(line._branches)))
[docs] def Generate(self) -> _Element: """ Generate the line's ``<line>`` element with its conditions. :returns: The ``<line>`` element. """ lineElement = XMLElement("line") lineElement.attrib["number"] = str(self._number) lineElement.attrib["hits"] = str(self._hits) if self._branch: lineElement.attrib["branch"] = "true" if self._conditionCoverage is not None: covered, total = self._conditionCoverage percent = 100 if total == 0 else 100 * covered // total lineElement.attrib["condition-coverage"] = f"{percent}% ({covered}/{total})" if len(self._conditions) > 0: conditionsElement = SubElement(lineElement, "conditions") for condition in self._conditions: conditionsElement.append(condition.Generate()) return lineElement
[docs] @export class Element(metaclass=ExtendedType, slots=True): """ Base-class of the named elements with rates - packages, classes and methods -, as the report states the rates. """ _name: str #: Name of the element. _lineRate: Nullable[float] #: Line coverage, as the report states it. _branchRate: Nullable[float] #: Branch coverage, as the report states it.
[docs] def __init__(self, name: str, lineRate: Nullable[float] = None, branchRate: Nullable[float] = None) -> None: """ Initialize the name and the rates. :param name: Name of the element. :param lineRate: Optional, line coverage, as the report states it. Default: ``None``. :param branchRate: Optional, branch coverage, as the report states it. Default: ``None``. """ self._name = name self._lineRate = lineRate self._branchRate = branchRate
@readonly def Name(self) -> str: """ Read-only property to access the element's name (:attr:`_name`). :returns: The name. """ return self._name @readonly def LineRate(self) -> Nullable[float]: """ Read-only property to access the line coverage, as the report states it (:attr:`_lineRate`). :returns: The rate in range 0.0..1.0, or ``None`` if the report doesn't state it. """ return self._lineRate @readonly def BranchRate(self) -> Nullable[float]: """ Read-only property to access the branch coverage, as the report states it (:attr:`_branchRate`). :returns: The rate in range 0.0..1.0, or ``None`` if the report doesn't state it. """ return self._branchRate
[docs] @staticmethod def _CountLines(lines: Iterable[Line]) -> tuple[int, int, int, int]: """ Count lines and their branches. :param lines: The lines. :returns: The number of lines, of lines, which ran, of branches, and of branches, which were taken. """ linesValid = linesCovered = branchesValid = branchesCovered = 0 for line in lines: linesValid += 1 if line._hits > 0: linesCovered += 1 if line._conditionCoverage is not None: branchesCovered += line._conditionCoverage[0] branchesValid += line._conditionCoverage[1] return linesValid, linesCovered, branchesValid, branchesCovered
[docs] @staticmethod def _Rates(counts: tuple[int, int, int, int]) -> tuple[float, float]: """ Compute the line and branch rates from the counts of :meth:`_CountLines`. :param counts: The number of lines, of lines, which ran, of branches, and of branches, which were taken. :returns: The line and the branch rate in range 0.0..1.0; ``1.0`` if there is no line or no branch. """ linesValid, linesCovered, branchesValid, branchesCovered = counts return ( 1.0 if linesValid == 0 else linesCovered / linesValid, 1.0 if branchesValid == 0 else branchesCovered / branchesValid )
[docs] def _GenerateRates(self, xmlElement: _Element, lines: Iterable[Line]) -> None: """ Add the line and branch rate to the element's XML element: as stated, or computed from its lines. :param xmlElement: The element's XML element. :param lines: The element's lines. """ lineRate = self._lineRate branchRate = self._branchRate if lineRate is None or branchRate is None: computedLineRate, computedBranchRate = self._Rates(self._CountLines(lines)) lineRate = computedLineRate if lineRate is None else lineRate branchRate = computedBranchRate if branchRate is None else branchRate xmlElement.attrib["line-rate"] = str(round(lineRate, 4)) xmlElement.attrib["branch-rate"] = str(round(branchRate, 4))
[docs] @export class Method(Element): """ A ``<method>`` of a class: its signature and lines. """ _signature: Nullable[str] #: Signature of the method. _lines: dict[int, Line] #: The method's lines, by number.
[docs] def __init__( self, name: str, signature: Nullable[str] = None, lineRate: Nullable[float] = None, branchRate: Nullable[float] = None ) -> None: """ Initialize a method. :param name: Name of the method. :param signature: Optional, signature of the method. Default: ``None``. :param lineRate: Optional, line coverage, as the report states it. Default: ``None``. :param branchRate: Optional, branch coverage, as the report states it. Default: ``None``. """ super().__init__(name, lineRate, branchRate) self._signature = signature self._lines = {}
@readonly def Signature(self) -> Nullable[str]: """ Read-only property to access the method's signature (:attr:`_signature`). :returns: The signature, or ``None`` if the report doesn't state it. """ return self._signature @readonly def Lines(self) -> dict[int, Line]: """ Read-only property to access the method's lines (:attr:`_lines`). :returns: The lines, by number. """ return self._lines
[docs] def Generate(self) -> _Element: """ Generate the method's ``<method>`` element with its lines. A rate the method doesn't state is computed from its lines; a missing signature is written as empty text. :returns: The ``<method>`` element. """ methodElement = XMLElement("method") methodElement.attrib["name"] = self._name methodElement.attrib["signature"] = "" if self._signature is None else self._signature self._GenerateRates(methodElement, self._lines.values()) linesElement = SubElement(methodElement, "lines") for line in self._lines.values(): linesElement.append(line.Generate()) return methodElement
[docs] @export class Class(Element): """ A ``<class>`` of a package: its source file, methods and lines. """ _filename: str #: Path of the class' source file, relative to a ``<source>`` directory. _complexity: Nullable[float] #: Complexity, as the report states it. _methods: dict[str, Method] #: The class' methods, by name and signature. _lines: dict[int, Line] #: The class' lines, by number.
[docs] def __init__( self, name: str, filename: str, lineRate: Nullable[float] = None, branchRate: Nullable[float] = None, complexity: Nullable[float] = None ) -> None: """ Initialize a class. :param name: Name of the class. :param filename: Path of the class' source file, relative to a ``<source>`` directory. :param lineRate: Optional, line coverage, as the report states it. Default: ``None``. :param branchRate: Optional, branch coverage, as the report states it. Default: ``None``. :param complexity: Optional, complexity, as the report states it. Default: ``None``. """ super().__init__(name, lineRate, branchRate) self._filename = filename self._complexity = complexity self._methods = {} self._lines = {}
@readonly def Filename(self) -> str: """ Read-only property to access the path of the class' source file (:attr:`_filename`). :returns: The path, relative to a ``<source>`` directory. """ return self._filename @readonly def Complexity(self) -> Nullable[float]: """ Read-only property to access the complexity, as the report states it (:attr:`_complexity`). :returns: The complexity, or ``None`` if the report doesn't state it. """ return self._complexity @readonly def Methods(self) -> dict[str, Method]: """ Read-only property to access the class' methods (:attr:`_methods`). :returns: The methods, by name - and signature, if two methods share a name. """ return self._methods @readonly def Lines(self) -> dict[int, Line]: """ Read-only property to access the class' lines (:attr:`_lines`). :returns: The lines, by number. """ return self._lines
[docs] @classmethod def _FromFile(cls, file: cc_File, packages: dict[str, Package]) -> None: """ Convert a file of the common model to classes, and add them to their packages. :param file: The file of the common model. :param packages: The packages, by name; a missing package is added. """ filename = file.Path.as_posix() directories = file.Path.parent.parts directoryPackage = ".".join(directories) if len(directories) > 0 else "." fileClasses: list[Class] = [] def addClass(packageName: str, className: str) -> Class: """ Nested function creating a class of this file, and adding it to its package. :param packageName: Name of the package. :param className: Name of the class. :returns: The class. """ if (package := packages.get(packageName)) is None: package = packages[packageName] = Package(packageName, complexity=0.0) klass = cls(className, filename, complexity=0.0) package._classes.append(klass) fileClasses.append(klass) return klass classes: dict[cc_Unit, Class] = {} spanned: list[cc_Unit] = [] for unit in file._units: if not isinstance(unit, (cc_SourceFile, cc_Module, cc_Class)): continue chain: list[cc_Unit] = [] element: cc_Unit | CoverageSummary = unit while isinstance(element, cc_Unit): chain.insert(0, element) element = element._parent packageCount = 0 while isinstance(chain[packageCount], cc_Package): packageCount += 1 classes[unit] = addClass( ".".join(part._name for part in chain[:packageCount]) if packageCount > 0 else directoryPackage, ".".join(part._name for part in chain[packageCount:]) ) if unit._startLine is not None and unit._endLine is not None: spanned.append(unit) # outer units first: an inner unit overwrites the owner of its lines owners: dict[int, Class] = {} for unit in sorted(spanned, key=lambda unit: (unit._startLine._lineNumber, -unit._endLine._lineNumber)): for line in file.IterateLines(unit._startLine, unit._endLine): owners[line._lineNumber] = classes[unit] fallback: Nullable[Class] = None lines: dict[int, Line] = {} for commonLine in file.IterateLines(): if commonLine._status is LineCoverageStatus.Excluded: continue line = lines[commonLine._lineNumber] = Line.FromLine(commonLine) if (klass := owners.get(line._number)) is None: if fallback is None: fallback = addClass(directoryPackage, file._name) klass = fallback klass._lines[line._number] = line for unit in file._units: if not isinstance(unit, (cc_Function, cc_Method)): continue names = [unit._name] element = unit._parent while isinstance(element, cc_Unit) and element not in classes: if not isinstance(element, cc_Package): names.insert(0, element._name) element = element._parent if isinstance(element, cc_Unit): klass = classes[element] else: if fallback is None: fallback = addClass(directoryPackage, file._name) klass = fallback method = Method(".".join(names)) if unit._startLine is not None and unit._endLine is not None: for commonLine in file.IterateLines(unit._startLine, unit._endLine): if (line := lines.get(commonLine._lineNumber)) is not None: method._lines[line._number] = line method._lineRate, method._branchRate = cls._Rates(cls._CountLines(method._lines.values())) klass._methods[method._name] = method for klass in fileClasses: klass._lineRate, klass._branchRate = cls._Rates(cls._CountLines(klass._lines.values()))
[docs] def Generate(self) -> _Element: """ Generate the class' ``<class>`` element with its methods and lines. A rate the class doesn't state is computed from its lines; a missing complexity is written as ``0``. :returns: The ``<class>`` element. """ classElement = XMLElement("class") classElement.attrib["name"] = self._name classElement.attrib["filename"] = self._filename self._GenerateRates(classElement, self._lines.values()) classElement.attrib["complexity"] = str(0 if self._complexity is None else self._complexity) methodsElement = SubElement(classElement, "methods") for method in self._methods.values(): methodsElement.append(method.Generate()) linesElement = SubElement(classElement, "lines") for line in self._lines.values(): linesElement.append(line.Generate()) return classElement
[docs] @export class Package(Element): """ A ``<package>``: its classes. """ _complexity: Nullable[float] #: Complexity, as the report states it. _classes: list[Class] #: The package's classes, in the report's order.
[docs] def __init__( self, name: str, lineRate: Nullable[float] = None, branchRate: Nullable[float] = None, complexity: Nullable[float] = None ) -> None: """ Initialize a package. :param name: Name of the package. :param lineRate: Optional, line coverage, as the report states it. Default: ``None``. :param branchRate: Optional, branch coverage, as the report states it. Default: ``None``. :param complexity: Optional, complexity, as the report states it. Default: ``None``. """ super().__init__(name, lineRate, branchRate) self._complexity = complexity self._classes = []
@readonly def Complexity(self) -> Nullable[float]: """ Read-only property to access the complexity, as the report states it (:attr:`_complexity`). :returns: The complexity, or ``None`` if the report doesn't state it. """ return self._complexity @readonly def Classes(self) -> list[Class]: """ Read-only property to access the package's classes (:attr:`_classes`). :returns: The classes, in the report's order; two classes may share a name. """ return self._classes
[docs] @classmethod def _FromFiles(cls, files: Iterable[cc_File]) -> list[Package]: """ Convert the files of the common model to packages of their classes. :param files: The files of the common model. :returns: The packages, by name. """ packages: dict[str, Package] = {} for file in files: Class._FromFile(file, packages) for package in packages.values(): package._lineRate, package._branchRate = cls._Rates(cls._CountLines( line for klass in package._classes for line in klass._lines.values() )) return [packages[name] for name in sorted(packages)]
[docs] def Generate(self) -> _Element: """ Generate the package's ``<package>`` element with its classes. A rate the package doesn't state is computed from its classes' lines; a missing complexity is written as ``0``. :returns: The ``<package>`` element. """ packageElement = XMLElement("package") packageElement.attrib["name"] = self._name self._GenerateRates(packageElement, [line for klass in self._classes for line in klass._lines.values()]) packageElement.attrib["complexity"] = str(0 if self._complexity is None else self._complexity) classesElement = SubElement(packageElement, "classes") for klass in self._classes: classesElement.append(klass.Generate()) return packageElement