Source code for pyEDAA.Reports.CodeCoverage.CoveragePy

# ==================================================================================================================== #
#              _____ ____    _        _      ____                       _                                              #
#  _ __  _   _| ____|  _ \  / \      / \    |  _ \ ___ _ __   ___  _ __| |_ ___                                        #
# | '_ \| | | |  _| | | | |/ _ \    / _ \   | |_) / _ \ '_ \ / _ \| '__| __/ __|                                       #
# | |_) | |_| | |___| |_| / ___ \  / ___ \ _|  _ <  __/ |_) | (_) | |  | |_\__ \                                       #
# | .__/ \__, |_____|____/_/   \_\/_/   \_(_)_| \_\___| .__/ \___/|_|   \__|___/                                       #
# |_|    |___/                                        |_|                                                              #
# ==================================================================================================================== #
# Authors:                                                                                                             #
#   Patrick Lehmann                                                                                                    #
#                                                                                                                      #
# License:                                                                                                             #
# ==================================================================================================================== #
# Copyright 2021-2026 Electronic Design Automation Abstraction (EDA²)                                                  #
#                                                                                                                      #
# Licensed under the Apache License, Version 2.0 (the "License");                                                      #
# you may not use this file except in compliance with the License.                                                     #
# You may obtain a copy of the License at                                                                              #
#                                                                                                                      #
#   http://www.apache.org/licenses/LICENSE-2.0                                                                         #
#                                                                                                                      #
# Unless required by applicable law or agreed to in writing, software                                                  #
# distributed under the License is distributed on an "AS IS" BASIS,                                                    #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.                                             #
# See the License for the specific language governing permissions and                                                  #
# limitations under the License.                                                                                       #
#                                                                                                                      #
# SPDX-License-Identifier: Apache-2.0                                                                                  #
# ==================================================================================================================== #
#
"""
coverage.py's JSON code coverage format: a model of the format, read from a report and converted to the common model.

coverage.py writes the format with ``coverage json``. A report is validated against the JSON Schema of the format
version it states (:class:`FormatVersion`), reverse-engineered from coverage.py: :file:`CoveragePy-2.schema.json` for
format 2 (coverage.py 7.4.1 to 7.5), :file:`CoveragePy-3.schema.json` for format 3 (coverage.py 7.6 and later). The
format's model keeps what the report states: a :class:`Document` holds :class:`File` records, a file - in format 3 -
:class:`Region` records of its functions and classes, and each its lines, branches and :class:`Summary`. Each
record's constructor takes typed values, so the model can be built by hand: a file or a region names its parent with
the keyword parameter ``parent`` and is added to it. Its class method ``Parse`` reads the record's JSON object.

:meth:`Document.ToCoverageSummary` converts the model to the common model of :mod:`pyEDAA.Reports.CodeCoverage`:

* A file's path is relative to the directory coverage.py ran in.
* Executed lines are covered - partially covered, if one of their branches wasn't taken -, missing lines uncovered,
  excluded lines excluded. The format has no counts.
* A branch is a pair of source and destination line; it becomes a branch of its source line, naming its target line -
  none for an exit of a function, which coverage.py states as a negative number.
* A file's directories become packages, the file a module spanning the whole file; its classes and functions - named by
  qualified names like ``Circle.Area`` - become classes, methods and functions, each spanning its ``class`` or ``def``
  line to its last line. coverage.py's own summary of a function counts the ``def`` line for the enclosing scope.

.. rubric:: Example

.. code-block:: Python

   from pathlib import Path
   from pyEDAA.Reports.CodeCoverage.CoveragePy import Document

   report = Document(Path("coverage.json"), analyzeAndConvert=True)
   summary = report.ToCoverageSummary()
   print(f"{summary.FileCount} files: {summary.Coverage:.1%}")
"""
from __future__                  import annotations

from datetime                    import datetime
from enum                        import Enum, IntEnum
from json                        import JSONDecodeError, loads
from pathlib                     import Path
from typing                      import Any, Iterable, Optional as Nullable, Self

from pyTooling.Common            import getFullyQualifiedName, readResourceFile
from pyTooling.Decorators        import export, readonly
from pyTooling.Exceptions        import ToolingException
from pyTooling.MetaClasses       import ExtendedType, abstractclass
from pyTooling.Stopwatch         import Stopwatch
from pyTooling.Versioning        import SemanticVersion

from pyEDAA.Reports              import Resources
from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, Class as cc_Class, CodeCoverageError, CoverageSummary, \
                                        Document as cc_Document, File as cc_File, Function as cc_Function, \
                                        Line as cc_Line, LineCoverageStatus, Method as cc_Method, Module as cc_Module, \
                                        Package as cc_Package, Unit as cc_Unit


__all__ = ["SCHEMAS"]

# A class with a property named like a class - ``Path``, ``Summary`` - can't name that class in the annotation of a
# field: the class body's namespace, where annotations are evaluated, binds the name to the property.
_Path = Path


