diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4d2ffb9d74..f236755562 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -91,7 +91,7 @@ jobs: # # OS: Linux, Windows # Compiler: GCC-5 to GCC-14, Clang-10 to CLANG-16 - # Python: 3.9 -- 3.12, pypy3 + # Python: 3.9 -- 3.13, pypy3 # # Instead of testing all combinations, we try to achieve full coverage # across each axis. The main test matrix just represents the Python axis on diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1ef4a3839a..f72a85e6b5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -21,6 +21,8 @@ Documentation: Internal changes: +- Refactor internal data model. (:issue:`1067`) + 8.3 (19 January 2025) --------------------- diff --git a/src/gcovr/__main__.py b/src/gcovr/__main__.py index 7e361d1ccf..14b8a30bab 100644 --- a/src/gcovr/__main__.py +++ b/src/gcovr/__main__.py @@ -33,7 +33,7 @@ parse_config_file, parse_config_into_dict, ) -from .coverage import CoverageContainer +from .data_model.container import CoverageContainer from .logging import ( configure_logging, update_logging, diff --git a/src/gcovr/data_model/__init__.py b/src/gcovr/data_model/__init__.py new file mode 100644 index 0000000000..93ff08b2de --- /dev/null +++ b/src/gcovr/data_model/__init__.py @@ -0,0 +1,18 @@ +# -*- coding:utf-8 -*- + +# ************************** Copyrights and license *************************** +# +# This file is part of gcovr 8.3+main, a parsing and reporting tool for gcov. +# https://gcovr.com/en/main +# +# _____________________________________________________________________________ +# +# Copyright (c) 2013-2025 the gcovr authors +# Copyright (c) 2013 Sandia Corporation. +# Under the terms of Contract DE-AC04-94AL85000 with Sandia Corporation, +# the U.S. Government retains certain rights in this software. +# +# This software is distributed under the 3-clause BSD License. +# For more information, see the README.rst file. +# +# **************************************************************************** diff --git a/src/gcovr/data_model/container.py b/src/gcovr/data_model/container.py new file mode 100644 index 0000000000..b9fb67ea05 --- /dev/null +++ b/src/gcovr/data_model/container.py @@ -0,0 +1,328 @@ +# -*- coding:utf-8 -*- + +# ************************** Copyrights and license *************************** +# +# This file is part of gcovr 8.3+main, a parsing and reporting tool for gcov. +# https://gcovr.com/en/main +# +# _____________________________________________________________________________ +# +# Copyright (c) 2013-2025 the gcovr authors +# Copyright (c) 2013 Sandia Corporation. +# Under the terms of Contract DE-AC04-94AL85000 with Sandia Corporation, +# the U.S. Government retains certain rights in this software. +# +# This software is distributed under the 3-clause BSD License. +# For more information, see the README.rst file. +# +# **************************************************************************** + +from __future__ import annotations +import logging +import os +import re +from typing import ItemsView, Iterable, Iterator, Literal, Optional, Union, ValuesView + +from .coverage_dict import CoverageDict + +from .merging import MergeOptions + +from ..utils import commonpath, force_unix_separator + +from .coverage import FileCoverage + +from .stats import CoverageStat, DecisionCoverageStat, SummarizedStats + +LOGGER = logging.getLogger("gcovr") + + +class ContainerBase: + """Base class for coverage containers""" + + def sort_coverage( + self, + sort_key: Literal["filename", "uncovered-number", "uncovered-percent"], + sort_reverse: bool, + by_metric: Literal["line", "branch", "decision"], + filename_uses_relative_pathname: bool = False, + ) -> list[str]: + """Sort a coverage dict. + + covdata (dict): the coverage dictionary + sort_key ("filename", "uncovered-number", "uncovered-percent"): the values to sort by + sort_reverse (bool): reverse order if True + by_metric ("line", "branch", "decision"): select the metric to sort + filename_uses_relative_pathname (bool): for html, we break down a pathname to the + relative path, but not for other formats. + + returns: the sorted keys + """ + + basedir = commonpath(list(self.data.keys())) + + def key_filename(key: str) -> list[Union[int, str]]: + def convert_to_int_if_possible(text: str) -> Union[int, str]: + return int(text) if text.isdigit() else text + + key = ( + force_unix_separator( + os.path.relpath(os.path.realpath(key), os.path.realpath(basedir)) + ) + if filename_uses_relative_pathname + else key + ).casefold() + + return [ + convert_to_int_if_possible(part) for part in re.split(r"([0-9]+)", key) + ] + + def coverage_stat(key: str) -> CoverageStat: + cov: Union[FileCoverage, CoverageContainerDirectory] = self.data[key] + if by_metric == "branch": + return cov.branch_coverage() + if by_metric == "decision": + return cov.decision_coverage().to_coverage_stat + return cov.line_coverage() + + def key_num_uncovered(key: str) -> int: + stat = coverage_stat(key) + uncovered = stat.total - stat.covered + return uncovered + + def key_percent_uncovered(key: str) -> float: + stat = coverage_stat(key) + covered = stat.covered + total = stat.total + + # No branches are always put directly after (or before when reversed) + # files with 100% coverage (by assigning such files 110% coverage) + return covered / total if total > 0 else 1.1 + + if sort_key == "uncovered-number": + # First sort filename alphabetical and then by the requested key + return sorted( + sorted(self.data, key=key_filename), + key=key_num_uncovered, + reverse=sort_reverse, + ) + if sort_key == "uncovered-percent": + # First sort filename alphabetical and then by the requested key + return sorted( + sorted(self.data, key=key_filename), + key=key_percent_uncovered, + reverse=sort_reverse, + ) + + # By default, we sort by filename alphabetically + return sorted(self.data, key=key_filename, reverse=sort_reverse) + + +class CoverageContainer(ContainerBase): + """Coverage container holding all the coverage data.""" + + def __init__(self) -> None: + self.data = CoverageDict[str, FileCoverage]() + self.directories = list[CoverageContainerDirectory]() + + def __getitem__(self, key: str) -> FileCoverage: + return self.data[key] + + def __len__(self) -> int: + return len(self.data) + + def __contains__(self, key: str) -> bool: + return key in self.data + + def __iter__(self) -> Iterator[str]: + return iter(self.data) + + def values(self) -> ValuesView[FileCoverage]: + """Get the file coverage data objects.""" + return self.data.values() + + def items(self) -> ItemsView[str, FileCoverage]: + """Get the file coverage data items.""" + return self.data.items() + + def merge(self, other: CoverageContainer, options: MergeOptions) -> None: + """ + Merge CoverageContainer information and clear directory statistics. + + Do not use 'other' objects afterwards! + """ + self.directories.clear() + other.directories.clear() + self.data.merge(other.data, options, None) + + def insert_file_coverage( + self, filecov: FileCoverage, options: MergeOptions + ) -> FileCoverage: + """Add a file coverage item.""" + self.directories.clear() + key = filecov.filename + if key in self.data: + self.data[key].merge(filecov, options, None) + else: + self.data[key] = filecov + + return filecov + + @property + def stats(self) -> SummarizedStats: + """Create a coverage statistic from a coverage data object.""" + stats = SummarizedStats.new_empty() + for filecov in self.values(): + stats += filecov.stats + return stats + + @staticmethod + def _get_dirname(filename: str) -> Optional[str]: + """Get the directory name with a trailing path separator. + + >>> import os + >>> CoverageContainer._get_dirname("bar/foobar.cpp".replace("/", os.sep)).replace(os.sep, "/") + 'bar/' + >>> CoverageContainer._get_dirname("/foo/bar/A/B.cpp".replace("/", os.sep)).replace(os.sep, "/") + '/foo/bar/A/' + >>> CoverageContainer._get_dirname(os.sep) is None + True + """ + if filename == os.sep: + return None + return str(os.path.dirname(filename.rstrip(os.sep))) + os.sep + + def populate_directories( + self, sorted_keys: Iterable[str], root_filter: re.Pattern[str] + ) -> None: + r"""Populate the list of directories and add accumulated stats. + + This function will accumulate statistics such that every directory + above it will know the statistics associated with all files deep within a + directory structure. + + Args: + sorted_keys: The sorted keys for covdata + root_filter: Information about the filter used with the root directory + """ + + # Get the directory coverage + subdirs = dict[str, CoverageContainerDirectory]() + for key in sorted_keys: + filecov = self[key] + dircov: Optional[CoverageContainerDirectory] = None + dirname: Optional[str] = ( + os.path.dirname(filecov.filename) + .replace("\\", os.sep) + .replace("/", os.sep) + .rstrip(os.sep) + ) + os.sep + while dirname is not None and root_filter.search(dirname + os.sep): + if dirname not in subdirs: + subdirs[dirname] = CoverageContainerDirectory(dirname) + if dircov is None: + subdirs[dirname][filecov.filename] = filecov + else: + subdirs[dirname].data[dircov.filename] = dircov + subdirs[dircov.filename].parent_dirname = dirname + subdirs[dirname].stats += filecov.stats + dircov = subdirs[dirname] + dirname = CoverageContainer._get_dirname(dirname) + + # Replace directories where only one sub container is available + # with the content this sub container + LOGGER.debug( + "Replace directories with only one sub element with the content of this." + ) + subdirs_to_remove = set() + for dirname, covdata_dir in subdirs.items(): + # There is exact one element, replace current element with referenced element + if len(covdata_dir) == 1: + # Get the orphan item + orphan_key, orphan_value = next(iter(covdata_dir.items())) + # The only child is a File object + if isinstance(orphan_value, FileCoverage): + # Replace the reference to ourself with our content + if covdata_dir.parent_dirname is not None: + LOGGER.debug( + f"Move {orphan_key} to {covdata_dir.parent_dirname}." + ) + parent_covdata_dir = subdirs[covdata_dir.parent_dirname] + parent_covdata_dir[orphan_key] = orphan_value + del parent_covdata_dir[dirname] + subdirs_to_remove.add(dirname) + else: + LOGGER.debug( + f"Move content of {orphan_value.dirname} to {dirname}." + ) + # Replace the children with the orphan ones + covdata_dir.data = orphan_value.data + # Change the parent key of each new child element + for new_child_value in covdata_dir.values(): + if isinstance(new_child_value, CoverageContainerDirectory): + new_child_value.parent_dirname = dirname + # Mark the key for removal. + subdirs_to_remove.add(orphan_key) + + for dirname in subdirs_to_remove: + del subdirs[dirname] + + self.directories = list(subdirs.values()) + + +class CoverageContainerDirectory(ContainerBase): + """Represent coverage information about a directory.""" + + __slots__ = "dirname", "parent_dirname", "data", "stats" + + def __init__(self, dirname: str) -> None: + self.dirname: str = dirname + self.parent_dirname: Optional[str] = None + self.data = CoverageDict[str, Union[FileCoverage, CoverageContainerDirectory]]() + self.stats: SummarizedStats = SummarizedStats.new_empty() + + def __setitem__( + self, key: str, item: Union[FileCoverage, CoverageContainerDirectory] + ) -> None: + self.data[key] = item + + def __getitem__(self, key: str) -> Union[FileCoverage, CoverageContainerDirectory]: + return self.data[key] + + def __delitem__(self, key: str) -> None: + del self.data[key] + + def __len__(self) -> int: + return len(self.data) + + def values(self) -> ValuesView[Union[FileCoverage, CoverageContainerDirectory]]: + """Get the file coverage data objects.""" + return self.data.values() + + def items(self) -> ItemsView[str, Union[FileCoverage, CoverageContainerDirectory]]: + """Get the file coverage data items.""" + return self.data.items() + + def merge(self, other: CoverageContainerDirectory, options: MergeOptions) -> None: + """ + Merge CoverageContainerDirectory information and clear directory statistics. + + Do not use 'other' objects afterwards! + """ + self.data.merge(other.data, options, None) + + @property + def filename(self) -> str: + """Helpful function for when we use this DirectoryCoverage in a union with FileCoverage""" + return self.dirname + + def line_coverage(self) -> CoverageStat: + """A simple wrapper function necessary for sort_coverage().""" + return self.stats.line + + def branch_coverage(self) -> CoverageStat: + """A simple wrapper function necessary for sort_coverage().""" + return self.stats.branch + + def decision_coverage(self) -> DecisionCoverageStat: + """A simple wrapper function necessary for sort_coverage().""" + return self.stats.decision diff --git a/src/gcovr/coverage.py b/src/gcovr/data_model/coverage.py similarity index 52% rename from src/gcovr/coverage.py rename to src/gcovr/data_model/coverage.py index 80198954b9..06653a4e02 100644 --- a/src/gcovr/coverage.py +++ b/src/gcovr/data_model/coverage.py @@ -36,104 +36,13 @@ from __future__ import annotations import logging -import os -import re -from typing import ( - ItemsView, - Iterator, - Iterable, - Optional, - TypeVar, - Union, - Literal, - ValuesView, -) -from dataclasses import dataclass - -from .utils import commonpath, force_unix_separator +from typing import Optional, Union -LOGGER = logging.getLogger("gcovr") - -_T = TypeVar("_T") - - -def sort_coverage( - covdata: Union[ - dict[str, FileCoverage], - dict[str, Union[FileCoverage, CoverageContainerDirectory]], - ], - sort_key: Literal["filename", "uncovered-number", "uncovered-percent"], - sort_reverse: bool, - by_metric: Literal["line", "branch", "decision"], - filename_uses_relative_pathname: bool = False, -) -> list[str]: - """Sort a coverage dict. - - covdata (dict): the coverage dictionary - sort_key ("filename", "uncovered-number", "uncovered-percent"): the values to sort by - sort_reverse (bool): reverse order if True - by_metric ("line", "branch", "decision"): select the metric to sort - filename_uses_relative_pathname (bool): for html, we break down a pathname to the - relative path, but not for other formats. - - returns: the sorted keys - """ - - basedir = commonpath(list(covdata.keys())) - - def key_filename(key: str) -> list[Union[int, str]]: - def convert_to_int_if_possible(text: str) -> Union[int, str]: - return int(text) if text.isdigit() else text - - key = ( - force_unix_separator( - os.path.relpath(os.path.realpath(key), os.path.realpath(basedir)) - ) - if filename_uses_relative_pathname - else key - ).casefold() - - return [convert_to_int_if_possible(part) for part in re.split(r"([0-9]+)", key)] - - def coverage_stat(key: str) -> CoverageStat: - cov = covdata[key] - if by_metric == "branch": - return cov.branch_coverage() - if by_metric == "decision": - return cov.decision_coverage().to_coverage_stat - return cov.line_coverage() - - def key_num_uncovered(key: str) -> int: - stat = coverage_stat(key) - uncovered = stat.total - stat.covered - return uncovered - - def key_percent_uncovered(key: str) -> float: - stat = coverage_stat(key) - covered = stat.covered - total = stat.total - - # No branches are always put directly after (or before when reversed) - # files with 100% coverage (by assigning such files 110% coverage) - return covered / total if total > 0 else 1.1 - - if sort_key == "uncovered-number": - # First sort filename alphabetical and then by the requested key - return sorted( - sorted(covdata, key=key_filename), - key=key_num_uncovered, - reverse=sort_reverse, - ) - if sort_key == "uncovered-percent": - # First sort filename alphabetical and then by the requested key - return sorted( - sorted(covdata, key=key_filename), - key=key_percent_uncovered, - reverse=sort_reverse, - ) +from .coverage_dict import CoverageDict +from .merging import DEFAULT_MERGE_OPTIONS, GcovrMergeAssertionError, MergeOptions +from .stats import CoverageStat, DecisionCoverageStat, SummarizedStats - # By default, we sort by filename alphabetically - return sorted(covdata, key=key_filename, reverse=sort_reverse) +LOGGER = logging.getLogger("gcovr") class BranchCoverage: @@ -184,6 +93,38 @@ def __init__( self.destination_block_id = destination_block_id self.excluded = excluded + def merge( + self, + other: BranchCoverage, + _options: MergeOptions, + _context: Optional[str], + ) -> None: + """ + Merge BranchCoverage information. + + Do not use 'other' objects afterwards! + + Examples: + >>> left = BranchCoverage(1, 2) + >>> right = BranchCoverage(1, 3, False, True) + >>> right.excluded = True + >>> left.merge(right, DEFAULT_MERGE_OPTIONS, None) + >>> left.count + 5 + >>> left.fallthrough + False + >>> left.throw + True + >>> left.excluded + True + """ + + self.count += other.count + self.fallthrough |= other.fallthrough + self.throw |= other.throw + if self.excluded is True or other.excluded is True: + self.excluded = True + @property def source_block_id_or_0(self) -> int: """Get a valid block number (0) if there was no definition in GCOV file.""" @@ -235,6 +176,24 @@ def __init__( self.covered = covered self.excluded = excluded + def merge( + self, + other: CallCoverage, + _options: MergeOptions, + context: Optional[str], + ) -> CallCoverage: + """ + Merge CallCoverage information. + + Do not use 'left' or 'right' objects afterwards! + """ + if self.callno != other.callno: + raise AssertionError( + f"Call number must be equal, got {self.callno} and {other.callno} while merging {context}." + ) + self.covered |= other.covered + return self + @property def is_reportable(self) -> bool: """Return True if the call is reportable.""" @@ -282,6 +241,84 @@ def __init__( self.not_covered_false = not_covered_false self.excluded = excluded + def merge( + self, + other: ConditionCoverage, + options: MergeOptions, + context: Optional[str], + ) -> None: + """ + Merge ConditionCoverage information. + + Do not use 'other' objects afterwards! + + Examples: + >>> left = ConditionCoverage(4, 2, [1, 2], []) + >>> right = ConditionCoverage(4, 3, [2], [1, 3]) + >>> left.merge(None, DEFAULT_MERGE_OPTIONS, None) + >>> left.count + 4 + >>> left.covered + 2 + >>> left.not_covered_true + [1, 2] + >>> left.not_covered_false + [] + >>> left.merge(right, DEFAULT_MERGE_OPTIONS, None) + >>> left.count + 4 + >>> left.covered + 3 + >>> left.not_covered_true + [2] + >>> left.not_covered_false + [] + + If ``options.cond_opts.merge_condition_fold`` is set, + the two condition coverage lists can have differing counts. + The conditions are shrunk to match the lowest count + """ + + if other is not None: + if self.count != other.count: + if options.cond_opts.merge_condition_fold: + LOGGER.warning( + f"Condition counts are not equal, got {other.count} and expected {self.count}. " + f"Reducing to {min(self.count, other.count)}." + ) + if self.count > other.count: + self.not_covered_true = self.not_covered_true[ + : len(other.not_covered_true) + ] + self.not_covered_false = self.not_covered_false[ + : len(other.not_covered_false) + ] + self.count = other.count + else: + other.not_covered_true = other.not_covered_true[ + : len(self.not_covered_true) + ] + other.not_covered_false = other.not_covered_false[ + : len(self.not_covered_false) + ] + other.count = self.count + else: + raise AssertionError( + f"The number of conditions must be equal, got {other.count} and expected {self.count} while merging {context}.\n" + "\tYou can run gcovr with --merge-mode-conditions=MERGE_MODE.\n" + "\tThe available values for MERGE_MODE are described in the documentation." + ) + + self.not_covered_false = sorted( + list(set(self.not_covered_false) & set(other.not_covered_false)) + ) + self.not_covered_true = sorted( + list(set(self.not_covered_true) & set(other.not_covered_true)) + ) + self.covered = ( + self.count - len(self.not_covered_false) - len(self.not_covered_true) + ) + class DecisionCoverageUncheckable: r"""Represent coverage information about a decision.""" @@ -389,16 +426,122 @@ def __init__( raise AssertionError("count must not be a negative value.") self.name = name self.demangled_name = demangled_name - self.count = dict[int, int]({lineno: count}) - self.blocks = dict[int, float]({lineno: blocks}) - self.excluded = dict[int, bool]({lineno: excluded}) - self.start: Optional[dict[int, tuple[int, int]]] = ( - None if start is None else {lineno: start} + self.count = CoverageDict[int, int]({lineno: count}) + self.blocks = CoverageDict[int, float]({lineno: blocks}) + self.excluded = CoverageDict[int, bool]({lineno: excluded}) + self.start: Optional[CoverageDict[int, tuple[int, int]]] = ( + None + if start is None + else CoverageDict[int, tuple[int, int]]({lineno: start}) ) - self.end: Optional[dict[int, tuple[int, int]]] = ( - None if end is None else {lineno: end} + self.end: Optional[CoverageDict[int, tuple[int, int]]] = ( + None if end is None else CoverageDict[int, tuple[int, int]]({lineno: end}) ) + def merge( + self, + other: FunctionCoverage, + options: MergeOptions, + context: Optional[str], + ) -> FunctionCoverage: + """ + Merge FunctionCoverage information. + + Do not use 'left' or 'right' objects afterwards! + + Precondition: both objects must have same name and lineno. + + If ``options.func_opts.ignore_function_lineno`` is set, + the two function coverage objects can have differing line numbers. + With following flags the merge mode can be defined: + - ``options.func_opts.merge_function_use_line_zero`` + - ``options.func_opts.merge_function_use_line_min`` + - ``options.func_opts.merge_function_use_line_max`` + - ``options.func_opts.separate_function`` + """ + if self.demangled_name != other.demangled_name: + raise AssertionError("Function demangled name must be equal.") + if self.name != other.name: + raise AssertionError("Function name must be equal.") + if not options.func_opts.ignore_function_lineno: + if self.count.keys() != other.count.keys(): + lines = sorted(set([*self.count.keys(), *other.count.keys()])) + raise GcovrMergeAssertionError( + f"Got function {other.demangled_name} in {context} on multiple lines: {', '.join([str(line) for line in lines])}.\n" + "\tYou can run gcovr with --merge-mode-functions=MERGE_MODE.\n" + "\tThe available values for MERGE_MODE are described in the documentation." + ) + + # keep distinct counts for each line number + if options.func_opts.separate_function: + for lineno, count in sorted(other.count.items()): + try: + self.count[lineno] += count + except KeyError: + self.count[lineno] = count + for lineno, blocks in other.blocks.items(): + try: + # Take the maximum value for this line + if self.blocks[lineno] < blocks: + self.blocks[lineno] = blocks + except KeyError: + self.blocks[lineno] = blocks + for lineno, excluded in other.excluded.items(): + try: + self.excluded[lineno] |= excluded + except KeyError: + self.excluded[lineno] = excluded + if other.start is not None: + if self.start is None: + self.start = CoverageDict[int, tuple[int, int]]() + for lineno, start in other.start.items(): + self.start[lineno] = start + if other.end is not None: + if self.end is None: + self.end = CoverageDict[int, tuple[int, int]]() + for lineno, end in other.end.items(): + self.end[lineno] = end + return self + + right_lineno = list(other.count.keys())[0] + # merge all counts into an entry for a single line number + if right_lineno in self.count: + lineno = right_lineno + elif options.func_opts.merge_function_use_line_zero: + lineno = 0 + elif options.func_opts.merge_function_use_line_min: + lineno = min(*self.count.keys(), *other.count.keys()) + elif options.func_opts.merge_function_use_line_max: + lineno = max(*self.count.keys(), *other.count.keys()) + else: + raise AssertionError("Sanity check, unknown merge mode") + + # Overwrite data with the sum at the desired line + self.count = CoverageDict[int, int]( + {lineno: sum(self.count.values()) + sum(other.count.values())} + ) + # or the max value at the desired line + self.blocks = CoverageDict[int, float]( + {lineno: max(*self.blocks.values(), *other.blocks.values())} + ) + # or the logical or of all values + self.excluded = CoverageDict[int, bool]( + {lineno: any(self.excluded.values()) or any(other.excluded.values())} + ) + + if self.start is not None and other.start is not None: + # or the minimum start + self.start = CoverageDict[int, tuple[int, int]]( + {lineno: min(*self.start.values(), *other.start.values())} + ) + if self.end is not None and other.end is not None: + # or the maximum end + self.end = CoverageDict[int, tuple[int, int]]( + {lineno: max(*self.end.values(), *other.end.values())} + ) + + return self + class LineCoverage: r"""Represent coverage information about a line. @@ -457,10 +600,128 @@ def __init__( self.block_ids: Optional[list[int]] = block_ids self.md5: Optional[str] = md5 self.excluded: bool = excluded - self.branches = dict[int, BranchCoverage]() - self.conditions = dict[int, ConditionCoverage]() + self.branches = CoverageDict[int, BranchCoverage]() + self.conditions = CoverageDict[int, ConditionCoverage]() self.decision: Optional[DecisionCoverage] = None - self.calls = dict[int, CallCoverage]() + self.calls = CoverageDict[int, CallCoverage]() + + def merge( + self, + other: LineCoverage, + options: MergeOptions, + context: Optional[str], + ) -> LineCoverage: + """ + Merge LineCoverage information. + + Do not use 'left' or 'right' objects afterwards! + + Precondition: both objects must have same lineno. + """ + context = f"{context}:{self.lineno}" + if self.lineno != other.lineno: + raise AssertionError("Line number must be equal.") + # If both checksums exists compare them if only one exists, use it. + if self.md5 is not None and other.md5 is not None: + if self.md5 != other.md5: + raise AssertionError(f"MD5 checksum of {context} must be equal.") + elif other.md5 is not None: + self.md5 = other.md5 + + self.count += other.count + self.excluded |= other.excluded + self.branches.merge(other.branches, options, context) + self.conditions.merge(other.conditions, options, context) + self.__merge_decision(other.decision) + self.calls.merge(other.calls, options, context) + + return self + + def __merge_decision( # pylint: disable=too-many-return-statements + self, + decisioncov: Optional[DecisionCoverage], + ) -> None: + """Merge DecisionCoverage information. + + The DecisionCoverage has different states: + + - None (no known decision) + - Uncheckable (there was a decision, but it can't be analyzed properly) + - Conditional + - Switch + + If there is a conflict between different types, Uncheckable will be returned. + """ + + # The DecisionCoverage classes have long names, so abbreviate them here: + Conditional = DecisionCoverageConditional + Switch = DecisionCoverageSwitch + Uncheckable = DecisionCoverageUncheckable + + # If decision coverage is not know for one side, return the other. + if self.decision is None: + self.decision = decisioncov + elif decisioncov is None: + self.decision = Uncheckable() + # If any decision is Uncheckable, the result is Uncheckable. + elif isinstance(self.decision, Uncheckable) or isinstance( + decisioncov, Uncheckable + ): + self.decision = Uncheckable() + # Merge Conditional decisions. + elif isinstance(self.decision, Conditional) and isinstance( + decisioncov, Conditional + ): + self.decision.count_true += decisioncov.count_true + self.decision.count_false += decisioncov.count_false + # Merge Switch decisions. + elif isinstance(self.decision, Switch) and isinstance(decisioncov, Switch): + self.decision.count += decisioncov.count + else: + self.decision = Uncheckable() + + def insert_branch_coverage( + self, + key: int, + branchcov: BranchCoverage, + options: MergeOptions = DEFAULT_MERGE_OPTIONS, + ) -> None: + """Add a branch coverage item, merge if needed.""" + if key in self.branches: + self.branches[key].merge(branchcov, options, None) + else: + self.branches[key] = branchcov + + def insert_condition_coverage( + self, + key: int, + conditioncov: ConditionCoverage, + options: MergeOptions = DEFAULT_MERGE_OPTIONS, + ) -> None: + """Add a condition coverage item, merge if needed.""" + if key in self.conditions: + self.conditions[key].merge(conditioncov, options, None) + else: + self.conditions[key] = conditioncov + + def insert_decision_coverage( + self, + decisioncov: Optional[DecisionCoverage], + ) -> None: + """Add a condition coverage item, merge if needed.""" + self.__merge_decision(decisioncov) + + def insert_call_coverage( + self, + callcov: CallCoverage, + options: MergeOptions = DEFAULT_MERGE_OPTIONS, + ) -> None: + """Add a branch coverage item, merge if needed.""" + key = callcov.callno + if key in self.calls: + self.calls[key].merge(callcov, options, None) + else: + self.calls[key] = callcov @property def is_excluded(self) -> bool: @@ -570,8 +831,8 @@ def __init__( self, filename: str, data_source: Optional[Union[str, set[str]]] ) -> None: self.filename: str = filename - self.functions = dict[str, FunctionCoverage]() - self.lines = dict[int, LineCoverage]() + self.functions = CoverageDict[str, FunctionCoverage]() + self.lines = CoverageDict[int, LineCoverage]() self.data_sources = ( set[str]() if data_source is None @@ -580,6 +841,70 @@ def __init__( ) ) + def merge( + self, + other: FileCoverage, + options: MergeOptions, + context: Optional[str], + ) -> None: + """ + Merge FileCoverage information. + + Do not use 'other' objects afterwards! + + Precondition: both objects have same filename. + """ + + if self.filename != other.filename: + raise AssertionError("Filename must be equal") + if context is not None: + raise AssertionError("For a file the context must not be set.") + + try: + self.lines.merge(other.lines, options, self.filename) + self.functions.merge(other.functions, options, self.filename) + if other.data_sources: + self.data_sources.update(other.data_sources) + except AssertionError as exc: + message = [str(exc)] + if other.data_sources: + message += ( + "GCOV source files of merge source is/are:", + *[f"\t{e}" for e in sorted(other.data_sources)], + ) + if self.data_sources: + message += ( + "and of merge target is/are:", + *[f"\t{e}" for e in sorted(self.data_sources)], + ) + raise AssertionError("\n".join(message)) from None + + def insert_line_coverage( + self, + linecov: LineCoverage, + options: MergeOptions = DEFAULT_MERGE_OPTIONS, + ) -> LineCoverage: + """Add a line coverage item, merge if needed.""" + key = linecov.lineno + if key in self.lines: + self.lines[key].merge(linecov, options, None) + else: + self.lines[key] = linecov + + return self.lines[key] + + def insert_function_coverage( + self, + functioncov: FunctionCoverage, + options: MergeOptions = DEFAULT_MERGE_OPTIONS, + ) -> None: + """Add a function coverage item, merge if needed.""" + key = functioncov.name or functioncov.demangled_name + if key in self.functions: + self.functions[key].merge(functioncov, options, self.filename) + else: + self.functions[key] = functioncov + def filter_for_function(self, functioncov: FunctionCoverage) -> FileCoverage: """Get a file coverage object reduced to a single function""" if functioncov.name not in self.functions: @@ -593,11 +918,13 @@ def filter_for_function(self, functioncov: FunctionCoverage) -> FileCoverage: filecov = FileCoverage(self.filename, self.data_sources) filecov.functions[functioncov.name] = functioncov - filecov.lines = { - lineno: linecov - for lineno, linecov in self.lines.items() - if linecov.function_name == functioncov.name - } + filecov.lines = CoverageDict[int, LineCoverage]( + { + lineno: linecov + for lineno, linecov in self.lines.items() + if linecov.function_name == functioncov.name + } + ) return filecov @@ -684,338 +1011,3 @@ def call_coverage(self) -> CoverageStat: covered += 1 return CoverageStat(covered, total) - - -class CoverageContainer: - """Coverage container holding all the coverage data.""" - - def __init__(self) -> None: - self.data = dict[str, FileCoverage]() - self.directories = list[CoverageContainerDirectory]() - - def __getitem__(self, key: str) -> FileCoverage: - return self.data[key] - - def __len__(self) -> int: - return len(self.data) - - def __contains__(self, key: str) -> bool: - return key in self.data - - def __iter__(self) -> Iterator[str]: - return iter(self.data) - - def values(self) -> ValuesView[FileCoverage]: - """Get the file coverage data objects.""" - return self.data.values() - - def items(self) -> ItemsView[str, FileCoverage]: - """Get the file coverage data items.""" - return self.data.items() - - @property - def stats(self) -> SummarizedStats: - """Create a coverage statistic from a coverage data object.""" - stats = SummarizedStats.new_empty() - for filecov in self.values(): - stats += filecov.stats - return stats - - def sort_coverage( - self, - sort_key: Literal["filename", "uncovered-number", "uncovered-percent"], - sort_reverse: bool, - by_metric: Literal["line", "branch", "decision"], - filename_uses_relative_pathname: bool = False, - ) -> list[str]: - """Sort the coverage data""" - return sort_coverage( - self.data, - sort_key, - sort_reverse, - by_metric, - filename_uses_relative_pathname, - ) - - @staticmethod - def _get_dirname(filename: str) -> Optional[str]: - """Get the directory name with a trailing path separator. - - >>> import os - >>> CoverageContainer._get_dirname("bar/foobar.cpp".replace("/", os.sep)).replace(os.sep, "/") - 'bar/' - >>> CoverageContainer._get_dirname("/foo/bar/A/B.cpp".replace("/", os.sep)).replace(os.sep, "/") - '/foo/bar/A/' - >>> CoverageContainer._get_dirname(os.sep) is None - True - """ - if filename == os.sep: - return None - return str(os.path.dirname(filename.rstrip(os.sep))) + os.sep - - def populate_directories( - self, sorted_keys: Iterable[str], root_filter: re.Pattern[str] - ) -> None: - r"""Populate the list of directories and add accumulated stats. - - This function will accumulate statistics such that every directory - above it will know the statistics associated with all files deep within a - directory structure. - - Args: - sorted_keys: The sorted keys for covdata - root_filter: Information about the filter used with the root directory - """ - - # Get the directory coverage - subdirs = dict[str, CoverageContainerDirectory]() - for key in sorted_keys: - filecov = self[key] - dircov: Optional[CoverageContainerDirectory] = None - dirname: Optional[str] = ( - os.path.dirname(filecov.filename) - .replace("\\", os.sep) - .replace("/", os.sep) - .rstrip(os.sep) - ) + os.sep - while dirname is not None and root_filter.search(dirname + os.sep): - if dirname not in subdirs: - subdirs[dirname] = CoverageContainerDirectory(dirname) - if dircov is None: - subdirs[dirname][filecov.filename] = filecov - else: - subdirs[dirname].data[dircov.filename] = dircov - subdirs[dircov.filename].parent_dirname = dirname - subdirs[dirname].stats += filecov.stats - dircov = subdirs[dirname] - dirname = CoverageContainer._get_dirname(dirname) - - # Replace directories where only one sub container is available - # with the content this sub container - LOGGER.debug( - "Replace directories with only one sub element with the content of this." - ) - subdirs_to_remove = set() - for dirname, covdata_dir in subdirs.items(): - # There is exact one element, replace current element with referenced element - if len(covdata_dir) == 1: - # Get the orphan item - orphan_key, orphan_value = next(iter(covdata_dir.items())) - # The only child is a File object - if isinstance(orphan_value, FileCoverage): - # Replace the reference to ourself with our content - if covdata_dir.parent_dirname is not None: - LOGGER.debug( - f"Move {orphan_key} to {covdata_dir.parent_dirname}." - ) - parent_covdata_dir = subdirs[covdata_dir.parent_dirname] - parent_covdata_dir[orphan_key] = orphan_value - del parent_covdata_dir[dirname] - subdirs_to_remove.add(dirname) - else: - LOGGER.debug( - f"Move content of {orphan_value.dirname} to {dirname}." - ) - # Replace the children with the orphan ones - covdata_dir.data = orphan_value.data - # Change the parent key of each new child element - for new_child_value in covdata_dir.values(): - if isinstance(new_child_value, CoverageContainerDirectory): - new_child_value.parent_dirname = dirname - # Mark the key for removal. - subdirs_to_remove.add(orphan_key) - - for dirname in subdirs_to_remove: - del subdirs[dirname] - - self.directories = list(subdirs.values()) - - -class CoverageContainerDirectory: - """Represent coverage information about a directory.""" - - __slots__ = "dirname", "parent_dirname", "data", "stats" - - def __init__(self, dirname: str) -> None: - super().__init__() - self.dirname: str = dirname - self.parent_dirname: Optional[str] = None - self.data = dict[str, Union[FileCoverage, CoverageContainerDirectory]]() - self.stats: SummarizedStats = SummarizedStats.new_empty() - - def __setitem__( - self, key: str, item: Union[FileCoverage, CoverageContainerDirectory] - ) -> None: - self.data[key] = item - - def __getitem__(self, key: str) -> Union[FileCoverage, CoverageContainerDirectory]: - return self.data[key] - - def __delitem__(self, key: str) -> None: - del self.data[key] - - def __len__(self) -> int: - return len(self.data) - - def values(self) -> ValuesView[Union[FileCoverage, CoverageContainerDirectory]]: - """Get the file coverage data objects.""" - return self.data.values() - - def items(self) -> ItemsView[str, Union[FileCoverage, CoverageContainerDirectory]]: - """Get the file coverage data items.""" - return self.data.items() - - @property - def filename(self) -> str: - """Helpful function for when we use this DirectoryCoverage in a union with FileCoverage""" - return self.dirname - - def sort_coverage( - self, - sort_key: Literal["filename", "uncovered-number", "uncovered-percent"], - sort_reverse: bool, - by_metric: Literal["line", "branch", "decision"], - filename_uses_relative_pathname: bool = False, - ) -> list[str]: - """Sort the coverage data""" - return sort_coverage( - self.data, - sort_key, - sort_reverse, - by_metric, - filename_uses_relative_pathname, - ) - - def line_coverage(self) -> CoverageStat: - """A simple wrapper function necessary for sort_coverage().""" - return self.stats.line - - def branch_coverage(self) -> CoverageStat: - """A simple wrapper function necessary for sort_coverage().""" - return self.stats.branch - - def decision_coverage(self) -> DecisionCoverageStat: - """A simple wrapper function necessary for sort_coverage().""" - return self.stats.decision - - -@dataclass -class SummarizedStats: - """Data class for the summarized coverage statistics.""" - - line: CoverageStat - branch: CoverageStat - condition: CoverageStat - decision: DecisionCoverageStat - function: CoverageStat - call: CoverageStat - - @staticmethod - def new_empty() -> SummarizedStats: - """Create a empty coverage statistic.""" - return SummarizedStats( - line=CoverageStat.new_empty(), - branch=CoverageStat.new_empty(), - condition=CoverageStat.new_empty(), - decision=DecisionCoverageStat.new_empty(), - function=CoverageStat.new_empty(), - call=CoverageStat.new_empty(), - ) - - def __iadd__(self, other: SummarizedStats) -> SummarizedStats: - self.line += other.line - self.branch += other.branch - self.condition += other.condition - self.decision += other.decision - self.function += other.function - self.call += other.call - return self - - -@dataclass -class CoverageStat: - """A single coverage metric, e.g. the line coverage percentage of a file.""" - - covered: int - """How many elements were covered.""" - - total: int - """How many elements there were in total.""" - - @staticmethod - def new_empty() -> CoverageStat: - """Create a empty coverage statistic.""" - return CoverageStat(0, 0) - - @property - def percent(self) -> Optional[float]: - """Percentage of covered elements, equivalent to ``self.percent_or(None)``""" - return self.percent_or(None) - - def percent_or(self, default: _T) -> Union[float, _T]: - """Percentage of covered elements. - - Coverage is truncated to one decimal: - >>> CoverageStat(1234, 10000).percent_or("default") - 12.3 - - Coverage is capped at 99.9% unless everything is covered: - >>> CoverageStat(9999, 10000).percent_or("default") - 99.9 - >>> CoverageStat(10000, 10000).percent_or("default") - 100.0 - - If there are no elements, percentage is NaN and the default will be returned: - >>> CoverageStat(0, 0).percent_or("default") - 'default' - """ - if not self.total: - return default - - # Return 100% only if covered == total. - if self.covered == self.total: - return 100.0 - - # There is at least one uncovered item. - # Round to 1 decimal and clamp to max 99.9%. - ratio = self.covered / self.total - return min(99.9, round(ratio * 100.0, 1)) - - def __iadd__(self, other: CoverageStat) -> CoverageStat: - self.covered += other.covered - self.total += other.total - return self - - -@dataclass -class DecisionCoverageStat: - """A CoverageStat for decision coverage (accounts for Uncheckable cases).""" - - covered: int - uncheckable: int - total: int - - @classmethod - def new_empty(cls) -> DecisionCoverageStat: - """Create a empty decision coverage statistic.""" - return cls(0, 0, 0) - - @property - def to_coverage_stat(self) -> CoverageStat: - """Convert a decision coverage statistic to a coverage statistic.""" - return CoverageStat(covered=self.covered, total=self.total) - - @property - def percent(self) -> Optional[float]: - """Return the percent value of the coverage.""" - return self.to_coverage_stat.percent - - def percent_or(self, default: _T) -> Union[float, _T]: - """Return the percent value of the coverage or the given default if no coverage is present.""" - return self.to_coverage_stat.percent_or(default) - - def __iadd__(self, other: DecisionCoverageStat) -> DecisionCoverageStat: - self.covered += other.covered - self.uncheckable += other.uncheckable - self.total += other.total - return self diff --git a/src/gcovr/data_model/coverage_dict.py b/src/gcovr/data_model/coverage_dict.py new file mode 100644 index 0000000000..f634aad4e0 --- /dev/null +++ b/src/gcovr/data_model/coverage_dict.py @@ -0,0 +1,59 @@ +# -*- coding:utf-8 -*- + +# ************************** Copyrights and license *************************** +# +# This file is part of gcovr 8.3+main, a parsing and reporting tool for gcov. +# https://gcovr.com/en/main +# +# _____________________________________________________________________________ +# +# Copyright (c) 2013-2025 the gcovr authors +# Copyright (c) 2013 Sandia Corporation. +# Under the terms of Contract DE-AC04-94AL85000 with Sandia Corporation, +# the U.S. Government retains certain rights in this software. +# +# This software is distributed under the 3-clause BSD License. +# For more information, see the README.rst file. +# +# **************************************************************************** + +from __future__ import annotations +import logging +from typing import Optional, TypeVar + +from .merging import MergeOptions + +LOGGER = logging.getLogger("gcovr") + +_Key = TypeVar("_Key", int, str) +_T = TypeVar("_T") + + +class CoverageDict(dict[_Key, _T]): + """Base class for a coverage dictionary.""" + + def merge( + self, + other: CoverageDict[_Key, _T], + options: MergeOptions, + context: Optional[str], + ) -> None: + """Helper function to merge items in a dictionary.""" + + # Ensure that "self" is the larger dict, + # so that fewer items have to be checked for merging. + # FIXME: This needs to be changed, result should be independent of the order + if len(self) < len(other): + other.merge(self, options, context) + for key, item in other.items(): + self[key] = item + else: + for key, item in other.items(): + if key in self: + self[key].merge(item, options, context) + else: + self[key] = item + + # At this point, "self" contains all merged items. + # The caller should access "other" objects therefore we clear it. + other.clear() diff --git a/src/gcovr/data_model/merging.py b/src/gcovr/data_model/merging.py new file mode 100644 index 0000000000..1a529c6f0b --- /dev/null +++ b/src/gcovr/data_model/merging.py @@ -0,0 +1,141 @@ +# -*- coding:utf-8 -*- + +# ************************** Copyrights and license *************************** +# +# This file is part of gcovr 8.3+main, a parsing and reporting tool for gcov. +# https://gcovr.com/en/main +# +# _____________________________________________________________________________ +# +# Copyright (c) 2013-2025 the gcovr authors +# Copyright (c) 2013 Sandia Corporation. +# Under the terms of Contract DE-AC04-94AL85000 with Sandia Corporation, +# the U.S. Government retains certain rights in this software. +# +# This software is distributed under the 3-clause BSD License. +# For more information, see the README.rst file. +# +# **************************************************************************** + +""" +Merge coverage data. + +All of these merging function have the signature +``merge(T, T) -> T``. +That is, they take two coverage data items and combine them, +returning the combined coverage. +This may change the input objects, so that they should be used afterwards. + +In a mathematical sense, all of these ``merge()`` functions +must behave somewhat like an addition operator: + +* commutative: order of arguments must not matter, + so that ``merge(a, b)`` must match ``merge(a, b)``. +* associative: order of merging must not matter, + so that ``merge(a, merge(b, c))`` must match ``merge(merge(a, b), c)``. +* identity element: there must be an empty element, + so that ``merge(a, empty)`` and ``merge(empty, a)`` and ``a`` all match. + However, the empty state might be implied by “parent dict does not contain an entry”, + or must contain matching information like the same line number. + +The insertion functions insert a single coverage item into a larger structure, +for example inserting BranchCoverage into a LineCoverage object. +The target/parent structure is updated in-place, +otherwise this has equivalent semantics to merging. +In particular, if there already is coverage data in the target with the same ID, +then the contents are merged. +The insertion functions return the coverage structure that is saved in the target, +which may not be the same as the input value. +""" + +from dataclasses import dataclass, field +import logging + +from ..options import Options + + +LOGGER = logging.getLogger("gcovr") + + +class GcovrMergeAssertionError(AssertionError): + """Exception for data merge errors.""" + + +@dataclass +class MergeFunctionOptions: + """Data class to store the function merge options.""" + + ignore_function_lineno: bool = False + merge_function_use_line_zero: bool = False + merge_function_use_line_min: bool = False + merge_function_use_line_max: bool = False + separate_function: bool = False + + +FUNCTION_STRICT_MERGE_OPTIONS = MergeFunctionOptions() +FUNCTION_LINE_ZERO_MERGE_OPTIONS = MergeFunctionOptions( + ignore_function_lineno=True, + merge_function_use_line_zero=True, +) +FUNCTION_MIN_LINE_MERGE_OPTIONS = MergeFunctionOptions( + ignore_function_lineno=True, + merge_function_use_line_min=True, +) +FUNCTION_MAX_LINE_MERGE_OPTIONS = MergeFunctionOptions( + ignore_function_lineno=True, + merge_function_use_line_max=True, +) +SEPARATE_FUNCTION_MERGE_OPTIONS = MergeFunctionOptions( + ignore_function_lineno=True, + separate_function=True, +) + + +@dataclass +class MergeConditionOptions: + """Data class to store the condition merge options.""" + + merge_condition_fold: bool = False + + +CONDITION_STRICT_MERGE_OPTIONS = MergeConditionOptions() +CONDITION_FOLD_MERGE_OPTIONS = MergeConditionOptions( + merge_condition_fold=True, +) + + +@dataclass +class MergeOptions: + """Data class to store the merge options.""" + + func_opts: MergeFunctionOptions = field(default_factory=MergeFunctionOptions) + cond_opts: MergeConditionOptions = field(default_factory=MergeConditionOptions) + + +DEFAULT_MERGE_OPTIONS = MergeOptions() + + +def get_merge_mode_from_options(options: Options) -> MergeOptions: + """Get the function merge mode.""" + merge_opts = MergeOptions() + if options.merge_mode_functions == "strict": + merge_opts.func_opts = FUNCTION_STRICT_MERGE_OPTIONS + elif options.merge_mode_functions == "merge-use-line-0": + merge_opts.func_opts = FUNCTION_LINE_ZERO_MERGE_OPTIONS + elif options.merge_mode_functions == "merge-use-line-min": + merge_opts.func_opts = FUNCTION_MIN_LINE_MERGE_OPTIONS + elif options.merge_mode_functions == "merge-use-line-max": + merge_opts.func_opts = FUNCTION_MAX_LINE_MERGE_OPTIONS + elif options.merge_mode_functions == "separate": + merge_opts.func_opts = SEPARATE_FUNCTION_MERGE_OPTIONS + else: + raise AssertionError("Sanity check: Unknown functions merge mode.") + + if options.merge_mode_conditions == "strict": + merge_opts.cond_opts = CONDITION_STRICT_MERGE_OPTIONS + elif options.merge_mode_conditions == "fold": + merge_opts.cond_opts = CONDITION_FOLD_MERGE_OPTIONS + else: + raise AssertionError("Sanity check: Unknown conditions merge mode.") + + return merge_opts diff --git a/src/gcovr/data_model/stats.py b/src/gcovr/data_model/stats.py new file mode 100644 index 0000000000..ae0c00b796 --- /dev/null +++ b/src/gcovr/data_model/stats.py @@ -0,0 +1,153 @@ +# -*- coding:utf-8 -*- + +# ************************** Copyrights and license *************************** +# +# This file is part of gcovr 8.3+main, a parsing and reporting tool for gcov. +# https://gcovr.com/en/main +# +# _____________________________________________________________________________ +# +# Copyright (c) 2013-2025 the gcovr authors +# Copyright (c) 2013 Sandia Corporation. +# Under the terms of Contract DE-AC04-94AL85000 with Sandia Corporation, +# the U.S. Government retains certain rights in this software. +# +# This software is distributed under the 3-clause BSD License. +# For more information, see the README.rst file. +# +# **************************************************************************** + +from __future__ import annotations +import logging +from typing import ( + Optional, + TypeVar, + Union, +) +from dataclasses import dataclass + +LOGGER = logging.getLogger("gcovr") + +_T = TypeVar("_T") + + +@dataclass +class SummarizedStats: + """Data class for the summarized coverage statistics.""" + + line: CoverageStat + branch: CoverageStat + condition: CoverageStat + decision: DecisionCoverageStat + function: CoverageStat + call: CoverageStat + + @staticmethod + def new_empty() -> SummarizedStats: + """Create a empty coverage statistic.""" + return SummarizedStats( + line=CoverageStat.new_empty(), + branch=CoverageStat.new_empty(), + condition=CoverageStat.new_empty(), + decision=DecisionCoverageStat.new_empty(), + function=CoverageStat.new_empty(), + call=CoverageStat.new_empty(), + ) + + def __iadd__(self, other: SummarizedStats) -> SummarizedStats: + self.line += other.line + self.branch += other.branch + self.condition += other.condition + self.decision += other.decision + self.function += other.function + self.call += other.call + return self + + +@dataclass +class CoverageStat: + """A single coverage metric, e.g. the line coverage percentage of a file.""" + + covered: int + """How many elements were covered.""" + + total: int + """How many elements there were in total.""" + + @staticmethod + def new_empty() -> CoverageStat: + """Create a empty coverage statistic.""" + return CoverageStat(0, 0) + + @property + def percent(self) -> Optional[float]: + """Percentage of covered elements, equivalent to ``self.percent_or(None)``""" + return self.percent_or(None) + + def percent_or(self, default: _T) -> Union[float, _T]: + """Percentage of covered elements. + + Coverage is truncated to one decimal: + >>> CoverageStat(1234, 10000).percent_or("default") + 12.3 + + Coverage is capped at 99.9% unless everything is covered: + >>> CoverageStat(9999, 10000).percent_or("default") + 99.9 + >>> CoverageStat(10000, 10000).percent_or("default") + 100.0 + + If there are no elements, percentage is NaN and the default will be returned: + >>> CoverageStat(0, 0).percent_or("default") + 'default' + """ + if not self.total: + return default + + # Return 100% only if covered == total. + if self.covered == self.total: + return 100.0 + + # There is at least one uncovered item. + # Round to 1 decimal and clamp to max 99.9%. + ratio = self.covered / self.total + return min(99.9, round(ratio * 100.0, 1)) + + def __iadd__(self, other: CoverageStat) -> CoverageStat: + self.covered += other.covered + self.total += other.total + return self + + +@dataclass +class DecisionCoverageStat: + """A CoverageStat for decision coverage (accounts for Uncheckable cases).""" + + covered: int + uncheckable: int + total: int + + @classmethod + def new_empty(cls) -> DecisionCoverageStat: + """Create a empty decision coverage statistic.""" + return cls(0, 0, 0) + + @property + def to_coverage_stat(self) -> CoverageStat: + """Convert a decision coverage statistic to a coverage statistic.""" + return CoverageStat(covered=self.covered, total=self.total) + + @property + def percent(self) -> Optional[float]: + """Return the percent value of the coverage.""" + return self.to_coverage_stat.percent + + def percent_or(self, default: _T) -> Union[float, _T]: + """Return the percent value of the coverage or the given default if no coverage is present.""" + return self.to_coverage_stat.percent_or(default) + + def __iadd__(self, other: DecisionCoverageStat) -> DecisionCoverageStat: + self.covered += other.covered + self.uncheckable += other.uncheckable + self.total += other.total + return self diff --git a/src/gcovr/decision_analysis.py b/src/gcovr/decision_analysis.py index dc1493ed6c..2b66db029d 100644 --- a/src/gcovr/decision_analysis.py +++ b/src/gcovr/decision_analysis.py @@ -22,7 +22,7 @@ import logging import re -from .coverage import ( +from .data_model.coverage import ( DecisionCoverageUncheckable, DecisionCoverageConditional, DecisionCoverageSwitch, diff --git a/src/gcovr/exclusions/__init__.py b/src/gcovr/exclusions/__init__.py index 4c01fd39b7..176c913345 100644 --- a/src/gcovr/exclusions/__init__.py +++ b/src/gcovr/exclusions/__init__.py @@ -41,7 +41,7 @@ get_functions_by_line, ) -from ..coverage import FileCoverage +from ..data_model.coverage import FileCoverage from .markers import ExclusionPredicate, FunctionListByLine, apply_exclusion_markers from .noncode import remove_unreachable_branches, remove_noncode_lines diff --git a/src/gcovr/exclusions/markers.py b/src/gcovr/exclusions/markers.py index 11bdee2985..ef38f5c9ea 100644 --- a/src/gcovr/exclusions/markers.py +++ b/src/gcovr/exclusions/markers.py @@ -32,7 +32,7 @@ get_functions_by_line, ) -from ..coverage import FileCoverage, FunctionCoverage +from ..data_model.coverage import FileCoverage, FunctionCoverage LOGGER = logging.getLogger("gcovr") diff --git a/src/gcovr/exclusions/noncode.py b/src/gcovr/exclusions/noncode.py index c456c6dcad..e65facbd55 100644 --- a/src/gcovr/exclusions/noncode.py +++ b/src/gcovr/exclusions/noncode.py @@ -24,7 +24,7 @@ import re import logging -from ..coverage import FileCoverage +from ..data_model.coverage import FileCoverage LOGGER = logging.getLogger("gcovr") @@ -48,7 +48,7 @@ def remove_unreachable_branches(filecov: FileCoverage, *, lines: list[str]) -> N filecov.filename, ) - linecov.branches = {} + linecov.branches.clear() def remove_noncode_lines(filecov: FileCoverage, *, lines: list[str]) -> None: diff --git a/src/gcovr/exclusions/utils.py b/src/gcovr/exclusions/utils.py index a05782ed21..aaaba0cf72 100644 --- a/src/gcovr/exclusions/utils.py +++ b/src/gcovr/exclusions/utils.py @@ -22,7 +22,7 @@ import logging from typing import Callable, Iterable, Optional -from ..coverage import FileCoverage, FunctionCoverage +from ..data_model.coverage import FileCoverage, FunctionCoverage LOGGER = logging.getLogger("gcovr") @@ -151,7 +151,7 @@ def apply_exclusion_ranges( linecov.exclude() elif branch_is_excluded(linecov.lineno): - linecov.branches = {} + linecov.branches.clear() for functioncov in filecov.functions.values(): for lineno in functioncov.excluded.keys(): diff --git a/src/gcovr/formats/__init__.py b/src/gcovr/formats/__init__.py index b6476cae50..50f8270404 100644 --- a/src/gcovr/formats/__init__.py +++ b/src/gcovr/formats/__init__.py @@ -20,13 +20,10 @@ import logging from typing import Callable, Optional -from ..coverage import CoverageContainer, FileCoverage +from ..data_model.coverage import FileCoverage +from ..data_model.container import CoverageContainer +from ..data_model.merging import get_merge_mode_from_options from ..filter import is_file_excluded -from ..merging import ( - get_merge_mode_from_options, - merge_covdata, - insert_file_coverage, -) from ..options import GcovrConfigOption, Options, OutputOrDefault from ..utils import search_file @@ -90,8 +87,7 @@ def read_reports(options: Options) -> CoverageContainer: """Read the reports from the given locations.""" if options.json_add_tracefile or options.cobertura_add_tracefile: covdata = JsonHandler(options).read_report() - covdata = merge_covdata( - covdata, + covdata.merge( CoberturaHandler(options).read_report(), get_merge_mode_from_options(options), ) @@ -111,10 +107,10 @@ def read_reports(options: Options) -> CoverageContainer: if is_file_excluded(fname, options.filter, options.exclude): continue - file_cov = FileCoverage(fname, None) + filecov = FileCoverage(fname, None) LOGGER.debug(f"Merge empty coverage data for {fname}") - insert_file_coverage( - covdata, file_cov, get_merge_mode_from_options(options) + covdata.insert_file_coverage( + filecov, get_merge_mode_from_options(options) ) return covdata diff --git a/src/gcovr/formats/base.py b/src/gcovr/formats/base.py index 580da3f194..6a6bf256c6 100644 --- a/src/gcovr/formats/base.py +++ b/src/gcovr/formats/base.py @@ -19,8 +19,8 @@ from typing import Union +from ..data_model.container import CoverageContainer from ..options import GcovrConfigOption, Options -from ..coverage import CoverageContainer class BaseHandler: diff --git a/src/gcovr/formats/clover/__init__.py b/src/gcovr/formats/clover/__init__.py index 518e2841f5..671e1b6a1d 100644 --- a/src/gcovr/formats/clover/__init__.py +++ b/src/gcovr/formats/clover/__init__.py @@ -22,7 +22,7 @@ from ...options import GcovrConfigOption, OutputOrDefault from ...formats.base import BaseHandler -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer class CloverHandler(BaseHandler): diff --git a/src/gcovr/formats/clover/write.py b/src/gcovr/formats/clover/write.py index f8e9ed25ae..b53b7ec5c6 100644 --- a/src/gcovr/formats/clover/write.py +++ b/src/gcovr/formats/clover/write.py @@ -23,14 +23,14 @@ import logging from lxml import etree # nosec # We only write XML files +from ...data_model.container import CoverageContainer +from ...data_model.coverage import LineCoverage from ...options import Options - from ...utils import ( get_md5_hexdigest, open_binary_for_writing, presentable_filename, ) -from ...coverage import CoverageContainer, LineCoverage LOGGER = logging.getLogger("gcovr") diff --git a/src/gcovr/formats/cobertura/__init__.py b/src/gcovr/formats/cobertura/__init__.py index 1fb7604c3f..67e648b0f0 100644 --- a/src/gcovr/formats/cobertura/__init__.py +++ b/src/gcovr/formats/cobertura/__init__.py @@ -19,7 +19,7 @@ from typing import Union -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer from ...formats.base import BaseHandler from ...options import GcovrConfigOption, OutputOrDefault diff --git a/src/gcovr/formats/cobertura/read.py b/src/gcovr/formats/cobertura/read.py index 95073ed68a..12ca524cab 100644 --- a/src/gcovr/formats/cobertura/read.py +++ b/src/gcovr/formats/cobertura/read.py @@ -22,19 +22,14 @@ from glob import glob from lxml import etree # nosec # We only write XML files -from ...coverage import ( +from ...data_model.container import CoverageContainer +from ...data_model.coverage import ( BranchCoverage, - CoverageContainer, FileCoverage, LineCoverage, ) +from ...data_model.merging import get_merge_mode_from_options from ...filter import is_file_excluded -from ...merging import ( - get_merge_mode_from_options, - insert_branch_coverage, - insert_file_coverage, - insert_line_coverage, -) from ...options import Options LOGGER = logging.getLogger("gcovr") @@ -96,11 +91,11 @@ def read_report(options: Options) -> CoverageContainer: merge_options = get_merge_mode_from_options(options) xml_line: etree._Element for xml_line in gcovr_file.xpath("./lines//line"): # type: ignore [assignment, union-attr] - insert_line_coverage( - filecov, _line_from_xml(data_source_filename, xml_line) + filecov.insert_line_coverage( + _line_from_xml(data_source_filename, xml_line) ) - insert_file_coverage(covdata, filecov, merge_options) + covdata.insert_file_coverage(filecov, merge_options) return covdata @@ -130,8 +125,8 @@ def _line_from_xml(filename: str, xml_line: etree._Element) -> LineCoverage: try: [covered, total] = branch_msg[branch_msg.rfind("(") + 1 : -1].split("/") for i in range(int(total)): - insert_branch_coverage( - linecov, i, _branch_from_json(i, i < int(covered)) + linecov.insert_branch_coverage( + i, _branch_from_json(i, i < int(covered)) ) except AssertionError: # pragma: no cover LOGGER.warning( diff --git a/src/gcovr/formats/cobertura/write.py b/src/gcovr/formats/cobertura/write.py index 1dd97729b5..6f48516ecb 100644 --- a/src/gcovr/formats/cobertura/write.py +++ b/src/gcovr/formats/cobertura/write.py @@ -29,7 +29,9 @@ open_binary_for_writing, presentable_filename, ) -from ...coverage import CoverageContainer, CoverageStat, LineCoverage, SummarizedStats +from ...data_model.container import CoverageContainer +from ...data_model.coverage import LineCoverage +from ...data_model.stats import CoverageStat, SummarizedStats def write_report( diff --git a/src/gcovr/formats/coveralls/__init__.py b/src/gcovr/formats/coveralls/__init__.py index 755ea9d2eb..b75a978bcf 100644 --- a/src/gcovr/formats/coveralls/__init__.py +++ b/src/gcovr/formats/coveralls/__init__.py @@ -19,7 +19,7 @@ from typing import Union -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer from ...formats.base import BaseHandler from ...options import GcovrConfigOption, OutputOrDefault diff --git a/src/gcovr/formats/coveralls/write.py b/src/gcovr/formats/coveralls/write.py index 831ab60ef5..79c8bc6c19 100644 --- a/src/gcovr/formats/coveralls/write.py +++ b/src/gcovr/formats/coveralls/write.py @@ -28,10 +28,10 @@ import subprocess # nosec # Commands are trusted. from typing import Any, Optional +from ...data_model.container import CoverageContainer +from ...data_model.coverage import FileCoverage from ...options import Options - from ...utils import get_md5_hexdigest, presentable_filename, open_text_for_writing -from ...coverage import CoverageContainer, FileCoverage PRETTY_JSON_INDENT = 4 diff --git a/src/gcovr/formats/csv/__init__.py b/src/gcovr/formats/csv/__init__.py index 2fc7af888f..eeca3c559f 100644 --- a/src/gcovr/formats/csv/__init__.py +++ b/src/gcovr/formats/csv/__init__.py @@ -19,10 +19,9 @@ from typing import Union -from ...options import GcovrConfigOption, OutputOrDefault +from ...data_model.container import CoverageContainer from ...formats.base import BaseHandler - -from ...coverage import CoverageContainer +from ...options import GcovrConfigOption, OutputOrDefault class CsvHandler(BaseHandler): diff --git a/src/gcovr/formats/csv/write.py b/src/gcovr/formats/csv/write.py index da932f85e9..109583ed3b 100644 --- a/src/gcovr/formats/csv/write.py +++ b/src/gcovr/formats/csv/write.py @@ -20,10 +20,10 @@ import csv from typing import Optional +from ...data_model.container import CoverageContainer +from ...data_model.stats import CoverageStat from ...options import Options - from ...utils import presentable_filename, open_text_for_writing -from ...coverage import CoverageContainer, CoverageStat def write_report( diff --git a/src/gcovr/formats/gcov/__init__.py b/src/gcovr/formats/gcov/__init__.py index a2827d7321..7e8eebc859 100644 --- a/src/gcovr/formats/gcov/__init__.py +++ b/src/gcovr/formats/gcov/__init__.py @@ -21,7 +21,7 @@ import os from typing import Union -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer from ...formats.base import BaseHandler from ...options import ( FilterOption, diff --git a/src/gcovr/formats/gcov/parser/json.py b/src/gcovr/formats/gcov/parser/json.py index 4a6241106e..98f0f15db5 100644 --- a/src/gcovr/formats/gcov/parser/json.py +++ b/src/gcovr/formats/gcov/parser/json.py @@ -42,22 +42,15 @@ from gcovr.utils import get_md5_hexdigest -from ....coverage import ( +from ....data_model.coverage import ( BranchCoverage, ConditionCoverage, FileCoverage, FunctionCoverage, LineCoverage, ) +from ....data_model.merging import FUNCTION_MAX_LINE_MERGE_OPTIONS, MergeOptions from ....filter import Filter, is_file_excluded -from ....merging import ( - FUNCTION_MAX_LINE_MERGE_OPTIONS, - MergeOptions, - insert_branch_coverage, - insert_condition_coverage, - insert_function_coverage, - insert_line_coverage, -) from .common import ( SUSPICIOUS_COUNTER, check_hits, @@ -122,7 +115,7 @@ def parse_coverage( line.decode(source_encoding, errors="replace") for line in source_lines ] - file_cov = _parse_file_node( + filecov = _parse_file_node( gcov_file_node=file, filename=fname, source_lines=encoded_source_lines, @@ -131,7 +124,7 @@ def parse_coverage( suspicious_hits_threshold=suspicious_hits_threshold, ) - files_coverage.append((file_cov, encoded_source_lines)) + files_coverage.append((filecov, encoded_source_lines)) return files_coverage @@ -168,10 +161,9 @@ def _parse_file_node( if ignore_parse_errors is None: ignore_parse_errors = set() - file_cov = FileCoverage(filename, data_fname) + filecov = FileCoverage(filename, data_fname) for line in gcov_file_node["lines"]: - line_cov = insert_line_coverage( - file_cov, + linecov: LineCoverage = filecov.insert_line_coverage( LineCoverage( line["line_number"], count=check_hits( @@ -189,8 +181,7 @@ def _parse_file_node( ), ) for index, branch in enumerate(line["branches"]): - insert_branch_coverage( - line_cov, + linecov.insert_branch_coverage( index, BranchCoverage( branch["source_block_id"], @@ -207,8 +198,7 @@ def _parse_file_node( ), ) for index, condition in enumerate(line.get("conditions", [])): - insert_condition_coverage( - line_cov, + linecov.insert_condition_coverage( index, ConditionCoverage( check_hits( @@ -233,8 +223,7 @@ def _parse_file_node( ratio = function["blocks_executed"] / function["blocks"] blocks = min(99.9, round(ratio * 100.0, 1)) - insert_function_coverage( - file_cov, + filecov.insert_function_coverage( FunctionCoverage( function["name"], function["demangled_name"], @@ -263,4 +252,4 @@ def _parse_file_node( f"Ignored {persistent_states['suspicious_hits.warn_once_per_file']} suspicious hits overall." ) - return file_cov + return filecov diff --git a/src/gcovr/formats/gcov/parser/text.py b/src/gcovr/formats/gcov/parser/text.py index e5cd350791..107c5d06a9 100644 --- a/src/gcovr/formats/gcov/parser/text.py +++ b/src/gcovr/formats/gcov/parser/text.py @@ -47,27 +47,19 @@ Union, ) -from gcovr.utils import get_md5_hexdigest - -from ....coverage import ( +from .common import ( + SUSPICIOUS_COUNTER, + check_hits, +) +from ....utils import get_md5_hexdigest +from ....data_model.coverage import ( BranchCoverage, CallCoverage, FileCoverage, FunctionCoverage, LineCoverage, ) -from ....merging import ( - FUNCTION_MAX_LINE_MERGE_OPTIONS, - MergeOptions, - insert_branch_coverage, - insert_call_coverage, - insert_function_coverage, - insert_line_coverage, -) -from .common import ( - SUSPICIOUS_COUNTER, - check_hits, -) +from ....data_model.merging import FUNCTION_MAX_LINE_MERGE_OPTIONS, MergeOptions LOGGER = logging.getLogger("gcovr") @@ -330,14 +322,14 @@ def parse_coverage( f"Ignored {persistent_states['suspicious_hits.warn_once_per_file']} suspicious hits overall." ) - coverage = FileCoverage(filename, data_filename) + filecov = FileCoverage(filename, data_filename) state = _ParserState() for line, raw_line in tokenized_lines: try: state = _gather_coverage_from_line( state, line, - coverage=coverage, + filecov=filecov, ) except Exception as ex: # pylint: disable=broad-except lines_with_errors.append((raw_line, ex)) @@ -347,8 +339,7 @@ def parse_coverage( # but the last line could theoretically contain pending function lines for function in state.deferred_functions: name, count, blocks = function - insert_function_coverage( - coverage, + filecov.insert_function_coverage( FunctionCoverage( None, name, @@ -367,7 +358,7 @@ def parse_coverage( src_lines = _reconstruct_source_code(line for line, _ in tokenized_lines) - return coverage, src_lines + return filecov, src_lines def _reconstruct_source_code(tokens: Iterable[_Line]) -> list[str]: @@ -392,27 +383,28 @@ def _gather_coverage_from_line( state: _ParserState, line: _Line, *, - coverage: FileCoverage, + filecov: FileCoverage, ) -> _ParserState: """ Interpret a Line, updating the FileCoverage, and transitioning ParserState. The function handles all possible Line variants, and dies otherwise: - >>> _gather_coverage_from_line(_ParserState(), "illegal line type", coverage=...) + >>> _gather_coverage_from_line(_ParserState(), "illegal line type", filecov=...) Traceback (most recent call last): AssertionError: Unexpected line type: 'illegal line type' """ # pylint: disable=too-many-return-statements,too-many-branches # pylint: disable=no-else-return # make life easier for type checkers + linecov: Optional[LineCoverage] + if isinstance(line, _SourceLine): raw_count, lineno, source_code, extra_info = line is_noncode = extra_info & _ExtraInfo.NONCODE if not is_noncode: - insert_line_coverage( - coverage, + filecov.insert_line_coverage( LineCoverage( lineno, count=raw_count, @@ -424,8 +416,7 @@ def _gather_coverage_from_line( for function in state.deferred_functions: name, count, blocks = function - insert_function_coverage( - coverage, + filecov.insert_function_coverage( FunctionCoverage(None, name, lineno=lineno, count=count, blocks=blocks), MergeOptions(func_opts=FUNCTION_MAX_LINE_MERGE_OPTIONS), ) @@ -448,11 +439,10 @@ def _gather_coverage_from_line( branchno, hits, annotation = line # linecov won't exist if it was considered noncode - linecov = coverage.lines.get(state.lineno) + linecov = filecov.lines.get(state.lineno) if linecov: - insert_branch_coverage( - linecov, + linecov.insert_branch_coverage( branchno, BranchCoverage( source_block_id=state.block_id, @@ -475,10 +465,9 @@ def _gather_coverage_from_line( # ignore unused line types, such as specialization sections elif isinstance(line, _CallLine): callno, returned = line - linecov = coverage.lines[state.lineno] # must already exist + linecov = filecov.lines[state.lineno] # must already exist - insert_call_coverage( - linecov, + linecov.insert_call_coverage( CallCoverage( callno=callno, covered=(returned > 0), diff --git a/src/gcovr/formats/gcov/read.py b/src/gcovr/formats/gcov/read.py index ae128047e2..31290d87f3 100644 --- a/src/gcovr/formats/gcov/read.py +++ b/src/gcovr/formats/gcov/read.py @@ -27,19 +27,14 @@ from threading import Lock from typing import Any, Callable, Optional -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer +from ...data_model.merging import GcovrMergeAssertionError, get_merge_mode_from_options from ...decision_analysis import DecisionParser from ...exclusions import ( apply_all_exclusions, get_exclusion_options_from_options, ) from ...filter import Filter, is_file_excluded -from ...merging import ( - GcovrMergeAssertionError, - get_merge_mode_from_options, - insert_file_coverage, - merge_covdata, -) from ...options import Options from ...utils import ( commonpath, @@ -98,9 +93,7 @@ def read_report(options: Options) -> CoverageContainer: to_erase = set() covdata = CoverageContainer() for context in contexts: - covdata = merge_covdata( - covdata, context["covdata"], get_merge_mode_from_options(options) - ) + covdata.merge(context["covdata"], get_merge_mode_from_options(options)) to_erase.update(context["to_erase"]) for filepath in to_erase: @@ -194,20 +187,21 @@ def process_gcov_json_data( data_fname=data_fname, ) - for file_cov, source_lines in coverage: - LOGGER.debug(f"Apply exclusions for {file_cov.filename}") + merge_options = get_merge_mode_from_options(options) + for filecov, source_lines in coverage: + LOGGER.debug(f"Apply exclusions for {filecov.filename}") apply_all_exclusions( - file_cov, + filecov, lines=source_lines, options=get_exclusion_options_from_options(options), ) if options.show_decision: - decision_parser = DecisionParser(file_cov, source_lines) + decision_parser = DecisionParser(filecov, source_lines) decision_parser.parse_all_lines() - LOGGER.debug(f"Merge coverage data for {file_cov.filename}") - insert_file_coverage(covdata, file_cov, get_merge_mode_from_options(options)) + LOGGER.debug(f"Merge coverage data for {filecov.filename}") + covdata.insert_file_coverage(filecov, merge_options) # @@ -257,7 +251,7 @@ def process_gcov_text_data( LOGGER.debug(f"Parsing coverage data for file {fname}") key = os.path.normpath(fname) - coverage, source_lines = text.parse_coverage( + filecov, source_lines = text.parse_coverage( lines, filename=key, data_filename=gcda_fname or data_fname, @@ -266,14 +260,14 @@ def process_gcov_text_data( ) LOGGER.debug(f"Apply exclusions for {fname}") - apply_all_exclusions(coverage, lines=source_lines, options=options) # type: ignore [arg-type] + apply_all_exclusions(filecov, lines=source_lines, options=options) # type: ignore [arg-type] if options.show_decision: - decision_parser = DecisionParser(coverage, source_lines) + decision_parser = DecisionParser(filecov, source_lines) decision_parser.parse_all_lines() LOGGER.debug(f"Merge coverage data for {fname}") - insert_file_coverage(covdata, coverage, get_merge_mode_from_options(options)) + covdata.insert_file_coverage(filecov, get_merge_mode_from_options(options)) def guess_source_file_name( diff --git a/src/gcovr/formats/html/__init__.py b/src/gcovr/formats/html/__init__.py index fb466a16ff..acec344471 100644 --- a/src/gcovr/formats/html/__init__.py +++ b/src/gcovr/formats/html/__init__.py @@ -20,7 +20,7 @@ import logging from typing import Union -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer from ...formats.base import BaseHandler from ...options import ( GcovrConfigOption, diff --git a/src/gcovr/formats/html/write.py b/src/gcovr/formats/html/write.py index 944f707dba..01cf8265e3 100644 --- a/src/gcovr/formats/html/write.py +++ b/src/gcovr/formats/html/write.py @@ -39,21 +39,19 @@ from pygments.lexers import get_lexer_for_filename -from ...coverage import ( +from ...data_model.container import CoverageContainer, CoverageContainerDirectory +from ...data_model.coverage import ( BranchCoverage, CallCoverage, ConditionCoverage, - CoverageContainer, - CoverageContainerDirectory, - CoverageStat, DecisionCoverage, DecisionCoverageConditional, - DecisionCoverageStat, DecisionCoverageSwitch, DecisionCoverageUncheckable, FileCoverage, LineCoverage, ) +from ...data_model.coverage import CoverageStat, DecisionCoverageStat from ...options import Options from ...utils import ( chdir, diff --git a/src/gcovr/formats/jacoco/__init__.py b/src/gcovr/formats/jacoco/__init__.py index 53cefb6711..b38edbccb6 100644 --- a/src/gcovr/formats/jacoco/__init__.py +++ b/src/gcovr/formats/jacoco/__init__.py @@ -19,7 +19,7 @@ from typing import Union -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer from ...formats.base import BaseHandler from ...options import GcovrConfigOption, OutputOrDefault diff --git a/src/gcovr/formats/jacoco/write.py b/src/gcovr/formats/jacoco/write.py index 2f9aa8a09a..7e54a1d5e2 100644 --- a/src/gcovr/formats/jacoco/write.py +++ b/src/gcovr/formats/jacoco/write.py @@ -23,10 +23,11 @@ import os from lxml import etree # nosec # We only write XML files +from ...data_model.container import CoverageContainer +from ...data_model.coverage import LineCoverage +from ...data_model.stats import CoverageStat, SummarizedStats from ...options import Options - from ...utils import force_unix_separator, open_binary_for_writing, presentable_filename -from ...coverage import CoverageContainer, CoverageStat, LineCoverage, SummarizedStats def write_report( diff --git a/src/gcovr/formats/json/__init__.py b/src/gcovr/formats/json/__init__.py index 8fa29dc21b..cbf4cc701e 100644 --- a/src/gcovr/formats/json/__init__.py +++ b/src/gcovr/formats/json/__init__.py @@ -21,7 +21,7 @@ import os from typing import Union -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer from ...formats.base import BaseHandler from ...options import GcovrConfigOption, OutputOrDefault from ...utils import force_unix_separator diff --git a/src/gcovr/formats/json/read.py b/src/gcovr/formats/json/read.py index 3bd85888ed..2491c61fa6 100644 --- a/src/gcovr/formats/json/read.py +++ b/src/gcovr/formats/json/read.py @@ -24,10 +24,10 @@ from typing import Any, Optional from . import versions -from ...coverage import ( +from ...data_model.container import CoverageContainer +from ...data_model.coverage import ( BranchCoverage, ConditionCoverage, - CoverageContainer, DecisionCoverage, DecisionCoverageConditional, DecisionCoverageSwitch, @@ -37,17 +37,8 @@ LineCoverage, CallCoverage, ) +from ...data_model.merging import get_merge_mode_from_options from ...filter import is_file_excluded -from ...merging import ( - get_merge_mode_from_options, - insert_branch_coverage, - insert_condition_coverage, - insert_decision_coverage, - insert_file_coverage, - insert_function_coverage, - insert_line_coverage, - insert_call_coverage, -) from ...options import Options LOGGER = logging.getLogger("gcovr") @@ -102,13 +93,13 @@ def read_report(options: Options) -> CoverageContainer: ) merge_options = get_merge_mode_from_options(options) for json_function in gcovr_file["functions"]: - insert_function_coverage( - filecov, _function_from_json(json_function), merge_options + filecov.insert_function_coverage( + _function_from_json(json_function), merge_options ) for json_line in gcovr_file["lines"]: - insert_line_coverage(filecov, _line_from_json(json_line)) + filecov.insert_line_coverage(_line_from_json(json_line), merge_options) - insert_file_coverage(covdata, filecov, merge_options) + covdata.insert_file_coverage(filecov, merge_options) return covdata @@ -144,21 +135,21 @@ def _line_from_json(json_line: dict[str, Any]) -> LineCoverage: ) for branchno, json_branch in enumerate(json_line["branches"]): - insert_branch_coverage(linecov, branchno, _branch_from_json(json_branch)) + linecov.insert_branch_coverage(branchno, _branch_from_json(json_branch)) if "conditions" in json_line: for conditionno, json_branch in enumerate(json_line["conditions"]): - insert_condition_coverage( - linecov, conditionno, _condition_from_json(json_branch) + linecov.insert_condition_coverage( + conditionno, _condition_from_json(json_branch) ) - insert_decision_coverage( - linecov, _decision_from_json(json_line.get("gcovr/decision")) + linecov.insert_decision_coverage( + _decision_from_json(json_line.get("gcovr/decision")) ) if "gcovr/calls" in json_line: for json_call in json_line["gcovr/calls"]: - insert_call_coverage(linecov, _call_from_json(json_call)) + linecov.insert_call_coverage(_call_from_json(json_call)) return linecov diff --git a/src/gcovr/formats/json/write.py b/src/gcovr/formats/json/write.py index 6c26579285..2889b00bd1 100644 --- a/src/gcovr/formats/json/write.py +++ b/src/gcovr/formats/json/write.py @@ -23,17 +23,10 @@ import functools from typing import Any, Optional -from ...options import Options - -from ...utils import ( - force_unix_separator, - presentable_filename, - open_text_for_writing, -) -from ...coverage import ( +from ...data_model.container import CoverageContainer +from ...data_model.coverage import ( BranchCoverage, ConditionCoverage, - CoverageContainer, DecisionCoverage, DecisionCoverageConditional, DecisionCoverageSwitch, @@ -42,7 +35,13 @@ FunctionCoverage, LineCoverage, CallCoverage, - SummarizedStats, +) +from ...data_model.stats import SummarizedStats +from ...options import Options +from ...utils import ( + force_unix_separator, + presentable_filename, + open_text_for_writing, ) from . import versions diff --git a/src/gcovr/formats/lcov/__init__.py b/src/gcovr/formats/lcov/__init__.py index 49fc54fe27..d63738c280 100644 --- a/src/gcovr/formats/lcov/__init__.py +++ b/src/gcovr/formats/lcov/__init__.py @@ -19,7 +19,7 @@ from typing import Union -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer from ...formats.base import BaseHandler from ...options import GcovrConfigOption, OutputOrDefault diff --git a/src/gcovr/formats/lcov/write.py b/src/gcovr/formats/lcov/write.py index 38693a8256..fc26062c32 100644 --- a/src/gcovr/formats/lcov/write.py +++ b/src/gcovr/formats/lcov/write.py @@ -26,10 +26,9 @@ # cspell:ignore FNDA BRDA +from ...data_model.container import CoverageContainer from ...options import Options - from ...utils import force_unix_separator, get_md5_hexdigest, open_text_for_writing -from ...coverage import CoverageContainer def write_report( diff --git a/src/gcovr/formats/sonarqube/__init__.py b/src/gcovr/formats/sonarqube/__init__.py index 4f58d1a20c..36926987bc 100644 --- a/src/gcovr/formats/sonarqube/__init__.py +++ b/src/gcovr/formats/sonarqube/__init__.py @@ -19,7 +19,7 @@ from typing import Union -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer from ...formats.base import BaseHandler from ...options import GcovrConfigOption, OutputOrDefault diff --git a/src/gcovr/formats/sonarqube/write.py b/src/gcovr/formats/sonarqube/write.py index 4fe537da79..cc8780fe2a 100644 --- a/src/gcovr/formats/sonarqube/write.py +++ b/src/gcovr/formats/sonarqube/write.py @@ -19,10 +19,9 @@ from lxml import etree # nosec # We only write XML files +from ...data_model.container import CoverageContainer from ...options import Options - from ...utils import open_binary_for_writing, presentable_filename -from ...coverage import CoverageContainer def write_report( diff --git a/src/gcovr/formats/txt/__init__.py b/src/gcovr/formats/txt/__init__.py index 3d1d4de2fa..652723e7d3 100644 --- a/src/gcovr/formats/txt/__init__.py +++ b/src/gcovr/formats/txt/__init__.py @@ -20,7 +20,7 @@ import logging from typing import Union -from ...coverage import CoverageContainer +from ...data_model.container import CoverageContainer from ...formats.base import BaseHandler from ...options import ( GcovrConfigOption, diff --git a/src/gcovr/formats/txt/write.py b/src/gcovr/formats/txt/write.py index e5d65bec94..5d1a5b4e57 100644 --- a/src/gcovr/formats/txt/write.py +++ b/src/gcovr/formats/txt/write.py @@ -19,18 +19,15 @@ from typing import Iterable +from ...data_model.container import CoverageContainer +from ...data_model.coverage import FileCoverage +from ...data_model.stats import CoverageStat from ...options import Options - from ...utils import ( force_unix_separator, presentable_filename, open_text_for_writing, ) -from ...coverage import ( - CoverageContainer, - CoverageStat, - FileCoverage, -) # Widths of the various columns COL_FILE_WIDTH = 40 diff --git a/src/gcovr/merging.py b/src/gcovr/merging.py deleted file mode 100644 index b7fedf543a..0000000000 --- a/src/gcovr/merging.py +++ /dev/null @@ -1,689 +0,0 @@ -# -*- coding:utf-8 -*- - -# ************************** Copyrights and license *************************** -# -# This file is part of gcovr 8.3+main, a parsing and reporting tool for gcov. -# https://gcovr.com/en/main -# -# _____________________________________________________________________________ -# -# Copyright (c) 2013-2025 the gcovr authors -# Copyright (c) 2013 Sandia Corporation. -# Under the terms of Contract DE-AC04-94AL85000 with Sandia Corporation, -# the U.S. Government retains certain rights in this software. -# -# This software is distributed under the 3-clause BSD License. -# For more information, see the README.rst file. -# -# **************************************************************************** - -""" -Merge coverage data. - -All of these merging function have the signature -``merge(T, T) -> T``. -That is, they take two coverage data items and combine them, -returning the combined coverage. -This may change the input objects, so that they should be used afterwards. - -In a mathematical sense, all of these ``merge()`` functions -must behave somewhat like an addition operator: - -* commutative: order of arguments must not matter, - so that ``merge(a, b)`` must match ``merge(a, b)``. -* associative: order of merging must not matter, - so that ``merge(a, merge(b, c))`` must match ``merge(merge(a, b), c)``. -* identity element: there must be an empty element, - so that ``merge(a, empty)`` and ``merge(empty, a)`` and ``a`` all match. - However, the empty state might be implied by “parent dict does not contain an entry”, - or must contain matching information like the same line number. - -The insertion functions insert a single coverage item into a larger structure, -for example inserting BranchCoverage into a LineCoverage object. -The target/parent structure is updated in-place, -otherwise this has equivalent semantics to merging. -In particular, if there already is coverage data in the target with the same ID, -then the contents are merged. -The insertion functions return the coverage structure that is saved in the target, -which may not be the same as the input value. -""" - -from dataclasses import dataclass, field -import logging -from typing import Callable, Optional, TypeVar - -from .options import Options -from .coverage import ( - BranchCoverage, - ConditionCoverage, - CoverageContainer, - DecisionCoverage, - DecisionCoverageConditional, - DecisionCoverageSwitch, - DecisionCoverageUncheckable, - FileCoverage, - FunctionCoverage, - LineCoverage, - CallCoverage, -) - - -LOGGER = logging.getLogger("gcovr") - - -class GcovrMergeAssertionError(AssertionError): - """Exception for data merge errors.""" - - -@dataclass -class MergeFunctionOptions: - """Data class to store the function merge options.""" - - ignore_function_lineno: bool = False - merge_function_use_line_zero: bool = False - merge_function_use_line_min: bool = False - merge_function_use_line_max: bool = False - separate_function: bool = False - - -FUNCTION_STRICT_MERGE_OPTIONS = MergeFunctionOptions() -FUNCTION_LINE_ZERO_MERGE_OPTIONS = MergeFunctionOptions( - ignore_function_lineno=True, - merge_function_use_line_zero=True, -) -FUNCTION_MIN_LINE_MERGE_OPTIONS = MergeFunctionOptions( - ignore_function_lineno=True, - merge_function_use_line_min=True, -) -FUNCTION_MAX_LINE_MERGE_OPTIONS = MergeFunctionOptions( - ignore_function_lineno=True, - merge_function_use_line_max=True, -) -SEPARATE_FUNCTION_MERGE_OPTIONS = MergeFunctionOptions( - ignore_function_lineno=True, - separate_function=True, -) - - -@dataclass -class MergeConditionOptions: - """Data class to store the condition merge options.""" - - merge_condition_fold: bool = False - - -CONDITION_STRICT_MERGE_OPTIONS = MergeConditionOptions() -CONDITION_FOLD_MERGE_OPTIONS = MergeConditionOptions( - merge_condition_fold=True, -) - - -@dataclass -class MergeOptions: - """Data class to store the merge options.""" - - func_opts: MergeFunctionOptions = field(default_factory=MergeFunctionOptions) - cond_opts: MergeConditionOptions = field(default_factory=MergeConditionOptions) - - -DEFAULT_MERGE_OPTIONS = MergeOptions() - - -def get_merge_mode_from_options(options: Options) -> MergeOptions: - """Get the function merge mode.""" - merge_opts = MergeOptions() - if options.merge_mode_functions == "strict": - merge_opts.func_opts = FUNCTION_STRICT_MERGE_OPTIONS - elif options.merge_mode_functions == "merge-use-line-0": - merge_opts.func_opts = FUNCTION_LINE_ZERO_MERGE_OPTIONS - elif options.merge_mode_functions == "merge-use-line-min": - merge_opts.func_opts = FUNCTION_MIN_LINE_MERGE_OPTIONS - elif options.merge_mode_functions == "merge-use-line-max": - merge_opts.func_opts = FUNCTION_MAX_LINE_MERGE_OPTIONS - elif options.merge_mode_functions == "separate": - merge_opts.func_opts = SEPARATE_FUNCTION_MERGE_OPTIONS - else: - raise AssertionError("Sanity check: Unknown functions merge mode.") - - if options.merge_mode_conditions == "strict": - merge_opts.cond_opts = CONDITION_STRICT_MERGE_OPTIONS - elif options.merge_mode_conditions == "fold": - merge_opts.cond_opts = CONDITION_FOLD_MERGE_OPTIONS - else: - raise AssertionError("Sanity check: Unknown conditions merge mode.") - - return merge_opts - - -_Key = TypeVar("_Key", int, str) -_T = TypeVar("_T") - - -def _merge_dict( - left: dict[_Key, _T], - right: dict[_Key, _T], - merge_item: Callable[[_T, _T, MergeOptions, Optional[str]], _T], - options: MergeOptions, - context: Optional[str], -) -> dict[_Key, _T]: - """ - Helper function to merge items in a dictionary. - - Example: - >>> _merge_dict(dict(a=2, b=3), dict(b=1, c=5), - ... lambda a, b, _o, _c: a + b, - ... DEFAULT_MERGE_OPTIONS, - ... None) - {'a': 2, 'b': 4, 'c': 5} - """ - # Ensure that "left" is the larger dict, - # so that fewer items have to be checked for merging. - if len(left) < len(right): - left, right = right, left - - for key, right_item in right.items(): - _insert_coverage_item(left, key, right_item, merge_item, options, context) - - # At this point, "left" contains all merged items. - # The caller should access neither the "left" nor "right" objects. - # While we can't prevent use of the "left" object since we want to return it, - # we can clear the contents of the "right" object. - right.clear() - - return left - - -def _insert_coverage_item( - target_dict: dict[_Key, _T], - key: _Key, - new_item: _T, - merge_item: Callable[[_T, _T, MergeOptions, Optional[str]], _T], - options: MergeOptions, - context: Optional[str], -) -> _T: - """ - Insert a single item into a coverage dictionary. - - That means:: - - merge(left, { key: item }) - - and:: - - insert_coverage_item(left, key, item, ...) - - should be equivalent with respect to their side effects. - - However, the target dict is updated in place, - and the return value differs! - """ - - if key in target_dict: - merged_item = merge_item(target_dict[key], new_item, options, context) - else: - merged_item = new_item - target_dict[key] = merged_item - return merged_item - - -def merge_covdata( - left: CoverageContainer, right: CoverageContainer, options: MergeOptions -) -> CoverageContainer: - """ - Merge CoverageContainer information and clear directory statistics. - - Do not use 'left' or 'right' objects afterwards! - """ - left.directories.clear() - right.directories.clear() - left.data = _merge_dict(left.data, right.data, merge_file, options, None) - return left - - -def insert_file_coverage( - target: CoverageContainer, - file: FileCoverage, - options: MergeOptions = DEFAULT_MERGE_OPTIONS, -) -> FileCoverage: - """Insert FileCoverage into CoverageContainer and clear directory statistics.""" - target.directories.clear() - return _insert_coverage_item( - target.data, file.filename, file, merge_file, options, None - ) - - -def merge_file( - left: FileCoverage, - right: FileCoverage, - options: MergeOptions, - context: Optional[str], -) -> FileCoverage: - """ - Merge FileCoverage information. - - Do not use 'left' or 'right' objects afterwards! - - Precondition: both objects have same filename. - """ - - if left.filename != right.filename: - raise AssertionError("Filename must be equal") - if context is not None: - raise AssertionError("For a file the context must not be set.") - - try: - left.lines = _merge_dict( - left.lines, right.lines, merge_line, options, left.filename - ) - left.functions = _merge_dict( - left.functions, right.functions, merge_function, options, left.filename - ) - if right.data_sources: - left.data_sources.update(right.data_sources) - except AssertionError as exc: - message = [str(exc)] - if right.data_sources: - message += ( - "GCOV source files of merge source is/are:", - *[f"\t{e}" for e in sorted(right.data_sources)], - ) - if left.data_sources: - message += ( - "and of merge target is/are:", - *[f"\t{e}" for e in sorted(left.data_sources)], - ) - raise AssertionError("\n".join(message)) from None - - return left - - -def insert_line_coverage( - target: FileCoverage, - linecov: LineCoverage, - options: MergeOptions = DEFAULT_MERGE_OPTIONS, -) -> LineCoverage: - """Insert LineCoverage into FileCoverage.""" - return _insert_coverage_item( - target.lines, linecov.lineno, linecov, merge_line, options, target.filename - ) - - -def merge_line( - left: LineCoverage, - right: LineCoverage, - options: MergeOptions, - context: Optional[str], -) -> LineCoverage: - """ - Merge LineCoverage information. - - Do not use 'left' or 'right' objects afterwards! - - Precondition: both objects must have same lineno. - """ - context = f"{context}:{left.lineno}" - if left.lineno != right.lineno: - raise AssertionError("Line number must be equal.") - # If both checksums exists compare them if only one exists, use it. - if left.md5 is not None and right.md5 is not None: - if left.md5 != right.md5: - raise AssertionError(f"MD5 checksum of {context} must be equal.") - elif right.md5 is not None: - left.md5 = right.md5 - - left.count += right.count - left.excluded |= right.excluded - left.branches = _merge_dict( - left.branches, right.branches, merge_branch, options, context - ) - left.conditions = _merge_dict( - left.conditions, right.conditions, merge_condition, options, context - ) - left.decision = merge_decision(left.decision, right.decision, options, context) - left.calls = _merge_dict(left.calls, right.calls, merge_call, options, context) - - return left - - -def insert_function_coverage( - filecov: FileCoverage, - function: FunctionCoverage, - options: MergeOptions = DEFAULT_MERGE_OPTIONS, -) -> FunctionCoverage: - """Insert FunctionCoverage into FileCoverage""" - return _insert_coverage_item( - filecov.functions, - function.name or function.demangled_name, - function, - merge_function, - options, - filecov.filename, - ) - - -def merge_function( - left: FunctionCoverage, - right: FunctionCoverage, - options: MergeOptions, - context: Optional[str], -) -> FunctionCoverage: - """ - Merge FunctionCoverage information. - - Do not use 'left' or 'right' objects afterwards! - - Precondition: both objects must have same name and lineno. - - If ``options.func_opts.ignore_function_lineno`` is set, - the two function coverage objects can have differing line numbers. - With following flags the merge mode can be defined: - - ``options.func_opts.merge_function_use_line_zero`` - - ``options.func_opts.merge_function_use_line_min`` - - ``options.func_opts.merge_function_use_line_max`` - - ``options.func_opts.separate_function`` - """ - if left.demangled_name != right.demangled_name: - raise AssertionError("Function demangled name must be equal.") - if left.name != right.name: - raise AssertionError("Function name must be equal.") - if not options.func_opts.ignore_function_lineno: - if left.count.keys() != right.count.keys(): - lines = sorted(set([*left.count.keys(), *right.count.keys()])) - raise GcovrMergeAssertionError( - f"Got function {right.demangled_name} in {context} on multiple lines: {', '.join([str(line) for line in lines])}.\n" - "\tYou can run gcovr with --merge-mode-functions=MERGE_MODE.\n" - "\tThe available values for MERGE_MODE are described in the documentation." - ) - - # keep distinct counts for each line number - if options.func_opts.separate_function: - for lineno, count in sorted(right.count.items()): - try: - left.count[lineno] += count - except KeyError: - left.count[lineno] = count - for lineno, blocks in right.blocks.items(): - try: - # Take the maximum value for this line - if left.blocks[lineno] < blocks: - left.blocks[lineno] = blocks - except KeyError: - left.blocks[lineno] = blocks - for lineno, excluded in right.excluded.items(): - try: - left.excluded[lineno] |= excluded - except KeyError: - left.excluded[lineno] = excluded - if right.start is not None: - if left.start is None: - left.start = {} - for lineno, start in right.start.items(): - left.start[lineno] = start - if right.end is not None: - if left.end is None: - left.end = {} - for lineno, end in right.end.items(): - left.end[lineno] = end - return left - - right_lineno = list(right.count.keys())[0] - # merge all counts into an entry for a single line number - if right_lineno in left.count: - lineno = right_lineno - elif options.func_opts.merge_function_use_line_zero: - lineno = 0 - elif options.func_opts.merge_function_use_line_min: - lineno = min(*left.count.keys(), *right.count.keys()) - elif options.func_opts.merge_function_use_line_max: - lineno = max(*left.count.keys(), *right.count.keys()) - else: - raise AssertionError("Sanity check, unknown merge mode") - - # Overwrite data with the sum at the desired line - left.count = {lineno: sum(left.count.values()) + sum(right.count.values())} - # or the max value at the desired line - left.blocks = {lineno: max(*left.blocks.values(), *right.blocks.values())} - # or the logical or of all values - left.excluded = { - lineno: any(left.excluded.values()) or any(right.excluded.values()) - } - - if left.start is not None and right.start is not None: - # or the minimum start - left.start = {lineno: min(*left.start.values(), *right.start.values())} - if left.end is not None and right.end is not None: - # or the maximum end - left.end = {lineno: max(*left.end.values(), *right.end.values())} - - return left - - -def insert_branch_coverage( - linecov: LineCoverage, - branchno: int, - branchcov: BranchCoverage, - options: MergeOptions = DEFAULT_MERGE_OPTIONS, -) -> BranchCoverage: - """Insert BranchCoverage into LineCoverage.""" - return _insert_coverage_item( - linecov.branches, branchno, branchcov, merge_branch, options, None - ) - - -def merge_branch( - left: BranchCoverage, - right: BranchCoverage, - _options: MergeOptions, - _context: Optional[str], -) -> BranchCoverage: - """ - Merge BranchCoverage information. - - Do not use 'left' or 'right' objects afterwards! - - Examples: - >>> left = BranchCoverage(1, 2) - >>> right = BranchCoverage(1, 3, False, True) - >>> right.excluded = True - >>> merged = merge_branch(left, right, DEFAULT_MERGE_OPTIONS, None) - >>> merged.count - 5 - >>> merged.fallthrough - False - >>> merged.throw - True - >>> merged.excluded - True - """ - - left.count += right.count - left.fallthrough |= right.fallthrough - left.throw |= right.throw - if left.excluded is True or right.excluded is True: - left.excluded = True - - return left - - -def insert_condition_coverage( - linecov: LineCoverage, - condition_id: int, - conditioncov: ConditionCoverage, - options: MergeOptions = DEFAULT_MERGE_OPTIONS, -) -> ConditionCoverage: - """Insert ConditionCoverage into LineCoverage.""" - return _insert_coverage_item( - linecov.conditions, condition_id, conditioncov, merge_condition, options, None - ) - - -def merge_condition( - left: ConditionCoverage, - right: ConditionCoverage, - options: MergeOptions, - context: Optional[str], -) -> ConditionCoverage: - """ - Merge ConditionCoverage information. - - Do not use 'left' or 'right' objects afterwards! - - Examples: - >>> left = ConditionCoverage(4, 2, [1, 2], []) - >>> right = ConditionCoverage(4, 3, [2], [1, 3]) - >>> merge_condition(left, None, DEFAULT_MERGE_OPTIONS, None) is left - True - >>> merge_condition(None, right, DEFAULT_MERGE_OPTIONS, None) is right - True - >>> merged = merge_condition(left, right, DEFAULT_MERGE_OPTIONS, None) - >>> merged.count - 4 - >>> merged.covered - 3 - >>> merged.not_covered_true - [2] - >>> merged.not_covered_false - [] - - If ``options.cond_opts.merge_condition_fold`` is set, - the two condition coverage lists can have differing counts. - The conditions are shrunk to match the lowest count - """ - - # If condition coverage is not know for one side, return the other. - if left is None: - return right - if right is None: - return left - - if left.count != right.count: - if options.cond_opts.merge_condition_fold: - LOGGER.warning( - f"Condition counts are not equal, got {right.count} and expected {left.count}. " - f"Reducing to {min(left.count, right.count)}." - ) - if left.count > right.count: - left.not_covered_true = left.not_covered_true[ - : len(right.not_covered_true) - ] - left.not_covered_false = left.not_covered_false[ - : len(right.not_covered_false) - ] - left.count = right.count - else: - right.not_covered_true = right.not_covered_true[ - : len(left.not_covered_true) - ] - right.not_covered_false = right.not_covered_false[ - : len(left.not_covered_false) - ] - right.count = left.count - else: - raise AssertionError( - f"The number of conditions must be equal, got {right.count} and expected {left.count} while merging {context}.\n" - "\tYou can run gcovr with --merge-mode-conditions=MERGE_MODE.\n" - "\tThe available values for MERGE_MODE are described in the documentation." - ) - - left.not_covered_false = sorted( - list(set(left.not_covered_false) & set(right.not_covered_false)) - ) - left.not_covered_true = sorted( - list(set(left.not_covered_true) & set(right.not_covered_true)) - ) - left.covered = left.count - len(left.not_covered_false) - len(left.not_covered_true) - - return left - - -def insert_decision_coverage( - target: LineCoverage, - decision: Optional[DecisionCoverage], - options: MergeOptions = DEFAULT_MERGE_OPTIONS, -) -> Optional[DecisionCoverage]: - """Insert DecisionCoverage into LineCoverage.""" - target.decision = merge_decision(target.decision, decision, options, None) - return target.decision - - -def merge_decision( # pylint: disable=too-many-return-statements - left: Optional[DecisionCoverage], - right: Optional[DecisionCoverage], - _options: MergeOptions, - _context: Optional[str], -) -> Optional[DecisionCoverage]: - """ - Merge DecisionCoverage information. - - Do not use 'left' or 'right' objects afterwards! - - The DecisionCoverage has different states: - - - None (no known decision) - - Uncheckable (there was a decision, but it can't be analyzed properly) - - Conditional - - Switch - - If there is a conflict between different types, Uncheckable will be returned. - """ - - # The DecisionCoverage classes have long names, so abbreviate them here: - Conditional = DecisionCoverageConditional - Switch = DecisionCoverageSwitch - Uncheckable = DecisionCoverageUncheckable - - # If decision coverage is not know for one side, return the other. - if left is None: - return right - if right is None: - return left - - # If any decision is Uncheckable, the result is Uncheckable. - if isinstance(left, Uncheckable): - return left - if isinstance(right, Uncheckable): - return right - - # Merge Conditional decisions. - if isinstance(left, Conditional) and isinstance(right, Conditional): - left.count_true += right.count_true - left.count_false += right.count_false - return left - - # Merge Switch decisions. - if isinstance(left, Switch) and isinstance(right, Switch): - left.count += right.count - return left - - # If the decisions have conflicting types, the result is Uncheckable. - return Uncheckable() - - -def insert_call_coverage( - target: LineCoverage, - call: CallCoverage, - options: MergeOptions = DEFAULT_MERGE_OPTIONS, -) -> CallCoverage: - """Insert BranchCoverage into LineCoverage.""" - return _insert_coverage_item( - target.calls, call.callno, call, merge_call, options, None - ) - - -def merge_call( - left: CallCoverage, - right: CallCoverage, - _options: MergeOptions, - context: Optional[str], -) -> CallCoverage: - """ - Merge CallCoverage information. - - Do not use 'left' or 'right' objects afterwards! - """ - if left.callno != right.callno: - raise AssertionError( - f"Call number must be equal, got {left.callno} and {right.callno} while merging {context}." - ) - left.covered |= right.covered - return left diff --git a/tests/test_gcov_parser.py b/tests/test_gcov_parser.py index 1f40c69ef4..ec84f9c700 100644 --- a/tests/test_gcov_parser.py +++ b/tests/test_gcov_parser.py @@ -26,7 +26,7 @@ import pytest -from gcovr.coverage import FileCoverage +from gcovr.data_model.coverage import FileCoverage from gcovr.exclusions import ExclusionOptions, apply_all_exclusions from gcovr.filter import AlwaysMatchFilter from gcovr.formats.gcov.parser import ( @@ -457,7 +457,9 @@ def test_exception_during_coverage_processing(caplog: pytest.LogCaptureFixture) ) lines = source.splitlines() - with mock.patch("gcovr.formats.gcov.parser.text.insert_function_coverage") as m: + with mock.patch( + "gcovr.data_model.coverage.FileCoverage.insert_function_coverage" + ) as m: m.side_effect = AssertionError("totally broken") with pytest.raises(AssertionError) as ex_info: text.parse_coverage(