Coverage for pyEDAA/Reports/CodeCoverage/Cobertura/CoveragePyCobertura.py: 100%
61 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"""
32coverage.py's dialect of the Cobertura XML code coverage format: read strictly, and converted to the common model.
34coverage.py writes the dialect with ``coverage xml``. A report is validated against :file:`CoveragePy-Cobertura.xsd`,
35reverse-engineered from coverage.py 7.x, which accepts what coverage.py writes and nothing else. The format's model is
36the one of :mod:`pyEDAA.Reports.CodeCoverage.Cobertura`; a :class:`Line` also keeps the targets of its branches never
37taken, which coverage.py adds as ``missing-branches``.
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 ``<source>`` directory; coverage.py writes a class per file.
42* A line's ``hits`` - ``0`` or ``1`` - says whether it ran: a line, which ran, is covered - partially covered, if one of
43 its branches wasn't taken -, otherwise uncovered. The format has no counts.
44* A branching line's ``condition-coverage`` - e.g. ``50% (1/2)`` - states its taken and all branches. A branch never
45 taken names its target line - none for an exit of a function, which coverage.py states as ``exit`` -, a taken one
46 doesn't.
47* A file's directories become packages, the file a module spanning the whole file. The format names no classes or
48 functions of the language.
49* The format has no excluded lines.
51.. rubric:: Example
53.. code-block:: Python
55 from pathlib import Path
56 from pyEDAA.Reports.CodeCoverage.Cobertura.CoveragePyCobertura import Document
58 report = Document(Path("coverage.xml"), analyzeAndConvert=True)
59 summary = report.ToCoverageSummary()
60 for unit in summary.IterateUnits():
61 print(f"{unit.QualifiedName}: {unit.LineCoverage:.1%}")
62"""
63from __future__ import annotations
65from pathlib import Path
66from typing import Iterable, Optional as Nullable
68from lxml.etree import _Element
69from pyTooling.Decorators import export, readonly
71from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, CoverageSummary, Line as cc_Line
72from pyEDAA.Reports.CodeCoverage import LineCoverageStatus, Module as cc_Module, Package as cc_Package
73from pyEDAA.Reports.CodeCoverage import Unit as cc_Unit
74from pyEDAA.Reports.CodeCoverage.Cobertura import CONDITION_COVERAGE, Document as cob_Document, Line as cob_Line
77__all__ = ["READ_SCHEMA"]
79READ_SCHEMA = "CoveragePy-Cobertura.xsd" #: The XML schema a report is validated against when read: strict.
82@export
83class Line(cob_Line):
84 """
85 A ``<line>`` as coverage.py writes it: whether it ran, and the targets of its branches never taken.
86 """
88 _missingBranches: list[Nullable[int]] #: Target lines of the branches never taken; ``None`` for a function's exit.
90 def __init__(
91 self,
92 number: int,
93 hits: int,
94 branch: bool = False,
95 conditionCoverage: Nullable[tuple[int, int]] = None,
96 missingBranches: Nullable[Iterable[Nullable[int]]] = None
97 ) -> None:
98 """
99 Initialize a line.
101 :param number: Line number.
102 :param hits: Whether the line ran: ``1`` or ``0``.
103 :param branch: Optional, whether the line branches. Default: ``False``.
104 :param conditionCoverage: Optional, taken and all branches. Default: ``None``.
105 :param missingBranches: Optional, target lines of the branches never taken; ``None`` for an exit of a function.
106 Default: none.
107 """
108 super().__init__(number, hits, branch, conditionCoverage)
110 self._missingBranches = [] if missingBranches is None else list(missingBranches)
112 @readonly
113 def MissingBranches(self) -> list[Nullable[int]]:
114 """
115 Read-only property to access the target lines of the branches never taken (:attr:`_missingBranches`).
117 :returns: The target lines, ``None`` for an exit of a function; empty, if the line took all its branches.
118 """
119 return self._missingBranches
122@export
123class Document(cob_Document):
124 """
125 A Cobertura XML code coverage report as coverage.py writes it: read strictly, and converted to the common model.
126 """
128 def Analyze(self) -> None:
129 """
130 Parse the XML file and validate it against the strict XML schema :data:`READ_SCHEMA`.
132 :raises CodeCoverageError: If the file doesn't exist.
133 :raises CodeCoverageError: If the file can't be read.
134 :raises CodeCoverageError: If the file isn't well-formed XML.
135 :raises CodeCoverageError: If the root element isn't ``<coverage>``.
136 :raises CodeCoverageError: If the XML schema can't be located or parsed.
137 :raises CodeCoverageError: If the file isn't valid according to the XML schema.
138 """
139 self._Analyze(READ_SCHEMA)
141 @staticmethod
142 def _ConvertLine(lineElement: _Element) -> Line:
143 """
144 Convert a ``<line>`` element and the targets of its branches never taken.
146 :param lineElement: The ``<line>`` element.
147 :returns: The line.
148 """
149 conditionCoverage = None
150 if (match := CONDITION_COVERAGE.search(lineElement.attrib.get("condition-coverage", ""))) is not None:
151 conditionCoverage = (int(match[1]), int(match[2]))
153 missingBranches: list[Nullable[int]] = []
154 if (targets := lineElement.attrib.get("missing-branches")) is not None:
155 missingBranches.extend(None if target == "exit" else int(target) for target in targets.split(","))
157 return Line(
158 int(lineElement.attrib["number"]), int(lineElement.attrib["hits"]), "branch" in lineElement.attrib,
159 conditionCoverage, missingBranches
160 )
162 def ToCoverageSummary(self) -> CoverageSummary:
163 """
164 Convert the format's model to the common model, and aggregate it.
166 A line, which ran, is covered - partially covered, if one of its branches wasn't taken -, otherwise uncovered; the
167 format has no counts. A branch never taken names its target line, a taken one doesn't. A file's directories
168 become packages, the file a module spanning the whole file.
170 :returns: The report's root of the common model, named after the report file.
171 :raises CodeCoverageError: If a file's path runs through another file.
172 :raises CodeCoverageError: If two classes name the same file.
173 """
174 summary = CoverageSummary(self._path.stem, sourceDirectories=[Path(source) for source in self._sources])
176 for package in self._packages:
177 for klass in package._classes:
178 file = summary.GetOrAddFile(klass._filename)
179 for line in klass._lines.values():
180 covered, total = (0, 0) if line._conditionCoverage is None else line._conditionCoverage
181 if line._hits == 0:
182 status = LineCoverageStatus.Uncovered
183 elif covered < total:
184 status = LineCoverageStatus.PartiallyCovered
185 else:
186 status = LineCoverageStatus.Covered
188 cc_Line(line._number, status, parent=file)
190 lines = file._lines
191 lastLineNumber = file._lastLineNumber
192 for line in klass._lines.values():
193 source = lines[line._number]
194 for _ in range(0 if line._conditionCoverage is None else line._conditionCoverage[0]):
195 cc_Branch(LineCoverageStatus.Covered, parent=source)
197 for target in line._missingBranches:
198 cc_Branch(
199 LineCoverageStatus.Uncovered,
200 target=lines[target] if target is not None and target <= lastLineNumber else None,
201 parent=source
202 )
204 path = Path(klass._filename)
205 parent: cc_Unit | CoverageSummary = summary
206 for part in path.parent.parts:
207 parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent)
209 cc_Module(
210 path.stem,
211 file=file,
212 startLine=next(file.IterateLines(), None),
213 endLine=lines[lastLineNumber] if lastLineNumber > 0 else None,
214 parent=parent
215 )
217 summary.Aggregate()
218 return summary