[docs] @export class FormatVersion(IntEnum): """ Version of coverage.py's JSON report format, as a report's ``meta.format`` states it. coverage.py 7.4.1 added ``meta.format`` with format 2. coverage.py 7.6 and later write format 3, which adds the functions and classes of a file. A report of an earlier coverage.py states no format version and isn't supported. """ Version2 = 2 #: Format 2, written by coverage.py 7.4.1 to 7.5. Version3 = 3 #: Format 3, written by coverage.py 7.6 and later.
[docs] @classmethod def Parse(cls, value: int) -> Self: """ Convert the version, as a report's ``meta.format`` states it, to the member of that version. :param value: The version. :returns: The member of that version. :raises ValueError: If parameter ``value`` is ``None``. :raises TypeError: If parameter ``value`` is not of type :class:`int`. :raises ValueError: If parameter ``value`` is not a supported format version. """ if value is None: raise ValueError("Parameter 'value' is None.") elif not isinstance(value, int) or isinstance(value, bool): ex = TypeError("Parameter 'value' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.") raise ex elif value not in [member.value for member in cls]: ex = ValueError("Parameter 'value' is not a supported coverage.py JSON format version.") ex.add_note(f"Got value '{value}'.") ex.add_note(f"Supported format versions: {', '.join(str(member.value) for member in cls)}.") raise ex return cls(value)
SCHEMAS: dict[FormatVersion, str] = { FormatVersion.Version2: "CoveragePy-2.schema.json", FormatVersion.Version3: "CoveragePy-3.schema.json" } #: Per format version, the JSON Schema a report of that version is validated against.
[docs] @export class Summary(metaclass=ExtendedType, slots=True): """ A ``summary``: the counters coverage.py computed for the whole report, a file or a region. coverage.py measures lines. What it calls a statement (``num_statements``) is a line number: a statement spanning several lines counts by its first line, several statements on one line count once. """ _lineCount: int #: Number of executable lines, without the excluded ones. _coveredLineCount: int #: Number of executed lines. _missingLineCount: int #: Number of executable lines, which never ran. _excludedLineCount: int #: Number of excluded lines. _percentCovered: float #: Coverage of lines and branches, in percent. _branchCount: Nullable[int] #: Number of branches, if branch coverage was measured. _coveredBranchCount: Nullable[int] #: Number of taken branches, if branch coverage was measured. _partialBranchCount: Nullable[int] #: Number of branches never taken from executed lines, if branches were measured.
[docs] def __init__( self, lineCount: int, coveredLineCount: int, missingLineCount: int, excludedLineCount: int, percentCovered: float, branchCount: Nullable[int] = None, coveredBranchCount: Nullable[int] = None, partialBranchCount: Nullable[int] = None ) -> None: """ Initialize the summary from its counters. :param lineCount: Number of executable lines, without the excluded ones. :param coveredLineCount: Number of executed lines. :param missingLineCount: Number of executable lines, which never ran. :param excludedLineCount: Number of excluded lines. :param percentCovered: Coverage of lines and branches, in percent. :param branchCount: Optional, number of branches. Default: ``None`` (branch coverage wasn't measured). :param coveredBranchCount: Optional, number of taken branches. Default: ``None`` (branch coverage wasn't measured). :param partialBranchCount: Optional, number of branches never taken from executed lines. |br| Default: ``None`` (branch coverage wasn't measured). :raises ValueError: If parameter ``lineCount`` is ``None``. :raises TypeError: If parameter ``lineCount`` is not of type :class:`int`. :raises ValueError: If parameter ``lineCount`` is negative. :raises ValueError: If parameter ``coveredLineCount`` is ``None``. :raises TypeError: If parameter ``coveredLineCount`` is not of type :class:`int`. :raises ValueError: If parameter ``coveredLineCount`` is negative. :raises ValueError: If parameter ``missingLineCount`` is ``None``. :raises TypeError: If parameter ``missingLineCount`` is not of type :class:`int`. :raises ValueError: If parameter ``missingLineCount`` is negative. :raises ValueError: If parameter ``excludedLineCount`` is ``None``. :raises TypeError: If parameter ``excludedLineCount`` is not of type :class:`int`. :raises ValueError: If parameter ``excludedLineCount`` is negative. :raises ValueError: If parameter ``percentCovered`` is ``None``. :raises TypeError: If parameter ``percentCovered`` is not of type :class:`float`. :raises ValueError: If parameter ``percentCovered`` is out of range 0..100. :raises TypeError: If parameter ``branchCount`` is not of type :class:`int`. :raises ValueError: If parameter ``branchCount`` is negative. :raises TypeError: If parameter ``coveredBranchCount`` is not of type :class:`int`. :raises ValueError: If parameter ``coveredBranchCount`` is negative. :raises TypeError: If parameter ``partialBranchCount`` is not of type :class:`int`. :raises ValueError: If parameter ``partialBranchCount`` is negative. """ for name, count in ( ("lineCount", lineCount), ("coveredLineCount", coveredLineCount), ("missingLineCount", missingLineCount), ("excludedLineCount", excludedLineCount) ): if count is None: raise ValueError(f"Parameter '{name}' is None.") elif not isinstance(count, int): ex = TypeError(f"Parameter '{name}' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.") raise ex elif count < 0: ex = ValueError(f"Parameter '{name}' is negative.") ex.add_note(f"Got value '{count}'.") raise ex if percentCovered is None: raise ValueError("Parameter 'percentCovered' is None.") elif not isinstance(percentCovered, (int, float)): ex = TypeError("Parameter 'percentCovered' is not of type 'float'.") ex.add_note(f"Got type '{getFullyQualifiedName(percentCovered)}'.") raise ex elif not 0 <= percentCovered <= 100: ex = ValueError("Parameter 'percentCovered' is out of range 0..100.") ex.add_note(f"Got value '{percentCovered}'.") raise ex for name, count in ( ("branchCount", branchCount), ("coveredBranchCount", coveredBranchCount), ("partialBranchCount", partialBranchCount) ): if count is None: continue elif not isinstance(count, int): ex = TypeError(f"Parameter '{name}' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.") raise ex elif count < 0: ex = ValueError(f"Parameter '{name}' is negative.") ex.add_note(f"Got value '{count}'.") raise ex self._lineCount = lineCount self._coveredLineCount = coveredLineCount self._missingLineCount = missingLineCount self._excludedLineCount = excludedLineCount self._percentCovered = percentCovered self._branchCount = branchCount self._coveredBranchCount = coveredBranchCount self._partialBranchCount = partialBranchCount
[docs] @classmethod def Parse(cls, summary: dict[str, Any]) -> Self: """ Read a summary from its JSON object. :param summary: The JSON object ``summary`` or ``totals``. :returns: The summary. :raises ValueError: If parameter ``summary`` is ``None``. :raises TypeError: If parameter ``summary`` is not of type :class:`dict`. """ if summary is None: raise ValueError("Parameter 'summary' is None.") elif not isinstance(summary, dict): ex = TypeError("Parameter 'summary' is not of type 'dict'.") ex.add_note(f"Got type '{getFullyQualifiedName(summary)}'.") raise ex return cls( summary["num_statements"], summary["covered_lines"], summary["missing_lines"], summary["excluded_lines"], summary["percent_covered"], summary.get("num_branches"), summary.get("covered_branches"), summary.get("num_partial_branches") )
@readonly def LineCount(self) -> int: """ Read-only property to access the number of executable lines, without the excluded ones (:attr:`_lineCount`). coverage.py states it as ``num_statements``. :returns: The number of executable lines. """ return self._lineCount @readonly def CoveredLineCount(self) -> int: """ Read-only property to access the number of executed lines (:attr:`_coveredLineCount`). :returns: The number of executed lines. """ return self._coveredLineCount @readonly def MissingLineCount(self) -> int: """ Read-only property to access the number of executable lines, which never ran (:attr:`_missingLineCount`). :returns: The number of missing lines. """ return self._missingLineCount @readonly def ExcludedLineCount(self) -> int: """ Read-only property to access the number of excluded lines (:attr:`_excludedLineCount`). :returns: The number of excluded lines. """ return self._excludedLineCount @readonly def PercentCovered(self) -> float: """ Read-only property to access the coverage of lines and branches (:attr:`_percentCovered`). :returns: The coverage in percent. """ return self._percentCovered @readonly def BranchCount(self) -> Nullable[int]: """ Read-only property to access the number of branches (:attr:`_branchCount`). :returns: The number of branches, or ``None`` if branch coverage wasn't measured. """ return self._branchCount @readonly def CoveredBranchCount(self) -> Nullable[int]: """ Read-only property to access the number of taken branches (:attr:`_coveredBranchCount`). :returns: The number of taken branches, or ``None`` if branch coverage wasn't measured. """ return self._coveredBranchCount @readonly def PartialBranchCount(self) -> Nullable[int]: """ Read-only property to access the number of branches never taken from executed lines (:attr:`_partialBranchCount`). :returns: The number of branches, which were never taken although their line ran, or ``None`` if branch coverage wasn't measured. """ return self._partialBranchCount
_Summary = Summary
[docs] @export @abstractclass class Base(metaclass=ExtendedType, slots=True): """ Base-class of a file and a region: its executed, missing and excluded lines, its branches, and its summary. """ _executedLines: list[int] #: The executed lines. _missingLines: list[int] #: The lines, which never ran. _excludedLines: list[int] #: The excluded lines. _executedBranches: list[tuple[int, int]] #: The taken branches, as pairs of source and destination line. _missingBranches: list[tuple[int, int]] #: The branches never taken, as pairs of source and destination line. _summary: _Summary #: The counters coverage.py computed.
[docs] def __init__( self, summary: Summary, executedLines: Nullable[Iterable[int]] = None, missingLines: Nullable[Iterable[int]] = None, excludedLines: Nullable[Iterable[int]] = None, executedBranches: Nullable[Iterable[tuple[int, int]]] = None, missingBranches: Nullable[Iterable[tuple[int, int]]] = None ) -> None: """ Initialize the summary, the lines and the branches. :param summary: The counters coverage.py computed. :param executedLines: Optional, the executed lines. Default: ``None`` (none). :param missingLines: Optional, the lines, which never ran. Default: ``None`` (none). :param excludedLines: Optional, the excluded lines. Default: ``None`` (none). :param executedBranches: Optional, the taken branches, as pairs of source and destination line. |br| Default: ``None`` (none, or branch coverage wasn't measured). :param missingBranches: Optional, the branches never taken, as pairs of source and destination line. |br| Default: ``None`` (none, or branch coverage wasn't measured). :raises ValueError: If parameter ``summary`` is ``None``. :raises TypeError: If parameter ``summary`` is not of type :class:`Summary`. :raises TypeError: If parameter ``executedLines`` is not iterable. :raises TypeError: If an element of parameter ``executedLines`` is not of type :class:`int`. :raises TypeError: If parameter ``missingLines`` is not iterable. :raises TypeError: If an element of parameter ``missingLines`` is not of type :class:`int`. :raises TypeError: If parameter ``excludedLines`` is not iterable. :raises TypeError: If an element of parameter ``excludedLines`` is not of type :class:`int`. :raises TypeError: If parameter ``executedBranches`` is not iterable. :raises TypeError: If an element of parameter ``executedBranches`` is not a pair of :class:`int`. :raises TypeError: If parameter ``missingBranches`` is not iterable. :raises TypeError: If an element of parameter ``missingBranches`` is not a pair of :class:`int`. """ if summary is None: raise ValueError("Parameter 'summary' is None.") elif not isinstance(summary, Summary): ex = TypeError("Parameter 'summary' is not of type 'Summary'.") ex.add_note(f"Got type '{getFullyQualifiedName(summary)}'.") raise ex lineLists: list[list[int]] = [] for name, numbers in ( ("executedLines", executedLines), ("missingLines", missingLines), ("excludedLines", excludedLines) ): lineList: list[int] = [] if numbers is not None: if not isinstance(numbers, Iterable): ex = TypeError(f"Parameter '{name}' is not iterable.") ex.add_note(f"Got type '{getFullyQualifiedName(numbers)}'.") raise ex for number in numbers: if not isinstance(number, int): ex = TypeError(f"An element of parameter '{name}' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.") raise ex lineList.append(number) lineLists.append(lineList) branchLists: list[list[tuple[int, int]]] = [] for name, branches in ( ("executedBranches", executedBranches), ("missingBranches", missingBranches) ): branchList: list[tuple[int, int]] = [] if branches is not None: if not isinstance(branches, Iterable): ex = TypeError(f"Parameter '{name}' is not iterable.") ex.add_note(f"Got type '{getFullyQualifiedName(branches)}'.") raise ex for branch in branches: if not (isinstance(branch, tuple) and len(branch) == 2 and all(isinstance(line, int) for line in branch)): ex = TypeError(f"An element of parameter '{name}' is not a pair of 'int'.") ex.add_note(f"Got '{branch!r}' of type '{getFullyQualifiedName(branch)}'.") raise ex branchList.append(branch) branchLists.append(branchList) self._executedLines, self._missingLines, self._excludedLines = lineLists self._executedBranches, self._missingBranches = branchLists self._summary = summary
@readonly def ExecutedLines(self) -> list[int]: """ Read-only property to access the executed lines (:attr:`_executedLines`). :returns: The line numbers. """ return self._executedLines @readonly def MissingLines(self) -> list[int]: """ Read-only property to access the lines, which never ran (:attr:`_missingLines`). :returns: The line numbers. """ return self._missingLines @readonly def ExcludedLines(self) -> list[int]: """ Read-only property to access the excluded lines (:attr:`_excludedLines`). :returns: The line numbers. """ return self._excludedLines @readonly def ExecutedBranches(self) -> list[tuple[int, int]]: """ Read-only property to access the taken branches (:attr:`_executedBranches`). :returns: The branches as pairs of source and destination line; empty, if branch coverage wasn't measured. """ return self._executedBranches @readonly def MissingBranches(self) -> list[tuple[int, int]]: """ Read-only property to access the branches never taken (:attr:`_missingBranches`). :returns: The branches as pairs of source and destination line; empty, if branch coverage wasn't measured. """ return self._missingBranches @readonly def Summary(self) -> Summary: """ Read-only property to access the counters coverage.py computed (:attr:`_summary`). :returns: The summary. """ return self._summary @readonly def AllLines(self) -> list[int]: """ Read-only property to return the executed, missing and excluded lines. :returns: The line numbers, sorted. """ return sorted({*self._executedLines, *self._missingLines, *self._excludedLines})
[docs] @export class RegionKind(Enum): """ Kind of a region of a file: a function or a class. A file states its regions of each kind in their own JSON object: ``functions`` and ``classes``. """ Function = 1 #: A function or a method, listed in ``functions``. Class = 2 #: A class, listed in ``classes``.
[docs] @export class Region(Base): """ A function or a class of a file - in format 3 -, named by its qualified name. A method ``Area`` of a class ``Circle`` is named ``Circle.Area``. """ _parent: Nullable[File] #: The file the region belongs to. _name: str #: Qualified name of the function or class. _kind: RegionKind #: Kind of the region: a function or a class. _startLine: int #: The region's first line.
[docs] def __init__( self, name: str, kind: RegionKind, startLine: int, summary: Summary, executedLines: Nullable[Iterable[int]] = None, missingLines: Nullable[Iterable[int]] = None, excludedLines: Nullable[Iterable[int]] = None, executedBranches: Nullable[Iterable[tuple[int, int]]] = None, missingBranches: Nullable[Iterable[tuple[int, int]]] = None, *, parent: Nullable[File] = None ) -> None: """ Initialize the region from its name, its kind, its first line, its summary, its lines and its branches, and add it to the functions or classes of its file. :param name: Qualified name of the function or class, e.g. ``Circle.Area``. :param kind: Kind of the region: a function or a class. :param startLine: The region's first line. :param summary: The counters coverage.py computed. :param executedLines: Optional, the executed lines. Default: ``None`` (none). :param missingLines: Optional, the lines, which never ran. Default: ``None`` (none). :param excludedLines: Optional, the excluded lines. Default: ``None`` (none). :param executedBranches: Optional, the taken branches, as pairs of source and destination line. |br| Default: ``None`` (none, or branch coverage wasn't measured). :param missingBranches: Optional, the branches never taken, as pairs of source and destination line. |br| Default: ``None`` (none, or branch coverage wasn't measured). :param parent: Optional, the file the region belongs to; the region is added to its functions or classes by :attr:`Name`, as its :attr:`Kind` says. Default: ``None``. :raises ValueError: If parameter ``name`` is ``None``. :raises TypeError: If parameter ``name`` is not of type :class:`str`. :raises ValueError: If parameter ``name`` is empty. :raises ValueError: If parameter ``kind`` is ``None``. :raises TypeError: If parameter ``kind`` is not of type :class:`RegionKind`. :raises ValueError: If parameter ``startLine`` is ``None``. :raises TypeError: If parameter ``startLine`` is not of type :class:`int`. :raises ValueError: If parameter ``startLine`` is less than 1. :raises TypeError: If parameter ``parent`` is not of type :class:`File`. :raises ValueError: If parameter ``parent`` contains a region of the same kind and name already. """ super().__init__(summary, executedLines, missingLines, excludedLines, executedBranches, missingBranches) if name is None: raise ValueError("Parameter 'name' is None.") elif not isinstance(name, str): ex = TypeError("Parameter 'name' is not of type 'str'.") ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") raise ex elif name == "": raise ValueError("Parameter 'name' is empty.") if kind is None: raise ValueError("Parameter 'kind' is None.") elif not isinstance(kind, RegionKind): ex = TypeError("Parameter 'kind' is not of type 'RegionKind'.") ex.add_note(f"Got type '{getFullyQualifiedName(kind)}'.") raise ex if startLine is None: raise ValueError("Parameter 'startLine' is None.") elif not isinstance(startLine, int): ex = TypeError("Parameter 'startLine' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(startLine)}'.") raise ex elif startLine < 1: ex = ValueError("Parameter 'startLine' is less than 1.") ex.add_note(f"Got value '{startLine}'.") raise ex if parent is not None: if not isinstance(parent, File): ex = TypeError("Parameter 'parent' is not of type 'File'.") ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") raise ex regions = parent._functions if kind is RegionKind.Function else parent._classes if name in regions: raise ValueError(f"Parameter 'parent' contains {kind.name.lower()} '{name}' already.") self._parent = parent self._name = name self._kind = kind self._startLine = startLine if parent is not None: regions[name] = self
[docs] @classmethod def Parse(cls, name: str, kind: RegionKind, region: dict[str, Any], *, parent: Nullable[File] = None) -> Self: """ Read a region from its JSON object. :param name: Qualified name of the function or class: the region's key in ``functions`` or ``classes``. :param kind: Kind of the region: :attr:`RegionKind.Function` in ``functions``, :attr:`RegionKind.Class` in ``classes``. :param region: The JSON object of the region. :param parent: Optional, the file the region belongs to. Default: ``None``. :returns: The region. :raises ValueError: If parameter ``region`` is ``None``. :raises TypeError: If parameter ``region`` is not of type :class:`dict`. """ if region is None: raise ValueError("Parameter 'region' is None.") elif not isinstance(region, dict): ex = TypeError("Parameter 'region' is not of type 'dict'.") ex.add_note(f"Got type '{getFullyQualifiedName(region)}'.") raise ex return cls( name, kind, region["start_line"], Summary.Parse(region["summary"]), region["executed_lines"], region["missing_lines"], region["excluded_lines"], (tuple(branch) for branch in region.get("executed_branches", [])), (tuple(branch) for branch in region.get("missing_branches", [])), parent=parent )
@readonly def Parent(self) -> Nullable[File]: """ Read-only property to access the file the region belongs to (:attr:`_parent`). :returns: The file; ``None`` if the region belongs to no file. """ return self._parent @readonly def Name(self) -> str: """ Read-only property to access the qualified name of the function or class (:attr:`_name`). :returns: The qualified name. """ return self._name @readonly def Kind(self) -> RegionKind: """ Read-only property to access the kind of the region (:attr:`_kind`). :returns: The kind: a function or a class. """ return self._kind @readonly def StartLine(self) -> int: """ Read-only property to access the region's first line (:attr:`_startLine`). :returns: The line number. """ return self._startLine
[docs] @export class File(Base): """ A measured file: its lines, branches and summary, and - in format 3 - its functions and classes. The regions named ``""`` - the lines outside of every function or class - aren't kept: :meth:`Parse` skips them. """ _parent: Nullable[Report] #: The report the file belongs to. _path: _Path #: The file's path, relative to the directory coverage.py ran in. _functions: dict[str, Region] #: The functions, by qualified name. _classes: dict[str, Region] #: The classes, by qualified name.
[docs] def __init__( self, path: Path, summary: Summary, executedLines: Nullable[Iterable[int]] = None, missingLines: Nullable[Iterable[int]] = None, excludedLines: Nullable[Iterable[int]] = None, executedBranches: Nullable[Iterable[tuple[int, int]]] = None, missingBranches: Nullable[Iterable[tuple[int, int]]] = None, *, parent: Nullable[Report] = None ) -> None: """ Initialize the file from its path, its summary, its lines and its branches, and add it to the files of its report. Its functions and classes are added by creating them with this file as their parent. :param path: The file's path, relative to the directory coverage.py ran in. :param summary: The counters coverage.py computed. :param executedLines: Optional, the executed lines. Default: ``None`` (none). :param missingLines: Optional, the lines, which never ran. Default: ``None`` (none). :param excludedLines: Optional, the excluded lines. Default: ``None`` (none). :param executedBranches: Optional, the taken branches, as pairs of source and destination line. |br| Default: ``None`` (none, or branch coverage wasn't measured). :param missingBranches: Optional, the branches never taken, as pairs of source and destination line. |br| Default: ``None`` (none, or branch coverage wasn't measured). :param parent: Optional, the report the file belongs to; the file is added to its files by :attr:`Path`. Default: ``None``. :raises ValueError: If parameter ``path`` is ``None``. :raises TypeError: If parameter ``path`` is not of type :class:`~pathlib.Path`. :raises TypeError: If parameter ``parent`` is not of type :class:`Report`. :raises ValueError: If parameter ``parent`` contains a file of the same path already. """ super().__init__(summary, executedLines, missingLines, excludedLines, executedBranches, missingBranches) if path is None: raise ValueError("Parameter 'path' is None.") elif not isinstance(path, Path): ex = TypeError("Parameter 'path' is not of type 'Path'.") ex.add_note(f"Got type '{getFullyQualifiedName(path)}'.") raise ex if parent is not None: if not isinstance(parent, Report): ex = TypeError("Parameter 'parent' is not of type 'Report'.") ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") raise ex elif path in parent._files: raise ValueError(f"Parameter 'parent' contains file '{path.as_posix()}' already.") self._parent = parent self._path = path self._functions = {} self._classes = {} if parent is not None: parent._files[path] = self
[docs] @classmethod def Parse(cls, name: str, file: dict[str, Any], *, parent: Nullable[Report] = None) -> Self: """ Read a file, its functions and classes from its JSON object. The regions named ``""`` - the lines outside of every function or class - are skipped. :param name: The file's path as the report states it - its key in ``files`` -, relative to the directory coverage.py ran in; ``\\`` separators become ``/``. :param file: The JSON object of the file. :param parent: Optional, the report the file belongs to. Default: ``None``. :returns: The file. :raises ValueError: If parameter ``name`` is ``None``. :raises TypeError: If parameter ``name`` is not of type :class:`str`. :raises ValueError: If parameter ``file`` is ``None``. :raises TypeError: If parameter ``file`` is not of type :class:`dict`. :raises CodeCoverageError: If the report names the file's path twice. """ if name is None: raise ValueError("Parameter 'name' is None.") elif not isinstance(name, str): ex = TypeError("Parameter 'name' is not of type 'str'.") ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") raise ex if file is None: raise ValueError("Parameter 'file' is None.") elif not isinstance(file, dict): ex = TypeError("Parameter 'file' is not of type 'dict'.") ex.add_note(f"Got type '{getFullyQualifiedName(file)}'.") raise ex path = Path(name.replace("\\", "/")) if parent is not None and path in parent._files: raise CodeCoverageError(f"coverage.py report names file '{path.as_posix()}' twice.") measuredFile = cls( path, Summary.Parse(file["summary"]), file["executed_lines"], file["missing_lines"], file["excluded_lines"], (tuple(branch) for branch in file.get("executed_branches", [])), (tuple(branch) for branch in file.get("missing_branches", [])), parent=parent ) for kind, regions in ((RegionKind.Function, "functions"), (RegionKind.Class, "classes")): for regionName, region in file.get(regions, {}).items(): if regionName != "": Region.Parse(regionName, kind, region, parent=measuredFile) return measuredFile
@readonly def Parent(self) -> Nullable[Report]: """ Read-only property to access the report the file belongs to (:attr:`_parent`). :returns: The report; ``None`` if the file belongs to no report. """ return self._parent @readonly def Path(self) -> Path: """ Read-only property to access the file's path (:attr:`_path`). :returns: The path, relative to the directory coverage.py ran in. """ return self._path @readonly def Functions(self) -> dict[str, Region]: """ Read-only property to access the functions (:attr:`_functions`). :returns: The functions, by qualified name; empty in format 2. """ return self._functions @readonly def Classes(self) -> dict[str, Region]: """ Read-only property to access the classes (:attr:`_classes`). :returns: The classes, by qualified name; empty in format 2. """ return self._classes
[docs] @export class Report(metaclass=ExtendedType, mixin=True): """ The report's root: how and when it was written, the measured files, and the totals. """ _format: Nullable[FormatVersion] #: Version of the report format. _version: Nullable[SemanticVersion] #: Version of coverage.py. _timestamp: Nullable[datetime] #: Time the report was written, local time without time zone. _branchCoverage: bool #: Whether branch coverage was measured. _hasContexts: bool #: Whether the report lists the contexts of the lines. _files: dict[Path, File] #: The measured files, by path. _totals: Nullable[Summary] #: The counters of the whole report.
[docs] def __init__(self) -> None: """ Initialize an empty report. """ self._format = None self._version = None self._timestamp = None self._branchCoverage = False self._hasContexts = False self._files = {} self._totals = None
@readonly def Format(self) -> Nullable[FormatVersion]: """ Read-only property to access the version of the report format (:attr:`_format`). :returns: The version; ``None`` before the report was converted. """ return self._format @readonly def Version(self) -> Nullable[SemanticVersion]: """ Read-only property to access the version of coverage.py, which wrote the report (:attr:`_version`). The report states it as ``meta.version``, e.g. ``7.16.1``. :returns: The version; ``None`` before the report was converted. """ return self._version @readonly def Timestamp(self) -> Nullable[datetime]: """ Read-only property to access the time the report was written (:attr:`_timestamp`). :returns: The time in the writer's local time, without a time zone; ``None`` before the report was converted. """ return self._timestamp @readonly def BranchCoverage(self) -> bool: """ Read-only property to access whether branch coverage was measured (:attr:`_branchCoverage`). :returns: ``True``, if branch coverage was measured. """ return self._branchCoverage @readonly def HasContexts(self) -> bool: """ Read-only property to access whether the report lists the contexts of the lines (:attr:`_hasContexts`). coverage.py includes them, when configured with ``[json] show_contexts = True``; the report states it as ``show_contexts``. :returns: ``True``, if the files list the contexts of their lines. """ return self._hasContexts @readonly def Files(self) -> dict[Path, File]: """ Read-only property to access the measured files (:attr:`_files`). :returns: The files, by path. """ return self._files @readonly def Totals(self) -> Nullable[Summary]: """ Read-only property to access the counters of the whole report (:attr:`_totals`). :returns: The totals; ``None`` before the report was converted. """ return self._totals
[docs] @export class Document(cc_Document, Report): """ A coverage.py JSON code coverage report: read into the format's model, and converted to the common model. """ _jsonDocument: Nullable[dict[str, Any]] #: The parsed and validated JSON document, after :meth:`Analyze`.
[docs] def __init__(self, jsonReportFile: Path, analyzeAndConvert: bool = False) -> None: """ Initialize the report, and optionally read it. :param jsonReportFile: Path to the JSON file. :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``. """ super().__init__(jsonReportFile) Report.__init__(self) self._jsonDocument = None if analyzeAndConvert: self.Analyze() self.Convert()
[docs] def Analyze(self) -> None: """ Parse the JSON file, read the format version it states, and validate it against the JSON Schema of that version (:data:`SCHEMAS`). :raises CodeCoverageError: If the file doesn't exist. :raises CodeCoverageError: If the file can't be read. :raises CodeCoverageError: If the file isn't UTF-8 encoded. :raises CodeCoverageError: If the file isn't valid JSON. :raises CodeCoverageError: If the file states no supported format version. |br| The note lists the supported format versions. :raises CodeCoverageError: If the JSON Schema can't be read. :raises CodeCoverageError: If the file isn't valid according to the JSON Schema of its format version. """ if not self._path.exists(): raise CodeCoverageError(f"coverage.py report file '{self._path}' does not exist.") \ from FileNotFoundError(f"File '{self._path}' not found.") from jsonschema import Draft202012Validator with Stopwatch() as sw: try: content = self._path.read_text(encoding="utf-8") except OSError as ex: raise CodeCoverageError(f"Couldn't read coverage.py report file '{self._path}'.") from ex except UnicodeDecodeError as ex: raise CodeCoverageError(f"coverage.py report file '{self._path}' is not UTF-8 encoded.") from ex try: jsonDocument = loads(content) except JSONDecodeError as ex: raise CodeCoverageError(f"JSON syntax error in coverage.py report file '{self._path}'.") from ex meta = jsonDocument.get("meta") if isinstance(jsonDocument, dict) else None version = meta.get("format") if isinstance(meta, dict) else None try: formatVersion = FormatVersion.Parse(version) except (ValueError, TypeError) as cause: ex = CodeCoverageError(f"coverage.py report file '{self._path}' states an unsupported format version.") got = f"value '{version}'" if version is not None else "no value" ex.add_note(f"Got {got} at '/meta/format'.") ex.add_note(f"Supported format versions: {', '.join(str(member.value) for member in FormatVersion)}.") raise ex from cause schemaFile = SCHEMAS[formatVersion] try: schema = loads(readResourceFile(Resources, schemaFile)) except (ToolingException, JSONDecodeError) as ex: raise CodeCoverageError(f"Couldn't read JSON Schema '{schemaFile}' from package resources.") from ex errors = sorted(Draft202012Validator(schema).iter_errors(jsonDocument), key=lambda error: list(error.path)) if len(errors) > 0: ex = CodeCoverageError(f"Validation error for '{self._path}' using JSON Schema '{schemaFile}'.") for error in errors: ex.add_note(f"/{'/'.join(str(part) for part in error.path)}: {error.message}") raise ex self._jsonDocument = jsonDocument self._analysisDuration = sw.Duration
[docs] def Convert(self) -> None: """ Convert the parsed and validated JSON document to the format's model. :raises CodeCoverageError: If the JSON file was not analyzed before. |br| Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'. :raises CodeCoverageError: If the coverage.py version isn't a semantic version. :raises CodeCoverageError: If the timestamp isn't in ISO 8601 format. :raises CodeCoverageError: If the report names a file's path twice. """ if self._jsonDocument is None: ex = CodeCoverageError(f"coverage.py report file '{self._path}' needs to be read and analyzed by a JSON parser.") ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.") raise ex with Stopwatch() as sw: meta = self._jsonDocument["meta"] try: version = SemanticVersion.Parse(meta["version"]) except ValueError as cause: ex = CodeCoverageError(f"coverage.py report file '{self._path}' states a malformed coverage.py version.") ex.add_note(f"Got value '{meta['version']}' at '/meta/version'.") raise ex from cause try: timestamp = datetime.fromisoformat(meta["timestamp"]) except ValueError as cause: ex = CodeCoverageError(f"coverage.py report file '{self._path}' states a malformed timestamp.") ex.add_note(f"Got value '{meta['timestamp']}' at '/meta/timestamp'.") raise ex from cause self._format = FormatVersion(meta["format"]) self._version = version self._timestamp = timestamp self._branchCoverage = meta["branch_coverage"] self._hasContexts = meta["show_contexts"] self._totals = Summary.Parse(self._jsonDocument["totals"]) for name, record in self._jsonDocument["files"].items(): File.Parse(name, record, parent=self) self._conversionDuration = sw.Duration
[docs] def ToCoverageSummary(self) -> CoverageSummary: """ Convert the format's model to the common model, and aggregate it. :returns: The report's root of the common model, named after the report file. :raises CodeCoverageError: If a file's path runs through another file. """ summary = CoverageSummary(self._path.stem) for file in self._files.values(): commonFile = summary.GetOrAddFile(file._path) self._ConvertLines(file, commonFile) self._ConvertUnits(file, commonFile, summary) summary.Aggregate() return summary
[docs] @staticmethod def _ConvertLines(file: File, commonFile: cc_File) -> None: """ Convert a file's lines and branches to lines of the common model. :param file: The file of the format's model. :param commonFile: The file of the common model. """ missingSources = {source for source, _ in file._missingBranches} statuses: dict[int, LineCoverageStatus] = {} for number in file._executedLines: partial = number in missingSources statuses[number] = LineCoverageStatus.PartiallyCovered if partial else LineCoverageStatus.Covered for number in file._missingLines: statuses[number] = LineCoverageStatus.Uncovered for number in file._excludedLines: statuses[number] = LineCoverageStatus.Excluded for number in sorted(statuses): cc_Line(number, statuses[number], parent=commonFile) lines = commonFile._lines lastLineNumber = commonFile._lastLineNumber for status, arcs in ( (LineCoverageStatus.Covered, file._executedBranches), (LineCoverageStatus.Uncovered, file._missingBranches) ): for source, target in arcs: cc_Branch(status, target=lines[target] if 0 < target <= lastLineNumber else None, parent=lines[source])
[docs] @staticmethod def _ConvertUnits(file: File, commonFile: cc_File, summary: CoverageSummary) -> None: """ Convert a file to units: its directories to packages, the file to a module, its classes and functions to classes, methods and functions. :param file: The file of the format's model. :param commonFile: The file of the common model. :param summary: The report's root of the common model. """ path = file._path parent: cc_Unit | CoverageSummary = summary for part in path.parent.parts: parent = parent._units[part] if part in parent._units else cc_Package(part, parent=parent) lines = commonFile._lines lastLineNumber = commonFile._lastLineNumber module = cc_Module( path.stem, file=commonFile, startLine=next(commonFile.IterateLines(), None), endLine=lines[lastLineNumber] if lastLineNumber > 0 else None, parent=parent ) for kind, regions in ((RegionKind.Class, file._classes), (RegionKind.Function, file._functions)): for name in sorted(regions): region = regions[name] *outerNames, ownName = name.split(".") # the enclosing class or function, found by the outer names; a region outside of a known one is left out container: Nullable[cc_Unit] = module for outerName in outerNames: if (container := container._units.get(outerName)) is None: break if container is None or ownName in container._units: continue if kind is RegionKind.Class: unitClass: type[cc_Unit] = cc_Class elif isinstance(container, cc_Class): unitClass = cc_Method else: unitClass = cc_Function numbers = [ number for number in (region._startLine, *region.AllLines) if number <= lastLineNumber and lines[number] is not None ] unitClass( ownName, file=commonFile, startLine=lines[min(numbers)] if len(numbers) > 0 else None, endLine=lines[max(numbers)] if len(numbers) > 0 else None, parent=container )