Skip to content

Commit 072319e

Browse files
committed
Add enum, static/class method support, and pragma: no mutate: class/function
Features: - Add enum class detection and external injection pattern for enum mutation - Add staticmethod/classmethod support via external trampoline pattern - Add parse_pragma_lines() for pragma: no mutate class/function - Add build_enum_trampoline() template Refactoring: - Extract pragma_handling.py: parse_pragma_lines() - Add utils/format_utils.py: make_mutant_key(), parse_mutant_key() - Simplify orig_function_and_class_names_from_key() using parse_mutant_key() Tests: - Add test_enum_handling.py mirroring enum_handling module - Add test_pragma_handling.py mirroring pragma_handling module Config: - Exclude AUTHORS.rst from merge conflict check in pre-commit
1 parent a10062b commit 072319e

12 files changed

Lines changed: 907 additions & 26 deletions

README.rst

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,33 @@ it will try to figure out where the code to mutate is.
5353

5454

5555
You can stop the mutation run at any time and mutmut will restart where you
56-
left off. It will continue where it left off, and re-test functions that were
57-
modified since last run.
56+
left off.
57+
58+
Incremental Testing
59+
~~~~~~~~~~~~~~~~~~~
60+
61+
Mutmut is designed for incremental workflows. It remembers which mutants have
62+
been tested and their results, so subsequent runs skip already-tested mutants.
63+
64+
**Function-level change detection:** Mutmut computes a hash of each function's
65+
source code. When you modify a function, mutmut detects the change and
66+
automatically re-tests all mutants in that function. Unchanged functions keep
67+
their previous results.
68+
69+
**Limitation:** Change detection only tracks direct function changes, not
70+
transitive dependencies. If function A calls function B, and you modify B,
71+
mutants in A are not automatically re-tested. For significant changes to
72+
shared utilities, use ``mutmut run "module*"`` to re-test affected modules,
73+
or delete the ``mutants/`` directory for a full re-run.
74+
75+
This means you can:
76+
77+
- Run ``mutmut run``, stop partway through, and continue later
78+
- Modify your source code and re-run - only changed functions are re-tested
79+
- Update your tests and use ``mutmut browse`` to selectively re-test mutants
80+
81+
The mutation data is stored in the ``mutants/`` directory. Delete this
82+
directory to start completely fresh.
5883

5984
To work with the results, use `mutmut browse` where you can see the mutants,
6085
retest them when you've updated your tests.
@@ -226,6 +251,92 @@ whitelist lines are:
226251
to continue, but it's slower.
227252

228253

254+
Enum Classes and Metaclass Compatibility
255+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
256+
257+
Mutmut 3.x fully supports mutating enum classes. Methods inside enum classes
258+
(``Enum``, ``IntEnum``, ``Flag``, ``IntFlag``, ``StrEnum``) are automatically
259+
mutated using an external injection pattern that avoids conflicts with the
260+
enum metaclass.
261+
262+
This means enums with methods like:
263+
264+
.. code-block:: python
265+
266+
from enum import Enum
267+
268+
class Color(Enum):
269+
RED = 1
270+
GREEN = 2
271+
272+
def describe(self):
273+
return self.name.lower()
274+
275+
@staticmethod
276+
def count():
277+
return 3
278+
279+
...will have their methods mutated just like regular class methods.
280+
281+
**Disabling Enum Mutation**
282+
283+
If you prefer to skip enum mutation entirely, you can disable it in your config:
284+
285+
.. code-block:: toml
286+
287+
# pyproject.toml
288+
[tool.mutmut]
289+
mutate_enums = false
290+
291+
Or skip a specific enum class using the pragma:
292+
293+
.. code-block:: python
294+
295+
class Color(Enum): # pragma: no mutate class
296+
RED = 1
297+
GREEN = 2
298+
299+
def describe(self):
300+
return f"Color is {self.name}"
301+
302+
This tells mutmut to completely skip the class—no mutations will be created
303+
for any methods.
304+
305+
Both syntax styles are supported:
306+
307+
- ``# pragma: no mutate class``
308+
- ``# pragma: no mutate: class``
309+
310+
**Note:** The regular ``# pragma: no mutate`` on a class line only prevents
311+
mutations on that specific line. It does NOT prevent mutations inside methods.
312+
Use ``# pragma: no mutate class`` to skip the entire class (kept for backward
313+
compatibility with <v3.5.0).
314+
315+
316+
Skipping Entire Functions
317+
~~~~~~~~~~~~~~~~~~~~~~~~~
318+
319+
Similarly, you can skip an entire function from mutation using
320+
``# pragma: no mutate function``:
321+
322+
.. code-block:: python
323+
324+
def complex_algorithm(): # pragma: no mutate function
325+
# This function won't be mutated at all
326+
return some_complex_calculation()
327+
328+
Both syntax styles are supported:
329+
330+
- ``# pragma: no mutate function``
331+
- ``# pragma: no mutate: function``
332+
333+
This is useful for functions that:
334+
335+
- Have complex side effects that make mutation testing impractical
336+
- Are performance-critical and you want to avoid trampoline overhead
337+
- Are known to cause issues with the mutation testing framework
338+
339+
229340
Modifying pytest arguments
230341
~~~~~~~~~~~~~~~~~~~~~~~~~~
231342

