Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/pynguin/assertion/mutation_analysis/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@ def create_module(
code = compile(ast_node, module_name, "exec")
module = types.ModuleType(module_name)
if original_module is not None:
# Look the attributes up in the module's namespace: hasattr() would fall back to
# a module-level __getattr__ (PEP 562) for missing ones, e.g., __path__.
original_namespace = vars(original_module)
for attribute in _PACKAGE_CONTEXT_ATTRIBUTES:
if hasattr(original_module, attribute):
setattr(module, attribute, getattr(original_module, attribute))
if attribute in original_namespace:
setattr(module, attribute, original_namespace[attribute])
with time_limit(config.configuration.stopping.maximum_module_execution_timeout):
exec(code, module.__dict__) # noqa: S102
return module
Expand Down
5 changes: 4 additions & 1 deletion src/pynguin/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1628,7 +1628,10 @@ def _setup_mutation_analysis_assertion_generator(
module = importlib.import_module(config.configuration.module_name)

_LOGGER.info("Mutate module %s", module.__name__)
mutation_controller = create_mutation_controller(module)
# Reading the module source calls hasattr(module, "__wrapped__"), which runs an
# instrumented module-level __getattr__ (PEP 562) outside of any test execution.
with executor.subject_properties.instrumentation_tracer.temporarily_disable():
mutation_controller = create_mutation_controller(module)
assertion_generator: ag.MutationAnalysisAssertionGenerator
if config.configuration.test_case_output.assertion_generation is config.AssertionGenerator.LLM:
assertion_generator = lag.MutationAnalysisLLMAssertionGenerator(
Expand Down
17 changes: 17 additions & 0 deletions tests/assertion/mutation_analysis/test_transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import ast
import types

import pytest

Expand Down Expand Up @@ -65,6 +66,22 @@ def test_create_module_keeps_package_context_for_relative_imports():
assert mod.__path__ == package.__path__


def test_create_module_does_not_call_module_level_getattr():
requested: list[str] = []

def lazy_getattr(name: str) -> str:
requested.append(name)
return name

original = types.ModuleType("lazy_mod")
original.__file__ = "lazy_mod.py"
original.__getattr__ = lazy_getattr
mod = create_module(ast.parse("x = 1\n"), "lazy_mod", original)
assert requested == []
assert mod.__file__ == "lazy_mod.py"
assert "__path__" not in vars(mod)


def test_create_module_without_package_context_fails_relative_import():
tree = ast.parse("from . import helper\n")
with pytest.raises(ImportError):
Expand Down
40 changes: 40 additions & 0 deletions tests/test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import pynguin.ga.testsuitechromosome as tsc
import pynguin.generator as gen
from pynguin.configuration import CoverageMetric
from pynguin.instrumentation.machinery import install_import_hook
from pynguin.instrumentation.tracer import SubjectProperties
from pynguin.utils.exceptions import CannotInstrumentCompiledModuleError
from pynguin.utils.statistics.runtimevariable import RuntimeVariable
from pynguin.utils.timeout import TestExecutionTimeoutError
Expand Down Expand Up @@ -313,6 +315,44 @@ def test_generate_assertions_for_llm_invokes_generators():
config.configuration.test_case_output.assertion_generation = orig_ass_gen


def test_setup_mutation_analysis_with_module_level_getattr(monkeypatch, tmp_path: Path):
"""A module-level ``__getattr__`` (PEP 562) must not abort mutation analysis.

``inspect.getsource`` checks ``hasattr(module, "__wrapped__")``, which runs the
instrumented ``__getattr__`` outside of any test execution.
"""
(tmp_path / "mod_with_getattr.py").write_text(
"def foo(x):\n"
" return x + 1\n"
"\n"
"def __getattr__(name):\n"
" if name == 'old_foo':\n"
" return foo\n"
" raise AttributeError(name)\n",
encoding="utf-8",
)
monkeypatch.syspath_prepend(str(tmp_path))
monkeypatch.delitem(sys.modules, "mod_with_getattr", raising=False)
config.configuration.module_name = "mod_with_getattr"

subject_properties = SubjectProperties()
with install_import_hook("mod_with_getattr", subject_properties):
# Import the SUT under an active tracer, as _load_sut does.
with subject_properties.instrumentation_tracer:
importlib.import_module("mod_with_getattr")

executor = MagicMock(subject_properties=subject_properties)
with (
mock.patch.object(gen.ag, "create_filtering_executor", return_value=None),
mock.patch.object(gen.ag, "MutationAnalysisAssertionGenerator") as generator_mock,
):
gen._setup_mutation_analysis_assertion_generator(executor)

mutation_controller = generator_mock.call_args.args[1]
assert mutation_controller.mutant_count() > 0
assert not subject_properties.instrumentation_tracer.is_disabled()


def test__setup_report_dir(tmp_path: Path):
path = tmp_path / "foo" / "bar"
config.configuration.statistics_output.report_dir = path.absolute()
Expand Down