From d8033375fbe7e1e8bac68d73acb55ffc50a57b02 Mon Sep 17 00:00:00 2001 From: Raj Aryan Singh Date: Mon, 4 May 2026 02:25:17 +0530 Subject: [PATCH] fix: only count user-code frames toward max_stack_depth --- e2e_projects/config/pyproject.toml | 2 +- src/mutmut/__main__.py | 14 ++- tests/test_record_trampoline_hit.py | 158 ++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 tests/test_record_trampoline_hit.py diff --git a/e2e_projects/config/pyproject.toml b/e2e_projects/config/pyproject.toml index d07373f1..bade35f5 100644 --- a/e2e_projects/config/pyproject.toml +++ b/e2e_projects/config/pyproject.toml @@ -28,7 +28,7 @@ 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 +max_stack_depth=5 # Counts only user-code frames; see https://github.com/boxed/mutmut/issues/378 # 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) diff --git a/src/mutmut/__main__.py b/src/mutmut/__main__.py index 26261ec4..cfcbbe88 100644 --- a/src/mutmut/__main__.py +++ b/src/mutmut/__main__.py @@ -117,14 +117,26 @@ def record_trampoline_hit(name: str) -> None: assert not name.startswith("src."), "Failed trampoline hit. Module name starts with `src.`, which is invalid" if Config.get().max_stack_depth != -1: + # Only frames of user code under mutation count toward max_stack_depth. + # CWD is the ``./mutants`` directory while tests run, so user files live + # under ``mutants_dir``. Skip frames from mutmut's own machinery + # (``record_trampoline_hit``, ``_mutmut_trampoline``) and frames from + # third-party / stdlib code so the configured number reflects only + # user-code call depth. ``realpath`` is used on both sides so symlinks + # (e.g. ``/var`` -> ``/private/var`` on macOS) compare equal. + mutants_dir = os.path.realpath(os.getcwd()) f = inspect.currentframe() c = Config.get().max_stack_depth while c and f: filename = f.f_code.co_filename if "pytest" in filename or "hammett" in filename or "unittest" in filename: break + real_filename = os.path.realpath(filename) + in_mutants = real_filename == mutants_dir or real_filename.startswith(mutants_dir + os.sep) + is_mutmut_machinery = f.f_code.co_name == "_mutmut_trampoline" + if in_mutants and not is_mutmut_machinery: + c -= 1 f = f.f_back - c -= 1 if not c: return diff --git a/tests/test_record_trampoline_hit.py b/tests/test_record_trampoline_hit.py new file mode 100644 index 00000000..d33ce98b --- /dev/null +++ b/tests/test_record_trampoline_hit.py @@ -0,0 +1,158 @@ +import tempfile +from collections.abc import Iterator +from pathlib import Path + +import pytest + +import mutmut +from mutmut.__main__ import record_trampoline_hit + + +class _FakeCode: + def __init__(self, filename: str, name: str = "") -> None: + self.co_filename = filename + self.co_name = name + + +class _FakeFrame: + def __init__(self, code: _FakeCode, back: "_FakeFrame | None" = None) -> None: + self.f_code = code + self.f_back = back + + +def _make_chain(specs: list[tuple[str, str]]) -> _FakeFrame: + """Build a frame chain from innermost to outermost. + + Each spec is ``(filename, function_name)``. The first entry is the deepest + frame (the one ``inspect.currentframe()`` would return). + """ + frame: _FakeFrame | None = None + for fname, fn_name in reversed(specs): + frame = _FakeFrame(_FakeCode(fname, fn_name), back=frame) + assert frame is not None + return frame + + +@pytest.fixture +def in_mutants_dir(monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: + """Create a fake ``./mutants`` directory and chdir into it. + + Uses ``tempfile.TemporaryDirectory`` rather than the ``tmp_path`` fixture so + the resulting path does not contain the literal substring ``pytest`` (which + the test-runner heuristic in ``record_trampoline_hit`` would treat as a + test-runner frame and exit the walk early). + + A ``src/`` subdirectory is created so ``Config.get()`` can resolve + ``source_paths`` via ``_guess_source_paths``. + """ + with tempfile.TemporaryDirectory() as tmp: + mutants = Path(tmp) / "mutants" + (mutants / "src").mkdir(parents=True) + monkeypatch.chdir(mutants) + yield mutants + + +@pytest.fixture(autouse=True) +def clear_stats(): + mutmut._stats = set() + yield + mutmut._stats = set() + + +class TestMaxStackDepth: + def test_records_hit_when_disabled(self, patch_config, in_mutants_dir: Path): + patch_config("max_stack_depth", -1) + record_trampoline_hit("foo.bar") + assert "foo.bar" in mutmut._stats + + def test_records_within_budget(self, patch_config, in_mutants_dir: Path, monkeypatch: pytest.MonkeyPatch): + user_file = str(in_mutants_dir / "user.py") + chain = _make_chain( + [ + (user_file, "_mutmut_trampoline"), + (user_file, "user_inner"), + (user_file, "user_outer"), + ("/site-packages/pytest/runner.py", "pytest_runtest"), + ] + ) + monkeypatch.setattr("inspect.currentframe", lambda: chain) + patch_config("max_stack_depth", 5) + record_trampoline_hit("foo.bar") + assert "foo.bar" in mutmut._stats + + def test_skips_when_over_budget(self, patch_config, in_mutants_dir: Path, monkeypatch: pytest.MonkeyPatch): + user_file = str(in_mutants_dir / "user.py") + chain = _make_chain( + [ + (user_file, "_mutmut_trampoline"), + (user_file, "user_a"), + (user_file, "user_b"), + (user_file, "user_c"), + ("/site-packages/pytest/runner.py", "pytest_runtest"), + ] + ) + monkeypatch.setattr("inspect.currentframe", lambda: chain) + patch_config("max_stack_depth", 2) + record_trampoline_hit("foo.bar") + assert "foo.bar" not in mutmut._stats + + def test_third_party_frames_do_not_count(self, patch_config, in_mutants_dir: Path, monkeypatch: pytest.MonkeyPatch): + user_file = str(in_mutants_dir / "user.py") + chain = _make_chain( + [ + (user_file, "_mutmut_trampoline"), + (user_file, "user_inner"), + ("/site-packages/django/middleware.py", "process_request"), + ("/site-packages/django/handlers.py", "get_response"), + ("/site-packages/requests/sessions.py", "send"), + (user_file, "user_outer"), + ("/site-packages/pytest/runner.py", "pytest_runtest"), + ] + ) + monkeypatch.setattr("inspect.currentframe", lambda: chain) + # Two user frames, three 3rd-party frames between them. Budget of 2 + # exactly covers the user frames; 3rd-party frames must not consume it. + patch_config("max_stack_depth", 3) + record_trampoline_hit("foo.bar") + assert "foo.bar" in mutmut._stats + + def test_mutmut_trampoline_frame_does_not_count( + self, patch_config, in_mutants_dir: Path, monkeypatch: pytest.MonkeyPatch + ): + user_file = str(in_mutants_dir / "user.py") + # Many _mutmut_trampoline frames must not eat the budget even though + # they live inside the mutants directory. With one user frame and a + # budget of 2, the call must be recorded; if the trampoline frames + # were counted the budget would be exhausted. + chain = _make_chain( + [ + (user_file, "_mutmut_trampoline"), + (user_file, "_mutmut_trampoline"), + (user_file, "_mutmut_trampoline"), + (user_file, "user_only_frame"), + ("/site-packages/pytest/runner.py", "pytest_runtest"), + ] + ) + monkeypatch.setattr("inspect.currentframe", lambda: chain) + patch_config("max_stack_depth", 2) + record_trampoline_hit("foo.bar") + assert "foo.bar" in mutmut._stats + + def test_filename_with_pytest_substring_breaks_walk( + self, patch_config, in_mutants_dir: Path, monkeypatch: pytest.MonkeyPatch + ): + # Preserve existing behavior: any frame whose filename contains + # ``pytest`` / ``hammett`` / ``unittest`` ends the walk. + user_file = str(in_mutants_dir / "user.py") + chain = _make_chain( + [ + (user_file, "_mutmut_trampoline"), + ("/site-packages/hammett/main.py", "run"), + (user_file, "ignored_after_break"), + ] + ) + monkeypatch.setattr("inspect.currentframe", lambda: chain) + patch_config("max_stack_depth", 1) + record_trampoline_hit("foo.bar") + # Walk breaks at hammett frame before ever decrementing; budget intact. + assert "foo.bar" in mutmut._stats