Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 43 additions & 12 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -231,25 +231,56 @@ macOS at your own risk, or to disable it on other platforms), set ``use_setproct
use_setproctitle = false


Whitelisting
~~~~~~~~~~~~
Disabling mutation on specific code
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

You can mark lines like this:
If you do not want to mutate specific parts of your code, you can disable mutation
via the following options. Examples where this could be relevant:

.. code-block:: python
- If you don't want to test exact log messages (e.g. `logging.info("Foo")` versus the mutated `logging.info("XXFooXX")`)
- Optimizing break instead of continue. The code runs fine when mutating break
to continue, but it's slower.

some_code_here() # pragma: no mutate
Skipping via regex
^^^^^^^^^^^^^^^^^^

to stop mutation on those lines. Some cases we've found where you need to
whitelist lines are:
You can use a regex on the source code, to tell mutmut which expressions it should not mutate:

- The version string on your library. You really shouldn't have a test for this :P
- Optimizing break instead of continue. The code runs fine when mutating break
to continue, but it's slower.
.. code-block:: toml

# pyproject.toml
[tool.mutmut]
do_not_mutate_patterns = [
# disable mutations of all logger.info/debug/... statements
'logger\.\w+',
# disable mutating exceptions
'raise \w+',
]

Mutmut will match the regex on the source code
and skip mutating any expression on the matched lines.

Skipping via code comments
^^^^^^^^^^^^^^^^^^^^^^^^^^

You can use following comments to disable mutation:

- `# pragma: no mutate`: disable mutation on this single line
- `# pragma: no mutate block`: disable mutation in the whole intendation block
- `# pragma: no mutate start/end`: disable mutation between the start and end comments

Skipping single lines
^^^^^^^^^^^^^^^^^^^^^

You can mark lines like this to stop mutating them:

.. code-block:: python

some_code_here() # pragma: no mutate


Skipping Code Blocks
~~~~~~~~~~~~~~~~~~~~
^^^^^^^^^^^^^^^^^^^^

You can skip an entire indentation block from mutation using
``# pragma: no mutate block``. This works on any compound statement --
Expand Down Expand Up @@ -318,7 +349,7 @@ This is useful for:


Skipping Code Regions
~~~~~~~~~~~~~~~~~~~~~
^^^^^^^^^^^^^^^^^^^^^

