Source code for pynguin.assertion.mutation_analysis.controller

#  This file is part of Pynguin.
#
#  SPDX-FileCopyrightText: 2019–2026 Pynguin Contributors
#
#  SPDX-License-Identifier: MIT
#
"""Provides a controller for generating mutants."""

from __future__ import annotations

import ast
import dataclasses
import inspect
import logging
from pathlib import Path
from typing import TYPE_CHECKING

import pynguin.assertion.mutation_analysis.mutators as mu
import pynguin.assertion.mutation_analysis.operators as mo
import pynguin.assertion.mutation_analysis.strategies as ms
import pynguin.configuration as config
from pynguin.assertion.mutation_analysis.transformer import ParentNodeTransformer, create_module
from pynguin.utils.exceptions import ConfigurationException
from pynguin.utils.timeout import TestExecutionTimeoutError

if TYPE_CHECKING:
    import types
    from collections.abc import Collection, Generator
    from types import ModuleType

    from pynguin.assertion.mutation_analysis.operators.base import (
        Mutation,
        MutationOperator,
    )


_LOGGER = logging.getLogger(__name__)

_strategies: dict[config.MutationStrategy, type[ms.HOMStrategy]] = {
    config.MutationStrategy.FIRST_TO_LAST: ms.FirstToLastHOMStrategy,
    config.MutationStrategy.BETWEEN_OPERATORS: ms.BetweenOperatorsHOMStrategy,
    config.MutationStrategy.RANDOM: ms.RandomHOMStrategy,
    config.MutationStrategy.EACH_CHOICE: ms.EachChoiceHOMStrategy,
}


[docs] @dataclasses.dataclass class MutationMetrics: """Stores metrics from mutation analysis.""" num_created_mutants: int num_killed_mutants: int num_timeout_mutants: int
[docs] def get_score(self) -> float | None: """Computes the mutation score. Returns: The mutation score, or ``None`` if no checked mutant contributed usable information (every created mutant timed out), in which case the score is unmeasurable rather than vacuously perfect. """ divisor = self.num_created_mutants - self.num_timeout_mutants assert divisor >= 0 if divisor == 0: if self.num_created_mutants == 0: # No mutants were created -> vacuously covered. return 1.0 # Every created mutant timed out; we learned nothing about the # assertions, so the score cannot be measured. return None return self.num_killed_mutants / divisor
[docs] def compute_reported_mutation_score(metrics: MutationMetrics, num_created: int) -> float | None: """Computes the mutation score to report, given the pre-truncation mutant count. Args: metrics: The metrics over the checked mutants. num_created: The number of mutants the module yielded, checked or not. Returns: The mutation score, or ``None`` if mutants were created but none of them could be checked (e.g., every mutant was an invalid module), in which case the score is unmeasurable rather than vacuously perfect. """ if num_created > 0 and metrics.num_created_mutants == 0: return None return metrics.get_score()
[docs] def setup_mutant_generator() -> mu.Mutator: """Set up the mutant generator based on configuration. Returns: The configured mutant generator. Raises: ConfigurationException: If the mutation strategy or order is invalid. """ operators: list[type[MutationOperator]] = [ *mo.standard_operators, *mo.experimental_operators, ] output = config.configuration.test_case_output mutation_strategy = output.mutation_strategy if mutation_strategy == config.MutationStrategy.FIRST_ORDER_MUTANTS: # Reorder (interleave + defer timeout-prone operators) whenever a bound on # the mutation-analysis phase is active, so truncation stays fair. reorder = output.maximum_mutants >= 0 or output.maximum_mutation_time >= 0 return mu.FirstOrderMutator( operators, maximum_mutants=output.maximum_mutants, sampling_seed=config.configuration.seeding.seed, reorder=reorder, ) order = config.configuration.test_case_output.mutation_order if order <= 0: raise ConfigurationException("Mutation order should be > 0.") if mutation_strategy in _strategies: hom_strategy = _strategies[mutation_strategy](order) return mu.HighOrderMutator(operators, hom_strategy=hom_strategy) raise ConfigurationException("No suitable mutation strategy found.")
[docs] def create_mutation_controller(module: types.ModuleType) -> MutationController: """Create a MutationController for the specified module. Args: module: The module to mutate. Returns: The configured MutationController. """ try: module_source_code = inspect.getsource(module) except Exception: file_path = getattr(module, "__file__", None) if file_path: module_source_code = Path(file_path).read_text(encoding="utf-8") else: raise module_ast = ParentNodeTransformer.create_ast(module_source_code) mutant_generator = setup_mutant_generator() return MutationController(mutant_generator, module_ast, module)
[docs] class MutationController: """A controller that creates mutants.""" def __init__( self, mutant_generator: mu.Mutator, module_ast: ast.Module, module: types.ModuleType, ) -> None: """Initialize the controller. Args: mutant_generator: The mutant generator to use. module_ast: The AST of the module to mutate. module: The module to mutate. """ self._mutant_generator = mutant_generator self._module_ast = module_ast self._module = module
[docs] def create_mutant(self, mutant_ast: ast.Module) -> ModuleType: """Creates a mutant of the module. Args: mutant_ast: The mutant AST. Returns: The created mutant module. """ return create_module(mutant_ast, self._module.__name__, self._module)
[docs] def create_mutants( self, ) -> Generator[tuple[ModuleType | None, list[Mutation]]]: """Creates mutants for the module. Returns: A generator of tuples where the first entry is the mutated module or None if the mutated module cannot be created and the second part is a list of all the mutations operators applied. """ for mutations, mutant_ast in self._mutant_generator.mutate(self._module_ast, self._module): yield self._try_create_mutant(mutant_ast), mutations
[docs] def create_selected_mutants( self, indices: Collection[int] ) -> dict[int, tuple[ModuleType | None, list[Mutation]]]: """Creates only the mutants at the given positions of the mutation order. Mutations that are not selected are enumerated but never compiled or executed, which keeps this cheap for large modules. Args: indices: Positions in the order produced by ``mutations()``. Returns: A mapping from each selected position to the tuple that ``create_mutants()`` would yield for it. """ selected: dict[int, tuple[ModuleType | None, list[Mutation]]] = {} for index, (mutations, mutant_ast) in enumerate( self._mutant_generator.mutate(self._module_ast, self._module) ): if index in indices: selected[index] = (self._try_create_mutant(mutant_ast), mutations) return selected
[docs] def mutations(self) -> Generator[list[Mutation]]: """Enumerates the mutations without creating any mutant module. Yields: The mutations applied for each mutant, in ``create_mutants()`` order. """ for mutations, _ in self._mutant_generator.mutate(self._module_ast, self._module): yield mutations
def _try_create_mutant(self, mutant_ast: ast.AST) -> ModuleType | None: assert isinstance(mutant_ast, ast.Module) try: return self.create_mutant(mutant_ast) except Exception as exception: # noqa: BLE001 _LOGGER.debug("Error creating mutant: %s", exception) except SystemExit as exception: _LOGGER.debug("Caught SystemExit during mutant creation/execution: %s", exception) except TestExecutionTimeoutError as exception: _LOGGER.warning("Caught timeout during mutant creation/execution: %s", exception) return None
[docs] def mutant_count(self) -> int: """Calculates the number of mutants that can be created. This is the pre-truncation total: if the mutant generator is configured to sample a subset (e.g. via a mutant-count cap), this still reports the full number of mutations the module yields. Returns: The number of mutants that can be created. """ return self._mutant_generator.mutation_count(self._module_ast, self._module)