diff --git a/README.rst b/README.rst index b226758c..9f024948 100644 --- a/README.rst +++ b/README.rst @@ -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 -- @@ -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``: diff --git a/e2e_projects/my_lib/pyproject.toml b/e2e_projects/my_lib/pyproject.toml index 3e77a5fb..c8a9b096 100644 --- a/e2e_projects/my_lib/pyproject.toml +++ b/e2e_projects/my_lib/pyproject.toml @@ -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" diff --git a/e2e_projects/my_lib/src/my_lib/__init__.py b/e2e_projects/my_lib/src/my_lib/__init__.py index 12ad0f2d..58eeed19 100644 --- a/e2e_projects/my_lib/src/my_lib/__init__.py +++ b/e2e_projects/my_lib/src/my_lib/__init__.py @@ -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: @@ -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!!!') diff --git a/e2e_projects/my_lib/tests/test_my_lib.py b/e2e_projects/my_lib/tests/test_my_lib.py index 8351cd31..9ff2767a 100644 --- a/e2e_projects/my_lib/tests/test_my_lib.py +++ b/e2e_projects/my_lib/tests/test_my_lib.py @@ -1,3 +1,4 @@ +import pytest import inspect from my_lib import * from my_lib import _PrivateClass @@ -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) diff --git a/src/mutmut/configuration.py b/src/mutmut/configuration.py index d3a8ef01..3f581bd9 100644 --- a/src/mutmut/configuration.py +++ b/src/mutmut/configuration.py @@ -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/"), @@ -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] diff --git a/src/mutmut/mutation/file_mutation.py b/src/mutmut/mutation/file_mutation.py index aeae8c16..66a90f88 100644 --- a/src/mutmut/mutation/file_mutation.py +++ b/src/mutmut/mutation/file_mutation.py @@ -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 @@ -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) @@ -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() @@ -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) diff --git a/src/mutmut/mutation/pragma_handling.py b/src/mutmut/mutation/pragma_handling.py index 8d6e1edb..af67f2c9 100644 --- a/src/mutmut/mutation/pragma_handling.py +++ b/src/mutmut/mutation/pragma_handling.py @@ -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 diff --git a/tests/e2e/test_e2e_my_lib.py b/tests/e2e/test_e2e_my_lib.py index ce3a22d9..5c0eddb3 100644 --- a/tests/e2e/test_e2e_my_lib.py +++ b/tests/e2e/test_e2e_my_lib.py @@ -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, } } ) diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index 2689d5c0..7dce84cb 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -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 = """ diff --git a/tests/mutation/test_pragma_handling.py b/tests/mutation/test_pragma_handling.py index 9fec1cbf..081bfd5c 100644 --- a/tests/mutation/test_pragma_handling.py +++ b/tests/mutation/test_pragma_handling.py @@ -4,16 +4,15 @@ import pytest from libcst.metadata import MetadataWrapper +from mutmut.mutation.pragma_handling import IgnoredCode from mutmut.mutation.pragma_handling import PragmaParseError -from mutmut.mutation.pragma_handling import PragmaVisitor +from mutmut.mutation.pragma_handling import get_ignored_lines -def parse_pragmas(filename: str, source: str) -> tuple[set[int], set[int]]: +def _parse_ignored_code(filename: str, source: str) -> IgnoredCode: module = cst.parse_module(source) wrapper = MetadataWrapper(module) - visitor = PragmaVisitor(filename) - wrapper.visit(visitor) - return visitor.no_mutate_lines, visitor.ignore_node_lines + return get_ignored_lines(filename, source, wrapper) class TestParsePragmaLines: @@ -24,18 +23,18 @@ def test_no_pragmas(self): def foo(): return 1 + 1 """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == set() - assert ignore_node_lines == set() + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == set() + assert ignored_code.ignore_node_lines == set() def test_simple_no_mutate(self): source = """ def foo(): return 1 + 1 # pragma: no mutate """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == {3} - assert ignore_node_lines == set() + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {3} + assert ignored_code.ignore_node_lines == set() def test_no_mutate_class(self): source = """ @@ -43,18 +42,18 @@ class Foo: # pragma: no mutate block def method(self): return 1 + 1 """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == set() - assert ignore_node_lines == {2, 3, 4} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == set() + assert ignored_code.ignore_node_lines == {2, 3, 4} def test_no_mutate_function(self): source = """ def foo(): # pragma: no mutate block return 1 + 1 """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == set() - assert ignore_node_lines == {2, 3} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == set() + assert ignored_code.ignore_node_lines == {2, 3} def test_mixed_pragmas(self): source = """ @@ -68,44 +67,44 @@ def skipped_func(): # pragma: no mutate block def mutated(): return 3 + 3 # pragma: no mutate """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == {10} - assert ignore_node_lines == {2, 3, 4, 6, 7} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {10} + assert ignored_code.ignore_node_lines == {2, 3, 4, 6, 7} def test_pragma_no_cover_with_no_mutate(self): source = """ def foo(): return 1 + 1 # pragma: no cover, no mutate """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == {3} - assert ignore_node_lines == set() + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {3} + assert ignored_code.ignore_node_lines == set() def test_single_line_function_body(self): """body is a SimpleStatementSuite.""" source = """ def foo(): pass # pragma: no mutate """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == {2} - assert ignore_node_lines == set() + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {2} + assert ignored_code.ignore_node_lines == set() def test_single_line_if_body(self): source = """ if True: pass # pragma: no mutate """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == {2} - assert ignore_node_lines == set() + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {2} + assert ignored_code.ignore_node_lines == set() def test_other_pragma_ignored(self): source = """ def foo(): return 1 + 1 # pragma: no cover """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == set() - assert ignore_node_lines == set() + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == set() + assert ignored_code.ignore_node_lines == set() class TestBlockPragma: @@ -119,8 +118,8 @@ def test_own_line(self): y = 2 z = 3 """ - no_mutate, _ = parse_pragmas("test.py", source) - assert no_mutate == {3, 4, 5} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {3, 4, 5} def test_own_line_with_colon(self): source = """ @@ -130,8 +129,8 @@ def test_own_line_with_colon(self): y = 2 z = 3 """ - no_mutate, _ = parse_pragmas("test.py", source) - assert no_mutate == {3, 4, 5} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {3, 4, 5} def test_inline(self): source = """ @@ -140,9 +139,9 @@ def test_inline(self): y = 2 z = 3 """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == set() - assert ignore_node_lines == {2, 3, 4} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == set() + assert ignored_code.ignore_node_lines == {2, 3, 4} def test_does_not_affect_code_after_dedent(self): source = """ @@ -154,8 +153,8 @@ def foo(): def bar(): z = 3 """ - no_mutate, _ = parse_pragmas("test.py", source) - assert no_mutate == {3, 4, 5} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {3, 4, 5} def test_does_not_affect_code_before_comment(self): source = """ @@ -164,8 +163,8 @@ def foo(): # pragma: no mutate block y = 2 """ - no_mutate, _ = parse_pragmas("test.py", source) - assert no_mutate == {4, 5} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {4, 5} def test_includes_nested_indentation(self): source = """ @@ -176,8 +175,8 @@ def foo(): y = 2 z = 3 """ - no_mutate, _ = parse_pragmas("test.py", source) - assert no_mutate == {3, 4, 5, 6} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {3, 4, 5, 6} def test_inline_only_ignores_deeper(self): """Inline block pragma on an if-statement: the else branch at the same @@ -189,9 +188,9 @@ def test_inline_only_ignores_deeper(self): else: z = 3 """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == set() - assert ignore_node_lines == {2, 3, 4} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == set() + assert ignored_code.ignore_node_lines == {2, 3, 4} def test_with_other_pragmas(self): source = """ @@ -206,9 +205,9 @@ def foo(): def bar(): z = 3 # pragma: no mutate """ - no_mutate, ignore_node_lines = parse_pragmas("test.py", source) - assert no_mutate == {6, 7, 8, 11} - assert ignore_node_lines == {2, 3} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {6, 7, 8, 11} + assert ignored_code.ignore_node_lines == {2, 3} def test_block_with_triple_quoted_string(self): """Triple-quoted strings with zero-indentation content must not @@ -222,8 +221,8 @@ def foo(): ""\" y = 2 """ - no_mutate, _ = parse_pragmas("test.py", source) - assert no_mutate == {3, 4, 5, 6, 7} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {3, 4, 5, 6, 7} class TestSelectionPragma: @@ -238,8 +237,8 @@ def test_start_end_ignores_enclosed_lines(self): # pragma: no mutate end w = 4 """ - no_mutate, _ = parse_pragmas("test.py", source) - assert no_mutate == {3, 4, 5, 6} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {3, 4, 5, 6} def test_start_end_includes_blank_lines(self): source = """ @@ -250,8 +249,8 @@ def test_start_end_includes_blank_lines(self): # pragma: no mutate end z = 3 """ - no_mutate, _ = parse_pragmas("test.py", source) - assert no_mutate == {2, 3, 4, 5, 6} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {2, 3, 4, 5, 6} def test_start_end_ignores_indentation(self): """Selection mode ignores all lines regardless of indent level.""" @@ -264,8 +263,8 @@ def foo(): # pragma: no mutate end z = 3 """ - no_mutate, _ = parse_pragmas("test.py", source) - assert no_mutate == {3, 4, 5, 6, 7} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {3, 4, 5, 6, 7} def test_end_without_start_raises(self): source = """ @@ -273,7 +272,7 @@ def test_end_without_start_raises(self): # pragma: no mutate end """ with pytest.raises(PragmaParseError, match="without a # pragma: no mutate start"): - parse_pragmas("test.py", source) + _parse_ignored_code("test.py", source) def test_end_without_start_includes_filename(self): source = """ @@ -281,7 +280,7 @@ def test_end_without_start_includes_filename(self): # pragma: no mutate end """ with pytest.raises(PragmaParseError, match="my_module.py:3"): - parse_pragmas("my_module.py", source) + _parse_ignored_code("my_module.py", source) def test_start_end_with_other_pragmas(self): source = """ @@ -292,8 +291,8 @@ def test_start_end_with_other_pragmas(self): # pragma: no mutate end d = 4 """ - no_mutate, _ = parse_pragmas("test.py", source) - assert no_mutate == {2, 3, 4, 5, 6} + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.no_mutate_lines == {2, 3, 4, 5, 6} class TestNestedContextErrors: @@ -307,7 +306,7 @@ def foo(): x = 1 """ with pytest.raises(PragmaParseError, match="test.py:3"): - parse_pragmas("test.py", source) + _parse_ignored_code("test.py", source) def test_start_inside_block_raises(self): source = """ @@ -318,7 +317,7 @@ def foo(): # pragma: no mutate end """ with pytest.raises(PragmaParseError, match="test.py:3"): - parse_pragmas("test.py", source) + _parse_ignored_code("test.py", source) def test_block_inside_selection_raises(self): source = """ @@ -328,7 +327,7 @@ def test_block_inside_selection_raises(self): # pragma: no mutate end """ with pytest.raises(PragmaParseError, match="test.py:2"): - parse_pragmas("test.py", source) + _parse_ignored_code("test.py", source) def test_start_inside_selection_raises(self): source = """ @@ -338,7 +337,7 @@ def test_start_inside_selection_raises(self): # pragma: no mutate end """ with pytest.raises(PragmaParseError, match="test.py:2"): - parse_pragmas("test.py", source) + _parse_ignored_code("test.py", source) def test_error_includes_filename_and_original_context(self): source = """ @@ -348,7 +347,7 @@ def foo(): pass """ with pytest.raises(PragmaParseError) as exc_info: - parse_pragmas("my_module.py", source) + _parse_ignored_code("my_module.py", source) assert "my_module.py:3" in str(exc_info.value) assert "my_module.py:4" in str(exc_info.value) @@ -359,4 +358,33 @@ def test_unclosed_selection_raises(self): y = 2 """ with pytest.raises(PragmaParseError, match="Missing no mutate end"): - parse_pragmas("test.py", source) + _parse_ignored_code("test.py", source) + + +class TestDoNotMutatePattern: + def test_do_not_mutate_pattern_empty(self, patch_config): + source = """ +logger.info("Hello world") +""" + patch_config("do_not_mutate_patterns", []) + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.ignore_pattern_lines == set() + + def test_do_not_mutate_pattern_single_line(self, patch_config): + source = """ +logger.info("Hello world") +""" + patch_config("do_not_mutate_patterns", [r"logger\.\w+\("]) + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.ignore_pattern_lines == {2} + + def test_do_not_mutate_pattern_multiline_node(self, patch_config): + source = """ +def foo(): + logger.info( + "Hello world" + ) +""" + patch_config("do_not_mutate_patterns", [r"logger\.\w+\("]) + ignored_code = _parse_ignored_code("test.py", source) + assert ignored_code.ignore_pattern_lines == {3} diff --git a/tests/test_configuration.py b/tests/test_configuration.py index e8de4c54..b1451eb0 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -59,6 +59,7 @@ def _get_config(only_mutate: list[str], do_not_mutate: list[str]) -> Config: return Config( only_mutate=only_mutate, do_not_mutate=do_not_mutate, + do_not_mutate_patterns=[], also_copy=[], max_stack_depth=-1, debug=False, @@ -293,13 +294,14 @@ def test_raises_when_cannot_guess(self, in_tmp_dir: Path): class TestLoadConfig: def test_loads_all_config_values(self, in_tmp_dir: Path): - (in_tmp_dir / "pyproject.toml").write_text(""" + (in_tmp_dir / "pyproject.toml").write_text(r""" [tool.mutmut] debug = true max_stack_depth = 10 source_paths = ["src"] only_mutate=["**/foo.py"] do_not_mutate = ["**/test_*.py"] +do_not_mutate_patterns = ['logger\.\w+\('] pytest_add_cli_args = ["-x", "--tb=short"] pytest_add_cli_args_test_selection = ["--no-header"] also_copy = ["fixtures"] @@ -317,6 +319,7 @@ def test_loads_all_config_values(self, in_tmp_dir: Path): assert config.source_paths == [Path("src")] assert config.only_mutate == ["**/foo.py"] assert config.do_not_mutate == ["**/test_*.py"] + assert config.do_not_mutate_patterns == [r"logger\.\w+\("] assert config.pytest_add_cli_args == ["-x", "--tb=short"] assert config.pytest_add_cli_args_test_selection == ["--no-header"] assert Path("fixtures") in config.also_copy