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
2 changes: 1 addition & 1 deletion ARCHITECTURE.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 6 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,15 @@ 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:

.. 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.
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions e2e_projects/config/config_pkg/logic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def hello() -> str:
return "Hello from config!"
Empty file.
2 changes: 2 additions & 0 deletions e2e_projects/config/config_pkg/utils/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def util_that_should_NOT_be_mutated():
return 1 + 2 + 3 + 4 + 5 - 7 - 8
6 changes: 3 additions & 3 deletions e2e_projects/config/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ dev = [

[tool.mutmut]
debug = true
paths_to_mutate = [ "config_pkg/" ]
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
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
Expand Down
4 changes: 2 additions & 2 deletions e2e_projects/config/tests/ignored/test_ignored.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from config_pkg.math import func_with_no_tests
from config_pkg.logic.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
10 changes: 7 additions & 3 deletions e2e_projects/config/tests/main/test_main.py
Original file line number Diff line number Diff line change
@@ -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!"
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions e2e_projects/mutate_only_covered_lines/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ 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 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"
Original file line number Diff line number Diff line change
@@ -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)"
46 changes: 19 additions & 27 deletions src/mutmut/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -388,10 +395,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
Expand Down Expand Up @@ -463,7 +466,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"):
Expand Down Expand Up @@ -828,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
Expand Down Expand Up @@ -859,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()
Expand Down Expand Up @@ -1060,7 +1057,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 <cpu_time_limit>. One second later signal SIGKILL if it is still running
resource.setrlimit(resource.RLIMIT_CPU, (cpu_time_limit_s, cpu_time_limit_s + 1))

Expand Down Expand Up @@ -1133,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():
Expand Down Expand Up @@ -1188,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:
Expand Down Expand Up @@ -1334,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)
Expand Down
55 changes: 46 additions & 9 deletions src/mutmut/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -88,15 +89,38 @@ 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()]

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

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/"),
Expand All @@ -108,10 +132,9 @@ 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()],
tests_dir=s("tests_dir", []),
source_paths=source_paths,
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", []),
Expand All @@ -127,20 +150,34 @@ 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
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]
mutate_only_covered_lines: bool
timeout_multiplier: float
timeout_constant: float
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
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/e2e_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
28 changes: 14 additions & 14 deletions tests/e2e/test_e2e_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
)
Loading
Loading