Source code for pyEDAA.Reports.CodeCoverage.LCOV

# ==================================================================================================================== #
#              _____ ____    _        _      ____                       _                                              #
#  _ __  _   _| ____|  _ \  / \      / \    |  _ \ ___ _ __   ___  _ __| |_ ___                                        #
# | '_ \| | | |  _| | | | |/ _ \    / _ \   | |_) / _ \ '_ \ / _ \| '__| __/ __|                                       #
# | |_) | |_| | |___| |_| / ___ \  / ___ \ _|  _ <  __/ |_) | (_) | |  | |_\__ \                                       #
# | .__/ \__, |_____|____/_/   \_\/_/   \_(_)_| \_\___| .__/ \___/|_|   \__|___/                                       #
# |_|    |___/                                        |_|                                                              #
# ==================================================================================================================== #
# Authors:                                                                                                             #
#   Patrick Lehmann                                                                                                    #
#                                                                                                                      #
# License:                                                                                                             #
# ==================================================================================================================== #
# Copyright 2026-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                                                                                  #
# ==================================================================================================================== #
#
"""
lcov's tracefile format: a model of the format, read from a tracefile and converted to the common model.

lcov writes the format with ``lcov --capture``; other tools write it too - e.g. ``llvm-cov export -format=lcov``,
coverage.py (``coverage lcov``) or GHDL (``ghdl coverage --format=lcov``). The format is specified by lcov's manual
page :manpage:`geninfo(1)`, section *TRACEFILE FORMAT*: a text file of records, one per line, e.g.
``DA:<line number>,<execution count>``. The format's model keeps what the tracefile states: a :class:`Document` holds
:class:`Section` records - a source file, as a test measured it -, a section :class:`Function`, :class:`Line`,
:class:`Branch` and :class:`Condition` records, and the summaries it states.

A tracefile is read by a strict line parser: an unknown, malformed or misplaced record raises an exception, which notes
the record's line number.

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

* A section's source file is a file; sections of the same file - one per test - are merged, their counts added.
* A line's execution count is its count; a branching line is partially covered, if one of its branches wasn't taken. A
  branch never evaluated - ``-`` - is uncovered without count.
* A file becomes a source file unit, its functions function units, each spanning its start to its end line.
* The format has no excluded lines, no branch targets and no units but functions; the conditions of MC/DC coverage
  have no counterpart in the common model.

.. rubric:: Example

.. code-block:: Python

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

   tracefile = Document(Path("coverage.info"), analyzeAndConvert=True)
   summary = tracefile.ToCoverageSummary()
   for file in summary.IterateFiles():
     print(f"{file.Path}: {file.LineCoverage:.1%}")
"""
from __future__                  import annotations

from pathlib                     import Path
from re                          import Pattern, compile as re_compile
from typing                      import Mapping, Optional as Nullable

from pyTooling.Common            import getFullyQualifiedName
from pyTooling.Decorators        import export, readonly
from pyTooling.MetaClasses       import ExtendedType
from pyTooling.Stopwatch         import Stopwatch

from pyEDAA.Reports.CodeCoverage import Branch as cc_Branch, CodeCoverageError, CoverageSummary, \
                                        Document as cc_Document, File as cc_File, Function as cc_Function, \
                                        Line as cc_Line, LineCoverageStatus, SourceFile as cc_SourceFile


__all__ = ["RECORD_PATTERNS", "RECORD_SYNTAX"]

#: The pattern of each record's value - the text after ``<key>:`` -, by key.
RECORD_PATTERNS: dict[str, Pattern[str]] = {
	"TN":   re_compile(r"(.*)"),
	"SF":   re_compile(r"(.+)"),
	"VER":  re_compile(r"(.+)"),
	"FN":   re_compile(r"([1-9]\d*),(?:([1-9]\d*),)?(.+)"),
	"FNDA": re_compile(r"(\d+),(.+)"),
	"FNL":  re_compile(r"(\d+),([1-9]\d*)(?:,([1-9]\d*))?"),
	"FNA":  re_compile(r"(\d+),(\d+),(.+)"),
	"FNF":  re_compile(r"(\d+)"),
	"FNH":  re_compile(r"(\d+)"),
	"BRDA": re_compile(r"([1-9]\d*),(e?)(\d+),(.+),(\d+|-)"),
	"BRF":  re_compile(r"(\d+)"),
	"BRH":  re_compile(r"(\d+)"),
	"MCDC": re_compile(r"([1-9]\d*),(\d+),([tf]),(\d+),(\d+),(.+)"),
	"MCF":  re_compile(r"(\d+)"),
	"MCH":  re_compile(r"(\d+)"),
	"DA":   re_compile(r"([1-9]\d*),(\d+)(?:,([^,\s]+))?"),
	"LF":   re_compile(r"(\d+)"),
	"LH":   re_compile(r"(\d+)"),
}

#: The syntax of each record, as :manpage:`geninfo(1)` states it, by key.
RECORD_SYNTAX: dict[str, str] = {
	"TN":            "TN:<test name>",
	"SF":            "SF:<path to the source file>",
	"VER":           "VER:<version ID>",
	"FN":            "FN:<line number of function start>,[<line number of function end>,]<function name>",
	"FNDA":          "FNDA:<execution count>,<function name>",
	"FNL":           "FNL:<index>,<line number of function start>[,<line number of function end>]",
	"FNA":           "FNA:<index>,<execution count>,<name>",
	"FNF":           "FNF:<number of functions found>",
	"FNH":           "FNH:<number of functions hit>",
	"BRDA":          "BRDA:<line number>,[<exception>]<block>,<branch>,<taken>",
	"BRF":           "BRF:<number of branches found>",
	"BRH":           "BRH:<number of branches hit>",
	"MCDC":          "MCDC:<line number>,<group size>,<sense>,<taken>,<index>,<expression>",
	"MCF":           "MCF:<number of conditions found>",
	"MCH":           "MCH:<number of conditions hit>",
	"DA":            "DA:<line number>,<execution count>[,<checksum>]",
	"LF":            "LF:<number of instrumented lines>",
	"LH":            "LH:<number of lines with a non-zero execution count>",
	"end_of_record": "end_of_record",
}


