diff --git a/README.rst b/README.rst index 1e76b4fd..97d07102 100644 --- a/README.rst +++ b/README.rst @@ -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 ^^^^^^^^^^^^^^^^^^ diff --git a/e2e_projects/config/pyproject.toml b/e2e_projects/config/pyproject.toml index cb361167..78a8095b 100644 --- a/e2e_projects/config/pyproject.toml +++ b/e2e_projects/config/pyproject.toml @@ -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 diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index c8563bd7..12175267 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -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") diff --git a/src/mutmut/configuration.py b/src/mutmut/configuration.py index e5710dd8..ce048527 100644 --- a/src/mutmut/configuration.py +++ b/src/mutmut/configuration.py @@ -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") @@ -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, @@ -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, ) @@ -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 @@ -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) diff --git a/src/mutmut/mutation/file_mutation.py b/src/mutmut/mutation/file_mutation.py index 3613565a..52f8d8f6 100644 --- a/src/mutmut/mutation/file_mutation.py +++ b/src/mutmut/mutation/file_mutation.py @@ -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): diff --git a/src/mutmut/mutation/mutators.py b/src/mutmut/mutation/mutators.py index 40b074d9..300a66e3 100644 --- a/src/mutmut/mutation/mutators.py +++ b/src/mutmut/mutation/mutators.py @@ -13,6 +13,7 @@ OPERATORS_TYPE = Sequence[ tuple[ type, + str, Callable[[Any], Iterable[cst.CSTNode]], ] ] @@ -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 @@ -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")) @@ -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]] diff --git a/tests/e2e/test_e2e_config.py b/tests/e2e/test_e2e_config.py index 383654cc..970a8241 100644 --- a/tests/e2e/test_e2e_config.py +++ b/tests/e2e/test_e2e_config.py @@ -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, diff --git a/tests/mutation/test_mutation.py b/tests/mutation/test_mutation.py index 5db569d4..f8d71239 100644 --- a/tests/mutation/test_mutation.py +++ b/tests/mutation/test_mutation.py @@ -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: @@ -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() diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 21a3f74b..77d9753b 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -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 """) @@ -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 @@ -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()