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
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
|
# ==================================================================================================================== #
# _____ ____ _ _ ____ _ #
# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
# |_| |___/ |_| #
# ==================================================================================================================== #
# Authors: #
# Patrick Lehmann #
# #
# License: #
# ==================================================================================================================== #
# Copyright 2021-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 Cobertura XML code coverage format: a model of the format, read from a report and converted to the common model, or
converted from the common model and written to a report.
Cobertura's XML format is written by many tools - e.g. coverage.py (``coverage xml``) or gcovr (``--cobertura``) - and
read by many CI services. The format's model follows Cobertura's DTD ``coverage-04.dtd``: a :class:`Document` holds
:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Package` elements, a package
:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Class` elements, a class
:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Method` elements and
:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Line` elements, and a line
:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Condition` elements; each keeps the attributes the report states,
e.g. its rates. The elements below the root element ``<coverage>`` are in
:mod:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records`.
A report is validated against :file:`Any-Cobertura.xsd`, which accepts the attributes tools add.
:file:`Cobertura-04.xsd` is the DTD's strict translation to XML Schema.
A dialect - the format as one tool writes it - has a module of its own in this package: a :class:`Document` derived
from this one, validating against a strict XML schema of the dialect, and reading what the dialect adds.
.. seealso::
:mod:`~pyEDAA.Reports.CodeCoverage.Cobertura.CoveragePyCobertura`
|rarr| coverage.py's dialect (``coverage xml``).
:mod:`~pyEDAA.Reports.CodeCoverage.Cobertura.NVCCobertura`
|rarr| NVC's dialect (``nvc --cover-export --format=cobertura``).
: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 one of the ``<source>`` directories; several classes of one file
- e.g. Java's nested classes - are one file, their lines merged.
* A line's ``hits`` is its count; a branching line's ``condition-coverage`` - e.g. ``50% (1/2)`` - states its taken and
all branches, which become as many branches without count.
* The packages, classes and methods become units of the logical hierarchy; a package's name is split at ``.`` into
nested packages. A class or method spans its file from the first to the last line it lists.
* The format has no excluded lines.
:meth:`Document.FromCoverageSummary` converts the common model to the format's model, which :meth:`Document.Write`
writes following the DTD:
* Every source file, module and class of the logical hierarchy becomes a ``<class>`` of its file, named by its
qualified name below its packages; its packages - or the file's directories, if it has none - name its
``<package>``. A file's line is listed by the innermost of them spanning it, a line outside of every one by a
``<class>`` named after the file.
* Every function and method becomes a ``<method>`` of the ``<class>`` of the unit containing it, listing its lines.
* A line's count is its ``hits`` - ``1`` or ``0``, if the report didn't say -; a branching line states its taken and
all branches in ``condition-coverage``. Excluded lines are left out.
.. rubric:: Example
.. code-block:: Python
from pathlib import Path
from pyEDAA.Reports.CodeCoverage.Cobertura import Document
report = Document(Path("coverage.xml"), analyzeAndConvert=True)
summary = report.ToCoverageSummary()
for file in summary.IterateFiles():
print(f"{file.Path}: {file.LineCoverage:.1%}")
Document.FromCoverageSummary(Path("Cobertura.xml"), summary).Write(regenerate=True)
"""
from __future__ import annotations
from io import BytesIO
from pathlib import Path
from re import compile as re_compile
from time import time_ns
from typing import Optional as Nullable
from lxml.etree import Element as XMLElement, ElementTree, SubElement, XMLParser
from lxml.etree import XMLSchema, XMLSchemaParseError, XMLSyntaxError, parse
from lxml.etree import tostring, _Element, _ElementTree
from pyTooling.Common import getFullyQualifiedName, getResourceFile
from pyTooling.Decorators import export, readonly
from pyTooling.Exceptions import ToolingException
from pyTooling.MetaClasses import ExtendedType
from pyTooling.Stopwatch import Stopwatch
from pyEDAA.Reports import Resources, __version__
from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, Class as cc_Class, CodeCoverageError
from pyEDAA.Reports.CodeCoverage import CoverageSummary, Document as cc_Document, File as cc_File
from pyEDAA.Reports.CodeCoverage import Line as cc_Line, LineCoverageStatus, Method as cc_Method
from pyEDAA.Reports.CodeCoverage import Package as cc_Package, Unit as cc_Unit
from pyEDAA.Reports.CodeCoverage.Cobertura.Records import Class, Condition, Element, Line, Method, Package
__all__ = ["READ_SCHEMA", "STRICT_SCHEMA", "CONDITION_COVERAGE"]
READ_SCHEMA = "Any-Cobertura.xsd" #: The XML schema a report is validated against when read: lenient.
STRICT_SCHEMA = "Cobertura-04.xsd" #: The XML schema translating Cobertura's DTD ``coverage-04.dtd``: strict.
#: Pattern of attribute ``condition-coverage``: the taken and all branches, e.g. ``(1/2)`` of ``50% (1/2)``.
CONDITION_COVERAGE = re_compile(r"\((\d+)/(\d+)\)$")
def _float(element: _Element, name: str) -> Nullable[float]:
"""
Read an attribute holding a decimal number.
:param element: The element.
:param name: The attribute's name.
:returns: The number, or ``None`` if the element has no such attribute.
"""
return None if (value := element.attrib.get(name)) is None else float(value)
def _int(element: _Element, name: str) -> Nullable[int]:
"""
Read an attribute holding an integer.
:param element: The element.
:param name: The attribute's name.
:returns: The number, or ``None`` if the element has no such attribute.
"""
return None if (value := element.attrib.get(name)) is None else int(value)
@export
class Coverage(metaclass=ExtendedType, mixin=True):
"""
The root element ``<coverage>``: the report's source directories, packages and figures, as the report states them.
"""
_version: Nullable[str] #: Version of the tool, which wrote the report.
_timestamp: Nullable[str] #: Time the report was written, as the report states it.
_lineRate: Nullable[float] #: Line coverage, as the report states it.
_branchRate: Nullable[float] #: Branch coverage, as the report states it.
_linesCovered: Nullable[int] #: Number of covered lines, as the report states it.
_linesValid: Nullable[int] #: Number of executable lines, as the report states it.
_branchesCovered: Nullable[int] #: Number of taken branches, as the report states it.
_branchesValid: Nullable[int] #: Number of branches, as the report states it.
_complexity: Nullable[float] #: Complexity, as the report states it.
_sources: list[str] #: The ``<source>`` directories.
_packages: list[Package] #: The packages, in the report's order.
def __init__(self) -> None:
"""
Initialize an empty report.
"""
self._version = None
self._timestamp = None
self._lineRate = None
self._branchRate = None
self._linesCovered = None
self._linesValid = None
self._branchesCovered = None
self._branchesValid = None
self._complexity = None
self._sources = []
self._packages = []
@readonly
def Version(self) -> Nullable[str]:
"""
Read-only property to access the version of the tool, which wrote the report (:attr:`_version`).
The version is stated as the report states it, e.g. ``gcovr 8.4``.
:returns: The version, or ``None`` if the report doesn't state it.
"""
return self._version
@readonly
def Timestamp(self) -> Nullable[str]:
"""
Read-only property to access the time the report was written, as the report states it (:attr:`_timestamp`).
The unit depends on the tool, e.g. milliseconds or seconds since the epoch.
:returns: The timestamp, or ``None``.
"""
return self._timestamp
@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
@readonly
def LinesCovered(self) -> Nullable[int]:
"""
Read-only property to access the number of covered lines, as the report states it (:attr:`_linesCovered`).
:returns: The number, or ``None`` if the report doesn't state it.
"""
return self._linesCovered
@readonly
def LinesValid(self) -> Nullable[int]:
"""
Read-only property to access the number of executable lines, as the report states it (:attr:`_linesValid`).
:returns: The number, or ``None`` if the report doesn't state it.
"""
return self._linesValid
@readonly
def BranchesCovered(self) -> Nullable[int]:
"""
Read-only property to access the number of taken branches, as the report states it (:attr:`_branchesCovered`).
:returns: The number, or ``None`` if the report doesn't state it.
"""
return self._branchesCovered
@readonly
def BranchesValid(self) -> Nullable[int]:
"""
Read-only property to access the number of branches, as the report states it (:attr:`_branchesValid`).
:returns: The number, or ``None`` if the report doesn't state it.
"""
return self._branchesValid
@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 Sources(self) -> list[str]:
"""
Read-only property to access the ``<source>`` directories (:attr:`_sources`).
:returns: The directories, as the report states them.
"""
return self._sources
@readonly
def Packages(self) -> list[Package]:
"""
Read-only property to access the packages (:attr:`_packages`).
:returns: The packages, in the report's order.
"""
return self._packages
@export
class Document(cc_Document, Coverage):
"""
A Cobertura XML code coverage report: read into the format's model and converted to the common model, or converted
from the common model and written.
"""
_xmlDocument: Nullable[_ElementTree] #: The XML document, parsed by :meth:`Analyze` or built by :meth:`Generate`.
def __init__(self, xmlReportFile: Path, analyzeAndConvert: bool = False) -> None:
"""
Initialize the report, and optionally read it.
:param xmlReportFile: Path to the Cobertura XML file.
:param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
"""
super().__init__(xmlReportFile)
Coverage.__init__(self)
self._xmlDocument = None
if analyzeAndConvert:
self.Analyze()
self.Convert()
@classmethod
def FromCoverageSummary(cls, xmlReportFile: Path, coverageSummary: CoverageSummary) -> Document:
"""
Create a report from the common model, ready to be generated and written.
Every source file, module and class of the logical hierarchy becomes a
:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Class` of its file, named by its qualified name below its
packages; its packages - or the file's directories, if it has none - name its
:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Package`. A file's line is listed by the innermost of these
classes spanning it; a line outside of every one by a class named after the file. Every function and method becomes
a :class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Method` of the class of the unit containing it. Excluded
lines are left out. The rates and figures are computed from the lines; the version names pyEDAA.Reports, the
timestamp is the current time in milliseconds since the epoch.
:param xmlReportFile: Path to the Cobertura XML file to write.
:param coverageSummary: The report's root of the common model.
:returns: The report.
:raises ValueError: If parameter ``xmlReportFile`` is ``None``.
:raises TypeError: If parameter ``xmlReportFile`` isn't of type :class:`~pathlib.Path`.
:raises ValueError: If parameter ``coverageSummary`` is ``None``.
:raises TypeError: If parameter ``coverageSummary`` isn't of type
:class:`~pyEDAA.Reports.CodeCoverage.CoverageSummary`.
"""
if xmlReportFile is None:
raise ValueError("Parameter 'xmlReportFile' is None.")
elif not isinstance(xmlReportFile, Path):
ex = TypeError("Parameter 'xmlReportFile' is not of type 'Path'.")
ex.add_note(f"Got type '{getFullyQualifiedName(xmlReportFile)}'.")
raise ex
if coverageSummary is None:
raise ValueError("Parameter 'coverageSummary' is None.")
elif not isinstance(coverageSummary, CoverageSummary):
ex = TypeError("Parameter 'coverageSummary' is not of type 'CoverageSummary'.")
ex.add_note(f"Got type '{getFullyQualifiedName(coverageSummary)}'.")
raise ex
document = cls(xmlReportFile)
document._version = f"pyEDAA.Reports {__version__}"
document._timestamp = str(time_ns() // 1_000_000)
document._complexity = 0.0
document._sources = [directory.as_posix() for directory in coverageSummary._sourceDirectories]
document._packages = Package._FromFiles(coverageSummary.IterateFiles())
counts = Element._CountLines(
line for package in document._packages for klass in package._classes for line in klass._lines.values()
)
document._linesValid, document._linesCovered, document._branchesValid, document._branchesCovered = counts
document._lineRate, document._branchRate = Element._Rates(counts)
return document
def Analyze(self) -> None:
"""
Parse the XML file and validate it against the lenient 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)
def _Analyze(self, xmlSchemaFile: str) -> None:
"""
Parse the XML file and validate it against an XML schema of the package resources.
A dialect's :meth:`Analyze` calls it with the dialect's XML schema.
:param xmlSchemaFile: File name of the XML schema in :mod:`pyEDAA.Reports.Resources`.
: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.
"""
if not self._path.exists():
raise CodeCoverageError(f"Cobertura report file '{self._path}' does not exist.") \
from FileNotFoundError(f"File '{self._path}' not found.")
with Stopwatch() as sw:
try:
content = self._path.read_bytes()
except OSError as ex:
raise CodeCoverageError(f"Couldn't read Cobertura report file '{self._path}'.") from ex
try:
xmlDocument = parse(BytesIO(content), XMLParser(ns_clean=True), base_url=str(self._path))
except XMLSyntaxError as ex:
raise CodeCoverageError(f"XML syntax error in Cobertura report file '{self._path}'.") from ex
rootElement: _Element = xmlDocument.getroot()
if rootElement.tag != "coverage":
ex = CodeCoverageError(f"Root element of '{self._path}' is not '<coverage>'.")
ex.add_note(f"Got root element '<{rootElement.tag}>'.")
raise ex
try:
schemaResourceFile = getResourceFile(Resources, xmlSchemaFile)
except ToolingException as ex:
raise CodeCoverageError(f"Couldn't locate XML Schema '{xmlSchemaFile}' in package resources.") from ex
try:
xmlSchema = XMLSchema(parse(schemaResourceFile, XMLParser(ns_clean=True)))
except (XMLSyntaxError, XMLSchemaParseError) as ex:
raise CodeCoverageError(f"Error while parsing XML Schema '{xmlSchemaFile}'.") from ex
if not xmlSchema.validate(xmlDocument):
ex = CodeCoverageError(f"Validation error for '{self._path}' using XSD schema '{xmlSchemaFile}'.")
for logEntry in xmlSchema.error_log:
ex.add_note(str(logEntry))
raise ex
self._xmlDocument = xmlDocument
self._analysisDuration = sw.Duration
def Convert(self) -> None:
"""
Convert the parsed and validated XML document to the format's model.
:raises CodeCoverageError: If the XML file was not analyzed before. |br|
Call 'Document.Analyze()' or create the document using
'Document(path, analyzeAndConvert=True)'.
"""
if self._xmlDocument is None:
ex = CodeCoverageError(f"Cobertura report file '{self._path}' needs to be read and analyzed by an XML parser.")
ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
raise ex
with Stopwatch() as sw:
root: _Element = self._xmlDocument.getroot()
self._version = root.attrib.get("version")
self._timestamp = root.attrib.get("timestamp")
self._lineRate = _float(root, "line-rate")
self._branchRate = _float(root, "branch-rate")
self._linesCovered = _int(root, "lines-covered")
self._linesValid = _int(root, "lines-valid")
self._branchesCovered = _int(root, "branches-covered")
self._branchesValid = _int(root, "branches-valid")
self._complexity = _float(root, "complexity")
self._sources = [
element.text.strip() for element in root.iterfind("sources/source")
if element.text is not None and element.text.strip() != ""
]
for packageElement in root.iterfind("packages/package"): # type: _Element
package = Package(
packageElement.attrib["name"], _float(packageElement, "line-rate"), _float(packageElement, "branch-rate"),
_float(packageElement, "complexity")
)
self._packages.append(package)
for classElement in packageElement.iterfind("classes/class"): # type: _Element
package._classes.append(self._ConvertClass(classElement))
self._conversionDuration = sw.Duration
@classmethod
def _ConvertClass(cls, classElement: _Element) -> Class:
"""
Convert a ``<class>`` element, its methods and lines.
:param classElement: The ``<class>`` element.
:returns: The class.
"""
klass = Class(
classElement.attrib.get("name", classElement.attrib["filename"]), classElement.attrib["filename"],
_float(classElement, "line-rate"), _float(classElement, "branch-rate"), _float(classElement, "complexity")
)
for methodElement in classElement.iterfind("methods/method"): # type: _Element
method = Method(
methodElement.attrib["name"], methodElement.attrib.get("signature"), _float(methodElement, "line-rate"),
_float(methodElement, "branch-rate")
)
key = method._name if method._name not in klass._methods else f"{method._name}{method._signature}"
klass._methods[key] = method
for lineElement in methodElement.iterfind("lines/line"): # type: _Element
line = cls._ConvertLine(lineElement)
method._lines[line._number] = line
for lineElement in classElement.iterfind("lines/line"): # type: _Element
line = cls._ConvertLine(lineElement)
klass._lines[line._number] = line
return klass
@staticmethod
def _ConvertLine(lineElement: _Element) -> Line:
"""
Convert a ``<line>`` element and its conditions.
: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]))
conditions = [
Condition(int(element.attrib["number"]), element.attrib.get("type", ""), element.attrib.get("coverage", ""))
for element in lineElement.iterfind("conditions/condition")
]
return Line(
int(lineElement.attrib["number"]), int(lineElement.attrib["hits"]),
lineElement.attrib.get("branch", "false") in ("true", "1"), conditionCoverage, conditions
)
def ToCoverageSummary(self) -> CoverageSummary:
"""
Convert the format's model to the common model, and aggregate it.
Several classes of one file - e.g. Java's nested classes - are one file: a line listed by several of them ran, if
one of them says so, its hits are added, and of its branches the higher counts are kept. Every package, class and
method becomes a unit; a class or method spans its file from its first to its last line.
: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.
"""
summary = CoverageSummary(self._path.stem, sourceDirectories=[Path(source) for source in self._sources])
merged: dict[int, dict[int, tuple[int, int, int]]] = {}
files: dict[int, cc_File] = {}
for package in self._packages:
for klass in package._classes:
file = summary.GetOrAddFile(klass._filename)
files[id(file)] = file
lines = merged.setdefault(id(file), {})
for line in klass._lines.values():
covered, total = (0, 0) if line._conditionCoverage is None else line._conditionCoverage
hits, oldCovered, oldTotal = lines.get(line._number, (0, 0, 0))
lines[line._number] = (hits + line._hits, max(covered, oldCovered), max(total, oldTotal))
for fileID, lines in merged.items():
file = files[fileID]
for number in sorted(lines):
hits, covered, total = lines[number]
branches = [cc_Branch(LineCoverageStatus.Covered) for _ in range(covered)]
branches.extend(cc_Branch(LineCoverageStatus.Uncovered) for _ in range(total - covered))
if hits == 0:
status = LineCoverageStatus.Uncovered
elif covered < total:
status = LineCoverageStatus.PartiallyCovered
else:
status = LineCoverageStatus.Covered
cc_Line(number, status, hits, branches, parent=file)
for package in self._packages:
parent: cc_Unit | CoverageSummary = summary
for part in (part for part in package._name.split(".") if part != ""):
parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent)
for klass in package._classes:
file = summary.GetOrAddFile(klass._filename)
if (classUnit := parent._units.get(klass._name)) is None:
numbers = list(klass._lines)
classUnit = cc_Class(
klass._name,
file=file,
startLine=file._lines[min(numbers)] if len(numbers) > 0 else None,
endLine=file._lines[max(numbers)] if len(numbers) > 0 else None,
parent=parent
)
for key, method in klass._methods.items():
if key not in classUnit._units:
numbers = [
number for number in method._lines if number <= file._lastLineNumber and file._lines[number] is not None
]
cc_Method(
key,
file=file,
startLine=file._lines[min(numbers)] if len(numbers) > 0 else None,
endLine=file._lines[max(numbers)] if len(numbers) > 0 else None,
parent=classUnit
)
summary.Aggregate()
return summary
def Generate(self, overwrite: bool = False) -> None:
"""
Generate the XML document from the format's model, following Cobertura's DTD.
A rate or figure the model doesn't state - e.g. read from a report without it - is computed from the lines; a
missing complexity is written as ``0``, a missing version, timestamp or signature as empty text.
:param overwrite: Optional, if true, replace the XML document read or generated before. Default: ``False``.
:raises ValueError: If parameter ``overwrite`` is ``None``.
:raises TypeError: If parameter ``overwrite`` isn't of type :class:`bool`.
:raises CodeCoverageError: If parameter ``overwrite`` is false and the XML document was read or generated before.
"""
if overwrite is None:
raise ValueError("Parameter 'overwrite' is None.")
elif not isinstance(overwrite, bool):
ex = TypeError("Parameter 'overwrite' is not of type 'bool'.")
ex.add_note(f"Got type '{getFullyQualifiedName(overwrite)}'.")
raise ex
elif not overwrite and self._xmlDocument is not None:
ex = CodeCoverageError(f"XML document of Cobertura report file '{self._path}' is already populated.")
ex.add_note("Call 'Document.Generate(overwrite=True)' to replace it.")
raise ex
counts = Element._CountLines(
line for package in self._packages for klass in package._classes for line in klass._lines.values()
)
linesValid, linesCovered, branchesValid, branchesCovered = counts
lineRate, branchRate = Element._Rates(counts)
rootElement = XMLElement("coverage")
rootElement.attrib["line-rate"] = str(round(lineRate if self._lineRate is None else self._lineRate, 4))
rootElement.attrib["branch-rate"] = str(round(branchRate if self._branchRate is None else self._branchRate, 4))
for name, stated, computed in (
("lines-covered", self._linesCovered, linesCovered),
("lines-valid", self._linesValid, linesValid),
("branches-covered", self._branchesCovered, branchesCovered),
("branches-valid", self._branchesValid, branchesValid)
):
rootElement.attrib[name] = str(computed if stated is None else stated)
rootElement.attrib["complexity"] = str(0 if self._complexity is None else self._complexity)
rootElement.attrib["version"] = "" if self._version is None else self._version
rootElement.attrib["timestamp"] = "" if self._timestamp is None else self._timestamp
sourcesElement = SubElement(rootElement, "sources")
for source in self._sources:
SubElement(sourcesElement, "source").text = source
packagesElement = SubElement(rootElement, "packages")
for package in self._packages:
packagesElement.append(package.Generate())
document = ElementTree(rootElement)
document.docinfo.system_url = "http://cobertura.sourceforge.net/xml/coverage-04.dtd"
self._xmlDocument = document
def Write(self, path: Nullable[Path] = None, overwrite: bool = False, regenerate: bool = False) -> None:
"""
Write the XML document to a file.
:param path: Optional, path to the XML file, if not the report's path. Default: ``None``.
:param overwrite: Optional, if true, overwrite an existing file. Default: ``False``.
:param regenerate: Optional, if true, generate the XML document from the format's model first.
Default: ``False``.
:raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path`.
:raises ValueError: If parameter ``overwrite`` is ``None``.
:raises TypeError: If parameter ``overwrite`` isn't of type :class:`bool`.
:raises ValueError: If parameter ``regenerate`` is ``None``.
:raises TypeError: If parameter ``regenerate`` isn't of type :class:`bool`.
:raises CodeCoverageError: If the file exists and parameter ``overwrite`` is false.
:raises CodeCoverageError: If the XML document was neither read nor generated. |br|
Call 'Document.Generate()' or 'Document.Write(..., regenerate=True)'.
:raises CodeCoverageError: If the file can't be written.
"""
if path is None:
path = self._path
elif not isinstance(path, Path):
ex = TypeError("Parameter 'path' is not of type 'Path'.")
ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
raise ex
if overwrite is None:
raise ValueError("Parameter 'overwrite' is None.")
elif not isinstance(overwrite, bool):
ex = TypeError("Parameter 'overwrite' is not of type 'bool'.")
ex.add_note(f"Got type '{getFullyQualifiedName(overwrite)}'.")
raise ex
if regenerate is None:
raise ValueError("Parameter 'regenerate' is None.")
elif not isinstance(regenerate, bool):
ex = TypeError("Parameter 'regenerate' is not of type 'bool'.")
ex.add_note(f"Got type '{getFullyQualifiedName(regenerate)}'.")
raise ex
if not overwrite and path.exists():
raise CodeCoverageError(f"Cobertura report file '{path}' can not be overwritten.") \
from FileExistsError(f"File '{path}' already exists.")
if regenerate:
self.Generate(overwrite=True)
if self._xmlDocument is None:
ex = CodeCoverageError(f"XML document of Cobertura report file '{path}' needs to be generated first.")
ex.add_note("Call 'Document.Generate()' or 'Document.Write(..., regenerate=True)'.")
raise ex
content = tostring(self._xmlDocument, encoding="utf-8", xml_declaration=True, pretty_print=True)
try:
with path.open("wb") as file:
file.write(content)
except OSError as ex:
raise CodeCoverageError(f"Cobertura report file '{path}' can not be written.") from ex
|