Coverage for pyEDAA/Reports/CodeCoverage/Cobertura/Records.py: 97%
293 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
1# ==================================================================================================================== #
2# _____ ____ _ _ ____ _ #
3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
7# |_| |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Electronic Design Automation Abstraction (EDA²) #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32The elements of the Cobertura XML format below ``<coverage>``: its packages, classes, methods, lines and conditions.
33"""
34from __future__ import annotations
36from typing import Iterable, Optional as Nullable
38from lxml.etree import Element as XMLElement, SubElement, _Element
39from pyTooling.Common import getFullyQualifiedName
40from pyTooling.Decorators import export, readonly
41from pyTooling.MetaClasses import ExtendedType
43from pyEDAA.Reports.CodeCoverage import Class as cc_Class, CoverageSummary, File as cc_File, Function as cc_Function
44from pyEDAA.Reports.CodeCoverage import Line as cc_Line, LineCoverageStatus, Method as cc_Method, Module as cc_Module
45from pyEDAA.Reports.CodeCoverage import Package as cc_Package, SourceFile as cc_SourceFile, Unit as cc_Unit
48@export
49class Condition(metaclass=ExtendedType, slots=True):
50 """
51 A ``<condition>`` of a line: a condition of a branching line, as gcovr writes it.
52 """
54 _number: int #: Number of the condition in its line.
55 _type: str #: Kind of condition, e.g. ``jump``.
56 _coverage: str #: Coverage of the condition, e.g. ``50%``.
58 def __init__(self, number: int, conditionType: str, coverage: str) -> None:
59 """
60 Initialize a condition.
62 :param number: Number of the condition in its line.
63 :param conditionType: Kind of condition, e.g. ``jump``.
64 :param coverage: Coverage of the condition, e.g. ``50%``.
65 """
66 self._number = number
67 self._type = conditionType
68 self._coverage = coverage
70 @readonly
71 def Number(self) -> int:
72 """
73 Read-only property to access the number of the condition in its line (:attr:`_number`).
75 :returns: The number.
76 """
77 return self._number
79 @readonly
80 def Type(self) -> str:
81 """
82 Read-only property to access the kind of condition (:attr:`_type`).
84 The kind is stated as the report states it, e.g. ``jump``.
86 :returns: The kind.
87 """
88 return self._type
90 @readonly
91 def Coverage(self) -> str:
92 """
93 Read-only property to access the coverage of the condition (:attr:`_coverage`).
95 The coverage is stated as the report states it, e.g. ``50%``.
97 :returns: The coverage.
98 """
99 return self._coverage
101 def Generate(self) -> _Element:
102 """
103 Generate the condition's ``<condition>`` element.
105 :returns: The ``<condition>`` element.
106 """
107 conditionElement = XMLElement("condition")
108 conditionElement.attrib["number"] = str(self._number)
109 conditionElement.attrib["type"] = self._type
110 conditionElement.attrib["coverage"] = self._coverage
111 return conditionElement
114@export
115class Line(metaclass=ExtendedType, slots=True):
116 """
117 A ``<line>``: a line's hits, whether it branches, its condition coverage and its conditions.
118 """
120 _number: int #: Line number.
121 _hits: int #: How often the line ran.
122 _branch: bool #: Whether the line branches.
123 _conditionCoverage: Nullable[tuple[int, int]] #: Taken and all branches, if the line states them.
124 _conditions: list[Condition] #: The line's conditions.
126 def __init__(
127 self,
128 number: int,
129 hits: int,
130 branch: bool = False,
131 conditionCoverage: Nullable[tuple[int, int]] = None,
132 conditions: list[Condition] | None = None
133 ) -> None:
134 """
135 Initialize a line.
137 :param number: Line number.
138 :param hits: How often the line ran.
139 :param branch: Optional, whether the line branches. Default: ``False``.
140 :param conditionCoverage: Optional, taken and all branches. Default: ``None``.
141 :param conditions: Optional, the line's conditions. Default: none.
142 """
143 self._number = number
144 self._hits = hits
145 self._branch = branch
146 self._conditionCoverage = conditionCoverage
147 self._conditions = [] if conditions is None else conditions
149 @readonly
150 def Number(self) -> int:
151 """
152 Read-only property to access the line number (:attr:`_number`).
154 :returns: The line number.
155 """
156 return self._number
158 @readonly
159 def Hits(self) -> int:
160 """
161 Read-only property to access how often the line ran (:attr:`_hits`).
163 :returns: The hits.
164 """
165 return self._hits
167 @readonly
168 def Branch(self) -> bool:
169 """
170 Read-only property to access whether the line branches (:attr:`_branch`).
172 :returns: ``True``, if the line branches.
173 """
174 return self._branch
176 @readonly
177 def ConditionCoverage(self) -> Nullable[tuple[int, int]]:
178 """
179 Read-only property to access the line's taken and all branches (:attr:`_conditionCoverage`).
181 A line with one of two branches taken has ``(1, 2)``.
183 :returns: The taken and all branches; ``None`` if the line doesn't state them.
184 """
185 return self._conditionCoverage
187 @readonly
188 def Conditions(self) -> list[Condition]:
189 """
190 Read-only property to access the line's conditions (:attr:`_conditions`).
192 :returns: The conditions.
193 """
194 return self._conditions
196 @classmethod
197 def FromLine(cls, line: cc_Line) -> Line:
198 """
199 Convert a line of the common model.
201 A line without count has ``1`` hit, if it ran, else ``0``. A line with branches is a branching line; its condition
202 coverage counts its branches with state :attr:`~pyEDAA.Reports.CodeCoverage.LineCoverageStatus.Covered` and all its
203 branches.
205 :param line: The line of the common model.
206 :returns: The line.
207 :raises ValueError: If parameter ``line`` is ``None``.
208 :raises TypeError: If parameter ``line`` isn't of type :class:`~pyEDAA.Reports.CodeCoverage.Line`.
209 """
210 if line is None:
211 raise ValueError("Parameter 'line' is None.")
212 elif not isinstance(line, cc_Line):
213 ex = TypeError("Parameter 'line' is not of type 'Line'.")
214 ex.add_note(f"Got type '{getFullyQualifiedName(line)}'.")
215 raise ex
217 if line._coverageCount is not None:
218 hits = line._coverageCount
219 elif line._status in (LineCoverageStatus.Covered, LineCoverageStatus.PartiallyCovered):
220 hits = 1
221 else:
222 hits = 0
224 if len(line._branches) == 0:
225 return cls(line._lineNumber, hits)
227 covered = sum(1 for branch in line._branches if branch._status is LineCoverageStatus.Covered)
228 return cls(line._lineNumber, hits, True, (covered, len(line._branches)))
230 def Generate(self) -> _Element:
231 """
232 Generate the line's ``<line>`` element with its conditions.
234 :returns: The ``<line>`` element.
235 """
236 lineElement = XMLElement("line")
237 lineElement.attrib["number"] = str(self._number)
238 lineElement.attrib["hits"] = str(self._hits)
239 if self._branch:
240 lineElement.attrib["branch"] = "true"
242 if self._conditionCoverage is not None:
243 covered, total = self._conditionCoverage
244 percent = 100 if total == 0 else 100 * covered // total
245 lineElement.attrib["condition-coverage"] = f"{percent}% ({covered}/{total})"
247 if len(self._conditions) > 0:
248 conditionsElement = SubElement(lineElement, "conditions")
249 for condition in self._conditions:
250 conditionsElement.append(condition.Generate())
252 return lineElement
255@export
256class Element(metaclass=ExtendedType, slots=True):
257 """
258 Base-class of the named elements with rates - packages, classes and methods -, as the report states the rates.
259 """
261 _name: str #: Name of the element.
262 _lineRate: Nullable[float] #: Line coverage, as the report states it.
263 _branchRate: Nullable[float] #: Branch coverage, as the report states it.
265 def __init__(self, name: str, lineRate: Nullable[float] = None, branchRate: Nullable[float] = None) -> None:
266 """
267 Initialize the name and the rates.
269 :param name: Name of the element.
270 :param lineRate: Optional, line coverage, as the report states it. Default: ``None``.
271 :param branchRate: Optional, branch coverage, as the report states it. Default: ``None``.
272 """
273 self._name = name
274 self._lineRate = lineRate
275 self._branchRate = branchRate
277 @readonly
278 def Name(self) -> str:
279 """
280 Read-only property to access the element's name (:attr:`_name`).
282 :returns: The name.
283 """
284 return self._name
286 @readonly
287 def LineRate(self) -> Nullable[float]:
288 """
289 Read-only property to access the line coverage, as the report states it (:attr:`_lineRate`).
291 :returns: The rate in range 0.0..1.0, or ``None`` if the report doesn't state it.
292 """
293 return self._lineRate
295 @readonly
296 def BranchRate(self) -> Nullable[float]:
297 """
298 Read-only property to access the branch coverage, as the report states it (:attr:`_branchRate`).
300 :returns: The rate in range 0.0..1.0, or ``None`` if the report doesn't state it.
301 """
302 return self._branchRate
304 @staticmethod
305 def _CountLines(lines: Iterable[Line]) -> tuple[int, int, int, int]:
306 """
307 Count lines and their branches.
309 :param lines: The lines.
310 :returns: The number of lines, of lines, which ran, of branches, and of branches, which were taken.
311 """
312 linesValid = linesCovered = branchesValid = branchesCovered = 0
313 for line in lines:
314 linesValid += 1
315 if line._hits > 0:
316 linesCovered += 1
318 if line._conditionCoverage is not None:
319 branchesCovered += line._conditionCoverage[0]
320 branchesValid += line._conditionCoverage[1]
322 return linesValid, linesCovered, branchesValid, branchesCovered
324 @staticmethod
325 def _Rates(counts: tuple[int, int, int, int]) -> tuple[float, float]:
326 """
327 Compute the line and branch rates from the counts of :meth:`_CountLines`.
329 :param counts: The number of lines, of lines, which ran, of branches, and of branches, which were taken.
330 :returns: The line and the branch rate in range 0.0..1.0; ``1.0`` if there is no line or no branch.
331 """
332 linesValid, linesCovered, branchesValid, branchesCovered = counts
333 return (
334 1.0 if linesValid == 0 else linesCovered / linesValid,
335 1.0 if branchesValid == 0 else branchesCovered / branchesValid
336 )
338 def _GenerateRates(self, xmlElement: _Element, lines: Iterable[Line]) -> None:
339 """
340 Add the line and branch rate to the element's XML element: as stated, or computed from its lines.
342 :param xmlElement: The element's XML element.
343 :param lines: The element's lines.
344 """
345 lineRate = self._lineRate
346 branchRate = self._branchRate
347 if lineRate is None or branchRate is None:
348 computedLineRate, computedBranchRate = self._Rates(self._CountLines(lines))
349 lineRate = computedLineRate if lineRate is None else lineRate
350 branchRate = computedBranchRate if branchRate is None else branchRate
352 xmlElement.attrib["line-rate"] = str(round(lineRate, 4))
353 xmlElement.attrib["branch-rate"] = str(round(branchRate, 4))
356@export
357class Method(Element):
358 """
359 A ``<method>`` of a class: its signature and lines.
360 """
362 _signature: Nullable[str] #: Signature of the method.
363 _lines: dict[int, Line] #: The method's lines, by number.
365 def __init__(
366 self,
367 name: str,
368 signature: Nullable[str] = None,
369 lineRate: Nullable[float] = None,
370 branchRate: Nullable[float] = None
371 ) -> None:
372 """
373 Initialize a method.
375 :param name: Name of the method.
376 :param signature: Optional, signature of the method. Default: ``None``.
377 :param lineRate: Optional, line coverage, as the report states it. Default: ``None``.
378 :param branchRate: Optional, branch coverage, as the report states it. Default: ``None``.
379 """
380 super().__init__(name, lineRate, branchRate)
382 self._signature = signature
383 self._lines = {}
385 @readonly
386 def Signature(self) -> Nullable[str]:
387 """
388 Read-only property to access the method's signature (:attr:`_signature`).
390 :returns: The signature, or ``None`` if the report doesn't state it.
391 """
392 return self._signature
394 @readonly
395 def Lines(self) -> dict[int, Line]:
396 """
397 Read-only property to access the method's lines (:attr:`_lines`).
399 :returns: The lines, by number.
400 """
401 return self._lines
403 def Generate(self) -> _Element:
404 """
405 Generate the method's ``<method>`` element with its lines.
407 A rate the method doesn't state is computed from its lines; a missing signature is written as empty text.
409 :returns: The ``<method>`` element.
410 """
411 methodElement = XMLElement("method")
412 methodElement.attrib["name"] = self._name
413 methodElement.attrib["signature"] = "" if self._signature is None else self._signature
414 self._GenerateRates(methodElement, self._lines.values())
416 linesElement = SubElement(methodElement, "lines")
417 for line in self._lines.values():
418 linesElement.append(line.Generate())
420 return methodElement
423@export
424class Class(Element):
425 """
426 A ``<class>`` of a package: its source file, methods and lines.
427 """
429 _filename: str #: Path of the class' source file, relative to a ``<source>`` directory.
430 _complexity: Nullable[float] #: Complexity, as the report states it.
431 _methods: dict[str, Method] #: The class' methods, by name and signature.
432 _lines: dict[int, Line] #: The class' lines, by number.
434 def __init__(
435 self,
436 name: str,
437 filename: str,
438 lineRate: Nullable[float] = None,
439 branchRate: Nullable[float] = None,
440 complexity: Nullable[float] = None
441 ) -> None:
442 """
443 Initialize a class.
445 :param name: Name of the class.
446 :param filename: Path of the class' source file, relative to a ``<source>`` directory.
447 :param lineRate: Optional, line coverage, as the report states it. Default: ``None``.
448 :param branchRate: Optional, branch coverage, as the report states it. Default: ``None``.
449 :param complexity: Optional, complexity, as the report states it. Default: ``None``.
450 """
451 super().__init__(name, lineRate, branchRate)
453 self._filename = filename
454 self._complexity = complexity
455 self._methods = {}
456 self._lines = {}
458 @readonly
459 def Filename(self) -> str:
460 """
461 Read-only property to access the path of the class' source file (:attr:`_filename`).
463 :returns: The path, relative to a ``<source>`` directory.
464 """
465 return self._filename
467 @readonly
468 def Complexity(self) -> Nullable[float]:
469 """
470 Read-only property to access the complexity, as the report states it (:attr:`_complexity`).
472 :returns: The complexity, or ``None`` if the report doesn't state it.
473 """
474 return self._complexity
476 @readonly
477 def Methods(self) -> dict[str, Method]:
478 """
479 Read-only property to access the class' methods (:attr:`_methods`).
481 :returns: The methods, by name - and signature, if two methods share a name.
482 """
483 return self._methods
485 @readonly
486 def Lines(self) -> dict[int, Line]:
487 """
488 Read-only property to access the class' lines (:attr:`_lines`).
490 :returns: The lines, by number.
491 """
492 return self._lines
494 @classmethod
495 def _FromFile(cls, file: cc_File, packages: dict[str, Package]) -> None:
496 """
497 Convert a file of the common model to classes, and add them to their packages.
499 :param file: The file of the common model.
500 :param packages: The packages, by name; a missing package is added.
501 """
502 filename = file.Path.as_posix()
503 directories = file.Path.parent.parts
504 directoryPackage = ".".join(directories) if len(directories) > 0 else "."
506 fileClasses: list[Class] = []
508 def addClass(packageName: str, className: str) -> Class:
509 """
510 Nested function creating a class of this file, and adding it to its package.
512 :param packageName: Name of the package.
513 :param className: Name of the class.
514 :returns: The class.
515 """
516 if (package := packages.get(packageName)) is None:
517 package = packages[packageName] = Package(packageName, complexity=0.0)
519 klass = cls(className, filename, complexity=0.0)
520 package._classes.append(klass)
521 fileClasses.append(klass)
522 return klass
524 classes: dict[cc_Unit, Class] = {}
525 spanned: list[cc_Unit] = []
526 for unit in file._units:
527 if not isinstance(unit, (cc_SourceFile, cc_Module, cc_Class)):
528 continue
530 chain: list[cc_Unit] = []
531 element: cc_Unit | CoverageSummary = unit
532 while isinstance(element, cc_Unit):
533 chain.insert(0, element)
534 element = element._parent
536 packageCount = 0
537 while isinstance(chain[packageCount], cc_Package):
538 packageCount += 1
540 classes[unit] = addClass(
541 ".".join(part._name for part in chain[:packageCount]) if packageCount > 0 else directoryPackage,
542 ".".join(part._name for part in chain[packageCount:])
543 )
544 if unit._startLine is not None and unit._endLine is not None:
545 spanned.append(unit)
547 # outer units first: an inner unit overwrites the owner of its lines
548 owners: dict[int, Class] = {}
549 for unit in sorted(spanned, key=lambda unit: (unit._startLine._lineNumber, -unit._endLine._lineNumber)):
550 for line in file.IterateLines(unit._startLine, unit._endLine):
551 owners[line._lineNumber] = classes[unit]
553 fallback: Nullable[Class] = None
554 lines: dict[int, Line] = {}
555 for commonLine in file.IterateLines():
556 if commonLine._status is LineCoverageStatus.Excluded:
557 continue
559 line = lines[commonLine._lineNumber] = Line.FromLine(commonLine)
560 if (klass := owners.get(line._number)) is None:
561 if fallback is None:
562 fallback = addClass(directoryPackage, file._name)
563 klass = fallback
565 klass._lines[line._number] = line
567 for unit in file._units:
568 if not isinstance(unit, (cc_Function, cc_Method)):
569 continue
571 names = [unit._name]
572 element = unit._parent
573 while isinstance(element, cc_Unit) and element not in classes: 573 ↛ 574line 573 didn't jump to line 574 because the condition on line 573 was never true
574 if not isinstance(element, cc_Package):
575 names.insert(0, element._name)
576 element = element._parent
578 if isinstance(element, cc_Unit):
579 klass = classes[element]
580 else:
581 if fallback is None: 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true
582 fallback = addClass(directoryPackage, file._name)
583 klass = fallback
585 method = Method(".".join(names))
586 if unit._startLine is not None and unit._endLine is not None: 586 ↛ 591line 586 didn't jump to line 591 because the condition on line 586 was always true
587 for commonLine in file.IterateLines(unit._startLine, unit._endLine):
588 if (line := lines.get(commonLine._lineNumber)) is not None: 588 ↛ 587line 588 didn't jump to line 587 because the condition on line 588 was always true
589 method._lines[line._number] = line
591 method._lineRate, method._branchRate = cls._Rates(cls._CountLines(method._lines.values()))
592 klass._methods[method._name] = method
594 for klass in fileClasses:
595 klass._lineRate, klass._branchRate = cls._Rates(cls._CountLines(klass._lines.values()))
597 def Generate(self) -> _Element:
598 """
599 Generate the class' ``<class>`` element with its methods and lines.
601 A rate the class doesn't state is computed from its lines; a missing complexity is written as ``0``.
603 :returns: The ``<class>`` element.
604 """
605 classElement = XMLElement("class")
606 classElement.attrib["name"] = self._name
607 classElement.attrib["filename"] = self._filename
608 self._GenerateRates(classElement, self._lines.values())
609 classElement.attrib["complexity"] = str(0 if self._complexity is None else self._complexity)
611 methodsElement = SubElement(classElement, "methods")
612 for method in self._methods.values():
613 methodsElement.append(method.Generate())
615 linesElement = SubElement(classElement, "lines")
616 for line in self._lines.values():
617 linesElement.append(line.Generate())
619 return classElement
622@export
623class Package(Element):
624 """
625 A ``<package>``: its classes.
626 """
628 _complexity: Nullable[float] #: Complexity, as the report states it.
629 _classes: list[Class] #: The package's classes, in the report's order.
631 def __init__(
632 self,
633 name: str,
634 lineRate: Nullable[float] = None,
635 branchRate: Nullable[float] = None,
636 complexity: Nullable[float] = None
637 ) -> None:
638 """
639 Initialize a package.
641 :param name: Name of the package.
642 :param lineRate: Optional, line coverage, as the report states it. Default: ``None``.
643 :param branchRate: Optional, branch coverage, as the report states it. Default: ``None``.
644 :param complexity: Optional, complexity, as the report states it. Default: ``None``.
645 """
646 super().__init__(name, lineRate, branchRate)
648 self._complexity = complexity
649 self._classes = []
651 @readonly
652 def Complexity(self) -> Nullable[float]:
653 """
654 Read-only property to access the complexity, as the report states it (:attr:`_complexity`).
656 :returns: The complexity, or ``None`` if the report doesn't state it.
657 """
658 return self._complexity
660 @readonly
661 def Classes(self) -> list[Class]:
662 """
663 Read-only property to access the package's classes (:attr:`_classes`).
665 :returns: The classes, in the report's order; two classes may share a name.
666 """
667 return self._classes
669 @classmethod
670 def _FromFiles(cls, files: Iterable[cc_File]) -> list[Package]:
671 """
672 Convert the files of the common model to packages of their classes.
674 :param files: The files of the common model.
675 :returns: The packages, by name.
676 """
677 packages: dict[str, Package] = {}
678 for file in files:
679 Class._FromFile(file, packages)
681 for package in packages.values():
682 package._lineRate, package._branchRate = cls._Rates(cls._CountLines(
683 line for klass in package._classes for line in klass._lines.values()
684 ))
686 return [packages[name] for name in sorted(packages)]
688 def Generate(self) -> _Element:
689 """
690 Generate the package's ``<package>`` element with its classes.
692 A rate the package doesn't state is computed from its classes' lines; a missing complexity is written as ``0``.
694 :returns: The ``<package>`` element.
695 """
696 packageElement = XMLElement("package")
697 packageElement.attrib["name"] = self._name
698 self._GenerateRates(packageElement, [line for klass in self._classes for line in klass._lines.values()])
699 packageElement.attrib["complexity"] = str(0 if self._complexity is None else self._complexity)
701 classesElement = SubElement(packageElement, "classes")
702 for klass in self._classes:
703 classesElement.append(klass.Generate())
705 return packageElement