# ==================================================================================================================== #
# _____ ____ _ _ ____ _ #
# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
# |_| |___/ |_| #
# ==================================================================================================================== #
# 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, \
XMLSchema, XMLSchemaParseError, XMLSyntaxError, parse, \
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, \
CoverageSummary, Document as cc_Document, File as cc_File, \
Line as cc_Line, LineCoverageStatus, Method as cc_Method, \
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)
[docs]
@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.
[docs]
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
[docs]
@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`.
[docs]
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()
[docs]
@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
[docs]
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)
[docs]
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
[docs]
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
[docs]
@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
[docs]
@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
)
[docs]
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
[docs]
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
[docs]
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