Skip to content
Open
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
35 changes: 35 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,41 @@ via the following options. Examples where this could be relevant:
- Optimizing break instead of continue. The code runs fine when mutating break
to continue, but it's slower.

Skipping mutation types
^^^^^^^^^^^^^^^^^^^^^^^

You can disable mutation types that are not useful for your codebase. For example,
SQL keywords and HTTP header names are often case-insensitive, so their lowercase
and uppercase string mutants may be equivalent:

.. code-block:: toml

# pyproject.toml
[tool.mutmut]
disable_mutation_types = ["string.lower", "string.upper"]

Mutation types use dotted groups. A group name disables every type below it, so
``disable_mutation_types = ["string"]`` disables all string mutations.

The available mutation types are:

- ``argument.keyword``
- ``argument.removal``
- ``assignment``
- ``assignment.augmented``
- ``if_expression``
- ``keyword``
- ``lambda``
- ``match``
- ``name``
- ``number``
- ``operator``
- ``operator.unary``
- ``string.lower``
- ``string.method.swap``
- ``string.upper``
- ``string.wrap``

Skipping via regex
^^^^^^^^^^^^^^^^^^

Expand Down
1 change: 1 addition & 0 deletions e2e_projects/config/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ debug = true
source_paths = [ "config_pkg/" ]
only_mutate = [ "config_pkg/logic/*" ]
do_not_mutate = [ "*ignore*" ]
disable_mutation_types = [ "string.lower", "string.upper" ]
also_copy = [ "data" ]
max_stack_depth=3
# verify that we can override options with pytest_add_cli_args
Expand Down
4 changes: 4 additions & 0 deletions src/mutmut/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,10 @@ def _apply_config_change_invalidation(mutants_caught_by_type_checker: dict[str,
state().function_dependencies.clear()
return True

# Filtering can shift the numeric IDs of every later mutant in a function.
if "mutant_generation" in changed_groups:
_reset_mutant_results(lambda key, exit_code: True)

# Timeout config only reclassifies timeouts; keep every other verdict.
if "timeout" in changed_groups:
_reset_mutant_results(lambda key, exit_code: status_by_exit_code[exit_code] == "timeout")
Expand Down
32 changes: 32 additions & 0 deletions src/mutmut/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,31 @@
from configparser import NoOptionError
from configparser import NoSectionError
from dataclasses import dataclass
from dataclasses import field
from os.path import isdir
from os.path import isfile
from pathlib import Path
from typing import Any

from mutmut.mutation.mutators import mutation_type_names


def _mutation_type_matches(configured_type: str, mutation_type: str) -> bool:
return mutation_type == configured_type or mutation_type.startswith(f"{configured_type}.")


def _validate_mutation_types(configured_types: list[str]) -> None:
invalid_types = [
configured_type
for configured_type in configured_types
if not any(_mutation_type_matches(configured_type, mutation_type) for mutation_type in mutation_type_names)
]
if invalid_types:
available_types = ", ".join(sorted(mutation_type_names))
raise ValueError(
f"Unknown mutation type(s): {', '.join(invalid_types)}. Available mutation types: {available_types}"
)


def _config_reader() -> Callable[[str, Any], Any]:
path = Path("pyproject.toml")
Expand Down Expand Up @@ -122,6 +142,9 @@ def _load_config() -> Config:
f'The configs only_mutate and do_not_mutate expect glob patterns like "src/api/*" or "src/main.py". Following patterns are likely invalid: {invalid_patterns}'
)

disable_mutation_types = s("disable_mutation_types", [])
_validate_mutation_types(disable_mutation_types)

return Config(
only_mutate=only_mutate,
do_not_mutate=do_not_mutate,
Expand Down Expand Up @@ -157,6 +180,7 @@ def _load_config() -> Config:
cache_invalidation_exclude=s("cache_invalidation_exclude", []),
on_dependency_change=s("on_dependency_change", "warn"),
use_git_change_detection=s("use_git_change_detection", True),
disable_mutation_types=disable_mutation_types,
)


Expand Down Expand Up @@ -186,6 +210,7 @@ class Config:
cache_invalidation_exclude: list[str]
on_dependency_change: str
use_git_change_detection: bool
disable_mutation_types: list[str] = field(default_factory=list)

def config_fingerprint(self) -> dict[str, str]:
"""Hash the config fields that can change cached mutant *results*, grouped so the
Expand All @@ -208,8 +233,15 @@ def _hash(value: object) -> str:
"timeout": _hash((self.timeout_multiplier, self.timeout_constant)),
# only changes the type-check pre-filter
"type_check": _hash(tuple(self.type_check_command)),
# Filtering changes mutant numbering, so no cached verdict is safe to reuse.
"mutant_generation": _hash(tuple(self.disable_mutation_types)),
}

def should_mutate_type(self, mutation_type: str) -> bool:
return not any(
_mutation_type_matches(disabled_type, mutation_type) for disabled_type in self.disable_mutation_types
)

def should_mutate(self, path: Path | str) -> bool:
return self._should_include_for_mutation(path) and not self._should_ignore_for_mutation(path)

Expand Down
4 changes: 3 additions & 1 deletion src/mutmut/mutation/file_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,9 @@ def on_visit(self, node: cst.CSTNode) -> bool:
return True

def _create_mutations(self, node: cst.CSTNode) -> None:
for t, operator in self._operators:
for t, mutation_type, operator in self._operators:
if not config().should_mutate_type(mutation_type):
continue
if isinstance(node, t):
for mutated_node in operator(node):
if self._removes_ignored_code(node, mutated_node):
Expand Down
72 changes: 42 additions & 30 deletions src/mutmut/mutation/mutators.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
OPERATORS_TYPE = Sequence[
tuple[
type,
str,
Callable[[Any], Iterable[cst.CSTNode]],
]
]
Expand All @@ -30,7 +31,7 @@ def operator_number(node: cst.BaseNumber) -> Iterable[cst.BaseNumber]:
print("Unexpected number type", node)


def operator_string(node: cst.BaseString) -> Iterable[cst.BaseString]:
def _operator_string(node: cst.BaseString, mutate: Callable[[str], str]) -> Iterable[cst.BaseString]:
if isinstance(node, cst.SimpleString):
value = node.value
old_value = value
Expand All @@ -42,22 +43,29 @@ def operator_string(node: cst.BaseString) -> Iterable[cst.BaseString]:
# that mutation is meaningless for
return

supported_str_mutations: list[Callable[[str], str]] = [
lambda x: "XX" + x + "XX",
# do not modify escape sequences, as this could break python syntax
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).lower(), x),
lambda x: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).upper(), x),
]

for mut_func in supported_str_mutations:
new_value = f"{prefix}{value[0]}{mut_func(value[1:-1])}{value[-1]}"
if new_value == value:
continue
if new_value == old_value:
continue
new_value = f"{prefix}{value[0]}{mutate(value[1:-1])}{value[-1]}"
if new_value != value and new_value != old_value:
yield node.with_changes(value=new_value)


def operator_string_wrap(node: cst.BaseString) -> Iterable[cst.BaseString]:
yield from _operator_string(node, lambda value: "XX" + value + "XX")


def operator_string_lower(node: cst.BaseString) -> Iterable[cst.BaseString]:
# Escape sequences must retain their spelling so the mutation cannot break Python syntax.
yield from _operator_string(
node, lambda value: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).lower(), value)
)


def operator_string_upper(node: cst.BaseString) -> Iterable[cst.BaseString]:
# Escape sequences must retain their spelling so the mutation cannot break Python syntax.
yield from _operator_string(
node, lambda value: NON_ESCAPE_SEQUENCE.sub(lambda match: match.group(1).upper(), value)
)


def operator_lambda(node: cst.Lambda) -> Iterable[cst.Lambda]:
if m.matches(node, m.Lambda(body=m.Name("None"))):
yield node.with_changes(body=cst.Integer("0"))
Expand Down Expand Up @@ -271,24 +279,28 @@ def operator_if_exp(node: cst.IfExp) -> Iterable[cst.IfExp]:

# Operators that should be called on specific node types
mutation_operators: OPERATORS_TYPE = [
(cst.BaseNumber, operator_number),
(cst.BaseString, operator_string),
(cst.Name, operator_name),
(cst.Assign, operator_assignment),
(cst.AnnAssign, operator_assignment),
(cst.AugAssign, operator_augmented_assignment),
(cst.UnaryOperation, operator_remove_unary_ops),
(cst.Call, operator_dict_arguments),
(cst.Call, operator_arg_removal),
(cst.Call, operator_symmetric_string_methods_swap),
(cst.Call, operator_unsymmetrical_string_methods_swap),
(cst.Lambda, operator_lambda),
(cst.CSTNode, operator_keywords),
(cst.CSTNode, operator_swap_op),
(cst.Match, operator_match),
(cst.IfExp, operator_if_exp),
(cst.BaseNumber, "number", operator_number),
(cst.BaseString, "string.wrap", operator_string_wrap),
(cst.BaseString, "string.lower", operator_string_lower),
(cst.BaseString, "string.upper", operator_string_upper),
(cst.Name, "name", operator_name),
(cst.Assign, "assignment", operator_assignment),
(cst.AnnAssign, "assignment", operator_assignment),
(cst.AugAssign, "assignment.augmented", operator_augmented_assignment),
(cst.UnaryOperation, "operator.unary", operator_remove_unary_ops),
(cst.Call, "argument.keyword", operator_dict_arguments),
(cst.Call, "argument.removal", operator_arg_removal),
(cst.Call, "string.method.swap", operator_symmetric_string_methods_swap),
(cst.Call, "string.method.swap", operator_unsymmetrical_string_methods_swap),
(cst.Lambda, "lambda", operator_lambda),
(cst.CSTNode, "keyword", operator_keywords),
(cst.CSTNode, "operator", operator_swap_op),
(cst.Match, "match", operator_match),
(cst.IfExp, "if_expression", operator_if_exp),
]

mutation_type_names = frozenset(mutation_type for _, mutation_type, _ in mutation_operators)


def _simple_mutation_mapping(
node: cst.CSTNode, mapping: dict[type[cst.CSTNode], type[cst.CSTNode]]
Expand Down
2 changes: 0 additions & 2 deletions tests/e2e/test_e2e_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ def test_config_result_snapshot():
{
"mutants/config_pkg/logic/__init__.py.meta": {
"config_pkg.logic.x_hello__mutmut_1": 1,
"config_pkg.logic.x_hello__mutmut_2": 1,
"config_pkg.logic.x_hello__mutmut_3": 1,
},
"mutants/config_pkg/logic/math.py.meta": {
"config_pkg.logic.math.x_add__mutmut_1": 0,
Expand Down
39 changes: 39 additions & 0 deletions tests/mutation/test_mutation.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,25 @@ def test_basic_mutations(original, expected):
assert sorted(mutants) == sorted(expected)


def test_disable_specific_mutation_type(patch_config):
patch_config("disable_mutation_types", ["string.lower"])

assert sorted(mutants_for_source('"FoO"')) == ['"FOO"', '"XXFoOXX"']


def test_disable_mutation_type_group(patch_config):
patch_config("disable_mutation_types", ["string"])

assert mutants_for_source('"FoO"') == []
assert mutants_for_source("value.lower()") == []


def test_disable_unrelated_mutation_type(patch_config):
patch_config("disable_mutation_types", ["number"])

assert sorted(mutants_for_source('"FoO"')) == ['"FOO"', '"XXFoOXX"', '"foo"']


def test_do_not_mutate_annotations():
source = """
def foo() -> int:
Expand Down Expand Up @@ -1674,6 +1693,26 @@ def test_timeout_config_change_resets_only_timeouts(tmp_path, monkeypatch):
reset_state()


def test_mutation_type_config_change_resets_all_results(tmp_path, monkeypatch):
reset_state()
monkeypatch.chdir(tmp_path)
old_cfg = _config_for_invalidation()
state().old_config_fingerprint = old_cfg.config_fingerprint()

monkeypatch.setattr(
mutmut.__main__,
"config",
lambda: _config_for_invalidation(disable_mutation_types=["string.lower"]),
)
_write_meta({"first": 1, "second": 0, "uncached": None})

force_full = _apply_config_change_invalidation({})

assert force_full is False
assert _load_results() == {"first": None, "second": None, "uncached": None}
reset_state()


def test_type_check_config_change_resets_symmetric_difference(tmp_path, monkeypatch):
"""A type-check command change re-tests verdicts whose type-check status flips."""
reset_state()
Expand Down
14 changes: 14 additions & 0 deletions tests/test_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ def test_loads_all_config_values(self, in_tmp_dir: Path):
also_copy = ["fixtures"]
mutate_only_covered_lines = true
type_check_command = ["mypy", "--strict"]
disable_mutation_types = ["string.lower", "string.upper"]
timeout_multiplier = 5.0
timeout_constant = 0.5
""")
Expand All @@ -326,6 +327,7 @@ def test_loads_all_config_values(self, in_tmp_dir: Path):
assert Path("fixtures") in config.also_copy
assert config.mutate_only_covered_lines is True
assert config.type_check_command == ["mypy", "--strict"]
assert config.disable_mutation_types == ["string.lower", "string.upper"]
assert config.timeout_multiplier == 5.0
assert config.timeout_constant == 0.5

Expand All @@ -343,9 +345,21 @@ def test_uses_defaults_when_no_config(self, in_tmp_dir: Path):
assert config.timeout_multiplier == 15.0
assert config.timeout_constant == 1.0
assert config.type_check_command == []
assert config.disable_mutation_types == []
assert config.track_dependencies is True
assert config.dependency_tracking_depth == -1

def test_rejects_unknown_mutation_type(self, in_tmp_dir: Path):
(in_tmp_dir / "pyproject.toml").write_text("""
[tool.mutmut]
source_paths = ["src"]
disable_mutation_types = ["string.typo"]
""")
(in_tmp_dir / "src").mkdir()

with pytest.raises(ValueError, match=r"Unknown mutation type\(s\): string\.typo"):
_load_config()

def test_also_copy_includes_defaults(self, in_tmp_dir: Path):
(in_tmp_dir / "src").mkdir()

Expand Down