e2e_projects/my_lib/src/my_lib/__init__.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
from collections.abc import Callable
2+
from enum import Enum
23
from functools import cache
34
from typing import Union
45
import ctypes
56
import asyncio
67

78

9+
def my_decorator(func): # pragma: no mutate: function
10+
return func
11+
12+
813
def hello() -> str:
914
return "Hello from my-lib!"
1015

@@ -14,6 +19,13 @@ def badly_tested() -> str:
1419
def untested() -> str:
1520
return "Mutants for this method should survive"
1621

22+
def skip_this_function() -> int: # pragma: no mutate: function
23+
return 1 + 2 * 3
24+
25+
def also_skip_this_function() -> str: # pragma: no mutate function
26+
return "should" + " not" + " mutate"
27+
28+
1729
def make_greeter(name: Union[str, None]) -> Callable[[], str]:
1830
def hi():
1931
if name:
@@ -88,6 +100,30 @@ def from_coords(coords) -> 'Point':
88100
def coords(self):
89101
return self.x, self.y
90102

103+
@staticmethod
104+
def skip_static_decorator_pragma(a: int, b: int) -> int: # pragma: no mutate: function
105+
return a + b * 2
106+
107+
@classmethod
108+
def skip_class_decorator_pragma(cls, value: int) -> "Point": # pragma: no mutate: function
109+
return cls(value + 1, value * 2)
110+
111+
def skip_instance_method_pragma(self) -> int: # pragma: no mutate: function
112+
return self.x + self.y * 2
113+
114+
@staticmethod # pragma: no mutate: function
115+
def pragma_on_staticmethod_decorator(a: int, b: int) -> int:
116+
return a + b * 2
117+
118+
@classmethod # pragma: no mutate: function
119+
def pragma_on_classmethod_decorator(cls, value: int) -> "Point":
120+
return cls(value + 1, value * 2)
121+
122+
@my_decorator
123+
@classmethod
124+
def skip_multi_decorator(cls, value: int) -> "Point":
125+
return cls(value + 1, value * 2)
126+
91127

