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

# ==================================================================================================================== #
#              _____ ____    _        _      ____                       _                                              #
#  _ __  _   _| ____|  _ \  / \      / \    |  _ \ ___ _ __   ___  _ __| |_ ___                                        #
# | '_ \| | | |  _| | | | |/ _ \    / _ \   | |_) / _ \ '_ \ / _ \| '__| __/ __|                                       #
# | |_) | |_| | |___| |_| / ___ \  / ___ \ _|  _ <  __/ |_) | (_) | |  | |_\__ \                                       #
# | .__/ \__, |_____|____/_/   \_\/_/   \_(_)_| \_\___| .__/ \___/|_|   \__|___/                                       #
# |_|    |___/                                        |_|                                                              #
# ==================================================================================================================== #
# 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                                                                                  #
# ==================================================================================================================== #
#
"""
coverage.py's dialect of the Cobertura XML code coverage format: read strictly, and converted to the common model.

coverage.py writes the dialect with ``coverage xml``. A report is validated against :file:`CoveragePy-Cobertura.xsd`,
reverse-engineered from coverage.py 7.x, which accepts what coverage.py writes and nothing else. The format's model is
the one of :mod:`pyEDAA.Reports.CodeCoverage.Cobertura`; a :class:`Line` also keeps the targets of its branches never
taken, which coverage.py adds as ``missing-branches``.

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

* A class' ``filename`` is a file's path, relative to the ``<source>`` directory; coverage.py writes a class per file.
* A line's ``hits`` - ``0`` or ``1`` - says whether it ran: a line, which ran, is covered - partially covered, if one of
  its branches wasn't taken -, otherwise uncovered. The format has no counts.
* A branching line's ``condition-coverage`` - e.g. ``50% (1/2)`` - states its taken and all branches. A branch never
  taken names its target line - none for an exit of a function, which coverage.py states as ``exit`` -, a taken one
  doesn't.
* A file's directories become packages, the file a module spanning the whole file. The format names no classes or
  functions of the language.
* The format has no excluded lines.

.. rubric:: Example

.. code-block:: Python

   from pathlib import Path
   from pyEDAA.Reports.CodeCoverage.Cobertura.CoveragePyCobertura import Document

   report = Document(Path("coverage.xml"), analyzeAndConvert=True)
   summary = report.ToCoverageSummary()
   for unit in summary.IterateUnits():
     print(f"{unit.QualifiedName}: {unit.LineCoverage:.1%}")
"""
from __future__                            import annotations

from pathlib                               import Path
from typing                                import Iterable, Optional as Nullable

from lxml.etree                            import _Element
from pyTooling.Decorators                  import export, readonly

from pyEDAA.Reports.CodeCoverage           import Branch as cc_Branch, CoverageSummary, Line as cc_Line, \
                                                  LineCoverageStatus, Module as cc_Module, Package as cc_Package, \
                                                  Unit as cc_Unit
from pyEDAA.Reports.CodeCoverage.Cobertura import CONDITION_COVERAGE, Document as cob_Document, Line as cob_Line


__all__ = ["READ_SCHEMA"]

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


[docs] @export class Line(cob_Line): """ A ``<line>`` as coverage.py writes it: whether it ran, and the targets of its branches never taken. """ _missingBranches: list[Nullable[int]] #: Target lines of the branches never taken; ``None`` for a function's exit.
[docs] def __init__( self, number: int, hits: int, branch: bool = False, conditionCoverage: Nullable[tuple[int, int]] = None, missingBranches: Nullable[Iterable[Nullable[int]]] = None ) -> None: """ Initialize a line. :param number: Line number. :param hits: Whether the line ran: ``1`` or ``0``. :param branch: Optional, whether the line branches. Default: ``False``. :param conditionCoverage: Optional, taken and all branches. Default: ``None``. :param missingBranches: Optional, target lines of the branches never taken; ``None`` for an exit of a function. Default: none. """ super().__init__(number, hits, branch, conditionCoverage) self._missingBranches = [] if missingBranches is None else list(missingBranches)
@readonly def MissingBranches(self) -> list[Nullable[int]]: """ Read-only property to access the target lines of the branches never taken (:attr:`_missingBranches`). :returns: The target lines, ``None`` for an exit of a function; empty, if the line took all its branches. """ return self._missingBranches
[docs] @export class Document(cob_Document): """ A Cobertura XML code coverage report as coverage.py writes it: read strictly, and converted to the common model. """
[docs] def Analyze(self) -> None: """ Parse the XML file and validate it against the strict XML schema :data:`READ_SCHEMA`. :raises CodeCoverageError: If the file doesn't exist. :raises CodeCoverageError: If the file can't be read. :raises CodeCoverageError: If the file isn't well-formed XML. :raises CodeCoverageError: If the root element isn't ``<coverage>``. :raises CodeCoverageError: If the XML schema can't be located or parsed. :raises CodeCoverageError: If the file isn't valid according to the XML schema. """ self._Analyze(READ_SCHEMA)
[docs] @staticmethod def _ConvertLine(lineElement: _Element) -> Line: """ Convert a ``<line>`` element and the targets of its branches never taken. :param lineElement: The ``<line>`` element. :returns: The line. """ conditionCoverage = None if (match := CONDITION_COVERAGE.search(lineElement.attrib.get("condition-coverage", ""))) is not None: conditionCoverage = (int(match[1]), int(match[2])) missingBranches: list[Nullable[int]] = [] if (targets := lineElement.attrib.get("missing-branches")) is not None: missingBranches.extend(None if target == "exit" else int(target) for target in targets.split(",")) return Line( int(lineElement.attrib["number"]), int(lineElement.attrib["hits"]), "branch" in lineElement.attrib, conditionCoverage, missingBranches )
[docs] def ToCoverageSummary(self) -> CoverageSummary: """ Convert the format's model to the common model, and aggregate it. A line, which ran, is covered - partially covered, if one of its branches wasn't taken -, otherwise uncovered; the format has no counts. A branch never taken names its target line, a taken one doesn't. A file's directories become packages, the file a module spanning the whole file. :returns: The report's root of the common model, named after the report file. :raises CodeCoverageError: If a file's path runs through another file. :raises CodeCoverageError: If two classes name the same file. """ summary = CoverageSummary(self._path.stem, sourceDirectories=[Path(source) for source in self._sources]) for package in self._packages: for klass in package._classes: file = summary.GetOrAddFile(klass._filename) for line in klass._lines.values(): covered, total = (0, 0) if line._conditionCoverage is None else line._conditionCoverage if line._hits == 0: status = LineCoverageStatus.Uncovered elif covered < total: status = LineCoverageStatus.PartiallyCovered else: status = LineCoverageStatus.Covered cc_Line(line._number, status, parent=file) lines = file._lines lastLineNumber = file._lastLineNumber for line in klass._lines.values(): source = lines[line._number] for _ in range(0 if line._conditionCoverage is None else line._conditionCoverage[0]): cc_Branch(LineCoverageStatus.Covered, parent=source) for target in line._missingBranches: cc_Branch( LineCoverageStatus.Uncovered, target=lines[target] if target is not None and target <= lastLineNumber else None, parent=source ) path = Path(klass._filename) parent: cc_Unit | CoverageSummary = summary for part in path.parent.parts: parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent) cc_Module( path.stem, file=file, startLine=next(file.IterateLines(), None), endLine=lines[lastLineNumber] if lastLineNumber > 0 else None, parent=parent ) summary.Aggregate() return summary