[docs] @export class Line(metaclass=ExtendedType, slots=True): """ A ``DA`` record: how often a line ran, and the line's checksum. """ _parent: Nullable[Section] #: The section the line belongs to. _number: int #: Line number. _count: int #: How often the line ran; the sum, if the section lists the line twice. _checksum: Nullable[str] #: Checksum of the line's source text, if stated.
[docs] def __init__( self, number: int, count: int, checksum: Nullable[str] = None, *, parent: Nullable[Section] = None ) -> None: """ Initialize a line, and add it to the lines of its section. :param number: Line number. :param count: How often the line ran. :param checksum: Optional, checksum of the line's source text. Default: ``None``. :param parent: Optional, the section the line belongs to; the line is added to its lines by :attr:`Number`. Default: ``None``. :raises ValueError: If parameter ``number`` is ``None``. :raises TypeError: If parameter ``number`` isn't of type :class:`int`. :raises ValueError: If parameter ``number`` is less than 1. :raises ValueError: If parameter ``count`` is ``None``. :raises TypeError: If parameter ``count`` isn't of type :class:`int`. :raises ValueError: If parameter ``count`` is negative. :raises TypeError: If parameter ``checksum`` isn't of type :class:`str`. :raises ValueError: If parameter ``checksum`` is empty. :raises TypeError: If parameter ``parent`` isn't of type :class:`Section`. :raises CodeCoverageError: If the section already has a line of this number. """ if number is None: raise ValueError("Parameter 'number' is None.") elif not isinstance(number, int): ex = TypeError("Parameter 'number' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(number)}'.") raise ex elif number < 1: ex = ValueError("Parameter 'number' is less than 1.") ex.add_note(f"Got value '{number}'.") raise ex if count is None: raise ValueError("Parameter 'count' is None.") elif not isinstance(count, int): ex = TypeError("Parameter 'count' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.") raise ex elif count < 0: ex = ValueError("Parameter 'count' is negative.") ex.add_note(f"Got value '{count}'.") raise ex if checksum is not None and not isinstance(checksum, str): ex = TypeError("Parameter 'checksum' is not of type 'str'.") ex.add_note(f"Got type '{getFullyQualifiedName(checksum)}'.") raise ex elif checksum == "": raise ValueError("Parameter 'checksum' is empty.") if parent is not None and not isinstance(parent, Section): ex = TypeError("Parameter 'parent' is not of type 'Section'.") ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") raise ex self._parent = parent self._number = number self._count = count self._checksum = checksum if parent is not None: if number in parent._lines: raise CodeCoverageError(f"Line {number} of the section of '{parent._sourceFile.as_posix()}' is added twice.") parent._lines[number] = self
@readonly def Parent(self) -> Nullable[Section]: """ Read-only property to access the section the line belongs to (:attr:`_parent`). :returns: The section; ``None`` if the line belongs to no section. """ return self._parent @readonly def Number(self) -> int: """ Read-only property to access the line number (:attr:`_number`). :returns: The line number. """ return self._number @readonly def Count(self) -> int: """ Read-only property to access how often the line ran (:attr:`_count`). :returns: The execution count. """ return self._count @readonly def Checksum(self) -> Nullable[str]: """ Read-only property to access the checksum of the line's source text (:attr:`_checksum`). lcov writes an MD5 hash in base64. :returns: The checksum, or ``None`` if not stated. """ return self._checksum
[docs] @export class Branch(metaclass=ExtendedType, slots=True): """ A ``BRDA`` record: a branch of a line, and how often it was taken. """ _parent: Nullable[Section] #: The section the branch belongs to. _lineNumber: int #: Line number of the branch. _block: int #: Number of the branch's block - the conditional - in its line. _expression: str #: Index or expression identifying the branch in its block. _taken: Nullable[int] #: How often the branch was taken; ``None``, if it was never evaluated. _isException: bool #: Whether the branch is taken by an exception.
[docs] def __init__( self, lineNumber: int, block: int, expression: str, taken: Nullable[int], isException: bool, *, parent: Nullable[Section] = None ) -> None: """ Initialize a branch, and append it to the branches of its section. :param lineNumber: Line number of the branch. :param block: Number of the branch's block in its line. :param expression: Index or expression identifying the branch in its block. :param taken: How often the branch was taken; ``None``, if it was never evaluated. :param isException: Whether the branch is taken by an exception. :param parent: Optional, the section the branch belongs to; the branch is appended to its branches. Default: ``None``. :raises ValueError: If parameter ``lineNumber`` is ``None``. :raises TypeError: If parameter ``lineNumber`` isn't of type :class:`int`. :raises ValueError: If parameter ``lineNumber`` is less than 1. :raises ValueError: If parameter ``block`` is ``None``. :raises TypeError: If parameter ``block`` isn't of type :class:`int`. :raises ValueError: If parameter ``block`` is negative. :raises ValueError: If parameter ``expression`` is ``None``. :raises TypeError: If parameter ``expression`` isn't of type :class:`str`. :raises ValueError: If parameter ``expression`` is empty. :raises TypeError: If parameter ``taken`` isn't of type :class:`int`. :raises ValueError: If parameter ``taken`` is negative. :raises ValueError: If parameter ``isException`` is ``None``. :raises TypeError: If parameter ``isException`` isn't of type :class:`bool`. :raises TypeError: If parameter ``parent`` isn't of type :class:`Section`. """ if lineNumber is None: raise ValueError("Parameter 'lineNumber' is None.") elif not isinstance(lineNumber, int): ex = TypeError("Parameter 'lineNumber' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(lineNumber)}'.") raise ex elif lineNumber < 1: ex = ValueError("Parameter 'lineNumber' is less than 1.") ex.add_note(f"Got value '{lineNumber}'.") raise ex if block is None: raise ValueError("Parameter 'block' is None.") elif not isinstance(block, int): ex = TypeError("Parameter 'block' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(block)}'.") raise ex elif block < 0: ex = ValueError("Parameter 'block' is negative.") ex.add_note(f"Got value '{block}'.") raise ex if expression is None: raise ValueError("Parameter 'expression' is None.") elif not isinstance(expression, str): ex = TypeError("Parameter 'expression' is not of type 'str'.") ex.add_note(f"Got type '{getFullyQualifiedName(expression)}'.") raise ex elif expression == "": raise ValueError("Parameter 'expression' is empty.") if taken is not None and not isinstance(taken, int): ex = TypeError("Parameter 'taken' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(taken)}'.") raise ex elif taken is not None and taken < 0: ex = ValueError("Parameter 'taken' is negative.") ex.add_note(f"Got value '{taken}'.") raise ex if isException is None: raise ValueError("Parameter 'isException' is None.") elif not isinstance(isException, bool): ex = TypeError("Parameter 'isException' is not of type 'bool'.") ex.add_note(f"Got type '{getFullyQualifiedName(isException)}'.") raise ex if parent is not None and not isinstance(parent, Section): ex = TypeError("Parameter 'parent' is not of type 'Section'.") ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") raise ex self._parent = parent self._lineNumber = lineNumber self._block = block self._expression = expression self._taken = taken self._isException = isException if parent is not None: parent._branches.append(self)
@readonly def Parent(self) -> Nullable[Section]: """ Read-only property to access the section the branch belongs to (:attr:`_parent`). :returns: The section; ``None`` if the branch belongs to no section. """ return self._parent @readonly def LineNumber(self) -> int: """ Read-only property to access the line number of the branch (:attr:`_lineNumber`). :returns: The line number. """ return self._lineNumber @readonly def Block(self) -> int: """ Read-only property to access the number of the branch's block in its line (:attr:`_block`). :returns: The block number, counted from 0. """ return self._block @readonly def Expression(self) -> str: """ Read-only property to access the index or expression identifying the branch in its block (:attr:`_expression`). Each tool identifies a branch its own way, e.g. GCC by an index like ``1``, coverage.py by a text like ``jump to line 8``. :returns: The identifier. """ return self._expression @readonly def Taken(self) -> Nullable[int]: """ Read-only property to access how often the branch was taken (:attr:`_taken`). :returns: The count, or ``None`` if the branch was never evaluated - stated as ``-``. """ return self._taken @readonly def IsException(self) -> bool: """ Read-only property to access whether the branch is taken by an exception (:attr:`_isException`). :returns: ``True``, if the branch is marked by the prefix ``e``. """ return self._isException
[docs] @export class Condition(metaclass=ExtendedType, slots=True): """ An ``MCDC`` record: whether the outcome of a line's expression changed, when one of its conditions changed. """ _parent: Nullable[Section] #: The section the condition belongs to. _lineNumber: int #: Line number of the expression. _groupSize: int #: Number of conditions in the expression's group. _sense: bool #: ``True`` for a change from false to true; ``False`` for the converse. _taken: int #: How often - or whether - the condition was sensitized. _index: int #: Index of the condition in its group. _expression: str #: The condition's expression.
[docs] def __init__( self, lineNumber: int, groupSize: int, sense: bool, taken: int, index: int, expression: str, *, parent: Nullable[Section] = None ) -> None: """ Initialize a condition, and append it to the conditions of its section. :param lineNumber: Line number of the expression. :param groupSize: Number of conditions in the expression's group. :param sense: ``True`` for a change of the condition from false to true; ``False`` for the converse. :param taken: How often - or whether - the condition was sensitized. :param index: Index of the condition in its group. :param expression: The condition's expression. :param parent: Optional, the section the condition belongs to; the condition is appended to its conditions. Default: ``None``. :raises ValueError: If parameter ``lineNumber`` is ``None``. :raises TypeError: If parameter ``lineNumber`` isn't of type :class:`int`. :raises ValueError: If parameter ``lineNumber`` is less than 1. :raises ValueError: If parameter ``groupSize`` is ``None``. :raises TypeError: If parameter ``groupSize`` isn't of type :class:`int`. :raises ValueError: If parameter ``groupSize`` is negative. :raises ValueError: If parameter ``sense`` is ``None``. :raises TypeError: If parameter ``sense`` isn't of type :class:`bool`. :raises ValueError: If parameter ``taken`` is ``None``. :raises TypeError: If parameter ``taken`` isn't of type :class:`int`. :raises ValueError: If parameter ``taken`` is negative. :raises ValueError: If parameter ``index`` is ``None``. :raises TypeError: If parameter ``index`` isn't of type :class:`int`. :raises ValueError: If parameter ``index`` is negative. :raises ValueError: If parameter ``expression`` is ``None``. :raises TypeError: If parameter ``expression`` isn't of type :class:`str`. :raises ValueError: If parameter ``expression`` is empty. :raises TypeError: If parameter ``parent`` isn't of type :class:`Section`. """ if lineNumber is None: raise ValueError("Parameter 'lineNumber' is None.") elif not isinstance(lineNumber, int): ex = TypeError("Parameter 'lineNumber' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(lineNumber)}'.") raise ex elif lineNumber < 1: ex = ValueError("Parameter 'lineNumber' is less than 1.") ex.add_note(f"Got value '{lineNumber}'.") raise ex if groupSize is None: raise ValueError("Parameter 'groupSize' is None.") elif not isinstance(groupSize, int): ex = TypeError("Parameter 'groupSize' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(groupSize)}'.") raise ex elif groupSize < 0: ex = ValueError("Parameter 'groupSize' is negative.") ex.add_note(f"Got value '{groupSize}'.") raise ex if sense is None: raise ValueError("Parameter 'sense' is None.") elif not isinstance(sense, bool): ex = TypeError("Parameter 'sense' is not of type 'bool'.") ex.add_note(f"Got type '{getFullyQualifiedName(sense)}'.") raise ex if taken is None: raise ValueError("Parameter 'taken' is None.") elif not isinstance(taken, int): ex = TypeError("Parameter 'taken' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(taken)}'.") raise ex elif taken < 0: ex = ValueError("Parameter 'taken' is negative.") ex.add_note(f"Got value '{taken}'.") raise ex if index is None: raise ValueError("Parameter 'index' is None.") elif not isinstance(index, int): ex = TypeError("Parameter 'index' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(index)}'.") raise ex elif index < 0: ex = ValueError("Parameter 'index' is negative.") ex.add_note(f"Got value '{index}'.") raise ex if expression is None: raise ValueError("Parameter 'expression' is None.") elif not isinstance(expression, str): ex = TypeError("Parameter 'expression' is not of type 'str'.") ex.add_note(f"Got type '{getFullyQualifiedName(expression)}'.") raise ex elif expression == "": raise ValueError("Parameter 'expression' is empty.") if parent is not None and not isinstance(parent, Section): ex = TypeError("Parameter 'parent' is not of type 'Section'.") ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") raise ex self._parent = parent self._lineNumber = lineNumber self._groupSize = groupSize self._sense = sense self._taken = taken self._index = index self._expression = expression if parent is not None: parent._conditions.append(self)
@readonly def Parent(self) -> Nullable[Section]: """ Read-only property to access the section the condition belongs to (:attr:`_parent`). :returns: The section; ``None`` if the condition belongs to no section. """ return self._parent @readonly def LineNumber(self) -> int: """ Read-only property to access the line number of the expression (:attr:`_lineNumber`). :returns: The line number. """ return self._lineNumber @readonly def GroupSize(self) -> int: """ Read-only property to access the number of conditions in the expression's group (:attr:`_groupSize`). :returns: The group size. """ return self._groupSize @readonly def Sense(self) -> bool: """ Read-only property to access the sense of the condition's change (:attr:`_sense`). :returns: ``True`` for a change from false to true - stated as ``t`` -, ``False`` for the converse - ``f``. """ return self._sense @readonly def Taken(self) -> int: """ Read-only property to access how often - or whether - the condition was sensitized (:attr:`_taken`). :returns: The count; ``0``, if the condition was never sensitized. """ return self._taken @readonly def Index(self) -> int: """ Read-only property to access the index of the condition in its group (:attr:`_index`). :returns: The index, counted from 0. """ return self._index @readonly def Expression(self) -> str: """ Read-only property to access the condition's expression (:attr:`_expression`). The text depends on the tool, e.g. GCC states ``0``. :returns: The expression. """ return self._expression
[docs] @export class Function(metaclass=ExtendedType, slots=True): """ A function: an ``FN`` record with the count of its ``FNDA`` record, or an ``FNL`` record with its ``FNA`` aliases. The aliases of a function - e.g. instances of a C++ template - share its lines. """ _parent: Nullable[Section] #: The section the function belongs to. _index: Nullable[int] #: Index of the ``FNL`` record; ``None`` for an ``FN`` record. _startLine: int #: The function's first line. _endLine: Nullable[int] #: The function's last line, if stated. _aliases: dict[str, Nullable[int]] #: The function's names, and how often each was called, if stated.
[docs] def __init__( self, startLine: int, endLine: Nullable[int], aliases: Mapping[str, Nullable[int]], index: Nullable[int] = None, *, parent: Nullable[Section] = None ) -> None: """ Initialize a function, and append it to the functions of its section. :param startLine: The function's first line. :param endLine: The function's last line, or ``None`` if not stated. :param aliases: The function's names, and how often each was called - ``None``, if not stated -; the first name is the function's name. :param index: Optional, index of the ``FNL`` record. Default: ``None``, for an ``FN`` record. :param parent: Optional, the section the function belongs to; the function is appended to its functions. Default: ``None``. :raises ValueError: If parameter ``startLine`` is ``None``. :raises TypeError: If parameter ``startLine`` isn't of type :class:`int`. :raises ValueError: If parameter ``startLine`` is less than 1. :raises TypeError: If parameter ``endLine`` isn't of type :class:`int`. :raises ValueError: If parameter ``endLine`` is less than 1. :raises ValueError: If parameter ``aliases`` is ``None``. :raises TypeError: If parameter ``aliases`` isn't a mapping. :raises ValueError: If parameter ``aliases`` is empty. :raises TypeError: If parameter ``aliases`` contains a name not of type :class:`str`. :raises ValueError: If parameter ``aliases`` contains an empty name. :raises TypeError: If parameter ``aliases`` contains a count not of type :class:`int`. :raises ValueError: If parameter ``aliases`` contains a negative count. :raises TypeError: If parameter ``index`` isn't of type :class:`int`. :raises ValueError: If parameter ``index`` is negative. :raises TypeError: If parameter ``parent`` isn't of type :class:`Section`. """ 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 endLine is not None and not isinstance(endLine, int): ex = TypeError("Parameter 'endLine' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(endLine)}'.") raise ex elif endLine is not None and endLine < 1: ex = ValueError("Parameter 'endLine' is less than 1.") ex.add_note(f"Got value '{endLine}'.") raise ex if aliases is None: raise ValueError("Parameter 'aliases' is None.") elif not isinstance(aliases, Mapping): ex = TypeError("Parameter 'aliases' is not a mapping.") ex.add_note(f"Got type '{getFullyQualifiedName(aliases)}'.") raise ex elif len(aliases) == 0: raise ValueError("Parameter 'aliases' is empty.") if index is not None and not isinstance(index, int): ex = TypeError("Parameter 'index' is not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(index)}'.") raise ex elif index is not None and index < 0: ex = ValueError("Parameter 'index' is negative.") ex.add_note(f"Got value '{index}'.") raise ex if parent is not None and not isinstance(parent, Section): ex = TypeError("Parameter 'parent' is not of type 'Section'.") ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") raise ex self._parent = parent self._index = index self._startLine = startLine self._endLine = endLine self._aliases = {} for name, count in aliases.items(): if not isinstance(name, str): ex = TypeError("Parameter 'aliases' contains a name not of type 'str'.") ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.") raise ex elif name == "": raise ValueError("Parameter 'aliases' contains an empty name.") elif count is not None and not isinstance(count, int): ex = TypeError("Parameter 'aliases' contains a count not of type 'int'.") ex.add_note(f"Got type '{getFullyQualifiedName(count)}'.") raise ex elif count is not None and count < 0: ex = ValueError("Parameter 'aliases' contains a negative count.") ex.add_note(f"Got value '{count}'.") raise ex self._aliases[name] = count if parent is not None: parent._functions.append(self)
@readonly def Parent(self) -> Nullable[Section]: """ Read-only property to access the section the function belongs to (:attr:`_parent`). :returns: The section; ``None`` if the function belongs to no section. """ return self._parent @readonly def Index(self) -> Nullable[int]: """ Read-only property to access the index of the ``FNL`` record (:attr:`_index`). :returns: The index, or ``None`` for an ``FN`` record. """ return self._index @readonly def StartLine(self) -> int: """ Read-only property to access the function's first line (:attr:`_startLine`). :returns: The line number. """ return self._startLine @readonly def EndLine(self) -> Nullable[int]: """ Read-only property to access the function's last line (:attr:`_endLine`). Not every tool states it, e.g. llvm-cov doesn't. :returns: The line number, or ``None`` if not stated. """ return self._endLine @readonly def Aliases(self) -> dict[str, Nullable[int]]: """ Read-only property to access the function's names, and how often each was called (:attr:`_aliases`). :returns: The counts by name, in the tracefile's order; a count is ``None``, if no ``FNDA`` record states it. """ return self._aliases @readonly def Name(self) -> str: """ Read-only property to return the function's name: its first alias. :returns: The name. """ return next(iter(self._aliases)) @readonly def Count(self) -> Nullable[int]: """ Read-only property to return how often the function was called: the sum over its aliases. :returns: The count, or ``None`` if no alias' count is stated. """ counts = [count for count in self._aliases.values() if count is not None] return sum(counts) if len(counts) > 0 else None
[docs] @export class Section(metaclass=ExtendedType, slots=True): """ A section from ``SF`` to ``end_of_record``: a source file's coverage, as a test measured it, and the summaries the section states. """ _parent: Nullable[Tracefile] #: The tracefile the section belongs to. _testName: str #: Name of the test, stated by the last ``TN`` record before the section. _sourceFile: Path #: Path of the source file. _version: Nullable[str] #: Version ID of the source file, if stated. _functions: list[Function] #: The functions, in the tracefile's order. _lines: dict[int, Line] #: The lines, by number. _branches: list[Branch] #: The branches, in the tracefile's order. _conditions: list[Condition] #: The conditions of MC/DC coverage, in the tracefile's order. _functionsFound: Nullable[int] #: Number of functions, as the section states it. _functionsHit: Nullable[int] #: Number of functions called, as the section states it. _branchesFound: Nullable[int] #: Number of branches, as the section states it. _branchesHit: Nullable[int] #: Number of branches taken, as the section states it. _conditionsFound: Nullable[int] #: Number of conditions, as the section states it. _conditionsHit: Nullable[int] #: Number of conditions sensitized, as the section states it. _linesFound: Nullable[int] #: Number of instrumented lines, as the section states it. _linesHit: Nullable[int] #: Number of lines, which ran, as the section states it.
[docs] def __init__(self, testName: str, sourceFile: Path, *, parent: Nullable[Tracefile] = None) -> None: """ Initialize an empty section, and append it to the sections of its tracefile. :param testName: Name of the test; empty, if not stated. :param sourceFile: Path of the source file. :param parent: Optional, the tracefile the section belongs to; the section is appended to its sections. Default: ``None``. :raises ValueError: If parameter ``testName`` is ``None``. :raises TypeError: If parameter ``testName`` isn't of type :class:`str`. :raises ValueError: If parameter ``sourceFile`` is ``None``. :raises TypeError: If parameter ``sourceFile`` isn't of type :class:`~pathlib.Path`. :raises TypeError: If parameter ``parent`` isn't of type :class:`Tracefile`. """ if testName is None: raise ValueError("Parameter 'testName' is None.") elif not isinstance(testName, str): ex = TypeError("Parameter 'testName' is not of type 'str'.") ex.add_note(f"Got type '{getFullyQualifiedName(testName)}'.") raise ex if sourceFile is None: raise ValueError("Parameter 'sourceFile' is None.") elif not isinstance(sourceFile, Path): ex = TypeError("Parameter 'sourceFile' is not of type 'Path'.") ex.add_note(f"Got type '{getFullyQualifiedName(sourceFile)}'.") raise ex if parent is not None and not isinstance(parent, Tracefile): ex = TypeError("Parameter 'parent' is not of type 'Tracefile'.") ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.") raise ex self._parent = parent self._testName = testName self._sourceFile = sourceFile self._version = None self._functions = [] self._lines = {} self._branches = [] self._conditions = [] self._functionsFound = None self._functionsHit = None self._branchesFound = None self._branchesHit = None self._conditionsFound = None self._conditionsHit = None self._linesFound = None self._linesHit = None if parent is not None: parent._sections.append(self)
@readonly def Parent(self) -> Nullable[Tracefile]: """ Read-only property to access the tracefile the section belongs to (:attr:`_parent`). :returns: The tracefile; ``None`` if the section belongs to no tracefile. """ return self._parent @readonly def TestName(self) -> str: """ Read-only property to access the name of the test (:attr:`_testName`). :returns: The name; empty, if no ``TN`` record states it. """ return self._testName @readonly def SourceFile(self) -> Path: """ Read-only property to access the path of the source file (:attr:`_sourceFile`). :returns: The path, as the tracefile states it: absolute, or relative to the directory the tool ran in; a backslash of a tracefile written on Windows is a separator. """ return self._sourceFile @readonly def Version(self) -> Nullable[str]: """ Read-only property to access the version ID of the source file (:attr:`_version`). :returns: The version ID, or ``None`` if not stated. """ return self._version @readonly def Functions(self) -> list[Function]: """ Read-only property to access the functions (:attr:`_functions`). :returns: The functions, in the tracefile's order. """ return self._functions @readonly def Lines(self) -> dict[int, Line]: """ Read-only property to access the lines (:attr:`_lines`). :returns: The lines, by number. """ return self._lines @readonly def Branches(self) -> list[Branch]: """ Read-only property to access the branches (:attr:`_branches`). :returns: The branches, in the tracefile's order. """ return self._branches @readonly def Conditions(self) -> list[Condition]: """ Read-only property to access the conditions of MC/DC coverage (:attr:`_conditions`). :returns: The conditions, in the tracefile's order. """ return self._conditions @readonly def FunctionsFound(self) -> Nullable[int]: """ Read-only property to access the number of functions, as the section states it (:attr:`_functionsFound`). :returns: The number from record ``FNF``, or ``None`` if not stated. """ return self._functionsFound @readonly def FunctionsHit(self) -> Nullable[int]: """ Read-only property to access the number of functions called, as the section states it (:attr:`_functionsHit`). :returns: The number from record ``FNH``, or ``None`` if not stated. """ return self._functionsHit @readonly def BranchesFound(self) -> Nullable[int]: """ Read-only property to access the number of branches, as the section states it (:attr:`_branchesFound`). :returns: The number from record ``BRF``, or ``None`` if not stated. """ return self._branchesFound @readonly def BranchesHit(self) -> Nullable[int]: """ Read-only property to access the number of branches taken, as the section states it (:attr:`_branchesHit`). :returns: The number from record ``BRH``, or ``None`` if not stated. """ return self._branchesHit @readonly def ConditionsFound(self) -> Nullable[int]: """ Read-only property to access the number of conditions, as the section states it (:attr:`_conditionsFound`). :returns: The number from record ``MCF``, or ``None`` if not stated. """ return self._conditionsFound @readonly def ConditionsHit(self) -> Nullable[int]: """ Read-only property to access the number of conditions sensitized, as the section states it (:attr:`_conditionsHit`). :returns: The number from record ``MCH``, or ``None`` if not stated. """ return self._conditionsHit @readonly def LinesFound(self) -> Nullable[int]: """ Read-only property to access the number of instrumented lines, as the section states it (:attr:`_linesFound`). :returns: The number from record ``LF``, or ``None`` if not stated. """ return self._linesFound @readonly def LinesHit(self) -> Nullable[int]: """ Read-only property to access the number of lines, which ran, as the section states it (:attr:`_linesHit`). :returns: The number from record ``LH``, or ``None`` if not stated. """ return self._linesHit
[docs] @export class Tracefile(metaclass=ExtendedType, mixin=True): """ A tracefile: its comments and sections. """ _comments: list[str] #: The comments, without the leading ``#``. _sections: list[Section] #: The sections, in the tracefile's order.
[docs] def __init__(self) -> None: """ Initialize an empty tracefile. """ self._comments = [] self._sections = []
@readonly def Comments(self) -> list[str]: """ Read-only property to access the comments (:attr:`_comments`). A tool writes them on request, e.g. ``lcov --comment``. :returns: The comments, without the leading ``#``. """ return self._comments @readonly def Sections(self) -> list[Section]: """ Read-only property to access the sections (:attr:`_sections`). :returns: The sections, in the tracefile's order; a source file has a section per test. """ return self._sections
[docs] @export class Document(cc_Document, Tracefile): """ An lcov tracefile: read into the format's model, and converted to the common model. """ _records: Nullable[list[tuple[int, str, str, tuple[Nullable[str], ...]]]] #: The records, after :meth:`Analyze`.
[docs] def __init__(self, tracefile: Path, analyzeAndConvert: bool = False) -> None: """ Initialize the tracefile, and optionally read it. :param tracefile: Path to the tracefile. :param analyzeAndConvert: Optional, if true, analyze the file and convert its content. Default: ``False``. """ super().__init__(tracefile) Tracefile.__init__(self) self._records = None if analyzeAndConvert: self.Analyze() self.Convert()
[docs] def Analyze(self) -> None: """ Read the tracefile and parse each line into a record: a comment, or a key and its values. Empty lines are skipped. :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 a line is no record lcov knows. |br| The exception notes the line and the known records. :raises CodeCoverageError: If a record's values don't match its syntax. |br| The exception notes the line and the record's syntax. """ if not self._path.exists(): raise CodeCoverageError(f"lcov tracefile '{self._path}' does not exist.") \ from FileNotFoundError(f"File '{self._path}' not found.") with Stopwatch() as sw: try: content = self._path.read_text(encoding="utf-8") except OSError as ex: raise CodeCoverageError(f"Couldn't read lcov tracefile '{self._path}'.") from ex except UnicodeDecodeError as ex: raise CodeCoverageError(f"lcov tracefile '{self._path}' is not UTF-8 encoded.") from ex records: list[tuple[int, str, str, tuple[Nullable[str], ...]]] = [] for lineNumber, text in enumerate(content.splitlines(), start=1): text = text.rstrip() if text == "": continue elif text.startswith("#"): records.append((lineNumber, text, "#", (text[1:], ))) continue elif text == "end_of_record": records.append((lineNumber, text, text, ())) continue key, colon, value = text.partition(":") if colon == "" or (pattern := RECORD_PATTERNS.get(key)) is None: ex = CodeCoverageError(f"Unknown record '{key}' in lcov tracefile '{self._path}'.") ex.add_note(f"Line {lineNumber}: '{text}'") ex.add_note(f"Known records: {', '.join(RECORD_SYNTAX)}") raise ex elif (match := pattern.fullmatch(value)) is None: ex = CodeCoverageError(f"Malformed record '{key}' in lcov tracefile '{self._path}'.") ex.add_note(f"Line {lineNumber}: '{text}'") ex.add_note(f"Expected '{RECORD_SYNTAX[key]}'.") raise ex records.append((lineNumber, text, key, match.groups())) self._records = records self._analysisDuration = sw.Duration
[docs] def Convert(self) -> None: """ Convert the parsed records to the format's model: comments, and sections from ``SF`` to ``end_of_record``. A ``TN`` record names the test of the sections after it. A line listed twice in a section - as geninfo may write it - counts the sum. :raises CodeCoverageError: If the tracefile was not analyzed before. |br| Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'. :raises CodeCoverageError: If a section starts before the previous one ended. |br| The exception notes the line. :raises CodeCoverageError: If a record other than a comment, ``TN`` or ``SF`` is outside of a section. |br| The exception notes the line. :raises CodeCoverageError: If a ``TN`` record is inside of a section. |br| The exception notes the line. :raises CodeCoverageError: If a section states a function name or ``FNL`` index twice. |br| The exception notes the line. :raises CodeCoverageError: If an ``FNDA`` or ``FNA`` record refers to an unknown function. |br| The exception notes the line. :raises CodeCoverageError: If an ``FNL`` record has no ``FNA`` record. |br| The exception notes the section's last line. :raises CodeCoverageError: If the last section doesn't end with ``end_of_record``. |br| The exception notes the section's first line. """ if self._records is None: ex = CodeCoverageError(f"lcov tracefile '{self._path}' needs to be read and analyzed by a line parser.") ex.add_note("Call 'Document.Analyze()' or create the document using 'Document(path, analyzeAndConvert=True)'.") raise ex with Stopwatch() as sw: testName = "" section: Nullable[Section] = None sectionLineNumber = 0 functions: list[tuple[int, Nullable[int], Nullable[int], dict[str, Nullable[int]]]] = [] functionsByName: dict[str, dict[str, Nullable[int]]] = {} functionsByIndex: dict[int, dict[str, Nullable[int]]] = {} for lineNumber, text, key, values in self._records: if key == "#": self._comments.append(values[0]) elif key == "SF": if section is not None: ex = CodeCoverageError(f"Record 'SF' inside the section of '{section._sourceFile}' in '{self._path}'.") ex.add_note(f"Line {lineNumber}: '{text}'") ex.add_note(f"End the section of line {sectionLineNumber} with 'end_of_record'.") raise ex section = Section(testName, Path(values[0].replace("\\", "/")), parent=self) sectionLineNumber = lineNumber functions = [] functionsByName = {} functionsByIndex = {} elif key == "TN": if section is not None: ex = CodeCoverageError(f"Record 'TN' inside the section of '{section._sourceFile}' in '{self._path}'.") ex.add_note(f"Line {lineNumber}: '{text}'") raise ex testName = values[0] elif section is None: ex = CodeCoverageError(f"Record '{key}' outside of a section in lcov tracefile '{self._path}'.") ex.add_note(f"Line {lineNumber}: '{text}'") ex.add_note(f"A section starts with '{RECORD_SYNTAX['SF']}'.") raise ex elif key == "end_of_record": for index, aliases in functionsByIndex.items(): if len(aliases) == 0: ex = CodeCoverageError(f"Function index {index} has no alias in the section of '{section._sourceFile}'.") ex.add_note(f"The section ends in line {lineNumber}.") ex.add_note(f"Name the function by '{RECORD_SYNTAX['FNA']}'.") raise ex for startLine, endLine, index, aliases in functions: Function(startLine, endLine, aliases, index, parent=section) section = None elif key == "DA": number = int(values[0]) count = int(values[1]) if (line := section._lines.get(number)) is None: Line(number, count, values[2], parent=section) else: line._count += count elif key == "BRDA": taken = None if values[4] == "-" else int(values[4]) Branch(int(values[0]), int(values[2]), values[3], taken, values[1] == "e", parent=section) elif key == "MCDC": Condition( int(values[0]), int(values[1]), values[2] == "t", int(values[3]), int(values[4]), values[5], parent=section ) elif key == "FN": if (name := values[2]) in functionsByName: ex = CodeCoverageError(f"Function '{name}' is stated twice in the section of '{section._sourceFile}'.") ex.add_note(f"Line {lineNumber}: '{text}'") raise ex aliases = {name: None} functions.append((int(values[0]), None if values[1] is None else int(values[1]), None, aliases)) functionsByName[name] = aliases elif key == "FNL": if (index := int(values[0])) in functionsByIndex: ex = CodeCoverageError(f"Function index {index} is stated twice in the section of '{section._sourceFile}'.") ex.add_note(f"Line {lineNumber}: '{text}'") raise ex aliases = {} functions.append((int(values[1]), None if values[2] is None else int(values[2]), index, aliases)) functionsByIndex[index] = aliases elif key == "FNDA" or key == "FNA": if key == "FNDA": count = int(values[0]) name = values[1] aliases = functionsByName.get(name) else: count = int(values[1]) name = values[2] aliases = functionsByIndex.get(int(values[0])) if aliases is None: ex = CodeCoverageError(f"Record '{key}' refers to an unknown function in '{self._path}'.") ex.add_note(f"Line {lineNumber}: '{text}'") raise ex previous = aliases.get(name) aliases[name] = count if previous is None else previous + count elif key == "VER": section._version = values[0] elif key == "FNF": section._functionsFound = int(values[0]) elif key == "FNH": section._functionsHit = int(values[0]) elif key == "BRF": section._branchesFound = int(values[0]) elif key == "BRH": section._branchesHit = int(values[0]) elif key == "MCF": section._conditionsFound = int(values[0]) elif key == "MCH": section._conditionsHit = int(values[0]) elif key == "LF": section._linesFound = int(values[0]) else: section._linesHit = int(values[0]) if section is not None: ex = CodeCoverageError(f"Missing 'end_of_record' at the end of lcov tracefile '{self._path}'.") ex.add_note(f"The section of '{section._sourceFile}' starts in line {sectionLineNumber}.") raise ex self._conversionDuration = sw.Duration
[docs] def ToCoverageSummary(self) -> CoverageSummary: """ Convert the format's model to the common model, and aggregate it. The sections of one source file - one per test - are one file: the counts of a line, of a branch and of a function are added. A line is partially covered, if one of its branches wasn't taken; a line with branches, but without ``DA`` record, has an unknown state. A file becomes a source file unit, spanning its first to its last line, its functions become function units, each spanning its start to its end line; a function without end line - e.g. from llvm-cov - names its start line only. :returns: The report's root of the common model, named after the tracefile. :raises CodeCoverageError: If a file's path runs through another file. """ summary = CoverageSummary(self._path.stem) files: dict[int, cc_File] = {} lineCounts: dict[int, dict[int, int]] = {} branchCounts: dict[int, dict[tuple[int, int, str, bool], Nullable[int]]] = {} functionCounts: dict[int, dict[str, tuple[int, Nullable[int], Nullable[int]]]] = {} for section in self._sections: file = summary.GetOrAddFile(section._sourceFile) files[id(file)] = file lines = lineCounts.setdefault(id(file), {}) for number, line in section._lines.items(): lines[number] = lines.get(number, 0) + line._count branches = branchCounts.setdefault(id(file), {}) for branch in section._branches: key = (branch._lineNumber, branch._block, branch._expression, branch._isException) taken = branches.get(key) if branch._taken is not None: branches[key] = branch._taken if taken is None else taken + branch._taken elif key not in branches: branches[key] = None functions = functionCounts.setdefault(id(file), {}) for function in section._functions: count = function.Count if (merged := functions.get(function.Name)) is not None and merged[2] is not None: count = merged[2] if count is None else merged[2] + count functions[function.Name] = (function._startLine, function._endLine, count) for fileID, file in files.items(): self._ConvertLines(file, lineCounts[fileID], branchCounts[fileID]) self._ConvertUnits(file, functionCounts[fileID], summary) summary.Aggregate() return summary
[docs] @staticmethod def _ConvertLines( file: cc_File, lineCounts: dict[int, int], branchCounts: dict[tuple[int, int, str, bool], Nullable[int]] ) -> None: """ Convert a file's merged line and branch counts to lines of the common model. :param file: The file of the common model. :param lineCounts: The execution counts, by line number. :param branchCounts: How often each branch was taken - ``None``, if never evaluated -, by line number, block, expression and exception flag. """ branchesByLine: dict[int, list[Nullable[int]]] = {} for (lineNumber, *_), taken in branchCounts.items(): branchesByLine.setdefault(lineNumber, []).append(taken) for lineNumber in sorted({*lineCounts, *branchesByLine}): takenCounts = branchesByLine.get(lineNumber, []) statuses = [ LineCoverageStatus.Covered if taken is not None and taken > 0 else LineCoverageStatus.Uncovered for taken in takenCounts ] branches = [cc_Branch(status, taken) for status, taken in zip(statuses, takenCounts)] if (count := lineCounts.get(lineNumber)) is None: status = LineCoverageStatus.Unknown elif count == 0: status = LineCoverageStatus.Uncovered elif LineCoverageStatus.Uncovered in statuses: status = LineCoverageStatus.PartiallyCovered else: status = LineCoverageStatus.Covered cc_Line(lineNumber, status, count, branches, parent=file)
[docs] @staticmethod def _ConvertUnits( file: cc_File, functionCounts: dict[str, tuple[int, Nullable[int], Nullable[int]]], summary: CoverageSummary ) -> None: """ Convert a file to a source file unit, and its functions to function units. :param file: The file of the common model. :param functionCounts: The start line, end line and count of each function, by name. :param summary: The report's root of the common model. """ lines = file._lines lastLineNumber = file._lastLineNumber sourceFile = cc_SourceFile( file.Path.as_posix(), file=file, startLine=next(file.IterateLines(), None), endLine=lines[lastLineNumber] if lastLineNumber > 0 else None, parent=summary ) for name, (start, end, count) in functionCounts.items(): if end is None: startLine = lines[start] if start <= lastLineNumber else None endLine = None else: numbers = [number for number in range(start, min(end, lastLineNumber) + 1) if lines[number] is not None] startLine = lines[numbers[0]] if len(numbers) > 0 else None endLine = lines[numbers[-1]] if len(numbers) > 0 else None if count is None: status = LineCoverageStatus.Unknown else: status = LineCoverageStatus.Covered if count > 0 else LineCoverageStatus.Uncovered cc_Function( name, file=file, startLine=startLine, endLine=endLine, status=status, coverageCount=count, parent=sourceFile )