92128
def escape_sequences():
93129
return "foo" \
@@ -111,3 +147,42 @@ def func_with_star(a, /, b, *, c, **kwargs):
111147
def func_with_arbitrary_args_clone(*args, **kwargs): pass # pragma: no mutate
112148
def func_with_arbitrary_args(*args, **kwargs):
113149
return len(args) + len(kwargs)
150+
151+
152+
class Color(Enum):
153+
RED = 1
154+
GREEN = 2
155+
BLUE = 3
156+
157+
def is_primary(self) -> bool:
158+
return self in (Color.RED, Color.GREEN, Color.BLUE)
159+
160+
def darken(self) -> int:
161+
return self.value - 1
162+
163+
@staticmethod
164+
def from_name(name: str) -> "Color":
165+
return Color[name.upper()]
166+
167+
@classmethod
168+
def default(cls) -> "Color":
169+
return cls.RED
170+
171+
172+
class SkipThisClass: # pragma: no mutate: class
173+
def method_one(self) -> int:
174+
return 1 + 2
175+
176+
def method_two(self) -> str:
177+
return "hello" + " world"
178+
179+
@staticmethod
180+
def static_method() -> int:
181+
return 3 * 4
182+
183+
184+
class AlsoSkipThisClass: # pragma: no mutate class
185+
VALUE = 10 + 20
186+
187+
def compute(self) -> int:
188+
return self.VALUE * 2

e2e_projects/my_lib/tests/test_my_lib.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,28 @@ def test_point():
3131
def test_point_from_coords():
3232
assert Point.from_coords((1, 2)).x == 1
3333

34+
35+
def test_point_skip_static_decorator_pragma():
36+
assert Point.skip_static_decorator_pragma(3, 4) == 11
37+
38+
39+
def test_point_skip_class_decorator_pragma():
40+
p = Point.skip_class_decorator_pragma(5)
41+
assert p.x == 6
42+
assert p.y == 10
43+
44+
45+
def test_point_skip_instance_method_pragma():
46+
p = Point(3, 4)
47+
assert p.skip_instance_method_pragma() == 11
48+
49+
50+
def test_point_skip_multi_decorator():
51+
p = Point.skip_multi_decorator(5)
52+
assert p.x == 6
53+
assert p.y == 10
54+
55+
3456
def test_fibonacci():
3557
assert fibonacci(1) == 1
3658
assert cached_fibonacci(1) == 1
@@ -66,3 +88,60 @@ def test_signature_functions_are_callable():
6688

6789
def test_signature_is_coroutine():
6890
assert asyncio.iscoroutinefunction(async_consumer)
91+
92+
93+
# Tests for enum mutation
94+
def test_color_enum_values():
95+
assert Color.RED.value == 1
96+
assert Color.GREEN.value == 2
97+
assert Color.BLUE.value == 3
98+
99+
100+
def test_color_is_primary():
101+
assert Color.RED.is_primary() is True
102+
assert Color.GREEN.is_primary() is True
103+
104+
105+
def test_color_darken():
106+
assert Color.GREEN.darken() == 1
107+
assert Color.BLUE.darken() == 2
108+
109+
110+
def test_color_from_name():
111+
assert Color.from_name("red") == Color.RED
112+
assert Color.from_name("BLUE") == Color.BLUE
113+
114+
115+
def test_color_default():
116+
assert Color.default() == Color.RED
117+
118+
119+
def test_skip_this_function():
120+
assert skip_this_function() == 7
121+
122+
123+
def test_also_skip_this_function():
124+
assert also_skip_this_function() == "should not mutate"
125+
126+
127+
def test_skip_this_class():
128+
obj = SkipThisClass()
129+
assert obj.method_one() == 3
130+
assert obj.method_two() == "hello world"
131+
assert SkipThisClass.static_method() == 12
132+
133+
134+
def test_also_skip_this_class():
135+
obj = AlsoSkipThisClass()
136+
assert obj.VALUE == 30
137+
assert obj.compute() == 60
138+
139+
140+
def test_pragma_on_staticmethod_decorator():
141+
assert Point.pragma_on_staticmethod_decorator(3, 4) == 11
142+
143+
144+
def test_pragma_on_classmethod_decorator():
145+
p = Point.pragma_on_classmethod_decorator(5)
146+
assert p.x == 6
147+
assert p.y == 10

