Skip to content

Commit c701b42

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 bb33829 commit c701b42

9 files changed

Lines changed: 870 additions & 9 deletions

File tree

README.rst

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,92 @@ whitelist lines are:
226226
to continue, but it's slower.
227227

228228

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

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

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
)

0 commit comments

Comments
 (0)