From 91f40b207a0141b03cfb56f41c0bb2e67df0c984 Mon Sep 17 00:00:00 2001 From: Esmail Date: Wed, 21 Oct 2026 21:43:56 +0200 Subject: [PATCH] Avoid triggering module-level __getattr__ during mutation analysis Mutation analysis called hasattr() on the module under test when creating the mutation controller (inspect.getsource -> hasattr(module, "__wrapped__")) and when creating each mutant (hasattr(original_module, "__path__")). For a module with a module-level __getattr__ (PEP 562), e.g., calendar on Python 3.12+, this ran the instrumented __getattr__ outside of a test execution. The tracer raised TracingAbortedException, the worker died, and after a full search the restarted worker had no time left, so no tests were written. Create the mutation controller with the tracer temporarily disabled and read the package-context attributes from the module's namespace instead. --- .../mutation_analysis/transformer.py | 7 +++- src/pynguin/generator.py | 5 ++- .../mutation_analysis/test_transformer.py | 17 ++++++++ tests/test_generator.py | 40 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/pynguin/assertion/mutation_analysis/transformer.py b/src/pynguin/assertion/mutation_analysis/transformer.py index 43ccc8887..ae94c9c3b 100644 --- a/src/pynguin/assertion/mutation_analysis/transformer.py +++ b/src/pynguin/assertion/mutation_analysis/transformer.py @@ -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 diff --git a/src/pynguin/generator.py b/src/pynguin/generator.py index eed8e4dfe..521659d1e 100644 --- a/src/pynguin/generator.py +++ b/src/pynguin/generator.py @@ -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( diff --git a/tests/assertion/mutation_analysis/test_transformer.py b/tests/assertion/mutation_analysis/test_transformer.py index 24d6552e7..c5ab26f77 100644 --- a/tests/assertion/mutation_analysis/test_transformer.py +++ b/tests/assertion/mutation_analysis/test_transformer.py @@ -7,6 +7,7 @@ from __future__ import annotations import ast +import types import pytest @@ -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): diff --git a/tests/test_generator.py b/tests/test_generator.py index 56ff8e5c1..2724731c8 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -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 @@ -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()