src/mutmut/mutation/trampoline_templates.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,7 @@ def {orig_name}({self_prefix}*args, **kwargs):
7575
7676
{body}
7777
78-
{orig_name}.__signature__ = _mutmut_signature({mangled_name}__mutmut_orig)
79-
{orig_name}.__annotations__ = {mangled_name}__mutmut_orig.__annotations__
78+
{orig_name} = _mutmut_wraps({mangled_name}__mutmut_orig)({orig_name})
8079
{mangled_name}__mutmut_orig.__name__ = '{mangled_name}'
8180
""")
8281

@@ -132,7 +131,7 @@ def {prefix}_trampoline(self, *args, **kwargs):
132131
# noinspection PyUnresolvedReferences
133132
# language=python
134133
trampoline_impl = _mark_generated("""
135-
from inspect import signature as _mutmut_signature
134+
from functools import wraps as _mutmut_wraps
136135
from typing import Annotated
137136
from typing import Callable
138137
from typing import ClassVar

tests/e2e/test_e2e_my_lib.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ def test_my_lib_result_snapshot():
7070
"my_lib.xǁPointǁfrom_coords__mutmut_4": 1,
7171
"my_lib.xǁPointǁfrom_coords__mutmut_5": 1,
7272
"my_lib.xǁPointǁfrom_coords__mutmut_6": 1,
73+
"my_lib.xǁPointǁpragma_on_staticmethod_decorator__mutmut_1": 1,
74+
"my_lib.xǁPointǁpragma_on_staticmethod_decorator__mutmut_2": 1,
75+
"my_lib.xǁPointǁpragma_on_staticmethod_decorator__mutmut_3": 1,
76+
"my_lib.xǁPointǁpragma_on_classmethod_decorator__mutmut_1": 1,
77+
"my_lib.xǁPointǁpragma_on_classmethod_decorator__mutmut_2": 1,
78+
"my_lib.xǁPointǁpragma_on_classmethod_decorator__mutmut_3": 1,
79+
"my_lib.xǁPointǁpragma_on_classmethod_decorator__mutmut_4": 1,
80+
"my_lib.xǁPointǁpragma_on_classmethod_decorator__mutmut_5": 1,
81+
"my_lib.xǁPointǁpragma_on_classmethod_decorator__mutmut_6": 1,
82+
"my_lib.xǁPointǁpragma_on_classmethod_decorator__mutmut_7": 1,
83+
"my_lib.xǁPointǁpragma_on_classmethod_decorator__mutmut_8": 1,
7384
"my_lib.x_escape_sequences__mutmut_1": 1,
7485
"my_lib.x_escape_sequences__mutmut_2": 0,
7586
"my_lib.x_escape_sequences__mutmut_3": 1,
@@ -85,6 +96,10 @@ def test_my_lib_result_snapshot():
8596
"my_lib.x_func_with_star__mutmut_2": 1,
8697
"my_lib.x_func_with_star__mutmut_3": 1,
8798
"my_lib.x_func_with_arbitrary_args__mutmut_1": 1,
99+
"my_lib.xǁColorǁis_primary__mutmut_1": 1,
100+
"my_lib.xǁColorǁdarken__mutmut_1": 1,
101+
"my_lib.xǁColorǁdarken__mutmut_2": 1,
102+
"my_lib.xǁColorǁfrom_name__mutmut_1": 1,
88103
}
89104
}
90105
)

tests/e2e/test_e2e_type_checking.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ def test_type_checking_result_snapshot():
1515
"type_checking.x_a_hello_wrapper__mutmut_2": 0,
1616
"type_checking.xǁPersonǁset_name__mutmut_1": 37,
1717
"type_checking.x_mutate_me__mutmut_1": 37,
18-
"type_checking.x_mutate_me__mutmut_2": 1,
19-
"type_checking.x_mutate_me__mutmut_3": 1,
20-
"type_checking.x_mutate_me__mutmut_4": 1,
18+
"type_checking.x_mutate_me__mutmut_2": 37,
19+
"type_checking.x_mutate_me__mutmut_3": 37,
20+
"type_checking.x_mutate_me__mutmut_4": 37,
2121
"type_checking.x_mutate_me__mutmut_5": 37,
2222
}
2323
}

0 commit comments

Comments
 (0)