From 261ce3aaecf26f4faa19d5e96f186dd595b0b23f Mon Sep 17 00:00:00 2001 From: axisrow Date: Wed, 7 Oct 2026 13:02:45 +0800 Subject: [PATCH] fix: strip_src_prefix config for flat layouts where the import package is `src` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects whose import package is literally `src` (flat layout: `src/__init__.py`, imports like `from src.calc import ...`) cannot run mutmut 3.8: 1. `get_mutant_name` unconditionally strips a `src.` prefix from module names — correct for the src-layout convention (`src/` is only a container), but for a flat layout the stripped names no longer match the imported modules, so the trampoline routing (`module != decorated_func.__module__`) never applies any mutant, the stats keys diverge (`"collection_queue.x_foo"` recorded as `"src.collection_queue.x_foo"`), and `record_trampoline_hit` asserts out during stats collection. Add a `[tool.mutmut] strip_src_prefix` config (default `true` = current behavior). With `false`, mutant names keep the full `src.`-prefixed module path, which makes routing, stats keys, and the assert all consistent. The sandbox already imports flat layouts correctly (`mutants/` on sys.path), so no other changes are needed. - `configuration.py`: new `strip_src_prefix: bool = True` config field. - `utils/format_utils.py`: `get_mutant_name` strips only when configured. - `mutation/stats.py`: the `src.` assert only fires while stripping is active (and now points flat-layout users at the new knob). - New e2e fixture `e2e_projects/flat_src` (`src` as the import package) with a result snapshot: 11 mutants generated with `src.`-prefixed names, 6 killed / 5 survived — mutants verifiably apply and route, boundary-condition mutants survive as the tests intend. - Unit tests for the config key and both `get_mutant_name` modes. Verified on a real flat-layout project (a ~1.8k-file Telegram engine with `from src.*` imports throughout): stats collection passes for the first time and mutants execute. Co-Authored-By: Claude Code --- e2e_projects/flat_src/README.md | 6 ++++ e2e_projects/flat_src/pyproject.toml | 24 ++++++++++++++++ e2e_projects/flat_src/src/__init__.py | 5 ++++ e2e_projects/flat_src/src/calc.py | 18 ++++++++++++ e2e_projects/flat_src/tests/test_calc.py | 26 ++++++++++++++++++ src/mutmut/configuration.py | 5 ++++ src/mutmut/stats.py | 10 ++++++- src/mutmut/utils/format_utils.py | 8 +++++- tests/e2e/test_e2e_flat_src.py | 27 ++++++++++++++++++ tests/test_configuration.py | 6 ++++ tests/utils/test_format_utils.py | 35 ++++++++++++++++++++++++ 11 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 e2e_projects/flat_src/README.md create mode 100644 e2e_projects/flat_src/pyproject.toml create mode 100644 e2e_projects/flat_src/src/__init__.py create mode 100644 e2e_projects/flat_src/src/calc.py create mode 100644 e2e_projects/flat_src/tests/test_calc.py create mode 100644 tests/e2e/test_e2e_flat_src.py create mode 100644 tests/utils/test_format_utils.py diff --git a/e2e_projects/flat_src/README.md b/e2e_projects/flat_src/README.md new file mode 100644 index 00000000..1f3c573d --- /dev/null +++ b/e2e_projects/flat_src/README.md @@ -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. diff --git a/e2e_projects/flat_src/pyproject.toml b/e2e_projects/flat_src/pyproject.toml new file mode 100644 index 00000000..cea940c3 --- /dev/null +++ b/e2e_projects/flat_src/pyproject.toml @@ -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" diff --git a/e2e_projects/flat_src/src/__init__.py b/e2e_projects/flat_src/src/__init__.py new file mode 100644 index 00000000..0c04056e --- /dev/null +++ b/e2e_projects/flat_src/src/__init__.py @@ -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" diff --git a/e2e_projects/flat_src/src/calc.py b/e2e_projects/flat_src/src/calc.py new file mode 100644 index 00000000..00d04bfd --- /dev/null +++ b/e2e_projects/flat_src/src/calc.py @@ -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" diff --git a/e2e_projects/flat_src/tests/test_calc.py b/e2e_projects/flat_src/tests/test_calc.py new file mode 100644 index 00000000..8f0861a6 --- /dev/null +++ b/e2e_projects/flat_src/tests/test_calc.py @@ -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() diff --git a/src/mutmut/configuration.py b/src/mutmut/configuration.py index a1b7650c..76fe1f39 100644 --- a/src/mutmut/configuration.py +++ b/src/mutmut/configuration.py @@ -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, @@ -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 diff --git a/src/mutmut/stats.py b/src/mutmut/stats.py index 6277c667..4011cea6 100644 --- a/src/mutmut/stats.py +++ b/src/mutmut/stats.py @@ -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 diff --git a/src/mutmut/utils/format_utils.py b/src/mutmut/utils/format_utils.py index 76cd199a..2165959c 100644 --- a/src/mutmut/utils/format_utils.py +++ b/src/mutmut/utils/format_utils.py @@ -3,6 +3,7 @@ import os from pathlib import Path +from mutmut.configuration import config from mutmut.mutation.trampoline_templates import CLASS_NAME_SEPARATOR @@ -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}" diff --git a/tests/e2e/test_e2e_flat_src.py b/tests/e2e/test_e2e_flat_src.py new file mode 100644 index 00000000..0d839577 --- /dev/null +++ b/tests/e2e/test_e2e_flat_src.py @@ -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, + }, + } + ) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 33a7438b..5352fce1 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -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"') diff --git a/tests/utils/test_format_utils.py b/tests/utils/test_format_utils.py new file mode 100644 index 00000000..2f8fa204 --- /dev/null +++ b/tests/utils/test_format_utils.py @@ -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"