Coverage for pyEDAA/Reports/CodeCoverage/Cobertura/__init__.py: 98%
302 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 2021-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"""
32The Cobertura XML code coverage format: a model of the format, read from a report and converted to the common model, or
33converted from the common model and written to a report.
35Cobertura's XML format is written by many tools - e.g. coverage.py (``coverage xml``) or gcovr (``--cobertura``) - and
36read by many CI services. The format's model follows Cobertura's DTD ``coverage-04.dtd``: a :class:`Document` holds
37:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Package` elements, a package
38:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Class` elements, a class
39:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Method` elements and
40:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Line` elements, and a line
41:class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Condition` elements; each keeps the attributes the report states,
42e.g. its rates. The elements below the root element ``<coverage>`` are in
43:mod:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records`.
45A report is validated against :file:`Any-Cobertura.xsd`, which accepts the attributes tools add.
46:file:`Cobertura-04.xsd` is the DTD's strict translation to XML Schema.
48A dialect - the format as one tool writes it - has a module of its own in this package: a :class:`Document` derived
49from this one, validating against a strict XML schema of the dialect, and reading what the dialect adds.
51.. seealso::
53 :mod:`~pyEDAA.Reports.CodeCoverage.Cobertura.CoveragePyCobertura`
54 |rarr| coverage.py's dialect (``coverage xml``).
56 :mod:`~pyEDAA.Reports.CodeCoverage.Cobertura.NVCCobertura`
57 |rarr| NVC's dialect (``nvc --cover-export --format=cobertura``).
59:meth:`Document.ToCoverageSummary` converts the model to the common model of :mod:`pyEDAA.Reports.CodeCoverage`:
61* A class' ``filename`` is a file's path, relative to one of the ``<source>`` directories; several classes of one file
62 - e.g. Java's nested classes - are one file, their lines merged.
63* A line's ``hits`` is its count; a branching line's ``condition-coverage`` - e.g. ``50% (1/2)`` - states its taken and
64 all branches, which become as many branches without count.
65* The packages, classes and methods become units of the logical hierarchy; a package's name is split at ``.`` into
66 nested packages. A class or method spans its file from the first to the last line it lists.
67* The format has no excluded lines.
69:meth:`Document.FromCoverageSummary` converts the common model to the format's model, which :meth:`Document.Write`
70writes following the DTD:
72* Every source file, module and class of the logical hierarchy becomes a ``<class>`` of its file, named by its
73 qualified name below its packages; its packages - or the file's directories, if it has none - name its
74 ``<package>``. A file's line is listed by the innermost of them spanning it, a line outside of every one by a
75 ``<class>`` named after the file.
76* Every function and method becomes a ``<method>`` of the ``<class>`` of the unit containing it, listing its lines.
77* A line's count is its ``hits`` - ``1`` or ``0``, if the report didn't say -; a branching line states its taken and
78 all branches in ``condition-coverage``. Excluded lines are left out.
80.. rubric:: Example
82.. code-block:: Python
84 from pathlib import Path
85 from pyEDAA.Reports.CodeCoverage.Cobertura import Document
87 report = Document(Path("coverage.xml"), analyzeAndConvert=True)
88 summary = report.ToCoverageSummary()
89 for file in summary.IterateFiles():
90 print(f"{file.Path}: {file.LineCoverage:.1%}")
92 Document.FromCoverageSummary(Path("Cobertura.xml"), summary).Write(regenerate=True)
93"""
94from __future__ import annotations
96from io import BytesIO
97from pathlib import Path
98from re import compile as re_compile
99from time import time_ns
100from typing import Optional as Nullable
102from lxml.etree import Element as XMLElement, ElementTree, SubElement, XMLParser
103from lxml.etree import XMLSchema, XMLSchemaParseError, XMLSyntaxError, parse
104from lxml.etree import tostring, _Element, _ElementTree
105from pyTooling.Common import getFullyQualifiedName, getResourceFile
106from pyTooling.Decorators import export, readonly
107from pyTooling.Exceptions import ToolingException
108from pyTooling.MetaClasses import ExtendedType
109from pyTooling.Stopwatch import Stopwatch
111from pyEDAA.Reports import Resources, __version__
112from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, Class as cc_Class, CodeCoverageError
113from pyEDAA.Reports.CodeCoverage import CoverageSummary, Document as cc_Document, File as cc_File
114from pyEDAA.Reports.CodeCoverage import Line as cc_Line, LineCoverageStatus, Method as cc_Method
115from pyEDAA.Reports.CodeCoverage import Package as cc_Package, Unit as cc_Unit
116from pyEDAA.Reports.CodeCoverage.Cobertura.Records import Class, Condition, Element, Line, Method, Package
119__all__ = ["READ_SCHEMA", "STRICT_SCHEMA", "CONDITION_COVERAGE"]
121READ_SCHEMA = "Any-Cobertura.xsd" #: The XML schema a report is validated against when read: lenient.
122STRICT_SCHEMA = "Cobertura-04.xsd" #: The XML schema translating Cobertura's DTD ``coverage-04.dtd``: strict.
124#: Pattern of attribute ``condition-coverage``: the taken and all branches, e.g. ``(1/2)`` of ``50% (1/2)``.
125CONDITION_COVERAGE = re_compile(r"\((\d+)/(\d+)\)$")
128def _float(element: _Element, name: str) -> Nullable[float]:
129 """
130 Read an attribute holding a decimal number.
132 :param element: The element.
133 :param name: The attribute's name.
134 :returns: The number, or ``None`` if the element has no such attribute.
135 """
136 return None if (value := element.attrib.get(name)) is None else float(value)
139def _int(element: _Element, name: str) -> Nullable[int]:
140 """
141 Read an attribute holding an integer.
143 :param element: The element.
144 :param name: The attribute's name.
145 :returns: The number, or ``None`` if the element has no such attribute.
146 """
147 return None if (value := element.attrib.get(name)) is None else int(value)
150@export
151class Coverage(metaclass=ExtendedType, mixin=True):
152 """
153 The root element ``<coverage>``: the report's source directories, packages and figures, as the report states them.
154 """
156 _version: Nullable[str] #: Version of the tool, which wrote the report.
157 _timestamp: Nullable[str] #: Time the report was written, as the report states it.
158 _lineRate: Nullable[float] #: Line coverage, as the report states it.
159 _branchRate: Nullable[float] #: Branch coverage, as the report states it.
160 _linesCovered: Nullable[int] #: Number of covered lines, as the report states it.
161 _linesValid: Nullable[int] #: Number of executable lines, as the report states it.
162 _branchesCovered: Nullable[int] #: Number of taken branches, as the report states it.
163 _branchesValid: Nullable[int] #: Number of branches, as the report states it.
164 _complexity: Nullable[float] #: Complexity, as the report states it.
165 _sources: list[str] #: The ``<source>`` directories.
166 _packages: list[Package] #: The packages, in the report's order.
168 def __init__(self) -> None:
169 """
170 Initialize an empty report.
171 """
172 self._version = None
173 self._timestamp = None
174 self._lineRate = None
175 self._branchRate = None
176 self._linesCovered = None
177 self._linesValid = None
178 self._branchesCovered = None
179 self._branchesValid = None
180 self._complexity = None
181 self._sources = []
182 self._packages = []
184 @readonly
185 def Version(self) -> Nullable[str]:
186 """
187 Read-only property to access the version of the tool, which wrote the report (:attr:`_version`).
189 The version is stated as the report states it, e.g. ``gcovr 8.4``.
191 :returns: The version, or ``None`` if the report doesn't state it.
192 """
193 return self._version
195 @readonly
196 def Timestamp(self) -> Nullable[str]:
197 """
198 Read-only property to access the time the report was written, as the report states it (:attr:`_timestamp`).
200 The unit depends on the tool, e.g. milliseconds or seconds since the epoch.
202 :returns: The timestamp, or ``None``.
203 """
204 return self._timestamp
206 @readonly
207 def LineRate(self) -> Nullable[float]:
208 """
209 Read-only property to access the line coverage, as the report states it (:attr:`_lineRate`).
211 :returns: The rate in range 0.0..1.0, or ``None`` if the report doesn't state it.
212 """
213 return self._lineRate
215 @readonly
216 def BranchRate(self) -> Nullable[float]:
217 """
218 Read-only property to access the branch coverage, as the report states it (:attr:`_branchRate`).
220 :returns: The rate in range 0.0..1.0, or ``None`` if the report doesn't state it.
221 """
222 return self._branchRate
224 @readonly
225 def LinesCovered(self) -> Nullable[int]:
226 """
227 Read-only property to access the number of covered lines, as the report states it (:attr:`_linesCovered`).
229 :returns: The number, or ``None`` if the report doesn't state it.
230 """
231 return self._linesCovered
233 @readonly
234 def LinesValid(self) -> Nullable[int]:
235 """
236 Read-only property to access the number of executable lines, as the report states it (:attr:`_linesValid`).
238 :returns: The number, or ``None`` if the report doesn't state it.
239 """
240 return self._linesValid
242 @readonly
243 def BranchesCovered(self) -> Nullable[int]:
244 """
245 Read-only property to access the number of taken branches, as the report states it (:attr:`_branchesCovered`).
247 :returns: The number, or ``None`` if the report doesn't state it.
248 """
249 return self._branchesCovered
251 @readonly
252 def BranchesValid(self) -> Nullable[int]:
253 """
254 Read-only property to access the number of branches, as the report states it (:attr:`_branchesValid`).
256 :returns: The number, or ``None`` if the report doesn't state it.
257 """
258 return self._branchesValid
260 @readonly
261 def Complexity(self) -> Nullable[float]:
262 """
263 Read-only property to access the complexity, as the report states it (:attr:`_complexity`).
265 :returns: The complexity, or ``None`` if the report doesn't state it.
266 """
267 return self._complexity
269 @readonly
270 def Sources(self) -> list[str]:
271 """
272 Read-only property to access the ``<source>`` directories (:attr:`_sources`).
274 :returns: The directories, as the report states them.
275 """
276 return self._sources
278 @readonly
279 def Packages(self) -> list[Package]:
280 """
281 Read-only property to access the packages (:attr:`_packages`).
283 :returns: The packages, in the report's order.
284 """
285 return self._packages
288@export
289class Document(cc_Document, Coverage):
290 """
291 A Cobertura XML code coverage report: read into the format's model and converted to the common model, or converted
292 from the common model and written.
293 """
295 _xmlDocument: Nullable[_ElementTree] #: The XML document, parsed by :meth:`Analyze` or built by :meth:`Generate`.
297 def __init__(self, xmlReportFile: Path, analyzeAndConvert: bool = False) -> None:
298 """
299 Initialize the report, and optionally read it.
301 :param xmlReportFile: Path to the Cobertura XML file.
302 :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``.
303 """
304 super().__init__(xmlReportFile)
305 Coverage.__init__(self)
307 self._xmlDocument = None
309 if analyzeAndConvert:
310 self.Analyze()
311 self.Convert()
313 @classmethod
314 def FromCoverageSummary(cls, xmlReportFile: Path, coverageSummary: CoverageSummary) -> Document:
315 """
316 Create a report from the common model, ready to be generated and written.
318 Every source file, module and class of the logical hierarchy becomes a
319 :class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Class` of its file, named by its qualified name below its
320 packages; its packages - or the file's directories, if it has none - name its
321 :class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Package`. A file's line is listed by the innermost of these
322 classes spanning it; a line outside of every one by a class named after the file. Every function and method becomes
323 a :class:`~pyEDAA.Reports.CodeCoverage.Cobertura.Records.Method` of the class of the unit containing it. Excluded
324 lines are left out. The rates and figures are computed from the lines; the version names pyEDAA.Reports, the
325 timestamp is the current time in milliseconds since the epoch.
327 :param xmlReportFile: Path to the Cobertura XML file to write.
328 :param coverageSummary: The report's root of the common model.
329 :returns: The report.
330 :raises ValueError: If parameter ``xmlReportFile`` is ``None``.
331 :raises TypeError: If parameter ``xmlReportFile`` isn't of type :class:`~pathlib.Path`.
332 :raises ValueError: If parameter ``coverageSummary`` is ``None``.
333 :raises TypeError: If parameter ``coverageSummary`` isn't of type
334 :class:`~pyEDAA.Reports.CodeCoverage.CoverageSummary`.
335 """
336 if xmlReportFile is None:
337 raise ValueError("Parameter 'xmlReportFile' is None.")
338 elif not isinstance(xmlReportFile, Path):
339 ex = TypeError("Parameter 'xmlReportFile' is not of type 'Path'.")
340 ex.add_note(f"Got type '{getFullyQualifiedName(xmlReportFile)}'.")
341 raise ex
343 if coverageSummary is None:
344 raise ValueError("Parameter 'coverageSummary' is None.")
345 elif not isinstance(coverageSummary, CoverageSummary):
346 ex = TypeError("Parameter 'coverageSummary' is not of type 'CoverageSummary'.")
347 ex.add_note(f"Got type '{getFullyQualifiedName(coverageSummary)}'.")
348 raise ex
350 document = cls(xmlReportFile)
351 document._version = f"pyEDAA.Reports {__version__}"
352 document._timestamp = str(time_ns() // 1_000_000)
353 document._complexity = 0.0
354 document._sources = [directory.as_posix() for directory in coverageSummary._sourceDirectories]
355 document._packages = Package._FromFiles(coverageSummary.IterateFiles())
357 counts = Element._CountLines(
358 line for package in document._packages for klass in package._classes for line in klass._lines.values()
359 )
360 document._linesValid, document._linesCovered, document._branchesValid, document._branchesCovered = counts
361 document._lineRate, document._branchRate = Element._Rates(counts)
363 return document
365 def Analyze(self) -> None:
366 """
367 Parse the XML file and validate it against the lenient XML schema :data:`READ_SCHEMA`.
369 :raises CodeCoverageError: If the file doesn't exist.
370 :raises CodeCoverageError: If the file can't be read.
371 :raises CodeCoverageError: If the file isn't well-formed XML.
372 :raises CodeCoverageError: If the root element isn't ``<coverage>``.
373 :raises CodeCoverageError: If the XML schema can't be located or parsed.
374 :raises CodeCoverageError: If the file isn't valid according to the XML schema.
375 """
376 self._Analyze(READ_SCHEMA)
378 def _Analyze(self, xmlSchemaFile: str) -> None:
379 """
380 Parse the XML file and validate it against an XML schema of the package resources.
382 A dialect's :meth:`Analyze` calls it with the dialect's XML schema.
384 :param xmlSchemaFile: File name of the XML schema in :mod:`pyEDAA.Reports.Resources`.
385 :raises CodeCoverageError: If the file doesn't exist.
386 :raises CodeCoverageError: If the file can't be read.
387 :raises CodeCoverageError: If the file isn't well-formed XML.
388 :raises CodeCoverageError: If the root element isn't ``<coverage>``.
389 :raises CodeCoverageError: If the XML schema can't be located or parsed.
390 :raises CodeCoverageError: If the file isn't valid according to the XML schema.
391 """
392 if not self._path.exists():
393 raise CodeCoverageError(f"Cobertura report file '{self._path}' does not exist.") \
394 from FileNotFoundError(f"File '{self._path}' not found.")
396 with Stopwatch() as sw:
397 try:
398 content = self._path.read_bytes()
399 except OSError as ex:
400 raise CodeCoverageError(f"Couldn't read Cobertura report file '{self._path}'.") from ex
402 try:
403 xmlDocument = parse(BytesIO(content), XMLParser(ns_clean=True), base_url=str(self._path))
404 except XMLSyntaxError as ex:
405 raise CodeCoverageError(f"XML syntax error in Cobertura report file '{self._path}'.") from ex
407 rootElement: _Element = xmlDocument.getroot()
408 if rootElement.tag != "coverage":
409 ex = CodeCoverageError(f"Root element of '{self._path}' is not '<coverage>'.")
410 ex.add_note(f"Got root element '<{rootElement.tag}>'.")
411 raise ex
413 try:
414 schemaResourceFile = getResourceFile(Resources, xmlSchemaFile)
415 except ToolingException as ex:
416 raise CodeCoverageError(f"Couldn't locate XML Schema '{xmlSchemaFile}' in package resources.") from ex
418 try:
419 xmlSchema = XMLSchema(parse(schemaResourceFile, XMLParser(ns_clean=True)))
420 except (XMLSyntaxError, XMLSchemaParseError) as ex:
421 raise CodeCoverageError(f"Error while parsing XML Schema '{xmlSchemaFile}'.") from ex
423 if not xmlSchema.validate(xmlDocument):
424 ex = CodeCoverageError(f"Validation error for '{self._path}' using XSD schema '{xmlSchemaFile}'.")
425 for logEntry in xmlSchema.error_log:
426 ex.add_note(str(logEntry))
427 raise ex
429 self._xmlDocument = xmlDocument
431 self._analysisDuration = sw.Duration
433 def Convert(self) -> None:
434 """
435 Convert the parsed and validated XML document to the format's model.
437 :raises CodeCoverageError: If the XML file was not analyzed before. |br|
438 Call 'Document.Analyze()' or create the document using
439 'Document(path, analyzeAndConvert=True)'.
440 """
441 if self._xmlDocument is None:
442 ex = CodeCoverageError(f"Cobertura report file '{self._path}' needs to be read and analyzed by an XML parser.")
443 ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.")
444 raise ex
446 with Stopwatch() as sw:
447 root: _Element = self._xmlDocument.getroot()
449 self._version = root.attrib.get("version")
450 self._timestamp = root.attrib.get("timestamp")
451 self._lineRate = _float(root, "line-rate")
452 self._branchRate = _float(root, "branch-rate")
453 self._linesCovered = _int(root, "lines-covered")
454 self._linesValid = _int(root, "lines-valid")
455 self._branchesCovered = _int(root, "branches-covered")
456 self._branchesValid = _int(root, "branches-valid")
457 self._complexity = _float(root, "complexity")
459 self._sources = [
460 element.text.strip() for element in root.iterfind("sources/source")
461 if element.text is not None and element.text.strip() != ""
462 ]
464 for packageElement in root.iterfind("packages/package"): # type: _Element
465 package = Package(
466 packageElement.attrib["name"], _float(packageElement, "line-rate"), _float(packageElement, "branch-rate"),
467 _float(packageElement, "complexity")
468 )
469 self._packages.append(package)
471 for classElement in packageElement.iterfind("classes/class"): # type: _Element
472 package._classes.append(self._ConvertClass(classElement))
474 self._conversionDuration = sw.Duration
476 @classmethod
477 def _ConvertClass(cls, classElement: _Element) -> Class:
478 """
479 Convert a ``<class>`` element, its methods and lines.
481 :param classElement: The ``<class>`` element.
482 :returns: The class.
483 """
484 klass = Class(
485 classElement.attrib.get("name", classElement.attrib["filename"]), classElement.attrib["filename"],
486 _float(classElement, "line-rate"), _float(classElement, "branch-rate"), _float(classElement, "complexity")
487 )
489 for methodElement in classElement.iterfind("methods/method"): # type: _Element
490 method = Method(
491 methodElement.attrib["name"], methodElement.attrib.get("signature"), _float(methodElement, "line-rate"),
492 _float(methodElement, "branch-rate")
493 )
494 key = method._name if method._name not in klass._methods else f"{method._name}{method._signature}"
495 klass._methods[key] = method
496 for lineElement in methodElement.iterfind("lines/line"): # type: _Element
497 line = cls._ConvertLine(lineElement)
498 method._lines[line._number] = line
500 for lineElement in classElement.iterfind("lines/line"): # type: _Element
501 line = cls._ConvertLine(lineElement)
502 klass._lines[line._number] = line
504 return klass
506 @staticmethod
507 def _ConvertLine(lineElement: _Element) -> Line:
508 """
509 Convert a ``<line>`` element and its conditions.
511 :param lineElement: The ``<line>`` element.
512 :returns: The line.
513 """
514 conditionCoverage = None
515 if (match := CONDITION_COVERAGE.search(lineElement.attrib.get("condition-coverage", ""))) is not None:
516 conditionCoverage = (int(match[1]), int(match[2]))
518 conditions = [
519 Condition(int(element.attrib["number"]), element.attrib.get("type", ""), element.attrib.get("coverage", ""))
520 for element in lineElement.iterfind("conditions/condition")
521 ]
523 return Line(
524 int(lineElement.attrib["number"]), int(lineElement.attrib["hits"]),
525 lineElement.attrib.get("branch", "false") in ("true", "1"), conditionCoverage, conditions
526 )
528 def ToCoverageSummary(self) -> CoverageSummary:
529 """
530 Convert the format's model to the common model, and aggregate it.
532 Several classes of one file - e.g. Java's nested classes - are one file: a line listed by several of them ran, if
533 one of them says so, its hits are added, and of its branches the higher counts are kept. Every package, class and
534 method becomes a unit; a class or method spans its file from its first to its last line.
536 :returns: The report's root of the common model, named after the report file.
537 :raises CodeCoverageError: If a file's path runs through another file.
538 """
539 summary = CoverageSummary(self._path.stem, sourceDirectories=[Path(source) for source in self._sources])
541 merged: dict[int, dict[int, tuple[int, int, int]]] = {}
542 files: dict[int, cc_File] = {}
543 for package in self._packages:
544 for klass in package._classes:
545 file = summary.GetOrAddFile(klass._filename)
546 files[id(file)] = file
547 lines = merged.setdefault(id(file), {})
548 for line in klass._lines.values():
549 covered, total = (0, 0) if line._conditionCoverage is None else line._conditionCoverage
550 hits, oldCovered, oldTotal = lines.get(line._number, (0, 0, 0))
551 lines[line._number] = (hits + line._hits, max(covered, oldCovered), max(total, oldTotal))
553 for fileID, lines in merged.items():
554 file = files[fileID]
555 for number in sorted(lines):
556 hits, covered, total = lines[number]
557 branches = [cc_Branch(LineCoverageStatus.Covered) for _ in range(covered)]
558 branches.extend(cc_Branch(LineCoverageStatus.Uncovered) for _ in range(total - covered))
559 if hits == 0:
560 status = LineCoverageStatus.Uncovered
561 elif covered < total:
562 status = LineCoverageStatus.PartiallyCovered
563 else:
564 status = LineCoverageStatus.Covered
565 cc_Line(number, status, hits, branches, parent=file)
567 for package in self._packages:
568 parent: cc_Unit | CoverageSummary = summary
569 for part in (part for part in package._name.split(".") if part != ""):
570 parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent)
572 for klass in package._classes:
573 file = summary.GetOrAddFile(klass._filename)
574 if (classUnit := parent._units.get(klass._name)) is None: 574 ↛ 584line 574 didn't jump to line 584 because the condition on line 574 was always true
575 numbers = list(klass._lines)
576 classUnit = cc_Class(
577 klass._name,
578 file=file,
579 startLine=file._lines[min(numbers)] if len(numbers) > 0 else None,
580 endLine=file._lines[max(numbers)] if len(numbers) > 0 else None,
581 parent=parent
582 )
584 for key, method in klass._methods.items():
585 if key not in classUnit._units: 585 ↛ 584line 585 didn't jump to line 584 because the condition on line 585 was always true
586 numbers = [
587 number for number in method._lines if number <= file._lastLineNumber and file._lines[number] is not None
588 ]
589 cc_Method(
590 key,
591 file=file,
592 startLine=file._lines[min(numbers)] if len(numbers) > 0 else None,
593 endLine=file._lines[max(numbers)] if len(numbers) > 0 else None,
594 parent=classUnit
595 )
597 summary.Aggregate()
598 return summary
600 def Generate(self, overwrite: bool = False) -> None:
601 """
602 Generate the XML document from the format's model, following Cobertura's DTD.
604 A rate or figure the model doesn't state - e.g. read from a report without it - is computed from the lines; a
605 missing complexity is written as ``0``, a missing version, timestamp or signature as empty text.
607 :param overwrite: Optional, if true, replace the XML document read or generated before. Default: ``False``.
608 :raises ValueError: If parameter ``overwrite`` is ``None``.
609 :raises TypeError: If parameter ``overwrite`` isn't of type :class:`bool`.
610 :raises CodeCoverageError: If parameter ``overwrite`` is false and the XML document was read or generated before.
611 """
612 if overwrite is None:
613 raise ValueError("Parameter 'overwrite' is None.")
614 elif not isinstance(overwrite, bool):
615 ex = TypeError("Parameter 'overwrite' is not of type 'bool'.")
616 ex.add_note(f"Got type '{getFullyQualifiedName(overwrite)}'.")
617 raise ex
618 elif not overwrite and self._xmlDocument is not None:
619 ex = CodeCoverageError(f"XML document of Cobertura report file '{self._path}' is already populated.")
620 ex.add_note("Call 'Document.Generate(overwrite=True)' to replace it.")
621 raise ex
623 counts = Element._CountLines(
624 line for package in self._packages for klass in package._classes for line in klass._lines.values()
625 )
626 linesValid, linesCovered, branchesValid, branchesCovered = counts
627 lineRate, branchRate = Element._Rates(counts)
629 rootElement = XMLElement("coverage")
630 rootElement.attrib["line-rate"] = str(round(lineRate if self._lineRate is None else self._lineRate, 4))
631 rootElement.attrib["branch-rate"] = str(round(branchRate if self._branchRate is None else self._branchRate, 4))
632 for name, stated, computed in (
633 ("lines-covered", self._linesCovered, linesCovered),
634 ("lines-valid", self._linesValid, linesValid),
635 ("branches-covered", self._branchesCovered, branchesCovered),
636 ("branches-valid", self._branchesValid, branchesValid)
637 ):
638 rootElement.attrib[name] = str(computed if stated is None else stated)
639 rootElement.attrib["complexity"] = str(0 if self._complexity is None else self._complexity)
640 rootElement.attrib["version"] = "" if self._version is None else self._version
641 rootElement.attrib["timestamp"] = "" if self._timestamp is None else self._timestamp
643 sourcesElement = SubElement(rootElement, "sources")
644 for source in self._sources:
645 SubElement(sourcesElement, "source").text = source
647 packagesElement = SubElement(rootElement, "packages")
648 for package in self._packages:
649 packagesElement.append(package.Generate())
651 document = ElementTree(rootElement)
652 document.docinfo.system_url = "http://cobertura.sourceforge.net/xml/coverage-04.dtd"
653 self._xmlDocument = document
655 def Write(self, path: Nullable[Path] = None, overwrite: bool = False, regenerate: bool = False) -> None:
656 """
657 Write the XML document to a file.
659 :param path: Optional, path to the XML file, if not the report's path. Default: ``None``.
660 :param overwrite: Optional, if true, overwrite an existing file. Default: ``False``.
661 :param regenerate: Optional, if true, generate the XML document from the format's model first.
662 Default: ``False``.
663 :raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path`.
664 :raises ValueError: If parameter ``overwrite`` is ``None``.
665 :raises TypeError: If parameter ``overwrite`` isn't of type :class:`bool`.
666 :raises ValueError: If parameter ``regenerate`` is ``None``.
667 :raises TypeError: If parameter ``regenerate`` isn't of type :class:`bool`.
668 :raises CodeCoverageError: If the file exists and parameter ``overwrite`` is false.
669 :raises CodeCoverageError: If the XML document was neither read nor generated. |br|
670 Call 'Document.Generate()' or 'Document.Write(..., regenerate=True)'.
671 :raises CodeCoverageError: If the file can't be written.
672 """
673 if path is None:
674 path = self._path
675 elif not isinstance(path, Path):
676 ex = TypeError("Parameter 'path' is not of type 'Path'.")
677 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
678 raise ex
680 if overwrite is None:
681 raise ValueError("Parameter 'overwrite' is None.")
682 elif not isinstance(overwrite, bool):
683 ex = TypeError("Parameter 'overwrite' is not of type 'bool'.")
684 ex.add_note(f"Got type '{getFullyQualifiedName(overwrite)}'.")
685 raise ex
687 if regenerate is None:
688 raise ValueError("Parameter 'regenerate' is None.")
689 elif not isinstance(regenerate, bool):
690 ex = TypeError("Parameter 'regenerate' is not of type 'bool'.")
691 ex.add_note(f"Got type '{getFullyQualifiedName(regenerate)}'.")
692 raise ex
694 if not overwrite and path.exists():
695 raise CodeCoverageError(f"Cobertura report file '{path}' can not be overwritten.") \
696 from FileExistsError(f"File '{path}' already exists.")
698 if regenerate:
699 self.Generate(overwrite=True)
701 if self._xmlDocument is None:
702 ex = CodeCoverageError(f"XML document of Cobertura report file '{path}' needs to be generated first.")
703 ex.add_note("Call 'Document.Generate()' or 'Document.Write(..., regenerate=True)'.")
704 raise ex
706 content = tostring(self._xmlDocument, encoding="utf-8", xml_declaration=True, pretty_print=True)
707 try:
708 with path.open("wb") as file:
709 file.write(content)
710 except OSError as ex:
711 raise CodeCoverageError(f"Cobertura report file '{path}' can not be written.") from ex