From 63a379070d079f2d0dfa0736d427162a4675ceb8 Mon Sep 17 00:00:00 2001 From: A_A <21040751+Otto-AA@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:10:28 +0200 Subject: [PATCH 1/3] rename paths_to_mutate to source_paths --- ARCHITECTURE.rst | 2 +- README.rst | 4 +- e2e_projects/config/pyproject.toml | 2 +- .../mutate_only_covered_lines/pyproject.toml | 2 +- src/mutmut/__main__.py | 6 ++- src/mutmut/configuration.py | 15 ++++++-- tests/test_configuration.py | 38 +++++++++---------- 7 files changed, 39 insertions(+), 30 deletions(-) diff --git a/ARCHITECTURE.rst b/ARCHITECTURE.rst index 8d1f04a7..e38562bc 100644 --- a/ARCHITECTURE.rst +++ b/ARCHITECTURE.rst @@ -11,7 +11,7 @@ Generating mutants This phase creates a ``./mutants/`` directory, which will be used by all following phases. -We start by copying ``paths_to_mutate`` to ``mutants/`` and then mutate the ``*.py`` files in there. Finally, we also copy ``also_copy`` paths to ``mutants/``, including the (guessed) test directories and some project files. +We start by copying ``source_paths`` to ``mutants/`` and then mutate the ``*.py`` files in there. Finally, we also copy ``also_copy`` paths to ``mutants/``, including the (guessed) test directories and some project files. The mutated files contains the original code and the mutants. With the ``MUTANT_UNDER_TEST`` environment variable, we can specify (among other things) which mutant should be enabled. If a mutant is not enabled, it will run the original code. diff --git a/README.rst b/README.rst index e4de28ee..fb8525f0 100644 --- a/README.rst +++ b/README.rst @@ -88,7 +88,7 @@ In `setup.cfg` in the root of your project you can configure mutmut if you need .. code-block:: ini [mutmut] - paths_to_mutate=src/ + source_paths=src/ pytest_add_cli_args_test_selection=tests/ If you use `pyproject.toml`, you must specify the paths as array in a `tool.mutmut` section: @@ -96,7 +96,7 @@ If you use `pyproject.toml`, you must specify the paths as array in a `tool.mutm .. code-block:: toml [tool.mutmut] - paths_to_mutate = [ "src/" ] + source_paths = [ "src/" ] pytest_add_cli_args_test_selection= [ "tests/" ] See below for more options for configuring mutmut. diff --git a/e2e_projects/config/pyproject.toml b/e2e_projects/config/pyproject.toml index 923268da..d6def2d9 100644 --- a/e2e_projects/config/pyproject.toml +++ b/e2e_projects/config/pyproject.toml @@ -24,7 +24,7 @@ dev = [ [tool.mutmut] debug = true -paths_to_mutate = [ "config_pkg/" ] +source_paths = [ "config_pkg/" ] do_not_mutate = [ "*ignore*" ] also_copy = [ "data" ] max_stack_depth=8 # Includes frames by mutmut, see https://github.com/boxed/mutmut/issues/378 diff --git a/e2e_projects/mutate_only_covered_lines/pyproject.toml b/e2e_projects/mutate_only_covered_lines/pyproject.toml index e51233f0..0cc57804 100644 --- a/e2e_projects/mutate_only_covered_lines/pyproject.toml +++ b/e2e_projects/mutate_only_covered_lines/pyproject.toml @@ -26,7 +26,7 @@ debug = true mutate_only_covered_lines = true tests_dir = [ "tests/main/" ] do_not_mutate = [ "*ignore*" ] -# do not add paths_to_mutate because we want to test src guessing +# do not add source_paths because we want to test src guessing [tool.pytest.ini_options] asyncio_default_fixture_loop_scope = "function" diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 8c6f1f04..5b2d4f8a 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -133,7 +133,7 @@ def record_trampoline_hit(name: str) -> None: def walk_all_files() -> Iterator[tuple[str, str]]: - for path in Config.get().paths_to_mutate: + for path in Config.get().source_paths: if not isdir(path): if isfile(path): yield "", str(path) @@ -1060,7 +1060,9 @@ def read_one_child_exit_status() -> None: if not sorted_tests: os._exit(33) - cpu_time_limit_s = ceil((estimated_time_of_tests + config.timeout_constant) * config.timeout_multiplier * 2 + process_time()) + cpu_time_limit_s = ceil( + (estimated_time_of_tests + config.timeout_constant) * config.timeout_multiplier * 2 + process_time() + ) # signal SIGXCPU after . One second later signal SIGKILL if it is still running resource.setrlimit(resource.RLIMIT_CPU, (cpu_time_limit_s, cpu_time_limit_s + 1)) diff --git a/src/mutmut/configuration.py b/src/mutmut/configuration.py index 59f62d3c..704d2d16 100644 --- a/src/mutmut/configuration.py +++ b/src/mutmut/configuration.py @@ -4,6 +4,7 @@ import os import platform import sys +import warnings from collections.abc import Callable from configparser import ConfigParser from configparser import NoOptionError @@ -64,7 +65,7 @@ def setup_cfg_conf(key: str, default: Any) -> Any: return setup_cfg_conf -def _guess_paths_to_mutate() -> list[str]: +def _guess_source_paths() -> list[str]: """Guess the path to source code to mutate :rtype: str @@ -88,13 +89,19 @@ def _guess_paths_to_mutate() -> list[str]: return [this_dir + ".py"] raise FileNotFoundError( "Could not figure out where the code to mutate is. " - 'Please specify it by adding "paths_to_mutate=code_dir" in setup.cfg to the [mutmut] section.' + 'Please specify it by adding "source_paths=code_dir" in setup.cfg to the [mutmut] section.' ) def _load_config() -> Config: s = _config_reader() + paths_to_mutate = [Path(path) for path in s("paths_to_mutate", [])] + if paths_to_mutate: + warnings.warn("The config paths_to_mutate is deprecated. Please rename it to source_paths") + source_paths = [Path(path) for path in s("source_paths", [])] + source_paths = source_paths or paths_to_mutate or [Path(path) for path in _guess_source_paths()] + return Config( do_not_mutate=s("do_not_mutate", []), also_copy=[Path(y) for y in s("also_copy", [])] @@ -108,7 +115,7 @@ def _load_config() -> Config: max_stack_depth=s("max_stack_depth", -1), debug=s("debug", False), mutate_only_covered_lines=s("mutate_only_covered_lines", False), - paths_to_mutate=[Path(y) for y in s("paths_to_mutate", [])] or [Path(p) for p in _guess_paths_to_mutate()], + source_paths=source_paths, tests_dir=s("tests_dir", []), pytest_add_cli_args=s("pytest_add_cli_args", []), pytest_add_cli_args_test_selection=s("pytest_add_cli_args_test_selection", []), @@ -130,7 +137,7 @@ class Config: do_not_mutate: list[str] max_stack_depth: int debug: bool - paths_to_mutate: list[Path] + source_paths: list[Path] pytest_add_cli_args: list[str] pytest_add_cli_args_test_selection: list[str] tests_dir: list[str] diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 445ba5bc..e8b1c224 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -4,7 +4,7 @@ from mutmut.configuration import Config from mutmut.configuration import _config_reader -from mutmut.configuration import _guess_paths_to_mutate +from mutmut.configuration import _guess_source_paths from mutmut.configuration import _load_config @@ -59,7 +59,7 @@ def test_ignores_non_python_files(self): do_not_mutate=[], max_stack_depth=-1, debug=False, - paths_to_mutate=[], + source_paths=[], pytest_add_cli_args=[], pytest_add_cli_args_test_selection=[], tests_dir=[], @@ -79,7 +79,7 @@ def test_does_not_ignore_python_files(self): do_not_mutate=[], max_stack_depth=-1, debug=False, - paths_to_mutate=[], + source_paths=[], pytest_add_cli_args=[], pytest_add_cli_args_test_selection=[], tests_dir=[], @@ -98,7 +98,7 @@ def test_respects_do_not_mutate_exact_match(self): do_not_mutate=["foo.py"], max_stack_depth=-1, debug=False, - paths_to_mutate=[], + source_paths=[], pytest_add_cli_args=[], pytest_add_cli_args_test_selection=[], tests_dir=[], @@ -117,7 +117,7 @@ def test_respects_do_not_mutate_glob_pattern(self): do_not_mutate=["**/test_*.py", "src/ignore_*.py"], max_stack_depth=-1, debug=False, - paths_to_mutate=[], + source_paths=[], pytest_add_cli_args=[], pytest_add_cli_args_test_selection=[], tests_dir=[], @@ -137,7 +137,7 @@ def test_accepts_path_objects(self): do_not_mutate=["foo.py"], max_stack_depth=-1, debug=False, - paths_to_mutate=[], + source_paths=[], pytest_add_cli_args=[], pytest_add_cli_args_test_selection=[], tests_dir=[], @@ -157,13 +157,13 @@ def test_reads_from_pyproject_toml(self, in_tmp_dir: Path): [tool.mutmut] debug = true max_stack_depth = 10 -paths_to_mutate = ["src", "lib"] +source_paths = ["src", "lib"] do_not_mutate = ["**/migrations/*"] """) reader = _config_reader() assert reader("debug", False) is True assert reader("max_stack_depth", -1) == 10 - assert reader("paths_to_mutate", []) == ["src", "lib"] + assert reader("source_paths", []) == ["src", "lib"] assert reader("do_not_mutate", []) == ["**/migrations/*"] def test_returns_default_for_missing_key(self, in_tmp_dir: Path): @@ -191,12 +191,12 @@ def test_reads_from_setup_cfg(self, in_tmp_dir: Path): [mutmut] debug = true max_stack_depth = 5 -paths_to_mutate = src +source_paths = src """) reader = _config_reader() assert reader("debug", False) is True assert reader("max_stack_depth", -1) == 5 - assert reader("paths_to_mutate", []) == ["src"] + assert reader("source_paths", []) == ["src"] def test_parses_multiline_list(self, in_tmp_dir: Path): (in_tmp_dir / "setup.cfg").write_text(""" @@ -277,31 +277,31 @@ def test_pyproject_toml_takes_precedence(self, in_tmp_dir: Path): class TestGuessPathsToMutate: def test_guesses_lib_directory(self, in_tmp_dir: Path): (in_tmp_dir / "lib").mkdir() - assert _guess_paths_to_mutate() == ["lib"] + assert _guess_source_paths() == ["lib"] def test_guesses_src_directory(self, in_tmp_dir: Path): (in_tmp_dir / "src").mkdir() - assert _guess_paths_to_mutate() == ["src"] + assert _guess_source_paths() == ["src"] def test_prefers_lib_over_src(self, in_tmp_dir: Path): (in_tmp_dir / "lib").mkdir() (in_tmp_dir / "src").mkdir() - assert _guess_paths_to_mutate() == ["lib"] + assert _guess_source_paths() == ["lib"] def test_guesses_directory_matching_cwd_name(self, in_tmp_dir: Path): # tmp_path has a random name, create a subdir matching it dir_name = in_tmp_dir.name (in_tmp_dir / dir_name).mkdir() - assert _guess_paths_to_mutate() == [dir_name] + assert _guess_source_paths() == [dir_name] def test_guesses_py_file_matching_cwd_name(self, in_tmp_dir: Path): dir_name = in_tmp_dir.name (in_tmp_dir / f"{dir_name}.py").touch() - assert _guess_paths_to_mutate() == [f"{dir_name}.py"] + assert _guess_source_paths() == [f"{dir_name}.py"] def test_raises_when_cannot_guess(self, in_tmp_dir: Path): with pytest.raises(FileNotFoundError, match="Could not figure out"): - _guess_paths_to_mutate() + _guess_source_paths() class TestLoadConfig: @@ -310,7 +310,7 @@ def test_loads_all_config_values(self, in_tmp_dir: Path): [tool.mutmut] debug = true max_stack_depth = 10 -paths_to_mutate = ["src"] +source_paths = ["src"] do_not_mutate = ["**/test_*.py"] tests_dir = ["tests/unit"] pytest_add_cli_args = ["-x", "--tb=short"] @@ -327,7 +327,7 @@ def test_loads_all_config_values(self, in_tmp_dir: Path): assert config.debug is True assert config.max_stack_depth == 10 - assert config.paths_to_mutate == [Path("src")] + assert config.source_paths == [Path("src")] assert config.do_not_mutate == ["**/test_*.py"] assert config.tests_dir == ["tests/unit"] assert config.pytest_add_cli_args == ["-x", "--tb=short"] @@ -345,7 +345,7 @@ def test_uses_defaults_when_no_config(self, in_tmp_dir: Path): assert config.debug is False assert config.max_stack_depth == -1 - assert config.paths_to_mutate == [Path("src")] + assert config.source_paths == [Path("src")] assert config.do_not_mutate == [] assert config.mutate_only_covered_lines is False assert config.timeout_multiplier == 15.0 From 5dfd358f99101dd382ac9add2c6a69dec964e6ef Mon Sep 17 00:00:00 2001 From: A_A <21040751+Otto-AA@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:21:04 +0200 Subject: [PATCH 2/3] warn that tests_dir is deprecated in favor of pytest_add_cli_args_test_selection --- e2e_projects/config/pyproject.toml | 3 +-- e2e_projects/config/tests/ignored/test_ignored.py | 2 +- e2e_projects/mutate_only_covered_lines/pyproject.toml | 2 +- .../tests/other/test_that_should_not_be_included.py | 2 +- src/mutmut/__main__.py | 5 ----- src/mutmut/configuration.py | 11 ++++++++--- tests/test_configuration.py | 7 ------- 7 files changed, 12 insertions(+), 20 deletions(-) diff --git a/e2e_projects/config/pyproject.toml b/e2e_projects/config/pyproject.toml index d6def2d9..1c195ef3 100644 --- a/e2e_projects/config/pyproject.toml +++ b/e2e_projects/config/pyproject.toml @@ -28,11 +28,10 @@ source_paths = [ "config_pkg/" ] do_not_mutate = [ "*ignore*" ] also_copy = [ "data" ] max_stack_depth=8 # Includes frames by mutmut, see https://github.com/boxed/mutmut/issues/378 -tests_dir = [ "tests/main/" ] # verify that we can override options with pytest_add_cli_args pytest_add_cli_args = ["-o", "xfail_strict=False"] # verify test exclusion (-m 'not fail') and test inclusion (-k=test_include) -pytest_add_cli_args_test_selection = [ "-m", "not fail", "-k=test_include"] +pytest_add_cli_args_test_selection = [ "-m", "not fail", "-k=test_include", "tests/main/"] [tool.pytest.ini_options] xfail_strict = true diff --git a/e2e_projects/config/tests/ignored/test_ignored.py b/e2e_projects/config/tests/ignored/test_ignored.py index 8a5f51ee..782c2066 100644 --- a/e2e_projects/config/tests/ignored/test_ignored.py +++ b/e2e_projects/config/tests/ignored/test_ignored.py @@ -1,5 +1,5 @@ from config_pkg.math import func_with_no_tests -# ignored, because tests_dir specifies only the main directory +# ignored, because pytest_add_cli_args_test_selection specifies only the main directory def test_include_func_with_no_tests(): assert func_with_no_tests() == 420 diff --git a/e2e_projects/mutate_only_covered_lines/pyproject.toml b/e2e_projects/mutate_only_covered_lines/pyproject.toml index 0cc57804..0d1d866c 100644 --- a/e2e_projects/mutate_only_covered_lines/pyproject.toml +++ b/e2e_projects/mutate_only_covered_lines/pyproject.toml @@ -24,7 +24,7 @@ omit = [ [tool.mutmut] debug = true mutate_only_covered_lines = true -tests_dir = [ "tests/main/" ] +pytest_add_cli_args_test_selection = [ "tests/main/" ] do_not_mutate = [ "*ignore*" ] # do not add source_paths because we want to test src guessing diff --git a/e2e_projects/mutate_only_covered_lines/tests/other/test_that_should_not_be_included.py b/e2e_projects/mutate_only_covered_lines/tests/other/test_that_should_not_be_included.py index 0e697978..5b00ba66 100644 --- a/e2e_projects/mutate_only_covered_lines/tests/other/test_that_should_not_be_included.py +++ b/e2e_projects/mutate_only_covered_lines/tests/other/test_that_should_not_be_included.py @@ -1,6 +1,6 @@ from mutate_only_covered_lines import hello_mutate_only_covered_lines -"""This test should be ignored, because of the tests_dir config.""" +"""This test should be ignored, because of the pytest_add_cli_args_test_selection config.""" def test_mutate_only_covered_lines(): assert hello_mutate_only_covered_lines(False) == "Hello from mutate_only_covered_lines! (false)" diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 5b2d4f8a..b373ebe2 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -388,10 +388,6 @@ def __init__(self) -> None: self._pytest_add_cli_args: list[str] = Config.get().pytest_add_cli_args self._pytest_add_cli_args_test_selection: list[str] = Config.get().pytest_add_cli_args_test_selection - # tests_dir is a special case of a test selection option, - # so also use pytest_add_cli_args_test_selection for the implementation - self._pytest_add_cli_args_test_selection += Config.get().tests_dir - # noinspection PyMethodMayBeStatic def execute_pytest(self, params: list[str], **kwargs: Any) -> int: import pytest @@ -463,7 +459,6 @@ def pytest_deselected(self, items: Any) -> None: collector = TestsCollector() - tests_dir = Config.get().tests_dir pytest_args = ["-x", "-q", "--collect-only"] + self._pytest_add_cli_args_test_selection with change_cwd("mutants"): diff --git a/src/mutmut/configuration.py b/src/mutmut/configuration.py index 704d2d16..0b71152d 100644 --- a/src/mutmut/configuration.py +++ b/src/mutmut/configuration.py @@ -102,6 +102,13 @@ def _load_config() -> Config: source_paths = [Path(path) for path in s("source_paths", [])] source_paths = source_paths or paths_to_mutate or [Path(path) for path in _guess_source_paths()] + tests_dir = s("tests_dir", []) + if tests_dir: + warnings.warn( + "The config tests_dir is deprecated. Please add the path to pytest_add_cli_args_test_selection instead" + ) + pytest_add_cli_args_test_selection = s("pytest_add_cli_args_test_selection", []) + tests_dir + return Config( do_not_mutate=s("do_not_mutate", []), also_copy=[Path(y) for y in s("also_copy", [])] @@ -116,9 +123,8 @@ def _load_config() -> Config: debug=s("debug", False), mutate_only_covered_lines=s("mutate_only_covered_lines", False), source_paths=source_paths, - tests_dir=s("tests_dir", []), pytest_add_cli_args=s("pytest_add_cli_args", []), - pytest_add_cli_args_test_selection=s("pytest_add_cli_args_test_selection", []), + pytest_add_cli_args_test_selection=pytest_add_cli_args_test_selection, timeout_multiplier=s("timeout_multiplier", 15.0), timeout_constant=s("timeout_constant", 1.0), type_check_command=s("type_check_command", []), @@ -140,7 +146,6 @@ class Config: source_paths: list[Path] pytest_add_cli_args: list[str] pytest_add_cli_args_test_selection: list[str] - tests_dir: list[str] mutate_only_covered_lines: bool timeout_multiplier: float timeout_constant: float diff --git a/tests/test_configuration.py b/tests/test_configuration.py index e8b1c224..b4666eda 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -62,7 +62,6 @@ def test_ignores_non_python_files(self): source_paths=[], pytest_add_cli_args=[], pytest_add_cli_args_test_selection=[], - tests_dir=[], mutate_only_covered_lines=False, timeout_multiplier=15.0, timeout_constant=1.0, @@ -82,7 +81,6 @@ def test_does_not_ignore_python_files(self): source_paths=[], pytest_add_cli_args=[], pytest_add_cli_args_test_selection=[], - tests_dir=[], mutate_only_covered_lines=False, timeout_multiplier=15.0, timeout_constant=1.0, @@ -101,7 +99,6 @@ def test_respects_do_not_mutate_exact_match(self): source_paths=[], pytest_add_cli_args=[], pytest_add_cli_args_test_selection=[], - tests_dir=[], mutate_only_covered_lines=False, timeout_multiplier=15.0, timeout_constant=1.0, @@ -120,7 +117,6 @@ def test_respects_do_not_mutate_glob_pattern(self): source_paths=[], pytest_add_cli_args=[], pytest_add_cli_args_test_selection=[], - tests_dir=[], mutate_only_covered_lines=False, timeout_multiplier=15.0, timeout_constant=1.0, @@ -140,7 +136,6 @@ def test_accepts_path_objects(self): source_paths=[], pytest_add_cli_args=[], pytest_add_cli_args_test_selection=[], - tests_dir=[], mutate_only_covered_lines=False, timeout_multiplier=15.0, timeout_constant=1.0, @@ -312,7 +307,6 @@ def test_loads_all_config_values(self, in_tmp_dir: Path): max_stack_depth = 10 source_paths = ["src"] do_not_mutate = ["**/test_*.py"] -tests_dir = ["tests/unit"] pytest_add_cli_args = ["-x", "--tb=short"] pytest_add_cli_args_test_selection = ["--no-header"] also_copy = ["fixtures"] @@ -329,7 +323,6 @@ def test_loads_all_config_values(self, in_tmp_dir: Path): assert config.max_stack_depth == 10 assert config.source_paths == [Path("src")] assert config.do_not_mutate == ["**/test_*.py"] - assert config.tests_dir == ["tests/unit"] 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 From b26650076714067c2dfb581230efa7294449d82c Mon Sep 17 00:00:00 2001 From: A_A <21040751+Otto-AA@users.noreply.github.com> Date: Sat, 25 Apr 2026 12:10:40 +0200 Subject: [PATCH 3/3] feat: only_mutate config --- README.rst | 4 + .../config/config_pkg/logic/__init__.py | 2 + .../config_pkg/{ => logic}/ignore_me.py | 0 .../config/config_pkg/{ => logic}/math.py | 0 .../config/config_pkg/utils/__init__.py | 0 e2e_projects/config/config_pkg/utils/utils.py | 2 + e2e_projects/config/pyproject.toml | 1 + .../config/tests/ignored/test_ignored.py | 2 +- e2e_projects/config/tests/main/test_main.py | 10 +- src/mutmut/__main__.py | 35 +++-- src/mutmut/configuration.py | 29 +++- tests/e2e/e2e_utils.py | 2 +- tests/e2e/test_e2e_config.py | 28 ++-- tests/test_configuration.py | 125 +++++++++--------- tests/test_generation_error_handling.py | 2 +- 15 files changed, 135 insertions(+), 107 deletions(-) create mode 100644 e2e_projects/config/config_pkg/logic/__init__.py rename e2e_projects/config/config_pkg/{ => logic}/ignore_me.py (100%) rename e2e_projects/config/config_pkg/{ => logic}/math.py (100%) create mode 100644 e2e_projects/config/config_pkg/utils/__init__.py create mode 100644 e2e_projects/config/config_pkg/utils/utils.py diff --git a/README.rst b/README.rst index fb8525f0..b226758c 100644 --- a/README.rst +++ b/README.rst @@ -143,10 +143,14 @@ caught. Exclude files from mutation ~~~~~~~~~~~~~~~~~~~~~~~~~~~ +By default mutmut mutates all python files in `source_paths`. You can exclude files from mutation in `setup.cfg`: .. code-block:: + only_mutate= + src/api/* + src/services/* do_not_mutate= *__tests.py diff --git a/e2e_projects/config/config_pkg/logic/__init__.py b/e2e_projects/config/config_pkg/logic/__init__.py new file mode 100644 index 00000000..74b0dc93 --- /dev/null +++ b/e2e_projects/config/config_pkg/logic/__init__.py @@ -0,0 +1,2 @@ +def hello() -> str: + return "Hello from config!" diff --git a/e2e_projects/config/config_pkg/ignore_me.py b/e2e_projects/config/config_pkg/logic/ignore_me.py similarity index 100% rename from e2e_projects/config/config_pkg/ignore_me.py rename to e2e_projects/config/config_pkg/logic/ignore_me.py diff --git a/e2e_projects/config/config_pkg/math.py b/e2e_projects/config/config_pkg/logic/math.py similarity index 100% rename from e2e_projects/config/config_pkg/math.py rename to e2e_projects/config/config_pkg/logic/math.py diff --git a/e2e_projects/config/config_pkg/utils/__init__.py b/e2e_projects/config/config_pkg/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/e2e_projects/config/config_pkg/utils/utils.py b/e2e_projects/config/config_pkg/utils/utils.py new file mode 100644 index 00000000..69b940ac --- /dev/null +++ b/e2e_projects/config/config_pkg/utils/utils.py @@ -0,0 +1,2 @@ +def util_that_should_NOT_be_mutated(): + return 1 + 2 + 3 + 4 + 5 - 7 - 8 diff --git a/e2e_projects/config/pyproject.toml b/e2e_projects/config/pyproject.toml index 1c195ef3..8409dc9f 100644 --- a/e2e_projects/config/pyproject.toml +++ b/e2e_projects/config/pyproject.toml @@ -25,6 +25,7 @@ dev = [ [tool.mutmut] debug = true source_paths = [ "config_pkg/" ] +only_mutate = [ "config_pkg/logic/*" ] do_not_mutate = [ "*ignore*" ] also_copy = [ "data" ] max_stack_depth=8 # Includes frames by mutmut, see https://github.com/boxed/mutmut/issues/378 diff --git a/e2e_projects/config/tests/ignored/test_ignored.py b/e2e_projects/config/tests/ignored/test_ignored.py index 782c2066..8008e2c4 100644 --- a/e2e_projects/config/tests/ignored/test_ignored.py +++ b/e2e_projects/config/tests/ignored/test_ignored.py @@ -1,4 +1,4 @@ -from config_pkg.math import func_with_no_tests +from config_pkg.logic.math import func_with_no_tests # ignored, because pytest_add_cli_args_test_selection specifies only the main directory def test_include_func_with_no_tests(): diff --git a/e2e_projects/config/tests/main/test_main.py b/e2e_projects/config/tests/main/test_main.py index 15500b61..018a9ea0 100644 --- a/e2e_projects/config/tests/main/test_main.py +++ b/e2e_projects/config/tests/main/test_main.py @@ -1,9 +1,10 @@ import json import pytest from pathlib import Path -from config_pkg import hello -from config_pkg.math import add, call_depth_two -from config_pkg.ignore_me import this_function_shall_NOT_be_mutated +from config_pkg.logic import hello +from config_pkg.logic.math import add, call_depth_two +from config_pkg.logic.ignore_me import this_function_shall_NOT_be_mutated +from config_pkg.utils.utils import util_that_should_NOT_be_mutated def test_include_hello(): assert hello() == "Hello from config!" @@ -14,6 +15,9 @@ def test_include_add(): def test_include_non_mutated_function(): assert this_function_shall_NOT_be_mutated() == 3 +def test_include_non_mutated_util(): + assert util_that_should_NOT_be_mutated() == 0 + def test_include_max_stack_depth(): # This test should only cover functions up to some depth # For more context, see https://github.com/boxed/mutmut/issues/378 diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index b373ebe2..26261ec4 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -149,6 +149,13 @@ def walk_source_files() -> Iterator[Path]: yield Path(root) / filename +def walk_mutatable_files() -> Iterator[Path]: + config = Config.get() + for path in walk_source_files(): + if config.should_mutate(path): + yield path + + class MutmutProgrammaticFailException(Exception): pass @@ -228,11 +235,11 @@ def create_file_mutants(path: Path) -> FileMutationResult: output_path = Path("mutants") / path makedirs(output_path.parent, exist_ok=True) - if Config.get().should_ignore_for_mutation(path): + if Config.get().should_mutate(path): + return create_mutants_for_file(path, output_path) + else: shutil.copy(path, output_path) return FileMutationResult(ignored=True) - else: - return create_mutants_for_file(path, output_path) except Exception as e: return FileMutationResult(error=e) @@ -823,10 +830,7 @@ def export_cicd_stats() -> None: source_file_mutation_data_by_path: dict[str, SourceFileMutationData] = {} - for path in walk_source_files(): - if Config.get().should_ignore_for_mutation(path): - continue - + for path in walk_mutatable_files(): meta_path = Path("mutants") / (str(path) + ".meta") if not meta_path.exists(): continue @@ -854,9 +858,7 @@ def collect_source_file_mutation_data( ]: source_file_mutation_data_by_path: dict[str, SourceFileMutationData] = {} - for path in walk_source_files(): - if Config.get().should_ignore_for_mutation(path): - continue + for path in walk_mutatable_files(): assert str(path) not in source_file_mutation_data_by_path m = SourceFileMutationData(path=path) m.load() @@ -1130,9 +1132,7 @@ def tests_for_mutant_names(mutant_names: tuple[str, ...] | list[str]) -> set[str @click.option("--all", default=False) def results(all: bool) -> None: Config.ensure_loaded() - for path in walk_source_files(): - if not str(path).endswith(".py"): - continue + for path in walk_mutatable_files(): m = SourceFileMutationData(path=path) m.load() for k, v in m.exit_code_by_key.items(): @@ -1185,10 +1185,7 @@ def read_mutant_function(module: cst.Module, mutant_name: str) -> cst.FunctionDe def find_mutant(mutant_name: str) -> SourceFileMutationData: - for path in walk_source_files(): - if Config.get().should_ignore_for_mutation(path): - continue - + for path in walk_mutatable_files(): m = SourceFileMutationData(path=path) m.load() if mutant_name in m.exit_code_by_key: @@ -1331,9 +1328,7 @@ def read_data(self) -> None: self.source_file_mutation_data_and_stat_by_path = {} self.path_by_name: dict[str, Path] = {} - for p in walk_source_files(): - if Config.get().should_ignore_for_mutation(p): - continue + for p in walk_mutatable_files(): source_file_mutation_data = SourceFileMutationData(path=p) source_file_mutation_data.load() stat = collect_stat(source_file_mutation_data) diff --git a/src/mutmut/configuration.py b/src/mutmut/configuration.py index 0b71152d..d3a8ef01 100644 --- a/src/mutmut/configuration.py +++ b/src/mutmut/configuration.py @@ -109,8 +109,18 @@ def _load_config() -> Config: ) pytest_add_cli_args_test_selection = s("pytest_add_cli_args_test_selection", []) + tests_dir + only_mutate = s("only_mutate", []) + do_not_mutate = s("do_not_mutate", []) + # only patterns for python files are valid: must end with ".py" or "*" + invalid_patterns = [p for p in only_mutate + do_not_mutate if not (p.endswith("*") or p.endswith(".py"))] + if invalid_patterns: + warnings.warn( + 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}' + ) + return Config( - do_not_mutate=s("do_not_mutate", []), + only_mutate=only_mutate, + do_not_mutate=do_not_mutate, also_copy=[Path(y) for y in s("also_copy", [])] + [ Path("tests/"), @@ -140,6 +150,7 @@ def _load_config() -> Config: @dataclass class Config: also_copy: list[Path] + only_mutate: list[str] do_not_mutate: list[str] max_stack_depth: int debug: bool @@ -152,7 +163,21 @@ class Config: type_check_command: list[str] use_setproctitle: bool - def should_ignore_for_mutation(self, path: Path | str) -> bool: + def should_mutate(self, path: Path | str) -> bool: + return self._should_include_for_mutation(path) and not self._should_ignore_for_mutation(path) + + def _should_include_for_mutation(self, path: Path | str) -> bool: + if not self.only_mutate: + return True + path_str = str(path) + if not path_str.endswith(".py"): + return True + for p in self.only_mutate: + if fnmatch.fnmatch(path_str, p): + return True + return False + + def _should_ignore_for_mutation(self, path: Path | str) -> bool: path_str = str(path) if not path_str.endswith(".py"): return True diff --git a/tests/e2e/e2e_utils.py b/tests/e2e/e2e_utils.py index c7a6ae71..1b7ca2d4 100644 --- a/tests/e2e/e2e_utils.py +++ b/tests/e2e/e2e_utils.py @@ -30,7 +30,7 @@ def read_all_stats_for_project(project_path: Path) -> dict[str, dict]: stats = {} for p in walk_source_files(): - if Config.get().should_ignore_for_mutation(p): + if not Config.get().should_mutate(p): continue data = SourceFileMutationData(path=p) data.load() diff --git a/tests/e2e/test_e2e_config.py b/tests/e2e/test_e2e_config.py index 84b712b2..8e383e22 100644 --- a/tests/e2e/test_e2e_config.py +++ b/tests/e2e/test_e2e_config.py @@ -6,21 +6,21 @@ def test_config_result_snapshot(): assert run_mutmut_on_project("config") == snapshot( { - "mutants/config_pkg/__init__.py.meta": { - "config_pkg.x_hello__mutmut_1": 1, - "config_pkg.x_hello__mutmut_2": 1, - "config_pkg.x_hello__mutmut_3": 1, + "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/math.py.meta": { - "config_pkg.math.x_add__mutmut_1": 0, - "config_pkg.math.x_call_depth_two__mutmut_1": 1, - "config_pkg.math.x_call_depth_two__mutmut_2": 1, - "config_pkg.math.x_call_depth_three__mutmut_1": 1, - "config_pkg.math.x_call_depth_three__mutmut_2": 1, - "config_pkg.math.x_call_depth_four__mutmut_1": 33, - "config_pkg.math.x_call_depth_four__mutmut_2": 33, - "config_pkg.math.x_call_depth_five__mutmut_1": 33, - "config_pkg.math.x_func_with_no_tests__mutmut_1": 33, + "mutants/config_pkg/logic/math.py.meta": { + "config_pkg.logic.math.x_add__mutmut_1": 0, + "config_pkg.logic.math.x_call_depth_two__mutmut_1": 1, + "config_pkg.logic.math.x_call_depth_two__mutmut_2": 1, + "config_pkg.logic.math.x_call_depth_three__mutmut_1": 1, + "config_pkg.logic.math.x_call_depth_three__mutmut_2": 1, + "config_pkg.logic.math.x_call_depth_four__mutmut_1": 33, + "config_pkg.logic.math.x_call_depth_four__mutmut_2": 33, + "config_pkg.logic.math.x_call_depth_five__mutmut_1": 33, + "config_pkg.logic.math.x_func_with_no_tests__mutmut_1": 33, }, } ) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index b4666eda..e8de4c54 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -52,11 +52,14 @@ def test_ensure_loaded_is_idempotent(self, in_tmp_dir: Path): assert config1 is config2 -class TestShouldIgnoreForMutation: - def test_ignores_non_python_files(self): - config = Config( +class TestShouldMutateFile: + @staticmethod + def _get_config(only_mutate: list[str], do_not_mutate: list[str]) -> Config: + # only the "only_mutate" and "do_not_mutate" configs are important for these tests + return Config( + only_mutate=only_mutate, + do_not_mutate=do_not_mutate, also_copy=[], - do_not_mutate=[], max_stack_depth=-1, debug=False, source_paths=[], @@ -68,82 +71,71 @@ def test_ignores_non_python_files(self): type_check_command=[], use_setproctitle=False, ) - assert config.should_ignore_for_mutation("foo.txt") is True - assert config.should_ignore_for_mutation("foo.js") is True - assert config.should_ignore_for_mutation("foo") is True - def test_does_not_ignore_python_files(self): - config = Config( - also_copy=[], + def test_ignores_non_python_files(self): + config = self._get_config( + only_mutate=[], do_not_mutate=[], - max_stack_depth=-1, - debug=False, - source_paths=[], - pytest_add_cli_args=[], - pytest_add_cli_args_test_selection=[], - mutate_only_covered_lines=False, - timeout_multiplier=15.0, - timeout_constant=1.0, - type_check_command=[], - use_setproctitle=False, ) - assert config.should_ignore_for_mutation("foo.py") is False - assert config.should_ignore_for_mutation("src/foo.py") is False + assert config.should_mutate("foo.txt") is False + assert config.should_mutate("foo.js") is False + assert config.should_mutate("foo") is False + + def test_includes_python_files(self): + config = self._get_config( + only_mutate=[], + do_not_mutate=[], + ) + assert config.should_mutate("foo.py") is True + assert config.should_mutate("src/foo.py") is True def test_respects_do_not_mutate_exact_match(self): - config = Config( - also_copy=[], + config = self._get_config( + only_mutate=[], do_not_mutate=["foo.py"], - max_stack_depth=-1, - debug=False, - source_paths=[], - pytest_add_cli_args=[], - pytest_add_cli_args_test_selection=[], - mutate_only_covered_lines=False, - timeout_multiplier=15.0, - timeout_constant=1.0, - type_check_command=[], - use_setproctitle=False, ) - assert config.should_ignore_for_mutation("foo.py") is True - assert config.should_ignore_for_mutation("bar.py") is False + assert config.should_mutate("foo.py") is False + assert config.should_mutate("bar.py") is True def test_respects_do_not_mutate_glob_pattern(self): - config = Config( - also_copy=[], + config = self._get_config( + only_mutate=[], do_not_mutate=["**/test_*.py", "src/ignore_*.py"], - max_stack_depth=-1, - debug=False, - source_paths=[], - pytest_add_cli_args=[], - pytest_add_cli_args_test_selection=[], - mutate_only_covered_lines=False, - timeout_multiplier=15.0, - timeout_constant=1.0, - type_check_command=[], - use_setproctitle=False, ) - assert config.should_ignore_for_mutation("tests/test_foo.py") is True - assert config.should_ignore_for_mutation("src/ignore_me.py") is True - assert config.should_ignore_for_mutation("src/keep_me.py") is False + assert config.should_mutate("tests/test_foo.py") is False + assert config.should_mutate("src/ignore_me.py") is False + assert config.should_mutate("src/keep_me.py") is True + + def test_respects_only_mutate(self): + config = self._get_config( + # without a glob, the `src/` is pointless + only_mutate=["src/", "foo/*"], + do_not_mutate=[], + ) + assert config.should_mutate("tests/test_foo.py") is False + assert config.should_mutate("src/main.py") is False + assert config.should_mutate("foo/main.py") is True + assert config.should_mutate("foo/nested/main.py") is True + + def test_respects_only_mutate_with_do_not_mutate(self): + config = self._get_config( + only_mutate=["src/api/*"], + do_not_mutate=["src/api/models/*"], + ) + # matched by only_mutate + assert config.should_mutate("src/api/endpoints/user.py") is True + # matched by only_mutate but excluded by do_not_mutate + assert config.should_mutate("src/api/models/user.py") is False + # not matched by only_mutate + assert config.should_mutate("src/services/user.py") is False def test_accepts_path_objects(self): - config = Config( - also_copy=[], + config = self._get_config( + only_mutate=[], do_not_mutate=["foo.py"], - max_stack_depth=-1, - debug=False, - source_paths=[], - pytest_add_cli_args=[], - pytest_add_cli_args_test_selection=[], - mutate_only_covered_lines=False, - timeout_multiplier=15.0, - timeout_constant=1.0, - type_check_command=[], - use_setproctitle=False, ) - assert config.should_ignore_for_mutation(Path("foo.py")) is True - assert config.should_ignore_for_mutation(Path("bar.py")) is False + assert config.should_mutate(Path("foo.py")) is False + assert config.should_mutate(Path("bar.py")) is True class TestConfigReaderPyprojectToml: @@ -306,6 +298,7 @@ def test_loads_all_config_values(self, in_tmp_dir: Path): debug = true max_stack_depth = 10 source_paths = ["src"] +only_mutate=["**/foo.py"] do_not_mutate = ["**/test_*.py"] pytest_add_cli_args = ["-x", "--tb=short"] pytest_add_cli_args_test_selection = ["--no-header"] @@ -322,6 +315,7 @@ def test_loads_all_config_values(self, in_tmp_dir: Path): assert config.debug is True assert config.max_stack_depth == 10 assert config.source_paths == [Path("src")] + assert config.only_mutate == ["**/foo.py"] assert config.do_not_mutate == ["**/test_*.py"] assert config.pytest_add_cli_args == ["-x", "--tb=short"] assert config.pytest_add_cli_args_test_selection == ["--no-header"] @@ -339,6 +333,7 @@ def test_uses_defaults_when_no_config(self, in_tmp_dir: Path): assert config.debug is False assert config.max_stack_depth == -1 assert config.source_paths == [Path("src")] + assert config.only_mutate == [] assert config.do_not_mutate == [] assert config.mutate_only_covered_lines is False assert config.timeout_multiplier == 15.0 diff --git a/tests/test_generation_error_handling.py b/tests/test_generation_error_handling.py index f556e392..ad3e087f 100644 --- a/tests/test_generation_error_handling.py +++ b/tests/test_generation_error_handling.py @@ -25,7 +25,7 @@ def test_mutant_generation_raises_exception_on_invalid_syntax(monkeypatch): source_dir / "invalid_syntax.py", ] monkeypatch.setattr(mutmut.__main__, "walk_source_files", lambda: source_files) - monkeypatch.setattr(Config.get(), "should_ignore_for_mutation", lambda _path: False) + monkeypatch.setattr(Config.get(), "should_mutate", lambda _path: True) # should raise an exception, because we copy the invalid_syntax.py file and then verify # if it is valid syntax