Coverage for pyEDAA/Reports/CodeCoverage/__init__.py: 96%
639 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"""
32Abstract data model of code coverage, the common model every report format is converted to.
34The model has two hierarchies over the same lines:
36* The **physical** hierarchy - :class:`CoverageSummary`, :class:`Directory`, :class:`File` - is built from the file
37 paths a report names. A file holds its executable lines; every format has them.
38* The **logical** hierarchy holds the language units a report names - :class:`Package`, :class:`Module`,
39 :class:`SourceFile`, :class:`Class`, :class:`Function`, :class:`Method` -, each spanning its file from a first to a
40 last :class:`Line`, so both hierarchies count the same lines.
42A :class:`Line` and a :class:`Branch` carry a :class:`LineCoverageStatus` and - if the report says - a count: how
43often the line ran, or the branch was taken. :meth:`CoverageSummary.Aggregate` computes the counters of every file,
44directory and unit.
46The report formats have models of their own, which convert to this one:
48.. seealso::
50 :mod:`pyEDAA.Reports.CodeCoverage.Cobertura`
51 |rarr| The Cobertura XML format, as written e.g. by coverage.py (``coverage xml``) or gcovr (``--cobertura``).
52 :mod:`pyEDAA.Reports.CodeCoverage.CoveragePy`
53 |rarr| coverage.py's JSON format (``coverage json``).
54 :mod:`pyEDAA.Reports.CodeCoverage.GHDL`
55 |rarr| GHDL's JSON coverage file (``ghdl -r --coverage``).
56 :mod:`pyEDAA.Reports.CodeCoverage.UCIS`
57 |rarr| The XML interchange format of the Accellera UCIS standard, as written e.g. by pyucis (``pyucis convert``).
58 :mod:`pyEDAA.Reports.CodeCoverage.Gcov`
59 |rarr| GCC's gcov JSON format (``gcov --json-format``).
60 :mod:`pyEDAA.Reports.CodeCoverage.LCOV`
61 |rarr| lcov's tracefile format, as written e.g. by lcov (``lcov --capture``) or llvm-cov
62 (``llvm-cov export -format=lcov``).
63 :mod:`pyEDAA.Reports.CodeCoverage.JaCoCo`
64 |rarr| JaCoCo's XML format, as written e.g. by Gradle's task ``jacocoTestReport``.
65 :mod:`pyEDAA.Reports.CodeCoverage.AldecUCDB`
66 |rarr| Aldec's UCDB XML format, as exported by ``acdb2xml`` of Riviera-PRO or Active-HDL.
67 :mod:`pyEDAA.Reports.CodeCoverage.QuestaSim`
68 |rarr| QuestaSim's code coverage report XML (``vcover report -xml -details``).
69 :mod:`pyEDAA.Reports.CodeCoverage.LLVM`
70 |rarr| LLVM's JSON code coverage export (``llvm-cov export``), e.g. of Clang's source-based code coverage.
71"""
72from __future__ import annotations
74from datetime import timedelta
75from enum import Enum
76from pathlib import Path
77from typing import Callable, ClassVar, Generator, Iterable, Optional as Nullable
79from pyTooling.Common import getFullyQualifiedName, getTypeName
80from pyTooling.Decorators import export, readonly
81from pyTooling.Exceptions import AlreadyInHierarchyError, DuplicateElementError
82from pyTooling.MetaClasses import ExtendedType, abstractclass, abstractmethod
84from pyEDAA.Reports import ReportException
87@export
88class CodeCoverageError(ReportException):
89 """Base-exception of the code coverage data model and its report formats."""
92@export
93class AlreadyInCoverageReportError(CodeCoverageError, AlreadyInHierarchyError):
94 """The exception is raised when an element is added to an element, but it already has a parent."""
97@export
98class DuplicateDirectoryError(CodeCoverageError, DuplicateElementError):
99 """The exception is raised when a directory already contains a directory or a file of the same name."""
102@export
103class DuplicateFileError(CodeCoverageError, DuplicateElementError):
104 """The exception is raised when a directory or a report already contains a file or a directory of the same name."""
107@export
108class DuplicateUnitError(CodeCoverageError, DuplicateElementError):
109 """The exception is raised when a unit or the report's root already contains a unit of the same name."""
112@export
113class DuplicateLineError(CodeCoverageError, DuplicateElementError):
114 """The exception is raised when a file already has a line of the same number."""
117@export
118class LineCoverageStatus(Enum):
119 """The coverage state of a line, a branch or a unit, as a report states it."""
121 Unknown = 0 #: The report doesn't say.
122 Uncovered = 1 #: The line never ran, the branch was never taken, the unit was never called.
123 PartiallyCovered = 2 #: The line ran, but not all of its branches were taken.
124 Covered = 3 #: The line ran, the branch was taken, the unit was called.
125 Excluded = 4 #: Excluded from the measurement, e.g. by a pragma comment.
128@export
129class Base(metaclass=ExtendedType, slots=True):
130 """
131 Base-class of every element of the code coverage model: a reference to the element containing it, and to the report's
132 root.
133 """
135 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: The types a parent may have; assigned by each class having a parent.
137 _parent: Nullable[Base] #: The element containing this one, or ``None``.
138 _root: Nullable[CoverageSummary] #: The report's root, or ``None`` while the element isn't part of a report.
140 def __init__(self, *, parent: Nullable[Base] = None) -> None:
141 """
142 Initialize an element with its parent and the parent's root.
144 The parent doesn't list the element yet: the class setting the field the parent stores it by - a name, a line
145 number - adds it with ``parent._AddElement(self)``.
147 :param parent: Optional, the element containing this one. Default: ``None``.
148 :raises TypeError: If parameter ``parent`` isn't of a type this class declares in :attr:`_PARENT_TYPE`.
149 """
150 if parent is None:
151 self._parent = None
152 self._root = None
153 elif not isinstance(parent, self._PARENT_TYPE):
154 typeNames = " or ".join(f"'{parentType.__name__}'" for parentType in self._PARENT_TYPE)
155 ex = TypeError(f"Parameter 'parent' is not of type {typeNames}.")
156 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
157 raise ex
158 else:
159 self._parent = parent
160 self._root = parent._root
162 @property
163 def Parent(self) -> Nullable[Base]:
164 """
165 Property to access the element containing this one (:attr:`_parent`).
167 Assigning a parent adds this element to it, and sets the parent's root as the root of this element and of every
168 element below it.
170 :returns: The parent, or ``None``.
171 :raises ValueError: If ``None`` is assigned.
172 :raises TypeError: If the assigned parent isn't of a type this class declares in
173 :attr:`_PARENT_TYPE`.
174 :raises TypeError: If a parent is assigned to a :class:`CoverageSummary`, which has no parent.
175 :raises AlreadyInCoverageReportError: If the element already has a parent.
176 :raises DuplicateDirectoryError: If the assigned parent already contains a directory of this name.
177 :raises DuplicateFileError: If the assigned parent already contains a file of this name.
178 :raises DuplicateUnitError: If the assigned parent already contains a unit of this name.
179 :raises DuplicateLineError: If the assigned parent already contains a line of this line number.
180 """
181 return self._parent
183 @Parent.setter
184 def Parent(self, parent: Base) -> None:
185 if parent is None:
186 raise ValueError("Parameter 'parent' is None.")
187 elif not isinstance(parent, self._PARENT_TYPE):
188 typeNames = " or ".join(f"'{parentType.__name__}'" for parentType in self._PARENT_TYPE)
189 ex = TypeError(f"Parameter 'parent' is not of type {typeNames}.")
190 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
191 raise ex
192 elif self._parent is not None:
193 raise AlreadyInCoverageReportError(f"{getTypeName(self)} already has a parent.")
195 parent._AddElement(self)
196 self._parent = parent
197 self._SetRoot(parent._root)
199 @readonly
200 def Root(self) -> Nullable[CoverageSummary]:
201 """
202 Read-only property to access the report's root (:attr:`_root`).
204 The root is maintained by :attr:`Parent`: assigning a parent sets it for this element and every element below it.
206 :returns: The root, or ``None`` while the element isn't part of a report.
207 """
208 return self._root
210 def _SetRoot(self, root: Nullable[CoverageSummary]) -> None:
211 """
212 Set the report's root of this element.
214 A class with child elements extends it to set the root of its children too.
216 :param root: The report's root, or ``None`` while the element isn't part of a report.
217 """
218 self._root = root
221@export
222class BaseWithStatus(Base):
223 """
224 Base-class of the elements with a coverage state and a count: lines, branches and units.
225 """
227 _status: LineCoverageStatus #: The coverage state.
228 _coverageCount: Nullable[int] #: How often it ran, was taken or was called, if the report says.
230 def __init__(self, status: LineCoverageStatus, coverageCount: Nullable[int], *, parent: Nullable[Base]) -> None:
231 """
232 Initialize the parent, the coverage state and the count.
234 A count of ``0`` is uncovered, a positive count covered.
236 :param status: The coverage state.
237 :param coverageCount: How often the line ran, the branch was taken or the unit was called; ``None``, if the report
238 doesn't say.
239 :param parent: The element containing this one, or ``None``.
240 :raises TypeError: If parameter ``parent`` isn't of a type the class declares in :attr:`_PARENT_TYPE`.
241 :raises ValueError: If parameter ``status`` is ``None``.
242 :raises TypeError: If parameter ``status`` isn't of type :class:`LineCoverageStatus`.
243 :raises TypeError: If parameter ``coverageCount`` isn't of type :class:`int`.
244 :raises ValueError: If parameter ``coverageCount`` is negative.
245 :raises ValueError: If parameter ``coverageCount`` contradicts parameter ``status``.
246 """
247 super().__init__(parent=parent)
249 if status is None:
250 raise ValueError("Parameter 'status' is None.")
251 elif not isinstance(status, LineCoverageStatus):
252 ex = TypeError("Parameter 'status' is not of type 'LineCoverageStatus'.")
253 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.")
254 raise ex
256 if coverageCount is not None:
257 if not isinstance(coverageCount, int):
258 ex = TypeError("Parameter 'coverageCount' is not of type 'int'.")
259 ex.add_note(f"Got type '{getFullyQualifiedName(coverageCount)}'.")
260 raise ex
261 elif coverageCount < 0:
262 ex = ValueError("Parameter 'coverageCount' is negative.")
263 ex.add_note(f"Got value '{coverageCount}'.")
264 raise ex
265 elif (coverageCount == 0 and status in (LineCoverageStatus.Covered, LineCoverageStatus.PartiallyCovered)) or \
266 (coverageCount > 0 and status is LineCoverageStatus.Uncovered):
267 ex = ValueError("Parameter 'coverageCount' contradicts parameter 'status'.")
268 ex.add_note(f"Got count '{coverageCount}' for status '{status.name}'.")
269 raise ex
271 self._status = status
272 self._coverageCount = coverageCount
274 @readonly
275 def Status(self) -> LineCoverageStatus:
276 """
277 Read-only property to access the coverage state (:attr:`_status`).
279 :returns: The coverage state; :attr:`LineCoverageStatus.Unknown`, if the report doesn't say.
280 """
281 return self._status
283 @readonly
284 def CoverageCount(self) -> Nullable[int]:
285 """
286 Read-only property to access how often it ran, was taken or was called (:attr:`_coverageCount`).
288 :returns: The count, or ``None`` if the report doesn't say.
289 """
290 return self._coverageCount
293@export
294class CoverageCountersMixin(metaclass=ExtendedType, mixin=True):
295 """
296 A mixin-class adding the counters of lines and branches, computed from the lines of a file or a unit, or summed over
297 a directory's children.
299 The counters are zero until :meth:`BaseWithPath.Aggregate` - of a directory or a file - or :meth:`Unit.Aggregate`
300 computed them.
301 """
303 _totalLines: int #: Number of executable lines, without the excluded ones.
304 _coveredLines: int #: Number of executable lines, which ran.
305 _excludedLines: int #: Number of lines excluded from the measurement.
306 _partialLines: int #: Number of lines, which ran without taking all their branches.
307 _totalBranches: int #: Number of branches.
308 _coveredBranches: int #: Number of branches, which were taken.
310 def __init__(self) -> None:
311 """
312 Initialize the counters to zero.
313 """
314 self._ResetCounters()
316 def _ResetCounters(self) -> None:
317 """
318 Set the counters to zero.
319 """
320 self._totalLines = 0
321 self._coveredLines = 0
322 self._excludedLines = 0
323 self._partialLines = 0
324 self._totalBranches = 0
325 self._coveredBranches = 0
327 def _CountLines(self, lines: Iterable[Line]) -> None:
328 """
329 Set the counters to the counts of lines.
331 A line's covered branches are those its :meth:`Line.Aggregate` computed.
333 :param lines: The lines.
334 """
335 self._ResetCounters()
336 for line in lines:
337 if line._status is LineCoverageStatus.Excluded:
338 self._excludedLines += 1
339 continue
341 self._totalLines += 1
342 self._totalBranches += len(line._branches)
343 self._coveredBranches += line._coveredBranches
344 if line._status is LineCoverageStatus.Covered:
345 self._coveredLines += 1
346 elif line._status is LineCoverageStatus.PartiallyCovered:
347 self._coveredLines += 1
348 self._partialLines += 1
350 @readonly
351 def TotalLines(self) -> int:
352 """
353 Read-only property to access the number of executable lines, without the excluded ones (:attr:`_totalLines`).
355 :returns: The number of executable lines.
356 """
357 return self._totalLines
359 @readonly
360 def CoveredLines(self) -> int:
361 """
362 Read-only property to access the number of executable lines, which ran (:attr:`_coveredLines`).
364 :returns: The number of covered lines, including the partially covered ones.
365 """
366 return self._coveredLines
368 @readonly
369 def MissingLines(self) -> int:
370 """
371 Read-only property to return the number of executable lines, which never ran.
373 :returns: :attr:`TotalLines` minus :attr:`CoveredLines`.
374 """
375 return self._totalLines - self._coveredLines
377 @readonly
378 def ExcludedLines(self) -> int:
379 """
380 Read-only property to access the number of lines excluded from the measurement (:attr:`_excludedLines`).
382 :returns: The number of excluded lines.
383 """
384 return self._excludedLines
386 @readonly
387 def PartialLines(self) -> int:
388 """
389 Read-only property to access the number of lines, which ran without taking all their branches
390 (:attr:`_partialLines`).
392 :returns: The number of partially covered lines.
393 """
394 return self._partialLines
396 @readonly
397 def TotalBranches(self) -> int:
398 """
399 Read-only property to access the number of branches (:attr:`_totalBranches`).
401 :returns: The number of branches.
402 """
403 return self._totalBranches
405 @readonly
406 def CoveredBranches(self) -> int:
407 """
408 Read-only property to access the number of branches, which were taken (:attr:`_coveredBranches`).
410 :returns: The number of covered branches.
411 """
412 return self._coveredBranches
414 @readonly
415 def MissingBranches(self) -> int:
416 """
417 Read-only property to return the number of branches, which were never taken.
419 :returns: :attr:`TotalBranches` minus :attr:`CoveredBranches`.
420 """
421 return self._totalBranches - self._coveredBranches
423 @readonly
424 def LineCoverage(self) -> float:
425 """
426 Read-only property to return the line coverage: the ratio of covered to executable lines.
428 :returns: The line coverage in range 0.0..1.0; ``1.0`` if there is no executable line.
429 """
430 return 1.0 if self._totalLines == 0 else self._coveredLines / self._totalLines
432 @readonly
433 def BranchCoverage(self) -> float:
434 """
435 Read-only property to return the branch coverage: the ratio of taken branches to all branches.
437 :returns: The branch coverage in range 0.0..1.0; ``1.0`` if there is no branch.
438 """
439 return 1.0 if self._totalBranches == 0 else self._coveredBranches / self._totalBranches
441 @readonly
442 def Coverage(self) -> float:
443 """
444 Read-only property to return the combined coverage of lines and branches, as coverage.py computes it.
446 :returns: Covered lines plus taken branches, divided by executable lines plus branches, in range 0.0..1.0;
447 ``1.0`` if there is neither.
448 """
449 total = self._totalLines + self._totalBranches
450 return 1.0 if total == 0 else (self._coveredLines + self._coveredBranches) / total
452 def _AggregateCounters(self, other: CoverageCountersMixin) -> None:
453 """
454 Add the counters of another directory, file or unit to this one's.
456 :param other: The other directory, file or unit, aggregated.
457 """
458 self._totalLines += other._totalLines
459 self._coveredLines += other._coveredLines
460 self._excludedLines += other._excludedLines
461 self._partialLines += other._partialLines
462 self._totalBranches += other._totalBranches
463 self._coveredBranches += other._coveredBranches
466@export
467class BaseWithPath(Base, CoverageCountersMixin):
468 """
469 Base-class of the physical hierarchy - directories and source files -: a name, a path below the report's root, and
470 the counters.
472 Its parent is the :class:`Directory` containing it.
473 """
475 _name: str #: Name of the directory or file.
477 def __init__(self, name: str, *, parent: Nullable[Directory]) -> None:
478 """
479 Initialize the parent, the name and the counters.
481 :param name: Name of the directory or file.
482 :param parent: The directory containing this one, or ``None``.
483 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`.
484 :raises ValueError: If parameter ``name`` is ``None``.
485 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
486 :raises ValueError: If parameter ``name`` is empty.
487 """
488 super().__init__(parent=parent)
489 CoverageCountersMixin.__init__(self)
491 if name is None:
492 raise ValueError("Parameter 'name' is None.")
493 elif not isinstance(name, str):
494 ex = TypeError("Parameter 'name' is not of type 'str'.")
495 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
496 raise ex
497 elif name == "":
498 raise ValueError("Parameter 'name' is empty.")
500 self._name = name
502 @readonly
503 def Name(self) -> str:
504 """
505 Read-only property to access the name of the directory or file (:attr:`_name`).
507 :returns: The name.
508 """
509 return self._name
511 @readonly
512 def Path(self) -> Path:
513 """
514 Read-only property to return the path below the root: the names of the parent directories and the own name.
516 A file ``Counter.vhdl`` in a directory ``src`` has the path ``src/Counter.vhdl``.
518 :returns: The path; the name, if there is no parent.
519 """
520 if self._parent is None:
521 return Path(self._name)
523 return self._parent.Path / self._name
525 @abstractmethod
526 def Aggregate(self) -> None:
527 """
528 Compute the counters.
529 """
532@export
533class Directory(BaseWithPath):
534 """
535 A directory: its directories and source files, and their summed counters.
536 """
538 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: A directory is in a directory; assigned below the class.
540 _directories: dict[str, Directory] #: The directories in this directory, by name.
541 _files: dict[str, File] #: The source files in this directory, by name.
543 def __init__(self, name: str, *, parent: Nullable[Directory] = None) -> None:
544 """
545 Initialize a directory, and add it to its parent directory.
547 :param name: Name of the directory.
548 :param parent: Optional, the directory containing this one. Default: ``None``.
549 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`.
550 :raises ValueError: If parameter ``name`` is ``None``.
551 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
552 :raises ValueError: If parameter ``name`` is empty.
553 :raises DuplicateDirectoryError: If the parent directory already contains a file or directory of this name.
554 """
555 super().__init__(name, parent=parent)
557 self._directories = {}
558 self._files = {}
560 if parent is not None:
561 parent._AddElement(self)
563 def _AddElement(self, element: Directory | File) -> None:
564 """
565 Add a directory or file, which names this directory as its parent.
567 :param element: The directory or file.
568 :raises DuplicateDirectoryError: If this directory already contains a directory of this name.
569 :raises DuplicateFileError: If this directory already contains a file of this name.
570 """
571 if isinstance(element, Directory):
572 elements = self._directories
573 exception = DuplicateDirectoryError
574 else:
575 elements = self._files
576 exception = DuplicateFileError
578 if element._name in self._directories or element._name in self._files:
579 raise exception(f"Directory '{self.Path.as_posix()}' already contains '{element._name}'.")
581 elements[element._name] = element
583 def _SetRoot(self, root: Nullable[CoverageSummary]) -> None:
584 """
585 Set the report's root of this directory, and of the directories and files in it.
587 :param root: The report's root, or ``None`` while the directory isn't part of a report.
588 """
589 super()._SetRoot(root)
590 for directory in self._directories.values():
591 directory._SetRoot(root)
593 for file in self._files.values():
594 file._SetRoot(root)
596 @readonly
597 def Directories(self) -> dict[str, Directory]:
598 """
599 Read-only property to access the directories in this directory (:attr:`_directories`).
601 :returns: The directories, by name.
602 """
603 return self._directories
605 @readonly
606 def Files(self) -> dict[str, File]:
607 """
608 Read-only property to access the source files in this directory (:attr:`_files`).
610 :returns: The files, by name.
611 """
612 return self._files
614 @readonly
615 def FileCount(self) -> int:
616 """
617 Read-only property to return the number of source files in this directory and below.
619 :returns: The number of files.
620 """
621 return len(self._files) + sum(directory.FileCount for directory in self._directories.values())
623 def GetOrAddFile(self, path: Path | str) -> File:
624 """
625 Return the source file at a path below this directory, adding it and the directories on its way, if missing.
627 A backslash in a string path separates directories too, as a report written on Windows has them.
629 :param path: The file's path relative to this directory, e.g. ``src/Counter.vhdl``.
630 :returns: The file.
631 :raises ValueError: If parameter ``path`` is ``None``.
632 :raises TypeError: If parameter ``path`` isn't of type :class:`~pathlib.Path` or :class:`str`.
633 :raises ValueError: If parameter ``path`` names no file.
634 :raises CodeCoverageError: If a part of the path is a file instead of a directory, or the file's name is a
635 directory's.
636 """
637 if path is None: 637 ↛ 638line 637 didn't jump to line 638 because the condition on line 637 was never true
638 raise ValueError("Parameter 'path' is None.")
639 elif isinstance(path, str):
640 path = Path(path.replace("\\", "/"))
641 elif not isinstance(path, Path):
642 ex = TypeError("Parameter 'path' is not of type 'Path' or 'str'.")
643 ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.")
644 raise ex
646 parts = [part for part in path.parts if part not in ("", ".", path.anchor)]
647 if len(parts) == 0:
648 ex = ValueError("Parameter 'path' names no file.")
649 ex.add_note(f"Got path '{path}'.")
650 raise ex
652 directory = self
653 for part in parts[:-1]:
654 if part in directory._files:
655 raise CodeCoverageError(f"'{(directory.Path / part).as_posix()}' is a file, not a directory.")
656 elif (subdirectory := directory._directories.get(part)) is None:
657 subdirectory = Directory(part, parent=directory)
658 directory = subdirectory
660 if (file := directory._files.get(parts[-1])) is None:
661 file = File(parts[-1], parent=directory)
663 return file
665 def IterateFiles(self, predicate: Nullable[Callable[[File], bool]] = None) -> Generator[File, None, None]:
666 """
667 Iterate all or selected source files in this directory and below.
669 A directory's files come before its directories' files, each in the order they were added. If parameter
670 ``predicate`` is not None, the given filter function is used to skip files in the generator.
672 :param predicate: Optional, filter function accepting any file and returning a boolean. Default: ``None``.
673 :returns: A generator of the files.
674 """
675 if predicate is None:
676 yield from self._files.values()
677 for directory in self._directories.values():
678 yield from directory.IterateFiles()
679 else:
680 for file in self._files.values():
681 if predicate(file):
682 yield file
684 for directory in self._directories.values():
685 yield from directory.IterateFiles(predicate)
687 def Aggregate(self) -> None:
688 """
689 Aggregate the directories and files in this directory, then sum their counters.
690 """
691 self._ResetCounters()
692 for child in (*self._directories.values(), *self._files.values()):
693 child.Aggregate()
694 self._AggregateCounters(child)
696 def __repr__(self) -> str:
697 """
698 Return a representation of the directory for debugging.
700 A directory ``src`` of 3 files reads ``<Directory src: 3 files, 75.0%>``.
702 :returns: The directory's path, its number of files and its line coverage.
703 """
704 return f"<Directory {self.Path.as_posix()}: {self.FileCount} files, {self.LineCoverage:.1%}>"
707Directory._PARENT_TYPE = (Directory, )
710@export
711class CoverageSummary(Directory):
712 """
713 The root of a code coverage report: the directory the report's file paths are relative to, and the top-level units
714 of the logical hierarchy.
716 It is its own root.
717 """
719 _sourceDirectories: list[Path] #: The directories the report names as where the sources were, if any.
720 _units: dict[str, Unit] #: The top-level units, by name.
722 def __init__(self, name: str, *, sourceDirectories: Nullable[Iterable[Path]] = None) -> None:
723 """
724 Initialize the root of a code coverage report.
726 :param name: Name of the report, e.g. of the project measured.
727 :param sourceDirectories: Optional, the directories the report names as where the sources were. Default: none.
728 :raises ValueError: If parameter ``name`` is ``None``.
729 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
730 :raises ValueError: If parameter ``name`` is empty.
731 :raises TypeError: If parameter ``sourceDirectories`` isn't iterable.
732 :raises TypeError: If parameter ``sourceDirectories`` contains an element not of type :class:`~pathlib.Path`.
733 """
734 super().__init__(name)
736 self._root = self
737 self._sourceDirectories = []
738 self._units = {}
740 if sourceDirectories is not None:
741 if not isinstance(sourceDirectories, Iterable): 741 ↛ 742line 741 didn't jump to line 742 because the condition on line 741 was never true
742 ex = TypeError("Parameter 'sourceDirectories' is not iterable.")
743 ex.add_note(f"Got type '{getFullyQualifiedName(sourceDirectories)}'.")
744 raise ex
746 for sourceDirectory in sourceDirectories:
747 if not isinstance(sourceDirectory, Path):
748 ex = TypeError("Parameter 'sourceDirectories' contains an element not of type 'Path'.")
749 ex.add_note(f"Got type '{getFullyQualifiedName(sourceDirectory)}'.")
750 raise ex
752 self._sourceDirectories.append(sourceDirectory)
754 @Base.Parent.setter
755 def Parent(self, parent: None) -> None:
756 ex = TypeError(f"A '{getFullyQualifiedName(self)}' has no parent.")
757 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
758 raise ex
760 def _AddElement(self, element: Directory | File | Unit) -> None:
761 """
762 Add a top-level unit, directory or file, which names the report's root as its parent.
764 :param element: The unit, directory or file.
765 :raises DuplicateUnitError: If the root already contains a unit of this name.
766 :raises DuplicateDirectoryError: If the root already contains a directory of this name.
767 :raises DuplicateFileError: If the root already contains a file of this name.
768 """
769 if not isinstance(element, Unit):
770 super()._AddElement(element)
771 elif element._name in self._units:
772 raise DuplicateUnitError(f"Unit '{element._name}' is added twice to '{self._name}'.")
773 else:
774 self._units[element._name] = element
776 @readonly
777 def Path(self) -> Path:
778 """
779 Read-only property to return the path below the root, which is the root's own: ``.``.
781 So the paths of the directories and files below are relative to the root, without its name.
783 :returns: ``.``
784 """
785 return Path(".")
787 @readonly
788 def SourceDirectories(self) -> list[Path]:
789 """
790 Read-only property to access the directories the report names as where the sources were
791 (:attr:`_sourceDirectories`).
793 They are the paths on the machine the coverage was measured on, so they may not exist where the report is read.
795 :returns: The source directories; empty, if the report names none.
796 """
797 return self._sourceDirectories
799 @readonly
800 def Units(self) -> dict[str, Unit]:
801 """
802 Read-only property to access the top-level units of the logical hierarchy (:attr:`_units`).
804 :returns: The units, by name.
805 """
806 return self._units
808 def IterateUnits(self, predicate: Nullable[Callable[[Unit], bool]] = None) -> Generator[Unit, None, None]:
809 """
810 Iterate all or selected units of the logical hierarchy.
812 A unit comes before its units, each level in the order the units were added. If parameter ``predicate`` is not None,
813 the given filter function is used to skip units in the generator. A skipped unit's units are still iterated.
815 :param predicate: Optional, filter function accepting any unit and returning a boolean. Default: ``None``.
816 :returns: A generator of the units.
817 """
818 for unit in self._units.values():
819 yield from unit.IterateUnits(predicate)
821 def Aggregate(self) -> None:
822 """
823 Aggregate the physical hierarchy - directories and files -, then the units of the logical hierarchy.
824 """
825 super().Aggregate()
826 for unit in self._units.values():
827 unit.Aggregate()
830@export
831class File(BaseWithPath):
832 """
833 A source file: the coverage of its executable lines, and the units of the logical hierarchy it holds.
835 The lines are a list indexed by line number: index 0 is unused, and a line the report doesn't list - a comment, a
836 declaration - is ``None``. Lines are iterated in order, and looked up without hashing.
837 """
839 _PARENT_TYPE: ClassVar[tuple[type, ...]] = (Directory, ) #: A file is in a directory.
841 _lines: list[Nullable[Line]] #: The executable lines by line number; ``None`` at index 0 and for gaps.
842 _lastLineNumber: int #: The last line number the report lists; ``0``, if it lists none.
843 _units: list[Unit] #: The units naming this file, in the order they were added.
845 def __init__(self, name: str, *, lines: Nullable[Iterable[Line]] = None, parent: Nullable[Directory] = None) -> None:
846 """
847 Initialize a source file, and add it to its directory.
849 :param name: Name of the file.
850 :param lines: Optional, the executable lines. Default: ``None``.
851 :param parent: Optional, the directory containing the file. Default: ``None``.
852 :raises TypeError: If parameter ``parent`` isn't of type :class:`Directory`.
853 :raises ValueError: If parameter ``name`` is ``None``.
854 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
855 :raises ValueError: If parameter ``name`` is empty.
856 :raises DuplicateFileError: If the directory already contains a file or directory of this name.
857 :raises TypeError: If parameter ``lines`` isn't iterable.
858 :raises TypeError: If parameter ``lines`` contains an element not of type :class:`Line`.
859 :raises DuplicateLineError: If two lines have the same number.
860 """
861 super().__init__(name, parent=parent)
863 self._units = []
865 if parent is not None:
866 parent._AddElement(self)
868 if lines is None:
869 self._lines = [None]
870 self._lastLineNumber = 0
871 elif not isinstance(lines, Iterable):
872 ex = TypeError("Parameter 'lines' is not iterable.")
873 ex.add_note(f"Got type '{getFullyQualifiedName(lines)}'.")
874 raise ex
875 else:
876 # check the lines and find the last line number, then allocate the list once at its final size
877 lineList = []
878 lastLineNumber = 0
879 for line in lines:
880 if not isinstance(line, Line):
881 ex = TypeError("Parameter 'lines' contains an element not of type 'Line'.")
882 ex.add_note(f"Got type '{getFullyQualifiedName(line)}'.")
883 raise ex
885 lineList.append(line)
886 if line._lineNumber > lastLineNumber:
887 lastLineNumber = line._lineNumber
889 fileLines = [None] * (lastLineNumber + 1)
890 for line in lineList:
891 lineNumber = line._lineNumber
892 if fileLines[lineNumber] is not None:
893 raise DuplicateLineError(f"Line {lineNumber} of file '{self.Path.as_posix()}' is added twice.")
895 fileLines[lineNumber] = line
896 line._parent = self
897 line._SetRoot(self._root)
899 self._lines = fileLines
900 self._lastLineNumber = lastLineNumber
902 def _AddElement(self, line: Line) -> None:
903 """
904 Add a line, which names this file as its parent.
906 :param line: The line.
907 :raises DuplicateLineError: If the file already has a line of this number.
908 """
909 lineNumber = line._lineNumber
910 if (gap := lineNumber - len(self._lines)) >= 0:
911 if gap > 0:
912 self._lines.extend([None] * gap)
914 self._lines.append(line)
915 elif self._lines[lineNumber] is None:
916 self._lines[lineNumber] = line
917 else:
918 raise DuplicateLineError(f"Line {lineNumber} of file '{self.Path.as_posix()}' is added twice.")
920 if lineNumber > self._lastLineNumber:
921 self._lastLineNumber = lineNumber
923 def _SetRoot(self, root: Nullable[CoverageSummary]) -> None:
924 """
925 Set the report's root of this file, and of its lines.
927 :param root: The report's root, or ``None`` while the file isn't part of a report.
928 """
929 super()._SetRoot(root)
930 for line in self.IterateLines():
931 line._SetRoot(root)
933 @readonly
934 def Lines(self) -> list[Nullable[Line]]:
935 """
936 Read-only property to access the executable lines (:attr:`_lines`).
938 The list ends at the last line the report lists. To iterate only the listed lines, use :meth:`IterateLines`.
940 :returns: The lines, indexed by line number; ``None`` for index 0 and for a line the report doesn't list.
941 """
942 return self._lines
944 @readonly
945 def LastLineNumber(self) -> int:
946 """
947 Read-only property to access the number of the last line the report lists (:attr:`_lastLineNumber`).
949 :returns: The line number; ``0``, if the report lists no line.
950 """
951 return self._lastLineNumber
953 def GetLine(self, lineNumber: int) -> Nullable[Line]:
954 """
955 Return the line of a line number.
957 :param lineNumber: The line number, counted from 1.
958 :returns: The line, or ``None`` if the report doesn't list it.
959 :raises ValueError: If parameter ``lineNumber`` is ``None``.
960 :raises TypeError: If parameter ``lineNumber`` isn't of type :class:`int`.
961 :raises ValueError: If parameter ``lineNumber`` is less than 1.
962 :raises ValueError: If parameter ``lineNumber`` is beyond the last line the report lists (:attr:`LastLineNumber`).
963 """
964 if lineNumber is None:
965 raise ValueError("Parameter 'lineNumber' is None.")
966 elif not isinstance(lineNumber, int):
967 ex = TypeError("Parameter 'lineNumber' is not of type 'int'.")
968 ex.add_note(f"Got type '{getFullyQualifiedName(lineNumber)}'.")
969 raise ex
970 elif lineNumber < 1:
971 ex = ValueError("Parameter 'lineNumber' is less than 1.")
972 ex.add_note(f"Got value '{lineNumber}'.")
973 raise ex
974 elif lineNumber > self._lastLineNumber:
975 ex = ValueError(f"Parameter 'lineNumber' is beyond the last line of file '{self.Path.as_posix()}'.")
976 ex.add_note(f"Got value '{lineNumber}' for {self._lastLineNumber} lines.")
977 raise ex from IndexError(f"Index {lineNumber} is out of range 1..{self._lastLineNumber}.")
979 return self._lines[lineNumber]
981 @readonly
982 def Units(self) -> list[Unit]:
983 """
984 Read-only property to access the units naming this file (:attr:`_units`).
986 A Python file is named by its module, its classes and its functions.
988 :returns: The units.
989 """
990 return self._units
992 def IterateLines(
993 self,
994 startLine: Nullable[Line] = None,
995 endLine: Nullable[Line] = None
996 ) -> Generator[Line, None, None]:
997 """
998 Iterate the executable lines of this file from a first to a last line, both included, by line number.
1000 :param startLine: Optional, the first line. Default: ``None``, the file's first line.
1001 :param endLine: Optional, the last line. Default: ``None``, the file's last line.
1002 :returns: A generator of the lines.
1003 :raises TypeError: If parameter ``startLine`` isn't of type :class:`Line`.
1004 :raises ValueError: If parameter ``startLine`` isn't a line of this file.
1005 :raises TypeError: If parameter ``endLine`` isn't of type :class:`Line`.
1006 :raises ValueError: If parameter ``endLine`` isn't a line of this file.
1007 """
1008 if startLine is None:
1009 first = 1
1010 elif not isinstance(startLine, Line): 1010 ↛ 1011line 1010 didn't jump to line 1011 because the condition on line 1010 was never true
1011 ex = TypeError("Parameter 'startLine' is not of type 'Line'.")
1012 ex.add_note(f"Got type '{getFullyQualifiedName(startLine)}'.")
1013 raise ex
1014 elif startLine._parent is not self:
1015 raise ValueError(f"Parameter 'startLine' is not a line of file '{self.Path.as_posix()}'.")
1016 else:
1017 first = startLine._lineNumber
1019 if endLine is None:
1020 last = self._lastLineNumber
1021 elif not isinstance(endLine, Line): 1021 ↛ 1022line 1021 didn't jump to line 1022 because the condition on line 1021 was never true
1022 ex = TypeError("Parameter 'endLine' is not of type 'Line'.")
1023 ex.add_note(f"Got type '{getFullyQualifiedName(endLine)}'.")
1024 raise ex
1025 elif endLine._parent is not self: 1025 ↛ 1026line 1025 didn't jump to line 1026 because the condition on line 1025 was never true
1026 raise ValueError(f"Parameter 'endLine' is not a line of file '{self.Path.as_posix()}'.")
1027 else:
1028 last = endLine._lineNumber
1030 for lineNumber in range(first, last + 1):
1031 if (line := self._lines[lineNumber]) is not None:
1032 yield line
1034 def Aggregate(self) -> None:
1035 """
1036 Aggregate the file's lines, then compute the counters from them.
1037 """
1038 lines = list(self.IterateLines())
1039 for line in lines:
1040 line.Aggregate()
1042 self._CountLines(lines)
1044 def __repr__(self) -> str:
1045 """
1046 Return a representation of the file for debugging.
1048 A file ``src/Counter.vhdl`` reads ``<File src/Counter.vhdl: 80.0%>``.
1050 :returns: The file's path and line coverage.
1051 """
1052 return f"<File {self.Path.as_posix()}: {self.LineCoverage:.1%}>"
1055@export
1056class Line(BaseWithStatus):
1057 """
1058 The coverage of an executable line: its coverage state, how often it ran - if the report says -, and its branches.
1060 A report lists only executable lines; a line it doesn't list - a comment, a declaration - has no :class:`Line`. Its
1061 parent is the :class:`File` it is in.
1062 """
1064 _PARENT_TYPE: ClassVar[tuple[type, ...]] = (File, ) #: A line is in a file.
1066 _lineNumber: int #: Line number, counted from 1.
1067 _branches: list[Branch] #: The branches starting at this line.
1068 _coveredBranches: int #: Number of branches, which were taken; zero until :meth:`Aggregate` computed it.
1070 def __init__(
1071 self,
1072 lineNumber: int,
1073 status: LineCoverageStatus,
1074 coverageCount: Nullable[int] = None,
1075 branches: Nullable[Iterable[Branch]] = None,
1076 *,
1077 parent: Nullable[File] = None
1078 ) -> None:
1079 """
1080 Initialize a line's coverage, and add it to its file.
1082 :param lineNumber: Line number, counted from 1.
1083 :param status: Coverage state of the line.
1084 :param coverageCount: Optional, how often the line ran, if the report says. Default: ``None``.
1085 :param branches: Optional, the branches starting at this line. Default: ``None``.
1086 :param parent: Optional, the file the line is in. Default: ``None``.
1087 :raises TypeError: If parameter ``parent`` isn't of type :class:`File`.
1088 :raises ValueError: If parameter ``status`` is ``None``.
1089 :raises TypeError: If parameter ``status`` isn't of type :class:`LineCoverageStatus`.
1090 :raises TypeError: If parameter ``coverageCount`` isn't of type :class:`int`.
1091 :raises ValueError: If parameter ``coverageCount`` is negative.
1092 :raises ValueError: If parameter ``coverageCount`` contradicts parameter ``status``.
1093 :raises ValueError: If parameter ``lineNumber`` is ``None``.
1094 :raises TypeError: If parameter ``lineNumber`` isn't of type :class:`int`.
1095 :raises ValueError: If parameter ``lineNumber`` is less than 1.
1096 :raises DuplicateLineError: If the file already has a line of this number.
1097 :raises TypeError: If parameter ``branches`` isn't iterable.
1098 :raises TypeError: If parameter ``branches`` contains an element not of type :class:`Branch`.
1099 """
1100 super().__init__(status, coverageCount, parent=parent)
1102 if lineNumber is None:
1103 raise ValueError("Parameter 'lineNumber' is None.")
1104 elif not isinstance(lineNumber, int):
1105 ex = TypeError("Parameter 'lineNumber' is not of type 'int'.")
1106 ex.add_note(f"Got type '{getFullyQualifiedName(lineNumber)}'.")
1107 raise ex
1108 elif lineNumber < 1:
1109 ex = ValueError("Parameter 'lineNumber' is less than 1.")
1110 ex.add_note(f"Got value '{lineNumber}'.")
1111 raise ex
1113 self._lineNumber = lineNumber
1114 self._branches = []
1115 self._coveredBranches = 0
1117 if parent is not None:
1118 parent._AddElement(self)
1120 if branches is not None:
1121 if not isinstance(branches, Iterable): 1121 ↛ 1122line 1121 didn't jump to line 1122 because the condition on line 1121 was never true
1122 ex = TypeError("Parameter 'branches' is not iterable.")
1123 ex.add_note(f"Got type '{getFullyQualifiedName(branches)}'.")
1124 raise ex
1126 for branch in branches:
1127 if not isinstance(branch, Branch):
1128 ex = TypeError("Parameter 'branches' contains an element not of type 'Branch'.")
1129 ex.add_note(f"Got type '{getFullyQualifiedName(branch)}'.")
1130 raise ex
1132 branch._parent = self
1133 branch._root = self._root
1134 self._branches.append(branch)
1136 def _AddElement(self, branch: Branch) -> None:
1137 """
1138 Add a branch, which names this line as its parent.
1140 :param branch: The branch.
1141 """
1142 self._branches.append(branch)
1144 def _SetRoot(self, root: Nullable[CoverageSummary]) -> None:
1145 """
1146 Set the report's root of this line, and of its branches.
1148 :param root: The report's root, or ``None`` while the line isn't part of a report.
1149 """
1150 super()._SetRoot(root)
1151 for branch in self._branches:
1152 branch._SetRoot(root)
1154 @readonly
1155 def LineNumber(self) -> int:
1156 """
1157 Read-only property to access the line number (:attr:`_lineNumber`).
1159 :returns: The line number, counted from 1.
1160 """
1161 return self._lineNumber
1163 @readonly
1164 def Branches(self) -> list[Branch]:
1165 """
1166 Read-only property to access the branches starting at this line (:attr:`_branches`).
1168 :returns: The branches; empty for a line, which doesn't branch.
1169 """
1170 return self._branches
1172 @readonly
1173 def CoveredBranches(self) -> int:
1174 """
1175 Read-only property to access the number of this line's branches, which were taken (:attr:`_coveredBranches`).
1177 :returns: The number of branches with state :attr:`LineCoverageStatus.Covered`; zero until :meth:`Aggregate`
1178 computed it.
1179 """
1180 return self._coveredBranches
1182 def Aggregate(self) -> None:
1183 """
1184 Compute the number of this line's branches, which were taken.
1185 """
1186 self._coveredBranches = sum(1 for branch in self._branches if branch._status is LineCoverageStatus.Covered)
1188 def __repr__(self) -> str:
1189 """
1190 Return a representation of the line's coverage for debugging.
1192 Line 12 with one of two branches taken reads ``<Line 12: PartiallyCovered (1/2 branches)>``.
1194 :returns: The line number, the state and the branches.
1195 """
1196 branches = f" ({self.CoveredBranches}/{len(self._branches)} branches)" if len(self._branches) > 0 else ""
1197 return f"<Line {self._lineNumber}: {self._status.name}{branches}>"
1200@export
1201class Branch(BaseWithStatus):
1202 """
1203 A branch of a line: whether it was taken, how often - if the report says -, and where it goes - if the report says.
1205 Its parent is the :class:`Line` it starts at.
1206 """
1208 _PARENT_TYPE: ClassVar[tuple[type, ...]] = (Line, ) #: A branch starts at a line.
1210 _target: Nullable[Line] #: The line the branch goes to, if the report says.
1212 def __init__(
1213 self,
1214 status: LineCoverageStatus,
1215 coverageCount: Nullable[int] = None,
1216 target: Nullable[Line] = None,
1217 *,
1218 parent: Nullable[Line] = None
1219 ) -> None:
1220 """
1221 Initialize a branch, and add it to its line.
1223 :param status: Whether the branch was taken.
1224 :param coverageCount: Optional, how often the branch was taken, if the report says. Default: ``None``.
1225 :param target: Optional, the line the branch goes to, if the report says; ``None`` for an exit of a function.
1226 Default: ``None``.
1227 :param parent: Optional, the line the branch starts at. Default: ``None``.
1228 :raises TypeError: If parameter ``parent`` isn't of type :class:`Line`.
1229 :raises ValueError: If parameter ``status`` is ``None``.
1230 :raises TypeError: If parameter ``status`` isn't of type :class:`LineCoverageStatus`.
1231 :raises TypeError: If parameter ``coverageCount`` isn't of type :class:`int`.
1232 :raises ValueError: If parameter ``coverageCount`` is negative.
1233 :raises ValueError: If parameter ``coverageCount`` contradicts parameter ``status``.
1234 :raises TypeError: If parameter ``target`` isn't of type :class:`Line`.
1235 """
1236 super().__init__(status, coverageCount, parent=parent)
1238 if target is not None and not isinstance(target, Line):
1239 ex = TypeError("Parameter 'target' is not of type 'Line'.")
1240 ex.add_note(f"Got type '{getFullyQualifiedName(target)}'.")
1241 raise ex
1243 self._target = target
1245 if parent is not None:
1246 parent._AddElement(self)
1248 @readonly
1249 def Target(self) -> Nullable[Line]:
1250 """
1251 Read-only property to access the line the branch goes to (:attr:`_target`).
1253 :returns: The line, or ``None`` if the report doesn't say or the branch exits the function.
1254 """
1255 return self._target
1258@export
1259class Unit(BaseWithStatus, CoverageCountersMixin):
1260 """
1261 Base-class of the logical hierarchy: a language unit - a package, a module, a class, a function, a method -, the
1262 units it contains, and the lines it spans.
1264 Its parent is the :class:`Unit` containing it, or the report's :class:`CoverageSummary`. A unit spans the lines of
1265 its file from its first to its last line - a language construct wraps the constructs nested in it -, so the
1266 physical and the logical hierarchy count the same lines. A unit without lines, e.g. a package of several files,
1267 counts the lines of the units it contains, each line once.
1268 """
1270 _PARENT_TYPE: ClassVar[tuple[type, ...]] #: A unit is in a unit or the report's root; assigned below the class.
1272 _name: str #: Name of the unit.
1273 _units: dict[str, Unit] #: The units this one contains, by name.
1274 _file: Nullable[File] #: The source file the unit is in, if the report says.
1275 _startLine: Nullable[Line] #: The unit's first line, if the report says.
1276 _endLine: Nullable[Line] #: The unit's last line, if the report says.
1278 def __init__(
1279 self,
1280 name: str,
1281 *,
1282 file: Nullable[File] = None,
1283 startLine: Nullable[Line] = None,
1284 endLine: Nullable[Line] = None,
1285 status: LineCoverageStatus = LineCoverageStatus.Unknown,
1286 coverageCount: Nullable[int] = None,
1287 parent: Nullable[Unit | CoverageSummary] = None
1288 ) -> None:
1289 """
1290 Initialize a unit, and add it to its file and to the unit or report containing it.
1292 :param name: Name of the unit.
1293 :param file: Optional, the source file the unit is in. Default: ``None``.
1294 :param startLine: Optional, the unit's first line. Default: ``None``.
1295 :param endLine: Optional, the unit's last line. Default: ``None``.
1296 :param status: Optional, whether the unit was called. Default: :attr:`LineCoverageStatus.Unknown`.
1297 :param coverageCount: Optional, how often the unit was called. Default: ``None``.
1298 :param parent: Optional, the unit or report containing this unit. Default: ``None``.
1299 :raises TypeError: If parameter ``parent`` isn't of type :class:`Unit` or :class:`CoverageSummary`.
1300 :raises ValueError: If parameter ``status`` is ``None``.
1301 :raises TypeError: If parameter ``status`` isn't of type :class:`LineCoverageStatus`.
1302 :raises TypeError: If parameter ``coverageCount`` isn't of type :class:`int`.
1303 :raises ValueError: If parameter ``coverageCount`` is negative.
1304 :raises ValueError: If parameter ``coverageCount`` contradicts parameter ``status``.
1305 :raises ValueError: If parameter ``name`` is ``None``.
1306 :raises TypeError: If parameter ``name`` isn't of type :class:`str`.
1307 :raises ValueError: If parameter ``name`` is empty.
1308 :raises TypeError: If parameter ``file`` isn't of type :class:`File`.
1309 :raises TypeError: If parameter ``startLine`` isn't of type :class:`Line`.
1310 :raises ValueError: If parameter ``startLine`` isn't a line of parameter ``file``.
1311 :raises TypeError: If parameter ``endLine`` isn't of type :class:`Line`.
1312 :raises ValueError: If parameter ``endLine`` isn't a line of parameter ``file``.
1313 :raises ValueError: If parameter ``endLine`` is before parameter ``startLine``.
1314 :raises DuplicateUnitError: If the parent already contains a unit of this name.
1315 """
1316 super().__init__(status, coverageCount, parent=parent)
1317 CoverageCountersMixin.__init__(self)
1319 if name is None:
1320 raise ValueError("Parameter 'name' is None.")
1321 elif not isinstance(name, str):
1322 ex = TypeError("Parameter 'name' is not of type 'str'.")
1323 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
1324 raise ex
1325 elif name == "":
1326 raise ValueError("Parameter 'name' is empty.")
1328 if file is not None and not isinstance(file, File): 1328 ↛ 1329line 1328 didn't jump to line 1329 because the condition on line 1328 was never true
1329 ex = TypeError("Parameter 'file' is not of type 'File'.")
1330 ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.")
1331 raise ex
1333 if startLine is not None:
1334 if not isinstance(startLine, Line):
1335 ex = TypeError("Parameter 'startLine' is not of type 'Line'.")
1336 ex.add_note(f"Got type '{getFullyQualifiedName(startLine)}'.")
1337 raise ex
1338 elif startLine._parent is not file:
1339 raise ValueError("Parameter 'startLine' is not a line of parameter 'file'.")
1341 if endLine is not None:
1342 if not isinstance(endLine, Line): 1342 ↛ 1343line 1342 didn't jump to line 1343 because the condition on line 1342 was never true
1343 ex = TypeError("Parameter 'endLine' is not of type 'Line'.")
1344 ex.add_note(f"Got type '{getFullyQualifiedName(endLine)}'.")
1345 raise ex
1346 elif endLine._parent is not file: 1346 ↛ 1347line 1346 didn't jump to line 1347 because the condition on line 1346 was never true
1347 raise ValueError("Parameter 'endLine' is not a line of parameter 'file'.")
1348 elif startLine is not None and endLine._lineNumber < startLine._lineNumber:
1349 ex = ValueError("Parameter 'endLine' is before parameter 'startLine'.")
1350 ex.add_note(f"Got lines {startLine._lineNumber} to {endLine._lineNumber}.")
1351 raise ex
1353 self._name = name
1354 self._units = {}
1355 self._file = file
1356 self._startLine = startLine
1357 self._endLine = endLine
1359 if parent is not None:
1360 parent._AddElement(self)
1362 if file is not None:
1363 file._units.append(self)
1365 def _AddElement(self, unit: Unit) -> None:
1366 """
1367 Add a unit, which names this unit as its parent.
1369 :param unit: The unit.
1370 :raises DuplicateUnitError: If this unit already contains a unit of this name.
1371 """
1372 if unit._name in self._units: 1372 ↛ 1373line 1372 didn't jump to line 1373 because the condition on line 1372 was never true
1373 raise DuplicateUnitError(f"Unit '{unit._name}' is added twice to '{self._name}'.")
1375 self._units[unit._name] = unit
1377 def _SetRoot(self, root: Nullable[CoverageSummary]) -> None:
1378 """
1379 Set the report's root of this unit, and of the units it contains.
1381 :param root: The report's root, or ``None`` while the unit isn't part of a report.
1382 """
1383 super()._SetRoot(root)
1384 for unit in self._units.values():
1385 unit._SetRoot(root)
1387 @readonly
1388 def Name(self) -> str:
1389 """
1390 Read-only property to access the unit's name (:attr:`_name`).
1392 :returns: The name.
1393 """
1394 return self._name
1396 @readonly
1397 def QualifiedName(self) -> str:
1398 """
1399 Read-only property to return the names of the units from the top down to this one, joined by ``.``.
1401 A method ``Area`` of a class ``Circle`` in a module ``myPackage.Shapes`` has the qualified name
1402 ``myPackage.Shapes.Circle.Area``.
1404 :returns: The qualified name.
1405 """
1406 if isinstance(self._parent, Unit):
1407 return f"{self._parent.QualifiedName}.{self._name}"
1409 return self._name
1411 @readonly
1412 def Units(self) -> dict[str, Unit]:
1413 """
1414 Read-only property to access the units this one contains (:attr:`_units`).
1416 :returns: The units, by name.
1417 """
1418 return self._units
1420 @readonly
1421 def File(self) -> Nullable[File]:
1422 """
1423 Read-only property to access the source file the unit is in (:attr:`_file`).
1425 :returns: The file, or ``None`` if the report doesn't say.
1426 """
1427 return self._file
1429 @readonly
1430 def StartLine(self) -> Nullable[Line]:
1431 """
1432 Read-only property to access the unit's first line (:attr:`_startLine`).
1434 :returns: The line of the unit's file, or ``None`` if the report doesn't say.
1435 """
1436 return self._startLine
1438 @readonly
1439 def EndLine(self) -> Nullable[Line]:
1440 """
1441 Read-only property to access the unit's last line (:attr:`_endLine`).
1443 :returns: The line of the unit's file, or ``None`` if the report doesn't say.
1444 """
1445 return self._endLine
1447 def IterateLines(self) -> Generator[Line, None, None]:
1448 """
1449 Iterate the executable lines of this unit's file from its first to its last line, both included.
1451 :returns: A generator of the lines; nothing, if the unit has no file, or no first or last line.
1452 """
1453 if self._file is not None and self._startLine is not None and self._endLine is not None:
1454 yield from self._file.IterateLines(self._startLine, self._endLine)
1456 def IterateUnits(self, predicate: Nullable[Callable[[Unit], bool]] = None) -> Generator[Unit, None, None]:
1457 """
1458 Iterate this unit and the units it contains, all or selected ones.
1460 This unit comes first, then the units it contains, each level in the order the units were added. If parameter
1461 ``predicate`` is not None, the given filter function is used to skip units in the generator. A skipped unit's units
1462 are still iterated.
1464 :param predicate: Optional, filter function accepting any unit and returning a boolean. Default: ``None``.
1465 :returns: A generator of the units.
1466 """
1467 if predicate is None:
1468 yield self
1469 for unit in self._units.values():
1470 yield from unit.IterateUnits()
1471 else:
1472 if predicate(self):
1473 yield self
1475 for unit in self._units.values():
1476 yield from unit.IterateUnits(predicate)
1478 def Aggregate(self) -> None:
1479 """
1480 Aggregate the units this one contains, then compute the counters from the lines this unit spans.
1482 A unit without first and last line counts the lines the units below it span, each line once. The lines are
1483 aggregated by their files - :meth:`CoverageSummary.Aggregate` does that first.
1484 """
1485 for unit in self._units.values():
1486 unit.Aggregate()
1488 if self._startLine is not None and self._endLine is not None:
1489 self._CountLines(self.IterateLines())
1490 else:
1491 lines: dict[int, Line] = {}
1492 for unit in self.IterateUnits():
1493 lines.update((id(line), line) for line in unit.IterateLines())
1495 self._CountLines(lines.values())
1497 def __repr__(self) -> str:
1498 """
1499 Return a representation of the unit for debugging.
1501 A method ``Area`` of class ``Circle`` in module ``Shapes`` reads ``<Function Shapes.Circle.Area: 100.0%>``.
1503 :returns: The unit's kind, qualified name and line coverage.
1504 """
1505 return f"<{getTypeName(self)} {self.QualifiedName}: {self.LineCoverage:.1%}>"
1508Unit._PARENT_TYPE = (Unit, CoverageSummary)
1511@export
1512class Package(Unit):
1513 """
1514 A package: a unit grouping modules.
1516 Python and Java have packages, VHDL has libraries.
1517 """
1520@export
1521class Module(Unit):
1522 """
1523 A module: a unit of a package.
1525 A Python module is one, and so are a VHDL package and a VHDL entity.
1526 """
1529@export
1530class SourceFile(Unit):
1531 """
1532 A source file as a unit, where the file is the language's unit.
1534 A C translation unit is one, and so is a Bash or TCL script.
1535 """
1538@export
1539class Class(Unit):
1540 """
1541 A class.
1543 Python, Java and C++ have classes.
1544 """
1547@export
1548class Function(Unit):
1549 """
1550 A function.
1552 Python and C have functions, VHDL has functions and procedures.
1553 """
1556@export
1557class Method(Unit):
1558 """A method of a class."""
1561@export
1562@abstractclass
1563class Document(metaclass=ExtendedType, slots=True):
1564 """
1565 A code coverage report file, which is read in two steps: analyzed, then converted.
1567 A format's document derives from this class and mixes in the root of the format's model.
1568 """
1570 _path: Path #: Path to the report file.
1571 _analysisDuration: Nullable[timedelta] #: Duration of :meth:`Analyze`, or ``None`` before it ran.
1572 _conversionDuration: Nullable[timedelta] #: Duration of :meth:`Convert`, or ``None`` before it ran.
1574 def __init__(self, reportFile: Path) -> None:
1575 """
1576 Initialize the report file.
1578 :param reportFile: Path to the report file.
1579 """
1580 self._path = reportFile
1582 self._analysisDuration = None
1583 self._conversionDuration = None
1585 @readonly
1586 def ReportFile(self) -> Path:
1587 """
1588 Read-only property to access the path to the report file (:attr:`_path`).
1590 :returns: The report file's path.
1591 """
1592 return self._path
1594 @readonly
1595 def AnalysisDuration(self) -> Nullable[timedelta]:
1596 """
1597 Read-only property to access the duration of :meth:`Analyze`: reading, parsing and validating the file
1598 (:attr:`_analysisDuration`).
1600 :returns: The duration, or ``None`` if the file wasn't analyzed yet.
1601 """
1602 return self._analysisDuration
1604 @readonly
1605 def ModelConversionDuration(self) -> Nullable[timedelta]:
1606 """
1607 Read-only property to access the duration of :meth:`Convert`: building the report format's model
1608 (:attr:`_conversionDuration`).
1610 :returns: The duration, or ``None`` if the content wasn't converted yet.
1611 """
1612 return self._conversionDuration
1614 @abstractmethod
1615 def Analyze(self) -> None:
1616 """
1617 Read, parse and validate the report file.
1618 """
1620 @abstractmethod
1621 def Convert(self) -> None:
1622 """
1623 Convert the analyzed content to the report format's model.
1624 """
1626 @abstractmethod
1627 def ToCoverageSummary(self) -> CoverageSummary:
1628 """
1629 Convert the report format's model to the common code coverage model, and aggregate it.
1631 :returns: The report's root of the common model.
1632 """