Skip to content
Closed
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
6 changes: 6 additions & 0 deletions e2e_projects/flat_src/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# flat-src

E2E fixture project for mutmut: a **flat layout** where the import package is
literally `src` (with `src/__init__.py`), instead of the src-layout convention
where `src/` is only a container directory. Tests import `from src.calc ...`,
and `[tool.mutmut] strip_src_prefix = false` keeps that prefix in mutant names.
24 changes: 24 additions & 0 deletions e2e_projects/flat_src/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[project]
name = "flat-src"
version = "0.1.0"
description = "E2E fixture: flat layout where the import package is literally `src`"
readme = "README.md"
authors = []
requires-python = ">=3.10"
dependencies = []

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[dependency-groups]
dev = [
"pytest>=9.0.3",
]

[tool.mutmut]
debug = true
strip_src_prefix = false

[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function"
5 changes: 5 additions & 0 deletions e2e_projects/flat_src/src/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""The package of the flat-src fixture: `src` is the import package itself."""


def version() -> str:
return "flat-src-1.0"
18 changes: 18 additions & 0 deletions e2e_projects/flat_src/src/calc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
def hello() -> str:
return "Hello from flat-src!"


def add(a: int, b: int) -> int:
return a + b


def clamp(value: int, low: int, high: int) -> int:
if value < low:
return low
if value > high:
return high
return value


def badly_tested() -> str:
return "Mutants for this method should survive"
26 changes: 26 additions & 0 deletions e2e_projects/flat_src/tests/test_calc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""These tests are flawed on purpose, some mutants survive and some are killed."""

from src import version
from src.calc import add, badly_tested, clamp, hello


def test_hello():
assert hello() == "Hello from flat-src!"


def test_version():
assert version() == "flat-src-1.0"


def test_add():
assert add(2, 3) == 5


def test_clamp():
assert clamp(5, 0, 10) == 5
assert clamp(-1, 0, 10) == 0
assert clamp(11, 0, 10) == 10


def test_badly_tested():
assert badly_tested()
5 changes: 5 additions & 0 deletions src/mutmut/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def _load_config() -> Config:
+ list(Path(".").glob("test*.py")),
max_stack_depth=s("max_stack_depth", -1),
debug=s("debug", False),
strip_src_prefix=s("strip_src_prefix", True),
mutate_only_covered_lines=s("mutate_only_covered_lines", False),
source_paths=source_paths,
resolved_mutated_source_paths=resolved_mutated_source_paths,
Expand Down Expand Up @@ -253,6 +254,10 @@ class Config:
preload_modules_file: str | None
log_to_file: bool
log_file_path: str
# flat layout support: when the import package is literally `src` (not a
# src-layout container dir), mutant names must keep the `src.` prefix or
# the trampoline routing and the stats mapping cannot match.
strip_src_prefix: bool = True

def config_fingerprint(self) -> dict[str, str]:
"""Hash the config fields that can change cached mutant *results*, grouped so the
Expand Down
10 changes: 9 additions & 1 deletion src/mutmut/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,15 @@ def save_stats() -> None:


def record_trampoline_hit(name: str, caller: str | None = None) -> None:
assert not name.startswith("src."), "Failed trampoline hit. Module name starts with `src.`, which is invalid"
if config().strip_src_prefix:
# With the src-layout strip active, a `src.`-prefixed module name means
# the stats keys and the mutant names diverged (flat `src` package being
# imported directly) — the mapping cannot work, so fail loudly.
assert not name.startswith("src."), (
"Failed trampoline hit. Module name starts with `src.`, which is invalid. "
"If your import package is literally `src` (flat layout), set "
"[tool.mutmut] strip_src_prefix = false"
)

mutated_source_paths = config().resolved_mutated_source_paths

Expand Down
8 changes: 7 additions & 1 deletion src/mutmut/utils/format_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import os
from pathlib import Path

from mutmut.configuration import config
from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR


Expand Down Expand Up @@ -60,7 +61,12 @@ def get_module_from_key(key: str) -> str:

def get_mutant_name(relative_source_path: Path, mutant_method_name: str) -> str:
module_name = str(relative_source_path)[: -len(relative_source_path.suffix)].replace(os.sep, ".")
module_name = strip_prefix(module_name, prefix="src.")
if config().strip_src_prefix:
# src-layout convention: `src/` is a container dir, so the real import
# package starts one level deeper. A flat layout where `src` IS the
# import package (strip_src_prefix = false) must keep the prefix,
# or the trampoline routing (`module == func.__module__`) never matches.
module_name = strip_prefix(module_name, prefix="src.")

# FYI, we currently use "mutant_name" inconsistently, for both the whole identifier including the path and only the mangled method name
mutant_name = f"{module_name}.{mutant_method_name}"
Expand Down
27 changes: 27 additions & 0 deletions tests/e2e/test_e2e_flat_src.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from inline_snapshot import snapshot

from tests.e2e.e2e_utils import run_mutmut_on_project


def test_flat_src_result_snapshot():
"""Flat layout (`src` IS the import package): mutants are named with the
full `src.` module prefix and the suite kills/survives as expected."""
assert run_mutmut_on_project("flat_src") == snapshot(
{
"mutants/src/__init__.py.meta": {
"src.x_version__mutmut_1": 1,
"src.x_version__mutmut_2": 1,
},
"mutants/src/calc.py.meta": {
"src.calc.x_hello__mutmut_1": 1,
"src.calc.x_hello__mutmut_2": 1,
"src.calc.x_hello__mutmut_3": 1,
"src.calc.x_add__mutmut_1": 1,
"src.calc.x_clamp__mutmut_1": 0,
"src.calc.x_clamp__mutmut_2": 0,
"src.calc.x_badly_tested__mutmut_1": 0,
"src.calc.x_badly_tested__mutmut_2": 0,
"src.calc.x_badly_tested__mutmut_3": 0,
},
}
)
6 changes: 6 additions & 0 deletions tests/test_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ def test_defaults(self, in_tmp_dir: Path):
assert cfg.forkserver_warmup == ForkServerWarmup.COLLECT
assert cfg.max_forkserver_restarts == 3
assert cfg.preload_modules_file is None
assert cfg.strip_src_prefix is True

def test_strip_src_prefix_disabled_for_flat_layout(self, in_tmp_dir: Path):
"""A flat `src` import package keeps its prefix in mutant names."""
self._write_pyproject(in_tmp_dir, "strip_src_prefix = false")
assert config().strip_src_prefix is False

def test_forkserver_selected(self, in_tmp_dir: Path):
self._write_pyproject(in_tmp_dir, 'process_isolation = "forkserver"')
Expand Down
35 changes: 35 additions & 0 deletions tests/utils/test_format_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from pathlib import Path

import pytest

from mutmut.configuration import reset_config
from mutmut.utils.format_utils import get_mutant_name


@pytest.fixture(autouse=True)
def _reset_config_singleton():
reset_config()
yield
reset_config()


@pytest.fixture
def in_tmp_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.chdir(tmp_path)
(tmp_path / "src").mkdir()
(tmp_path / "pyproject.toml").write_text("[tool.mutmut]\n")
return tmp_path


def test_default_still_strips_src_prefix(in_tmp_dir: Path):
"""src-layout default: the container dir is not part of the import name."""
assert get_mutant_name(Path("src/my_lib/__init__.py"), "x_hello__mutmut_1") == "my_lib.x_hello__mutmut_1"


def test_flat_src_layout_keeps_prefix(in_tmp_dir: Path):
"""strip_src_prefix = false: `src` IS the import package, so mutant names
must keep the `src.` prefix — the trampoline routing compares them against
the imported module's `__module__`."""
(in_tmp_dir / "pyproject.toml").write_text("[tool.mutmut]\nstrip_src_prefix = false\n")
reset_config()
assert get_mutant_name(Path("src/calc.py"), "x_add__mutmut_1") == "src.calc.x_add__mutmut_1"