1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
|
# ==================================================================================================================== #
# _____ ____ _ _ ____ _ #
# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
# |_| |___/ |_| #
# ==================================================================================================================== #
# 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
from pyEDAA.Reports.CodeCoverage import LineCoverageStatus, Module as cc_Module, Package as cc_Package
from pyEDAA.Reports.CodeCoverage import 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.
@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.
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
@export
class Document(cob_Document):
"""
A Cobertura XML code coverage report as coverage.py writes it: read strictly, and converted to the common model.
"""
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)
@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
)
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
|