From 19d914f0d799e85c770e280c12a3948c2dfb292d Mon Sep 17 00:00:00 2001 From: Kelly Hair Date: Sat, 26 Sep 2026 17:35:32 -0400 Subject: [PATCH] Add mutmut.is_mutmut_generated_name for tests that inspect classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutated files add functions (x_foo__mutmut_orig, xǁClsǁmethod__mutmut_1) and mutants_... dicts whose names do not start with an underscore, so tests that walk vars()/dir()/inspect.getmembers() see them as public members. Since 3.8.0 mutates @dataclass methods, this shows up in more codebases. Expose a public predicate for filtering them and document it. Co-Authored-By: Claude Opus 5.5 --- HISTORY.rst | 5 ++ README.rst | 23 +++++++++ src/mutmut/__init__.py | 1 + src/mutmut/utils/format_utils.py | 11 ++++ tests/utils/test_format_utils.py | 86 ++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+) create mode 100644 tests/utils/test_format_utils.py diff --git a/HISTORY.rst b/HISTORY.rst index f94da6bc..1efb064a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -1,6 +1,11 @@ Changelog --------- +Unreleased +~~~~~~~~~~ + +* Add ``mutmut.is_mutmut_generated_name`` so tests that inspect modules or classes can skip the functions and dicts mutmut adds to mutated code + 3.8.0 ~~~~~~~~~~ diff --git a/README.rst b/README.rst index 6b8a074f..c1e1a42f 100644 --- a/README.rst +++ b/README.rst @@ -622,6 +622,29 @@ directory. If you want to make sure you run a full mutmut run you can delete this directory to start from scratch. +Tests that inspect modules or classes +------------------------------------- + +To switch between mutants, mutmut adds functions and dicts to the mutated +modules and classes, such as ``x_foo__mutmut_orig``, +``xǁMyClassǁmethod__mutmut_1`` and ``mutants_x_foo__mutmut``. Their names +don't start with an underscore, so a test that walks ``vars()``, ``dir()`` or +``inspect.getmembers()`` sees them as public attributes. A test that checks +every public method has a docstring, for example, fails in the stats run +and no mutants get tested. + +Use ``mutmut.is_mutmut_generated_name`` to skip them: + +.. code-block:: python + + import mutmut + + public = [ + name for name in vars(MyClass) + if not name.startswith("_") and not mutmut.is_mutmut_generated_name(name) + ] + + Mutation score badges --------------------- diff --git a/src/mutmut/__init__.py b/src/mutmut/__init__.py index 03ebbe44..c6b2d0a9 100644 --- a/src/mutmut/__init__.py +++ b/src/mutmut/__init__.py @@ -7,6 +7,7 @@ from mutmut.configuration import reset_config from mutmut.state import reset_state from mutmut.state import state +from mutmut.utils.format_utils import is_mutmut_generated_name as is_mutmut_generated_name __version__ = importlib.metadata.version("mutmut") diff --git a/src/mutmut/utils/format_utils.py b/src/mutmut/utils/format_utils.py index 76cd199a..2a80b6bc 100644 --- a/src/mutmut/utils/format_utils.py +++ b/src/mutmut/utils/format_utils.py @@ -38,6 +38,17 @@ def is_mutated_method_name(name: str) -> bool: return name.startswith(("x_", "xǁ")) and "__mutmut" in name +def is_mutmut_generated_name(name: str) -> bool: + """Return True if ``name`` is a module or class attribute that mutmut added to the mutated code. + + Mutated files define extra functions (``x_foo__mutmut_orig``, ``xǁClsǁmethod__mutmut_1``, ...) + and the ``mutants_...`` dicts that map mutant names to them. Their names do not start with an + underscore, so tests that inspect a module or class (``vars()``, ``dir()``, ``inspect.getmembers()``) + see them as public attributes. Filter with this function to ignore them. + """ + return is_mutated_method_name(name) or (name.startswith("mutants_x") and name.endswith("__mutmut")) + + def strip_prefix(s: str, *, prefix: str, strict: bool = False) -> str: if s.startswith(prefix): return s[len(prefix) :] diff --git a/tests/utils/test_format_utils.py b/tests/utils/test_format_utils.py new file mode 100644 index 00000000..fd245c8e --- /dev/null +++ b/tests/utils/test_format_utils.py @@ -0,0 +1,86 @@ +import types + +import pytest + +import mutmut +from mutmut.mutation.file_mutation import mutate_file_contents +from mutmut.utils.format_utils import is_mutmut_generated_name + +SOURCE = ''' +from dataclasses import dataclass + + +@dataclass +class DateRange: + """A closed date interval.""" + + start: int + end: int + + def __post_init__(self): + if self.end < self.start: + raise ValueError("end before start") + + def length(self): + """Number of days in the range.""" + return self.end - self.start + 1 + + +def span(r): + """Length of the range.""" + return r.end - r.start + 1 +''' + + +def _load_mutated_module() -> types.ModuleType: + module = types.ModuleType("demo") + exec(compile(mutate_file_contents("demo.py", SOURCE).code, "demo.py", "exec"), module.__dict__) + return module + + +def _public(namespace: dict[str, object]) -> set[str]: + return {name for name in namespace if not name.startswith("_")} + + +def test_is_mutmut_generated_name_is_public_api(): + assert mutmut.is_mutmut_generated_name is is_mutmut_generated_name + + +def test_filters_every_generated_class_member(): + module = _load_mutated_module() + + public_members = _public(vars(module.DateRange)) + assert any(is_mutmut_generated_name(name) for name in public_members), "precondition: mutmut adds members" + assert {name for name in public_members if not is_mutmut_generated_name(name)} == {"length"} + + +def test_filters_every_generated_module_attribute(): + module = _load_mutated_module() + + public_names = _public(vars(module)) + assert {name for name in public_names if not is_mutmut_generated_name(name)} == { + "DateRange", + "MutantDict", + "dataclass", + "span", + } + + +@pytest.mark.parametrize( + "name", + [ + "length", + "span", + "x", + "x_", + "xǁ", + "x_value", + "mutants", + "mutants_x", + "mutants_count", + "mutmut_orig", + "__post_init__", + ], +) +def test_does_not_match_user_names(name): + assert not is_mutmut_generated_name(name)