-
Notifications
You must be signed in to change notification settings - Fork 146
Description
@dataclass methods produce zero mutants
Let me start by thanking you for this great asset!
And it could be that we are using this incorrectly.
Summary
mutate_file_contents generates zero mutants for methods defined inside a @dataclass class, while identical methods in a plain class produce mutants as expected.
Environment
- mutmut 3.5.0
- Python 3.11.14
- Linux (WSL2)
Minimal reproduction
# repro.py
from mutmut.file_mutation import mutate_file_contents
PLAIN_CLASS = """
class Foo:
def __init__(self, x, y):
self.x = x
self.y = y
def sum(self):
return self.x + self.y
"""
DATACLASS = """
from dataclasses import dataclass
@dataclass
class Foo:
x: int
y: int
def sum(self):
return self.x + self.y
"""
FROZEN_DATACLASS = """
from dataclasses import dataclass
@dataclass(frozen=True)
class Foo:
x: int
y: int
def sum(self):
return self.x + self.y
"""
for label, code in [
("plain class", PLAIN_CLASS),
("@dataclass", DATACLASS),
("@dataclass(frozen=True)", FROZEN_DATACLASS),
]:
_, names = mutate_file_contents("foo.py", code)
print(f"{label}: {len(names)} mutants {names}")Actual output
plain class: 3 mutants ['xǁFooǁ__init____mutmut_1', 'xǁFooǁ__init____mutmut_2', 'xǁFooǁsum__mutmut_1']
@dataclass: 0 mutants []
@dataclass(frozen=True): 0 mutants []
Expected output
All three cases should produce at least one mutant for sum (e.g. self.x + self.y -> self.x - self.y).
Root cause
In file_mutation.py, MutationVisitor._skip_node_and_children (line 138) has a blanket skip for any decorated class or function:
# line 154-159
# ignore decorated functions, because
# 1) copying them for the trampoline setup can cause side effects (e.g. multiple @app.post("/foo") definitions)
# 2) decorators are executed when the function is defined, so we don't want to mutate their arguments and cause exceptions
# 3) @property decorators break the trampoline signature assignment (which expects it to be a function)
if isinstance(node, (cst.FunctionDef, cst.ClassDef)) and len(node.decorators):
return TrueThe three reasons in the comment are valid for @app.post, @property, @staticmethod,
etc. — but @dataclass doesn't appear to those problems in this case.
- No side effects from re-execution (it just defines a class)
- Decorator arguments aren't in method bodies
- Doesn't change method signatures the way
@propertydoes
Workaround we're using
We patched our local mutmut install with a safe-decorator allowlist. The change is small — a helper function and a three-line guard added before the existing return True:
SAFE_CLASS_DECORATORS = {"dataclass"}
def _is_safe_class_decorator(decorator: cst.Decorator) -> bool:
"""Check if a decorator is safe to recurse into (won't break trampolines)."""
dec = decorator.decorator
# @dataclass
if isinstance(dec, cst.Name) and dec.value in SAFE_CLASS_DECORATORS:
return True
# @dataclass(frozen=True) etc.
if isinstance(dec, cst.Call) and isinstance(dec.func, cst.Name) and dec.func.value in SAFE_CLASS_DECORATORS:
return True
return FalseThen in _skip_node_and_children:
if isinstance(node, (cst.FunctionDef, cst.ClassDef)) and len(node.decorators):
if isinstance(node, cst.ClassDef) and all(
_is_safe_class_decorator(d) for d in node.decorators
):
return False # safe — recurse into methods
return TrueWe tested this against a real @dataclass(frozen=True) entity with a __post_init__ method — 20 mutants generated and all 20 killed by existing tests.
We plan to maintain this in a fork for our CI pipeline. If this looks like something you'd accept as a PR, we're happy to contribute it upstream with tests. No rush — just let us know.
Relationship to #302
#302 was about type hint mutations (| → &) in mutmut v2 and was closed when v3 shipped. This is a different issue introduced by the v3 rewrite: the blanket decorator skip in _skip_node_and_children prevents mutation of all code inside any decorated class, not just type annotations.
In #302, @boxed noted: "I think there is a case for SOME type hints to be mutated though, like for dataclasses or serializers." — which suggests dataclass method bodies were always intended to be mutable.
Notes
- The trampoline boilerplate (
_mutmut_trampoline,MutantDict, etc.) is still injected into the output for the dataclass cases — only the per-method mutant variants are missing. - Tested with both
@dataclassand@dataclass(frozen=True)— same result. - Standalone functions in the same file are mutated; only methods inside
@dataclass-decorated classes are skipped. - mutmut itself uses
@dataclassinfile_mutation.pyline 16.