For suppressing mutations across a range of lines regardless of indentation,
use ``# pragma: no mutate start`` and ``# pragma: no mutate end``:
Expand Down
6 changes: 6 additions & 0 deletions e2e_projects/my_lib/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ dev = [

[tool.mutmut]
debug = true
do_not_mutate_patterns = [
# disable mutations of all logger.info/debug/... statements
'logger\.\w+',
# disable mutating exceptions
'raise \w+',
]

[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function"
28 changes: 28 additions & 0 deletions e2e_projects/my_lib/src/my_lib/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ class AlsoSkipThisClass:

def compute(self) -> int:
return self.VALUE * 2

class Logger:
def info(*args, **kwargs): pass
def debug(*args, **kwargs): pass
def error(*args, **kwargs): pass
# pragma: no mutate end

class _PrivateClass:
Expand All @@ -213,3 +218,26 @@ def get_question(self):
@classmethod
def get_answer(cls):
return 42


logger = Logger()

def divide(a: int, b: int):
# test that logging statements are filtered because of the regex filter in the config
logger.info(f"Computing {a=} / {b=}")
logger.debug(
"This is some very informational"
"multiline debug statement"
)

try:
return a / b
except ZeroDivisionError as e:
logger.error(
e,
extra = dict(
code = 400,
reason = f'{b=}'
),
)
raise Exception(f'Cannot divide if {b=} cannot be 0!!!')
5 changes: 5 additions & 0 deletions e2e_projects/my_lib/tests/test_my_lib.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pytest
import inspect
from my_lib import *
from my_lib import _PrivateClass
Expand Down Expand Up @@ -167,3 +168,7 @@ def test_private_class_method():

def test_private_class_classmethod():
assert _PrivateClass().get_answer() > 0

def test_divide():
with pytest.raises(Exception, match='.*cannot be 0!!!'):
divide(1, 0)
2 changes: 2 additions & 0 deletions src/mutmut/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def _load_config() -> Config:
return Config(
only_mutate=only_mutate,
do_not_mutate=do_not_mutate,
do_not_mutate_patterns=s("do_not_mutate_patterns", []),
also_copy=[Path(y) for y in s("also_copy", [])]
+ [
Path("tests/"),
Expand Down Expand Up @@ -152,6 +153,7 @@ class Config:
also_copy: list[Path]
only_mutate: list[str]
do_not_mutate: list[str]
do_not_mutate_patterns: list[str]
max_stack_depth: int
debug: bool
source_paths: list[Path]
Expand Down
24 changes: 14 additions & 10 deletions src/mutmut/mutation/file_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
from mutmut.mutation.mutators import MethodType
from mutmut.mutation.mutators import get_method_type
from mutmut.mutation.mutators import mutation_operators
from mutmut.mutation.pragma_handling import PragmaVisitor
from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR, build_enum_trampoline
from mutmut.mutation.pragma_handling import IgnoredCode
from mutmut.mutation.pragma_handling import get_ignored_lines
from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR
from mutmut.mutation.trampoline_templates import build_enum_trampoline
from mutmut.mutation.trampoline_templates import build_mutants_dict_and_name
from mutmut.mutation.trampoline_templates import mangle_function_name
from mutmut.mutation.trampoline_templates import trampoline_impl
Expand Down Expand Up @@ -67,14 +69,12 @@ def create_mutations(
module = cst.parse_module(code)
metadata_wrapper = MetadataWrapper(module)

pragma_visitor = PragmaVisitor(filename)
metadata_wrapper.visit(pragma_visitor)
ignored_code = get_ignored_lines(filename, code, metadata_wrapper)

visitor = MutationVisitor(
mutation_operators,
pragma_visitor.no_mutate_lines,
ignored_code,
covered_lines,
pragma_visitor.ignore_node_lines,
)
module = metadata_wrapper.visit(visitor)

Expand Down Expand Up @@ -139,15 +139,15 @@ class MutationVisitor(cst.CSTVisitor):
def __init__(
self,
operators: OPERATORS_TYPE,
ignore_lines: set[int],
ignored_code: IgnoredCode,
covered_lines: set[int] | None = None,
ignored_node_lines: set[int] | None = None,
):
self.mutations: list[Mutation] = []
self._operators = operators
self._ignored_lines = ignore_lines
self._ignored_lines = ignored_code.no_mutate_lines
self._covered_lines = covered_lines
self._ignored_node_lines = ignored_node_lines or set()
self._ignored_node_lines = ignored_code.ignore_node_lines
self._ignored_pattern_lines = ignored_code.ignore_pattern_lines
self.ignored_classes: set[str] = set()
self.ignored_functions: set[str] = set()

Expand Down Expand Up @@ -201,6 +201,10 @@ def _skip_node_and_children(self, node: cst.CSTNode) -> bool:
return True
# other types of nodes (if, elif, for, while, ...) get treated on a line-by-line basis

if position and position.start.line in self._ignored_pattern_lines:
if isinstance(node, cst.BaseExpression):
return True

if (
isinstance(node, cst.Call)
and isinstance(node.func, cst.Name)
Expand Down
37 changes: 37 additions & 0 deletions src/mutmut/mutation/pragma_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,48 @@

from __future__ import annotations

import re
from collections.abc import Sequence
from dataclasses import dataclass

import libcst as cst
from libcst.metadata import PositionProvider

from mutmut.configuration import Config


@dataclass
class IgnoredCode:
"""1-indexed line numbers"""

no_mutate_lines: set[int]
ignore_node_lines: set[int]
ignore_pattern_lines: set[int]


def get_ignored_lines(filename: str, source: str, metadata_wrapper: cst.MetadataWrapper) -> IgnoredCode:
pragma_visitor = PragmaVisitor(filename)
metadata_wrapper.visit(pragma_visitor)

lines_ignored_by_pattern = get_lines_ignored_by_pattern(source)

return IgnoredCode(
no_mutate_lines=pragma_visitor.no_mutate_lines,
ignore_node_lines=pragma_visitor.ignore_node_lines,
ignore_pattern_lines=lines_ignored_by_pattern,
)


def get_lines_ignored_by_pattern(source: str) -> set[int]:
matching_lines = set()
for pattern in Config.get().do_not_mutate_patterns:
compiled_pattern = re.compile(pattern)
for i, line in enumerate(source.splitlines()):
if compiled_pattern.search(line):
matching_lines.add(i + 1)

return matching_lines


class PragmaParseError(Exception):
pass
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/test_e2e_my_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ def test_my_lib_result_snapshot():
"my_lib.xǁ_PrivateClassǁget_question__mutmut_2": 0,
"my_lib.xǁ_PrivateClassǁget_question__mutmut_3": 0,
"my_lib.xǁ_PrivateClassǁget_answer__mutmut_1": 0,
"my_lib.x_divide__mutmut_1": 1,
}
}
)
59 changes: 59 additions & 0 deletions tests/mutation/test_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,65 @@ def x(self):
assert not mutants


def test_do_not_mutate_pattern_single_line(patch_config):
source = 'logger.info("hello")'
patch_config("do_not_mutate_patterns", [r"logger\.\w+\("])

mutants = mutants_for_source(source)
assert not mutants


def test_do_not_mutate_pattern_multiline(patch_config):
source = """
logger.info(
"hello"
)
"""
patch_config("do_not_mutate_patterns", [r"logger\.\w+\("])

mutants = mutants_for_source(source)
assert not mutants


def test_do_not_mutate_pattern_mutates_other_code(patch_config):
# ignoring the logging statement should not prevent mutatoin of code around the logging statement
source = """
def foo():
a = 1
logger.info(
"hello"
)
b = 1
"""
patch_config("do_not_mutate_patterns", [r"logger\.\w+\("])

mutants = set(mutants_for_source(source))
a_mutants = {m for m in mutants if "a = 1" not in m}
b_mutants = {m for m in mutants if "b = 1" not in m}
remaining_mutants = mutants - a_mutants - b_mutants
# we mutated a = 1 and b = 1
assert a_mutants
assert b_mutants
# we did not do any other mutation (logger.info should not be mutated)
assert not remaining_mutants


def test_do_not_mutate_pattern_only_skips_if_condition(patch_config):
# we should not skip the if body, only the line of the condition
source = """
def foo():
if some_comparison(1, 2, 3, 4):
return 1
"""
patch_config("do_not_mutate_patterns", [r"some_comparison"])

mutants = set(mutants_for_source(source))
return_mutants = {m for m in mutants if "return 1" not in m}
# we should only mutate the if body (not the comparison)
assert return_mutants
assert return_mutants == mutants


@pytest.mark.skip(reason="Feature not yet implemented")
def test_decorated_inner_functions_mutation():
source = """
Expand Down
Loading
Loading